- Updated task tool execution to pin live regions and drop partial snapshots once rows commit.
- Tracked background task frozen styled rows and render timestamps to prevent clock drift on committed history.
- Added tests verifying detached and blocking task progress do not duplicate rows in scrollback.
The bridge is constructed once, at session creation, and was handed the
startup `cwd` by value. The session's own cwd moves under it — `/cd`,
resume, branch restore all call `sessionManager.moveTo` — and the two
frames that confine a path themselves (the native `delete`, and a
`read_mcp_resource` carrying `download_path`) resolve against whichever cwd
the bridge holds. So after a move the primary deleted or overwrote the
relative path in the workspace the session had left, and reported success
for the path the server actually named.
The advisor bridge already passed a live resolver; this is the same
resolver on the path that was missed. Locked by a wiring test: the seam is
the session handing its handlers to the provider, so the test captures them
there, moves the session, and asserts the frame acts on the new workspace
and leaves the old file alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014oA3H7aHUL85ydp9PJ3ryF
(cherry picked from commit 079c7ac61104d017eecbf781aa1c58eebd39b0b1)
A `download_path` naming a FIFO hung the turn outright. The target is
opened write-only, which on POSIX blocks until a reader attaches, so the
`isFile()` refusal sitting behind that open was unreachable — the open
never returned. The path comes from the server, so this needed no planted
file to reach, only a named pipe where a download was aimed. Opening
non-blocking turns a readerless pipe into an immediate refusal and leaves
the existing guard to reject one that has a reader; the flag is inert on
regular files, which is every legitimate target. (The repo already fixed
this shape once, for discovery context-file reads, by stat-gating; the
flag closes the same hole without the stat's TOCTOU window.)
A `pi_grep` that hit the native backend's own match ceiling answered as an
unqualified success. `GrepTool` folds that cap into the flat
`details.truncated` and sets neither `details.truncation` nor
`perFileLimitReached` — the two fields the Pi result reads — so the one
truncation a caller can neither detect nor page around was the one it was
never told about. The flat flag now translates into a `PiTruncation`, and
only once the specific counters came back empty, so a cap that already
reported itself is never restated.
Both regressions are locked: the FIFO test detects a relapse by timing out
rather than by a failed assertion, since a relapse never reaches the
assertion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014oA3H7aHUL85ydp9PJ3ryF
(cherry picked from commit 20438ff68cf9c8aaaec30703f5b9972c7bda205e)
`runs advisor tools through the approval gate` built its advisor from the
`advisor` role chain, which resolves against `modelRegistry.getAvailable()`
— the models the host holds auth for. On a developer box whose environment
carries provider keys the roster resolved and the test passed; in CI, where
the suite's isolated auth storage is empty, every advisor resolved to
`no_model` and `getAdvisorAgent()` returned undefined ("expected an advisor
agent").
The advisor now names `gpt-4o-mini` outright and runs inside the file's
`withProviderAuth` helper, so the roster resolves from the granted key
rather than from whatever the machine happens to have configured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014oA3H7aHUL85ydp9PJ3ryF
(cherry picked from commit ef2054da5dbe3d3d1cca4025e12e5c350f47173d)
Three remaining review findings:
- `list_mcp_resources` frames a handler answered now synthesize a
`list_mcp_resources` block and pair a result derived from the same
answer sent on the wire; the streamed `ListMcpResourcesToolCall` /
`ReadMcpResourceToolCall` announcements join the exec-owned set so
they cannot double-render. No-handler frames still synthesize
nothing, since nothing ran.
- Advisors receive the same `MCPManager`-backed resource adapter as the
primary bridge, so their `list_mcp_resources` no longer reports every
server as empty and `read_mcp_resource` no longer answers `not_found`
against live connections the advisor shares.
- An unavailable `pi_edit`/`pi_write` answers with the protocol's
`rejected` variant instead of `error`: refusal and failure are
separate oneof cases, and a denial reported as an execution error
invites a retry of an operation that was never permitted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSWZTe6YA2PX1cqtukZvYi
(cherry picked from commit 47ce936c8df05d6970504af19e5ef7d2e8c38d7b)
Forwarding the legacy read/grep frames' range and page fixed only the
execution: the transcript block was built from a second translation and
still showed a bare path and an unskipped search. That block is what a
reloaded session replays, so a slice read as the whole file and a later
window presented as page one. Both now come from the shared helpers,
`limit: 0` included -- recorded as the zero lines it returns.
The approval probe answered `approved` unconditionally, which laundered a
configured `deny` into a server-side blessing. It now resolves through a
bridge preflight against the same policy the wrapper applies at execution
time: approved only for a definite allow, refused for a deny, for a mode
demanding a prompt this frame cannot raise, and for an unknown tool.
Still never executes.
Comments and changelog no longer assert server-side semantics for
`range_applied`/`offset_applied`; they describe what the client did,
which is all the proto establishes.
(cherry picked from commit 5ac1870f6d67bf365c84b1affae63c89de32d1cb)
Advisor tools are built straight from the builtin table, outside the
loop that wraps every registry tool in `ExtensionToolWrapper` - which is
where the approval mode, per-tool `tools.approval.<tool>` policies and
`autoApprove` are enforced. Both the advisor's own agent loop and its
Cursor exec bridge (`pi_write`, `pi_bash`) run those instances directly,
so an advisor granted `write` or `bash` executed them regardless of a
configured `ask` or `deny`. Verified before the fix: a raw `write`
instance created the file under `tools.approval.write: deny`; the
wrapped one refuses. `bridgeToolMap` and the grep factory only ever
wrapped the two tools they build themselves.
The grep answer also now echoes `offset_applied`. Forwarding the offset
without acknowledging it leaves the server unable to distinguish a
honored page from a client that ignored the field, so it re-paginates
from the same place. Set on all three result variants (files, count,
content); absent when the frame requested no offset.
(cherry picked from commit 13c2ed565f30ff86e31c8dc9896f940faf3aa088)
Modern Cursor builds paginate the legacy `read` and `grep` frames with
fields this branch modeled in the proto but never wired.
`read` composed no range, so every page returned the whole file (or its
own truncation) and a model walking a large file never advanced past the
first window. It now goes through `piReadPath`, the same helper the Pi
frame uses, so both translate a range identically - including the
`limit: 0` case, which asks for zero lines and has no selector. The
answer reports `range_applied`, left false for an unranged read since
that is precisely the server's "this is the whole file".
`grep` dropped its `offset`. The local tool paginates by file through
`skip` and advertises exactly that unit in its own "use skip=N" advice,
so an unforwarded offset re-ran the identical search and answered page
one forever. A present `0` stays unset: it means "start at the
beginning", which is the un-skipped search.
(cherry picked from commit 4f647d86b6e4a507d57fb4d247f19db8240e51cf)
`ReadMcpResourceExecResult` has a `rejected` variant carrying `reason`,
not `error`, so the pairing text's collapsed error branch did not
typecheck against the full union. Each variant is now switched
explicitly; the refusal is unreachable today (the handler answers
content or `null`) but a collapsed default would have read `undefined`
if the client ever builds one.
`bun check` type-checks the workspace projects; the union error only
surfaced through `ci:check:full`, which is what CI runs.
Also renames a loop variable that shadowed the global `escape`, which
was failing `biome check` on the full tree.
(cherry picked from commit f5dca418d91983a14492f51f2873514781a9e066)
Every native `pi_edit` failed after a session switched onto Cursor. The
replace-mode `edit` instance the frame needs was built only for sessions
CREATED on Cursor, and the tool roster is built once, at creation - a
session that started elsewhere kept its configured-mode `edit` in the
registry, which `executeTool` resolves before its fallback, so the
frame's `old_text`/`new_text` pairs failed validation against a
`hashline` schema.
The instance is now built from the `edit` grant regardless of the
initial provider, lazily so a session that never reaches Cursor never
constructs one, and `pi_edit` asks for it through a dedicated
`getEditReplaceTool` accessor rather than relying on Cursor sessions
having deleted `edit` from the registry. A session that was never
granted `edit` is still refused.
That accessor also closes an escalation the previous wiring opened up.
The session's device resolver is handed to the bridge as `getTool` and
installed as the agent loop's `resolveFallbackTool`, which runs for ANY
call outside the advertised set - so serving `edit` from it let a
hallucinated call, or one naming a tool the session deselected after
startup, execute a replace-mode edit the model was never offered. It is
device-only again.
Regressions cover both directions at the SDK level, driving a real
unadvertised `edit` through the loop and asserting the surfaced
`Tool edit not found`: an unchanged file alone would also pass if the
fallback had resolved the tool and the edit then failed validation.
(cherry picked from commit 11a28dcf7b995a9e94913269733b3199d6f4790d)
Download-mode resource reads created and overwrote workspace files
without running a registry tool - the same hole the native `delete`
frame had - so a session that withheld `write`/`edit`, or whose `write`
tier is `deny`/`always-ask`, still had files written. Both frames now
share one grant and one policy check, and the download refuses before
the read so a blocked call never fetches the resource.
`allowNativeDelete` is renamed `allowDirectFileMutation`: it now gates
more than deletion. The primary session derives it from the registry
BEFORE its own rewriting (Cursor moves `edit` out of the tool map and
`write` may be auto-registered later, so reading the map at bridge
construction would misjudge both) and unconditionally, since the bridge
is installed for every session and one that starts on another provider
can switch to Cursor later.
`pi_grep` with a match cap: the local tool windows to 20 files and
suggests `skip`, which `PiGrepExecArgs` cannot express - 100 matches
requested over 25 one-match files returned 20, with the cap reported
unreached. A capped search now reads cap+1 files, so a result landing
exactly on the cap is distinguishable from a clipped one, and
`match_limit_reached` is truthful either way.
`read_mcp_resource` synthesized no transcript block and paired no
result, so a read - including a download that mutates the workspace -
was invisible in the UI and stripped from every rebuilt history. It now
synthesizes a `read_mcp_resource` block (not `read`: the name drives
rendering and prune semantics) and pairs success, not-found and error.
(cherry picked from commit 5ff27a3efe8bec522d9d5dbd7763055eb03eae3b)
Hard link escape: a hardlink inside the workspace is a regular file
that passes containment AND `O_NOFOLLOW` while sharing its inode with
a file anywhere else, so truncating it clobbers that file. Proven
before the fix. The open now drops `O_TRUNC`, checks `nlink`/regular
on the OPEN handle, and truncates only after - the pattern
`autolearn/managed-skills.ts` already uses. `O_NOFOLLOW` covers the
final component only; the parent-swap window is documented, not
claimed shut.
MCP resource discovery: `getServerResources` is async and awaits
`ensureServerResources`, so a frame arriving while a server's catalog
still loads no longer reads the empty cache and reports "advertises
nothing" - a lie the model cannot distinguish from the truth.
Mixed-content reads: the mime type came from `contents[0]` while the
payload came from whichever item supplied it, so an image blob
followed by a text note sent the text as `image/png`.
Ranged `pi_read`: a plain `:N+K` selector pads one leading and three
trailing context lines, so offset 5/limit 20 handed Cursor lines 4-27.
Ranged reads compose `:raw:N+K`, verified against a real `ReadTool`.
The wire result is an opaque string, so the gutter `raw` drops is not
part of the contract.
(cherry picked from commit 679785aa6b3243ea39b26abf4dda9435960019b1)
A lexical containment check is not containment. `out/config` is
relative and `..`-free, so it passed - while a `ws/out -> /elsewhere`
link inside the workspace sent the write straight out. Proven before
the fix: the download landed in the link's target.
Containment now realpaths three things: the target when it exists, the
immediate link destination when it is a dangling symlink (a write still
follows it), and otherwise the deepest existing ancestor with the
not-yet-created segments re-applied. Each branch has a regression, and
all three fail the suite when individually reverted.
Also moves this branch's ai/catalog changelog entries back under
[Unreleased]; two commits had re-landed them inside the released
[17.1.5] section, which left `packages/ai/CHANGELOG.md` with two.
All three released sections are now byte-identical to upstream/main.
(cherry picked from commit e3ed4035ab8a1781c49a68c1fbe92c88bec3aa25)
`download_path` is workspace-relative by contract, but it arrives from
the server and `resolveToCwd` deliberately honors absolute paths, `~`,
and `..` - correct for a path a user typed, a write-anywhere primitive
for one a remote peer supplied. `/etc/cron.d/x` or `../../escape` would
have been written wherever the process can reach.
`confineToWorkspace` accepts only a non-empty relative path resolving
under the live cwd, and the download refuses anything else. The refusal
throws inside the dispatch's existing try, so it reaches the model as a
`ReadMcpResourceError` rather than a silent success or a crash.
(cherry picked from commit 963cfee21a56576033ec115db745bb18ab0a8d06)
`ReadMcpResourceExecArgs.download_path` means "write the resource to
this workspace-relative path and return no model content". The handler
I added forwarded only server and uri, so a download reported success
while creating no file and leaving `ReadMcpResourceSuccess.download_path`
unset - the model was pointed at a path that did not exist.
The path now reaches the handler, the bridge writes the bytes (decoding
a base64 blob, or the joined text) under the session cwd, and the
answer carries the path with the content oneof deliberately unset: a
host that also has the payload on hand must not have it forwarded, or
the download mode puts it right back in context.
(cherry picked from commit f0a6784533201f412529fb0ae5a6542531012197)
The bridge looked for a flat `details.resultLimitReached`. `glob` sets
that alongside the structured meta, so `pi_find` worked; `read` - the
tool serving `pi_ls` - records the cap only through `OutputMeta`, at
`details.meta.limits.resultLimit.reached`. Every capped listing
therefore reached Cursor with `entry_limit_reached` unset, which reads
as a complete listing.
Both shapes are now checked, mirroring how `piTruncation` already
handles its two producers. Covered by running the real `ReadTool`
against a directory that trips the per-directory cap and asserting the
wire field, so a move in the producer's shape fails here instead of
silently sending clipped output as whole.
(cherry picked from commit cbfe7ca9d59faa2f967e2724744b58a96a9a1839)
`list_mcp_resources` / `read_mcp_resource` answered as though this
client hosted no MCP servers - a hardcoded empty catalog and
`not_found`. The same session reads those resources through `mcp://`
via `MCPManager.getServerResources` / `readServerResource`, so a Cursor
model could not see resources its own session was connected to.
`CursorExecHandlers` gained `listMcpResources`/`readMcpResource`, the
bridge answers them from the manager's live connections, and the
no-handler fallback is unchanged. A throwing lookup surfaces as an
error: an empty success claims "asked, none exist", which the model
cannot retry.
The native `delete` frame also bypassed approval. Unlike every other
frame it calls `fs.rmSync` directly rather than running a registry
tool, so no `ExtensionToolWrapper` sat in front of it, and
`allowNativeDelete` only answers whether a mutating tool was granted -
not whether the user's policy allows the call. It now resolves the
write tier against the session's approval mode and per-tool policies,
failing closed on `always-ask`, which this channel cannot prompt in.
(cherry picked from commit 44d36d1e8d35b0038d00e0202454d68fbcc53bce)
Two independent bugs found in review.
A stream that dies mid-turn takes the terminal-error path: `settleH2`
rejects when the transport closes without `turnEnded`, so the flush on
the success path never runs. `connect_scm` and native todo blocks are
stamped `kCursorExecResolved` at start, so `agent-loop.ts` synthesizes
no placeholder and only their completion frame pairs a result - the call
was left unpaired and its card animating, and `buildSessionContext`
strips a dangling call from every rebuilt transcript. The catch path now
closes open blocks and pairs those server-owned calls with an
interrupted result. Exec-settled MCP blocks are excluded: the dispatch
that ran them owns their result and `drainInFlightDispatches` awaits it,
so pairing here would duplicate against the same id.
Separately, the advisor bridge supplied no `getToolContext`.
`ExtensionToolWrapper` reads the approval mode, per-tool policies and
`autoApprove` only from that execute-time context, so every wrapped
advisor bridge tool resolved as `yolo` with empty policies - a
configured `ask` or `deny` on `edit`/`grep` did not apply to native
frames. Advisors now get the same `ToolContextStore` as the primary
bridge.
Both are covered against the real paths: the interrupted call through
the HTTP/2 fixture server (a helper-level test passes even with the
catch-path flush removed), and approval through real deny policies.
(cherry picked from commit 5ace682578af96708caf88db2d44b9077a1c0e74)
The primary bridge builds a `replace`-mode `EditTool` because
`PiEditExecArgs` carries `old_text`/`new_text` pairs that no other mode
accepts. The advisor roster passed its own instances straight through,
and those follow the session's configured `edit.mode` - `hashline` by
default, whose schema is a single `input` string - so every native
advisor edit failed validation instead of touching the file.
Both bridge-only tools now come from `cursor-bridge-tools.ts`:
`createBridgeEditTool` builds the wrapped `replace` instance, and
`bridgeToolMap` substitutes it into a granted map. The substitution is
gated on `edit` actually having been granted, since the tool is
constructed rather than looked up - handing one to a read-only roster is
the #5680 escalation. The advisor's own loop keeps its instance; only
the exec map is swapped.
(cherry picked from commit e6cf9f8046c595cab9793b4b2a5795d8488d8d22)
The rebase onto a tree where 17.1.5 had been cut landed this branch's
entries inside that released section. Released sections are immutable,
so they belong under [Unreleased] — the release script folds them into
whatever version ships next.
Both released sections are now byte-identical to upstream/main, no
bullet was dropped in the move, and the `Added` heading the
coding-agent entry needs came back with it.
(cherry picked from commit c1c6566dccb7255395e463c940cca6c76a707a9d)
Both are constructed rather than looked up, and `executeTool` prefers a
constructed override over the registry — so a session that withheld
either still got a working frame. Native `pi_edit`/`pi_grep` arrive
regardless of the advertised catalog, so a restricted agent
(`toolNames` without them, or `restrictToolNames: true`) could modify
and search files it was never granted.
Both now check the registry for the grant before building. The edit
check reads it before the Cursor-specific delete, since that delete is
about not advertising the tool, not about revoking it. This is the same
escalation the `delete` frame already guards against (#5680); the
advisor path got the grep gate in the previous commit and the primary
session was missed.
(cherry picked from commit 68f82b0ce08bb93db941e0fbe1bd2a515d45c2cb)