diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 51ffd178cc9..d7dc4677df7 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -18,6 +18,7 @@ import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from import { configureAgentLifecycle } from "../services/agent-lifecycle.js"; import { agentService } from "../services/agents.js"; import { accessService } from "../services/access.js"; +import { secretService } from "../services/secrets.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; @@ -652,9 +653,33 @@ describe("connection management with production default agents", () => { }; try { const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "slack", assignedAgentId: f.agentId, slackSetupMethod: "manual" }); - const configured = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "configure", credentials: { botToken: "xoxb-fixture-token", signingSecret: "fixture-signing-secret" } }); + const setupPath = `/chat-endpoints/${created.body.id}/setup`; + const cookie = await server.humanSession(f.userId); + const configuredResponse = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, + body: JSON.stringify({ action: "configure", credentials: { botToken: "xoxb-fixture-token", signingSecret: "fixture-signing-secret" } }) }); + const configured = { status: configuredResponse.status, body: await configuredResponse.json() }; expect(configured, JSON.stringify(configured)).toMatchObject({ status: 200 }); expect(JSON.stringify(configured.body)).not.toContain("xoxb-fixture-token"); + const [before] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + const botTokenRef = before.credentialSecretRefs.find(ref => ref.configPath === "credentials.botToken")!; + const rebound = before.credentialSecretRefs.map(ref => ref.configPath === "credentials.signingSecret" ? { ...ref, secretId: botTokenRef.secretId } : ref); + expect(await f.request("PATCH", `/tool-connections/${created.body.connectionId}`, { credentialSecretRefs: rebound }, f.jwt)) + .toMatchObject({ status: 403, body: { code: "chat_endpoint_credentials_require_setup" } }); + for (const action of ["reconnect", "configure"] as const) for (const token of [f.key.token, f.jwt]) { + const chosen = "agent-chosen-webhook-key"; + const handoff = await f.request("POST", setupPath, { action, credentials: { signingSecret: chosen } }, token); + expect(handoff, JSON.stringify(handoff)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id, + requiredUserAction: { kind: "enter_credentials", userId: f.userId, resource: { id: created.body.id } } } }); + expect(JSON.stringify(handoff.body)).not.toContain(chosen); + const [after] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + expect(after.credentialSecretRefs).toEqual(before.credentialSecretRefs); + const signing = after.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!; + expect(await secretService(server.db).resolveSecretValue(f.companyId, signing.secretId, "latest", { + accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" }, + })).toBe("fixture-signing-secret"); + } + // Ordinary write-only bot-token repair remains delegated under current authority. + expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 200, body: { id: created.body.id } }); // Identity is a provider fixture; production HTTP admission and policy remain real. const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", providerAccountId: "TFIXTURE", externalId: "UHUMAN", isBot: false }).returning(); await server.db.insert(chatIdentityLinks).values({ companyId: f.companyId, endpointId: created.body.id, principalId: principal.id, paperclipUserId: f.userId, status: "linked", confirmedAt: new Date() }); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 0cda5bd1579..48da426bab0 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -754,6 +754,9 @@ describe("openapi routes", () => { ).toEqual(["current", "stale", "unverified"]); const setup = spec.paths["/api/chat-endpoints/{endpointId}/setup"].post; + const setupResponse = setup.responses["200"].content["application/json"].schema; + expect((setupResponse.oneOf ?? setupResponse.anyOf)[1].properties.requiredUserAction.properties.kind.enum).toEqual(["enter_credentials"]); + expect(setup.description).toContain("without changing credentials"); expect( setup.requestBody.content["application/json"].schema.properties.action .enum, diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 4e7f19f6ee0..1dfea15656d 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -433,9 +433,15 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { validate(configureChatEndpointSchema), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; - res.json( - await service.configure(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req)), - ); + res.set("Cache-Control", "no-store"); + try { + res.json(await service.configure(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req))); + } catch (error) { + if (req.actor.type !== "agent" || !(error instanceof HttpError) || error.status !== 403 + || !error.details || typeof error.details !== "object" || !("code" in error.details) + || error.details.code !== "chat_endpoint_credential_entry_requires_human") throw error; + res.json(await setupHandoff(req, "The responsible user must enter provider verification credentials in the saved setup.", "enter_credentials")); + } }, ); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index dab6135db41..d4903dcf40d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -2497,13 +2497,13 @@ registry.registerPath({ tags: ["chat-channels"], summary: "Configure or change chat endpoint lifecycle state", description: - "Runs a setup or lifecycle action. `configure` and `reconnect` accept provider credentials (Slack: `botToken`, `signingSecret`; GitHub: `appId`, `privateKey` after Paperclip generates the webhook secret; Discord: `applicationId`, `guildId`, `botToken`; Microsoft Teams: `clientId`, `tenantId`, `clientSecret`; Telegram: `botToken`; iMessage Photon: `projectSecret`, with nonsecret `photon.projectId` and `photon.lineId` configuration). Credentials are stored as Paperclip secret references and are never returned. Other actions do not require credentials.", + "Runs a setup or lifecycle action. `configure` and `reconnect` accept provider credentials (Slack: `botToken`, `signingSecret`; GitHub: `appId`, `privateKey` after Paperclip generates the webhook secret; Discord: `applicationId`, `guildId`, `botToken`; Microsoft Teams: `clientId`, `tenantId`, `clientSecret`; Telegram: `botToken`; iMessage Photon: `projectSecret`, with nonsecret `photon.projectId` and `photon.lineId` configuration). Agent-supplied signing secrets, webhook secrets, private keys, client secrets, and project secrets return a saved human credential-entry handoff without changing credentials. Authorized write-only bot tokens and API keys remain supported. Credentials are stored as Paperclip secret references and are never returned. Other actions do not require credentials.", request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(configureChatEndpointSchema), }, responses: { - 200: r.ok(chatEndpointResponseSchema), + 200: r.ok(z.union([chatEndpointResponseSchema, chatEndpointCredentialHandoffSchema])), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, diff --git a/server/src/services/chat-channels/configuration.ts b/server/src/services/chat-channels/configuration.ts index eda63d37f31..5fc9cb5565a 100644 --- a/server/src/services/chat-channels/configuration.ts +++ b/server/src/services/chat-channels/configuration.ts @@ -62,6 +62,7 @@ import { import { badRequest, conflict, + forbidden, notFound, unprocessable, } from "../../errors.js"; @@ -144,6 +145,15 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption }, ); } + // These keys establish provider authentication or inbound trust. They + // must come from the human setup step or a verified provider handoff. + if (managementContext?.actor.type === "agent" && suppliedCredentialKeys.some( + key => ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"].includes(key), + )) { + throw forbidden("The responsible user must enter provider verification credentials in the saved setup", { + code: "chat_endpoint_credential_entry_requires_human", + }); + } } return withCredentialMutationLease(record.endpoint, (credentialLease) => configureWithCredentialLease( diff --git a/server/src/services/native-runtime/runner-api-sensitive-response.ts b/server/src/services/native-runtime/runner-api-sensitive-response.ts index c135df2c852..d4eb121076f 100644 --- a/server/src/services/native-runtime/runner-api-sensitive-response.ts +++ b/server/src/services/native-runtime/runner-api-sensitive-response.ts @@ -1,4 +1,5 @@ export function runnerApiSensitiveResponse(method: string, path: string): boolean { + if (method === "POST" && /^\/api\/chat-endpoints\/\{[^}]+\}\/setup$/.test(path)) return true; if (/(\/apps\/connect$|\/email\/connections$|setup-secret|\/github\/(setup|identity\/start)|\/slack\/resume|start-authorization|finalize-oauth-access|\/oauth\/|\/registration|\/managed\/authorize|\/install$|\/login|\/setup-token-login-sessions|\/local\/attempts)/.test(path)) return true; if (/\/secrets\/\{[^}]+\}\/value$/.test(path)) return true; return /^\/api\/tool-gateway\/sessions(?:\/|$)/.test(path) diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts index b32db58ef49..634ee737146 100644 --- a/server/src/services/native-runtime/runner-api.test.ts +++ b/server/src/services/native-runtime/runner-api.test.ts @@ -60,6 +60,7 @@ describe("bounded response capture receipts", () => { describe("connection setup response protection", () => { it.each([ + ["POST", "/api/chat-endpoints/{endpointId}/setup"], ["POST", "/api/chat-endpoints/{endpointId}/setup-secret"], ["POST", "/api/chat-endpoints/{endpointId}/github/setup"], ["POST", "/api/chat-endpoints/{endpointId}/slack/resume"], diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index cead6192f79..6cb2220af72 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -4592,6 +4592,12 @@ export function toolAccessService( managementContext?: ConnectionManagementContext, ): Promise => { const existing = await getConnectionRow(connectionId); + if (managementContext?.actor.type === "agent" && existing.connectionPurpose === "channel" + && (input.credentialRefs !== undefined || input.credentialSecretRefs !== undefined)) { + throw forbidden("Use chat endpoint setup to change channel credentials; verification keys require human entry", { + code: "chat_endpoint_credentials_require_setup", + }); + } if (existing.connectionPurpose === "ai" && (input.config || input.transportConfig || input.credentialRefs || input.credentialSecretRefs || (input.credentialPolicy && input.credentialPolicy !== existing.credentialPolicy))) throw badRequest("Use AI account reconnect to change credentials. Provider, sign-in method, and ownership cannot be changed."); const config = normalizeGoogleSheetsConnectionConfig( input.config ?? input.transportConfig ?? existing.config,