Skip to content

revert(upstream-packages): keep the webui branch's local changes inside packages/webui - #240

Merged
modacker merged 1 commit into
webuifrom
revert/upstream-package-changes
Oct 23, 2026
Merged

modacker merged 1 commit into
webuifrom
revert/upstream-package-changes

Conversation

@modacker

Copy link
Copy Markdown
Collaborator

Why

The webui branch is the downstream client of MiniMax-AI/minimax-code. The
upstream owns every package except packages/webui and
packages/browser-extension, and the sync direction is upstream → fork
(33ab349f chore(sync): merge upstream main into webui).

A change to an upstream package here does not flow back upstream. It becomes
unreviewed divergence at the next upstream sync, and the defect stays open in
upstream. So local work on this branch belongs inside packages/webui, plus
the documentation, tests and gates.

What this removes

The upstream-package half of the work merged today:

Path Came from
packages/local-runtime/src/turns/file-changes.ts #232, #234
packages/local-runtime/test/unit/diff-capture-symlink-containment.test.ts #234
packages/local-runtime/test/unit/diff-mutation-symlink-containment.test.ts #232
packages/agent-tools/src/shared/workspace-real-path.ts #238
packages/agent-tools/src/desktop/workspace-fence.ts (+ its test) #238
packages/agent-tools/src/desktop/path-guard.ts (+ its test) #238
packages/agent-tools/src/desktop/local-pi-tools.ts #238
packages/agent-tools/src/cloud/matrix-tools/path-guard.ts #238
packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts (+ its test) #238
packages/local-runtime-v2/src/application/conversation/conversation-application.ts #237
packages/local-runtime-v2/src/service/session-system/stream/session-stream-service.ts (+ its test) #237

Each file is restored to the state it had immediately before the change that
touched it — the parent of that change, not the start of the day, so the
upstream sync (33ab349f) is preserved rather than undone.

The inventories follow the files: eight paths leave
release/public-source.json (regenerated with
node scripts/source-inventory.mjs --write, not hand-edited) and six suites
leave test/vitest-suites.json.

What stays

Everything under packages/webui:

What this gives up

The upstream packages go back to the state upstream has, so the defects those
changes addressed are open again in this fork:

  • the turn-diff mutation target can escape the workspace through a symlink
    (upstream local-runtime/src/turns/file-changes.ts)
  • the turn-diff capture reads content outside the workspace
  • the agent read / write / edit tools resolve a path lexically, so a
    symlink inside the workspace reaches outside it
  • local-runtime-v2 writes fall back to the lexical path when resolution
    fails

The WebUI client cannot close those on its own. It does not sit on the write
path, on the capture path or on the tool path, and the runtime host options
expose no path or tool policy hook — systemPromptOverrides is prompt text,
which is advice, not a boundary.

This is stated here so the decision is on the record: the boundary and the
security fix cannot both be satisfied inside this branch. The options are to
carry the fix here, or to file it upstream and take it back through the sync.

…de packages/webui

The webui branch is a downstream client of MiniMax-AI/minimax-code. Changes
to the upstream packages do not flow back upstream and turn into unreviewed
divergence at the next upstream sync, so they do not belong here.

This removes the upstream-package half of the work merged today:

- packages/local-runtime/src/turns/file-changes.ts (#232, #234)
- packages/agent-tools/** (#238)
- packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts (#238)
- packages/local-runtime-v2/src/application/conversation/conversation-application.ts
  and .../stream/session-stream-service.ts (#237)

Everything under packages/webui stays: the queue-drain client half, the
regenerated visual baselines, the browser test budgets and the CJK font gate.

The inventories follow the files: eight removed paths leave
release/public-source.json and six removed suites leave
test/vitest-suites.json.
@modacker
modacker merged commit 39a168d into webui Oct 23, 2026
16 of 20 checks passed
@modacker
modacker deleted the revert/upstream-package-changes branch October 23, 2026 07:41
modacker pushed a commit that referenced this pull request Oct 23, 2026
What was broken: the upstream runtime's workspace write path
(`resolveWorkspaceWritePath` in
`packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts`)
falls back to the lexical spelling when `realpath` fails. A dangling symlink
fails `realpath` exactly as a file that is not there yet does, but a write
through a dangling link creates the file it points at — so the write landed
outside the workspace while the check compared a path inside it. The upstream
fix for this was merged and then reverted (#240), because it touched packages
this branch does not own.

Why this fix: the same fence is reimplemented on the WebUI side, where the
binding that forwards a workspace write is ours.
`packages/webui/src/runtime/workspace-real-path.ts` resolves a path to the
location the filesystem really uses for it — following symlinks, and following
dangling ones by hand — and compares it against the real workspace root. A
path that cannot be resolved at all (a symlink cycle, a permission error, a
missing root) is refused rather than guessed at, and a symlink that stays
inside the workspace keeps working.

The fence is wired into the two WebUI bindings that write into a workspace:
`applyCanvas`, whose operation names workspace files, and
`extractWorkspaceArchive`, whose destination is created before anything is
written into it. It validates only: the path reaches the runtime exactly as it
was spelled, and the refusal is reported in the WebUI's own voice.

How it was verified: with real temp directories and real symlinks. A dangling
symlink pointing outside the workspace is refused and the file it points at is
never created; a symlink pointing inside the workspace keeps working; a
workspace root that is itself a symlink keeps working; an ordinary path is
accepted; a symlink cycle is refused instead of followed. The harness half of
the suite proves the runtime is never called for a path that leaves the
workspace — both refusals fail without the fence.

Known boundaries: the upstream `realpathOrFallback` is still live, so a write
that does not pass through a WebUI binding is still exposed there. The fence
cannot close the gap between its `realpath` and the write itself, so a symlink
swapped in between the two is still a race. A shell run in the workspace is
out of scope.
modacker pushed a commit that referenced this pull request Oct 23, 2026
What was broken: the upstream runtime's workspace write path
(`resolveWorkspaceWritePath` in
`packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts`)
falls back to the lexical spelling when `realpath` fails. A dangling symlink
fails `realpath` exactly as a file that is not there yet does, but a write
through a dangling link creates the file it points at — so the write landed
outside the workspace while the check compared a path inside it. The upstream
fix for this was merged and then reverted (#240), because it touched packages
this branch does not own.

Why this fix: the same fence is reimplemented on the WebUI side, where the
binding that forwards a workspace write is ours.
`packages/webui/src/runtime/workspace-real-path.ts` resolves a path to the
location the filesystem really uses for it — following symlinks, and following
dangling ones by hand — and compares it against the real workspace root. A
path that cannot be resolved at all (a symlink cycle, a permission error, a
missing root) is refused rather than guessed at, and a symlink that stays
inside the workspace keeps working.

The fence is wired into the two WebUI bindings that write into a workspace:
`applyCanvas`, whose operation names workspace files, and
`extractWorkspaceArchive`, whose destination is created before anything is
written into it. It validates only: the path reaches the runtime exactly as it
was spelled, and the refusal is reported in the WebUI's own voice.

How it was verified: with real temp directories and real symlinks. A dangling
symlink pointing outside the workspace is refused and the file it points at is
never created; a symlink pointing inside the workspace keeps working; a
workspace root that is itself a symlink keeps working; an ordinary path is
accepted; a symlink cycle is refused instead of followed. The harness half of
the suite proves the runtime is never called for a path that leaves the
workspace — both refusals fail without the fence.

Known boundaries: the upstream `realpathOrFallback` is still live, so a write
that does not pass through a WebUI binding is still exposed there. The fence
cannot close the gap between its `realpath` and the write itself, so a symlink
swapped in between the two is still a race. A shell run in the workspace is
out of scope.
modacker pushed a commit that referenced this pull request Oct 23, 2026
A turn's diff can name a file outside the workspace. The runtime's capture
layer records an out-of-workspace write as an absolute path, so the entry
reaches the client with that file's content attached — and the client
rendered it: the transcript's diff card, the workspace turn-review panel and
the workspace review page each put the patch text on screen.

The fix that stopped the collection at the source was made upstream in
`packages/local-runtime/src/turns/file-changes.ts` and reverted in PR #240,
because it changed a package this fork may not change. The boundary is
therefore drawn on the client side, which is the last point before the
content reaches a screen.

A diff's file entries are now projected through that boundary before they
reach a surface. An entry whose path resolves outside the workspace keeps
its name, its status and its line counts, and loses its body: the diff text,
the patch hunks, the file's content. The surface says what is withheld
instead of showing it. The entry itself stays in the list — "which file was
touched" is the part a person can still act on.

The comparison is lexical, on posix-style strings, the same normalization
the message file-reference reader uses. An absolute path is compared with the
root segment by segment, so a sibling directory that shares the root's name
is outside; a relative path is refused when it climbs out with `..`.

Two limits are stated rather than papered over, and ADR 0024 and the tests
say so out loud:

  * A symlink inside the workspace that points outside it stays contained
    here. The entry's path is a relative one and the browser has no
    filesystem to resolve it against; telling the two apart needs `realpath`
    on the runtime side.
  * An unknown workspace root is not a boundary. The entries are shown as
    they are, because a check that hid everything it could not place would
    hide ordinary content too.

The upstream half of this is still open: nothing here stops the runtime from
sending an out-of-workspace file's patch to the browser, only from the
browser showing it. The client stops the content from being displayed; only
the upstream change stops it from being collected.
modacker pushed a commit that referenced this pull request Oct 23, 2026
What was broken: the upstream runtime's workspace write path
(`resolveWorkspaceWritePath` in
`packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts`)
falls back to the lexical spelling when `realpath` fails. A dangling symlink
fails `realpath` exactly as a file that is not there yet does, but a write
through a dangling link creates the file it points at — so the write landed
outside the workspace while the check compared a path inside it. The upstream
fix for this was merged and then reverted (#240), because it touched packages
this branch does not own.

Why this fix: the same fence is reimplemented on the WebUI side, where the
binding that forwards a workspace write is ours.
`packages/webui/src/runtime/workspace-real-path.ts` resolves a path to the
location the filesystem really uses for it — following symlinks, and following
dangling ones by hand — and compares it against the real workspace root. A
path that cannot be resolved at all (a symlink cycle, a permission error, a
missing root) is refused rather than guessed at, and a symlink that stays
inside the workspace keeps working.

The fence is wired into the two WebUI bindings that write into a workspace:
`applyCanvas`, whose operation names workspace files, and
`extractWorkspaceArchive`, whose destination is created before anything is
written into it. It validates only: the path reaches the runtime exactly as it
was spelled, and the refusal is reported in the WebUI's own voice.

How it was verified: with real temp directories and real symlinks. A dangling
symlink pointing outside the workspace is refused and the file it points at is
never created; a symlink pointing inside the workspace keeps working; a
workspace root that is itself a symlink keeps working; an ordinary path is
accepted; a symlink cycle is refused instead of followed. The harness half of
the suite proves the runtime is never called for a path that leaves the
workspace — both refusals fail without the fence.

Known boundaries: the upstream `realpathOrFallback` is still live, so a write
that does not pass through a WebUI binding is still exposed there. The fence
cannot close the gap between its `realpath` and the write itself, so a symlink
swapped in between the two is still a race. A shell run in the workspace is
out of scope.
modacker added a commit that referenced this pull request Oct 23, 2026
What was broken: the upstream runtime's workspace write path
(`resolveWorkspaceWritePath` in
`packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts`)
falls back to the lexical spelling when `realpath` fails. A dangling symlink
fails `realpath` exactly as a file that is not there yet does, but a write
through a dangling link creates the file it points at — so the write landed
outside the workspace while the check compared a path inside it. The upstream
fix for this was merged and then reverted (#240), because it touched packages
this branch does not own.

Why this fix: the same fence is reimplemented on the WebUI side, where the
binding that forwards a workspace write is ours.
`packages/webui/src/runtime/workspace-real-path.ts` resolves a path to the
location the filesystem really uses for it — following symlinks, and following
dangling ones by hand — and compares it against the real workspace root. A
path that cannot be resolved at all (a symlink cycle, a permission error, a
missing root) is refused rather than guessed at, and a symlink that stays
inside the workspace keeps working.

The fence is wired into the two WebUI bindings that write into a workspace:
`applyCanvas`, whose operation names workspace files, and
`extractWorkspaceArchive`, whose destination is created before anything is
written into it. It validates only: the path reaches the runtime exactly as it
was spelled, and the refusal is reported in the WebUI's own voice.

How it was verified: with real temp directories and real symlinks. A dangling
symlink pointing outside the workspace is refused and the file it points at is
never created; a symlink pointing inside the workspace keeps working; a
workspace root that is itself a symlink keeps working; an ordinary path is
accepted; a symlink cycle is refused instead of followed. The harness half of
the suite proves the runtime is never called for a path that leaves the
workspace — both refusals fail without the fence.

Known boundaries: the upstream `realpathOrFallback` is still live, so a write
that does not pass through a WebUI binding is still exposed there. The fence
cannot close the gap between its `realpath` and the write itself, so a symlink
swapped in between the two is still a race. A shell run in the workspace is
out of scope.

Co-authored-by: probe <probe@local>
modacker pushed a commit that referenced this pull request Oct 23, 2026
A turn's diff can name a file outside the workspace. The runtime's capture
layer records an out-of-workspace write as an absolute path, so the entry
reaches the client with that file's content attached — and the client
rendered it: the transcript's diff card, the workspace turn-review panel and
the workspace review page each put the patch text on screen.

The fix that stopped the collection at the source was made upstream in
`packages/local-runtime/src/turns/file-changes.ts` and reverted in PR #240,
because it changed a package this fork may not change. The boundary is
therefore drawn on the client side, which is the last point before the
content reaches a screen.

A diff's file entries are now projected through that boundary before they
reach a surface. An entry whose path resolves outside the workspace keeps
its name, its status and its line counts, and loses its body: the diff text,
the patch hunks, the file's content. The surface says what is withheld
instead of showing it. The entry itself stays in the list — "which file was
touched" is the part a person can still act on.

The comparison is lexical, on posix-style strings, the same normalization
the message file-reference reader uses. An absolute path is compared with the
root segment by segment, so a sibling directory that shares the root's name
is outside; a relative path is refused when it climbs out with `..`.

Two limits are stated rather than papered over, and ADR 0024 and the tests
say so out loud:

  * A symlink inside the workspace that points outside it stays contained
    here. The entry's path is a relative one and the browser has no
    filesystem to resolve it against; telling the two apart needs `realpath`
    on the runtime side.
  * An unknown workspace root is not a boundary. The entries are shown as
    they are, because a check that hid everything it could not place would
    hide ordinary content too.

The upstream half of this is still open: nothing here stops the runtime from
sending an out-of-workspace file's patch to the browser, only from the
browser showing it. The client stops the content from being displayed; only
the upstream change stops it from being collected.
modacker added a commit that referenced this pull request Oct 23, 2026
…244)

* fix(webui): withhold the body of a diff entry outside the workspace

A turn's diff can name a file outside the workspace. The runtime's capture
layer records an out-of-workspace write as an absolute path, so the entry
reaches the client with that file's content attached — and the client
rendered it: the transcript's diff card, the workspace turn-review panel and
the workspace review page each put the patch text on screen.

The fix that stopped the collection at the source was made upstream in
`packages/local-runtime/src/turns/file-changes.ts` and reverted in PR #240,
because it changed a package this fork may not change. The boundary is
therefore drawn on the client side, which is the last point before the
content reaches a screen.

A diff's file entries are now projected through that boundary before they
reach a surface. An entry whose path resolves outside the workspace keeps
its name, its status and its line counts, and loses its body: the diff text,
the patch hunks, the file's content. The surface says what is withheld
instead of showing it. The entry itself stays in the list — "which file was
touched" is the part a person can still act on.

The comparison is lexical, on posix-style strings, the same normalization
the message file-reference reader uses. An absolute path is compared with the
root segment by segment, so a sibling directory that shares the root's name
is outside; a relative path is refused when it climbs out with `..`.

Two limits are stated rather than papered over, and ADR 0024 and the tests
say so out loud:

  * A symlink inside the workspace that points outside it stays contained
    here. The entry's path is a relative one and the browser has no
    filesystem to resolve it against; telling the two apart needs `realpath`
    on the runtime side.
  * An unknown workspace root is not a boundary. The entries are shown as
    they are, because a check that hid everything it could not place would
    hide ordinary content too.

The upstream half of this is still open: nothing here stops the runtime from
sending an out-of-workspace file's patch to the browser, only from the
browser showing it. The client stops the content from being displayed; only
the upstream change stops it from being collected.

* test(webui): stop the agent transcript case from racing three writes it reads

The case reads three things that are written by someone else — the runtime's
active turn, the client's captured request frames, and the runtime's event
stream — and it read all three once, at a moment that is not the moment the
write lands.

The turn before the cancellation step is over when its answer is on screen,
but the runtime retires the turn a moment later, and the case sent the next
message into that gap. A session whose turn has not been retired does not
start a turn: the message is queued instead, so it never reaches the model
and the case waits for an event that will not come. The case now waits for
the turn to be retired, the way it already does before its other sends.

The plan-entry request was read out of the frame capture once, right after
the answer that answers it had rendered. The capture is now polled, the way
the persisted history next to it is already polled.

The runtime's event watch was torn down the moment the turn went inactive —
which is exactly when the runtime reports the failure — so an event still in
flight when the watch was aborted was lost, and the comparison that follows
had nothing on the runtime side to compare the client's event against. The
watch now stays open until both sides carry the error. The turn retries the
failing model call, so the runtime reports the failure more than once and
the first report is not necessarily the one the client saw; the pairing now
looks at every error each side reported, and the two views still have to name
the same turn.

How it was verified: the case was run repeatedly on the same machine. Before
the change it failed on the cancellation gate in about two runs out of five,
and the model server never received the message at all — the runtime never
started a turn for it. The same case fails the same way on the trunk without
this branch, so the flake is not this branch's; the change removes the races
the case was reading through and leaves the assertions as they were.

---------

Co-authored-by: probe <probe@local>
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