Skip to content

Commit 3ef51f4

Browse files
fix(copilot): say whether a withheld tool call changed anything (#7178)
* fix(copilot): disclose how far a withheld run got instead of one opaque sentinel A tool result the egress projection cannot vouch for is reduced to a bare success or to `TOOL_RESULT_UNAVAILABLE_ERROR`. Both drop the execution id with the payload, and the sentinel also overwrites the real error text, so a call rejected on its own arguments and a run that already executed come back byte-identical. Those need opposite retry decisions. Reproduced against real code by latching a registry the way production latches one — a child run that returned no provenance envelope — and driving the real handler and the real projection: a pre-dispatch rejection and a post-dispatch failure were identical, and a completed run arrived as `{"success":true}` with nothing to look it up by. Two distinct shapes for three outcomes. The registry is right to fail closed; the boundary was discarding facts it never needed to redact. A tool may now declare a `ToolCallEffect` — a phase and server-minted ids — which the projection preserves when it withholds content, because neither is derived from that content. The exemption is enforced rather than asserted: ids must match the identifier shape this system mints, and one that does not voids the whole disclosure. The phase is attached in the application layer from dispatch onward and nowhere earlier, which is what makes the id's absence the positive statement that nothing was created rather than an admission of not knowing. Withholding also now reports its cause — a latched registry names the guard that tripped, an absent one means no catalog was built, and a content refusal means the registry was fine — so the next occurrence is diagnosable from the logs it already writes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): keep the withheld-result tests out of the secret scanners The withheld-run fixture was shaped like a live provider key, which is exactly what a secret scanner is built to catch — it flagged the test file itself. The value only has to clear the eight-character substitution floor, so it says what it is instead. The id-shape guard likewise no longer needs a credential-looking string to prove it refuses one. Also routes the test's error-message mock through getErrorMessage rather than reimplementing it inline, which check:utils bans. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): name the dispatched run from the boundary that owns it Review found the attempted-run id attached around the whole of executeWorkflow, which validates workspace and billing attribution before it can create anything. A preflight refusal therefore reported a run that never existed, telling a caller to resolve an id with nothing behind it and to skip a retry that was safe — the mirror of the defect this branch fixes. Move the attachment inside executeWorkflow, at the point it enters the execution core, which is the first moment a row may exist. Everything above it now correctly carries nothing, and the copilot layer keeps only the window executeWorkflow cannot see: a failure after the run already returned, where the crossing import threw and an execution certainly exists. Also from review: - Attach to any thrown object rather than only an Error, and normalize a thrown primitive past the dispatch boundary. Restricting to Error made the invariant silently invert for a thrown plain object — the id would not attach, its absence would read as "nothing started", and the caller would duplicate a real run. - Void the disclosure when an id would take one of the record's own field names. A valid uuid under `effect` overwrote the phase the retry decision reads, on the same all-or-nothing terms as an unvouchable id. - Drop `effect` from the provider model response. That path spreads every non-output field through verbatim, so the type's claim that the disclosure reaches the model only through the withheld-result projection was true by accident rather than by construction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): refuse an in-band tool call whose egress catalog is unavailable Production shows 88 of these in fourteen days, every one from this route and every one caused by a workspace id that no longer exists reaching the in-band lane. The handler ran anyway, which is the worst pair of outcomes available: the side effect happened, and because the projection can vouch for nothing without a catalog, the caller got a bare success or an opaque sentinel naming neither the cause nor whether anything had changed. It is also where the reported "cannot tell whether the mutation occurred" came from — of the tools affected, read and grep dominate, and the runs were bursts inside single sessions. Refuse before dispatch instead. Nothing runs, so there is nothing to be uncertain about, and the caller is told which workspace and why. A missing workspace also reported itself as an access denial, which sent every deleted-workspace call down a permissions path nobody could reproduce. `checkWorkspaceAccess` already distinguishes the two, so say which one it was. The refusal log now carries the user and workspace it refused; without them the only way to find the cause was to join by timestamp. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): put the dispatch boundary at the logging session, not the core Review was right that entering the execution core is too early. The core loads custom blocks, workflow state, and the environment before `safeStart` writes a row, so a setup failure — which ran nothing and is safely retryable — still reported a dispatched run and sent the caller looking for it. Move the marker to `loggingStarted`, read before the catch's own recovery `safeStart` writes a row for the failure itself. That is the first point blocks may have executed, so it is the honest line, and it lets executeWorkflow go back to a plain rethrow. The thrown value stays exactly as received, including a non-Error one: the core's finalization guard identifies it, and three existing tests pin that. A thrown primitive therefore carries no id, which costs nothing today because every throw site past `safeStart` raises an Error — noted in the code rather than papered over. Also read the marker with `Object.hasOwn` rather than `in`, so an id reached through a prototype chain can never disclose an unrelated run, and cover the reserved-key branch of the disclosure guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): log a withheld in-band result even when it withheld a success The cause was written only on the failure branch, but a withheld success keeps `projected.success` true — so the one case that leaves no other trace, where the model reads a bare success and nothing says why, was also the only one whose cause was never recorded. Report it on its own, as the resume driver already does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): name the run from the executor, not the logging session Review found the logging session wrong in both directions, and it is: the result of `safeStart` is never checked, so blocks execute even when it fails — reporting that nothing started for a run that did, which is the direction that duplicates work — and it flips before trigger resolution and serialization, reporting a run for failures that never reached a block. A resume whose conditional update matches no row returns true and named a run that does not exist. Entering the executor is the only honest answer to "could a side effect have occurred", because side effects come from blocks rather than from log rows. Moving the marker there settles all three at once. Also from review: - Stop returning the thrown environment or database error to the model when the egress catalog is unavailable. Nothing there can project it — the catalog it would need is the very thing that is missing — so the reason stays in the log and the response carries fixed text plus the workspace id the caller itself supplied. - Guard the attach against a frozen failure, which would otherwise throw and replace the original error partway through cleanup, making a diagnostic aid the thing that loses the diagnosis. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): key the dispatched-run id off the failure instead of writing to it Guarding the write was trading one failure for the worse one: a frozen or sealed error kept the process alive but dropped the marker, which turns "this run exists" into "nothing started" — the single direction that duplicates work. Record the id in a WeakMap keyed by the thrown value, the same shape markExecutionFinalizedByCore already keeps for the same reason. Nothing is written to the error, so a non-extensible one is recorded like any other and there is no throw to guard. Identity keying also retires the prototype-chain concern, and the error's own surface stays clean, so a serialized failure no longer carries a stray field. A thrown primitive still cannot be keyed, which costs nothing today because every throw site past the dispatch boundary raises an Error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): let the executor say when a block could first run Review was right that entering `execute` is still too early: DAG construction, snapshot restoration and pipeline assembly all happen inside it and reject a malformed graph having changed nothing, so a validation failure reported a run to resolve. Only the executor knows where that line falls, so it reports it. A `onBlocksMayRun` context extension fires immediately before `engine.run` on both entry points, and execution-core records the run from there rather than guessing at it from outside. A rejected graph now correctly says nothing started. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): report the run from the engine, and never let recovery erase it Two findings, both real. Firing before `engine.run` was still one step early: the engine's cancellation subscription is fallible and rejects having run nothing, so that failure claimed a run. The signal now fires inside the engine, immediately before the loop that processes blocks and past every startup step that can refuse a request — DAG construction, pipeline assembly and the subscription. The executor no longer guesses at the line from outside; the engine states it. Separately, the copilot catch path could throw while recording the failed crossing or releasing the execution slot. Either one propagated a different error — one the dispatched-run id was never recorded against — so an existing run reported itself as never started and invited the duplicate the id exists to prevent. Both are recovery work and neither may replace the failure it is describing, so both are contained and logged. Contained with try/catch rather than a rejection handler, since a synchronous throw has to be caught too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): mark a run that failed after the core returned Once `executeWorkflowCore` returns, the run happened. Everything after it in `executeWorkflow` — analytics, pause persistence, post-execution settling — is bookkeeping that can still throw, and the core's own catch no longer runs, so those failures named no run. The copilot handler then reported `not_attempted` for an execution that had already produced side effects, which is the one direction that duplicates work. Mark it as soon as the core settles, so any later failure carries it. The `finally` had the same shape and is now contained: a throw there replaces whatever the function was about to do, turning a successful run into an error or an error that names its run into one that does not. Settling post-execution work is bookkeeping and must not be able to do either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): stop calling a cancelled run performed `performed` claims the run reached the end of its work, so a caller reads it as "never retry, just read the outcome". Every returned result carried it, including a cancelled or paused one — which stopped partway and may have run every block, one, or none. Those are `attempted`: an execution exists under this id, resolve it before deciding anything. That is true whether the cancellation landed before the first block or after the last. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): derive the run phase from what the executor saw, not the result's shape Nine review rounds found the same class of defect, which makes it a design problem rather than nine bugs. "Did a side effect occur" had two sources that disagreed: a precise marker on the thrown path, and on the returned path an inference from whatever the outcome happened to look like. Every property used for that inference is a proxy that breaks on the paths that matter — an engine failing before its first block still carries an ExecutionResult, and a run that ends without one still ran every block it had — so each round found another path where the proxy lied. There is now one source. The engine reports the moment a block handler is first about to run, which is terminal: no fallible step remains between it and the handler, so there is nothing left for a later reviewer to find in front of it. The signal is threaded to the caller and recorded against the outcome, and the copilot adapter reads it on every exit path instead of inspecting status or the presence of an attached result. The phase then follows from two stated facts rather than a guess: nothing dispatched is not_attempted whatever the result looks like, a run that stopped partway is attempted, and one that reached the end is performed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): report dispatch from the block handler itself I claimed last round that nothing could precede the signal. That was wrong: `executeNode` returns early on a cache hit, initializes loop and parallel scopes, and handles a sentinel that never reaches a handler — all after the point it fired. Both reviewers found the same thing. Move it to the line before `blockExecutor.execute`, which is the handler call. Nothing separates the two, so unlike every previous position this one cannot have something in front of it. Fired per block rather than once, since observers record a boolean and repeats cost nothing. Also accept functions as carriers of the run markers. They key a WeakMap exactly as objects do, so excluding them dropped the record for a thrown function and lost the distinction the markers exist to make. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(copilot): decide the run phase where the caller lives, not in the executor Ten review rounds chased the same question — when exactly may a side effect have occurred — through six positions in the executor, ending with a callback on every block of every execution in the product. Against 543k executions a week, serving a disclosure read about fifty times a week. The precision was never the point: `attempted` and `performed` both mean an execution exists under this id, and the caller was already handed the id that resolves it. Revert all of it. The engine, the orchestrator, both context types and the callback threading through execute-workflow and execution-core go back to staging untouched; the executor's only remaining change is the id carrier in utils/errors.ts. The phase now comes from what the copilot layer already holds. Its `try` opens on the executor call, so everything it catches is post-dispatch by construction while authorization, admission and provenance export throw past it having created nothing — no id means nothing exists, an id means resolve it. A result in hand says how the run ended, which separates cancelled and paused from completed. The harness that motivated this is now in the diff: every outcome the run path can produce, driven through the real handler and the real projection, asserted on the retry decision a caller can reach and on no run content crossing. Six mutations were used to confirm it fails for the right reasons; one of them found the dispatch flag this refactor introduced was already dead, and it is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(copilot): state that a named run may resolve to nothing Both reviewers read an id on a preflight failure as a defect. It is the one place this contract is deliberately coarse, so say so where each of them was looking rather than leave it to be rediscovered. `attempted` already means "zero or one executions exist under this id" — the id is a correlation key, not a promise that a row exists. A caller resolves it, finds nothing, and retries, which is the right outcome at the cost of one lookup. Buying that lookup back means an executor-side dispatch marker: a callback on every block of every execution in the product, which this branch just reverted for that reason. It would also gain nothing, since all four preflight throws are invariant violations — no workspace id, no billing attribution, no principal, attribution mismatch — and a retry fails identically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 46703e3 commit 3ef51f4

14 files changed

Lines changed: 934 additions & 50 deletions

File tree

apps/sim/app/api/copilot/tools/execute/route.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,26 @@ describe('POST /api/copilot/tools/execute (in-band)', () => {
9595
expect(body.error).toBe('File not found: files/a.md')
9696
})
9797

98-
it('withholds results when no egress registry can be built', async () => {
99-
mockPrepareEnvironmentContext.mockRejectedValue(new Error('env unavailable'))
98+
/**
99+
* Running the tool without a catalog used to produce the worst pair of outcomes available:
100+
* the side effect happened and the caller got a bare `{success: true}` naming neither the
101+
* cause nor whether anything had changed.
102+
*/
103+
it('refuses the call, without running the tool, when no egress registry can be built', async () => {
104+
mockPrepareEnvironmentContext.mockRejectedValue(new Error('Workspace ws-gone does not exist'))
100105
mockHandler.mockResolvedValue({ success: true, output: { content: 'sensitive' } })
106+
101107
const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-no-registry' }) as never)
102108
const body = await res.json()
103-
expect(body).toEqual({ success: true })
109+
110+
expect(mockHandler).not.toHaveBeenCalled()
111+
expect(body.success).toBe(false)
112+
expect(body.output).toEqual({ resultWithheld: true, effect: 'not_attempted' })
113+
// The thrown reason is an unprojectable environment failure — the catalog that would
114+
// vouch for it is the very thing missing — so it stays in the log.
115+
expect(body.error).not.toContain('does not exist')
116+
expect(body.error).toContain(BASE_BODY.workspaceId)
117+
expect(body.error).toContain('could not be resolved')
104118
})
105119

106120
it('reuses one turn registry across calls that share a messageId', async () => {

apps/sim/app/api/copilot/tools/execute/route.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@ import { type NextRequest, NextResponse } from 'next/server'
44
import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot'
55
import { validationErrorResponse } from '@/lib/api/server'
66
import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context'
7+
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
78
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
89
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
910
import { checkInternalApiKey } from '@/lib/copilot/request/http'
1011
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
1112
import {
13+
describeWithholdingCause,
1214
inspectToolResultForCopilot,
1315
projectToolErrorMessageForCopilot,
1416
} from '@/lib/copilot/request/tools/resolved-secret-result'
1517
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
1618
import type { ToolCallResult } from '@/lib/copilot/request/types'
1719
import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor'
1820
import { executeTool } from '@/lib/copilot/tool-executor/executor'
21+
import { TOOL_EFFECT_PHASE } from '@/lib/copilot/tool-executor/types'
1922
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2023
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
2124

@@ -115,17 +118,43 @@ export const POST = withRouteHandler((request: NextRequest) =>
115118
[TraceAttr.UserId]: userId,
116119
})
117120

118-
let toolRegistry: ResolvedSecretTraceRegistry | undefined
119-
let turnRegistry: ResolvedSecretTraceRegistry | undefined
121+
let toolRegistry: ResolvedSecretTraceRegistry
122+
let turnRegistry: ResolvedSecretTraceRegistry
120123
try {
121124
turnRegistry = await getTurnEgressRegistry(userId, workspaceId, messageId)
122125
toolRegistry = turnRegistry.forkForInputPaths([])
123126
} catch (err) {
124-
logger.error('In-band egress registry unavailable; results will be withheld', {
127+
/**
128+
* Without a catalog the projection can vouch for nothing, so every result this call
129+
* could produce would be withheld. Running the tool anyway was the worst of both
130+
* outcomes: the side effect happened and the caller got an opaque sentinel that named
131+
* neither the cause nor whether anything had changed. Refusing before dispatch is
132+
* both truthful and the only answer that leaves nothing behind.
133+
*
134+
* The cause is almost always the workspace itself — a deleted or inaccessible id
135+
* reaching this lane — which is actionable, so it is reported rather than swallowed.
136+
*/
137+
logger.error('In-band egress registry unavailable; refusing the call', {
125138
toolName,
126139
toolCallId,
140+
userId,
141+
workspaceId,
127142
error: getErrorMessage(err),
128143
})
144+
rootSpan.setAttributes({ [TraceAttr.ToolOutcome]: MothershipStreamV1ToolOutcome.error })
145+
/**
146+
* The thrown reason stays in the log. It is an environment or database failure that
147+
* nothing here can project — the catalog it needed is the very thing that is missing —
148+
* so this is the one message on this route that must be fixed text. The workspace id
149+
* is echoed because the caller supplied it, and it is what makes this actionable.
150+
*/
151+
return NextResponse.json({
152+
success: false,
153+
error: workspaceId
154+
? `${toolName} was not run: its workspace (${workspaceId}) could not be resolved. Check that the workspace exists and is accessible before retrying.`
155+
: `${toolName} was not run: its execution environment could not be resolved.`,
156+
output: { resultWithheld: true, effect: TOOL_EFFECT_PHASE.notAttempted },
157+
})
129158
}
130159

131160
try {
@@ -148,9 +177,23 @@ export const POST = withRouteHandler((request: NextRequest) =>
148177
})
149178
const projection = inspectToolResultForCopilot(result, toolRegistry, toolName)
150179
const projected = projection.result
151-
if (projection.safe && toolRegistry?.isComplete() && turnRegistry) {
180+
if (projection.safe && toolRegistry.isComplete()) {
152181
turnRegistry.mergeToolCallRegistry(toolRegistry)
153182
}
183+
if (!projection.safe) {
184+
/**
185+
* Reported on its own rather than folded into the failure branch below: a withheld
186+
* SUCCESS keeps `projected.success` true, so gating on failure meant the one case
187+
* that leaves no other trace — the model reads a bare success — was also the one
188+
* case whose cause was never written down.
189+
*/
190+
logger.warn('In-band tool result withheld by egress projection', {
191+
toolName,
192+
toolCallId,
193+
runtimeSucceeded: result.success,
194+
...describeWithholdingCause(projection.cause),
195+
})
196+
}
154197
if (!projected.success) {
155198
logger.warn('In-band tool execution failed', {
156199
toolName,

apps/sim/executor/utils/errors.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,55 @@ export function attachExecutionResult(error: Error, executionResult: ExecutionRe
3636
Object.assign(error, { executionResult })
3737
}
3838

39+
/**
40+
* Dispatched-run ids, keyed by the thrown value itself.
41+
*
42+
* A side table rather than a property on the error, for the same reason
43+
* {@link markExecutionFinalizedByCore} keeps one: a thrown value is not reliably writable.
44+
* `Object.assign` throws on a frozen or sealed failure, and guarding that throw would drop
45+
* the marker instead — silently converting "this run exists" into "nothing started", which
46+
* is the one direction that duplicates work. Identity keying also means no id can arrive
47+
* through a prototype chain, and nothing is added to the error's own surface, so a
48+
* serialized error carries no stray field.
49+
*/
50+
const attemptedExecutionIds = new WeakMap<object, string>()
51+
52+
/**
53+
* Names the run a failure belongs to once dispatch has been attempted.
54+
*
55+
* A caller that only sees the thrown error cannot tell an authorization refusal — which
56+
* created nothing — from a crash after the run was already dispatched, and those need
57+
* opposite retry decisions. Recording the id at the point of no return makes its absence
58+
* mean "nothing was started" rather than "we do not know", and its presence a key that
59+
* resolves to zero or one executions.
60+
*
61+
* Distinct from {@link attachExecutionResult}: that says the workflow ran and produced a
62+
* result, this says only that it was dispatched.
63+
*/
64+
export function attachAttemptedExecutionId(error: unknown, executionId: string): void {
65+
if (!isRecordedThrown(error) || !executionId) return
66+
if (attemptedExecutionIds.has(error)) return
67+
attemptedExecutionIds.set(error, executionId)
68+
}
69+
70+
/** Reads the dispatched-run id a thrown value carries, if dispatch was reached at all. */
71+
export function readAttemptedExecutionId(error: unknown): string | undefined {
72+
return isRecordedThrown(error) ? attemptedExecutionIds.get(error) : undefined
73+
}
74+
75+
/**
76+
* Any non-null object, not only an `Error`.
77+
*
78+
* Restricting this to `Error` would silently invert the invariant for a thrown plain object:
79+
* no id would be recorded, its absence would read as "nothing was started", and the caller
80+
* would retry a run that already exists. A thrown primitive cannot be keyed at all, which
81+
* costs nothing today because every throw site past the dispatch boundary raises an `Error`.
82+
*/
83+
function isRecordedThrown(value: unknown): value is object {
84+
/** Functions key a WeakMap as well as objects do, so excluding them would drop the record. */
85+
return (typeof value === 'object' || typeof value === 'function') && value !== null
86+
}
87+
3988
export interface BlockExecutionErrorDetails {
4089
block: SerializedBlock
4190
error: Error | string

apps/sim/lib/copilot/request/tools/executor.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ import {
6161
setTerminalToolCallState,
6262
} from '@/lib/copilot/request/tool-call-state'
6363
import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files'
64-
import { inspectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
64+
import {
65+
describeWithholdingCause,
66+
inspectToolResultForCopilot,
67+
} from '@/lib/copilot/request/tools/resolved-secret-result'
6568
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
6669
import {
6770
maybeWriteOutputToTable,
@@ -737,15 +740,20 @@ async function executeToolAndReportInner(
737740
toolSpan.attributes = {
738741
...toolSpan.attributes,
739742
...summarizeToolResultForSpan(copilotResult),
740-
...(projection.safe ? {} : { resultWithheld: true }),
743+
...(projection.safe
744+
? {}
745+
: { resultWithheld: true, ...describeWithholdingCause(projection.cause) }),
741746
}
742747
if (!projection.safe) {
743748
// A withheld SUCCESS otherwise leaves no trace anywhere: the span reads
744749
// ok and the model just sees a bare `{success: true}` with no output.
750+
// The cause is what says whether a guard latched, no catalog was built,
751+
// or the payload itself was unprojectable — three different fixes.
745752
logger.warn('Tool result withheld by egress projection', {
746753
toolCallId: toolCall.id,
747754
toolName: toolCall.name,
748755
runtimeSucceeded: result.success,
756+
...describeWithholdingCause(projection.cause),
749757
})
750758
}
751759

apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import { describe, expect, it } from 'vitest'
55
import { RunCode, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1'
66
import {
7+
describeWithholdingCause,
8+
inspectToolResultForCopilot,
79
projectToolResultForCopilot,
810
READ_TOOL_RESULT_UNAVAILABLE_ERROR,
911
TOOL_RESULT_UNAVAILABLE_ERROR,
@@ -457,3 +459,105 @@ describe('projectToolResultForCopilot', () => {
457459
expect(toolResultUnavailableError(undefined)).toBe(TOOL_RESULT_UNAVAILABLE_ERROR)
458460
})
459461
})
462+
463+
describe('effect disclosure on a withheld result', () => {
464+
const EXECUTION_ID = '0f4d5a4c-6a1e-4c2f-9b7d-2c8f1a3e5d90'
465+
466+
it('carries nothing extra for a tool that declared no effect', () => {
467+
expect(projectToolResultForCopilot({ success: true, output: { a: 1 } }, undefined)).toEqual({
468+
success: true,
469+
})
470+
expect(projectToolResultForCopilot({ success: false, error: 'why' }, undefined)).toEqual({
471+
success: false,
472+
error: TOOL_RESULT_UNAVAILABLE_ERROR,
473+
})
474+
})
475+
476+
/**
477+
* The exemption is what makes the disclosure trustworthy, so it has to be all or
478+
* nothing: a disclosure that silently dropped the id it could not vouch for would
479+
* read exactly like one that never had a run to name.
480+
*/
481+
it('voids the whole disclosure when an id is not a shape this system mints', () => {
482+
expect(
483+
projectToolResultForCopilot(
484+
{
485+
success: false,
486+
error: 'why',
487+
effect: { phase: 'performed', ids: { executionId: 'not-a-server-minted-id' } },
488+
},
489+
undefined,
490+
'run_workflow'
491+
)
492+
).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR })
493+
})
494+
495+
it.each(['effect', 'resultWithheld'])(
496+
'voids the disclosure when an id would take the reserved key %s',
497+
(reserved) => {
498+
expect(
499+
projectToolResultForCopilot(
500+
{
501+
success: false,
502+
error: 'why',
503+
effect: { phase: 'performed', ids: { [reserved]: EXECUTION_ID } },
504+
},
505+
undefined,
506+
'run_workflow'
507+
)
508+
).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR })
509+
}
510+
)
511+
512+
it('reports the phase and ids when every id is vouchable', () => {
513+
expect(
514+
projectToolResultForCopilot(
515+
{
516+
success: false,
517+
error: 'why',
518+
effect: { phase: 'attempted', ids: { executionId: EXECUTION_ID } },
519+
},
520+
undefined,
521+
'run_workflow'
522+
)
523+
).toEqual({
524+
success: false,
525+
output: { resultWithheld: true, effect: 'attempted', executionId: EXECUTION_ID },
526+
error: expect.stringContaining('At most one run exists'),
527+
})
528+
})
529+
530+
it('never leaks the disclosure into a result that projected cleanly', () => {
531+
const registry = new ResolvedSecretTraceRegistry()
532+
533+
expect(
534+
projectToolResultForCopilot(
535+
{
536+
success: true,
537+
output: { executionId: EXECUTION_ID },
538+
effect: { phase: 'performed', ids: { executionId: EXECUTION_ID } },
539+
},
540+
registry,
541+
'run_workflow'
542+
)
543+
).toEqual({ success: true, output: { executionId: EXECUTION_ID } })
544+
})
545+
546+
it('names why the content was withheld, for the surface about to log it', () => {
547+
const latched = createRegistry()
548+
latched.markIncomplete('source-provenance-incomplete', { origin: 'test.origin' })
549+
550+
const projection = inspectToolResultForCopilot({ success: false }, latched, 'run_workflow')
551+
expect(projection.safe).toBe(false)
552+
// The per-call fork adds its own propagation reason; the guard that originally
553+
// tripped has to survive alongside it, or a refusal names only the messenger.
554+
expect(projection.safe === false && describeWithholdingCause(projection.cause)).toEqual({
555+
withheldCause: 'registry-incomplete',
556+
withheldReasons: expect.arrayContaining(['source-provenance-incomplete']),
557+
withheldOrigins: ['test.origin'],
558+
})
559+
560+
const absent = inspectToolResultForCopilot({ success: false }, undefined)
561+
expect(absent.safe === false && absent.cause).toEqual({ kind: 'registry-absent' })
562+
})
563+
})

0 commit comments

Comments
 (0)