Skip to content

fix(webui): keep workspace writes inside the workspace - #243

Open
modacker wants to merge 1 commit into
webuifrom
fix/webui-workspace-write-real-path-fence
Open

modacker wants to merge 1 commit into
webuifrom
fix/webui-workspace-write-real-path-fence

Conversation

@modacker

@modacker modacker commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

What was broken

The runtime's workspace write path (resolveWorkspaceWritePath in
packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts)
resolves the write target with realpath and, when that fails, falls back to
the lexical spelling (realpathOrFallback).

A dangling symlink fails realpath exactly as a file that does not exist yet
does — but a write through a dangling link creates the file it points at. So the
fallback compared a path inside the workspace (<workspace>/link) against the
real root, passed, and the file was really created outside the workspace, at
whatever the link pointed at.

The upstream fix for this was merged as PR #238 and reverted in PR #240, because
it touched the upstream packages (local-runtime-v2, agent-tools) that this
branch is not allowed to change.

Why this fix has to live on the WebUI side

The branch's hard boundary is packages/webui: the sixteen upstream packages
must not diverge from MiniMax-AI/minimax-code. The same fence is therefore
reimplemented here, on the WebUI side of the boundary, where the binding that
forwards a workspace write is ours.

This is the WebUI-side equivalent of the reverted upstream fix — the same
algorithm (realpath the target; when it does not exist, resolve its nearest
existing ancestor and re-attach the missing segments; follow a dangling link
by hand
), the same refusal rules, and the same "validate only, never change
the path" discipline.

Implementation

packages/webui/src/runtime/workspace-real-path.ts (new)

  • resolveRealPath(path) — the real location of a path. realpath first; on
    ENOENT/ENOTDIR it resolves the nearest existing ancestor and re-attaches
    the missing segments. A path that is a dangling symlink is followed by hand
    (readlink + the link's own target, relative links resolved against the
    real holding directory), because that is the case realpath cannot
    express. A hop limit of 32 stops a symlink cycle.
  • isInsideDirectory(root, candidate) — path.relative-based containment
    (a string prefix would accept /tmp/evil for a root of /tmp/ev).
  • checkWorkspaceFence(root, target) — the fence. The workspace root is
    resolved with realpath too
    , because a workspace root is allowed to be a
    symlink and comparing a real target against a lexical root would refuse every
    path in such a workspace.

Refusal rules:

case verdict
target resolves inside the real root allowed
target resolves outside the real root outside
target cannot be resolved (symlink cycle, permission error) unresolvable
workspace root cannot be resolved unresolvable

Unresolvable is a refusal, not a guess.

packages/webui/src/runtime/harness/workspace.ts (modified) — the fence is
wired into the two WebUI bindings that write into a workspace:

  • applyCanvas — the canvas operation names workspace files
    (add_file.relativePath, workspace-relative, client-supplied). The root they
    are relative to is the session's workspace, which the fence asks the
    runtime for (getSession), the same root the runtime resolves on its side.
    The fence runs before cliService.applyCanvas is called.
  • extractWorkspaceArchive — the destination is a browser-chosen path that the
    extraction creates before writing anything into it. Without the fence,
    prepareExtractionRoot ran mkdir -p on the requested destination before
    its containment check, so a destination such as link/extracted (with link
    a symlink to a directory outside the workspace) created <outside>/extracted
    and was only refused afterwards. The fence now runs before the extraction
    creates anything.

Both refusals are explicit and readable, in the voice of the surface they
answer: 解压目标越界:… / 解压目标无法解析:… for the archive (the wording
the archive reader already uses) and Canvas file path escapes the workspace: …
/ Canvas file path cannot be resolved: … for the canvas (the wording
canvas-input.ts already uses).

The fence only validates. The path is handed to the runtime exactly as it
was spelled — how it is joined, and what is passed upstream, is unchanged.

What the tests cover

packages/webui/test/unit/workspace-write-fence.test.ts (new) — real temp
directories and real symlinks, because the bug is about what the filesystem
does with a link:

  • a dangling symlink pointing outside the workspace is refused, and the
    file it points at is never created. The suite first writes through the link
    directly, to show the escape is real without the fence, and removes that
    file before asserting the fenced write creates nothing outside;
  • a symlink pointing inside the workspace still works — the write lands on
    the file the link points at;
  • a workspace root that is itself a symlink works;
  • an ordinary path inside the workspace is accepted and written;
  • a symlink cycle is refused instead of followed — no hang, no crash;
  • a workspace root that cannot be resolved is refused;
  • an archive destination under a symlinked ancestor is refused, and the
    directory the extraction would have created outside the workspace does not
    appear;
  • an archive destination that is such a symlink is refused as well.

The harness half of the suite proves the fence runs before the runtime is
asked to write: the host is never called for a path that leaves the workspace,
and no file appears outside it. All three refusal tests fail without the
fence
— they were checked by removing the two fence calls: the canvas case
then resolved instead of rejecting, the archive case then created the directory
outside the workspace.

Known boundaries

  • The upstream realpathOrFallback is still live. A write that does not pass
    through a WebUI binding is still exposed there; this fence is the WebUI
    side's own protection, not a replacement for the upstream fix.
  • The fence compares a realpath with the write that follows it. A symlink
    swapped in between the two is still a race — the same window the upstream
    fix has.
  • A shell run in the workspace is out of scope.
  • The canvas fence needs the session's workspace root. A host that cannot
    answer getSession leaves the root unknown, and the fence is skipped for
    that call — the runtime then resolves the canvas path against the session's
    own workspace and refuses a path that is not inside it.
  • The runtime's own canvas service already resolves add_file.relativePath
    with realpath and requires an existing file inside the workspace, so the
    canvas half of this fence is a second, independent gate in front of that
    call rather than the gate that closes a currently reachable canvas escape.
    The archive half is the one that closes a currently reachable WebUI-side
    escape.

How it was validated

node scripts/run-vitest-suite.mjs webui          # 207 passed | 5 skipped (212 files), 3248 tests passed
pnpm --filter @mavis/webui typecheck:server      # passed
pnpm --filter @mavis/webui typecheck:test        # passed
pnpm typecheck                                   # passed
node scripts/check-webui-dependency.mjs          # passed
node scripts/source-inventory.mjs                # passed

release/public-source.json was regenerated with
node scripts/source-inventory.mjs --write; the new vitest suite is
registered in test/vitest-suites.json; the frozen path-metadata oracle
(packages/webui/test/gates/fixtures/path-metadata-f00.json) gained the one
new source path, classified with the frozen baseline classifier.

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
modacker force-pushed the fix/webui-workspace-write-real-path-fence branch from 06163a8 to 74edf24 Compare October 23, 2026 08:09
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