Skip to content

feat(core): run Code Mode modules through verify and run, with stable outcomes - #387

Merged
nickruigrok merged 5 commits into
mainfrom
feat/code-mode-module-contract
Oct 11, 2026
Merged

nickruigrok merged 5 commits into
mainfrom
feat/code-mode-module-contract

Conversation

@nickruigrok

@nickruigrok nickruigrok commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Second PR of the GRA-349 stack, on top of #385. It brings the existing executeCode and runCode up to the module contract in section 21.1. The executor and the tool are the same ones as before; nothing new runs beside them.

What changes

  • Input. The tool takes { code } and nothing else. The declaration sets additionalProperties: false, and prepareArguments refuses anything else with a short message. Pi's own validation error would repeat the whole input, which can be 200 KB, back to the model and into the transcript.
  • Source limit. A module over 200,000 characters is refused before it loads. It ends with status refused and a bounded diagnostic.
  • Signature. The default export is called as (self, env, ctx):
    • self is null;
    • env holds the same seven scoped stubs as before (decision D1);
    • ctx is a frozen object with only the run's own waitUntil, so nothing of core's and no exports. waitUntil extends no lease, because the host closes the run's stubs when the run ends.
    • The agent instructions and every test module now use the new signature. This is a hard cut.
  • Verify, then run. The harness has two entry points:
    • verify imports the module and checks its default export. Whatever the module does while it loads runs inside the isolate's limits. A failure there is load_failed, and what the module logged before failing is kept.
    • run then calls the function.
  • Wall clock. The limit is set before the isolate is asked to load the module, and covers loading, verify and run. Workerd gives no way to observe when the load itself starts, so this ordering is in the code, not under test.
  • Statuses. The host sets every outcome; the code can't. They are completed, threw, refused, load_failed, cpu_exceeded, crashed, wall_expired and cancelled.
    • A failed run is a Pi isError result. The status is kept in the tool result's details, and the model reads it as Error (<status>):.
    • When workerd sees code waiting on something that can never settle, it cancels the request as hung. That counts as wall_expired.
  • Test-only limit. CODE_RUN_TIMEOUT_MS shortens the wall clock in tests only, like APP_CALL_TIMEOUT_MS. It can only lower the limit, and the model can't set it.

Not here

  • CPU limit (D2). cpu_exceeded maps the runtime's message, but plain workerd doesn't enforce limits.cpuMs. The CPU proof is GRA-382's; PR 5 of this stack records cpu_limit_unsupported on plain workerd.
  • Lease and revocation. The persisted record, revocation and closing the lease are PR 3. Output budget and streaming are PR 4.

Evidence

New tests in apps/core/test/agent.test.ts, all through the real Workspace DO, Worker Loader and Pi loop, with only the model provider faked:

  • A full module: helpers, self === null, ctx keys ["waitUntil"], and two parallel native calls.
  • An oversized module is refused before loading. It would log while loading, and it doesn't.
  • No default export, a non-function export, a module that throws while loading (its log is kept) and a syntax error are all load_failed.
  • A function that throws is threw, after the module's load-time log.
  • Extra input fields are refused, the module doesn't run, and the input isn't repeated back.
  • A promise that can never settle is wall_expired.
  • A 60 s wait under a 1 s test limit is wall_expired within 10 s.
  • Cancellation is now reported as Error (cancelled).

Commands:

  • vp check: passes.
  • vp test run test/agent.test.ts test/run-fixes.test.ts: 54 passed.
  • vp test run test/chat-connections.test.ts test/agent-knowledge.test.ts test/agent-instructions.test.ts test/agent-connections.test.ts test/run-fixes.test.ts, and the same with test/packages.test.ts test/dependencies.test.ts test/chats.test.ts: all pass.
  • vp test run -c vite.screens.config.ts test/agent-apps.test.ts test/agent-builds.test.ts test/preview-repairs.test.ts: 38 passed.

Review fixes (711c4a5)

Must-fix: the code could forge the host-set statuses. It could make cpu_exceeded, wall_expired or crashed appear by making a harness call reject with a message it chose. Three routes did it: a thrown Proxy, built-ins patched after load, and replacing Harness.prototype.run through an import of the harness.

  • The harness takes every built-in it uses before the code loads: Object.keys, Proxy, Reflect, Error, the array and string helpers, JSON.stringify and String.
  • The entrypoint class and its prototype are frozen before load.
  • render, show and describe are total. They catch everything, fall back to fixed text, and read a thrown value's stack with Reflect.get instead of instanceof.
  • verify and run return every JS outcome and never reject from JS. A rejection is therefore always the runtime's own.
  • The host maps cpu_exceeded only from the runtime's exact message, matched whole, and wall_expired only from its hang message (prefix).

Nits:

  • run() with no function to call ends as load_failed (the outcome carries loaded).
  • The crashed doc now says a rejection while loading ends as load_failed and one while running as crashed.
  • An outcome that fails the schema ends as load_failed from verify and threw from run; this is documented.

New tests:

  • A thrown Proxy whose traps throw the runtime's message ends as threw.
  • An error carrying the runtime's message ends as threw.
  • Built-ins replaced as the module loads end as threw.
  • Harness methods replaced through an import end as completed.
  • A Proxy thrown while loading ends as load_failed.
  • The hang message thrown while loading ends as load_failed.
  • A module of exactly 200,000 characters runs, and one character more is refused.
  • ctx.waitUntil work calling env after the run returned is never answered. On workerd, that work either reaches core and is refused at the lease, or never reaches core at all; the test accepts both, so it doesn't prove which.

Evidence: vp test run test/agent.test.ts (57 passed at this commit), plus the connection, knowledge, chat and run-fix test files (42 passed).

Delta review fix (b4ae7c2)

Must-fix: forgery through structured clone. A setter on Array.prototype[0] fired when the harness pushed to its logs array, installed a throwing getter, and made the RPC serialisation of the outcome reject with the code's own message.

  • verify and run now return one string: the outcome's JSON, put together by hand from primitives (two booleans, the logs, and one result or error string), with a fixed fallback.
  • Logs are kept as the JSON of their lines (logsJson), built by string concatenation. No array or object of the harness's is ever filled through [[Set]] or sent.
  • The host checks it is a string and bounded, parses it, then applies the schema.
  • New entries in the forgery table: a setter on Array.prototype[0] set up while the module loads and inside the function, each throwing the CPU and the hang message. They end as completed or threw.
  • Mutation check: against the previous harness (711c4a5), all four new entries fail.

Evidence: vp test run test/agent.test.ts passes (61), and so do the connection, knowledge, chat and run-fix test files (42).

@nickruigrok
nickruigrok force-pushed the chore/pi-agent-core-1-1 branch from d1536dc to b3dd385 Compare October 11, 2026 02:41
@nickruigrok
nickruigrok force-pushed the feat/code-mode-module-contract branch from 8dfae1d to 17e3dfd Compare October 11, 2026 02:46
@nickruigrok
nickruigrok force-pushed the chore/pi-agent-core-1-1 branch from b3dd385 to df18454 Compare October 11, 2026 03:06
@nickruigrok
nickruigrok force-pushed the feat/code-mode-module-contract branch from 17e3dfd to 711c4a5 Compare October 11, 2026 03:06
Base automatically changed from chore/pi-agent-core-1-1 to main October 11, 2026 03:53
… outcomes

The agent code is a complete module whose default export takes
(self, env, ctx): self is null and ctx is the run own context. The
harness verifies the module first, so a module that fails to load is
told apart from a function that throws. Source over 200000 characters
is refused before it loads, input fields besides code are refused, and
the wall clock starts before the module loads. Every run ends with a
status the host sets: completed, threw, refused, load_failed,
cpu_exceeded, crashed, wall_expired or cancelled.
The harness takes every built-in it uses before the code loads, freezes
its entrypoint class, and returns every outcome of the code, describing
whatever was thrown without ever throwing itself. A call that rejects
is then always the runtime, so the host reads cpu_exceeded, wall_expired
and crashed only from the runtime own messages, matched whole. A run
whose function is gone ends as load_failed.
verify and run now return a single JSON string, put together by hand
from primitives, and the logs are kept as the JSON of their lines. No
array or object of the harness that a prototype the code changed could
reach is ever filled or sent, so the code cannot make a call reject
with a message it chose. The host parses and checks the string.
@nickruigrok
nickruigrok force-pushed the feat/code-mode-module-contract branch from b4ae7c2 to e41f072 Compare October 11, 2026 03:54
@nickruigrok
nickruigrok marked this pull request as ready for review October 11, 2026 03:54
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

5 similar comments
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@greptile-apps

greptile-apps Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; the timeout cleanup introduces no actionable defect.

Summary

Code Mode now checks modules before calling their default export with (self, env, ctx), bounds source size, and returns host-set statuses.

  • Code Mode checks a module before calling its default function.
  • Every code run ends with a status the host records.
  • The code tool accepts only bounded modules and limits each run's time.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["executeCode receives code"] --> B["Start wall-clock deadline"]
  B --> C{"Source within limit?"}
  C -->|No| D["refused"]
  C -->|Yes| E["verify imports module"]
  E --> F{"Default export is a function?"}
  F -->|No| G["load_failed"]
  F -->|Yes| H["run calls default(null, env, ctx)"]
  H --> I["Host checks returned outcome"]
  I --> J["completed or threw"]
  E -.-> K["Host handles timeout, cancellation, or runtime failure"]
  H -.-> K
Loading

Reviews (3) · Last reviewed commit: "test(core): set the shortened code run l..." · Reviewed by Greptile

Comment thread apps/core/src/code-mode.ts Outdated
The outcome string was capped at eight times the room, but JSON escapes
a character to as many as six, so output within both decoded limits
could pass the cap and the run was taken as thrown. The cap is now the
worst escaped size of the logs and result with their punctuation; the
decoded limits are still checked once the string is parsed.
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

The wall-clock test lowered CODE_RUN_TIMEOUT_MS to a second in the env
the Workspace objects share, and never set it back, so every later test
in the file ran its code under a one-second limit. A run of 150 API
calls took longer than that on a loaded runner and ended wall_expired.
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok
nickruigrok merged commit 272c69b into main Oct 11, 2026
9 checks passed
@nickruigrok
nickruigrok deleted the feat/code-mode-module-contract branch October 11, 2026 11:43
nickruigrok added a commit that referenced this pull request Oct 11, 2026
…tion (#389)

Third PR of the GRA-349 stack, on top of #387. **Security-relevant:** it
changes how long the agent's code holds authority. It needs a person's
review before merge.

## Threat model (written before the code)
Each row is a way stale or stolen authority could act, and what stops it
now.

| # | Failure or attack | What stops it | Test |
|---|---|---|---|
| T1 | Code keeps running after its run ended (cancelled, past the wall
clock, or detached work after it returned) and calls an API | The lease
closes as the run ends; every call is counted and checked against it
first (`requireOpenRun`) | existing cancel test; new wall-clock write
test |
| T2 | A call admitted while the lease was open writes **after** the
lease closed (a slow write that races the end of the run) | Admitted
again when the write commits. Draft writes, discards and App creates
check the lease **inside the Workspace object's write transaction**.
Commits elsewhere (propose, permission and dependency requests,
connection requests and calls, USER.md saves) call `admitEffect` right
before the effect. | DO-level fence test |
| T3 | A permission of the agent is revoked while code is under way |
Every call and every write admission checks for any revocation of the
agent's permissions since the run opened (one indexed D1 read). The
first one that finds it closes the lease as `revoked`, and the run ends
`revoked` whatever its code returned. Its result isn't handed to the
model, since it may rest on what was revoked. | revocation test |
| T4 | A core restart mid-run leaves a usable lease, or the module runs
again | Leases live only in memory. After a restart the run is unknown
and answers `ended`. The record is marked `interrupted` on wake. Turns
are never replayed, so a follow-up is a new run with a new ID. | restart
test (the object is aborted with `ctx.abort`) |
| T5 | Forged or guessed run IDs, or one chat's run reaching another
chat | The run ID is a random UUID in props the code can't read or
change, and leases are keyed `<chat>/<run>` | existing env/import tests;
unknown-run case |
| T6 | Flooding the audit log by calling after the end | One refusal is
audited per run. The call that finds a revocation audits it, and later
calls don't. | existing 100-call test; revocation test |
| T7 | Erasing what already happened | Effects admitted before the close
keep their receipts (pending actions, proposed versions). Nothing is
rolled back or described as undone. | (by construction) |

Accepted residual: revoking **any** permission of the org agent ends
every run of that agent under way, not only runs that used it. A run
lasts at most 30 s, so this errs safe and costs little. It follows the
posture of allowing and auditing, without per-permission bookkeeping.

## What changes
- **`chat_code_runs`**, a new Workspace DO SQLite table. It is the
chat's existing persistence, not a separate Code Mode store. Each row
holds:
- the run ID, tool call ID, SHA-256 and length of the module (never the
module itself);
- `agent` (`@earendil-works/pi-agent-core@<exact version>`, imported
from the package; pi-ai is pinned to the same version in the catalog)
and the compatibility date;
  - `runtime` and `cpu_limit`;
- `status` (`running`, then the terminal status, or `interrupted`) and
its times.
- Rows are deleted with the chat. The migration is
`0008_chat_code_runs`; it is new, so this is a hard cut.
- **D2.** Only `RUNTIME_PROFILE=cloudflare` records `enforced`. Anything
else, including unset or a typo, records `cpu_limit_unsupported`. Tests,
`vp run dev` and the on-prem smoke config set `workerd`. The CPU proof
is GRA-382's. **Follow-up:** the console must set
`RUNTIME_PROFILE=cloudflare` on Cloudflare deployments. Until it does,
their records say `cpu_limit_unsupported`, which errs safe.
- **Leases.** They now carry `openedAt`. New `revokeCodeRun` and
`admitFromCodeRun` close a lease and check one without counting a call.
Draft writes and discards now name their run: `saveDraft(..., runId)`
and `dropDraft(..., { runId })`. A proposal's follow-through drop goes
by `{ revision }`, because its commit was already admitted.
`takeCreate(..., runId)` also names its run.
- **New error code** `agent.run_revoked`.

## Evidence
New tests in `apps/core/test/agent.test.ts` ("chat agent code run
leases"), through the real Workspace DO, Worker Loader, Pi loop and D1:
- **The record:** call ID, source hash and length, the exact Pi version,
compatibility date, `workerd` with `cpu_limit_unsupported`, and status
`completed`.
- **The DO fence:**
  - a write while the run is open is admitted;
- after `closeCodeRun` (`wall_expired`), a write and a discard are
refused with `agent.run_ended`;
- after `revokeCodeRun`, a write and a create are refused with
`agent.run_revoked`;
  - an unknown run is refused;
  - the revoked run's close returns `revoked`;
- the draft holds only the admitted write, and the records read
`wall_expired` and `revoked`.
- **Revocation mid-run:** the code waits, an admin revokes the agent's
grant, and the next call is refused. The tool result is `Error
(revoked)`, the record says `revoked`, and exactly one
`agent.run_revoked` audit refusal is written.
- **Restart mid-run:** `ctx.abort("restart")` while the code waits. The
turn fails, and on wake the record says `interrupted` and the run's ID
answers `ended`. 6 s later there is still one tool call, no result, one
run record and one model request: nothing ran again.

In `apps/core/test/agent-builds.test.ts`: code that waits 2 s past its 1
s wall-clock limit and then writes the draft is refused once
(`agent.run_ended`), and the draft stays empty.

Commands:
- `vp check`: passes.
- `vp test run test/agent.test.ts test/agent-connections.test.ts
test/chat-connections.test.ts test/agent-instructions.test.ts
test/agent-knowledge.test.ts test/chats.test.ts test/memory.test.ts
test/run-fixes.test.ts test/packages.test.ts test/dependencies.test.ts`:
184 passed.
- `vp test run -c vite.screens.config.ts test/agent-builds.test.ts
test/preview-repairs.test.ts test/app-preview.test.ts
test/agent-apps.test.ts`: 46 passed.



## Security review fixes (cc14ff5)
**M1. A revocation with no later call went unnoticed.** As a run ends,
`#endCodeRun` now reads revocations once more. If it finds one, it
closes the run as `revoked` and records a single `agent.run_revoked`
(`method: executeCode`).

**M2. A revoked run's logs still reached the model.** A revoked run now
hands on neither its result nor its logs. In #390, output that no
watcher has had yet is dropped when the lease is revoked.

**M3. Any revoked row of the org agent ended every run.** `revokedSince`
now counts only revoked permissions with `granted_at IS NOT NULL AND
(chat_id IS NULL OR chat_id = <this chat>)`. A declined request that was
never granted, or a revocation in another chat, leaves the run alone.
The comparison is `revoked_at >= opened_at`.
- I added no skew margin. With a 2 s margin, a run opened just after an
unrelated revoke ended as revoked; the existing held-outcome connection
test showed it. Both timestamps come from Workers clocks.

**S1.** `saveDraft`, `dropDraft({ runId })` and `takeCreate` now run
`admitEffect` first, and keep the lease check inside their own
transaction.

**S2.** `admitEffect` takes three steps in this order:
- a cheap lease read, to get `openedAt` and fail fast;
- the D1 revocation read;
- the lease check, last, right before the effect.

**S3.**
- `connections.call` and `memory.saveUser` record their `admitEffect`
refusals with `admitEffect(env, scope, method)`. Calls wrapped in
`auditedCall` are recorded by it.
- A refusal is recorded only by the call that closed the lease
(`revocationOf` returns `closed` or `closedBefore`), which fixes the
double audit.
- Remaining case: a call admitted at its start, then refused at commit
because another call had already revoked the lease, is recorded by
`auditedCall` too.
- The `admitEffect` in `memory.saveUser` stays. It is the commit-time
check, which `requireOpenRun` at call start doesn't cover.

**S4. New tests:**
- M1: the code calls once and waits, an admin revokes, and the code
returns without another call. The result and logs are withheld, the
status is `revoked`, and one refusal is recorded.
- M3: a declined, never-granted request and another chat's revocation,
stored mid-run, leave the run `completed`.
- `admitEffect` end to end, using a slow outside dependency: every read
of core's files is delayed 2 s against a 1 s run limit.
- A proposal whose run expired while it was checked commits nothing, and
`build.propose` is refused as `agent.run_ended`.
- A draft write let in at once outlives its run while it reads the App's
files, and is refused at commit.
- Mutation checks:
  - an `admitEffect` that does nothing fails the proposal test;
  - dropping the granted and chat filters fails the M3 test;
  - dropping the end-of-run revocation read fails the M1 test.

**S5.** `RUNTIME_PROFILE` now fails closed (see D2 above). There is a
test of how each profile value maps.

**S6.** New migration `0056_permissions_revoked_idx` adds
`permissions(subject_type, subject_id, status, revoked_at)`.

**Nits:**
- `recordSources` refuses a revoked lease with `agent.run_revoked`.
- `interrupted` is a `CodeRunStatus`, and the statuses live in
`code-run-statuses.ts`.
- `chat_code_runs` (migration `0008`, regenerated since it isn't on
main) gains a CHECK on `status`, a CHECK on `tool_call_id` length (1 to
200, cut to fit before insert), and a partial index `WHERE status =
'running'`. A chat keeps its latest 1000 records.
- Not recorded: pi-ai's version, which exports no `package.json` to read
(it is pinned to pi-agent-core's version in the catalog), and the
workerd build version, which the runtime doesn't expose.
- `RUNTIME_PROFILE` is added to the platform's reserved binding names.

**Evidence at the top of the stack:**
- `vp check`: passes.
- `vp test run` on agent, connections, chat-connections, instructions,
knowledge, chats, memory, run-fixes, permissions, packages and
dependencies: 220 passed.
- `vp test run -c vite.screens.config.ts` on agent-builds,
preview-repairs, app-preview and agent-apps: 47 passed.


## Delta review fixes (d14824f)
**Must-fix 1, fail open on T1:** the end-of-run revocation read could
throw before the lease closed. **Must-fix 2, regression:** the lease
stayed open through that D1 round trip after wall expiry or cancel.
- `#endCodeRun` now closes the lease and records the status
synchronously and unconditionally first. Only then does it read
revocations.
- If a revocation is found, the record is revised to `revoked`, one
`agent.run_revoked` is recorded, and the result and logs are withheld.
- A read that **throws** counts as revoked (fail closed). It is logged
as `agent.revocation_read_failed`.

New tests (only the revocation query is faulted, through a database
proxy; everything else is real):
- **A throwing revocation read:** the result and logs are withheld
(`Error (revoked)`), the record says `revoked`, and the lease reads
`ended`.
- **A call during the end-of-run read** (the read is slowed 2 s; the run
ends at its 1 s wall clock while the code waits, then calls): refused
once as `agent.run_ended`, with the run recorded as `wall_expired`.
- Mutation check: against the previous `#endCodeRun`, both tests fail.

**Nits:**
- A comment now states that cross-machine clock skew is the accepted
residual of the margin-free comparison.
- The `admitEffect` doc now says refusals are recorded per refused call.
- A comment ties `granted_at IS NOT NULL` to how permissions become
active: both grant paths set it, so only a request declined before it
was granted lacks it.

Evidence at the top of the stack:
- `vp check`: passes.
- Core test files: 226 passed (11 files).
- Screen-config test files: 47 passed (4 files).
- `smoke:code-mode`: passes.


## Greptile fix (1b7fd82): admission right before the write
`admitEffect` ran before slow preparation, not right before the write,
so work that outlasted the run, or a revocation meanwhile, still let the
write land after the lease closed. I confirmed this with a probe for
dependency resolution.

Each write helper now takes an admission callback and calls it right
before its batch, after any slow preparation. The early check stays as a
cheap refusal. Where the late check runs:

| Helper | Late check runs |
|---|---|
| `resolveDependencies` | after `resolveGraph` and `inspectGraph`,
before `proposeResolved` |
| `proposeDraft` | after `storeTree`, before `auditedBatch` |
| `saveUserMemory` | as `writeVersion`'s `lastCheck` |
| `createApp`, `createFromBlueprint`, `requestPermission`, `askInChat` |
before their batch |

Unchanged:
- `saveDraft`, `dropDraft` and `takeCreate` already check the lease
inside the Workspace transaction.
- `connections.call` is admitted right before `CONNECT.call`.

New tests, each slowing an outside step past a 1 s run limit:

| Slowed step | Result |
|---|---|
| the registry metadata fetch | no dependency request or lock is stored,
and the refusal is recorded as `build.resolveDependencies` |
| the upload of the version's tree | no pending version |
| the knowledge reads | no USER.md is saved, and one `memory.saveUser`
refusal is recorded |

Mutation check: removing the late admission from any one of the three
helpers makes its test fail.

Evidence:
- `vp check`: passes.
- Agent, connections, chat-connections, knowledge, chats, memory,
permissions, dependencies, packages, app-blueprints and apps tests: 236
passed.
- `agent-builds` and `agent-apps` (screen config): 34 passed.
nickruigrok added a commit that referenced this pull request Oct 11, 2026
…get (#390)

Fourth PR of the GRA-349 stack, on top of #389. It adds bounded output
and output that streams before the tool completes (section 21.1, step
5).

## What changes
- **One output budget:** 64 KiB of UTF-8, shared by logs, streamed
progress, result and error.
- 1 KiB is kept for how the run ended, so a flood of logs never hides
the error.
- Cut text ends with an explicit marker: `… (cut: over 65536 bytes of
output)`.
  - This replaces the 32 K-character model view.
- Text is cut with `TextEncoder.encodeInto`, so a cut never lands inside
a character.
- **Streaming:**
- The harness sends each logged line to core's callback in order, one
after another, and waits for every line to arrive before a call returns.
The code never sees the callback.
- The callback is handed to `verify` and to `run` separately, because an
RPC stub only works in the call that brought it.
- Core checks each line like the run's result (a string, bounded) and
drops it once the run has ended.
- Each line becomes a Pi tool update (`onUpdate`; Pi ignores updates
after the step settles). The turn hands it to the Workspace DO.
- The DO takes a line only while the run's lease is open. It counts and
bounds the lines itself (1,000 lines, 128 KiB of raw text a run),
whatever the harness claims.
- A line is shown only after a read of revocations that **began after
the line arrived** finds none. Those reads happen at most once a second
a run and fail closed. So nothing logged after a revocation is ever
shown, even if the code makes no further call.
- The shown text is the whole output redacted at once, then cut at the
budget less the reserve, so a credential split across lines is caught.
Watchers get it through `ChatUpdate.output`, which grows incrementally
like `partial` and is left out when unchanged.
- The output stays until the step's result is stored, so it doesn't
flicker away first. A revocation, or the end of the turn, drops it at
once.
- **Redaction.** Bearer and basic credentials, `sk-`/`pk-`/`rk-` keys,
JWTs, PEM private keys and AWS access keys become `[redacted]`. This
applies to every streamed line and to what the model reads and the
transcript stores. A run holds no secret of its own; this guards against
an outside system's data carrying one.
- **Correlation.** The tool result's `details` now carry the run ID
along with the status, tying the transcript to the run's record.
- **Web.**
- `ChatView.output` holds the running step's output, built with
`applyOutput`.
- The code step shows an "Output" section while it runs, replaced by its
result.
  - Translations are added for nl, de, fr and es.

## Evidence
New tests in `apps/core/test/agent.test.ts` ("chat agent code output"),
through the real DO watch, Loader and Pi loop:
- **Streaming:**
- before the step's result message reaches the watcher, it has seen
`first\nkey [redacted]` and then `…\nafter the wait`;
  - the output goes back to `null` once the step ends;
  - the model's text is redacted the same way;
- a line sent through `codeRunOutput` after the run closed shows
nowhere.
- **Budget:** a flood of 5,000 lines of two-byte characters followed by
a throw:
- the final text is at most 64 KiB, is cut with the marker and still
ends with `Error (threw): Error: the end`;
  - the streamed output never passes 64 KiB less the 1 KiB reserve.
- **Pure helpers:** byte cuts with `é` and emoji, and the redaction
patterns.
- **Updated tests:** the existing flood and forged-prototype tests now
check the 64 KiB budget and the new marker.

In `apps/web/src/chat/follow-chat.test.ts`: the output grows, starts
afresh for another step, and drops when no step runs.

Commands:
- `vp check`: passes.
- `vp test run test/agent.test.ts test/chats.test.ts
test/run-fixes.test.ts test/agent-connections.test.ts`: 90 passed.
- `vp test run -c vite.screens.config.ts test/agent-builds.test.ts
test/agent-apps.test.ts`: 32 passed.
- In apps/web, `vp test run src/chat/follow-chat.test.ts
src/locales/locales.test.ts`: 12 passed.
- `vp build` (web, React Compiler): passes.


## Restacked on the #387 and #389 review fixes (daf0cb4)
- The harness's streaming is merged into the hardened harness. The
output callback, captured `Promise.prototype.then` and
`Promise.resolve`, and the in-order delivery are unchanged, and every
outcome is still returned rather than thrown.
- A lease closed by a revocation drops output that no watcher has had
yet. The model gets no logs of a revoked run (#389, M2).


## Review fixes (c7d3cdd)
**Must-fix: output kept streaming after a mid-run revocation.** A line
is now shown only once a read of revocations begun after it arrived
finds none. Those reads run at most once a second a run, through the
same `revokedSince` read, and fail closed. A revocation found there:
- closes the lease as `revoked`;
- drops the output, shown or pending;
- records one `agent.run_revoked` (`method: output`);
- streams nothing more.

**Should-fix:**
- **Redaction now also covers** Stripe `sk_`/`pk_`/`rk_` live and test
keys, GitHub `ghp_`/`gho_`/`ghs_`/`ghu_`/`ghr_`/`github_pat_`, Slack
`xox[abpr]-`, Google `AIza`, and the value of `api_key`, `apikey`,
`token`, `secret`, `password` or `authorization` written as `name=value`
or `name: value`. The docstring lists exactly what is covered and says
it is a net, not a guarantee.
- **The stream redacts like the model view:** core keeps each run's
lines and redacts all of them as one text.
- **Core counts lines and raw bytes itself**, and doesn't trust the
harness's totals.

**Nits:**
- The output stays until the step's result is stored (no flicker).
- `output` is left out of an update when it didn't change (`applyOutput`
keeps what was shown).
- `aria-live` is dropped.

**New tests:**
- **Revocation with no call:** the code logs, waits and logs again with
no call; the admin revokes in between. No line logged after the revoke
ever reaches the chat, and the run ends `revoked`.
- Mutation check: with the stream's revocation read disabled, this test
fails.
- **Clearing when a call finds the revocation** (`revokeCodeRun`): the
shown output goes to `null` before the step's result.
- **A PEM private key logged line by line** never shows its body in any
streamed text, and the model reads `Logs:\n[redacted]\ndone`.
- **1,500 lines sent straight to `codeRunOutput`** show as 1,000.
- **The redaction table covers the new shapes.** The token-shaped
fixtures are put together at runtime, so secretlint finds no token in
the repository.

Evidence (top of the stack):
- `vp check`: passes.
- Core: 230 passed.
- Screen-config tests: 47 passed.
- Web: 12 passed, and the web build passes.


## Delta review fix (2815b1e)
**Must-fix: quadratic JWT redaction.** The pattern started at `\b`, and
a flood like `eyJ-eyJ-…` has a word boundary at every step. Redacting
about 128K characters of that took about 4 s, inside the Workspace
object, which re-redacts output about once a second.
- The pattern now starts at `(?<![\w-])`. A real JWT still matches.
- A new test redacts floods of about 128K characters for every
credential shape, each with tails of `""`, `"x"`, `"-"` and `"."`. All
must finish within 250 ms.
- With the old pattern, that test fails (13.8 s). With the fix, it
passes. No other pattern was slow under the same floods.

**Nits:**
- A revocation read that fails is now recorded with its own reason,
`agent.revocation_read_failed`, both by the output check and at the end
of a run.
- New tests for the output check:
- **Failed read:** nothing is shown, the run ends `revoked`, and one
`agent.revocation_read_failed` is recorded.
- **Slow read:** the read takes 3 s, longer than the run, so the line is
never shown before the step's result, which shows it.

Evidence: `vp check` passes; `vp test run test/agent.test.ts
test/chats.test.ts` passes (103).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant