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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-19 09:41:40 -05:00
1 parent c1f6c3310a
commit 20d26117c9
3 files changed
+153 -26

No files matched your search

+7
View File
@@ -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
@@ -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<typeof createService> | 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<typeof createService> | undefined;
let github: Awaited<ReturnType<typeof configuredGitHubEndpoint>> | undefined;
try {
const fixture = await seedCompany();
const telegramRequests: Array<Record<string, unknown>> = [];
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 [
+33 -26
View File
@@ -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 =