diff --git a/apps/sim/app/api/copilot/tools/execute/route.test.ts b/apps/sim/app/api/copilot/tools/execute/route.test.ts index 716fbeb9ffd..79efd345ce4 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.test.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.test.ts @@ -125,4 +125,52 @@ describe('POST /api/copilot/tools/execute (in-band)', () => { ) expect(mockPrepareEnvironmentContext).toHaveBeenCalledTimes(1) }) + + /** + * The generated key is a browser-only artifact. This lane's response is what Go feeds the + * model, so it must carry the same projection the resume lane produces — the status message + * alone. A `key` field here would put the plaintext credential in model context. + */ + it('returns only the status message for generate_api_key, never the key', async () => { + const message = 'API key "demo" created. You did NOT receive the key value' + mockHandler.mockResolvedValue({ + success: true, + output: { id: 'key-1', name: 'demo', key: 'sk_live_plaintext', workspaceId: 'ws-1', message }, + }) + + const res = await POST( + makeRequest({ + ...BASE_BODY, + toolName: 'generate_api_key', + params: { name: 'demo' }, + messageId: 'msg-api-key', + }) as never + ) + const body = await res.json() + + expect(body).toEqual({ success: true, output: message }) + expect(JSON.stringify(body)).not.toContain('sk_live_plaintext') + }) + + it('leaves a non-generate_api_key result carrying a key field untouched', async () => { + mockHandler.mockResolvedValue({ success: true, output: { key: 'lookup-key', value: 42 } }) + const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-other-tool-key' }) as never) + await expect(res.json()).resolves.toEqual({ + success: true, + output: { key: 'lookup-key', value: 42 }, + }) + }) + + it('passes a failed generate_api_key call through with its error', async () => { + mockHandler.mockResolvedValue({ success: false, error: 'name is required' }) + const res = await POST( + makeRequest({ + ...BASE_BODY, + toolName: 'generate_api_key', + params: {}, + messageId: 'msg-api-key-error', + }) as never + ) + await expect(res.json()).resolves.toEqual({ success: false, error: 'name is required' }) + }) }) diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index b6a0c57c2ec..284a2e5286c 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot' import { validationErrorResponse } from '@/lib/api/server' +import { toolResultForModel } from '@/lib/copilot/chat/sim-key-redaction' import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -243,9 +244,17 @@ export const POST = withRouteHandler((request: NextRequest) => }) }) } + /** + * The response IS the model-facing channel on this lane — Go relays it straight into + * the turn — so it carries the same projection the resume lane's + * `getToolCallTerminalData` produces, not the raw handler output. Without this, + * `generate_api_key`'s freshly minted plaintext key crossed to the model here while + * the redaction held on the other lane. Every other tool is returned unchanged. + */ + const modelOutput = toolResultForModel(toolName, projected.output) return NextResponse.json({ success: projected.success, - ...(projected.output !== undefined ? { output: projected.output } : {}), + ...(modelOutput !== undefined ? { output: modelOutput } : {}), ...(projected.error ? { error: projected.error } : {}), }) } catch (err) {