docs/multi-user-plan.md lists the system operations as admin-only, but
these routes had no role check, so any signed-in regular user could run
them in multi-user mode:
- POST /api/auth/revoke with an empty body cleared EVERY login session on
the server, every admin's included. A non-admin now revokes only their
own logins, and a named sessionToken only when it is one of theirs.
- POST /api/system/update (update and restart the server for everyone),
POST /api/system/span-displays (a window on the server's desktop),
POST /api/tunnel/qr/regenerate (clears every user's pending QR login
code) and POST /api/cleanup-state (drops every user's stale session
records) now answer 403 FORBIDDEN to a non-admin.
- GET /api/tunnel/info lists only a non-admin's own logins (it showed the
address and browser of every user's sessions).
Admins and single-user mode are unchanged. The tunnel panel's Regenerate
QR button no longer reports success for a refused request.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GET /api/settings returned the whole settings.json to every signed-in user.
An admin's App Settings save stores the Deepgram key there
(voiceSettings.apiKey), so any regular user could read it, and their
browser copied it into its own voice config and dictated on the admin's
account. caseOrder and lastUsedCase also named the admin's cases.
In multi-user mode a non-admin now reads the file without those three
(settingsForNonAdmin). Admins and single-user mode get the file as it
is. Pinned by route tests and the live multi-user test.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- test/routes/system-routes-settings-admin-only.test.ts (app.inject): a
non-admin PUT /api/settings is 403 FORBIDDEN before the body is parsed, and
nothing is written, no watcher toggled, no tunnel started, no history limit
set; the same gate on PUT /api/execution/model-config, PUT /api/config and
PUT /api/cases/order; admins and single-user mode write as before; GET stays
readable.
- test/settings-admin-only-multiuser.test.ts (live server, port 0, real
multi-user auth): settings.json is byte-identical after a non-admin's PUTs,
and a non-admin's quick-start or Ralph loop no longer records lastUsedCase.
- test/settings-admin-only-ui.test.ts (real index.html in JSDOM): a non-admin
App Settings save sends no PUT, keeps per-device keys, keeps synced keys at
the server value, and toasts once only on a real synced change; the
background writes are skipped; admins still PUT; zh-CN for both toasts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
feat(codex): raise codex approval, question and trust dialogs as BLOCKED
Conflict resolved against #634 in src/session.ts _probePaneWorking():
#624's assignment (a dialog on screen is never work) is kept, and #634's
trust-dialog verdict block (log the hold once, remember the reading)
follows it, before _readWatching().
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Docker rule was applied to the CASE folder and its common git dir
only. A Docker case can be linked to any host folder, including a managed
worktree (`<repo>/.claude/worktrees/<name>`), which is a natural setup for
running one of the parallel agents in a container. Removing that worktree
from the local case's dialog, or through Close Session's "Kill & remove
worktree", ran `git worktree remove`, which runs a git status INSIDE the
tree (container-written files, checkout filters on this machine) and then
deletes the whole tree, ignored files included, while the container still
mounts it. Reproduced on an isolated instance: DELETE answered 200 and the
Docker case's workspace was gone.
That contradicts the invariant the PR itself documents in CLAUDE.md ("git
never runs ... at or inside a Docker workspace"). removeCaseWorktree now
refuses (400) a present worktree that is, sits inside, or holds a Docker
case workspace, before any git runs; the DELETE route passes the Docker
roots it already reads for the case check. A worktree whose folder is gone
is unaffected (git only drops its record there). Unit and route tests
cover both directions and an unrelated Docker workspace; docs updated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The wiki said .codeman-uploads/ is the one path "a local agent, a container
and a remote host all see the same way", two sentences after saying uploads
are refused for remote (SSH) sessions. Only a local agent and a Docker case
(workspace bind-mounted at the same absolute path) share it, which is why the
route refuses remote sessions; say that.
The same paragraph said every image goes in after a re-encode on the device,
but _normalizeImageForUpload passes a GIF through untouched to keep its
frames.
CLAUDE.md and a test comment described the image fallback as behaving
"as before 1.36", a release that never existed (1.35.0 was followed by
1.40.0). It means the image path as it was before this change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`GET .../wait` answers `blocked` at once for a codex pane showing a dialog,
because a screen-read dialog is a level the server is still reading. The
duplicate branch of `POST .../input` (a tagged retry after a dropped
connection, answered from current state) still used the plain
`currentSignalFor()`, so a retry with `wait: "blocked"` on a pane the first
delivery had already blocked waited out its whole timeout for an edge that
had passed. It now uses `currentSignalForWait()` like the GET route; a
caller that asked only for `idle` still gets `idle`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With Codeman Agent Skill on (agentSkillEnabled), every session create
writes the skill into the folder the session runs in, so a session in a
worktree leaves an untracked .claude/skills/codeman/ (claude) or
.agents/skills/codeman/ (codex, gemini and the rest) behind. git then
refuses `git worktree remove` for that worktree forever, and an agent
running `git add -A` there would commit Codeman's skill into its branch
(reproduced: the worktree's status showed "?? .agents/" and
"?? .claude/").
A create now excludes <skills dir>/codeman/ next to the hooks file, for
every skills folder the registry names (capabilities.agentSkillDir,
read at call time by the route and passed in as data, so no CLI id is
branched on). Like the other lines it is only added when git does not
already ignore the path, and only to the repository's local
info/exclude.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When a managed worktree's folder is deleted by hand (a user in a file
manager, an agent's rm -rf), git keeps its record and lists it as
prunable. Codeman then had no way out: the dialog showed the row as
"missing" with no action, DELETE answered "No worktree named ...", and
the name could never be used again (the branch still existed, and with
another branch git refused "a missing but already registered
worktree"). Reproduced on git 2.43; git's own `git worktree remove` on
the missing path drops the record with exit 0.
Removal (only removal, never a session start) now accepts a worktree
whose folder is gone when git lists it at exactly the managed path, as
prunable or locked, and no component on the way is a symlink or a file.
The live-session and lock refusals still apply. The dialog offers
Remove... (not Open) on such a row, with a note saying what it does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The removal guard and the per-worktree session count compared each
session's workingDir lexically against the worktree paths git lists,
which are real paths. POST /api/sessions stores workingDir as it was
spelled, so a session started in a worktree of a linked case whose
folder is a symlink (a repository on another disk, macOS /tmp, a
symlinked cases folder), named through that link, counted for no
worktree, and DELETE /api/cases/:name/worktrees/:worktree removed the
folder under the running agent (reproduced: 200 instead of 409).
Each session folder under the case path is now respelled onto the
repository's real top level before the comparison (respellUnder, pure
and lexical, so no filesystem call per session).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A codex dialog the session now reads off its screen gets exactly what a
Claude `permission_prompt` hook gets, so every surface that already handles
that event needs no second path: `web/screen-blocked.ts` turns the session's
`blockedChanged` edge into the `blocked` wait signal, an Approvals Inbox
item, the `hook:permission_prompt` broadcast (with `source: 'screen'`), web
push and webhooks, and a run-summary row. That lights the red "needs you"
tab alert and desktop notification, the phone overview's NEEDS YOU row and
the TUI. When the dialog leaves the screen the item it opened is resolved,
and `approval:resolved` clears every client.
It is an ALERT and never a keystroke, following #610's shape for DeepSeek:
the item is `answerInTerminal`, push carries no `approvalId` (so no
Approve/Deny), and the answer route refuses it. The dialog was read from a
screen the agent writes most of, so a forged match must cost a false alert
and nothing more. The message every surface shows is built from the CLI's
registry label, never from the dialog's text. The item gets no `capture`
callback, only its frame as context, because the inbox's option parser and
staleness sweep are shaped for Claude's dialogs and the session owns this
item's lifetime. A CLI whose hooks report blocked gets nothing from the
screen, so declaring both could never alert twice.
The answer route now checks `answerInTerminal` BEFORE its hooks gate and
names the CLI: a codex session has no hooks, and "Session mode cannot have
pending approvals" would be false about the item the caller holds.
Wait signals: `blocked` is available where hooks report it OR a CLI declares
`blockedLine` (`blockedSignalAvailable()`); `stop` stays hooks-only, since
nothing on a screen says a turn ended, so `until=stop` on codex is still a
400 (now suggesting `idle, blocked or exit`). A screen-read dialog is a
level the server keeps reading, so a `GET .../wait` that asks for `blocked`
while one is up answers at once; a caller that asked for `idle` alone keeps
getting `idle`. The MCP and `codeman agent` help text say so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sessions in one case share its folder, so several agents on one repository
edit, build and commit in the same checkout and step on each other. A case
whose folder is a git repository can now keep worktrees under
<repo>/.claude/worktrees/<name> on a new branch (default worktree-<name>),
which is Claude Code's own --worktree layout (measured in Claude Code
2.1.296) and the one Codeman's worktree pill already recognizes.
New module src/git-worktrees.ts (pure name/branch gates, worktree list
parsing, exclude text; IO through an injectable runner) and routes
GET/POST /api/cases/:name/worktrees and
DELETE /api/cases/:name/worktrees/:worktree:
- git runs as argv with -- before paths, non-interactive env, process
group timeouts, --no-optional-locks on reads; names are gated before
git and confirmed with git check-ref-format --branch.
- Only <repo>/.claude/worktrees/<valid-name> is created or removed; every
path component is lstat'ed and realpath-checked, so a symlinked .claude
cannot redirect a create or remove outside the repository.
- /.claude/worktrees/ (and the per-workspace hooks file Codeman writes,
which otherwise blocks git worktree remove) go to the repository's
local info/exclude, never a tracked .gitignore.
- Removal is git worktree remove WITHOUT --force, refused while a live
session runs inside; branch delete is git branch -d, never -D.
- Docker and remote cases are refused with a 400, and git never runs for
a case at or inside a Docker case workspace.
Starting a session reuses the existing create paths: quick-start takes
a worktree name (validated, local cases only) and runs hooks, the skill,
the model override and the session itself in the worktree;
POST /api/sessions takes the worktree as workingDir. The unified session
list names the worktree of a live session from its folder layout, so a
codex or shell session in one gets the worktree pill too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
last_miss_cause.causes was z.array(z.unknown()).max(20), so a statusline
payload carrying more than 20 causes failed validation and lost the
whole post: plan usage, model, cache and the footer with it. Truncate
to the first 20 with a preprocess that slices before the array
validates, so an over-long array costs one bounded slice rather than
per-element work across every entry.
The status-telemetry route gated the model report on
capabilities.statusLineTelemetry but stored the prompt-cache report for
any session mode, even though both ride the same statusline payload the
exporter POSTs. Gate the prompt-cache store on the same capability so a
CLI with no statusline exporter no longer carries prompt-cache state it
never actually reported.
The Attach button, the Compose and paste-dialog pickers, terminal drop and the
clipboard now take documents beside images and videos: the office formats
pdf/docx/pptx/xlsx/zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml.
A document is admitted by its name's extension (the MIME a phone attaches to a
.md is anyone's guess), buffered under the same 50MB image cap, and judged on
its bytes by the same fail-closed sniffer: a magic-number signature for the
binary formats, UTF-8 with no NUL byte for text. It is saved under a sanitized
stem of its name, so Q3 report.pdf is findable as paste-<ts>-<hex>-Q3-report.pdf.
sh/py/js/html are refused by name with a clear 400. That is a scope choice, not
a security property: file-raw already serves non-media text inert, so this keeps
the server from storing what it would serve or an agent could run, and html and
svg are the ones deliberately kept out. One client classifier (_promptAttachKind)
now returns image, video or document, and the server's dispatch mirrors it: an
image MIME wins whatever the name says, a document extension takes the document
branch, and a foreign extension with no image MIME is refused only after the
image sniff fails, so nothing the image path accepted by its bytes is lost.
#604 makes `~/` links clickable and passes them through unchanged, since only
the server knows the session host's home. The html-view mint read `~/x.html`
as workspace-relative and answered 404, so the preview fell back to the
source view. It now expands `~/` against this host's home, as the attachment
route does, and refuses it in a Docker case (that `~` is the container's)
with the same message as the attachment and folder routes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflicts resolved against #604, #606 and #601:
- constants.js: the rooted link pattern takes html-md's `markdown` and
`html?` with #606's m4v/ogv; #604's relative pattern gets the same list
(link-provider-regex pins the two equal); #604's findFilePathLinks /
folder helpers and html-md's rendersAsPage() are both kept, and
window.CodemanFilePaths exports all of them.
- terminal-ui.js: #604's resolved `target` (relative, ~/ and folder links)
now also goes through rendersAsPage(), so every link kind routes .html to
the page preview; .md routes there through FILE_PREVIEW_EXTENSIONS.
- CLAUDE.md, api-reference, architecture-invariants: both sides' text kept.
- test/folder-grid-ui.test.ts (#604) stubs rendersAsPage() like the other
panels-ui harnesses.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflicts resolved against #605 (and #602's test mock):
- CLAUDE.md Prompt uploads: #606's rewrite is kept and #605's bounded
workspace probe sentence follows the remote (SSH) refusal.
- test/routes/session-routes.test.ts: the remote-pane-history and
bounded-path-probe mocks are kept beside #606's _resetPasteRateBuckets
import, and both PRs' paste-image tests are kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflict resolved in test/routes/session-routes.test.ts: #602's
remote-pane-history mock and #605's bounded-path-probe mock are both kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflict resolved in docs/api-reference.md: #607's prompt cache section and
#528's tabGroupId section are both kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Docker refusal on POST /api/sessions/:id/folders tested only `~/`, but
listFolder also expands a bare `~` to this host's home, so a Docker case
could list and serve the host home through it (the preview's "Show folder"
for a `~/x.png` link reached it with no typing). A bare `~` now gets the same
400.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A remote session's local pane hosts the remote tmux client in the alternate
screen, so its history_size is always 0. #583 read that as "nothing to load"
and hid the partial-history notice, but with #602 a full=1 load reads the real
scrollback from the remote tmux. The route now leaves the field out for remote
sessions, so their notice keeps the byte-based wording and its Load full
history button.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflict resolved in tmux-manager.ts: #609's screen-aware
joinScrollbackAndFrame keeps #602's merge-time SGR reset before the frame,
and #609's join tests expect it. The #601/#602 route test now mocks the
remote read in #609's { scrollback, screen } shape.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A remote session's local pane hosts the remote tmux client, so it is always
in the alternate screen. #601's alt=recording fallback therefore threw away
the scrollback #602 had just spliced in from the remote tmux on every remote
shell load. The fallback now applies only when no remote scrollback was
read (the read failed, or the session is not remote).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A full-history load of a remote SSH session reads the scrollback from the
remote tmux in one bounded ssh round trip and splices it above the local
frame. A transport failure (ssh 255, timeout, unparseable output) starts a
30 s per-host back-off; a missing remote session is per-session only.
Conflict resolved in session-routes.ts: #602's remote scrollback read runs
before #601's capture helper and its alternate-screen fallback.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflicts resolved against #583 and #588:
- One history-depth field: #583's `historyLines` / `capturedHistoryLines`
(reported as `paneHistoryLines`) is kept, and #601's lines cut now reads it
instead of a second `capturedHistorySize`. #601's `alternateOn` /
`capturedAlternateOn` joins it, and the one display-message format string
ends `#{history_size} #{alternate_on}`.
- selectSession keeps #588's full=1 switch for panes with no scrollback, gated
off remote and Docker sessions, and #601's SHELL_LOAD_QUERY for shells.
- computeHistoryTruncationNotice names a line count when paneHistoryLines is
known (#583) and otherwise uses #601's remainder wording.
- docs: api-reference lists both historyLinesCut and paneHistoryLines; the
notice invariant keys on history_size while the shell's automatic load is
the one place alternate_on decides.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner decision 2026-10-13 (#547): a rendered .html page behaves like a
file opened from disk in a browser. It may load scripts, styles, fonts,
images and media from any host and make fetch, XHR, WebSocket, beacon
and form requests to any host.
The /html-view Content-Security-Policy is therefore the `sandbox`
directive alone (HTML_VIEW_CSP, now exported and documented): the same
sandbox flags as before, never `allow-same-origin`, and no fetch
directive. It replaces Codeman's own `default-src 'self'` policy on these
responses. Everything that confines a page stays: the opaque origin (no
Codeman cookies or document, API writes with `Origin: null` refused, API
reads not readable cross-origin), and the capability serving only the
page's own folder under every path, symlink, dot, extension and
confinement rule.
A new test builds the route behind registerSecurityHeaders, as in
production, and pins the exact header: one `sandbox` directive with the
current flags, no `allow-same-origin`, no `-src` directive. The security
architecture, architecture invariants, API reference, CLAUDE.md and the
Working With Files wiki page now say what a page can do (reach the
network, read its own folder) and cannot do (Codeman's origin, cookies,
API, other folders), and that this is the owner's decision.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A rendered page's own <video> or <audio> could not seek, because the
route streamed a plain 200 without Accept-Ranges. sendFileBody() and
localFileSource() are now exported from file-routes.ts and the serving
route uses them, so it advertises Accept-Ranges and answers a Range with
206 + Content-Range (or 416), exactly like file-raw, the attachment raw
route and /api/download. The body sender hijacks the reply and copies
every header set before it (the sandbox CSP, CORS, no-store), which the
new test pins on a 206.
Media types stay in ASSET_TYPES: with Range support they work, and they
are served under the same sandbox CSP as everything else.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The mint checked the extension of the requested path, then built the page
URL from the symlink-resolved file name. A `report.html -> notes.txt` link
minted a page URL for the text file. The resolved name now has to be
.html or .htm as well, the mint-side counterpart of the serving route's
resolved-path extension check.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
POST /api/sessions/:id/html-view read `path` off an unchecked cast. It now
goes through HtmlViewOpenSchema in schemas.ts (a non-empty string of at
most 4096 characters without NUL or line breaks, no other keys) via
parseBody, like the other file routes, and keeps its "Missing path
parameter" message for an absent path.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The mint refused an HTML file whose absolute path had any dot segment, so
a workspace that itself lives under a hidden folder (~/.local/src/app)
could never render one of its own pages. For a file inside the workspace
the rule now covers the path below the workspace root, which is all the
page's tree can reach; outside the workspace it still covers the whole
path. A hidden directory below the workspace root is still refused.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The serving route checked the blocklist per request but not
attachmentConfineToWorkspace, while the attachment serve path
(file-routes.ts) re-checks it every time. A capability minted for a
folder outside the workspace before an admin turned confinement on kept
serving that folder for the rest of its TTL.
The capability record now says whether its folder was admitted from
outside the workspace, and the serving route refuses such a record while
confinement is on. The flag is part of the reuse key, so an
out-of-workspace mint never picks up a capability recorded as
in-workspace for the same folder.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The serving route checked hidden segments and the asset allowlist on the
requested name only; after realpath it checked containment and the
blocklist. A symlink `cfg.json -> .git/config` inside the page's folder
was therefore served as 200 application/json. The dot rule now also runs
on the segments of relative(rootDir, resolvedPath), and the extension
allowlist on the resolved file name, which also types the response.
Both realpath calls on the request path (the mint of an out-of-workspace
file and the serving route) are now `await fs.realpath` instead of
realpathSync, so a slow mount stalls one request, not the event loop.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
os.tmpdir() returns $TMPDIR when it is set, and macOS sets it in every
terminal session, so with TMPDIR set /tmp, /private/tmp and /var/tmp were
never treated as broad and /tmp/report.html got a capability over all of
/tmp. The broad-root list now names /tmp and /var/tmp literally (through
canonical(), so macOS gets /private/tmp), refuses any directory that is an
ancestor of a broad root (/home, /Users, /private), and treats a drive's
mount point (/mnt/d on WSL, /media/x, /Volumes/x) as broad as well.
The new test sets TMPDIR elsewhere and still gets /tmp refused.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflicts resolved:
- CLAUDE.md: kept master's Prompt uploads paragraph and its longer
File-path links paragraph (XLSX admission limits), and inserted the
PR's html-view sentence after the download-only rule.
- src/web/public/terminal-ui.js: kept master's tile-aware
getSessionId() in the shared file-path link provider and added the
PR's .html routing to the file preview on top of it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The agent skill only reached Claude Code: injection wrote
.claude/skills/codeman, which no other CLI reads, and the per-session
preamble was seeded for claude alone. A Codex, Gemini, OpenCode,
Antigravity, Grok, DeepSeek or OMP session inside Codeman never learned it
could start and drive workers.
- Registry field capabilities.agentSkillDir (a closed enum, since
clis.json can set it and it decides where the server writes) names the
skills directory each CLI scans. Claude keeps .claude/skills; those seven
CLIs get the same skill in <case>/.agents/skills/codeman (each measured to
scan it; Claude Code does not, so it never sees two copies). pi gets none:
a project skill makes pi ask "Trust project folder?" at startup.
- The preamble is seeded for every local session whose CLI takes the
skill; nothing in it is claude-specific.
- The skill text is CLI-neutral and names the two CLI-side failures to
report instead of retrying: a sandbox that blocks loopback (Codex's
default), and commands that do not carry this session's environment
(Codex 0.162 runs them in a shared app-server daemon).
- POST /api/sessions injects after the launch pre-flight, so a CLI that
cannot start leaves nothing in the workspace.
- codeman skill install gains --mode; uninstall --case removes the copy
from every directory the registry can write.
- The settings toggle moves to Agents & CLIs > All agent CLIs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sshd runs the command in the remote user's login shell. On Hufflepuff cd is
an alias that echoes the directory, so the listing script captured the folder
path twice and zsh aborted on the glob: every folder grid over ssh failed
with "remote host unreachable: no matches found". Wrap the script in sh -c,
as the transcript read already does.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A Docker case is a local session for the file routes, but `~` inside the
container is the container's own seeded home. Expanding `~/x.png` on the host
opened a different file than the one the agent printed. The attachment POST
and POST …/folders now answer 400 for `~/` when the session has `docker` set
(absolute paths are unaffected: the workspace is a bind mount).
Docs: the resolved-target gate, the route-parameter limit, the listing join
rule and the Docker `~/` refusal in architecture-invariants.md,
security-architecture.md and CLAUDE.md.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Folder routes only looked at the requested name, so `shot.png -> ../secret.bin`
and `notes.txt -> ../history` were served (registration answers 400 for both),
and a FIFO or device behind a supported name parked a worker on an open that
never returned. After resolving, the target's extension must be supported and
it must be a regular file (locally fs.stat, remotely the probe kind).
A 101..255-character file name answered 404 "Route not found": Fastify caps a
path parameter at 100 characters. The server now sets routerOptions.maxParamLength
(MAX_ROUTE_PARAM_LENGTH, shared with the route test harness) and a wiring guard
pins it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Supervised-hook CLIs (DeepSeek Harness) raise Approvals Inbox items whose
answer route refuses keystrokes, but push notifications and the mobile/desktop
UI still offered Approve/Deny. Mark those items answerInTerminal, omit
approvalId (and therefore push actions) for supervised hooks, and show
"Answer in the terminal" / Open session instead of broken buttons.
Fixes#598 (functional part; docs gaps left for a follow-up).
Claude Code's tracker nulls recache_tokens_if_cold at a compaction or a tool-result clearing and only counts the expected rebuild at the next request, which also closes the window, so no report carried both and the old rule never fired. Stamp on the first warm null report after one that carried a value, carry it through idle re-reports and drop it at the next request; the readout now says "Context compacted at HH:MM" since a clearing opens the same window.
Also persist a changed report so a crash restores the latest one, rename the session field to statusLineExporter (statusLineTelemetry is the registry capability) and document its values, word the stake as re-cached, move the readout block above the SSE staleness heading, and drop keep-warm mentions until it lands.
- Multi-user: a non-admin's folder must resolve inside their own userSpacePath
or the session's workingDir (folderAllowedForUser, the path picker's rule),
checked on the resolved folder before it is read and again on each served
file after symlink resolution; 403 otherwise.
- listFolder probes the local folder with probePathKind({ pastCap: true })
first (unknown -> 503 via describeUnknownPath, never 404), then async
fs.realpath; resolveFolderFile uses probePathKind instead of fs.stat.
- The guard runs on the realpath before readdir.
- FOLDER_PATH_LINK_PATTERN ends a folder at ':' (quadratic `://` rescan on
colon runs; PATH=/a:/b linked as one folder).
- Load counters drop stale preview / folder-grid responses; closing the grid
invalidates a listing still in flight.
- validFolderFileName refuses a leading dot; the remote listing matches
extensions in any case.
- Docs: folder grid in the z-index lists; multi-user rule and bounded probe in
the invariants and security docs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The remote history and the local frame are read a round trip apart, so
two seams dropped rows at the join: rows that scrolled off the screen
during the round trip, and the top of a remote screen taller than this
pane (a shared session sized by a taller client under window-size
latest). Both are rows of the remote screen above where the local frame
begins.
The remote script now also captures the screen, right after the
history, in the same round trip. joinScrollbackAndFrame() places the
local frame among the screen rows (findFrameInScreen(): text compare
with escape sequences and trailing blanks ignored, the screen's trailing
blank rows and last content row left out, at least three rows of
overlap with two of them non-blank, highest match wins) and keeps the
screen rows above it between the history and the frame. When the frame
cannot be placed, the join is history over frame as before. The read's
buffer slack grows to 1 MiB, since the screen is outside the tail -c
bound.
Left: the history and screen are two tmux commands, so a row that
scrolls between them (milliseconds) is lost, and a screen that repeats
a whole block can place the frame at the higher copy, losing the rows
between the copies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>