Skip to content

feat(runner): show the real change on the card, commit those bytes - #5760

Merged
mmabrouk merged 14 commits into
release/v0.110.0from
agent-config-editing-s3b-wire-runner
Aug 7, 2026
Merged

feat(runner): show the real change on the card, commit those bytes#5760
mmabrouk merged 14 commits into
release/v0.110.0from
agent-config-editing-s3b-wire-runner

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Aug 5, 2026

Copy link
Copy Markdown
Member

Depends on #5763. Merge that one first.
This branch carries 13 test cases for the direct-call error-detail fix whose source lives in #5763 (approved). Until #5763 is in the base, this branch does not compile in isolation, because tool-direct.test.ts imports agentaErrorDetail, and the typecheck job here stays red for exactly that reason. Once #5763 merges, rebasing this stack onto the release branch clears it with no code change.

Context

Part of the agent-config-editing stack. Targets agent-config-editing-s7e. Read the stack bottom up.

s3a can read a workspace file. s3b-core can bind an approval to a call. Neither is connected to anything. This lane wires them into the permission gate and the relay, which is where the property users care about starts holding: what a human reads on the approval card is what gets committed.

Two things had to be true at once. The card must show the substance of the change, not a path and a byte count. And the call that executes must be provably the call that was approved, on every harness, including the ones where the runner never sees a dialog.

Changes

The approval path

At the gate, before the card, the runner resolves every @ag.file marker, freezes those exact bytes, mints one authorization record per marker, and returns a manifest. The order matters. A denied call performs zero workspace reads, because reading a file for a call that will never run leaks its existence into runner memory, spends the turn's byte budget, and on Daytona runs a process inside the sandbox for a call the policy already refused.

The manifest carries each file's path, size, digest, and executable bit. When a set replaces a whole field from one file, it also carries a unified diff against the text it replaces. That old text is fetched at the operation's own base_revision_id, never the session's revision, and the reason is a real hole rather than a technicality: the session may run revision N while the model correctly supplies head N+1, and diffing against N shows the human an N-to-new change, passes the base check, and replaces N+1 with text nobody compared against it. Nothing fails and the wrong thing commits. A fetch failure fails closed rather than showing a complete-content approval, since approving a replacement without seeing what it replaces is the one thing that mode exists to prevent.

The diff is written here rather than pulled in as a dependency. The runner is a standalone package whose image ships to every sandbox host, and a bounded line diff is a hundred lines of well-understood code. It trims common prefix and suffix first and degrades a still-too-large middle to a whole-block replacement rather than spending the turn's budget. Counts and digests stay exact either way.

At execution, the relay verifies the complete record set, consumes it, substitutes the frozen bytes, and runs. This check runs for every harness and does not depend on a dialog having been raised, which is what makes a forged request file useless on the non-Pi ask path. The catalog generation is captured at mint and compared at consume, because a tool named commit_revision under generation N may have a different schema, permission, or execution binding under N+1, and an approval minted under N does not describe that call.

A refused call is now an error, not a success (contract change)

This is the one behavior change a reviewer should look at closely, because it changes what every caller sees.

A refusal used to travel as an ordinary return string, so the relay wrote {ok: true, text} and every transport reported a refused commit as a SUCCESS.

Before:  { ok: true,  text: "The user declined this 'commit_revision' call. ..." }
After:   { ok: false, error: "The user declined this 'commit_revision' call. ..." }

On Codex the old shape surfaced as a blank successful tool result: the model saw no error and no text, invented an explanation, and told the user to approve again. A refusal that reads as success is worse than one that reads as a crash, because only the second makes the model stop. RelayRefusal now exists so the reason cannot be mistaken for output at any layer between the relay and the harness. The model still gets the same text and the loop still continues; only isError changes.

Six existing tests asserted res.ok === true for a refusal. They now assert the refusal, which is the point of the change rather than a side effect of it.

Approvals that survive the harness, not just the happy path

Four defects, all found by live QA rather than by the suite, all in the path between a human saying yes and the bytes landing.

Codex wraps tool arguments in an MCP envelope. A recorded MCP call's input is {tool, server, arguments}, not the tool's own arguments, so the marker scan found no workflow_revision at the top level and minted nothing. Every approved commit then died with authorization_missing. The gate now unwraps that exact shape, and only that shape, before scanning. Three existing tests had pinned the envelope as the expected gate payload, which is how this survived two earlier fix reports; they are corrected.

The Daytona transport rejects NUL bytes inside command arguments. The manifest walk asked find for a NUL-separated -printf format, so on Daytona the walk failed and no workspace-file commit could EVER be approved there, on any harness. Local never crosses that transport, which is why the suite stayed green. The walk now uses find's own backslash-zero escape.

An approved import could not find its own authorization. The shim mints under a fresh randomUUID() while the harness gate carries its own id, so an exact-id lookup came back empty and a genuinely approved commit was refused. findSetByCall now falls back to matching a complete, unconsumed, unexpired set by tool name, argument digest, and required markers. The digest still binds the model's original arguments, so the security property is unchanged: the fallback finds the right record, it does not weaken what a record proves.

A denied gate kept its authorization alive. Neither denial path discarded the records, and the store is session-scoped: it survives the turn whenever a sibling gate is still parked. So after a human denied one commit and carried another, the denied call's records stayed valid for the full fifteen-minute expiry. On Claude and Codex the relay guard passes ask, so a forged execute record carrying that call's id and arguments would consume the live record and commit exactly what the human rejected. Both denial paths now discard every record for the call and release its bytes before the harness is answered.

A fifth, from the same round: a cold resume spent the human's approval on nothing. The environment is gone and so are the frozen bytes, but the conversation still carries their {approved: true}, and the gate consumed it and replied allow. Execution then failed with authorization_missing, so the user saw a failed commit instead of a second card. The gate now recognizes a replayed answer for a marker-carrying call with no records behind it, reads the file again, mints fresh records, and shows a new card. The check is narrow on purpose: an allow whose effective permission is ask can only be a replayed answer, so a policy allow still resolves inline as contract section 4 requires.

Editing instructions or skills rebuilds the sandbox again

The lifecycle work had made the workspaceFiles facet a live route: an instructions edit rewrote AGENTS.md on the running sandbox and kept the session. Gate cell matrix_l5 proved that route is a silent lie. Every harness reads its instruction file once, at session start, so the refresh wrote the new file and applyReconcilePlan committed the incoming configuration as applied, the pool then reported the new fingerprint, every later turn matched and continued warm, and the model went on answering from the instructions it started with. The user's edit had no effect until something else evicted the session. A cold session with the identical configuration obeyed it immediately, which is what isolated the runner rather than the model.

workspaceFiles now routes to rebuild-sandbox, and refresh-workspace has left LIVE_ACTION_KINDS. An instructions edit costs a sandbox again, which is what it cost before the optimisation, and the edit takes effect on the very next turn.

Both halves are deliberate. Changing only the live set would leave the router planning a workspace refresh (outcome reuse) against a coordinator that rebuilds, so every instructions edit in production would log a permanent DISAGREE that no router work could drive to zero. Changing only the capability table would leave a live-set entry nothing routes to, so a future flip back would go live again without any guard firing. The applier keeps the refresh arm, unreachable, because the intended next shape is refresh and then reopen the session, and that needs the reopen to build its session init from the incoming request first.

Smaller changes

A description argument is lifted out of the payload and never saved. The schema marks it with x-ag-ephemeral: true, so the runner strips it by reading the advertised marker rather than by hard-coding a tool name, and the SDK can move or tolerate the field without the two sides drifting.

When a marker cannot resolve, the structured reason now survives instead of being reduced to one sentence: the failure code, the path the model named, the operation it sat in, a next step, and available, the entries that really are under the import root. That last field usually names the correction. The model-facing half is deliberately not shipped, and it is a recorded decision rather than an oversight: answering a permission gate is session.respondPermission(id, reply) where PermissionReply is "once" | "always" | "reject", a bare enum with no room for text, so nothing on that path can carry the reason to the model. Today it reaches the operator log. Delivering it needs a channel that does not exist yet, and the design work for that is a follow-up.

Refusal text no longer names a decider the caller cannot know. The Pi extension gates through a confirm that resolves to a boolean, so a policy deny, a live human decline, a replayed stored decline and a fail-closed reject all arrive identical; the old wording claimed "policy" for all four, and a model reading it after a human declined one change concluded the tool was unavailable and stopped asking.

Tests

  • The whole runner suite passes: 120 files, 2047 tests.
  • 47 tests for the approval path, including the forged relay record driven through the real relay loop, the deny-with-a-parked-sibling case, cold resume against live resume, and the policy-allow exception.
  • Every fix above was checked against the old behavior first, so each test fails without its fix.
  • Several test files live in this lane rather than beside the source they exercise (workspace-import.test.ts, tool-direct.test.ts, and the lifecycle suites). This is the first lane whose tip holds every symbol they touch, so anywhere lower they would not compile.

What to QA

  • Ask the agent to write a long instructions document to a file under .agenta-imports/ and commit it from that file. The approval card shows a diff of the old text against the new, not just a path.
  • Approve it. The committed revision holds the file's text.
  • Do the same on Codex with sandbox=daytona. The approval goes through and the revision lands. Before this branch, no workspace-file commit could be approved on Daytona at all.
  • Deny a commit that references a file. The agent reports a refusal, nothing commits, and the result reads as a failed tool call rather than a silent success.
  • Edit the agent's instructions mid-conversation and ask a question the new instructions answer. The next turn obeys them. The sandbox is rebuilt, so expect that turn to be slower.
  • Approve a commit, then let the session go cold and resume it. You are asked to approve again, on a fresh card, rather than seeing a failed tool call.

@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview Aug 6, 2026 8:09pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 38c23151-d6ff-47b3-9aa9-736f146aad27

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

if (!toolCallId) return true;
return input.state.store.recordsFor(toolCallId).length === 0;
},
onDenied: (toolCallId) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the discard seam for a denied gate. Both denial paths call it before the harness receives the answer.

The store is scoped to the session, not to the turn. The turn clears it only when no gate parked. So when a human denies one commit and carries a second one, the denied call keeps live records for the full 15 minute expiry.

Those records are enough to execute. On Claude and on Codex the relay guard passes every ask verdict, because the harness raises its own dialog and the runner records no grant. An execute record in the relay directory that carries the denied call id and the same arguments then verifies, consumes, and commits the exact content the human rejected. The relay directory is writable from inside the sandbox.

execution-authorization.md section 3.5 lists a denied gate among the discard events. Section 10 requires the test.

If this call moves after respondPermission, the window returns.

// still holds them, so this is false and the parked answer stands; on a COLD resume the store
// is new and empty, and the human's old `{approved: true}` describes bytes no longer in
// memory (execution-authorization.md 7.2).
shouldRegateStaleApproval: ({ gate, toolCallId }) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This predicate answers one question. Is this allow a replayed answer with nothing behind it?

A cold resume destroys the environment and the frozen bytes with it. The conversation still carries the human's {approved: true}, so decide returns allow. Executing on that runs content nobody saw. The old behavior spent the approval, failed at the relay with authorization_missing, and showed the user a failed commit instead of a second card.

The first test is the narrow part. An allow under an ask permission can only come from a stored answer, because decide consults the decision store on no other path. A policy allow is the explicit exception in contract section 4 and must still resolve inline at the relay.

If you widen this test to every allow, you break that exception. If you drop the record count test, a live resume stops consuming its parked approval and the human is asked twice for one commit.

if (required.length === 0) return { ok: true, args: input.args };

try {
if (this.options.store.recordsFor(input.toolCallId).length === 0) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check runs at the relay for every harness. It does not depend on a dialog having been raised.

That independence is the point. The relay guard answers whether the permission policy allows the tool. On a non Pi harness it passes ask for compatibility, because the harness owns that dialog and the runner records no grant. A forged record can ride that pass.

This hook demands a record the runner minted itself at the gate. The record binds the exact tool, the exact arguments the model wrote, and the exact bytes a human saw. A missing record fails the call closed.

The block below is the single exception. It resolves inline only on an explicit policy allow. That is a positive statement by the policy owner. A missing record is the absence of information, and the two must not be treated alike.

// must leave no record and no frozen bytes behind.
const diffs = await this.buildDiffs(input.args, resolved, operations);

const contentDigest = strictDigest(resolved.args);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The digest covers the model's original arguments, which still hold the markers. It does not cover the resolved arguments.

This is what makes a substituted replay fail. An attacker who sends the resolved content in place of the marker produces a different digest, so verification refuses the call.

The manifest is built once, here, from the same bytes the authorization freezes. It is never rebuilt at execution from a second read of the workspace. A second read is a second chance for the content to differ from what the human approved.

pause.markAnsweredDeny(decision.toolCallId);
// Before the harness is answered, and for the same reason as the ACP deny path above:
// the records this call parked on must not outlive the human's "no".
approvedContent.onDenied(decision.toolCallId);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the live resume denial. The gate parked in an earlier turn, and this turn answers it on the same session.

The discard runs before respondPermission below. The order matters, because the relay is live for the rest of this turn and an execute record can arrive at any point in it.

The two marks above this line are bookkeeping only. markToolCallDenied shapes the event the frontend renders. markAnsweredDeny protects the failed frame from a sibling pause sweep. Neither blocks execution, so neither is a substitute for the discard.

});

return {
onResolveApprovedContent: async ({ toolName, toolCallId, args }) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gate hook resolves the markers, freezes the bytes, and mints the records before the card is shown.

It runs only after a non deny verdict, and only for a gate that is about to pause. A denied call performs zero workspace reads. Reading a file for a call that will never run leaks its existence into runner memory, spends the byte budget of the turn, and on Daytona starts a process inside the sandbox for a call the policy already refused.

A resolution failure denies the gate rather than showing a card. A card the runner could not build in full would ask the human to approve less than it appears to show.

@mmabrouk

mmabrouk commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mmabrouk

mmabrouk commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

mmabrouk added 14 commits August 6, 2026 22:03
Gate-time resolution and freezing after a non-deny verdict only (a
denied call performs zero workspace reads), one authorization record per
marker, and a RelayExecutionAuthorizer that verifies and synchronously
consumes on EVERY harness independent of any dialog, which is what
closes the forged-ask hole; the guard's non-Pi ask pass stays as
compatibility. Inline resolution only on an explicit allow verdict
computed against an empty decision store. The diff is computed
runner-side over the exact bytes the digest binds. Also fixes the S3a
whole-value marker bug that broke the founding use case, and implements
catalogGeneration per the adapter matrix.

GitButler-Conflict: This is a GitButler-managed conflicted commit. Files are auto-resolved
   using the "ours" side. The commit tree contains additional directories:
     .conflict-side-0  — our tree
     .conflict-side-1  — their tree
     .conflict-base-0  — the merge base tree
     .auto-resolution  — the auto-resolved tree
     .conflict-files   — metadata about conflicted files
   To manually resolve, check out this commit, remove the directories
   listed above, resolve the conflicts, and amend the commit.
…me re-gates marker calls (final review F2, F8)

GitButler-Conflict: This is a GitButler-managed conflicted commit. Files are auto-resolved
   using the "ours" side. The commit tree contains additional directories:
     .conflict-side-0  — our tree
     .conflict-side-1  — their tree
     .conflict-base-0  — the merge base tree
     .auto-resolution  — the auto-resolved tree
     .conflict-files   — metadata about conflicted files
   To manually resolve, check out this commit, remove the directories
   listed above, resolve the conflicts, and amend the commit.
…arguments; refusals travel as errors with their reason. CONTRACT CHANGE: a guard deny is now an MCP error result (the loop still continues); six tests updated deliberately. Plus the x-ag-ephemeral lift marker.
…of two byte-identical gated calls leaves the approved twin's set matchable under the denied id (bounded, recorded in open-issues)
…ope, so the gate now unwraps {tool,server,arguments} before minting; the Daytona manifest walk uses find's backslash-zero escape because the transport rejects NUL argv bytes (no workspace-file commit could ever be approved on Daytona); the deny path names the argv that died. Three regression tests; three tests that pinned the envelope corrected (that wrong expectation survived two fixed reports).

GitButler-Conflict: This is a GitButler-managed conflicted commit. Files are auto-resolved
   using the "ours" side. The commit tree contains additional directories:
     .conflict-side-0  — our tree
     .conflict-side-1  — their tree
     .conflict-base-0  — the merge base tree
     .auto-resolution  — the auto-resolved tree
     .conflict-files   — metadata about conflicted files
   To manually resolve, check out this commit, remove the directories
   listed above, resolve the conflicts, and amend the commit.
…efresh-workspace leaves the live set (both halves pinned; capability-only would shadow-DISAGREE forever, live-set-only lets a future flip go live untested); three vacuous live-routes tests retargeted to the model route
…through the catch (code, named path, next step, and the import-root listing with directory markers) into the operator log; the deny is untouched. The model-facing half needs a pre-gate seam and is a recorded decision, not shipped here.
…ecided (the confirm boolean cannot distinguish them); the settled decision, the no-reshape rule, and go-ask-the-user survive without the false attribution; all three sites share the text and the bundle carries it
…routing change they pin (exactly-one-live-route exists only from this lane; three lanes failed in isolation)
…the s3a reader shipped a statChain walk bug its own tests catch; the fix incl. the NUL manifest separator lives here)
…n suite they extend (hunk attribution owns these regions here)
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-pr-5760.up.railway.app/w
Project agenta-oss-clone-spike
Image tag pr-5760-e34f392
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-08-07T09:51:39.916Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend lgtm This PR has been approved by a maintainer size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant