Review round 2 on #661: the hostname in the origin orphaned every snapshot when a Docker container was recreated, so the origin is a random id kept in <data dir>/config-backup-id; after restoring settings.json the Settings form is re-read from /api/settings so Save cannot write the old values back; the restore confirm names the files and the credential ones; clis.json is reloaded (reloadCliRegistry) and users.json needs no restart; API reference and route docs corrected.
Review round on #661: webhook.json and intents.json are redacted in compare and URL values lose userinfo/query; a custom folder is probed (bounded, pastCap) before each pass and in each route and only created when its parent exists; manifests record an origin and change check, prune, list, restore and diff are scoped to it; a restore reloads IntentStore, lists untouched files and treats settings.json as restart-required; remote-cases.json is tracked; stale .tmp-cfg-* folders are swept and a failed restore unlinks its temp; the group is hidden from non-admins; an empty days field saves 30.
Settings -> System -> Config backups gains a Compare view: a backup against the
running config, a backup against the one before it, or any two backups. Each
differing file lists its changed keys as before/after, plus files that appeared
or vanished. GET /api/config-backups/diff?from=&to= serves it (`to` defaults to
`current`; admin only in multi-user mode).
Credential values are never returned: .env, users.json and push-keys.json report
names only, and any other key that looks like a token, secret, password,
credential or key is reported as changed without its value. A non-JSON file
reports only that it differs, a file is cut at 200 changes, and a self-refreshed
discovery timestamp does not count as a difference.
The files that hold a user's customization (settings.json, clis.json, custom-model-hosts.json,
webhook.json, intents.json, linked-cases.json, push-keys.json, users.json, .env) are plain JSON
in the data dir, rewritten by the server, the Settings UI and the CLI, with no copy to go back to.
A snapshot is taken at startup and whenever the tracked files' content changes (checked every
five minutes, and only written when a digest of the file set differs from the latest snapshot's),
into `<data dir>/backups/config` by default or any absolute folder. Retention keeps the newest
configBackupKeepCount (1-500) and, when configBackupKeepDays > 0, drops older ones; it only ever
deletes folders it made (name pattern and a parseable manifest) and never the newest.
GET/POST /api/config-backups list and back up now; POST .../:id/restore verifies every file against
the manifest's SHA-256, takes a pre-restore snapshot, then replaces files atomically with mode
0600, writing only allowlisted names. Snapshots are 0700 folders of 0600 files and all routes are
admin only in multi-user mode. Settings -> System gets the controls and a Restore list.
GitHub Copilot CLI (`copilot`) becomes a run mode: Run menu, welcome
screen, cron and CLI management once it is installed, tmux-backed like
the other agent CLIs, working/idle and the model read off its footer,
launch options through `copilotConfig` (model, resume, continue, the
multi-user-clamped allowAll), Custom Model Endpoints via Copilot's BYOK
variables, MCP sync of its mcp-config.json, a status probe, and the
`codeman agent` verbs (spawn waits for its composer, read returns its
last answer).
The plan-usage quota row that earlier rounds carried was split out on
review and is not part of this merge.
Merge-time commits on top: the wider COPILOT_* clamp and the zh-CN
README rows (maintainer), the Docker sign-in docs, divider rows inside
an answer kept by `agent read`, route tests for the screen-read
last-response branch, and doc drift (Past Sessions, wheelForward JSDoc,
COPILOT_/OMP_ in the env prefix lists).
Verified before merge on Copilot CLI 1.0.98: injected keystrokes,
bracketed paste and Enter reach the TUI in tmux (github/copilot-cli#4180
does not reproduce), a shell tool call completes inside tmux (#4223 does
not reproduce), and a session driven through this branch answered over
send-and-wait.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Now that the set is exported for the parity test, type it read-only like
VIDEO_ATTACHMENT_EXTENSIONS, so an importer cannot add to the live upload
allowlist.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`/api/sessions/:id/last-response` fell through to the Claude transcript scan for copilot (not in the
external-CLI list), so `codeman agent read` answered empty even with an answer on screen. Copilot
is a repainting full-screen TUI whose stream is absolute cursor moves, so the buffer cannot be cut
into lines either; the route now reads the pane's visible screen (tmux capture-pane) for such
modes and falls back to the buffer where there is no pane.
The segmenter gets a small per-CLI "pane dialect" row instead of another branch in the
Codex-oriented loop: `❯ <prompt> HH:MM`, `● <answer>` with indented continuation, the cwd /
`Session: N AIC used` row, composer, footer and the `┃` scrollbar gutter are chrome, and
everything before the first prompt (banner, trust dialog) is dropped so a session that has not
been asked anything answers nothing rather than serving its logo.
Fixture is a real Copilot 1.0.95 pane. Tool-call rows are not modelled and read as answer text.
- Drop GH_TOKEN / GITHUB_TOKEN from env.allowedKeys. The allowlist is one global list with no
mode context, so they were settable on every session. COPILOT_GITHUB_TOKEN is already admitted
by the COPILOT_ prefix.
- Declare capabilities.mcpConfig (copilot-json, COPILOT_HOME relocation) so Settings does not
list Copilot as unsupported while the sync-only target also lists it. The sync-only row is
skipped while the run mode is enabled.
- Require a leading alphanumeric on the resume id (path-segment in the registry, matching regex in
the schema): --resume's value is optional, so `--resume --yolo` made --yolo its own flag and
bypassed the allowAll clamp. Golden cases added; "ids only" wording corrected.
- Remove the unread overlays.credStore line and document signing in inside a Docker container.
- Add the zh-CN "Run GitHub Copilot" string, pin the new declarations in tests and update the
MCP registry expectations.
Copilot (`copilot`, npm @github/copilot) as a stock CLI entry with the same launch options as the other agent CLIs: model (--model), resume (--resume), continue (--continue) and allow-all (--yolo, clamped off for non-granted multi-user owners). Adds a resolver and GET /api/copilot/status probe, the agent-image package, Run-menu/mobile/logo entries, skill docs and tests.
An Excel or Notepad CSV/TXT export (UTF-16 with a BOM, or Windows-1252) was refused as "File bytes do not match declared type", which reads like a corrupt file. Name the encoding and tell the user to save it as UTF-8.
The browser cannot import the server's document allowlist, so the classifier, the three pickers' accept lists and the two file-path link patterns each carry a hand-kept copy. A missed copy fails silently: a file greyed out in the picker, an upload dropped client-side, or a pasted path that is no longer a link. Hoist the set to module scope so the test reads the shipped list.
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>
In multi-user mode any signed-in non-admin could rewrite the server-wide
~/.codeman/settings.json through PUT /api/settings (feature switches, the
plan-usage collection switch, run mode, respawn presets, the tunnel), although
docs/multi-user-plan.md makes settings writes admin-only.
PUT /api/settings now answers 403 FORBIDDEN for a non-admin before anything is
parsed, written, toggled or started. The other request paths that write a
server-wide setting get the same gate: PUT /api/execution/model-config (the
modelConfig key), PUT /api/config (state.json app config) and
PUT /api/cases/order (the one global caseOrder list). Quick-start and
ralph-loop start no longer record a non-admin's lastUsedCase in settings.json,
where every user could read it. GET /api/settings stays readable by everyone;
single-user mode is unchanged (requireAdmin short-circuits there).
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>
fix(deepseek): find pnpm when installing the terminal profile, and install it with dsh
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>
sendInput() typed the text of a text+Enter pair with `send-keys -l` and
sent Enter 50 ms later. Codex detects an unmarked paste from a fast
character burst and folds an Enter inside that window into the paste as a
newline, so the prompt stays in the composer unsubmitted. Measured with
codex-cli 0.162.1 in a throwaway tmux pane: 1505 chars stuck 3/3 as
`[Pasted Content 1506 chars]`, a 98-char line stuck 1/4. SubmitVerifier
re-presses Enter for the short case, but it cannot match the long one: the
composer shows the placeholder instead of the prompt's head.
The text now goes in via set-buffer + `paste-buffer -p -d` (markers only
when the pane requested bracketed paste), followed by the separate Enter as
before. Same pane, same texts: 4/4 and 3/3 submitted. Each send gets its
own named buffer; a paste that fails (pane or server gone) deletes it,
since tmux never trims named buffers on its own.
Also escapes a trailing `;` in the text, which tmux treats as a command
separator (`send-keys -l 'x;'` typed `x`, a lone `;` typed nothing).
sendInputToPane() has no callers in src/ and is left as it was.
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>
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>
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>
The ?kind=video branch called req.file(), which pipes the request into
busboy, then answered 400 (unsupported container) or 403 (upload dir not a
regular directory) without reading part.file. The server then stopped
reading the body, and a browser fetch, which reads the response only after
its upload is sent, hung on "Uploading 1/1 file" for an Android 3gp or an
.mkv. Both early answers now consume the part first, as the mislabeled
container path already did. (Reviewer-verified in Chromium: hang before,
400 in about 0.2 s after; inject and a Node client do not reproduce the hang.)
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>