Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@inkbox/opencode-plugin",
"version": "0.2.9",
"version": "0.2.10",
"private": true,
"description": "Inkbox for opencode \u2014 give your agent an email address, a phone number (SMS/MMS + voice), iMessage, contacts, notes, and an encrypted credential vault.",
"license": "MIT",
Expand Down
5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export interface GatewayOptions {
allowedInboundContactIds?: string[];
// Verify webhook signatures (default true). Disable only for local dev.
requireSignature?: boolean;
/** Leave webhook subscriptions untouched at boot; they must already point here. */
skipWebhookReconcile?: boolean;
// Deliver verified non-Inkbox webhooks (and unverified ones) to the agent.
externalEvents?: boolean;
// Outbound sends from gateway sessions never prompt interactively:
Expand Down Expand Up @@ -121,6 +123,7 @@ export interface ResolvedGatewayConfig {
allowAllUsers: boolean;
allowedInboundContactIds: string[];
requireSignature: boolean;
skipWebhookReconcile: boolean;
externalEvents: boolean;
outboundApproval: "allowlist" | "auto";
permissionTimeoutS: number;
Expand Down Expand Up @@ -400,6 +403,8 @@ function resolveGatewayConfig(
allowAllUsers: opts.allowAllUsers ?? boolEnv(env.INKBOX_ALLOW_ALL_USERS) ?? false,
allowedInboundContactIds: stringArray(opts.allowedInboundContactIds),
requireSignature: opts.requireSignature ?? boolEnv(env.INKBOX_REQUIRE_SIGNATURE) ?? true,
skipWebhookReconcile:
opts.skipWebhookReconcile ?? boolEnv(env.INKBOX_SKIP_WEBHOOK_RECONCILE) ?? false,
externalEvents: opts.externalEvents ?? boolEnv(env.INKBOX_EXTERNAL_EVENTS_ENABLED) ?? false,
outboundApproval: opts.outboundApproval === "auto" ? "auto" : "allowlist",
permissionTimeoutS:
Expand Down
8 changes: 8 additions & 0 deletions src/gateway/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ export async function reconcileSubscriptions(
): Promise<ReconcileResult> {
const base = normalizePublicUrl(publicUrl);
const webhookUrl = `${base}${WEBHOOK_PATH}`;

// Deployments that provision subscriptions ahead of time have a fixed
// destination, and an API key that may not be allowed to change it.
if (deps.config.gateway.skipWebhookReconcile) {
deps.logger.info("subscriptions.skipped", { expectedUrl: webhookUrl });
return { created: 0, updated: 0, unchanged: 0 };
}

const identity = await deps.inkbox.getIdentity();
const client = await deps.inkbox.getClient();

Expand Down
44 changes: 44 additions & 0 deletions tests/gateway/subscriptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function makeDeps(
options: {
voiceEnabled?: boolean;
phoneVoiceStack?: "inkbox_voice_ai" | "openai_realtime" | "inkbox_tts_stt";
skipWebhookReconcile?: boolean;
} = {},
): GatewayDeps & { logger: { [K in keyof GatewayLogger]: ReturnType<typeof vi.fn> } } {
const client = { webhooks: { subscriptions } };
Expand All @@ -94,6 +95,7 @@ function makeDeps(
allowAllUsers: false,
allowedInboundContactIds: [],
requireSignature: true,
skipWebhookReconcile: options.skipWebhookReconcile ?? false,
externalEvents: false,
outboundApproval: "allowlist",
permissionTimeoutS: 600,
Expand Down Expand Up @@ -448,3 +450,45 @@ describe("reconcileSubscriptions", () => {
).rejects.not.toThrow(secret);
});
});

describe("skipWebhookReconcile", () => {
// Deployments that provision subscriptions ahead of time have a fixed
// destination and a key that may not be allowed to change it, so writing on
// every boot is redundant at best and fatal to startup at worst.
const identity = {
id: "identity-1",
mailbox: { id: "mailbox-1" },
phoneNumber: { id: "phone-1" },
imessageEnabled: true,
};

it("touches no subscriptions when enabled", async () => {
const subscriptions = makeSubscriptions();
const deps = makeDeps(identity, subscriptions, { skipWebhookReconcile: true });

const result = await reconcileSubscriptions(deps, PUBLIC_URL);

expect(result).toEqual({ created: 0, updated: 0, unchanged: 0 });
expect(subscriptions.list).not.toHaveBeenCalled();
expect(subscriptions.create).not.toHaveBeenCalled();
});

it("names the URL it expects deliveries to reach", async () => {
const deps = makeDeps(identity, makeSubscriptions(), { skipWebhookReconcile: true });

await reconcileSubscriptions(deps, PUBLIC_URL);

expect(deps.logger.info).toHaveBeenCalledWith("subscriptions.skipped", {
expectedUrl: WEBHOOK_URL,
});
});

it("still reconciles when left at the default", async () => {
const subscriptions = makeSubscriptions();
const deps = makeDeps(identity, subscriptions);

await reconcileSubscriptions(deps, PUBLIC_URL);

expect(subscriptions.create).toHaveBeenCalled();
});
});
Loading