Both picker endpoints are a second file-serving surface, and they
inherited neither the attachment guard's confinement nor its ownership
scoping. Two separate holes:
1. `sessionId` contributes that session's workingDir as a browse root,
but it was resolved straight off ctx.sessions/ctx.store with no owner
check, unlike the nine other session-scoped handlers in this file. A
non-admin could pin ANOTHER user's working directory as a root just
by passing their session id, then list and preview underneath it. Now
runs canAccessOwned and reports 404, which also avoids confirming
that a session id exists.
2. `Home` and `CASES_DIR` were unconditional roots for every caller.
Per-user spaces live at <USER_SPACES_DIR>/<username>, which is INSIDE
homedir(), so the Home root alone exposed every other user's
workspace. A multi-user non-admin now gets only their own
userSpacePath plus anything explicitly listed in
CODEMAN_FILE_PICKER_ROOTS. /mnt/d is dropped as well: a broad host
mount should be an explicit operator decision in a multi-user
deployment, and operators who want it can name it in that env var.
Admins and single-user mode keep the host-wide roots, so behavior is
unchanged unless CODEMAN_MULTIUSER is on (opt-in, off by default).
All three discriminating tests were verified to fail against the
previous code: browse and preview both returned 200 instead of 404, and
the roots came back as [Home, Codeman Cases, ...] instead of [My Space].
Full suite green, 3784 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scopes real-time streams and the init snapshot so a multi-user client only
receives what it owns. No-op in single-user mode (identity-less clients).
- WS terminal (ws-routes): owner gate after the session lookup. A non-admin may
only attach to their own session (close 4003); the global auth hook already
ran on the upgrade and decorated req.authUser, so an unauthenticated upgrade
never reaches the handler.
- SSE (sse-stream-manager): per-client identity stored at addClient; broadcast()
and the terminal-batch flush both enforce a routing hint via canDeliver().
WebServer.broadcast auto-derives the hint (deriveSseHint): session-scoped event
families resolve the owner from the payload's session id (fail closed when the
owner can't be resolved), machine-level families (docker/tunnel/update/system/
cron) + host-plan telemetry are admin-only, everything else stays global. Raw
terminal bytes resolve the owner once and are withheld from non-owners.
- getLightState is filtered per connection AFTER the shared cache (sessions,
respawnStatus, subagents, workflowRuns by owner; scheduledRuns + planUsage
admin-only); applied to both the SSE init snapshot and GET /api/status.
- file-routes: getKnownSessionWorkingDir + getSessionAttachmentHistory (the
preview/thumbnail/history helpers that bypass findSessionOrFail) now owner-check
the session, closing a cross-user file-read path.
- GET /api/search: harvestSources is owner-scoped.
Deferred to a follow-up (documented in docs/multi-user-plan.md): away-digest +
subagent/workflow REST list scoping, push-subscription identity + routing,
per-user screenshot subdirs. The live-event versions of these are already routed
by the SSE hint; only the on-demand REST aggregates remain global for admins-only
follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Threads per-user ownership through sessions, cases, cron, and the permission
policy. All scoping is a no-op in single-user mode (isMultiUserMode() guards).
Sessions
- Session.owner stamped at every create path from req.authUser / job.owner:
POST /api/sessions, /api/run, /api/quick-start, ralph start, cron launch,
plan generation. Round-trips through recovery (MuxSession.owner mirror, read
muxSession.owner ?? savedState?.owner) and the mux layer.
- findSessionOrFail(ctx, id, req) now does a NOT_FOUND owner check (never 403, so
other users' session existence is not leaked); wired at ~50 call sites.
- List endpoints filtered by owner: GET /api/sessions, /api/sessions/unified
(live+persisted+lifecycle scoped, host-wide transcripts admin-only), cron jobs.
Permission policy (section 6.3)
- resolveClaudeModeForUsername wraps getClaudeModeConfig at every spawn site so a
non-granted user is forced to --permission-mode auto (bypass -> auto), including
recovery (or a reboot would un-downgrade). buildPromptArgs now respects the
session's claudeMode, closing the one-shot (runPrompt) bypass hole.
- Shell mode and cron launchCommand require canBypassPermissions: 403 at
POST /api/sessions, /api/quick-start create, cron job create, AND cron fire time
(re-checked against the owner's current grant).
Cases
- resolveCasesDir(user): per-user ~/codeman-users/<name>/cases in multi-user, the
shared ~/codeman-cases otherwise. All case CRUD + ralph + plan + quick-start
resolve through it. resolveCasePath is owner-aware.
- GET /api/cases scoped per user (own folders; legacy linked cases admin-only;
remote/docker cases owner-filtered). RemoteCase/DockerCase gain owner, stamped
at link/quickcreate/import.
- Remote + Docker host CRUD is admin-only.
- Non-admin workingDir confinement (the linchpin): realpath must resolve inside the
user's space, enforced at POST /api/sessions and /api/run BEFORE any disk write.
Limits
- sessionCapacityState / sessionCapacityMessage centralize the global + per-user
cap (CODEMAN_MAX_SESSIONS_PER_USER, default global/2), replacing the 6 copy-pasted
MAX_CONCURRENT_SESSIONS checks.
Tests: test/ownership-scoping.test.ts (case isolation, host-CRUD gate, workingDir +
shell gates, and the scoping helpers). Deferred to phase 4: WS owner gate, SSE
fan-out filtering, file-route preview/thumbnail helper scoping, push routing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The File Browser preview and Attachments preview share openFilePreview(),
but the workspace branch (via /file-content) misclassified several types the
attachments viewer handled fine:
- SVG was reported as type:image, but file-raw serves SVG as octet-stream +
attachment (XSS hardening), so the <img> broke. Now fetched and rendered via
a same-origin image/svg+xml blob <img> (safe; <img> never runs SVG scripts).
file-raw's SVG hardening is unchanged.
- Audio (mp3/wav/ogg/m4a/aac/flac/opus) was type:binary -> "Cannot preview".
Now classified as audio and rendered with <audio controls>; file-raw gained
the matching audio/video MIME types so playback works.
- Binary formats not in the hardcoded list (xlsx/doc/zip/...) were decoded as
UTF-8 and dumped as mojibake. Replaced the static list with a NUL-byte
content sniff that flags arbitrary binaries; the binary fallback now offers a
Download link instead of dead-ending.
Adds route tests for audio, known-binary (xlsx), and NUL-sniff classification.
Verified end-to-end on an isolated instance + headless browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up fixes applied during review of PR #121 (all confirmed minor/nit;
no blockers). Security posture verified sound (externalPath never leaves
toState()/the list route; re-registration runs the guard).
- fix(recovery): restoreAttachmentHistory now skips malformed/legacy saved
items (null, non-object, missing source/fileName) instead of throwing inside
the Session constructor — a corrupt __attachmentHistory entry could otherwise
abort the entire mux-recovery loop. (P1)
- fix(routes): the attachment-list route degrades a single failing entry to
{missing:true} instead of failing the whole drawer. (INT-4)
- fix(ui): give the attachments header button a positioning context so the
unread badge anchors to the icon, not the header bar. (F1/CSS-1)
- fix(ui): cancel the debounced history refresh on drawer close and guard it
against a stale session/closed drawer. (F3)
- fix(ui): re-show ("Card") of a detected item now uses the item's own
timestamp so the cardId is stable — focuses the existing card instead of
stacking duplicates. (F4)
- fix(ui): Escape now closes the drawer, matching every other panel. (UX-1)
- fix(ui): badge shows "99+" past 99 (was an inconsistent 100/99 cap). (BADGE-1)
- style: drop the duplicate @keyframes notif-badge-pulse (dead CSS). (INT-1/CSS-3)
- style: empty-state used three undefined CSS custom properties
(--text-primary/--border-color/--bg-tertiary) → use the defined
--text/--border-light/--bg-input tokens. (CSS-2)
- test: add constructor restore round-trip + malformed-item resilience tests.
Deferred (noted for author): broadcasting the full 100-item history in every
session-state SSE event (payload bloat), "unread" badge semantics, making the
header button opt-in, and app.inject route tests for the two new endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stacks on COD-38: accumulates a per-session attachment history and exposes it
through a slide-in drawer with an unread badge, so attachments stay reachable
after their cards are dismissed.
Backend:
- session-attachment-history: history state — dedupe by source path / relative
path, newest-first, 100-item cap, and externalPath sanitization (the absolute
host path is server-private and never leaves toState()).
- session.ts: _attachmentHistory + getter (sanitized) / upsert / restore /
getAttachmentHistoryForPersist; restored from saved state in the constructor.
- file-routes: GET /attachments (list — resolves each entry to live metadata +
routes; external entries are re-registered) and GET /attachments/:id
(metadata poll). The by-id route guards via the registry's TOCTOU-safe
resolveServableAttachmentPath.
- server.ts: detected/registered attachments upsert into history and persist;
the private (externalPath-bearing) history rides on disk under
__attachmentHistory, separate from the sanitized public copy, and is restored
on mux-session recovery.
- types/session.ts: SessionAttachmentHistoryItem + SessionState.attachmentHistory.
Frontend:
- panels-ui: the drawer (lazy-built), unread badge, list render with per-item
preview/download/open/"Card" (reshow) actions, and live refresh of the open
drawer on new detections.
- app.js: history state + per-session badge/cleanup wiring.
- index.html / styles.css / mobile.css: header button + badge and the drawer.
Verified: tsc / eslint / prettier / frontend-syntax / public-assets clean; new
history-module unit tests pass; full test:ci green (2866 passed); badge, drawer
open/render/reshow/close verified in-browser.
Builds on the COD-37 registry: surfaces detected/registered attachments as
dismissible cards with a first-page thumbnail and an inline preview — the
consumer the registry PR deliberately deferred.
Backend:
- document-thumbnailer: first-page PNG thumbnails (PNG passthrough; PDF via
pdftoppm; Office via the preview cache).
- document-preview-cache: disk-cached DOCX/PPTX -> PDF conversion (LibreOffice
/ PowerShell COM), in-flight dedup, multi-converter fallback.
- file-routes: serveConvertedPreview / serveThumbnail + four routes —
GET .../attachments/:id/preview, .../thumbnail and the workspace-path
file-preview / file-thumbnail. Reuses the registry's TOCTOU-safe
resolveServableAttachmentPath, so previews stream the freshly-resolved path.
- server: enrich detected attachment events with a thumbnail route.
- image-watcher: .png now routes to attachment:detected — this PR adds the card
consumer, so the screenshot popup is no longer its only handler.
Frontend:
- panels-ui: attachment cards (addAttachmentCard, lazy stack, Clear-all,
per-session cleanup) plus a 3-arg openFilePreview that renders registered
attachments inline (image/PDF) or via the server-converted PDF (docx/pptx).
- app.js: wire attachment:detected -> _onAttachmentDetected and card state.
- styles: attachment-card + stack styling.
Verified: tsc / eslint / prettier / frontend-syntax clean; new thumbnailer +
preview-cache unit tests pass; full test:ci green (2861 passed); card render +
preview overlay + dismiss verified in-browser.
Security (MAJOR): the terminal-output codeman://attach scanner registered any
matching path server-side with no user confirmation and broadcast the rawUrl
over SSE. Terminal output is attacker-influenceable (a prompt-injected session
can print an arbitrary path), so on the default no-auth deployment this was an
arbitrary host-file (png/pdf/docx/pptx/md/txt) read primitive reachable by any
SSE client. Magic-link registration is now force-confined to the session
workspace (forceWorkspaceConfinement) regardless of the global confine setting;
deliberate cross-workspace attach still works through the explicit,
Origin-guarded POST /attachments route and 'codeman attach' (which POSTs
directly inside a managed session). Documented in security-architecture.md.
Regression (MAJOR): .png was rerouted from the image-popup path to
attachment:detected, which has no frontend consumer — silently breaking the
dropped/pasted-screenshot popup. PNG stays on image:detected; only pdf/docx/pptx
(which never had a popup) emit attachment:detected.
Also:
- raw route streams the freshly-resolved path, not the stored one, so a
post-registration symlink swap can't redirect the stream (TOCTOU).
- 50MB cap on the attachment raw route, matching file-raw / download.
- per-session attachment registry cap (200) to bound the POST path.
- CLI reads creds via dataPath('.env'), honoring CODEMAN_INSTANCE.
Tests: forced-confinement reject/allow cases; PNG popup-path assertions updated.
Adds the foundation for serving local files to the browser as live external
attachments with a stable id, so requests never carry arbitrary absolute paths.
- attachment-registry: in-memory, session-scoped registry. registerExternalAttachment
validates an absolute path, resolves symlinks, enforces the path guard, and mints
an `att_<uuid>` id; records are cleared when the session is removed.
- attachment path guard: a configurable blocklist (secret locations + /root,/etc
trees, extendable via attachmentBlockedPaths / CODEMAN_ATTACHMENT_BLOCKED_PATHS)
plus an optional, default-off workspace-confinement mode. Shares one
sensitive-path blocklist (web/sensitive-path.ts) with /api/download, which is
refactored to use the extracted module instead of an inline copy.
- terminal magic links: the session scans output for codeman://attach?path=... and
emits `attachmentRequested`; the web server registers the file and broadcasts an
`attachment:detected` SSE event. `codeman attach <path>` (CLI) prints the magic
link or POSTs directly when a session id is known.
- image watcher: detects png/pdf/docx/pptx dropped into a session's working dir and
emits `attachment:detected`.
- routes: POST /api/sessions/:id/attachments (register) and
GET /api/sessions/:id/attachments/:attachmentId/raw (serve), both re-checking the
guard before streaming.
Document previews/thumbnails and the attachment-history drawer build on this
foundation and land separately.
Verified: tsc --noEmit, lint, format, frontend-syntax, full test:ci (2846 passed),
and a server boot smoke (/api/status 200).
A 15-agent audit of the merged tree confirmed 9 envelope/contract bugs;
all fixed here, with live-server contract tests added:
Blockers (fresh-install quick start broken):
- session-ui.js runClaude/runShell unwrapped .data from the /api/cases/:name
404 error envelope (which has no data key), so a not-yet-created case threw
TypeError instead of triggering the auto-create fallback. Now '?.data ?? {}'.
Contract violations on the new stable surface:
- Unknown /api routes returned HTTP 404 with {success:true,...} (Fastify's
default not-found payload was wrapped by the envelope hook). Added a
setNotFoundHandler returning the standard error envelope for /api paths.
- POST /api/events/subscribe 400 body became {success:true,data:{error}};
now createErrorResponse(INVALID_INPUT).
- POST /api/clipboard validation error lacked errorCode and shipped HTTP 200;
now createErrorResponse(INVALID_INPUT) -> 400.
- POST /api/run catch path returned bare {success:false,sessionId,error}
(HTTP 200, no errorCode); now OPERATION_FAILED envelope -> 422 with the
dead session id in the message.
- DELETE tail-file/:streamId returned {success: closed}, colliding with the
envelope discriminator; now returns {closed}.
Dead/regressed UI paths:
- Plan history modal could never open: route returned the bare history array
under data while the frontend read data.data.history/currentVersion. Route
now returns {history, currentVersion}; modal task count fixed to stats.total.
- Self-update error toast read j.error.message from the string-typed envelope
error, always falling back to the generic message; now reads the string.
Cleanup:
- Removed the stale QuickStartResponse type (unreferenced; documented the
pre-envelope shape and invited success-key collisions).
Tests: new test/http-contract.test.ts boots a real WebServer (port 3168) and
pins the envelope, /api/v1 alias, error statuses, and the /api 404 shape —
the route-test harness does not install the server-level hook, so these need
the live server. Updated file-routes/plan-routes/scheduled-runs tests to the
fixed shapes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split 6,710-line server.ts into focused route modules using port
interfaces for dependency injection. 107/109 routes extracted into
12 domain files with auth middleware, 5 port interfaces, and shared
helpers. Server.ts retains orchestration (SSE, lifecycle, state).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>