diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 7b1df6492a..8acb77930f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2137,14 +2137,20 @@ export class AIChatManager implements ChatViewHost { /** The reader ran the item on its own page; the tool follows that job rather than * starting one. False when the call is no longer waiting. */ - handleRunFormJob = (toolId: string, jobId: string, args: Record): boolean => { + handleRunFormJob = ( + toolId: string, + jobId: string, + args: Record, + schema: Record + ): boolean => { if (!this.isRunFormPending(toolId)) return false // Snapshotted for the same reason the draft is: these are `$state` proxies off the // page's own form, which keeps editing them after the run. this.#settleRunForm(toolId, { kind: 'job', jobId, - args: ($state.snapshot(args) ?? {}) as Record + args: ($state.snapshot(args) ?? {}) as Record, + schema: ($state.snapshot(schema) ?? {}) as Record }) return true } diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index aabd633072..481b6323ec 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -5461,7 +5461,8 @@ describe('global AI tools', () => { requestRunArgs: async () => ({ kind: 'job', jobId: 'job-123', - args: { name: 'Grace' } + args: { name: 'Grace' }, + schema: { properties: { name: { type: 'string' } } } }) } ) @@ -5474,6 +5475,46 @@ describe('global AI tools', () => { expect(result).not.toContain('Ada') }) + // Redaction only visits the leaves the schema it is given declares, and the page runs the + // deployed version as it is now — which a deploy can have given an argument this call's + // schema never had. Redacting with ours would put the bytes in the persisted card and in + // the model's context. + it('run_script redacts an adopted job against the schema that page ran', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({ + path: 'f/scripts/redeployed', + schema: { properties: { name: { type: 'string' } } } + } as any) + + const statuses: any[] = [] + const result = await withCompletedTestJob(() => + callGlobalTool( + 'run_script', + { path: 'f/scripts/redeployed', args: { name: 'Ada' } }, + { + ...toolCallbacks, + setToolStatus: (_id: string, s: any) => statuses.push(s), + requestRunArgs: async () => ({ + kind: 'job', + jobId: 'job-123', + args: { name: 'Grace', attachment: 'QUJDREVG' }, + // The deployed version the page loaded declares a file this call never saw. + schema: { + properties: { + name: { type: 'string' }, + attachment: { type: 'string', contentEncoding: 'base64' } + } + } + }) + } + ) + ) + + const parameters = statuses.filter((s) => s.parameters).at(-1)?.parameters + expect(parameters?.name).toBe('Grace') + expect(parameters?.attachment).not.toBe('QUJDREVG') + expect(result).not.toContain('QUJDREVG') + }) + // The posture answers wherever it is set, form or no form: what it answers is consent, and a // host without one has nothing left to ask. A secret still becomes a reference first, which // is the only thing the missing form would have done. diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index a2a98c169c..7dceaa964d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -6397,7 +6397,12 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise MAX_SUBMITTED_ARGS_LENGTH diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 92fe646ce4..12316bd0e7 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -615,8 +615,10 @@ export type RunFormDisplay = { export type RunFormAnswer = | { kind: 'args'; args: Record } /** A job another surface already started. Its `args` are what that surface sent, already - * coerced and minted by its own form, so they are reported rather than processed. */ - | { kind: 'job'; jobId: string; args: Record } + * coerced and minted by its own form, so they are reported rather than processed, and + * `schema` is the one it ran against — which redaction needs, since it only visits the + * leaves a schema declares. */ + | { kind: 'job'; jobId: string; args: Record; schema: Record } export type RunFormDraft = { args: Record diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index b9ca597f6d..a851f40707 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -315,7 +315,8 @@ // The call asked for this run; it follows the job rather than starting one, and // shows it in place of this page — so navigating here as well would leave two // surfaces open on the same job. - if (!call?.adopt(run, args)) await onNavigate('/run/' + run + '?workspace=' + workspace) + if (!call?.adopt(run, args, flow?.schema ?? {})) + await onNavigate('/run/' + run + '?workspace=' + workspace) } catch (e) { throw e } finally { diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 6d463434eb..71816b798d 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -417,7 +417,8 @@ // The call asked for this run; it follows the job rather than starting one, and // shows it in place of this page — so navigating here as well would leave two // surfaces open on the same job. - if (!call?.adopt(run, args)) await onNavigate('/run/' + run + '?workspace=' + workspace) + if (!call?.adopt(run, args, script?.schema ?? {})) + await onNavigate('/run/' + run + '?workspace=' + workspace) } catch (err) { sendUserToast(`Could not create job: ${err.body}`, true) } finally { diff --git a/frontend/src/lib/components/details/pendingRun.ts b/frontend/src/lib/components/details/pendingRun.ts index 77d3d65807..00e75622fc 100644 --- a/frontend/src/lib/components/details/pendingRun.ts +++ b/frontend/src/lib/components/details/pendingRun.ts @@ -15,10 +15,13 @@ export type PendingRun = { * is the live draft of the card's own form rather than a copy to keep in step. */ readonly proposedArgs: Record /** The job the reader started here answers the call, with the arguments it was started - * with — the card has no other way to show what ran. True when the call took it: the chat + * with — the card has no other way to show what ran — and the schema it ran against, which + * a deploy can have moved on from the one the call fetched. Both are needed: redaction + * only visits the leaves a schema declares, so a file argument this page has and that one + * does not would reach the transcript as bytes. True when the call took the job: the chat * then shows that run in place of this page, so the page must not navigate to it as well. * False when the call settled while the job was starting, and the run is the reader's * alone. */ - adopt: (jobId: string, args: Record) => boolean + adopt: (jobId: string, args: Record, schema: Record) => boolean decline: () => void } diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 2fcc63af5e..a1f5880ef2 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -612,7 +612,7 @@ function createRuntime(session: Session): SessionRuntime { get proposedArgs() { return manager.pendingRunFormArgs(toolCallId) ?? {} }, - adopt: (jobId, args) => manager.handleRunFormJob(toolCallId, jobId, args), + adopt: (jobId, args, schema) => manager.handleRunFormJob(toolCallId, jobId, args, schema), decline: () => manager.handleRunFormCancel(toolCallId) } }