From 20d26117c94e609d5f03655f16cbc5b04aec763f Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:41:40 -0500 Subject: [PATCH] fix(chat): use the claimed Cloud origin for connector URLs (#13680) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat connectors publish callback URLs and links to the board. > - Cloud can assign a warm instance its final origin after the server starts. > - The signed runtime identity already tracks that change. > - The chat service kept a copy of the startup origin and continued to publish it. > - This pull request resolves the trusted origin when it creates each URL. > - New connector setup uses the claimed hostname without a server restart. ## Linked Issues or Issue Description **What happened?** Chat setup in a claimed warm instance used its old pool hostname in provider callbacks. Account confirmation and task links could also use the old hostname. **Expected behavior** Chat URLs follow the signed canonical origin after the claim. An explicit webhook ingress override still applies only to provider callbacks. Self-hosted URL precedence stays the same. **Steps to reproduce** 1. Construct the chat service with a pool origin. 2. Apply the Cloud claim without restarting the service. 3. Open Slack setup or create an account-linking intent. 4. Observe the startup hostname in the returned URL. **Paperclip version or commit** Reproduced on master at `9335b7db1`. **Deployment mode** Paperclip Cloud warm-instance claim. Related: #12766 introduced the signed canonical runtime identity. ## What Changed - Resolve the signed Cloud origin when building chat setup, account confirmation, and task URLs. - Use the same callback origin for Telegram registration and GitHub webhook recovery. - Preserve explicit webhook ingress and self-hosted configuration precedence. - Add regression coverage for existing and new endpoints across Slack, GitHub, Teams, and Telegram. - Document the origin precedence and the need to update callbacks already saved at a provider. ## Verification - Reproduced both new regression cases against the original code. - Full chat integration and signed Cloud identity suites: 1,015 tests passed after the production-code correction. - Five origin and ingress cases passed after review additions, including Telegram registration and GitHub webhook repair after a live claim. - Focused origin, ingress, callback, task-link safety, and tenant-isolation checks: 67 passed. - `pnpm -r typecheck` and `pnpm build` passed. Server typecheck and compilation passed again after the task-link validation correction. - A broad local `pnpm test:run` started before the correction was stopped after the final-commit CI suite passed. It is not counted as a passing local run. - Final-commit CI: 54 successful checks; two optional Storybook checks skipped. Greptile: 5/5 with all review threads resolved. - No live deployment or Slack app mutation was performed. ## Risks - Cloud chat URLs now follow the signed runtime identity. Request host headers cannot set this value. - An explicit webhook ingress override still takes precedence for callbacks. - Existing Slack app settings are external state. Operators must replace an old callback URL in Slack. - This change does not deploy the app or change gateway ingress policy. No schema migration is required. ## Model Used OpenAI Codex (GPT-6), with repository search, code execution, and automated tests. The exact model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- docs/deploy/environment-variables.md | 7 ++ .../chat-channels.integration.test.ts | 113 ++++++++++++++++++ server/src/services/chat-channels.ts | 59 +++++---- 3 files changed, 153 insertions(+), 26 deletions(-) diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index 4c6517d2c0..b8ccadbdb1 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -47,6 +47,13 @@ only `POST /api/chat-webhooks/*` from that host. Provider signatures still gate ingress; this variable does not expose routes or grant provider access. Never forward the private `local_trusted` board through a public tunnel. +In Paperclip Cloud, chat callback URLs and account-linking URLs follow the +instance's signed canonical origin after a warm instance is claimed, without +requiring a restart. An explicit `PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL` still takes +precedence for provider callbacks only; board links follow the claimed origin. +Existing provider-side callback settings must be updated if they were created +with an old URL. + Task links in external messages require an externally safe HTTPS board URL. Local/private board URLs are omitted with instructions to open the task in Paperclip; the public webhook host is never substituted for the board. Identity diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 646950ed42..4c82b36938 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import * as cloudRuntimeIdentity from "../services/cloud-runtime-identity.js"; import { createHash, createHmac, @@ -1765,6 +1766,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { | "githubWebhookReplayBarrier" | "githubWebhookResponseBudgetMs" | "publicBaseUrl" + | "webhookPublicBaseUrl" | "scheduleDeferredWork" | "setupSecretActivityLogger" | "setupSecretCredentialPersistBarrier" @@ -12755,6 +12757,117 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await service.shutdown(); }); + it.each([undefined, "https://ingress.example"])( + "uses a live Cloud claim after chat service startup (ingress: %s)", + async (webhookPublicBaseUrl) => { + const poolOrigin = "https://pool-fixture.staging.paperclip.app"; + const claimedOrigin = "https://claimed-fixture.staging.paperclip.app"; + const canonicalOrigin = vi.spyOn(cloudRuntimeIdentity, "runtimeCanonicalOrigin").mockReturnValue(null); + let context: ReturnType | undefined; + try { + const fixture = await seedCompany(); + context = createService(new FakeChatSdkRuntime(), fakeSlackFetch(), { + publicBaseUrl: poolOrigin, + webhookPublicBaseUrl, + }); + const { service } = context; + const endpoints = []; + for (const provider of ["slack", "github", "microsoft-teams", "telegram"] as const) { + endpoints.push(await service.create(fixture.companyId, { + provider, + assignedAgentId: fixture.assignedAgentId, + }, "owner-user")); + } + expect(endpoints[0].setup.webhookUrl).toBe( + `${webhookPublicBaseUrl ?? poolOrigin}/api/chat-webhooks/${endpoints[0].publicId}/slack`, + ); + + // The warm process is already serving; applying a signed claim changes + // the trusted identity provider without constructing another service. + canonicalOrigin.mockReturnValue(claimedOrigin); + for (const endpoint of endpoints) { + const updated = await service.get(endpoint.id); + const callback = `${webhookPublicBaseUrl ?? claimedOrigin}/api/chat-webhooks/${endpoint.publicId}/${endpoint.provider}`; + expect(updated.setup).toMatchObject(endpoint.provider === "microsoft-teams" + ? { messagingEndpoint: callback } + : { webhookUrl: callback }); + const [principal] = await db.insert(chatExternalPrincipals).values({ + companyId: fixture.companyId, + provider: endpoint.provider, + providerAccountId: "", + externalId: randomUUID(), + kind: "user", + isBot: false, + }).returning(); + const intent = await service.createLinkIntent(endpoint.id, principal.id, 1800); + expect(intent.confirmationUrl).toMatch( + /^https:\/\/claimed-fixture\.staging\.paperclip\.app\/chat-identity\/confirm\?token=/, + ); + } + const fresh = await service.create(fixture.companyId, { + provider: "slack", + assignedAgentId: fixture.assignedAgentId, + }, "owner-user"); + expect(fresh.setup.webhookUrl).toBe( + `${webhookPublicBaseUrl ?? claimedOrigin}/api/chat-webhooks/${fresh.publicId}/slack`, + ); + } finally { + try { + await context?.service.shutdown(); + } finally { + canonicalOrigin.mockRestore(); + } + } + }, + ); + + it.each([undefined, "https://ingress.example"])( + "registers provider callbacks after a live Cloud claim (ingress: %s)", + async (webhookPublicBaseUrl) => { + const poolOrigin = "https://pool-fixture.staging.paperclip.app"; + const claimedOrigin = "https://claimed-fixture.staging.paperclip.app"; + const canonicalOrigin = vi.spyOn(cloudRuntimeIdentity, "runtimeCanonicalOrigin").mockReturnValue(null); + let telegram: ReturnType | undefined; + let github: Awaited> | undefined; + try { + const fixture = await seedCompany(); + const telegramRequests: Array> = []; + const telegramFetch = fakeTelegramFetch(); + telegram = createService(new FakeChatSdkRuntime(), (async (input, init) => { + if (String(input).endsWith("/setWebhook")) { + telegramRequests.push(JSON.parse(String(init?.body))); + } + return telegramFetch(input); + }) as typeof globalThis.fetch, { publicBaseUrl: poolOrigin, webhookPublicBaseUrl }); + const endpoint = await telegram.service.create(fixture.companyId, { + provider: "telegram", + assignedAgentId: fixture.assignedAgentId, + }, "owner-user"); + github = await configuredGitHubEndpoint(fixture, { publicBaseUrl: poolOrigin, webhookPublicBaseUrl }); + expect(github.webhookSyncRequests).toHaveLength(0); + + canonicalOrigin.mockReturnValue(claimedOrigin); + await telegram.service.configure(endpoint.id, { + action: "configure", + credentials: { botToken: "123456:telegram-cloud-claim-test" }, + }, "owner-user"); + expect(telegramRequests).toEqual([expect.objectContaining({ + url: `${webhookPublicBaseUrl ?? claimedOrigin}/api/chat-webhooks/${endpoint.publicId}/telegram`, + })]); + await github.service.configure(github.endpoint.id, { action: "reconnect" }, "owner-user"); + expect(github.webhookSyncRequests).toEqual([expect.objectContaining({ + url: `${webhookPublicBaseUrl ?? claimedOrigin}/api/chat-webhooks/${github.endpoint.publicId}/github`, + })]); + } finally { + try { + await Promise.all([telegram?.service.shutdown(), github?.service.shutdown()]); + } finally { + canonicalOrigin.mockRestore(); + } + } + }, + ); + it("separates verified webhook ingress from board identity links for every webhook provider", async () => { const fixture = await seedCompany(); for (const publicBaseUrl of [ diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 6edefa604a..9c3f23f2b1 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,3 +1,4 @@ +import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js"; import { takePhotonCompanion } from "./photon/attachments.js"; import { writePhotonCheckpoint } from "./photon/receiver.js"; import { PhotonState } from "./photon/state.js"; @@ -2979,10 +2980,16 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { >(); const persistence = createChatSdkStatePersistence(db); const fetchImpl = options.fetch ?? globalThis.fetch; - const publicBaseUrl = absoluteBaseUrl(options.publicBaseUrl); - const webhookPublicBaseUrl = - parseChatWebhookPublicBaseUrl(options.webhookPublicBaseUrl) ?? - publicBaseUrl; + const configuredPublicBaseUrl = absoluteBaseUrl(options.publicBaseUrl); + const configuredWebhookPublicBaseUrl = parseChatWebhookPublicBaseUrl(options.webhookPublicBaseUrl); + // A warm Cloud instance is constructed before it receives its signed claim. + // Resolve its live identity when producing URLs, not once at service startup. + // An explicit webhook ingress remains separate from board/identity links. + const getPublicBaseUrl = () => runtimeCanonicalOrigin() ?? configuredPublicBaseUrl; + // Task links must validate the original configured URL before normalization + // can remove credentials or other evidence that makes it unsafe to publish. + const getTaskBaseUrl = () => runtimeCanonicalOrigin() ?? options.publicBaseUrl; + const getWebhookPublicBaseUrl = () => configuredWebhookPublicBaseUrl ?? getPublicBaseUrl(); const issuesSvc = issueService(db); const secrets = secretService(db); const questionResponses = questionResponseDeliveryService(db, { @@ -5829,7 +5836,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { setup: { ...providerSetupState( endpoint, - webhookPublicBaseUrl, + getWebhookPublicBaseUrl(), row.assignedAgentName, ), ...(endpoint.provider === "github" @@ -9460,7 +9467,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ) { throw unprocessable("Unsupported chat endpoint setup action"); } - if (!webhookPublicBaseUrl && endpoint.provider !== "discord" && endpoint.provider !== "imessage-photon") { + if (!getWebhookPublicBaseUrl() && endpoint.provider !== "discord" && endpoint.provider !== "imessage-photon") { throw unprocessable( `A public HTTPS Paperclip URL is required before connecting ${PROVIDER_LABELS[endpoint.provider]}`, ); @@ -9468,7 +9475,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { if ( endpoint.provider === "telegram" && (input.action === "configure" || input.action === "reconnect") && - !isSupportedTelegramWebhookBaseUrl(webhookPublicBaseUrl) + !isSupportedTelegramWebhookBaseUrl(getWebhookPublicBaseUrl()) ) { throw unprocessable( "Telegram webhooks require PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL to use HTTPS on port 443, 80, 88, or 8443", @@ -9774,7 +9781,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { await resyncGitHubAppWebhook({ fetch: fetchImpl, appToken: githubAppJwt(credentials.appId, credentials.privateKey), - webhookUrl: `${webhookPublicBaseUrl}/api/chat-webhooks/${endpoint.publicId}/github`, + webhookUrl: `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/github`, webhookSecret: credentials.webhookSecret, }); await auditWebhookSync("chat_endpoint.webhook_synced"); @@ -9786,8 +9793,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { waitForDiscordOwnership: next.endpoint.provider === "discord", }); - if (endpoint.provider === "telegram" && webhookPublicBaseUrl) { - const webhookUrl = `${webhookPublicBaseUrl}/api/chat-webhooks/${endpoint.publicId}/telegram`; + if (endpoint.provider === "telegram" && getWebhookPublicBaseUrl()) { + const webhookUrl = `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/telegram`; const infoResponse = await fetchImpl( `https://api.telegram.org/bot${encodeURIComponent(credentials.botToken)}/getWebhookInfo`, { signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS) }, @@ -23290,8 +23297,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { await tx.update(chatActions).set({ payload: { version: 1, channelId: event.event.channel.id, userId: event.event.user.userId, identityLinkHash: tokenHash, } }).where(eq(chatActions.id, inserted[0].id)); - const url = `${publicBaseUrl}/chat-identity/confirm?token=${encodeURIComponent(token)}`; - notice = publicBaseUrl + const url = `${getPublicBaseUrl()}/chat-identity/confirm?token=${encodeURIComponent(token)}`; + notice = getPublicBaseUrl() ? `[Connect your Paperclip account](${url}) — sign in and confirm this Slack identity. This private link expires in 15 minutes and works once. You can also confirm in the setup wizard. No agent work has started.` : "Return to the Paperclip setup wizard to confirm your Slack account. No agent work has started."; } @@ -24737,7 +24744,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { fence.generation !== context.generation || fence.credentialFingerprint !== context.credentialFingerprint || fence.webhookUrl !== - `${webhookPublicBaseUrl}/api/chat-webhooks/${currentEndpoint.publicId}/github` || + `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${currentEndpoint.publicId}/github` || !original?.payload?.comment || original.event !== eventType || incoming?.action !== "created" || @@ -25409,7 +25416,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { limit = 5, onlyEndpointId?: string, ) { - if (shuttingDown || !webhookPublicBaseUrl?.startsWith("https://")) return 0; + if (shuttingDown || !getWebhookPublicBaseUrl()?.startsWith("https://")) return 0; const now = new Date(); const rows = await db .select({ endpoint: chatEndpoints }) @@ -25442,7 +25449,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { // the superseded epoch was rate limited for several hours. sql`${chatSdkState.value}->>'generation' is distinct from coalesce(${chatEndpoints.setup}->>'runtimeGeneration', '0')`, sql`${chatSdkState.value}->>'appId' is distinct from ${chatEndpoints.botExternalId}`, - sql`${chatSdkState.value}->>'webhookUrl' is distinct from (${webhookPublicBaseUrl} || '/api/chat-webhooks/' || ${chatEndpoints.publicId} || '/github')`, + sql`${chatSdkState.value}->>'webhookUrl' is distinct from (${getWebhookPublicBaseUrl()} || '/api/chat-webhooks/' || ${chatEndpoints.publicId} || '/github')`, sql`(${chatSdkState.value}->>'nextScanAt')::timestamptz <= ${now.toISOString()}::timestamptz`, ), ), @@ -25453,7 +25460,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const record = await endpointRecord(endpoint.id); if (!record || !record.endpoint.botExternalId) continue; const context = runtimeContextForRecord(record); - const webhookUrl = `${webhookPublicBaseUrl}/api/chat-webhooks/${endpoint.publicId}/github`; + const webhookUrl = `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/github`; const scope = { companyId: endpoint.companyId, endpointId: endpoint.id }; const stored = await persistence.read(scope, GITHUB_RECOVERY_STATE_KEY); const previous = githubRecoveryWindow(stored?.value); @@ -26388,7 +26395,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { errorCode: "slack_session_stopped", issueId: issue.id, milestone: "failed", - publicBaseUrl, + publicBaseUrl: getPublicBaseUrl(), }), }), principalId: principal.id, @@ -26506,7 +26513,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }, }, ); - await enqueueChatRunMilestones(db, { publicBaseUrl }); + await enqueueChatRunMilestones(db, { publicBaseUrl: getPublicBaseUrl() }); const authoritativeRun = await db .select({ status: heartbeatRuns.status }) .from(heartbeatRuns) @@ -27944,7 +27951,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); const path = `/chat-identity/confirm?token=${encodeURIComponent(token)}`; return { - confirmationUrl: publicBaseUrl ? `${publicBaseUrl}${path}` : path, + confirmationUrl: getPublicBaseUrl() ? `${getPublicBaseUrl()}${path}` : path, expiresAt: expiresAt.toISOString(), }; } @@ -32213,12 +32220,12 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { if ( endpoint.provider !== "telegram" || !endpoint.botExternalId || - !webhookPublicBaseUrl + !getWebhookPublicBaseUrl() ) return null; const webhookUrlSha256 = createHash("sha256") .update( - `${webhookPublicBaseUrl}/api/chat-webhooks/${endpoint.publicId}/telegram`, + `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/telegram`, ) .digest("hex"); return { @@ -32468,7 +32475,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { String(identity.id) !== scope.botUserId ) throw reject(); - const url = `${webhookPublicBaseUrl}/api/chat-webhooks/${endpoint.publicId}/telegram`; + const url = `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/telegram`; const plan = telegramStopSubscriptionPlan( await request("getWebhookInfo"), url, @@ -32947,7 +32954,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } } else if (files.length === 0) { const taskUrl = safeChatTaskUrl( - options.publicBaseUrl, + getTaskBaseUrl(), input.publication.issueId, ); if ( @@ -33105,7 +33112,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { interaction, questionIndex: nextQuestion ? Number(nextQuestion[2]) : 0, taskUrl: safeChatTaskUrl( - options.publicBaseUrl, + getTaskBaseUrl(), input.publication.issueId, ), assertCurrent: promptGuard, @@ -33393,7 +33400,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ? typeof prepared.payload.taskUrl === "string" ? safeChatTaskUrl(prepared.payload.taskUrl, publication.issueId) : null - : safeChatTaskUrl(options.publicBaseUrl, publication.issueId); + : safeChatTaskUrl(getTaskBaseUrl(), publication.issueId); if ( prepared && (prepared.kind !== "github_omission_navigation" || @@ -35245,7 +35252,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { noticeEndpoint.provider === attachmentFailure.provider); if (providerCanSendTextNotice && noticeConversation) { const taskUrl = safeChatTaskUrl( - options.publicBaseUrl, + getTaskBaseUrl(), publication.issueId, ); const noticeText =