diff --git a/package.json b/package.json index 9924919..d2fadac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config.ts b/src/config.ts index 14781ec..c6668db 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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: @@ -121,6 +123,7 @@ export interface ResolvedGatewayConfig { allowAllUsers: boolean; allowedInboundContactIds: string[]; requireSignature: boolean; + skipWebhookReconcile: boolean; externalEvents: boolean; outboundApproval: "allowlist" | "auto"; permissionTimeoutS: number; @@ -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: diff --git a/src/gateway/subscriptions.ts b/src/gateway/subscriptions.ts index 735c105..49940aa 100644 --- a/src/gateway/subscriptions.ts +++ b/src/gateway/subscriptions.ts @@ -108,6 +108,14 @@ export async function reconcileSubscriptions( ): Promise { 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(); diff --git a/tests/gateway/subscriptions.test.ts b/tests/gateway/subscriptions.test.ts index afd03fc..48bfab8 100644 --- a/tests/gateway/subscriptions.test.ts +++ b/tests/gateway/subscriptions.test.ts @@ -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 } } { const client = { webhooks: { subscriptions } }; @@ -94,6 +95,7 @@ function makeDeps( allowAllUsers: false, allowedInboundContactIds: [], requireSignature: true, + skipWebhookReconcile: options.skipWebhookReconcile ?? false, externalEvents: false, outboundApproval: "allowlist", permissionTimeoutS: 600, @@ -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(); + }); +});