Compare commits

...
Author SHA1 Message Date
arkonandClaude Fable 5 eda95adaa9 feat(ui): theme skins — OG Codeman, Daylight Green, Daylight Blue
Add a per-device skin switcher in App Settings → Display:
- Three skins via html[data-skin]: og (original Codeman look),
  daylight-green, and daylight-blue (new default). Per-skin CSS-variable
  token blocks; the v1.0 "Carbon Aurora" component polish is scoped to
  non-og skins and parameterized so green/blue differ only by token values.
- Self-hosted Manrope (UI) + JetBrains Mono (terminal) variable fonts,
  served from /fonts (no external CDN, CSP-safe via font-src 'self').
- Per-skin xterm terminal theme with live re-theming of open terminals on
  skin change; skin-aware --term-bg so the terminal background fills cleanly
  (fixes the variable-height gap above the toolbar).
- Pre-paint inline script applies the saved skin before first paint (no
  flash); persisted per-device in localStorage + the settings blob, and
  kept out of the server settings payload (device-local).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 23:21:47 +02:00
Ark0N 3f2c23cb0f Merge pull request #119 from aakhter/pr/cod-37-attachments
Add server-side attachment pipeline (registry, magic-link, path guard)

Review fixes (2767e80): force-confine the terminal magic-link scan path to the
session workspace (closes a prompt-injectable arbitrary host-file read primitive
that broadcast over SSE); keep PNG on the image-popup path (the attachment UI
consumer is out of scope, so rerouting it broke the screenshot popup); serve the
re-resolved path (TOCTOU); 50MB raw cap; per-session registry cap; CLI .env via
dataPath(). Documented in security-architecture.md.
2026-06-11 10:32:34 +02:00
arkon f7ce8e4767 fix(attachments): harden registry + close magic-link injection vector
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.
2026-06-11 10:27:09 +02:00
Aamer Akhter f1c64994ad COD-37 add server-side attachment pipeline (registry, magic-link, path guard)
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).
2026-06-11 10:27:09 +02:00
Ark0N 12c8e080c1 Merge pull request #118 from aakhter/pr/cod-81-snapshot
feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture)

Review fixes (9893a7f): bounded/hardened xterm snapshot persistence — shell-session skip, true LRU eviction, localStorage quota-deadlock fix with evict-and-retry, OSC-strip regex tightened.
2026-06-11 10:22:59 +02:00
arkon 9893a7f64a fix(terminal): bound + harden xterm snapshot persistence
- Skip snapshot save for shell sessions (restore is gated on mode!=='shell',
  so they only burned a serialize() + cache slot + localStorage quota).
- In-memory cache: delete-before-set so eviction is true LRU, not FIFO that
  could drop the most-recently-used session.
- localStorage: extract _persistXtermSnapshot — evict to a fixed key budget
  regardless of session liveness (the old prune only dropped dead keys, so
  >10 live sessions at the 20-session target deadlocked the quota) and
  evict-and-retry on quota errors (the old prune ran only after a successful
  setItem, so a full quota permanently disabled persistence).
- Tighten the OSC-strip regex in _isUsableXtermSnapshot to stop at ST.
- Update the structural test's usability-gate assertion to not depend on a
  fixed byte window.
2026-06-11 10:16:47 +02:00
Aamer Akhter 5b2da424a1 feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture)
Switching away from a session and back replayed only the server's byte
history. For TUI modes (codex especially) that shows just the latest
repaint — the idle banner — because the TUI drops earlier conversation
from its current frame. This restores the actual on-screen view.

Two complementary mechanisms:

- Client: load xterm's SerializeAddon and snapshot the rendered state
  (viewport + scrollback + colors) per session on switch-away, restoring
  it for an instant first paint on switch-back. The snapshot is only the
  first paint — the canonical /terminal frame is still fetched and
  reconciled (restoredSnapshot/clearedForBusy force the replay). Snapshots
  are LRU-bounded in memory (<=20) and persisted to localStorage
  (<=256KB each, <=10 sessions, stale-pruned) so they survive tab discard.

- Server: GET /api/sessions/:id/terminal prepends the live tmux pane
  buffer (via the existing captureActivePaneBuffer) ahead of the byte
  history, cleared between, so replay reflects the current frame.

Also fix formatPaneSnapshot dropping the rightmost column of every
captured row: it painted to cols - 1 out of caution about last-column
autowrap, but every row is followed by an absolute cursor-position CSI
that cancels xterm's pending-wrap, so painting the full width is safe.

The SerializeAddon is built from @xterm/addon-serialize (new dependency)
into the vendor bundle by postinstall.js (dev) and build.mjs (prod),
matching how the other xterm addons are vendored.
2026-06-10 19:56:30 -04:00
Ark0N aa84447899 Update README to include 'Terminal' in description 2026-06-11 00:38:34 +02:00
Ark0N a0e1a2e33b Update README.md 2026-06-11 00:36:51 +02:00
arkonandClaude Fable 5 6da22f0db0 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:06:12 +02:00
Ark0N dc9c4b3bda Merge pull request #117 from aakhter/pr/cod-86-codex-frontend
fix(codex): smaller first-frame write budget + scroll-up grace for codex
2026-06-10 22:59:40 +02:00
Ark0N 1cf5c8c8ad Merge pull request #116 from aakhter/pr/cod-35-codex-polish
fix(codex): strip alt-screen + scrollback-erase from the codex byte stream
2026-06-10 22:52:02 +02:00
arkonandClaude Fable 5 7eda39e7f7 fix(codex): reassemble chunk-split sequences before the strip; mouse parity on replay
Review fixes:

- Hold back a trailing partial CSI (digit-only intro, ≤7 chars) in
  _handleTerminalOutput and prepend it to the next chunk. PTY chunk
  boundaries are arbitrary, so '\x1b[?1049h' can arrive as '\x1b[?104' +
  '9h' — the per-chunk strip misses it, xterm obeys the reassembled toggle,
  and (with the matching ?1049l stripped) stays stuck in the scrollback-less
  alt buffer until the next replay. Complete sequences are never held; the
  carry resets with the other buffers in _resetBuffers.

- Replay path now also strips mouse-tracking enables (?1000-?1007), matching
  the live strip: buffers persisted BEFORE the live strip existed can still
  carry them, and a replayed ?1006h re-hijacks the scroll wheel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:47:02 +02:00
Ark0N f0db5f827f Merge pull request #115 from aakhter/pr/cod-78-security
feat(security): hook-event auth secret + tunnel password guard
2026-06-10 22:36:47 +02:00
arkonandClaude Fable 5 aa4e1ce9cf fix(security): deliver the hook secret to hooks + isolate its rate-limit bucket
Review fixes for COD-54:

- Generated hook curl commands now present X-Codeman-Hook-Secret, read from
  the secret file AT EXECUTION TIME via $CODEMAN_HOOK_SECRET_FILE (exported
  into every managed session's env by tmux buildEnvExports / the direct-PTY
  env builders). Without this, every local hook 401'd the moment a managed
  tunnel came up — the enforcement existed but nothing presented the secret.
  Path-not-value keeps the secret off command lines and out of config files,
  and running sessions pick up a newly generated secret with no respawn;
  server.start() ensures the file exists up front.

- Hook-secret failures now count into a DEDICATED per-IP bucket
  (hookSecretFailures) instead of the shared authFailures map. Legacy
  (pre-secret) hook configs fire constantly from 127.0.0.1; counting their
  401s against the shared bucket would 429 every cookie-less loopback
  request — locking out the Basic-Auth login path (and, through a tunnel,
  every client, since tunneled traffic also arrives as 127.0.0.1).

- docs/security-architecture.md: secret-gated hook exemption, dedicated
  bucket, COD-55 refusal, and the residual caveat for EXTERNAL loopback
  proxies (user-run cloudflared / tailscale serve), which the
  managed-tunnel probe cannot see.

- test/cod54-hook-event-auth.test.ts: +3 tests — login path unaffected
  after hook-bucket exhaustion; generated hooks reference the header +
  $CODEMAN_HOOK_SECRET_FILE without embedding the value; env builders
  export the path only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:31:09 +02:00
arkonandClaude Fable 5 b8cb4670dd fix(mobile,respawn-ui): unbury the session-options modal on phones; regroup the Respawn tab
Mobile fixes (user-reported: stuck in Session Options with no way to close):
- Modals now stack at z-index 1300, above the fixed mobile/tablet header
  (z-index 1200) that was burying the modal header and its close button —
  the full-screen modal was undismissable on phones
- Duration presets collapse to one compact 24px row (was a 3-row grid)
- Hide the tab detach (open-in-new-window) button on viewports <=768px

Respawn tab regrouped so its two features read as separate options:
- New green-tinted "Respawn loop" box wraps duration, presets, cycle
  steps, and the status/Enable row — a visual sibling of the blue
  auto-resume box; includes a short explanation of the loop
- Enable/status row moved from the top of the tab to the bottom of the
  box, so it no longer reads as a modal-level confirm button
- Font sizes unified: feature titles match; step checkboxes (2./3.)
  match the step labels (1./4.); "Respawn Cycle" renamed "Cycle Steps"

CLAUDE.md: add usage-limit-patterns.ts to the Session row; app.js ~3.7K

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:37:44 +02:00
Aamer AkhterandSaqeb Akhter fe821fb679 fix(codex): smaller first-frame write budget + scroll-up grace for codex
Two render-polish fixes for codex sessions in the terminal write pipeline:

- flushPendingWrites uses a 32KB first-frame budget for codex (vs 64KB for
  other modes). Codex's TUI emits dense synchronized redraws during
  thinking/high-effort phases; a smaller first frame keeps per-frame
  xterm/WebGL stalls short and avoids multi-second main-thread blocks.

- Sticky-scroll now honours a short grace window after a manual scroll-up
  gesture (USER_SCROLL_STICKY_SUPPRESS_MS = 1500ms). High-frequency codex
  "Working (Ns)" status ticks were snapping the viewport back to the bottom
  while the user tried to read earlier output. The wheel/touch scroll
  handlers record the gesture (_noteTerminalUserScroll); flushPendingWrites
  suppresses the auto-scroll-to-bottom and restores the preserved viewport
  via scrollToLine while the grace window is active.

Adds test/terminal-flush-budget.test.ts (vm-sandbox harness over
terminal-ui.js): codex vs non-codex first-frame budget, buffer-load
ownership, and the scroll-up suppression / viewport restore.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-10 13:34:45 -04:00
Aamer AkhterandSaqeb Akhter d7606366a2 fix(codex): strip alt-screen + scrollback-erase from the codex byte stream
Codex's TUI emits alternate-screen toggles (DECSET/DECRST 47/1047/1049),
scrollback-erase (CSI 3 J), and mouse-tracking enables (?1000-1007) during
startup and on every repaint. xterm.js obeys them: it switches to the
scrollback-less alternate buffer, wipes saved lines, and forwards the scroll
wheel to codex — so the user's conversation history both disappears and
becomes unreachable on each tab switch / pane refresh.

Strip these sequences in two places, leaving the visible-viewport erases
(2J / J) intact so codex can still repaint its own rows:

- Session._handleTerminalOutput: filter the live SSE/WS stream and the
  persisted terminal buffer at the source, for mode === 'codex'.
- GET /api/sessions/:id/terminal: apply the same strip to the replayed
  buffer (ALT_SCREEN_TOGGLE_PATTERN / ERASE_SCROLLBACK_PATTERN) so a
  tab-switch replay keeps full scrollback.

Adds test/codex-terminal-output.test.ts covering the strip (alt-screen and
3J removed, 2J/J preserved, Ctrl+L redraws preserved) and confirming codex
output passes through without Ink row-repair mangling.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-10 13:18:45 -04:00
Aamer Akhter 42f0b28c75 feat(security): hook-event auth secret + tunnel password guard
Two hardening fixes for the public-tunnel exposure path (COD-54 / COD-55).

COD-54 — gate the /api/hook-event localhost bypass when a tunnel is up:
`cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
loopback origin, so a tunneled hook request arrives with req.ip === 127.0.0.1
and the old bare-localhost bypass would pass it unauthenticated. Now:
- tunnel running  → bypass requires a shared per-instance hook secret
  (X-Codeman-Hook-Secret header; constant-time compare) + per-IP rate limiting
- tunnel not running (loopback-only, the normal case) → unchanged, so
  already-deployed credential-less hooks keep working.
New src/config/hook-secret.ts; auth middleware takes a getTunnelRunning probe
(wired from server.ts via tunnelManager.isRunning()).

COD-55 — refuse starting the Cloudflare tunnel without auth:
enabling the tunnel publishes full terminal control to a public URL; with no
CODEMAN_PASSWORD the auth middleware is inactive and the bind guard never trips
(tunnel binds loopback). PUT /api/settings now refuses tunnelEnabled:true with a
403 (before persisting) unless CODEMAN_PASSWORD is set or
CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 is acknowledged. New
isUnauthenticatedNetworkAcknowledged() in network-auth-policy; settings-ui
surfaces the refusal as an error toast and reverts the toggle.

Scope: the always-on CSRF/Origin guard, Host-header allowlist, and
network-auth-policy itself are already upstream (#113) and not re-proposed here.

Verification: tsc, eslint, prettier, check:frontend-syntax clean; full test:ci
green (2723 passed), incl. test/cod54-hook-event-auth and
test/routes/system-routes-tunnel-guard.
2026-06-10 12:25:26 -04:00
49 changed files with 3503 additions and 214 deletions
+18
View File
@@ -1,5 +1,23 @@
# aicodeman # aicodeman
## 0.9.14
### Patch Changes
- Security hardening for the tunnel exposure path, Codex terminal rendering fixes, and a mobile modal fix.
**Security (PR #115, COD-54/COD-55):**
- `/api/hook-event` localhost bypass is now gated while the managed Cloudflare tunnel is running: tunneled traffic arrives with a loopback source IP, so the bypass additionally requires a per-instance shared secret (`X-Codeman-Hook-Secret`, 256-bit, `~/.codeman/hook-secret`, mode 0600). Locally generated hook commands read the secret file at execution time via `$CODEMAN_HOOK_SECRET_FILE` (exported into every managed session's environment), so the value never lands on command lines or in case configs, and running sessions pick up a new secret without respawn. Failed presentations rate-limit in a dedicated per-IP bucket so misfiring legacy hooks can never lock out the Basic-Auth login path. With no tunnel running, behavior is unchanged.
- Enabling the Cloudflare tunnel now **refuses with 403** when no `CODEMAN_PASSWORD` is set (a public tunnel URL with no auth is effectively public RCE), unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly acknowledges the exposure. The settings UI surfaces the refusal as an error toast and reverts the toggle.
**Codex rendering (PRs #116, #117):**
- Alt-screen toggles (`?47/?1047/?1049`), scrollback-erase (`CSI 3 J`), and mouse-tracking enables (`?1000`–`?1007`) are stripped from the Codex byte stream (live + replay), so conversation history survives tab switches and the scroll wheel scrolls the viewport instead of being hijacked. Sequences split across PTY chunk boundaries are reassembled via a small carry before stripping, so a split `?1049h` can no longer trap xterm in the scrollback-less alt buffer.
- Smaller 32KB first-frame write budget for Codex sessions keeps dense synchronized redraws from stalling the renderer; a 1.5s grace window after a manual scroll-up suppresses sticky-scroll so high-frequency `• Working (Ns)` status ticks no longer snap the viewport back to the bottom while reading earlier output.
**Mobile:** session-options modal raised above the fixed mobile/tablet header (z-index 1300 vs 1200) so the close button is reachable on phones; Respawn tab controls regrouped.
**Docs:** security-architecture.md updated for the secret-gated hook bypass (including the external-proxy caveat) and the tunnel password guard; README documents auto-resume on usage limit.
## 0.9.13 ## 0.9.13
### Patch Changes ### Patch Changes
+5 -4
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.9.13 (must match `package.json`) **Version**: 0.9.14 (must match `package.json`)
## Project Overview ## Project Overview
@@ -102,6 +102,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically) - **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift - **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts` - **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
- **Multi-CLI prefix discipline** — Codeman supports Claude Code, OpenCode, and Codex (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts` / `codex-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design - **Multi-CLI prefix discipline** — Codeman supports Claude Code, OpenCode, and Codex (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts` / `codex-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3 - **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`. - **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
@@ -129,7 +130,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | | | **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts` | | | **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts` | |
| **Frontend** | `src/web/public/app.js` (~3.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | | | **Frontend** | `src/web/public/app.js` (~3.7K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts | | **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
@@ -173,7 +174,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
**Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7). **Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried; bug fixed in `b8cb467`), log viewers (2000), image popups (3000), local echo overlay (7).
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`. **Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
@@ -200,7 +201,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | | **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | | **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter | | **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated) | | **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) | | **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) | | **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
+6 -5
View File
@@ -5,11 +5,7 @@
<h2 align="center">Mission control for AI coding agents</h2> <h2 align="center">Mission control for AI coding agents</h2>
<p align="center"> <p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &mdash; One Dashboard &bull; Any Device</em> <em>Claude Code &bull; OpenCode &bull; Codex &bull; Terminal - One Dashboard &bull; Any Device</em>
</p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
</p> </p>
<p align="center"> <p align="center">
@@ -20,6 +16,10 @@
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests"> <img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
</p> </p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center"> <p align="center">
<img src="docs/images/subagent-demo.gif" alt="Codeman — parallel subagent visualization" width="900"> <img src="docs/images/subagent-demo.gif" alt="Codeman — parallel subagent visualization" width="900">
</p> </p>
@@ -214,6 +214,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
``` ```
- **Multi-layer idle detection** — completion messages, AI-powered idle check, output silence, token stability - **Multi-layer idle detection** — completion messages, AI-powered idle check, output silence, token stability
- **Auto-resume on usage limit** *(opt-in, off by default)* — when Claude halts on a subscription limit ("You've hit your limit · resets 3pm"), Codeman parses the reset time, waits it out plus a 2-minute safety buffer, then dismisses the rate-limit dialog and sends `continue` — so an overnight run survives the 5-hour window instead of stalling until morning. Recognizes every Claude Code limit-message format, retries if still limited, survives Codeman restarts, and holds respawn cycles while paused so `/clear` can't wipe the waiting conversation. Enable per session at the top of the Respawn tab
- **Circuit breaker** — prevents respawn thrashing when Claude is stuck (CLOSED -> HALF_OPEN -> OPEN states, tracks consecutive no-progress and repeated errors) - **Circuit breaker** — prevents respawn thrashing when Claude is stuck (CLOSED -> HALF_OPEN -> OPEN states, tracks consecutive no-progress and repeated errors)
- **Health scoring** — 0-100 health score with component scores for cycle success, circuit breaker state, iteration progress, and stuck recovery - **Health scoring** — 0-100 health score with component scores for cycle success, circuit breaker state, iteration progress, and stuck recovery
- **Built-in presets** — `solo-work` (3s idle, 60min), `subagent-workflow` (45s, 240min), `team-lead` (90s, 480min), `ralph-todo` (8s, 480min), `overnight-autonomous` (10s, 480min) - **Built-in presets** — `solo-work` (3s idle, 60min), `subagent-workflow` (45s, 240min), `team-lead` (90s, 480min), `ralph-todo` (8s, 480min), `overnight-autonomous` (10s, 480min)
+1
View File
@@ -216,6 +216,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
``` ```
- **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性 - **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性
- **用量限额自动恢复**(*可选,默认关闭*)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误) - **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复 - **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
- **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min) - **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min)
+53 -7
View File
@@ -124,7 +124,11 @@ loopback bind matters. The auth pipeline (`src/web/middleware/auth.ts`,
`onRequest` hook) runs in this order: `onRequest` hook) runs in this order:
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR 1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3). `/q/` short‑code path are exempt when `req.ip` is loopback (see §3). While the
**managed tunnel is running**, the hook‑event exemption additionally requires
the per‑instance `X-Codeman-Hook-Secret` header (COD‑54); failed presentations
are rate‑limited in a **dedicated bucket** (separate from Basic‑Auth failures)
so misfiring hooks can never lock out the login path.
2. **Session cookie** check — a valid `codeman_session` cookie short‑circuits to 2. **Session cookie** check — a valid `codeman_session` cookie short‑circuits to
allow. allow.
3. **HTTP Basic** check — correct credentials short‑circuit to allow and clear 3. **HTTP Basic** check — correct credentials short‑circuit to allow and clear
@@ -165,17 +169,29 @@ protection is unchanged.
with `req.ip = 127.0.0.1`**. The localhost‑only exemptions then treat those with `req.ip = 127.0.0.1`**. The localhost‑only exemptions then treat those
requests as local: requests as local:
- `POST /api/hook-event` — auth‑exempt for loopback. Bounded impact: it is - `POST /api/hook-event` — auth‑exempt for loopback **only while no managed tunnel
is running**. When Codeman's own tunnel is up, the exemption requires the
per‑instance shared secret (`X-Codeman-Hook-Secret`, 256‑bit hex in
`~/.codeman/hook-secret`, mode 0600, COD‑54). Local hook commands read the
secret file at execution time (`$CODEMAN_HOOK_SECRET_FILE`, exported into every
managed session), so they keep working — tunneled internet traffic can't know
it. Even without the secret the impact is bounded: the route is
`HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can `HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can
drive respawn signals, SSE broadcasts, push notifications, and transcript drive respawn signals, SSE broadcasts, push notifications, and transcript
watching — **not** arbitrary terminal input or file reads. It is a watching — **not** arbitrary terminal input or file reads. ⚠️ The gate keys off
session‑disruption / notification‑spoofing surface, not RCE. the **managed** tunnel — an externally run loopback proxy (your own
`cloudflared`, `tailscale serve`) is invisible to it, so the plain loopback
exemption still applies there (prefer `tailscale serve`, which authenticates at
the tailnet layer). Hook configs regenerated since COD‑54 always present the
header, so a future release can require the secret unconditionally.
- QR `/q/` — still protected by its own short‑code brute‑force limiter - QR `/q/` — still protected by its own short‑code brute‑force limiter
(10 failures / 60s against a 62⁶ space). (10 failures / 60s against a 62⁶ space).
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is **Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
up (it does not gate the hook‑event exemption, but it gates everything else and up — it gates everything except the (secret‑gated) hook exemption and is the
is the documented practice). Prefer `tailscale serve` (below), which authenticates documented practice; since COD‑55 enabling the managed tunnel **refuses** to start
without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly
acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates
at the tailnet layer so untrusted clients never reach the loopback port at all. at the tailnet layer so untrusted clients never reach the loopback port at all.
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense) ### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
@@ -304,7 +320,37 @@ injected from API JSON (`innerHTML`), not via `file-raw`, so they are unaffected
`/api/download` additionally refuses a blocklist of sensitive paths `/api/download` additionally refuses a blocklist of sensitive paths
(`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, `.aws/credentials`, …). This (`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, `.aws/credentials`, …). This
is **defense‑in‑depth, not the primary boundary** — the realpath containment is is **defense‑in‑depth, not the primary boundary** — the realpath containment is
the control. the control. The blocklist patterns are shared (`src/web/sensitive-path.ts`) with
the attachment guard below.
### External attachments (registry) & the magic‑link trust boundary
Live external attachments (`src/attachment-registry.ts`) mint an `att_<uuid>` id
for a host file so browser requests carry the id, never an absolute path. Serving
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, 50 MB cap,
`nosniff`) and re‑resolves the symlink + re‑checks the **attachment guard**
(`src/config/attachment-guard.ts`: the shared sensitive‑path blocklist **plus**
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
routes, attachments are intentionally **cross‑workspace** — so the effective gate
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
realpath containment.
Two registration paths, with **different trust**:
- **Explicit `POST /api/sessions/:id/attachments`** (and `codeman attach`, which
POSTs directly inside a managed session) — a deliberate, Origin‑guarded HTTP
request. Allowed cross‑workspace (subject to the guard). This is the supported
path for codeman‑publish and the `~/.codeman` review‑card loop.
- **Terminal `codeman://attach?path=…` magic links** — scanned passively from
session output. Terminal output is **attacker‑influenceable** (a prompt‑injected
session can print an arbitrary path), and registration here is server‑side with
no Origin gate and broadcasts the `rawUrl` over SSE to all clients. This path is
therefore **force‑confined to the session workspace** (`forceWorkspaceConfinement`
in `registerExternalAttachment`, wired in `WebServer.registerAttachment`),
regardless of the global confine setting — a passive magic link cannot expose a
file outside the session's own workspace. Cross‑workspace attach must go through
the explicit POST path above.
### SSE log‑tail route — intentional extra read roots ### SSE log‑tail route — intentional extra read roots
+9 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.9.13", "version": "0.9.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aicodeman", "name": "aicodeman",
"version": "0.9.13", "version": "0.9.14",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
@@ -20,6 +20,7 @@
"@fastify/static": "^9.1.3", "@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0", "@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
@@ -4525,6 +4526,12 @@
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@xterm/addon-serialize": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": { "node_modules/@xterm/addon-unicode11": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.9.13", "version": "0.9.14",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -61,6 +61,7 @@
"@fastify/static": "^9.1.3", "@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0", "@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
+1
View File
@@ -45,6 +45,7 @@ run('copy template', 'cp src/templates/case-template.md dist/templates/');
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/'); run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js'); run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js'); run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/lib/addon-serialize.js --minify --outfile=dist/web/public/vendor/xterm-addon-serialize.min.js');
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js'); run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js'); run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js'); run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
+3
View File
@@ -252,6 +252,7 @@ if (isGlobalInstall) {
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..'); const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..'); const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
const serializeDir = join(require.resolve('@xterm/addon-serialize'), '..', '..');
const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..'); const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..'); const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
const vendorDir = join(srcDir, 'web', 'public', 'vendor'); const vendorDir = join(srcDir, 'web', 'public', 'vendor');
@@ -264,12 +265,14 @@ if (isGlobalInstall) {
try { try {
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' }); execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' }); execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(serializeDir, 'lib', 'addon-serialize.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-serialize.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' }); execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/')); console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
} catch { } catch {
// Fallback: copy unminified // Fallback: copy unminified
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js')); copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js')); copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
copyFileSync(join(serializeDir, 'lib', 'addon-serialize.js'), join(vendorDir, 'xterm-addon-serialize.min.js'));
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js')); copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)')); console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
} }
+35
View File
@@ -0,0 +1,35 @@
/**
* @fileoverview Parses terminal magic links that request attachment cards.
*/
import { isAbsolute } from 'node:path';
import { isSupportedAttachmentExtension } from './attachment-registry.js';
const MAGIC_LINK_RE = /codeman:\/\/attach\?([^\s<>"']+)/g;
export function parseAttachmentMagicLinks(data: string): string[] {
const results: string[] = [];
const seen = new Set<string>();
for (const match of data.matchAll(MAGIC_LINK_RE)) {
const query = trimTrailingPunctuation(match[1] || '');
try {
const params = new URLSearchParams(query);
const filePath = params.get('path');
if (!filePath || !isAbsolute(filePath)) continue;
const extension = filePath.split('.').pop()?.toLowerCase() || '';
if (!isSupportedAttachmentExtension(extension)) continue;
if (seen.has(filePath)) continue;
seen.add(filePath);
results.push(filePath);
} catch {
// Ignore malformed terminal text. Magic links are advisory.
}
}
return results;
}
function trimTrailingPunctuation(value: string): string {
return value.replace(/[),.;:]+$/g, '');
}
+237
View File
@@ -0,0 +1,237 @@
/**
* @fileoverview In-memory attachment registry for live external document references.
*
* Session-local files keep using the existing workspace-scoped file routes. This
* registry is only for explicit, live external attachments that need a stable ID
* so browser requests never contain arbitrary absolute paths.
*/
import { randomUUID } from 'node:crypto';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { basename, extname, isAbsolute } from 'node:path';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { validateSessionFilePath } from './web/route-helpers.js';
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set(['png', 'pdf', 'docx', 'pptx', 'md', 'txt']);
export type AttachmentSource = 'detected' | 'external';
export interface AttachmentRecord {
attachmentId: string;
sessionId: string;
filePath: string;
fileName: string;
extension: string;
attachmentType: AttachmentDetectedType;
size: number;
mtimeMs: number;
timestamp: number;
source: AttachmentSource;
}
export interface AttachmentRegistrationResult extends AttachmentDetectedEvent {
attachmentId: string;
source: AttachmentSource;
rawUrl: string;
previewUrl: string;
thumbnailUrl: string;
}
export class AttachmentRegistrationError extends Error {
constructor(
message: string,
readonly statusCode: number = 400
) {
super(message);
}
}
/** Per-session attachment cap. Bounds memory against a client (or a
* prompt-injected magic-link flood) registering unbounded distinct paths. */
const MAX_ATTACHMENTS_PER_SESSION = 200;
class AttachmentRegistry {
private recordsBySession = new Map<string, Map<string, AttachmentRecord>>();
register(record: AttachmentRecord): void {
let records = this.recordsBySession.get(record.sessionId);
if (!records) {
records = new Map();
this.recordsBySession.set(record.sessionId, records);
}
records.set(record.attachmentId, record);
// Evict oldest (insertion-order) entries beyond the cap.
while (records.size > MAX_ATTACHMENTS_PER_SESSION) {
const oldest = records.keys().next().value;
if (oldest === undefined) break;
records.delete(oldest);
}
}
get(sessionId: string, attachmentId: string): AttachmentRecord | undefined {
return this.recordsBySession.get(sessionId)?.get(attachmentId);
}
findByFilePath(sessionId: string, filePath: string): AttachmentRecord | undefined {
const records = this.recordsBySession.get(sessionId);
if (!records) return undefined;
for (const record of records.values()) {
if (record.filePath === filePath) return record;
}
return undefined;
}
clearSession(sessionId: string): void {
this.recordsBySession.delete(sessionId);
}
}
export const attachmentRegistry = new AttachmentRegistry();
export function isSupportedAttachmentExtension(extension: string): boolean {
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
}
export function getAttachmentType(extension: string): AttachmentDetectedType {
const normalized = extension.toLowerCase().replace(/^\./, '');
if (normalized === 'png') return 'image';
if (normalized === 'pdf') return 'pdf';
if (normalized === 'pptx') return 'presentation';
if (normalized === 'md') return 'markdown';
if (normalized === 'txt') return 'text';
return 'document';
}
export function buildAttachmentRoutes(
sessionId: string,
attachmentId: string
): {
rawUrl: string;
previewUrl: string;
thumbnailUrl: string;
} {
const encodedId = encodeURIComponent(attachmentId);
return {
rawUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/raw`,
previewUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/preview`,
thumbnailUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/thumbnail`,
};
}
export function buildFileThumbnailRoute(sessionId: string, relativePath: string): string {
return `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(relativePath)}`;
}
export function attachmentRecordToEvent(record: AttachmentRecord): AttachmentRegistrationResult {
const routes = buildAttachmentRoutes(record.sessionId, record.attachmentId);
return {
sessionId: record.sessionId,
filePath: record.fileName,
relativePath: '',
fileName: record.fileName,
extension: record.extension,
attachmentType: record.attachmentType,
timestamp: record.timestamp,
size: record.size,
attachmentId: record.attachmentId,
source: record.source,
...routes,
};
}
/** Options for {@link registerExternalAttachment}. */
export interface RegisterExternalAttachmentOptions {
/**
* The registering session's working directory. Required to enforce workspace
* confinement — either when the global mode is enabled
* (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`) or when
* {@link forceWorkspaceConfinement} is set for this call.
*/
sessionWorkingDir?: string;
/**
* Force workspace confinement for THIS registration regardless of the global
* setting. Used by the terminal-output `codeman://attach` magic-link scanner:
* terminal output is attacker-influenceable (a prompt-injected session can
* print an arbitrary path), so passive magic links may only reference files
* inside the session workspace. Deliberate cross-workspace attachment still
* works through the explicit, Origin-guarded `POST /attachments` route and the
* `codeman attach` CLI (which POSTs directly when a session id is known).
*/
forceWorkspaceConfinement?: boolean;
}
export async function registerExternalAttachment(
sessionId: string,
requestedPath: string,
options: RegisterExternalAttachmentOptions = {}
): Promise<AttachmentRegistrationResult> {
if (!requestedPath || !isAbsolute(requestedPath)) {
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
}
let resolvedPath: string;
try {
resolvedPath = realpathSync(requestedPath);
} catch {
throw new AttachmentRegistrationError('Attachment file not found', 404);
}
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
// path before doing anything else.
const guard = await loadAttachmentGuardConfig();
if (guard.confineToWorkspace || options.forceWorkspaceConfinement) {
// Workspace-confined: the file MUST resolve inside the session's workspace.
// Applies when the global strict mode is on (opt-in, default OFF) OR when
// the caller forces it for this registration (the magic-link scanner — see
// forceWorkspaceConfinement). Strictly more restrictive than the blocklist.
const workingDir = options.sessionWorkingDir;
if (!workingDir || !validateSessionFilePath(workingDir, resolvedPath)) {
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
}
}
// Blocklist (DEFAULT, also applied alongside confinement as defense in
// depth): pre-populated secret locations + the /root and /etc trees + any
// operator-configured extra trees. Symlinks are already resolved above.
// Cross-workspace attachment of non-blocked files stays allowed, so
// codeman-publish and the ~/.codeman review loop keep working.
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
}
const extension = extname(resolvedPath).toLowerCase().replace(/^\./, '');
if (!isSupportedAttachmentExtension(extension)) {
throw new AttachmentRegistrationError('Unsupported attachment type');
}
const stat = await fs.stat(resolvedPath);
if (typeof stat.isFile === 'function' && !stat.isFile()) {
throw new AttachmentRegistrationError('Attachment path is not a file');
}
const existing = attachmentRegistry.findByFilePath(sessionId, resolvedPath);
if (existing) {
existing.size = stat.size;
existing.mtimeMs = stat.mtimeMs ?? 0;
existing.timestamp = Date.now();
return attachmentRecordToEvent(existing);
}
const record: AttachmentRecord = {
attachmentId: `att_${randomUUID()}`,
sessionId,
filePath: resolvedPath,
fileName: basename(resolvedPath),
extension,
attachmentType: getAttachmentType(extension),
size: stat.size,
mtimeMs: stat.mtimeMs ?? 0,
timestamp: Date.now(),
source: 'external',
};
attachmentRegistry.register(record);
return attachmentRecordToEvent(record);
}
+93
View File
@@ -10,11 +10,17 @@
import { Command } from 'commander'; import { Command } from 'commander';
import chalk from 'chalk'; import chalk from 'chalk';
import { createRequire } from 'module'; import { createRequire } from 'module';
import http from 'node:http';
import https from 'node:https';
import { readFileSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { dataPath } from './config/instance.js';
import { getSessionManager } from './session-manager.js'; import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js'; import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js'; import { getRalphLoop } from './ralph-loop.js';
import { getStore } from './state-store.js'; import { getStore } from './state-store.js';
import { getErrorMessage } from './types.js'; import { getErrorMessage } from './types.js';
import { isSupportedAttachmentExtension } from './attachment-registry.js';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { version: string }; const pkg = require('../package.json') as { version: string };
@@ -23,6 +29,93 @@ const program = new Command();
program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version); program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
function makeAttachmentMagicLink(filePath: string): string {
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
}
function readCodemanEnv(): Record<string, string> {
const envPath = dataPath('.env');
try {
const text = readFileSync(envPath, 'utf-8');
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
} catch {
return {};
}
}
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
const envFile = readCodemanEnv();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
const body = JSON.stringify({ path: filePath });
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve) => {
const headers: Record<string, string | number> = {
Accept: 'application/json',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
};
if (password) {
headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method: 'POST',
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
},
(res) => {
res.resume();
res.on('end', () => resolve(Boolean(res.statusCode && res.statusCode >= 200 && res.statusCode < 300)));
}
);
req.on('error', () => resolve(false));
req.write(body);
req.end();
});
}
program
.command('attach <path>')
.description('Show an attachment card for a local file')
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL or https://127.0.0.1:3000)')
.action(async (filePath, options) => {
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
console.error(chalk.red('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
process.exit(1);
}
const sessionId = options.session || process.env.CODEMAN_SESSION_ID;
const apiUrl = options.url || process.env.CODEMAN_API_URL || 'https://127.0.0.1:3000';
if (sessionId && (await postAttachment(apiUrl, sessionId, filePath))) {
console.log(chalk.green('✓ Attachment card requested'));
return;
}
console.log(makeAttachmentMagicLink(filePath));
});
// ============ Session Commands ============ // ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions'); const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
+133
View File
@@ -0,0 +1,133 @@
/**
* @fileoverview Attachment path-guard configuration (COD-53).
*
* Governs which host files may be registered as cross-workspace attachments
* and served to the browser. Two operator-facing knobs, both with safe
* defaults:
*
* 1. **Blocked-path blocklist (DEFAULT, configurable).** Pre-populated with the
* shared secret-location blocklist (`isSensitivePath`) PLUS the directory
* trees `/root` and `/etc` (anything under them is blocked). The operator
* EXTENDS — never shrinks — this set with additional absolute directory
* trees via the settings key `attachmentBlockedPaths: string[]` and/or the
* env var `CODEMAN_ATTACHMENT_BLOCKED_PATHS` (comma-separated).
*
* 2. **Workspace confinement (OPTIONAL, default OFF).** When enabled, an
* attachment must resolve INSIDE the registering session's workingDir
* (reusing `validateSessionFilePath` containment semantics). This is
* strictly more restrictive than the blocklist and breaks intentional
* cross-workspace attachment (codeman-publish, the ~/.codeman review-card
* loop), so it is OFF by default. Toggle via settings
* `attachmentConfineToWorkspace: boolean` and/or env
* `CODEMAN_ATTACHMENT_CONFINE` (`1`/`true`).
*
* All paths passed to the predicates here MUST be absolute and symlink-resolved
* (realpath) by the caller, mirroring `isSensitivePath`'s contract.
*
* @module config/attachment-guard
*/
import { sep } from 'node:path';
import { isSensitivePath } from '../web/sensitive-path.js';
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
/**
* Directory trees blocked by default, IN ADDITION to the secret-location
* blocklist in `isSensitivePath`. Anything resolving under one of these trees
* is rejected. Pre-populated with the root account home and the system config
* tree (which already partially overlaps `isSensitivePath`'s `/etc/shadow`
* etc., but here we block the WHOLE tree).
*/
export const DEFAULT_BLOCKED_TREES: readonly string[] = ['/root', '/etc'];
/** Settings key carrying extra blocked directory trees (extends the defaults). */
export const ATTACHMENT_BLOCKED_PATHS_SETTING = 'attachmentBlockedPaths';
/** Settings key carrying the workspace-confinement toggle. */
export const ATTACHMENT_CONFINE_SETTING = 'attachmentConfineToWorkspace';
/** Resolved attachment-guard configuration. */
export interface AttachmentGuardConfig {
/** Pre-populated default trees PLUS any operator extras. */
blockedTrees: string[];
/** Whether attachments must resolve inside the session workspace. */
confineToWorkspace: boolean;
}
/** Normalizes a tree prefix: trim, drop trailing separators (but keep root). */
function normalizeTree(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
// Strip trailing slashes so '/etc/' and '/etc' behave the same; never reduce
// a bare separator to empty.
const stripped = trimmed.replace(/[/\\]+$/, '');
return stripped || trimmed[0];
}
/**
* Returns true if `absPath` (absolute, symlink-resolved) is the tree itself or
* lives under it. Uses path-separator-aware matching so `/etc` does NOT block
* an unrelated `/etcetera/notes.md`.
*/
export function isUnderTree(absPath: string, tree: string): boolean {
const t = normalizeTree(tree);
if (!t) return false;
if (absPath === t) return true;
return absPath.startsWith(t.endsWith(sep) ? t : t + sep);
}
/** Parses the comma-separated env override into a list of normalized trees. */
function parseEnvBlockedTrees(): string[] {
const raw = process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
if (!raw) return [];
return raw
.split(',')
.map(normalizeTree)
.filter((t) => t.length > 0);
}
/** Parses the env confinement toggle (`1`/`true`/`yes`/`on`, case-insensitive). */
function parseEnvConfine(): boolean | undefined {
const raw = process.env.CODEMAN_ATTACHMENT_CONFINE;
if (raw === undefined) return undefined;
return /^(1|true|yes|on)$/i.test(raw.trim());
}
/**
* Loads the effective attachment-guard config by merging the pre-populated
* defaults with settings.json and env overrides. Env wins over settings for the
* confinement toggle; blocked-tree extras from BOTH sources are unioned on top
* of the defaults (operators can only EXTEND, never shrink, the blocked set).
*/
export async function loadAttachmentGuardConfig(): Promise<AttachmentGuardConfig> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const settingsTrees = Array.isArray(settings[ATTACHMENT_BLOCKED_PATHS_SETTING])
? (settings[ATTACHMENT_BLOCKED_PATHS_SETTING] as unknown[])
.filter((v): v is string => typeof v === 'string')
.map(normalizeTree)
.filter((t) => t.length > 0)
: [];
const blockedTrees = Array.from(new Set([...DEFAULT_BLOCKED_TREES, ...settingsTrees, ...parseEnvBlockedTrees()]));
const envConfine = parseEnvConfine();
const settingsConfine = settings[ATTACHMENT_CONFINE_SETTING] === true;
const confineToWorkspace = envConfine ?? settingsConfine;
return { blockedTrees, confineToWorkspace };
}
/**
* Attachment-specific blocklist check. Builds on the shared `isSensitivePath`
* base (secret locations, shared with `/api/download`) and ADDS the configured
* directory trees (`/root`, `/etc`, plus operator extras). `absPath` must be
* absolute and symlink-resolved.
*
* NOTE: this is intentionally a SUPERSET of `isSensitivePath` so `/api/download`
* behavior is NOT changed — only attachment registration/serving uses this.
*/
export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly string[]): boolean {
if (isSensitivePath(absPath)) return true;
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
}
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview Per-instance shared hook secret (COD-54).
*
* Claude Code hooks POST to `/api/hook-event` with no Basic-Auth credentials,
* relying on a localhost bypass in `web/middleware/auth.ts`. That bypass is safe
* for loopback-only deploys, but a `cloudflared --url http://127.0.0.1:port`
* tunnel proxies internet traffic INTO the loopback origin, so tunneled requests
* arrive with `req.ip === 127.0.0.1` and would otherwise pass the bypass and
* drive respawn/Ralph signals unauthenticated.
*
* To close that hole WITHOUT breaking the loop's own (credential-less) hook
* channel, every locally-generated hook command now presents a per-instance
* shared secret in the `X-Codeman-Hook-Secret` header. The middleware requires
* a matching secret for the bypass WHEN A TUNNEL IS RUNNING. Tunneled internet
* traffic can't know the secret; local hooks (which we generate) do.
*
* Storage mirrors the VAPID-key pattern in `push-store.ts`: a small file under
* the instance data dir (`dataPath('hook-secret')`), read-if-present /
* generate-if-missing, stable across restarts. 256 bits of hex.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { getDataDir, dataPath } from './instance.js';
/** HTTP header local hooks use to present the shared secret. */
export const HOOK_SECRET_HEADER = 'X-Codeman-Hook-Secret';
/** Number of random bytes in the secret (256 bits → 64 hex chars). */
const SECRET_BYTES = 32;
let cachedSecret: string | null = null;
/**
* Return this instance's hook secret, generating and persisting it on first use.
* Stable across restarts. Cached in-process after the first read.
*/
export function getHookSecret(): string {
if (cachedSecret) return cachedSecret;
const secretFile = dataPath('hook-secret');
if (existsSync(secretFile)) {
try {
const raw = readFileSync(secretFile, 'utf-8').trim();
if (raw) {
cachedSecret = raw;
return cachedSecret;
}
// Empty/whitespace file — fall through and regenerate.
} catch {
// Unreadable — fall through and regenerate.
}
}
const secret = randomBytes(SECRET_BYTES).toString('hex');
try {
mkdirSync(getDataDir(), { recursive: true });
// Owner-only perms — the secret gates the hook bypass.
writeFileSync(secretFile, secret, { mode: 0o600 });
} catch {
// Best-effort persistence: even if the write fails we still return a usable
// secret for this process so hooks/middleware agree within this run.
}
cachedSecret = secret;
return cachedSecret;
}
+10 -2
View File
@@ -3,8 +3,9 @@
* *
* Generates `.claude/settings.local.json` with hook definitions that POST * Generates `.claude/settings.local.json` with hook definitions that POST
* to Codeman's `/api/hook-event` endpoint when Claude Code fires hooks. * to Codeman's `/api/hook-event` endpoint when Claude Code fires hooks.
* Uses `$CODEMAN_API_URL` and `$CODEMAN_SESSION_ID` env vars (set on every * Uses `$CODEMAN_API_URL`, `$CODEMAN_SESSION_ID`, and `$CODEMAN_HOOK_SECRET_FILE`
* managed session) so the config is static per case directory. * env vars (set on every managed session) so the config is static per case
* directory and free of secret values.
* *
* Key exports: * Key exports:
* - `generateHooksConfig()` — returns hooks object for settings.local.json * - `generateHooksConfig()` — returns hooks object for settings.local.json
@@ -41,11 +42,18 @@ import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
export function generateHooksConfig(): { hooks: Record<string, unknown[]> } { export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
// Read Claude Code's stdin JSON and forward it as the data field. // Read Claude Code's stdin JSON and forward it as the data field.
// Falls back to empty object if stdin is unavailable or malformed. // Falls back to empty object if stdin is unavailable or malformed.
// COD-54: present the per-instance hook secret so the bypass keeps working while
// a tunnel is running. The value is read from the secret file AT EXECUTION TIME
// (path via $CODEMAN_HOOK_SECRET_FILE, set in every managed session's env), so it
// never lands in this config and rotation needs no respawn. If the var/file is
// missing the header is empty — the middleware then allows the request only on
// the plain loopback bypass (tunnel down), same as pre-secret behavior.
const curlCmd = (event: HookEventType) => const curlCmd = (event: HookEventType) =>
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` + `HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` + `printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` + `curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
`-H 'Content-Type: application/json' ` + `-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- ` + `--data @- ` +
`2>/dev/null || true`; `2>/dev/null || true`;
+55 -8
View File
@@ -12,7 +12,7 @@ import { EventEmitter } from 'node:events';
import { watch, type FSWatcher } from 'chokidar'; import { watch, type FSWatcher } from 'chokidar';
import { basename, extname, relative } from 'node:path'; import { basename, extname, relative } from 'node:path';
import { statSync } from 'node:fs'; import { statSync } from 'node:fs';
import type { ImageDetectedEvent } from './types.js'; import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
import { KeyedDebouncer } from './utils/index.js'; import { KeyedDebouncer } from './utils/index.js';
// ========== Types ========== // ========== Types ==========
@@ -20,7 +20,13 @@ import { KeyedDebouncer } from './utils/index.js';
// ========== Constants ========== // ========== Constants ==========
/** Supported image file extensions (lowercase) */ /** Supported image file extensions (lowercase) */
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']); // PNG stays on the image-popup path: it's the dominant screenshot format and the
// frontend only wires the `image:detected` popup today. The attachment-card UI
// that would consume `attachment:detected` for images is out of scope for this
// PR, so routing PNG to it would silently break the dropped-screenshot popup.
const IMAGE_POPUP_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
const ATTACHMENT_EXTENSIONS = new Set(['.pdf', '.docx', '.pptx']);
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
/** Time to wait for file writes to stabilize (ms) */ /** Time to wait for file writes to stabilize (ms) */
const STABILITY_THRESHOLD_MS = 500; const STABILITY_THRESHOLD_MS = 500;
@@ -166,8 +172,8 @@ export class ImageWatcher extends EventEmitter {
} }
const ext = extname(path).toLowerCase(); const ext = extname(path).toLowerCase();
// Don't ignore directories (needed for watching to work) // Don't ignore directories (needed for watching to work)
// Ignore files that aren't images // Ignore files that aren't previewable images/documents
return ext !== '' && !IMAGE_EXTENSIONS.has(ext); return ext !== '' && !DETECTED_FILE_EXTENSIONS.has(ext);
}, },
}); });
@@ -229,15 +235,16 @@ export class ImageWatcher extends EventEmitter {
/** /**
* Handle a new file being detected. * Handle a new file being detected.
* Verifies it's an image and emits the detection event. * Verifies it's a previewable image/document and emits the detection event.
*/ */
private handleNewFile(sessionId: string, filePath: string): void { private handleNewFile(sessionId: string, filePath: string): void {
const ext = extname(filePath).toLowerCase(); const ext = extname(filePath).toLowerCase();
// Double-check it's an image extension // Double-check it's a supported extension
if (!IMAGE_EXTENSIONS.has(ext)) { if (!DETECTED_FILE_EXTENSIONS.has(ext)) {
return; return;
} }
const isAttachment = ATTACHMENT_EXTENSIONS.has(ext);
// Burst limit: skip if too many images detected for this session in a short window // Burst limit: skip if too many images detected for this session in a short window
const now = Date.now(); const now = Date.now();
@@ -259,7 +266,11 @@ export class ImageWatcher extends EventEmitter {
// Debounce rapid file creation (e.g., multiple screenshots quickly) // Debounce rapid file creation (e.g., multiple screenshots quickly)
this.fileDeb.schedule(filePath, () => { this.fileDeb.schedule(filePath, () => {
this.fileToSession.delete(filePath); this.fileToSession.delete(filePath);
this.emitImageDetected(sessionId, filePath); if (isAttachment) {
this.emitAttachmentDetected(sessionId, filePath);
} else {
this.emitImageDetected(sessionId, filePath);
}
// Increment burst count on actual emission (not on detection) // Increment burst count on actual emission (not on detection)
const b = this.burstTrackers.get(sessionId); const b = this.burstTrackers.get(sessionId);
if (b) b.count++; if (b) b.count++;
@@ -294,6 +305,42 @@ export class ImageWatcher extends EventEmitter {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId); this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
} }
} }
/**
* Emit the attachment:detected event with file metadata.
*/
private emitAttachmentDetected(sessionId: string, filePath: string): void {
try {
const stat = statSync(filePath);
const fileName = basename(filePath);
const workingDir = this.sessionDirs.get(sessionId);
const relativePath = workingDir ? relative(workingDir, filePath) : fileName;
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
const event: AttachmentDetectedEvent = {
sessionId,
filePath,
relativePath,
fileName,
extension,
attachmentType: this.getAttachmentType(extension),
timestamp: Date.now(),
size: stat.size,
};
this.emit('attachment:detected', event);
} catch (error) {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
}
}
private getAttachmentType(extension: string): AttachmentDetectedType {
if (extension === 'png') return 'image';
if (extension === 'pdf') return 'pdf';
if (extension === 'docx') return 'document';
if (extension === 'pptx') return 'presentation';
return 'document';
}
} }
// Export singleton instance for convenience // Export singleton instance for convenience
+5
View File
@@ -11,6 +11,7 @@
import type { ClaudeMode, EffortLevel } from './types.js'; import type { ClaudeMode, EffortLevel } from './types.js';
import { isEffortLevel } from './types.js'; import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js'; import { getAugmentedPath } from './utils/index.js';
import { dataPath } from './config/instance.js';
/** /**
* Build Claude CLI permission flags based on the configured mode. * Build Claude CLI permission flags based on the configured mode.
@@ -113,6 +114,8 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
CODEMAN_MUX: '1', CODEMAN_MUX: '1',
CODEMAN_SESSION_ID: sessionId, CODEMAN_SESSION_ID: sessionId,
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000', CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
}; };
} }
@@ -149,5 +152,7 @@ export function buildShellEnv(sessionId: string): Record<string, string | undefi
CODEMAN_MUX: '1', CODEMAN_MUX: '1',
CODEMAN_SESSION_ID: sessionId, CODEMAN_SESSION_ID: sessionId,
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000', CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
}; };
} }
+63
View File
@@ -80,6 +80,7 @@ import {
import { SessionAutoOps } from './session-auto-ops.js'; import { SessionAutoOps } from './session-auto-ops.js';
import { detectUsageLimitPause } from './usage-limit-patterns.js'; import { detectUsageLimitPause } from './usage-limit-patterns.js';
import { SessionTaskCache } from './session-task-cache.js'; import { SessionTaskCache } from './session-task-cache.js';
import { parseAttachmentMagicLinks } from './attachment-magic.js';
export type { BackgroundTask } from './task-tracker.js'; export type { BackgroundTask } from './task-tracker.js';
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js'; export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
@@ -259,6 +260,9 @@ export class Session extends EventEmitter {
private _messages: ClaudeMessage[] = []; private _messages: ClaudeMessage[] = [];
private _lineBuffer: string = ''; private _lineBuffer: string = '';
private _lineBufferFlushTimer: NodeJS.Timeout | null = null; private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
// Codex only: trailing partial CSI held back so sequences split across PTY
// chunks can't slip past the alt-screen/scrollback strip (see _handleTerminalOutput)
private _codexSeqCarry: string = '';
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null; private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null; private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
@@ -308,6 +312,9 @@ export class Session extends EventEmitter {
private _parentAgentId: string | null = null; private _parentAgentId: string | null = null;
private _childAgentIds: string[] = []; private _childAgentIds: string[] = [];
// Bounded dedup set for terminal attachment magic-links already requested.
private _attachmentMagicSeen = new Set<string>();
// Nice prioritying configuration // Nice prioritying configuration
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG }; private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
@@ -1091,6 +1098,61 @@ export class Session extends EventEmitter {
} }
private _handleTerminalOutput(data: string): void { private _handleTerminalOutput(data: string): void {
// Codex emits sequences that wipe xterm.js scrollback, plus mouse-tracking
// enables that hijack the scroll wheel so the user can't reach scrollback:
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
// scrollback) — \x1b[?...l switches back.
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
// the visible viewport — are left intact; the TUI repaints those rows.)
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
// xterm.js forwards wheel events to codex instead of scrolling the
// viewport, so the conversation is in scrollback but unreachable.
// (Focus events at ?1004 are left alone — codeman uses them for
// active-tab detection.)
// Strip them at the source so neither the persisted buffer nor the live
// SSE/WS stream carries them, keeping everything in the main buffer with
// scrollback intact. Codex's cursor-positioned redraws overwrite only the
// cells they actually target, so the non-erased rows keep their content.
if (this.mode === 'codex') {
// Reassemble sequences split across PTY chunk boundaries first: a chunk
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
// strip below and leave xterm stuck in the scrollback-less alt buffer
// until the next buffer replay. Hold back an incomplete digit-only CSI
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
// prepend it to the next chunk; complete sequences are never held.
data = this._codexSeqCarry + data;
this._codexSeqCarry = '';
// eslint-disable-next-line no-control-regex
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
if (splitTail) {
this._codexSeqCarry = splitTail[0];
data = data.slice(0, -splitTail[0].length);
if (!data) return;
}
data = data
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[3J/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
}
// Scan terminal output for `codeman://attach?path=...` magic links and emit
// an attachmentRequested event for each newly-seen absolute path. The web
// server turns these into registered attachment cards.
const attachmentPaths = parseAttachmentMagicLinks(data);
for (const attachmentPath of attachmentPaths) {
if (this._attachmentMagicSeen.has(attachmentPath)) continue;
this._attachmentMagicSeen.add(attachmentPath);
if (this._attachmentMagicSeen.size > 200) {
const oldest = this._attachmentMagicSeen.values().next().value;
if (oldest) this._attachmentMagicSeen.delete(oldest);
}
this.emit('attachmentRequested', { sessionId: this.id, path: attachmentPath, timestamp: Date.now() });
}
// BufferAccumulator handles auto-trimming when max size exceeded // BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data); this._terminalBuffer.append(data);
this._lastActivityAt = Date.now(); this._lastActivityAt = Date.now();
@@ -1711,6 +1773,7 @@ export class Session extends EventEmitter {
this._errorBuffer = ''; this._errorBuffer = '';
this._messages = []; this._messages = [];
this._lineBuffer = ''; this._lineBuffer = '';
this._codexSeqCarry = '';
this._lastActivityAt = Date.now(); this._lastActivityAt = Date.now();
} }
+9 -1
View File
@@ -426,7 +426,12 @@ export function formatPaneSnapshot(
geometry: { cols: number; rows: number; cursorX: number; cursorY: number } geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
): string { ): string {
const cols = Math.max(1, geometry.cols); const cols = Math.max(1, geometry.cols);
const paintCols = Math.max(1, cols - 1); // Paint the full pane width. Earlier this dropped the rightmost column
// (cols - 1) out of caution about last-column autowrap, but every painted
// row is immediately followed by an absolute cursor-position CSI (the next
// row's `\x1b[r;1H`, or the final cursor move), which cancels xterm's
// pending-wrap state before any further glyph — so the last column is safe.
const paintCols = cols;
const rows = Math.max(1, geometry.rows); const rows = Math.max(1, geometry.rows);
const parts: string[] = []; const parts: string[] = [];
for (let row = 0; row < Math.min(lines.length, rows); row++) { for (let row = 0; row < Math.min(lines.length, rows); row++) {
@@ -857,6 +862,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
`export CODEMAN_SESSION_ID=${sessionId}`, `export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`, `export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`, `export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
// Path only (not the secret value): hook curl commands cat the file at
// execution time, so the COD-54 hook secret stays off the command line.
`export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`,
]; ];
// Only unset CLAUDECODE for Claude sessions // Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE'); if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
+37 -1
View File
@@ -7,13 +7,14 @@
* - ActiveBashTool — a live bash command with extracted file paths and status * - ActiveBashTool — a live bash command with extracted file paths and status
* - ActiveBashToolStatus — 'running' | 'completed' * - ActiveBashToolStatus — 'running' | 'completed'
* - ImageDetectedEvent — screenshot/image file detection trigger for UI popup * - ImageDetectedEvent — screenshot/image file detection trigger for UI popup
* - AttachmentDetectedEvent — document/image file detection trigger for attachment cards
* *
* Cross-domain relationships: * Cross-domain relationships:
* - ActiveBashTool.sessionId links to SessionState.id (session domain) * - ActiveBashTool.sessionId links to SessionState.id (session domain)
* - ImageDetectedEvent.sessionId links to SessionState.id (session domain) * - ImageDetectedEvent.sessionId links to SessionState.id (session domain)
* *
* Both types are in-memory only (not persisted). Broadcast via SSE events * Both types are in-memory only (not persisted). Broadcast via SSE events
* `subagent:tool_call` and `image:detected`. Parsed by BashToolParser * `subagent:tool_call`, `image:detected`, and `attachment:detected`. Parsed by BashToolParser
* (`src/bash-tool-parser.ts`). * (`src/bash-tool-parser.ts`).
*/ */
@@ -61,3 +62,38 @@ export interface ImageDetectedEvent {
/** File size in bytes */ /** File size in bytes */
size: number; size: number;
} }
export type AttachmentDetectedType = 'image' | 'pdf' | 'document' | 'presentation' | 'markdown' | 'text';
/**
* Event emitted when a new previewable attachment file is detected in a session's
* working directory. Used to render a compact attachment card in the web UI.
*/
export interface AttachmentDetectedEvent {
/** Codeman session ID where the attachment was detected */
sessionId: string;
/** Full path to the detected attachment file */
filePath: string;
/** Path relative to the session's working directory (for file-raw/file-preview endpoints) */
relativePath: string;
/** Attachment file name (basename) */
fileName: string;
/** Lowercase extension without a leading dot */
extension: string;
/** Viewer category used by the web UI */
attachmentType: AttachmentDetectedType;
/** Timestamp when the attachment was detected */
timestamp: number;
/** File size in bytes */
size: number;
/** Registered attachment id for explicit live external attachments */
attachmentId?: string;
/** Source of the attachment card request */
source?: 'detected' | 'external';
/** Raw file route for explicit attachments */
rawUrl?: string;
/** Inline preview route for explicit attachments */
previewUrl?: string;
/** First-page thumbnail route for card previews */
thumbnailUrl?: string;
}
+68 -8
View File
@@ -19,6 +19,7 @@ import {
AUTH_FAILURE_MAX, AUTH_FAILURE_MAX,
AUTH_FAILURE_WINDOW_MS, AUTH_FAILURE_WINDOW_MS,
} from '../../config/auth-config.js'; } from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
// Auth session cookie name // Auth session cookie name
export const AUTH_COOKIE_NAME = 'codeman_session'; export const AUTH_COOKIE_NAME = 'codeman_session';
@@ -28,19 +29,32 @@ interface AuthState {
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null; authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
authFailures: StaleExpirationMap<string, number> | null; authFailures: StaleExpirationMap<string, number> | null;
qrAuthFailures: StaleExpirationMap<string, number> | null; qrAuthFailures: StaleExpirationMap<string, number> | null;
hookSecretFailures: StaleExpirationMap<string, number> | null;
} }
/** /**
* Register HTTP Basic Auth middleware with session cookies and rate limiting. * Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set. * Only active when CODEMAN_PASSWORD is set.
* *
* @param getTunnelRunning - returns true while a managed tunnel is active. Used
* to gate the `/api/hook-event` localhost bypass: when a tunnel is up, tunneled
* internet traffic reaches the loopback origin with `req.ip === 127.0.0.1`, so
* the bypass additionally requires the shared hook secret (COD-54). When no
* tunnel is running (loopback-only, the normal case) the plain localhost bypass
* is kept so already-deployed (pre-secret) hooks + the loop channel keep working.
* Optional; defaults to "no tunnel" (unchanged behavior) when omitted.
* @returns AuthState for lifecycle management (dispose on server stop) * @returns AuthState for lifecycle management (dispose on server stop)
*/ */
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState { export function registerAuthMiddleware(
app: FastifyInstance,
https: boolean,
getTunnelRunning: () => boolean = () => false
): AuthState {
const state: AuthState = { const state: AuthState = {
authSessions: null, authSessions: null,
authFailures: null, authFailures: null,
qrAuthFailures: null, qrAuthFailures: null,
hookSecretFailures: null,
}; };
const authPassword = process.env.CODEMAN_PASSWORD; const authPassword = process.env.CODEMAN_PASSWORD;
@@ -67,24 +81,70 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
refreshOnGet: false, refreshOnGet: false,
}); });
// Separate hook-secret failure counter (COD-54). MUST NOT share authFailures:
// legacy (pre-secret) hook configs fire constantly from 127.0.0.1, and counting
// their 401s against the shared bucket would 429 every cookie-less request from
// loopback — locking out the Basic-Auth login path (and, through a tunnel, every
// client, since tunneled traffic also arrives as 127.0.0.1).
state.hookSecretFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
const authSessions = state.authSessions; const authSessions = state.authSessions;
const authFailures = state.authFailures; const authFailures = state.authFailures;
const hookSecretFailures = state.hookSecretFailures;
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void { function sendAuthRateLimit(
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS; reply: FastifyReply,
clientIp: string,
failures: StaleExpirationMap<string, number> = authFailures
): void {
const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
reply.header('Retry-After', String(retryAfterSeconds)); reply.header('Retry-After', String(retryAfterSeconds));
reply.code(429).send('Too Many Requests — try again later'); reply.code(429).send('Too Many Requests — try again later');
} }
app.addHook('onRequest', (req, reply, done) => { app.addHook('onRequest', (req, reply, done) => {
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available. // Hook events come from local Claude Code hooks (curl from localhost) — no
// Safe: validated by HookEventSchema, only triggers broadcasts. // Basic-Auth credentials available. Validated downstream by HookEventSchema.
// Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN. //
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
// would pass. So:
// - tunnel running → bypass requires the shared hook secret (local hooks present
// it via the X-Codeman-Hook-Secret header; internet traffic can't know it),
// - tunnel not running (loopback-only, the normal case) → keep the plain
// localhost bypass so already-deployed (pre-secret) hooks + the loop's own
// credential-less hook channel keep working.
if (req.url === '/api/hook-event' && req.method === 'POST') { if (req.url === '/api/hook-event' && req.method === 'POST') {
const ip = req.ip; const ip = req.ip;
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') { const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
done(); if (isLoopback) {
if (!getTunnelRunning()) {
// Loopback-only: unchanged behavior.
done();
return;
}
// Tunnel up: require the shared secret (constant-time compare).
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
done();
return;
}
// Wrong/absent secret while tunneled — rate-limit per IP in the DEDICATED
// hook bucket (never authFailures, which would lock out the login path).
const hookIp = req.ip;
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
return;
}
hookSecretFailures.set(hookIp, hookFailures + 1);
reply.code(401).send('Unauthorized: hook secret required');
return; return;
} }
// Non-localhost hook requests fall through to normal auth // Non-localhost hook requests fall through to normal auth
+10
View File
@@ -6,6 +6,16 @@ export function isExplicitlyEnabled(value: string | undefined): boolean {
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase()); return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
} }
/**
* True when unauthenticated network exposure is acceptable: either a password is
* set (auth active) or the operator explicitly acknowledged it. Used by the
* tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
*/
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean {
if (process.env.CODEMAN_PASSWORD) return true;
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
}
export function isLoopbackBindHost(host: string): boolean { export function isLoopbackBindHost(host: string): boolean {
const normalized = host const normalized = host
.trim() .trim()
+171 -4
View File
@@ -580,6 +580,7 @@ class CodemanApp {
const _kbSettings = this.loadAppSettingsFromStorage(); const _kbSettings = this.loadAppSettingsFromStorage();
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended'); if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
this.applyHeaderVisibilitySettings(); this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyTabWrapSettings(); this.applyTabWrapSettings();
this.applyMonitorVisibility(); this.applyMonitorVisibility();
// Remove mobile-init class now that JS has applied visibility settings. // Remove mobile-init class now that JS has applied visibility settings.
@@ -637,6 +638,7 @@ class CodemanApp {
// Load server-stored settings (async, re-applies visibility after load) // Load server-stored settings (async, re-applies visibility after load)
this.loadAppSettingsFromServer(settingsPromise).then(() => { this.loadAppSettingsFromServer(settingsPromise).then(() => {
this.applyHeaderVisibilitySettings(); this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyTabWrapSettings(); this.applyTabWrapSettings();
this.applyMonitorVisibility(); this.applyMonitorVisibility();
}); });
@@ -2123,6 +2125,7 @@ class CodemanApp {
this.ralphStates.clear(); this.ralphStates.clear();
this.terminalBuffers.clear(); this.terminalBuffers.clear();
this.terminalBufferCache.clear(); this.terminalBufferCache.clear();
this._xtermSnapshots?.clear();
this.projectInsights.clear(); this.projectInsights.clear();
this.teams.clear(); this.teams.clear();
this.teamTasks.clear(); this.teamTasks.clear();
@@ -2913,7 +2916,107 @@ class CodemanApp {
* terminal write queue, IME composition, and local echo flush. * terminal write queue, IME composition, and local echo flush.
* @param {string} newSessionId - The session being switched TO. * @param {string} newSessionId - The session being switched TO.
*/ */
_isUsableXtermSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== 'string' || snapshot.length < 8) return false;
const visibleText = snapshot
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
.replace(/\x1b[()][0-2A-Z]/g, '')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.trim();
return visibleText.length >= 3;
}
/**
* Persist one xterm snapshot to localStorage, bounded to a fixed key budget
* regardless of how many sessions are live, and resilient to quota errors.
* The previous inline version only pruned snapshots for sessions that no
* longer existed AND pruned only after a successful setItem — so once the
* quota filled (e.g. >10 live sessions at the 20-session target) the write
* threw before the prune could run, permanently disabling persistence.
*/
_persistXtermSnapshot(key, snapshot) {
const PREFIX = 'codeman-xs-';
const MAX_KEYS = 10;
const others = () => Object.keys(localStorage).filter((k) => k.startsWith(PREFIX) && k !== key);
try {
// Evict down to the budget before writing a NEW key, dead sessions first
// then oldest. (Overwriting an existing key doesn't grow the key count.)
if (localStorage.getItem(key) === null) {
const live = new Set(Array.from(this.sessions?.keys?.() || []));
const pool = others().sort(
(a, b) =>
Number(live.has(a.slice(PREFIX.length))) - Number(live.has(b.slice(PREFIX.length)))
);
while (pool.length >= MAX_KEYS) localStorage.removeItem(pool.shift());
}
try {
localStorage.setItem(key, snapshot);
} catch (_quota) {
// Quota exceeded: drop other snapshots one at a time and retry so a full
// quota can't permanently disable persistence.
for (const victim of others()) {
localStorage.removeItem(victim);
try {
localStorage.setItem(key, snapshot);
return;
} catch (_again) {
/* keep evicting */
}
}
try { localStorage.removeItem(key); } catch {}
}
} catch (_unavailable) {
/* localStorage unavailable (Safari private mode / disabled) — in-memory only */
}
}
_cleanupPreviousSession(newSessionId) { _cleanupPreviousSession(newSessionId) {
// Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback +
// colors/attrs) before the terminal gets cleared/reset. Lets us restore the
// exact view on switch-back rather than replaying codex's byte stream, which
// drops earlier conversation from each TUI redraw and ends up showing only
// the latest (idle) frame.
// Shell sessions are never restored from a snapshot (restore is gated on
// mode !== 'shell'), so skip the serialize() + cache slot + localStorage
// quota for them. Unknown/undefined mode still snapshots, matching restore.
const outgoingSession = this.activeSessionId ? this.sessions?.get?.(this.activeSessionId) : null;
if (
this.activeSessionId &&
outgoingSession?.mode !== 'shell' &&
this._serializeAddon &&
this._xtermSnapshots
) {
try {
const snapshot = this._serializeAddon.serialize({ scrollback: 1000 });
if (this._isUsableXtermSnapshot(snapshot)) {
// Delete-before-set so re-touching a session moves it to the end of
// the Map's insertion order — otherwise eviction is FIFO and can drop
// the most-recently-used session instead of the least.
this._xtermSnapshots.delete(this.activeSessionId);
this._xtermSnapshots.set(this.activeSessionId, snapshot);
// Cap in-memory snapshot cache at 20 entries; evict oldest on overflow.
if (this._xtermSnapshots.size > 20) {
const oldest = this._xtermSnapshots.keys().next().value;
this._xtermSnapshots.delete(oldest);
}
// Persist to localStorage so the snapshot survives tab discard /
// browser reload (Chrome discards inactive tabs after idle periods,
// wiping in-memory state). Cap per-snapshot at 256KB; codex
// buffer-replay produces a visual mess of stacked banner redraws when
// no snapshot exists, so persistence matters more here than for claude.
if (snapshot.length < 256 * 1024) {
this._persistXtermSnapshot(`codeman-xs-${this.activeSessionId}`, snapshot);
}
} else {
this._xtermSnapshots.delete(this.activeSessionId);
try { localStorage.removeItem(`codeman-xs-${this.activeSessionId}`); } catch {}
}
} catch (_err) {
/* Serialize failed — fall back to server buffer replay */
}
}
// Close WebSocket for previous session (new one opens after buffer load) // Close WebSocket for previous session (new one opens after buffer load)
this._disconnectWs(); this._disconnectWs();
@@ -3014,6 +3117,8 @@ class CodemanApp {
if (this.activeSessionId === sessionId && !forceReload) return; if (this.activeSessionId === sessionId && !forceReload) return;
if (this.activeSessionId === sessionId && forceReload) { if (this.activeSessionId === sessionId && forceReload) {
this.terminalBufferCache?.delete(sessionId); this.terminalBufferCache?.delete(sessionId);
this._xtermSnapshots?.delete(sessionId);
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
this._clearTimer('syncWaitTimeout'); this._clearTimer('syncWaitTimeout');
this.pendingWrites = []; this.pendingWrites = [];
this.writeFrameScheduled = false; this.writeFrameScheduled = false;
@@ -3134,7 +3239,57 @@ class CodemanApp {
return; return;
} }
// xterm snapshot restore: if we have a serialized xterm state from a
// previous visit to this session, restore the user's exact prior view
// (viewport + scrollback + colors) for an instant first paint. For codex
// this is also a correctness fix — its byte-stream replay shows only the
// latest TUI frame (the idle welcome banner) because codex doesn't include
// earlier conversation in its current redraw. For claude/opencode/gemini
// the replay is already complete, so the snapshot is purely a faster,
// scroll-preserving first paint before the canonical fetch reconciles.
//
// Try in-memory first (fast); fall back to localStorage so snapshots
// survive tab discards / browser reloads.
let snapshot = this._xtermSnapshots?.get(sessionId);
if (snapshot && !this._isUsableXtermSnapshot(snapshot)) {
this._xtermSnapshots?.delete(sessionId);
snapshot = null;
}
if (!snapshot) {
try {
const persisted = localStorage.getItem(`codeman-xs-${sessionId}`);
if (persisted && this._isUsableXtermSnapshot(persisted)) {
snapshot = persisted;
// Hoist into in-memory cache for next time (delete-before-set keeps
// the Map in LRU order so the just-used session isn't evicted first).
this._xtermSnapshots?.delete(sessionId);
this._xtermSnapshots?.set(sessionId, persisted);
} else if (persisted) {
localStorage.removeItem(`codeman-xs-${sessionId}`);
}
} catch (_e) {
/* localStorage unavailable — proceed without snapshot */
}
}
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working'); const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
let restoredSnapshot = false;
if (snapshot && !sessionIsBusy && session?.mode !== 'shell') {
_crashDiag.log(`SNAPSHOT_RESTORE: ${(snapshot.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
this._resetTerminalForReplay();
await new Promise((resolve) => this.terminal.write(snapshot, resolve));
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
this.scrollToLastNonEmptyLine();
_crashDiag.log('SNAPSHOT_RESTORE_DONE');
// Snapshot restore is only first paint. Inactive tabs intentionally
// unsubscribe from high-volume terminal output, so they can miss bytes
// emitted while away. Keep going and replace the snapshot with the
// canonical live tmux pane frame from /terminal.
restoredSnapshot = true;
}
// Instant cache restore for IDLE sessions only. // Instant cache restore for IDLE sessions only.
// For busy sessions, the cache is always stale — writing it first causes a // For busy sessions, the cache is always stale — writing it first causes a
@@ -3142,7 +3297,8 @@ class CodemanApp {
// blank and rewrites with fresh data. Skip the cache and write the fresh // blank and rewrites with fresh data. Skip the cache and write the fresh
// buffer once for a single clean transition. // buffer once for a single clean transition.
const cachedBuffer = this.terminalBufferCache.get(sessionId); const cachedBuffer = this.terminalBufferCache.get(sessionId);
if (cachedBuffer && !sessionIsBusy) { let clearedForBusy = false;
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`); _crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying'); this._setTerminalLoadState(sessionId, selectGen, 'replaying');
this._resetTerminalForReplay(); this._resetTerminalForReplay();
@@ -3156,6 +3312,7 @@ class CodemanApp {
} else if (sessionIsBusy) { } else if (sessionIsBusy) {
// Clear stale content immediately — fresh buffer is being fetched // Clear stale content immediately — fresh buffer is being fetched
this._resetTerminalForReplay(); this._resetTerminalForReplay();
clearedForBusy = true;
_crashDiag.log('CACHE_SKIP_BUSY'); _crashDiag.log('CACHE_SKIP_BUSY');
} }
@@ -3186,9 +3343,11 @@ class CodemanApp {
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash. // Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is // On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
// very visible, causing the terminal to flash blank then repaint. // very visible, causing the terminal to flash blank then repaint.
// Busy sessions skip cache restore and clear the terminal before fetching, // A snapshot restore or a busy-clear leaves the terminal showing
// so they must replay the fetched buffer even when it matches cache. // something other than the cache, so the fetched buffer must be
const needsRewrite = sessionIsBusy || data.terminalBuffer !== cachedBuffer; // replayed even when it byte-matches the cache.
const needsRewrite =
restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer;
if (needsRewrite) { if (needsRewrite) {
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`); _crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying'); this._setTerminalLoadState(sessionId, selectGen, 'replaying');
@@ -3364,6 +3523,8 @@ class CodemanApp {
} }
this.terminalBuffers.delete(sessionId); this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId); this.terminalBufferCache.delete(sessionId);
this._xtermSnapshots?.delete(sessionId);
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
this._flushedOffsets?.delete(sessionId); this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId); this._flushedTexts?.delete(sessionId);
@@ -3537,6 +3698,12 @@ class CodemanApp {
this.terminalBuffers.clear(); this.terminalBuffers.clear();
this.terminalBufferCache.clear(); this.terminalBufferCache.clear();
this.terminalLoadStates.clear(); this.terminalLoadStates.clear();
this._xtermSnapshots?.clear();
try {
for (const k of Object.keys(localStorage)) {
if (k.startsWith('codeman-xs-')) localStorage.removeItem(k);
}
} catch {}
this.activeSessionId = null; this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {} try { localStorage.removeItem('codeman-active-session'); } catch {}
this.respawnStatus = {}; this.respawnStatus = {};
+1
View File
@@ -336,6 +336,7 @@ const SSE_EVENTS = {
// Images // Images
IMAGE_DETECTED: 'image:detected', IMAGE_DETECTED: 'image:detected',
ATTACHMENT_DETECTED: 'attachment:detected',
// Tunnel // Tunnel
TUNNEL_STARTED: 'tunnel:started', TUNNEL_STARTED: 'tunnel:started',
Binary file not shown.
Binary file not shown.
+44 -22
View File
@@ -8,7 +8,7 @@
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="description" content="Claude Code session manager with web interface"> <meta name="description" content="Claude Code session manager with web interface">
<meta name="theme-color" content="#0a0a0a"> <meta name="theme-color" content="#11151c">
<meta name="google" content="notranslate"> <meta name="google" content="notranslate">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -30,21 +30,28 @@
'defer' preserves execution order (xterm loads before fit addon). --> 'defer' preserves execution order (xterm loads before fit addon). -->
<script defer src="vendor/xterm.min.js"></script> <script defer src="vendor/xterm.min.js"></script>
<script defer src="vendor/xterm-addon-fit.min.js"></script> <script defer src="vendor/xterm-addon-fit.min.js"></script>
<!-- SerializeAddon: snapshots xterm state (viewport + scrollback + attrs) for
per-session restore on tab switches. Lets codex tabs survive switch-away
without codeman having to replay codex's byte stream (which loses earlier
conversation because codex's TUI redraws drop it from the viewport). -->
<script defer src="vendor/xterm-addon-serialize.min.js"></script>
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) --> <!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
<script defer src="vendor/xterm-addon-unicode11.min.js"></script> <script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<script defer src="vendor/xterm-zerolag-input.js"></script> <script defer src="vendor/xterm-zerolag-input.js"></script>
<script defer src="vendor/marked.min.js"></script> <script defer src="vendor/marked.min.js"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash --> <!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script> <script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) --> <!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style> <style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b} .loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
.skeleton-header{height:40px;background:rgba(19,19,22,0.85);border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;padding:0 12px} .skeleton-header{height:40px;background:rgba(31,38,48,0.85);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:700;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7} .skeleton-brand{color:#38b6f0;font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
.skeleton-tabs{display:flex;gap:4px;margin-left:16px} .skeleton-tabs{display:flex;gap:4px;margin-left:16px}
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px} .skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
.skeleton-terminal{flex:1;background:#0d0d0d} .skeleton-terminal{flex:1;background:#161b23}
.skeleton-toolbar{height:42px;background:rgba(19,19,22,0.85);border-top:1px solid rgba(255,255,255,0.06)} .skeleton-toolbar{height:42px;background:rgba(31,38,48,0.85);border-top:1px solid rgba(255,255,255,0.08)}
.app-loaded .loading-skeleton{display:none} .app-loaded .loading-skeleton{display:none}
</style> </style>
</head> </head>
@@ -173,8 +180,8 @@
<svg viewBox="0 0 36 36" class="ralph-ring-svg"> <svg viewBox="0 0 36 36" class="ralph-ring-svg">
<defs> <defs>
<linearGradient id="ralphGradientMini" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="ralphGradientMini" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" /> <stop offset="0%" stop-color="#3ec8ee" />
<stop offset="100%" stop-color="#22c55e" /> <stop offset="100%" stop-color="#2b8fd9" />
</linearGradient> </linearGradient>
</defs> </defs>
<circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" /> <circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" />
@@ -219,8 +226,8 @@
<svg viewBox="0 0 100 100" class="ralph-ring-svg-large"> <svg viewBox="0 0 100 100" class="ralph-ring-svg-large">
<defs> <defs>
<linearGradient id="ralphGradient" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="ralphGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" /> <stop offset="0%" stop-color="#3ec8ee" />
<stop offset="100%" stop-color="#22c55e" /> <stop offset="100%" stop-color="#2b8fd9" />
</linearGradient> </linearGradient>
</defs> </defs>
<circle class="ralph-ring-track" cx="50" cy="50" r="42" /> <circle class="ralph-ring-track" cx="50" cy="50" r="42" />
@@ -604,17 +611,6 @@
<div class="modal-tab-content" id="respawn-tab"> <div class="modal-tab-content" id="respawn-tab">
<!-- Respawn Settings Section --> <!-- Respawn Settings Section -->
<div class="session-respawn-section" id="sessionRespawnSection"> <div class="session-respawn-section" id="sessionRespawnSection">
<div class="respawn-header">
<div class="session-respawn-status" id="sessionRespawnStatus">
<span class="respawn-status-indicator"></span>
<span class="respawn-status-text">Not active</span>
</div>
<div class="respawn-actions">
<button class="btn-toolbar btn-success btn-sm" onclick="app.enableRespawnFromModal()" id="modalEnableRespawnBtn">Enable</button>
<button class="btn-toolbar btn-danger btn-sm" onclick="app.stopRespawnFromModal()" id="modalStopRespawnBtn" style="display: none;">Stop</button>
</div>
</div>
<div class="auto-resume-box"> <div class="auto-resume-box">
<label class="checkbox-inline"> <label class="checkbox-inline">
<input type="checkbox" id="modalAutoResumeEnabled" onchange="app.autoSaveAutoResume()"> <input type="checkbox" id="modalAutoResumeEnabled" onchange="app.autoSaveAutoResume()">
@@ -624,6 +620,10 @@
<span class="form-hint">If Claude pauses on a usage limit ("limit reached &middot; resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.</span> <span class="form-hint">If Claude pauses on a usage limit ("limit reached &middot; resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.</span>
</div> </div>
<div class="respawn-loop-box">
<div class="respawn-loop-title">Respawn loop</div>
<span class="form-hint respawn-loop-hint">One autonomous work cycle: whenever Claude goes idle, Codeman sends the update prompt, optionally runs /clear + /init, and kickstarts the next round &mdash; repeating for the chosen duration. All settings below belong to this loop; configure them, then press Enable.</span>
<div class="form-row"> <div class="form-row">
<label>Duration</label> <label>Duration</label>
<div class="duration-presets"> <div class="duration-presets">
@@ -660,7 +660,7 @@
<p class="form-hint" id="presetDescriptionHint"></p> <p class="form-hint" id="presetDescriptionHint"></p>
</div> </div>
<div class="form-section-header">Respawn Cycle</div> <div class="form-section-header">Cycle Steps</div>
<div class="form-row"> <div class="form-row">
<label>1. Update Prompt</label> <label>1. Update Prompt</label>
<textarea id="modalRespawnPrompt" rows="1" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;">update all the docs and CLAUDE.md</textarea> <textarea id="modalRespawnPrompt" rows="1" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;">update all the docs and CLAUDE.md</textarea>
@@ -686,6 +686,18 @@
<textarea id="modalRespawnKickstart" rows="1" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;"></textarea> <textarea id="modalRespawnKickstart" rows="1" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;"></textarea>
<span class="form-hint">Sent only when /init completes but Claude stays idle &middot; Auto-accept presses Enter for plan approvals and default options</span> <span class="form-hint">Sent only when /init completes but Claude stays idle &middot; Auto-accept presses Enter for plan approvals and default options</span>
</div> </div>
<div class="respawn-header">
<div class="session-respawn-status" id="sessionRespawnStatus">
<span class="respawn-status-indicator"></span>
<span class="respawn-status-text">Not active</span>
</div>
<div class="respawn-actions">
<button class="btn-toolbar btn-success btn-sm" onclick="app.enableRespawnFromModal()" id="modalEnableRespawnBtn">Enable</button>
<button class="btn-toolbar btn-danger btn-sm" onclick="app.stopRespawnFromModal()" id="modalStopRespawnBtn" style="display: none;">Stop</button>
</div>
</div>
</div><!-- End respawn-loop-box -->
</div> </div>
</div><!-- End respawn-tab --> </div><!-- End respawn-tab -->
@@ -908,6 +920,16 @@
<!-- Display Tab --> <!-- Display Tab -->
<div class="modal-tab-content" id="settings-display"> <div class="modal-tab-content" id="settings-display">
<div class="settings-grid"> <div class="settings-grid">
<!-- Appearance Section -->
<div class="settings-section-header">Appearance</div>
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
<span class="settings-item-label">Skin</span>
<select id="appSettingsSkin" class="form-select">
<option value="daylight-blue">Daylight Blue</option>
<option value="daylight-green">Daylight Green</option>
<option value="og">OG Codeman</option>
</select>
</div>
<!-- Input Section --> <!-- Input Section -->
<div class="settings-section-header">Input</div> <div class="settings-section-header">Input</div>
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections."> <div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
+42 -18
View File
@@ -36,6 +36,13 @@ html.mobile-init .file-browser-panel {
html { html {
touch-action: manipulation; touch-action: manipulation;
} }
/* No "open in new window" (detach) on phones/tablets — popped-out browser
windows aren't usable there. !important beats the hover/detached reveal
rules in styles.css */
.session-tab .tab-detach {
display: none !important;
}
} }
/* ============================================================================ /* ============================================================================
@@ -200,6 +207,12 @@ html.mobile-init .file-browser-panel {
/* ---- Settings Modal: Tablet Optimizations ---- */ /* ---- Settings Modal: Tablet Optimizations ---- */
/* Modals must stack above the fixed tablet header (z-index 1200) so the
modal header with the close button stays visible */
.modal {
z-index: 1300;
}
.modal-tabs { .modal-tabs {
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@@ -1215,6 +1228,13 @@ html.mobile-init .file-browser-panel {
max-height: 35vh; max-height: 35vh;
} }
/* Modals must stack above the fixed mobile header (z-index 1200), or the
modal header with the close button is buried underneath it and the
full-screen modal cannot be dismissed */
.modal {
z-index: 1300;
}
/* Full-screen modals on phones */ /* Full-screen modals on phones */
.modal-content { .modal-content {
width: 100%; width: 100%;
@@ -1528,42 +1548,36 @@ html.mobile-init .file-browser-panel {
border-radius: 5px; border-radius: 5px;
} }
/* Duration preset buttons — grid layout, 4 columns for even spacing */ /* Duration preset buttons — one compact row */
.duration-presets { .duration-presets {
display: grid; display: flex;
grid-template-columns: repeat(4, 1fr); flex-wrap: wrap;
gap: 0.2rem; gap: 0.2rem;
} }
.duration-preset-btn { .duration-preset-btn {
min-height: 32px; min-height: 24px;
padding: 0.2rem 0.25rem; padding: 0.1rem 0.4rem;
font-size: 0.65rem; font-size: 0.6rem;
border-radius: 5px; border-radius: 4px;
text-align: center; text-align: center;
} }
/* Custom duration — spans full row below */
.duration-custom { .duration-custom {
grid-column: 1 / -1;
display: flex; display: flex;
gap: 0.25rem; gap: 0.2rem;
align-items: center; align-items: center;
} }
.duration-custom .duration-preset-btn {
flex: 0 0 auto;
min-width: 50px;
}
.duration-custom-input.visible { .duration-custom-input.visible {
flex: 1; flex: 0 1 auto;
} }
.duration-custom-input input { .duration-custom-input input {
width: 100%; width: 64px;
min-height: 28px; min-height: 24px;
font-size: 16px; /* Prevents iOS zoom */ font-size: 16px; /* Prevents iOS zoom */
padding: 0.1rem 0.3rem;
} }
/* Preset selector row — full-width dropdown, buttons below */ /* Preset selector row — full-width dropdown, buttons below */
@@ -1622,6 +1636,16 @@ html.mobile-init .file-browser-panel {
height: 18px; height: 18px;
} }
/* Respawn-loop box title matches the auto-resume title; its cycle-step
checkboxes match the step labels */
#sessionOptionsModal .respawn-loop-title {
font-size: 0.75rem;
}
#sessionOptionsModal .respawn-loop-box .checkbox-inline {
font-size: 0.65rem;
}
/* Respawn options row — stack if needed */ /* Respawn options row — stack if needed */
#sessionOptionsModal .respawn-options-row { #sessionOptionsModal .respawn-options-row {
gap: 0.5rem; gap: 0.5rem;
+1 -23
View File
@@ -1550,29 +1550,7 @@ Object.assign(CodemanApp.prototype, {
} }
const terminal = new Terminal({ const terminal = new Terminal({
theme: { theme: { ...window.codemanCurrentXtermTheme() },
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
fontSize: 12, fontSize: 12,
lineHeight: 1.2, lineHeight: 1.2,
+78 -4
View File
@@ -309,6 +309,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false; document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true; document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false; document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
@@ -844,11 +845,18 @@ Object.assign(CodemanApp.prototype, {
btn.disabled = true; btn.disabled = true;
try { try {
const newEnabled = !isActive; const newEnabled = !isActive;
await fetch('/api/settings', { const res = await fetch('/api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: newEnabled }), body: JSON.stringify({ tunnelEnabled: newEnabled }),
}); });
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (newEnabled && (await this._handleTunnelEnableRefusal(res))) {
this._dismissTunnelConnecting();
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
return;
}
if (newEnabled) { if (newEnabled) {
this._showTunnelConnecting(); this._showTunnelConnecting();
// Poll tunnel status as fallback in case SSE event is missed // Poll tunnel status as fallback in case SSE event is missed
@@ -1148,13 +1156,40 @@ Object.assign(CodemanApp.prototype, {
return `${Math.floor(hrs / 24)}d ago`; return `${Math.floor(hrs / 24)}d ago`;
}, },
/**
* COD-55: detect the server's refusal to start an unauthenticated public tunnel.
* The PUT /api/settings route returns a 4xx with { success:false, error } when no
* CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast.
* @param {Response|null} res - the fetch Response from the settings PUT
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
try {
const body = await res.json();
if (body && body.error) message = body.error;
} catch {
/* non-JSON body — use the default message */
}
this._dismissTunnelConnecting?.();
this.showToast(message, 'error');
return true;
},
async _tunnelPanelToggle(enable) { async _tunnelPanelToggle(enable) {
try { try {
await fetch('/api/settings', { const res = await fetch('/api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: enable }), body: JSON.stringify({ tunnelEnabled: enable }),
}); });
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (enable && (await this._handleTunnelEnableRefusal(res))) {
this.closeTunnelPanel();
return;
}
if (enable) { if (enable) {
this._updateTunnelIndicator(false); this._updateTunnelIndicator(false);
const indicator = document.getElementById('tunnelIndicator'); const indicator = document.getElementById('tunnelIndicator');
@@ -1343,6 +1378,7 @@ Object.assign(CodemanApp.prototype, {
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked, cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked, extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
skin: document.getElementById('appSettingsSkin').value,
// Claude CLI settings // Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value, claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(), allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
@@ -1457,6 +1493,7 @@ Object.assign(CodemanApp.prototype, {
// Apply header visibility immediately // Apply header visibility immediately
this.applyHeaderVisibilitySettings(); this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyTabWrapSettings(); this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change) this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility(); this.applyMonitorVisibility();
@@ -1471,9 +1508,26 @@ Object.assign(CodemanApp.prototype, {
// Save to server (includes notification prefs for cross-browser persistence) // Save to server (includes notification prefs for cross-browser persistence)
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform // Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings; const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, ...serverSettings } = settings;
try { try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings }); const res = await this._apiPut('/api/settings', {
...serverSettings,
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
this.closeAppSettings();
return;
}
// Save model configuration separately // Save model configuration separately
await this.saveModelConfigFromSettings(); await this.saveModelConfigFromSettings();
@@ -1613,6 +1667,7 @@ Object.assign(CodemanApp.prototype, {
ralphTrackerEnabled: false, ralphTrackerEnabled: false,
tabTwoRows: false, tabTwoRows: false,
cjkInputEnabled: false, cjkInputEnabled: false,
skin: 'daylight-blue',
}; };
} }
// Desktop defaults - rely on ?? operators in apply functions // Desktop defaults - rely on ?? operators in apply functions
@@ -1650,6 +1705,24 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
// Apply the chosen skin live: sets the html[data-skin] attribute, syncs BOTH
// localStorage locations (the standalone 'codeman:skin' key the pre-paint head
// script reads + the app-settings blob field written by saveAppSettingsToStorage),
// updates window.__codemanSkin, and re-themes any live terminals.
applySkin() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.documentElement.setAttribute('data-skin', skin);
window.__codemanSkin = skin;
try {
localStorage.setItem('codeman:skin', skin);
} catch (_e) {
/* private mode */
}
if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin);
},
applyHeaderVisibilitySettings() { applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage(); const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings(); const defaults = this.getDefaultSettings();
@@ -1893,6 +1966,7 @@ Object.assign(CodemanApp.prototype, {
'showLifecycleLog', 'showResponseViewer', 'showLifecycleLog', 'showResponseViewer',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin',
]); ]);
// Merge settings: non-display keys always sync from server, // Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value // display keys only seed from server when localStorage has no value
+411 -30
View File
@@ -1,36 +1,65 @@
/* Codeman - Terminal-First UI */ /* Codeman - Terminal-First UI */
/* Self-hosted UI + mono fonts (Carbon Aurora · Daylight) — served from /fonts,
no external CDN, CSP-safe (font-src 'self'). Variable woff2 cover all weights. */
@font-face {
font-family: 'Manrope';
font-style: normal;
font-display: swap;
font-weight: 400 800;
src: url('fonts/manrope-variable.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-display: swap;
font-weight: 400 700;
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
}
:root { :root {
--bg-dark: #09090b; /* Carbon Aurora · Daylight — deep-but-not-black slate, surfaces step UP into the light */
--bg-card: #131316; --bg-dark: #11151c;
--bg-input: #1a1a1f; --bg-card: #1b222c;
--bg-hover: #1f1f26; --bg-input: #202833;
--border: #232329; --bg-hover: #2a323d;
--border-light: #2e2e38; --border: #2b333f;
--text: #ececf0; --border-light: #3a4350;
--text-dim: #8b8b97; --text: #f3f6fa;
--text-muted: #52525e; --text-dim: #98a2b1;
--accent: #3b82f6; --text-muted: #717b8c;
--accent-hover: #60a5fa; /* SINGLE cohesive accent — optimistic emerald → teal (replaces the old blue) */
--green: #22c55e; --accent: #38b6f0;
--yellow: #eab308; --accent-hover: #3ec8ee;
--red: #ef4444; /* calmer secondary green for non-primary pills/dots so Run + ring stay focal */
--green: #44b993;
/* warm, quiet "working" amber (no second saturated hue) */
--yellow: #f0c25a;
--red: #e06363;
/* Daylight accent extras — referenced by the v1.0 override block at end of file */
--accent-d: #2487c9;
--emerald: #3ec8ee;
--teal: #2b8fd9;
--accent-soft: #44b993;
--accent-ink: #04223a;
--warm: #e8c79a;
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
--header-height: 36px; --header-height: 36px;
--toolbar-height: 42px; --toolbar-height: 42px;
--glass-bg: rgba(19, 19, 22, 0.85); --glass-bg: rgba(31, 38, 48, 0.85);
--glass-border: rgba(255, 255, 255, 0.06); --glass-border: rgba(255, 255, 255, 0.08);
--subtle-shadow: 0 -1px 3px rgba(0, 0, 0, 0.3), 0 -4px 16px rgba(0, 0, 0, 0.15); --subtle-shadow: 0 -1px 3px rgba(0, 0, 0, 0.3), 0 -4px 16px rgba(0, 0, 0, 0.15);
--btn-radius: 6px; --btn-radius: 6px;
--transition-smooth: 0.2s cubic-bezier(0.4, 0, 0.2, 1); --transition-smooth: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
/* Session color palette */ /* Session color palette — retoned toward the Daylight system */
--session-red: #ef4444; --session-red: #e06363;
--session-orange: #f97316; --session-orange: #e8a45c;
--session-yellow: #eab308; --session-yellow: #f0c25a;
--session-green: #22c55e; --session-green: #44b993;
--session-blue: #3b82f6; --session-blue: #2b8fd9;
--session-purple: #a855f7; --session-purple: #a98fe0;
--session-pink: #ec4899; --session-pink: #e07db0;
/* Safe area insets for notched devices (iPhone X+, Dynamic Island) */ /* Safe area insets for notched devices (iPhone X+, Dynamic Island) */
--safe-area-top: env(safe-area-inset-top, 0px); --safe-area-top: env(safe-area-inset-top, 0px);
@@ -40,6 +69,78 @@
/* Touch target minimum (iOS Human Interface Guidelines) */ /* Touch target minimum (iOS Human Interface Guidelines) */
--touch-target-min: 44px; --touch-target-min: 44px;
/* UI font + accent/gradient/hover tokens referenced by the v1.0 override block.
These :root values equal the daylight-blue palette so the no-attribute fallback
matches current behavior. */
--ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--accent-rgb: 56, 182, 240;
--accent-grad-a: #3ec8ee;
--accent-grad-b: #2b8fd9;
--run-hover-a: #5cc4f5;
--run-hover-b: #3aa3e2;
--gear-hover: #2a93d0;
}
/* ===== Skin: Daylight Blue (current default) ===== */
html[data-skin="daylight-blue"] {
--bg-dark: #11151c; --bg-card: #1b222c; --bg-input: #202833; --bg-hover: #2a323d;
--border: #2b333f; --border-light: #3a4350;
--text: #f3f6fa; --text-dim: #98a2b1; --text-muted: #717b8c;
--accent: #38b6f0; --accent-hover: #3ec8ee;
--term-bg: #161b23;
--green: #44b993; --yellow: #f0c25a; --red: #e06363;
--glass-bg: rgba(31, 38, 48, 0.85); --glass-border: rgba(255, 255, 255, 0.08);
--accent-d: #2487c9; --emerald: #3ec8ee; --teal: #2b8fd9;
--accent-soft: #44b993; --accent-ink: #04223a;
--accent-rgb: 56, 182, 240;
--accent-grad-a: #3ec8ee; --accent-grad-b: #2b8fd9;
--run-hover-a: #5cc4f5; --run-hover-b: #3aa3e2; --gear-hover: #2a93d0;
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
--session-red: #e06363; --session-orange: #e8a45c; --session-yellow: #f0c25a;
--session-green: #44b993; --session-blue: #2b8fd9; --session-purple: #a98fe0; --session-pink: #e07db0;
--ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* ===== Skin: Daylight Green ===== */
html[data-skin="daylight-green"] {
--bg-dark: #11151c; --bg-card: #1b222c; --bg-input: #202833; --bg-hover: #2a323d;
--border: #2b333f; --border-light: #3a4350;
--text: #f3f6fa; --text-dim: #98a2b1; --text-muted: #717b8c;
--accent: #2fd3aa; --accent-hover: #34d8a0;
--term-bg: #161b23;
--green: #44b993; --yellow: #f0c25a; --red: #e06363;
--glass-bg: rgba(31, 38, 48, 0.85); --glass-border: rgba(255, 255, 255, 0.08);
--accent-d: #19a987; --emerald: #34d8a0; --teal: #2bcbbb;
--accent-soft: #44b993; --accent-ink: #062019;
--accent-rgb: 47, 211, 170;
--accent-grad-a: #34d8a0; --accent-grad-b: #2bcbbb;
--run-hover-a: #3ee0aa; --run-hover-b: #34d4c4; --gear-hover: #1cbd97;
--ring-glow: 0 0 12px -2px rgba(47, 211, 170, 0.55);
--session-red: #e06363; --session-orange: #e8a45c; --session-yellow: #f0c25a;
--session-green: #44b993; --session-blue: #2bcbbb; --session-purple: #a98fe0; --session-pink: #e07db0;
--ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* ===== Skin: OG Codeman (original look — no Daylight polish, system font) ===== */
html[data-skin="og"] {
--bg-dark: #09090b; --bg-card: #131316; --bg-input: #1a1a1f; --bg-hover: #1f1f26;
--border: #232329; --border-light: #2e2e38;
--text: #ececf0; --text-dim: #8b8b97; --text-muted: #52525e;
--accent: #3b82f6; --accent-hover: #60a5fa;
--term-bg: #0d0d0d;
--green: #22c55e; --yellow: #eab308; --red: #ef4444;
--glass-bg: rgba(19, 19, 22, 0.85); --glass-border: rgba(255, 255, 255, 0.06);
/* daylight-only extras left at sane neutral defaults (unused — override block does not apply to og) */
--accent-d: #3b82f6; --emerald: #3b82f6; --teal: #2563eb;
--accent-soft: #22c55e; --accent-ink: #0b1220;
--accent-rgb: 59, 130, 246;
--accent-grad-a: #3b82f6; --accent-grad-b: #2563eb;
--run-hover-a: #60a5fa; --run-hover-b: #3b82f6; --gear-hover: #2563eb;
--ring-glow: 0 0 12px -2px rgba(59, 130, 246, 0.55);
--session-red: #ef4444; --session-orange: #f97316; --session-yellow: #eab308;
--session-green: #22c55e; --session-blue: #3b82f6; --session-purple: #a855f7; --session-pink: #ec4899;
--ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -129,7 +230,7 @@ textarea:focus-visible {
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: var(--ui-font);
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text); color: var(--text);
height: 100vh; height: 100vh;
@@ -2097,7 +2198,7 @@ body.solo-mode .btn-lifecycle-log {
.terminal-container { .terminal-container {
flex: 1; flex: 1;
background: #0d0d0d; background: var(--term-bg, #161b23);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
/* Performance: isolate layout and paint operations. /* Performance: isolate layout and paint operations.
@@ -3828,6 +3929,22 @@ body.solo-mode .btn-lifecycle-log {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Skin picker: full-width row (not squeezed into a half-width grid cell) with an
auto-width select, so the "Skin" label keeps its space instead of truncating. */
.settings-item-skin {
grid-column: 1 / -1;
gap: 0.75rem;
}
.settings-item-skin .settings-item-label {
flex: 0 0 auto;
}
.settings-item-skin .form-select {
width: auto;
min-width: 220px;
max-width: 320px;
flex: 0 1 auto;
}
.settings-item-multiline { .settings-item-multiline {
align-items: center; align-items: center;
} }
@@ -4645,15 +4762,16 @@ body.solo-mode .btn-lifecycle-log {
width: 60px; width: 60px;
} }
/* Respawn header with status and actions */ /* Respawn status + Enable/Stop row — sits at the bottom of the Respawn tab,
below the settings it acts on */
.respawn-header { .respawn-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1rem; gap: 1rem;
margin-bottom: 0.75rem; margin-top: 0.75rem;
padding-bottom: 0.75rem; padding-top: 0.75rem;
border-bottom: 1px solid var(--border); border-top: 1px solid rgba(34, 197, 94, 0.2);
} }
.respawn-header .respawn-actions { .respawn-header .respawn-actions {
@@ -4692,6 +4810,39 @@ body.solo-mode .btn-lifecycle-log {
margin-top: 0; margin-top: 0;
} }
/* Respawn loop box — groups every setting that belongs to the autonomous
respawn cycle (duration, presets, cycle prompts, enable). Green like its
Enable button; a visual sibling of the standalone auto-resume option above */
.respawn-loop-box {
margin: 0.75rem 0;
padding: 0.6rem 0.75rem;
background: rgba(34, 197, 94, 0.05);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 6px;
}
.respawn-loop-title {
font-size: 0.85rem;
font-weight: 500;
color: var(--text);
margin-bottom: 0.3rem;
}
.respawn-loop-hint {
display: block;
margin-top: 0;
margin-bottom: 0.5rem;
}
.respawn-loop-box .form-section-header {
border-bottom-color: rgba(34, 197, 94, 0.2);
}
/* Match the cycle-step checkboxes (2./3.) to the step labels (1./4.) */
.respawn-loop-box .checkbox-inline {
font-size: 0.7rem;
}
.auto-resume-status { .auto-resume-status {
display: none; display: none;
font-size: 0.7rem; font-size: 0.7rem;
@@ -9023,3 +9174,233 @@ body.touch-device.cjk-input-visible .main {
pointer-events: none; pointer-events: none;
z-index: 100; z-index: 100;
} }
/* ============================================================
=== v1.0 Carbon Aurora · Daylight overrides ===
Single appended block — trivially removable. Retones the hardcoded
component colors that don't flow through :root: the active session
tab (#00FF66), the banners' literal gradients, the Ralph Material
palette, the Run/welcome/stop gradient buttons, the panels' glass,
and the welcome title. Calms heavy glows; the ONLY real saturation/
glow is reserved for the Run button + the progress ring.
============================================================ */
html:not([data-skin="og"]) {
/* ---- Brand wordmark: emerald, calm (kill the blue glow) ---- */
.logo:hover {
color: var(--accent-hover);
text-shadow: none;
filter: brightness(1.06);
}
/* ---- Active session tab: crisp emerald (replaces neon #00FF66) ---- */
.session-tab.active {
background: rgba(var(--accent-rgb), 0.13) !important;
border: 1px solid var(--accent) !important;
color: var(--text) !important;
box-shadow: none !important;
}
.session-tab:hover {
background: rgba(var(--accent-rgb), 0.06);
border-color: rgba(var(--accent-rgb), 0.2);
box-shadow: none;
}
.session-tab .tab-number {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.18);
color: var(--text-dim);
}
.session-tab.active .tab-number {
background: rgba(var(--accent-rgb), 0.2) !important;
border-color: rgba(var(--accent-rgb), 0.45) !important;
color: var(--accent-hover) !important;
}
/* per-color active tints: calm the glow, keep the identity hue as a quiet border */
.session-tab.active[data-color] {
box-shadow: none;
}
/* tab status dots: quiet, no halo */
.session-tab .tab-status.idle,
.session-tab .tab-status.busy {
box-shadow: none;
}
.tab-glow { animation: none; box-shadow: none; }
/* ---- Timer banner: emerald-teal, flat ---- */
.timer-banner {
background: linear-gradient(90deg, rgba(31, 38, 48, 0.92), rgba(22, 27, 35, 0.92));
border-bottom: 1px solid var(--glass-border);
}
.timer-value { color: var(--accent); }
.timer-progress-fill { background: linear-gradient(90deg, var(--accent-grad-a), var(--accent-grad-b)); }
/* ---- Respawn banner: lifted slate, calm green hairline ---- */
.respawn-banner {
background: linear-gradient(90deg, rgba(31, 38, 48, 0.9), rgba(22, 27, 35, 0.9));
border-bottom: 1px solid var(--glass-border);
}
.respawn-countdown-timer .respawn-timer-value,
.action-type { color: var(--accent); }
.respawn-timer-progress { background: var(--accent); }
.respawn-action-log { border-left-color: rgba(var(--accent-rgb), 0.18); }
.respawn-action-log::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb), 0.28); }
/* ---- Ralph panel: replace the blue->green ring gradient + Material badges ---- */
.ralph-ring-text {
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
-webkit-background-clip: text;
background-clip: text;
}
.ralph-ring-bg, .ralph-ring-track { stroke: rgba(255, 255, 255, 0.1); }
/* Ralph status badges: collapse the off-token Material palette onto the system */
.ralph-status-badge { color: var(--text-dim); }
.ralph-status-dot { box-shadow: none; }
.ralph-info { background: rgba(var(--accent-rgb), 0.05); }
/* ---- Bottom toolbar + neutral buttons ---- */
.toolbar { box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.22), 0 -4px 14px rgba(0, 0, 0, 0.14); }
.btn-toolbar {
background: rgba(255, 255, 255, 0.045);
border-color: rgba(255, 255, 255, 0.09);
color: var(--text-dim);
}
.btn-toolbar:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.14);
color: var(--text);
box-shadow: none;
}
/* ---- RUN button: THE focal saturated accent (flat emerald->teal fill) ---- */
.btn-toolbar.btn-run,
.btn-toolbar.btn-run.mode-claude {
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
border-color: var(--accent);
color: var(--accent-ink);
font-weight: 700;
text-shadow: none;
}
.btn-toolbar.btn-run:hover,
.btn-toolbar.btn-run.mode-claude:hover {
background: linear-gradient(135deg, var(--run-hover-a), var(--run-hover-b));
box-shadow: 0 0 14px -2px rgba(var(--accent-rgb), 0.5);
}
.btn-toolbar.btn-run.mode-opencode {
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
border-color: var(--accent);
color: var(--accent-ink);
}
.btn-toolbar.btn-run.mode-opencode:hover { box-shadow: 0 0 14px -2px rgba(var(--accent-rgb), 0.45); }
.btn-toolbar.btn-run.mode-codex {
background: linear-gradient(135deg, var(--accent-grad-b), #5cc6e8);
border-color: var(--accent-grad-b);
color: #061c20;
}
.btn-toolbar.btn-run.mode-codex:hover { box-shadow: 0 0 14px -2px rgba(43, 203, 187, 0.45); }
.btn-toolbar.btn-run-gear {
background: var(--accent-d);
border-color: var(--accent);
color: var(--accent-ink);
}
.btn-toolbar.btn-run-gear:hover { background: var(--gear-hover); box-shadow: none; }
.run-mode-dot.claude { background: var(--accent); }
.run-mode-dot.opencode { background: var(--accent-soft); }
.run-mode-dot.codex { background: var(--accent-grad-b); }
/* ---- Shell button: quiet neutral with a calm green tint ---- */
.btn-toolbar.btn-shell {
background: rgba(68, 185, 147, 0.1);
border: 1px solid rgba(68, 185, 147, 0.28);
color: var(--green);
text-shadow: none;
}
.btn-toolbar.btn-shell:hover {
background: rgba(68, 185, 147, 0.16);
border-color: rgba(68, 185, 147, 0.4);
box-shadow: none;
}
/* ---- Stop button: quiet neutral control (no rose glow) ---- */
.btn-toolbar.btn-stop {
background: rgba(255, 255, 255, 0.045);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--text-dim);
text-shadow: none;
}
.btn-toolbar.btn-stop:hover {
background: rgba(224, 99, 99, 0.14);
border-color: rgba(224, 99, 99, 0.4);
color: #f0a0a0;
box-shadow: none;
}
.btn-toolbar.btn-stop.confirming {
background: linear-gradient(135deg, #5a4a1a, #6b5620);
border-color: rgba(240, 194, 90, 0.6);
color: var(--yellow);
}
/* ---- Case selector + add ---- */
.toolbar-select {
background: rgba(255, 255, 255, 0.045);
border-color: rgba(255, 255, 255, 0.09);
color: var(--text-dim);
}
.toolbar-select:hover { background: rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.14); }
.toolbar-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.25);
}
.btn-case-add { color: var(--accent-soft); }
/* ---- Header system stats: cohesive accent meters ---- */
.stat-bar-mem { background: linear-gradient(90deg, var(--accent), var(--accent-hover)); }
.header-tokens.clickable:hover { background: rgba(var(--accent-rgb), 0.16); }
/* ---- Monitor + Subagents panels: lifted frosted slate ---- */
.monitor-panel,
.subagents-panel {
background: rgba(31, 38, 48, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* ---- Subagent progress ring: emerald, calm glow ---- */
.subagent-ring-progress,
.subagent-ring circle.progress { stroke: var(--accent); }
/* ---- PROGRESS RING (Ralph large): the one place a soft glow lives ---- */
.ralph-ring-fill { filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.45)); }
/* ---- Welcome overlay: emerald title + Daylight buttons ---- */
.welcome-title {
background: linear-gradient(135deg, var(--accent-grad-a) 0%, var(--accent-grad-b) 50%, var(--accent-grad-a) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.welcome-btn-claude {
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
border-color: var(--accent);
color: var(--accent-ink);
}
.welcome-btn-claude:hover { box-shadow: 0 0 28px -4px rgba(var(--accent-rgb), 0.3); }
.welcome-btn-opencode {
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
border-color: rgba(var(--accent-rgb), 0.4);
color: var(--accent-ink);
}
.welcome-btn-opencode:hover { box-shadow: 0 0 28px -4px rgba(var(--accent-rgb), 0.25); }
.welcome-btn-tunnel {
background: linear-gradient(135deg, rgba(46, 55, 67, 0.9), rgba(56, 66, 79, 0.9));
border-color: var(--border-light);
color: var(--text);
}
.welcome-btn-tunnel:hover { box-shadow: none; }
.welcome-btn-tunnel.active {
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-a));
border-color: var(--accent);
color: var(--accent-ink);
}
/* ---- Focus rings (driven by --accent, now emerald) stay accessible ---- */
:focus-visible { outline-color: var(--accent); }
}
+102 -26
View File
@@ -15,6 +15,10 @@
(function (global) { (function (global) {
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/; const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/; const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
// Grace window after a manual scroll-up gesture during which sticky-scroll is
// suppressed, so high-frequency Codex status redraws don't snap the viewport
// back to the bottom while the user is inspecting earlier output.
const USER_SCROLL_STICKY_SUPPRESS_MS = 1500;
function isTerminalQueryResponse(data) { function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data); return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
@@ -24,10 +28,27 @@
return isTerminalQueryResponse(data); return isTerminalQueryResponse(data);
} }
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
// theme, so default behavior is unchanged. Shared at module scope and exported on the
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
// a separate IIFE) can read the current skin's palette.
const CODEMAN_XTERM_THEMES = {
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
};
function currentXtermTheme() {
const skin = (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue'];
}
global.CodemanTerminalInput = { global.CodemanTerminalInput = {
isTerminalQueryResponse, isTerminalQueryResponse,
shouldSuppressTerminalQueryResponse, shouldSuppressTerminalQueryResponse,
USER_SCROLL_STICKY_SUPPRESS_MS,
}; };
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
})(window); })(window);
Object.assign(CodemanApp.prototype, { Object.assign(CodemanApp.prototype, {
@@ -42,29 +63,7 @@ Object.assign(CodemanApp.prototype, {
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK; const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
this.terminal = new Terminal({ this.terminal = new Terminal({
theme: { theme: { ...window.codemanCurrentXtermTheme() },
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line) // Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14, fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
@@ -79,6 +78,23 @@ Object.assign(CodemanApp.prototype, {
this.fitAddon = new FitAddon.FitAddon(); this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon); this.terminal.loadAddon(this.fitAddon);
// SerializeAddon: lets us snapshot the xterm rendered state (viewport +
// scrollback + colors/attrs) when switching away from a tab and restore
// it on switch-back. Needed primarily for codex tabs — codex's TUI drops
// earlier conversation from its current frame, so replaying the server
// byte buffer on tab-switch shows only the latest (idle) frame. The
// snapshot captures what the user was actually looking at.
this._xtermSnapshots = new Map(); // Map<sessionId, serialized-string>
if (typeof SerializeAddon !== 'undefined') {
try {
this._serializeAddon = new SerializeAddon.SerializeAddon();
this.terminal.loadAddon(this._serializeAddon);
} catch (_e) {
/* SerializeAddon failed — snapshot/restore disabled, fallback to buffer-fetch */
this._serializeAddon = null;
}
}
if (typeof Unicode11Addon !== 'undefined') { if (typeof Unicode11Addon !== 'undefined') {
try { try {
const unicode11Addon = new Unicode11Addon.Unicode11Addon(); const unicode11Addon = new Unicode11Addon.Unicode11Addon();
@@ -302,6 +318,7 @@ Object.assign(CodemanApp.prototype, {
(ev) => { (ev) => {
ev.preventDefault(); ev.preventDefault();
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1); const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines); this.terminal.scrollLines(lines);
}, },
{ passive: false } { passive: false }
@@ -376,6 +393,7 @@ Object.assign(CodemanApp.prototype, {
const ch = cellHeight(); const ch = cellHeight();
const lines = Math.trunc(pixelAccum / ch); const lines = Math.trunc(pixelAccum / ch);
if (lines !== 0) { if (lines !== 0) {
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines); this.terminal.scrollLines(lines);
pixelAccum -= lines * ch; pixelAccum -= lines * ch;
} }
@@ -428,6 +446,7 @@ Object.assign(CodemanApp.prototype, {
this._chunkedWriteGen = 0; this._chunkedWriteGen = 0;
this._bufferLoadSeq = 0; this._bufferLoadSeq = 0;
this._bufferLoadOwner = null; this._bufferLoadOwner = null;
this._lastUserScrollUpAt = null;
// Handle resize with throttling for performance // Handle resize with throttling for performance
this._resizeTimeout = null; this._resizeTimeout = null;
@@ -1364,6 +1383,22 @@ Object.assign(CodemanApp.prototype, {
return buffer.viewportY >= buffer.baseY - 2; return buffer.viewportY >= buffer.baseY - 2;
}, },
// Record manual scroll gestures so sticky-scroll can give an upward scroll a
// short grace window (see _hasRecentUserScrollUp). A downward scroll that
// lands back at the bottom clears the suppression immediately.
_noteTerminalUserScroll(lines) {
if (lines < 0) {
this._lastUserScrollUpAt = performance.now();
} else if (this.isTerminalAtBottom()) {
this._lastUserScrollUpAt = null;
}
},
_hasRecentUserScrollUp() {
if (typeof this._lastUserScrollUpAt !== 'number') return false;
return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS;
},
batchTerminalWrite(data) { batchTerminalWrite(data) {
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events // If a buffer load (chunkedTerminalWrite) is in progress, queue live events
// to prevent interleaving historical buffer data with live SSE data. // to prevent interleaving historical buffer data with live SSE data.
@@ -1615,8 +1650,16 @@ Object.assign(CodemanApp.prototype, {
// Per-frame byte budget to prevent main thread blocking. // Per-frame byte budget to prevent main thread blocking.
// Large writes (141KB+) can freeze Chrome for 2+ minutes. // Large writes (141KB+) can freeze Chrome for 2+ minutes.
const MAX_FRAME_BYTES = 65536; // 64KB budget per frame // Codex's TUI emits dense synchronized redraws during thinking/high-effort
// phases, so it gets a smaller first frame to keep per-frame xterm/WebGL
// stalls short; other modes keep the larger 64KB budget.
const activeSession = this.activeSessionId && this.sessions ? this.sessions.get(this.activeSessionId) : null;
const MAX_FRAME_BYTES = activeSession?.mode === 'codex' ? 32768 : 65536;
let deferred = false; let deferred = false;
// If the user recently scrolled up, remember the viewport so we can restore
// it after the write — Codex status redraws would otherwise jump it.
const preserveViewportY =
this._hasRecentUserScrollUp() && this.terminal.buffer?.active ? this.terminal.buffer.active.viewportY : null;
if (_joinedLen <= MAX_FRAME_BYTES) { if (_joinedLen <= MAX_FRAME_BYTES) {
this.terminal.write(joined); this.terminal.write(joined);
@@ -1633,6 +1676,13 @@ Object.assign(CodemanApp.prototype, {
}); });
} }
} }
if (
preserveViewportY !== null &&
this.terminal.buffer?.active?.viewportY !== preserveViewportY &&
typeof this.terminal.scrollToLine === 'function'
) {
this.terminal.scrollToLine(preserveViewportY);
}
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen; const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
const _dt = performance.now() - _t0; const _dt = performance.now() - _t0;
if (_dt > 100 || deferred) if (_dt > 100 || deferred)
@@ -1640,8 +1690,11 @@ Object.assign(CodemanApp.prototype, {
`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame / 1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)` `[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame / 1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)`
); );
// Sticky scroll: if user was at bottom, keep them there after new output // Sticky scroll: if user was at bottom, keep them there after new output.
if (this._wasAtBottomBeforeWrite) { // Give manual scroll-up gestures a short grace window so high-frequency
// Codex status ticks do not snap the viewport back while the user is
// trying to inspect earlier output.
if (this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) {
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
} }
@@ -2123,4 +2176,27 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('dirDisplay').textContent = value || 'No directory'; document.getElementById('dirDisplay').textContent = value || 'No directory';
}, 100); }, 100);
}, },
// Re-theme all live xterm terminals (main + teammate) to the given skin's palette.
// Uses the xterm v5+ live setter (full object assignment triggers a repaint for both
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
applyTerminalSkin(skin) {
const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) };
if (this.terminal) {
this.terminal.options.theme = theme;
try {
this.terminal.refresh(0, this.terminal.rows - 1);
} catch {}
}
if (this.teammateTerminals) {
for (const [, entry] of this.teammateTerminals) {
if (entry && entry.terminal) {
entry.terminal.options.theme = { ...theme };
try {
entry.terminal.refresh(0, entry.terminal.rows - 1);
} catch {}
}
}
}
},
}); });
+206 -22
View File
@@ -3,16 +3,163 @@
* Provides directory listing, file content preview, raw file serving, and tail streaming. * Provides directory listing, file content preview, raw file serving, and tail streaming.
*/ */
import { FastifyInstance } from 'fastify'; import { FastifyInstance, type FastifyReply } from 'fastify';
import { basename as pathBasename, join } from 'node:path'; import { basename as pathBasename, join } from 'node:path';
import { homedir } from 'node:os'; import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js'; import { fileStreamManager } from '../../file-stream-manager.js';
import {
AttachmentRegistrationError,
attachmentRegistry,
registerExternalAttachment,
type AttachmentRecord,
} from '../../attachment-registry.js';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js'; import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js'; import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, SessionPort } from '../ports/index.js';
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void { const MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
ico: 'image/x-icon',
bmp: 'image/bmp',
pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
json: 'application/json',
md: 'text/markdown',
txt: 'text/plain',
};
function sanitizeDownloadName(fileName: string): string {
return fileName.replace(/["\\\r\n]/g, '_');
}
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
const headers = reply.getHeaders();
reply.hijack();
for (const [name, value] of Object.entries(headers)) {
if (value !== undefined) {
reply.raw.setHeader(name, value);
}
}
content.on('error', (err) => {
if (reply.raw.headersSent) {
reply.raw.destroy(err);
return;
}
reply.raw.statusCode = 500;
reply.raw.end('Failed to read file');
});
content.pipe(reply.raw);
}
async function serveRawFile(
reply: FastifyReply,
resolvedPath: string,
fileName: string,
extension: string,
download?: boolean
): Promise<void> {
const stat = await fs.stat(resolvedPath);
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
reply
.code(413)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`
)
);
return;
}
const content = createReadStream(resolvedPath);
const safeName = sanitizeDownloadName(fileName);
if (download || extension === 'svg') {
reply.header(
'Content-Type',
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
);
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
return;
}
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
}
function getAttachmentOr404(
reply: FastifyReply,
sessionId: string,
attachmentId: string
): AttachmentRecord | undefined {
const record = attachmentRegistry.get(sessionId, attachmentId);
if (!record) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment not found'));
return undefined;
}
return record;
}
/**
* COD-53 defense-in-depth: refuse to stream a record whose underlying path is
* blocked by the active attachment-guard policy, even though registration
* already blocks them. Guards against records that predate the guard or were
* crafted to point at a sensitive file. Resolves symlinks before the check so a
* record pointing at a symlink that now resolves to a sensitive target is also
* caught; if the path can't be resolved (deleted/unreadable) the check still
* runs on the stored path. When workspace confinement is enabled it additionally
* rejects any record outside the session workspace. Returns true (and sends a
* 403) when blocked.
*/
async function resolveServableAttachmentPath(
reply: FastifyReply,
record: AttachmentRecord,
sessionWorkingDir?: string
): Promise<string | null> {
let pathToCheck = record.filePath;
let resolved = false;
try {
pathToCheck = realpathSync(record.filePath);
resolved = true;
} catch {
// Fall back to the stored (already realpath-resolved at registration) path.
}
const guard = await loadAttachmentGuardConfig();
const blocked =
isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) ||
isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
(guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck)));
if (blocked) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return null;
}
// Serve the freshly-resolved path, not the stored one: if a path component
// became a symlink after registration, the guard checked the resolved target
// but streaming record.filePath would follow the symlink to a swapped file.
return resolved ? pathToCheck : record.filePath;
}
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
// File tree listing // File tree listing
app.get('/api/sessions/:id/files', async (req) => { app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
@@ -315,6 +462,59 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
} }
}); });
// ===== Live external attachments =====
// Register an explicit, live external file (absolute host path) as an
// attachment with a stable id so browser requests never carry arbitrary
// paths. Registration enforces the COD-53 attachment-guard policy. Serving is
// by id via the /raw route below; document previews/thumbnails and the
// attachment-history list are layered on separately.
app.post('/api/sessions/:id/attachments', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const body = (req.body || {}) as { path?: string };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
return;
}
try {
const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
ctx.broadcast(SseEvent.AttachmentDetected, event);
return { success: true, data: event };
} catch (err) {
if (err instanceof AttachmentRegistrationError) {
reply.code(err.statusCode).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message));
return;
}
return reply
.code(500)
.send(
createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to register attachment: ${getErrorMessage(err)}`)
);
}
});
// Serve the raw bytes of a registered attachment by id. Re-checks the
// attachment-guard policy on every request (defense-in-depth) before streaming.
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const { download } = req.query as { download?: string };
const session = findSessionOrFail(ctx, id);
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
if (!servePath) return;
try {
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true');
} catch (err) {
reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
});
// Stream file content via tail -f (SSE endpoint) // Stream file content via tail -f (SSE endpoint)
app.get('/api/sessions/:id/tail-file', async (req, reply) => { app.get('/api/sessions/:id/tail-file', async (req, reply) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
@@ -387,24 +587,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
}); });
// Session-scoped file download. // Session-scoped file download.
// Uses the same realpath-based workspace boundary as file preview/raw routes; // Uses the same realpath-based workspace boundary as file preview/raw routes;
// the sensitive-path blocklist remains defense-in-depth, not the primary boundary. // the shared sensitive-path blocklist (../sensitive-path.js, also used by the
const SENSITIVE_PATTERNS: RegExp[] = [ // attachment guard) remains defense-in-depth, not the primary boundary.
/^\/etc\/shadow$/,
/^\/etc\/gshadow$/,
/^\/etc\/master\.passwd$/,
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
/\/\.env$/,
/\/\.env\./,
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
/\/\.aws\/credentials$/,
/\/\.gcloud\/credentials\.db$/,
/\/\.docker\/config\.json$/,
];
function isSensitivePath(absPath: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
}
app.get('/api/download', async (req, reply) => { app.get('/api/download', async (req, reply) => {
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string }; const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
+54 -2
View File
@@ -66,6 +66,31 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
const CTRL_L_PATTERN = /\x0c/g; const CTRL_L_PATTERN = /\x0c/g;
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/; const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
/**
* Match xterm alternate-screen mode toggles + the standalone scrollback-erase.
*
* - DECSET/DECRST 47, 1047, 1049 = enter/exit alternate screen buffer
* (1049 also saves cursor and clears the alt buffer).
* - CSI 3 J = erase saved lines (scrollback).
*
* Codex emits `\x1b[?1049h` and clear-scrollback sequences during startup and
* on repaint. xterm.js obeys them by switching to the alt buffer (no native
* scrollback) and wiping saved lines, so the user's conversation history
* disappears on every tab switch / pane refresh. Stripping these from the
* replayed byte stream keeps everything in the main buffer with scrollback
* intact. Mirrors the live-stream strip in Session._handleTerminalOutput.
*/
// eslint-disable-next-line no-control-regex
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
// eslint-disable-next-line no-control-regex
const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g;
// Mouse-tracking enables (X10/button/any-event/UTF-8/SGR/alt-scroll) — once on,
// xterm.js forwards wheel events to the app instead of scrolling the viewport.
// Live streams are stripped at the source, but buffers persisted BEFORE that
// strip existed can still carry them; strip on replay for parity.
// eslint-disable-next-line no-control-regex
const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g;
/** /**
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer. * Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate * Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
@@ -907,7 +932,24 @@ export function registerSessionRoutes(
const query = req.query as { tail?: string }; const query = req.query as { tail?: string };
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
const rawBuffer = session.terminalBuffer; // Prepend the live tmux pane buffer so tab-switch replay shows the current
// on-screen frame, not just the accumulated byte history. This matters for
// TUI modes (codex/opencode) that repaint only their latest frame: the
// accumulated buffer alone replays as the idle banner. We clear the viewport
// (`\x1b[H\x1b[2J`) between the history and the live pane so they don't
// overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and
// returns null when unavailable, in which case we fall back to history.
const muxName = session.muxName;
const liveMuxBuffer =
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
? ctx.mux.captureActivePaneBuffer(muxName)
: null;
const rawBuffer =
liveMuxBuffer !== null && liveMuxBuffer.length > 0
? session.terminalBufferLength > 0
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
: liveMuxBuffer
: session.terminalBuffer;
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const fullSize = rawBuffer.length; const fullSize = rawBuffer.length;
let truncated = false; let truncated = false;
@@ -917,7 +959,17 @@ export function registerSessionRoutes(
// During long thinking phases, Ink rewrites the same rows thousands of times // During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and // (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs. // the terminal appears empty when switching tabs.
const strippedBuffer = stripInkRedrawBloat(rawBuffer); let strippedBuffer = stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from codex byte streams.
// xterm.js obeys them by switching to its scrollback-less alt buffer and
// wiping saved lines, so conversation history disappears on tab switch.
if (session.mode === 'codex') {
strippedBuffer = strippedBuffer
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
.replace(ERASE_SCROLLBACK_PATTERN, '')
.replace(MOUSE_TRACKING_PATTERN, '');
}
if (tailBytes > 0 && strippedBuffer.length > tailBytes) { if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer. // Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
+21
View File
@@ -14,6 +14,7 @@ import { execSync, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js'; import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { import {
ConfigUpdateSchema, ConfigUpdateSchema,
SettingsUpdateSchema, SettingsUpdateSchema,
@@ -498,6 +499,26 @@ export function registerSystemRoutes(
app.put('/api/settings', async (req) => { app.put('/api/settings', async (req) => {
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>; const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
// control = effectively RCE) to a public *.trycloudflare.com URL. Because the
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
// unauthenticated. Refuse to start a tunnel unless auth is configured OR the
// operator has acknowledged unauthenticated-network exposure. A public tunnel is
// higher-stakes than a LAN bind, so this is REFUSE (vs the bind guard's warn).
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning() && !isUnauthenticatedNetworkAcknowledged()) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, or set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 to acknowledge an ' +
'unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
try { try {
const dir = dirname(SETTINGS_PATH); const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) { if (!existsSync(dir)) {
+41
View File
@@ -0,0 +1,41 @@
/**
* @fileoverview Shared sensitive-path blocklist.
*
* A small defense-in-depth blocklist of absolute paths that must never be
* served to the browser regardless of how the path was obtained (workspace
* download, cross-workspace attachment registration, raw/preview serving).
*
* This is intentionally a BLOCKLIST, not a workspace-confinement check:
* cross-workspace attachment is a supported feature (codeman-publish skill +
* the automated review-card loop attaching files under ~/.codeman/), so a
* strict session-workspace boundary would break legitimate use. The blocklist
* rejects well-known secret locations (system password files, SSH keys, cloud
* credentials, dotenv files) while leaving ordinary cross-workspace files
* attachable.
*
* Callers MUST resolve symlinks (realpath) BEFORE calling isSensitivePath so a
* symlink pointing at a sensitive target is also caught.
*/
import { homedir } from 'node:os';
const SENSITIVE_PATTERNS: RegExp[] = [
/^\/etc\/shadow$/,
/^\/etc\/gshadow$/,
/^\/etc\/master\.passwd$/,
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
/\/\.env$/,
/\/\.env\./,
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
/\/\.aws\/credentials$/,
/\/\.gcloud\/credentials\.db$/,
/\/\.docker\/config\.json$/,
];
/**
* Returns true if the given ABSOLUTE, symlink-resolved path matches the
* sensitive-file blocklist and must not be served to the browser.
*/
export function isSensitivePath(absPath: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
}
+44 -1
View File
@@ -41,6 +41,7 @@ import fs from 'node:fs/promises';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os'; import { hostname as getHostname } from 'node:os';
import { dataPath } from '../config/instance.js'; import { dataPath } from '../config/instance.js';
import { getHookSecret } from '../config/hook-secret.js';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js'; import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
import type { ClaudeMode, SessionState } from '../types.js'; import type { ClaudeMode, SessionState } from '../types.js';
@@ -59,6 +60,7 @@ import {
type SubagentToolResult, type SubagentToolResult,
} from '../subagent-watcher.js'; } from '../subagent-watcher.js';
import { imageWatcher } from '../image-watcher.js'; import { imageWatcher } from '../image-watcher.js';
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
import { TranscriptWatcher } from '../transcript-watcher.js'; import { TranscriptWatcher } from '../transcript-watcher.js';
import { TeamWatcher } from '../team-watcher.js'; import { TeamWatcher } from '../team-watcher.js';
import { TunnelManager } from '../tunnel-manager.js'; import { TunnelManager } from '../tunnel-manager.js';
@@ -109,6 +111,7 @@ import {
type PersistedRespawnConfig, type PersistedRespawnConfig,
type NiceConfig, type NiceConfig,
type ImageDetectedEvent, type ImageDetectedEvent,
type AttachmentDetectedEvent,
DEFAULT_NICE_CONFIG, DEFAULT_NICE_CONFIG,
} from '../types.js'; } from '../types.js';
import { import {
@@ -247,12 +250,14 @@ export class WebServer extends EventEmitter {
} | null = null; } | null = null;
private imageWatcherHandlers: { private imageWatcherHandlers: {
detected: (event: ImageDetectedEvent) => void; detected: (event: ImageDetectedEvent) => void;
attachmentDetected: (event: AttachmentDetectedEvent) => void;
error: (error: Error, sessionId?: string) => void; error: (error: Error, sessionId?: string) => void;
} | null = null; } | null = null;
private tunnelManager: TunnelManager = new TunnelManager(); private tunnelManager: TunnelManager = new TunnelManager();
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null; private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
private authFailures: StaleExpirationMap<string, number> | null = null; private authFailures: StaleExpirationMap<string, number> | null = null;
private qrAuthFailures: StaleExpirationMap<string, number> | null = null; private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore(); private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher(); private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null; private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
@@ -434,12 +439,15 @@ export class WebServer extends EventEmitter {
// Store handlers for cleanup on shutdown // Store handlers for cleanup on shutdown
this.imageWatcherHandlers = { this.imageWatcherHandlers = {
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event), detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
attachmentDetected: (event: AttachmentDetectedEvent) =>
this.broadcast(SseEvent.AttachmentDetected, { ...event, source: event.source || 'detected' }),
error: (error: Error, sessionId?: string) => { error: (error: Error, sessionId?: string) => {
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message); console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
}, },
}; };
imageWatcher.on('image:detected', this.imageWatcherHandlers.detected); imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
imageWatcher.on('attachment:detected', this.imageWatcherHandlers.attachmentDetected);
imageWatcher.on('image:error', this.imageWatcherHandlers.error); imageWatcher.on('image:error', this.imageWatcherHandlers.error);
} }
@@ -449,6 +457,7 @@ export class WebServer extends EventEmitter {
private cleanupImageWatcherListeners(): void { private cleanupImageWatcherListeners(): void {
if (this.imageWatcherHandlers) { if (this.imageWatcherHandlers) {
imageWatcher.off('image:detected', this.imageWatcherHandlers.detected); imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
imageWatcher.off('attachment:detected', this.imageWatcherHandlers.attachmentDetected);
imageWatcher.off('image:error', this.imageWatcherHandlers.error); imageWatcher.off('image:error', this.imageWatcherHandlers.error);
this.imageWatcherHandlers = null; this.imageWatcherHandlers = null;
} }
@@ -603,11 +612,12 @@ export class WebServer extends EventEmitter {
registerHostGuard(this.app, () => this.getHostPolicy()); registerHostGuard(this.app, () => this.getHostPolicy());
// Auth middleware (Basic Auth + session cookies + rate limiting) // Auth middleware (Basic Auth + session cookies + rate limiting)
const authState = registerAuthMiddleware(this.app, this.https); const authState = registerAuthMiddleware(this.app, this.https, () => this.tunnelManager.isRunning());
if (authState) { if (authState) {
this.authSessions = authState.authSessions; this.authSessions = authState.authSessions;
this.authFailures = authState.authFailures; this.authFailures = authState.authFailures;
this.qrAuthFailures = authState.qrAuthFailures; this.qrAuthFailures = authState.qrAuthFailures;
this.hookSecretFailures = authState.hookSecretFailures;
} }
// WebSocket support (terminal I/O — low-latency bidirectional channel) // WebSocket support (terminal I/O — low-latency bidirectional channel)
@@ -1064,6 +1074,8 @@ export class WebServer extends EventEmitter {
session.removeAllListeners(); session.removeAllListeners();
// Close any active file streams for this session // Close any active file streams for this session
fileStreamManager.closeSessionStreams(sessionId); fileStreamManager.closeSessionStreams(sessionId);
// Drop live external attachment registrations for this session
attachmentRegistry.clearSession(sessionId);
// Stop watching for images in this session's directory // Stop watching for images in this session's directory
imageWatcher.unwatchSession(sessionId); imageWatcher.unwatchSession(sessionId);
// Clean up pasted images directory for this session // Clean up pasted images directory for this session
@@ -1249,9 +1261,32 @@ export class WebServer extends EventEmitter {
} }
}, },
getStore: () => this.store, getStore: () => this.store,
registerAttachment: (id: string, filePath: string) => this.registerAttachment(id, filePath),
}; };
} }
/**
* Register a terminal-requested external file as a live attachment and
* broadcast it. Triggered by the session's `attachmentRequested` event
* (codeman://attach magic links). Because terminal output is
* attacker-influenceable (a prompt-injected session can print an arbitrary
* `codeman://attach?path=` link), the scanned path is FORCE-confined to the
* session workspace — passive magic links can't expose arbitrary host files.
* Deliberate cross-workspace attachment goes through the explicit,
* Origin-guarded `POST /attachments` route (and `codeman attach`, which POSTs
* directly inside a managed session). Registration also enforces the COD-53
* blocklist as defense-in-depth.
*/
private async registerAttachment(sessionId: string, filePath: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
const event = await registerExternalAttachment(sessionId, filePath, {
sessionWorkingDir: session.workingDir,
forceWorkspaceConfinement: true,
});
this.broadcast(SseEvent.AttachmentDetected, event);
}
private setupRespawnListeners(sessionId: string, controller: RespawnController): void { private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps()); wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps());
} }
@@ -1816,6 +1851,10 @@ export class WebServer extends EventEmitter {
this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host; this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host;
process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`; process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`;
// Ensure the COD-54 hook secret exists on disk before any session exports
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
getHookSecret();
// Start scheduled runs cleanup timer // Start scheduled runs cleanup timer
this.cleanup.setInterval( this.cleanup.setInterval(
() => { () => {
@@ -2298,6 +2337,10 @@ export class WebServer extends EventEmitter {
this.qrAuthFailures.dispose(); this.qrAuthFailures.dispose();
this.qrAuthFailures = null; this.qrAuthFailures = null;
} }
if (this.hookSecretFailures) {
this.hookSecretFailures.dispose();
this.hookSecretFailures = null;
}
this.activePlanOrchestrators.clear(); this.activePlanOrchestrators.clear();
this.cleaningUp.clear(); this.cleaningUp.clear();
+12 -1
View File
@@ -58,6 +58,7 @@ export interface SessionListenerRefs {
bashToolStart: (tool: ActiveBashTool) => void; bashToolStart: (tool: ActiveBashTool) => void;
bashToolEnd: (tool: ActiveBashTool) => void; bashToolEnd: (tool: ActiveBashTool) => void;
bashToolsUpdate: (tools: ActiveBashTool[]) => void; bashToolsUpdate: (tools: ActiveBashTool[]) => void;
attachmentRequested: (event: { path: string }) => void;
} }
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */ /** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
@@ -77,10 +78,11 @@ interface SessionListenerDeps {
removeSessionListenerRefs(sessionId: string): void; removeSessionListenerRefs(sessionId: string): void;
cleanupRespawnOnExit(sessionId: string): void; cleanupRespawnOnExit(sessionId: string): void;
getStore(): import('../state-store.js').StateStore; getStore(): import('../state-store.js').StateStore;
registerAttachment(sessionId: string, filePath: string): Promise<void>;
} }
/** /**
* Creates all 25 session listener handlers, capturing dependencies via closure. * Creates all 26 session listener handlers, capturing dependencies via closure.
* Call `attachSessionListeners()` after to wire them to the session. * Call `attachSessionListeners()` after to wire them to the session.
*/ */
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs { export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
@@ -355,6 +357,13 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
bashToolsUpdate: (tools: ActiveBashTool[]) => { bashToolsUpdate: (tools: ActiveBashTool[]) => {
deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools }); deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
}, },
/** Registers an explicit attachment card requested by terminal magic text. */
attachmentRequested: (event: { path: string }) => {
deps.registerAttachment(session.id, event.path).catch((err) => {
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
});
},
}; };
} }
@@ -388,6 +397,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('bashToolStart', refs.bashToolStart); session.on('bashToolStart', refs.bashToolStart);
session.on('bashToolEnd', refs.bashToolEnd); session.on('bashToolEnd', refs.bashToolEnd);
session.on('bashToolsUpdate', refs.bashToolsUpdate); session.on('bashToolsUpdate', refs.bashToolsUpdate);
session.on('attachmentRequested', refs.attachmentRequested);
} }
/** Detach all listeners from a session (prevents memory leaks from closure references). */ /** Detach all listeners from a session (prevents memory leaks from closure references). */
@@ -420,4 +430,5 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('bashToolStart', refs.bashToolStart); session.off('bashToolStart', refs.bashToolStart);
session.off('bashToolEnd', refs.bashToolEnd); session.off('bashToolEnd', refs.bashToolEnd);
session.off('bashToolsUpdate', refs.bashToolsUpdate); session.off('bashToolsUpdate', refs.bashToolsUpdate);
session.off('attachmentRequested', refs.attachmentRequested);
} }
+3
View File
@@ -284,6 +284,8 @@ export const TunnelQrAuthUsed = 'tunnel:qrAuthUsed' as const;
/** New image file detected (e.g. screenshot upload). */ /** New image file detected (e.g. screenshot upload). */
export const ImageDetected = 'image:detected' as const; export const ImageDetected = 'image:detected' as const;
/** New document/image attachment detected in a session working directory. */
export const AttachmentDetected = 'attachment:detected' as const;
// ─── Hooks ─────────────────────────────────────────────────────────────────── // ─── Hooks ───────────────────────────────────────────────────────────────────
@@ -479,6 +481,7 @@ export const SseEvent = {
// Image // Image
ImageDetected, ImageDetected,
AttachmentDetected,
// Hooks // Hooks
HookIdlePrompt, HookIdlePrompt,
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { parseAttachmentMagicLinks } from '../src/attachment-magic.js';
describe('attachment magic links', () => {
it('extracts absolute paths from codeman attach magic URLs', () => {
const links = parseAttachmentMagicLinks(
'Preview this: codeman://attach?path=%2Fmnt%2Fc%2FDecks%2FBoard%20Update.pptx'
);
expect(links).toEqual(['/mnt/c/Decks/Board Update.pptx']);
});
it('ignores duplicate links in one terminal chunk', () => {
const links = parseAttachmentMagicLinks(
[
'codeman://attach?path=/tmp/report.pdf',
'codeman://attach?path=/tmp/report.pdf',
'codeman://attach?path=/tmp/brief.docx',
].join('\n')
);
expect(links).toEqual(['/tmp/report.pdf', '/tmp/brief.docx']);
});
it('accepts markdown and plain-text magic paths', () => {
const links = parseAttachmentMagicLinks(
['codeman://attach?path=/tmp/notes.md', 'codeman://attach?path=/tmp/run.txt'].join('\n')
);
expect(links).toEqual(['/tmp/notes.md', '/tmp/run.txt']);
});
it('rejects relative or unsupported magic paths', () => {
const links = parseAttachmentMagicLinks(
[
'codeman://attach?path=relative.pdf',
'codeman://attach?path=/tmp/archive.zip',
'codeman://attach?path=/tmp/deck.pptx',
].join('\n')
);
expect(links).toEqual(['/tmp/deck.pptx']);
});
it('emits attachmentRequested from raw terminal output', () => {
const session = new Session({ id: 'session-attach-test', workingDir: '/tmp', mode: 'codex' });
const requested: string[] = [];
session.on('attachmentRequested', (event: { path: string }) => requested.push(event.path));
(session as unknown as { _handleTerminalOutput(data: string): void })._handleTerminalOutput(
'codeman://attach?path=%2Ftmp%2Fdeck.pptx'
);
expect(requested).toEqual(['/tmp/deck.pptx']);
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* @fileoverview COD-54 — hook-event auth bypass hardening.
*
* The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared
* --url http://127.0.0.1:port) reach the loopback origin with req.ip ===
* 127.0.0.1 and drive respawn/Ralph signals unauthenticated. The fix gates
* the bypass behind a shared hook secret WHEN A TUNNEL IS RUNNING, while
* keeping the plain localhost bypass for the normal loopback-only case so
* already-deployed (pre-secret) hooks and the loop's own channel keep working.
*
* Tests:
* - tunnel running + no secret → 401 (closes the hole)
* - tunnel running + bad secret → 401
* - tunnel running + good secret → not 401 (allowed)
* - tunnel NOT running + no secret → not 401 (back-compat regression guard)
* - rate limiting: rapid unauthorized hook POSTs eventually 429
*
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { TunnelManager } from '../src/tunnel-manager.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
const TUNNEL_UP_PORT = 3230;
const TUNNEL_DOWN_PORT = 3231;
const RATE_LIMIT_PORT = 3232;
const TEST_USER = 'admin';
const TEST_PASS = 'cod54-test-password';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
function hookBody(): string {
return JSON.stringify({ event: 'stop', sessionId: 'nonexistent-session', data: {} });
}
async function postHook(baseUrl: string, headers: Record<string, string> = {}): Promise<Response> {
return fetch(`${baseUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: hookBody(),
});
}
describe('COD-54 hook-event auth — tunnel running requires secret', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Force the middleware's tunnel check to report "running".
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(TUNNEL_UP_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_UP_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('rejects a localhost hook POST WITHOUT the secret header (closes the tunnel hole)', async () => {
const res = await postHook(baseUrl);
expect(res.status).toBe(401);
});
it('rejects a localhost hook POST with a WRONG secret', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: 'wrong-secret-value' });
expect(res.status).toBe(401);
});
it('allows a localhost hook POST WITH the correct secret', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: getHookSecret() });
// Passes auth (may 200 with success:false for unknown session) — key is NOT 401.
expect(res.status).not.toBe(401);
});
});
describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-compat)', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel NOT running — loopback-only normal prod case.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false);
server = new WebServer(TUNNEL_DOWN_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_DOWN_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('still allows a localhost hook POST WITHOUT a secret (existing hooks + loop channel keep working)', async () => {
const res = await postHook(baseUrl);
expect(res.status).not.toBe(401);
});
});
describe('COD-54 hook-event auth — rate limiting', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(RATE_LIMIT_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${RATE_LIMIT_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('eventually returns 429 for rapid unauthorized hook POSTs', async () => {
let saw429 = false;
// A few more than the failure max to cross the threshold.
for (let i = 0; i < AUTH_FAILURE_MAX + 3; i++) {
const res = await postHook(baseUrl);
if (res.status === 429) {
saw429 = true;
expect(res.headers.get('retry-after')).toMatch(/^\d+$/);
break;
}
expect(res.status).toBe(401);
}
expect(saw429).toBe(true);
});
it('hook-secret failures do NOT lock out the Basic-Auth login path (separate bucket)', async () => {
// The previous test exhausted the hook bucket for 127.0.0.1. Legacy (pre-secret)
// hooks fire constantly, so if they shared authFailures, every cookie-less
// request from loopback would now 429 — locking out login (and, via a tunnel,
// every client). Assert the login path is unaffected:
// 1. A credential-less request still gets a 401 challenge, NOT 429.
const unauthed = await fetch(`${baseUrl}/api/status`);
expect(unauthed.status).toBe(401);
// 2. Correct Basic credentials still authenticate.
const authed = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: 'Basic ' + Buffer.from(`${TEST_USER}:${TEST_PASS}`).toString('base64') },
});
expect(authed.status).toBe(200);
});
});
describe('COD-54 secret delivery — generated hooks + session env present the secret', () => {
it('generated hook curl commands send the secret header, read from the file at exec time', async () => {
const { generateHooksConfig } = await import('../src/hooks-config.js');
const config = generateHooksConfig();
const commands = JSON.stringify(config);
// Header present, value sourced from $CODEMAN_HOOK_SECRET_FILE (not embedded).
expect(commands).toContain(HOOK_SECRET_HEADER);
expect(commands).toContain('$CODEMAN_HOOK_SECRET_FILE');
expect(commands).not.toContain(getHookSecret());
});
it('session env builders export CODEMAN_HOOK_SECRET_FILE (path only, never the value)', async () => {
const { buildClaudeEnv, buildShellEnv } = await import('../src/session-cli-builder.js');
const claudeEnv = buildClaudeEnv('test-session');
const shellEnv = buildShellEnv('test-session');
expect(claudeEnv.CODEMAN_HOOK_SECRET_FILE).toMatch(/hook-secret$/);
expect(shellEnv.CODEMAN_HOOK_SECRET_FILE).toMatch(/hook-secret$/);
expect(JSON.stringify(claudeEnv)).not.toContain(getHookSecret());
});
});
+94
View File
@@ -0,0 +1,94 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
// Structural tests for the xterm snapshot/replay slice (COD-81). app.js has no
// bundler and is hard to drive through a real DOM, so — following the repo's
// existing pattern for app.js — these assert the source structure that makes
// the snapshot first-paint correct rather than executing it.
describe('xterm snapshot/replay (codex tab-switch)', () => {
const appSource = () => readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
it('rejects blank xterm snapshots before saving or restoring them', () => {
const source = appSource();
const helper = source.indexOf('_isUsableXtermSnapshot(snapshot)');
const save = source.indexOf('this._xtermSnapshots.set(this.activeSessionId, snapshot)');
const restore = source.indexOf('SNAPSHOT_RESTORE:', save);
const restoreBlock = source.slice(save, restore);
expect(helper).toBeGreaterThan(-1);
// The save is gated on a usability check immediately above it.
const usabilityGate = source.lastIndexOf('if (this._isUsableXtermSnapshot(snapshot))', save);
expect(usabilityGate).toBeGreaterThan(-1);
expect(usabilityGate).toBeLessThan(save);
// …and so is each restore path (in-memory + persisted).
expect(restoreBlock).toContain('if (snapshot && !this._isUsableXtermSnapshot(snapshot))');
expect(restoreBlock).toContain('persisted && this._isUsableXtermSnapshot(persisted)');
});
it('declares the snapshot-restore flag before selectSession uses it', () => {
const source = appSource();
const selectStart = source.indexOf('async selectSession(sessionId, options = {})');
const declaration = source.indexOf('let restoredSnapshot = false;', selectStart);
const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart);
const rewriteDecision = source.indexOf(
'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer',
selectStart
);
expect(selectStart).toBeGreaterThan(-1);
expect(declaration).toBeGreaterThan(selectStart);
expect(declaration).toBeLessThan(snapshotBranch);
expect(declaration).toBeLessThan(rewriteDecision);
});
it('uses xterm snapshots as first paint but still fetches the canonical terminal frame', () => {
const source = appSource();
const snapshotRestore = source.indexOf('SNAPSHOT_RESTORE:');
const cacheRestore = source.indexOf('Instant cache restore', snapshotRestore);
const fetchStart = source.indexOf("FETCH_START'", snapshotRestore);
const needsRewrite = source.indexOf('const needsRewrite', fetchStart);
const snapshotBlock = source.slice(snapshotRestore, cacheRestore);
const postSnapshotRestore = source.slice(snapshotRestore, needsRewrite + 160);
expect(snapshotRestore).toBeGreaterThan(-1);
expect(cacheRestore).toBeGreaterThan(snapshotRestore);
expect(fetchStart).toBeGreaterThan(cacheRestore);
expect(needsRewrite).toBeGreaterThan(fetchStart);
// Snapshot restore must NOT short-circuit the canonical fetch.
expect(snapshotBlock).not.toContain('this._finishBufferLoad();');
expect(postSnapshotRestore).toContain('restoredSnapshot');
expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
});
it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => {
const source = appSource();
const cacheRestore = source.indexOf('Instant cache restore');
const busyClear = source.indexOf('CACHE_SKIP_BUSY', cacheRestore);
const needsRewrite = source.indexOf('const needsRewrite', busyClear);
const replayBlock = source.slice(cacheRestore, needsRewrite + 160);
expect(cacheRestore).toBeGreaterThan(-1);
expect(busyClear).toBeGreaterThan(cacheRestore);
expect(needsRewrite).toBeGreaterThan(busyClear);
expect(replayBlock).toContain('clearedForBusy');
expect(replayBlock).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
});
it('loads the SerializeAddon and keeps a per-session snapshot map', () => {
const terminalSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
expect(terminalSource).toContain('this._xtermSnapshots = new Map()');
expect(terminalSource).toContain('new SerializeAddon.SerializeAddon()');
expect(terminalSource).toContain('this.terminal.loadAddon(this._serializeAddon)');
});
it('evicts the in-memory snapshot cache and persists with a bounded localStorage budget', () => {
const source = appSource();
// In-memory cache is LRU-bounded…
expect(source).toContain('if (this._xtermSnapshots.size > 20)');
// …per-snapshot localStorage writes are size-capped…
expect(source).toContain('snapshot.length < 256 * 1024');
// …and the persisted key set is pruned of dead sessions.
expect(source).toContain("k.startsWith('codeman-xs-')");
});
});
+278
View File
@@ -0,0 +1,278 @@
import { describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_ptyRows: number;
};
function handleOutput(session: Session, data: string): void {
(session as unknown as SessionInternals)._handleTerminalOutput(data);
}
describe('Codex terminal output filtering', () => {
it('keeps browser scrollback guards but skips Codeman row repair in hybrid render mode', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex', codexConfig: { renderMode: 'hybrid' } });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const hybridRedraw = '\x1b[?1049h\x1b[55;1H\x1b[2m• Working (21s)\x1b[3J\x1b[?1006h\x1b[?1049l';
handleOutput(session, hybridRedraw);
expect(emitted[0]).toBe('\x1b[55;1H\x1b[2m• Working (21s)');
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(session.terminalBuffer).toBe(emitted[0]);
});
it('preserves Codex erase-display redraws used by the TUI layout engine', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
handleOutput(session, '\x1b[H\x1b[Jidle redraw');
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jidle redraw');
});
it('strips Codex scrollback erase without stripping visible-screen erase', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
});
it('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
// '\x1b[?1049h' split mid-sequence, then '\x1b[3J' split before its final byte.
handleOutput(session, 'before\x1b[?104');
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
handleOutput(session, 'Jtail');
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
});
it('emits nothing for a chunk that is only a partial CSI, and completes it next chunk', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
handleOutput(session, '6h\x1b[55;1H• Working'); // completes ?1006h (stripped); rest passes
expect(emitted).toEqual(['\x1b[55;1H• Working']);
expect(session.terminalBuffer).toBe('\x1b[55;1H• Working');
});
it('preserves Codex erase-display redraw when the user pressed Ctrl+L', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
session.write('\x0c');
handleOutput(session, '\x1b[H\x1b[Jredraw after clear');
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jredraw after clear');
});
it('passes native Codex TUI prompt/status redraws through without row repair', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const bottomBandRedraw =
'\x1b[48;2;42;42;42m' +
'\x1b[60;2H\x1b[K' +
'\x1b[61;39H\x1b[K' +
'\x1b[62;2H\x1b[K' +
'\x1b[52;1H\x1b[49m\x1b[2m• \x1b[1mRunning node -e ...' +
'\x1b[60;1H\x1b[48;2;42;42;42m \r\n' +
'\x1b[1m›\x1b[0m\x1b[48;2;42;42;42m \x1b[2mUse /skills to list available skills\r\n' +
'\x1b[63;3H\x1b[49m\x1b[38;2;246;226;183mgpt-5.5 xhigh\x1b[39m' +
'\x1b[2m · \x1b[38;2;242;181;144mContext 42% left\x1b[39m' +
'\x1b[61;3H';
handleOutput(session, bottomBandRedraw);
expect(emitted[0]).not.toContain('\x1b[52;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[60;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[63;1H\x1b[2K');
expect(emitted[0]).toContain(bottomBandRedraw);
});
it('passes Codex advisory rows through without row repair', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const advisoryRedraw =
'\x1b[55;1H\x1b[2mMessages\x1b[Cto\x1b[Cbe submitted\x1b[Cafter\x1b[Cnext toolcall ' +
'(press esc to interrupt and send immediately)\x1b[56;1H';
handleOutput(session, advisoryRedraw);
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[0]).toContain(advisoryRedraw);
});
it('does not clear Codex resume-picker rows just because an option is selected', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const resumePickerRedraw =
'\x1b[1;2H\x1b[36m\x1b[1mResume a previous session' +
'\x1b[3;2H\x1b[2mType to search Filter: \x1b[35m[Cwd]\x1b[39m\x1b[2m All' +
'\x1b[5;3H\x1b[33m\x1b[48;2;42;42;42m\x1b[1m❯ \x1b[2m22h ago ll' +
'\x1b[6;3H\x1b[2m 1d ago $kb-health' +
'\x1b[60;1H\x1b[2m──── 2 / 2 · 100% ─' +
'\x1b[61;1H enter resume esc exit ↑/↓ browse';
handleOutput(session, resumePickerRedraw);
expect(emitted[0]).not.toContain('\x1b[4;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[5;1H\x1b[2K');
expect(emitted[0]).toContain(resumePickerRedraw);
});
it('does not full-clear sparse Codex resume-picker navigation redraws', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
let sparseResumePickerRedraw = '';
for (let row = 1; row <= 51; row++) {
const col = row % 2 === 0 ? 239 : 27;
sparseResumePickerRedraw += `\x1b[${row};${col}H\x1b[K`;
}
sparseResumePickerRedraw +=
'\x1b[21;3H \x1b[2m9d ago \x1b[mreview this webex room webexteams://im?space=672465b0-4fcb-11f1-9d54-51475df86e3a\x1b[K' +
'\x1b[22;3H\x1b[33m\x1b[1m❯ \x1b[m\x1b[33m\x1b[2m9d ago \x1b[m\x1b[33mcisco hybrid mesh firewall includes support for smart switch enforcement...\x1b[K' +
'\x1b[52;229H\x1b[39m\x1b[2m8\x1b[m';
handleOutput(session, sparseResumePickerRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
expect(emitted[0]).toContain(sparseResumePickerRedraw);
});
it('passes Codex UI rows through when the status band moves downward', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(
session,
'\x1b[55;1H\x1b[2m• Working (1s)\x1b[56;1H\x1b[1m›\x1b[0m ask\x1b[57;3Hgpt-5.5 · Context 80% left'
);
handleOutput(
session,
'\x1b[58;1H\x1b[2m• Working (2s)\x1b[59;1H\x1b[1m›\x1b[0m ask\x1b[60;3Hgpt-5.5 · Context 79% left'
);
expect(emitted[1]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[56;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[57;1H\x1b[2K');
expect(emitted[1]).toContain('\x1b[58;1H');
expect(emitted[1]).not.toContain('\x1b[54;1H\x1b[2K');
});
it('does not full-clear the viewport for stable Codex UI rows at the same position', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const stableRedraw =
'\x1b[58;1H\x1b[2m• Working (2s)\x1b[59;1H\x1b[1m›\x1b[0m ask\x1b[60;3Hgpt-5.5 · Context 79% left';
handleOutput(session, stableRedraw);
handleOutput(session, stableRedraw.replace('2s', '3s'));
expect(emitted[1]).not.toContain('\x1b[H\x1b[2J');
});
it('passes status-only Codex Working redraw rows through', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const workingRedraw = '\x1b[55;1H\x1b[2m• Working (21s)';
handleOutput(session, workingRedraw);
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[0]).toContain(workingRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
});
it('passes Codex spinner Working rows that omit elapsed time parentheses through', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 29;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const spinnerRedraw = '\x1b[24;1H\x1b[38;5;254m\x1b[1m•\x1b[CWorking\x1b[27;3H';
handleOutput(session, spinnerRedraw);
expect(emitted[0]).not.toContain('\x1b[24;1H\x1b[2K');
expect(emitted[0]).toContain(spinnerRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
});
it('does not treat ordinary gpt model mentions as Codex status rows', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const outputRow = '\x1b[20;1Hnormal output comparing gpt-5 and another model';
handleOutput(session, outputRow);
expect(emitted[0]).toContain(outputRow);
expect(emitted[0]).not.toContain('\x1b[19;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[20;1H\x1b[2K');
});
it('does not inject row erases during partial Working spinner ticks', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(
session,
'\x1b[55;1H\x1b[2m• Working (1s)' +
'\x1b[56;1H\x1b[1m›\x1b[0m ask' +
'\x1b[57;3Hgpt-5.5 xhigh fast · codeman · Working · Context 79% left'
);
handleOutput(session, '\x1b[55;1H\x1b[2m• Working (2s)');
expect(emitted[1]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[56;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[57;1H\x1b[2K');
expect(emitted[1]).toContain('\x1b[55;1H\x1b[2m• Working (2s)');
});
});
+42 -12
View File
@@ -145,7 +145,7 @@ describe('ImageWatcher', () => {
// ========== Image Detection ========== // ========== Image Detection ==========
describe('image detection', () => { describe('image detection', () => {
it('should emit image:detected for .png files', () => { it('should emit image:detected (popup) for .png files', () => {
const handler = vi.fn(); const handler = vi.fn();
watcher.on('image:detected', handler); watcher.on('image:detected', handler);
@@ -161,7 +161,37 @@ describe('ImageWatcher', () => {
expect(event.fileName).toBe('screenshot.png'); expect(event.fileName).toBe('screenshot.png');
expect(event.filePath).toBe('/home/user/project/screenshot.png'); expect(event.filePath).toBe('/home/user/project/screenshot.png');
expect(event.relativePath).toBe('screenshot.png'); expect(event.relativePath).toBe('screenshot.png');
expect(event.size).toBe(2048); });
it('should not emit attachment:detected for .png (stays on the popup path)', () => {
const handler = vi.fn();
watcher.on('attachment:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
vi.advanceTimersByTime(300);
expect(handler).not.toHaveBeenCalled();
});
it.each([
['report.pdf', 'pdf'],
['brief.docx', 'document'],
['deck.pptx', 'presentation'],
])('should emit attachment:detected for %s files', (fileName, attachmentType) => {
const handler = vi.fn();
watcher.on('attachment:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', `/home/user/project/${fileName}`);
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0]).toMatchObject({
sessionId: 'session-1',
fileName,
attachmentType,
});
}); });
it('should emit for .jpg files', () => { it('should emit for .jpg files', () => {
@@ -250,10 +280,10 @@ describe('ImageWatcher', () => {
watcher.on('image:detected', handler); watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project'); watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.png'); mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.jpg');
vi.advanceTimersByTime(300); vi.advanceTimersByTime(300);
expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.png'); expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.jpg');
}); });
}); });
@@ -268,11 +298,11 @@ describe('ImageWatcher', () => {
const chokidarWatcher = mockWatchers.get('/home/user/project')!; const chokidarWatcher = mockWatchers.get('/home/user/project')!;
// Rapid adds of the same file // Rapid adds of the same file
chokidarWatcher.emit('add', '/home/user/project/screenshot.png'); chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
vi.advanceTimersByTime(100); // not yet past debounce vi.advanceTimersByTime(100); // not yet past debounce
chokidarWatcher.emit('add', '/home/user/project/screenshot.png'); chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
vi.advanceTimersByTime(100); vi.advanceTimersByTime(100);
chokidarWatcher.emit('add', '/home/user/project/screenshot.png'); chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
vi.advanceTimersByTime(300); // now past debounce from last emit vi.advanceTimersByTime(300); // now past debounce from last emit
// Should only emit once (the last debounced one) // Should only emit once (the last debounced one)
@@ -286,8 +316,8 @@ describe('ImageWatcher', () => {
watcher.watchSession('session-1', '/home/user/project'); watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!; const chokidarWatcher = mockWatchers.get('/home/user/project')!;
chokidarWatcher.emit('add', '/home/user/project/a.png'); chokidarWatcher.emit('add', '/home/user/project/a.jpg');
chokidarWatcher.emit('add', '/home/user/project/b.png'); chokidarWatcher.emit('add', '/home/user/project/b.jpg');
vi.advanceTimersByTime(300); vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(2); expect(handler).toHaveBeenCalledTimes(2);
@@ -306,7 +336,7 @@ describe('ImageWatcher', () => {
// Emit 25 unique images in quick succession // Emit 25 unique images in quick succession
for (let i = 0; i < 25; i++) { for (let i = 0; i < 25; i++) {
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`); chokidarWatcher.emit('add', `/home/user/project/img${i}.jpg`);
vi.advanceTimersByTime(250); // past debounce, within burst window vi.advanceTimersByTime(250); // past debounce, within burst window
} }
@@ -323,7 +353,7 @@ describe('ImageWatcher', () => {
// Fill up burst limit // Fill up burst limit
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`); chokidarWatcher.emit('add', `/home/user/project/img${i}.jpg`);
vi.advanceTimersByTime(250); vi.advanceTimersByTime(250);
} }
expect(handler).toHaveBeenCalledTimes(20); expect(handler).toHaveBeenCalledTimes(20);
@@ -332,7 +362,7 @@ describe('ImageWatcher', () => {
vi.advanceTimersByTime(11_000); vi.advanceTimersByTime(11_000);
// Should accept new images // Should accept new images
chokidarWatcher.emit('add', '/home/user/project/new.png'); chokidarWatcher.emit('add', '/home/user/project/new.jpg');
vi.advanceTimersByTime(300); vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(21); expect(handler).toHaveBeenCalledTimes(21);
@@ -0,0 +1,358 @@
/**
* @fileoverview COD-53 — attachment path-traversal / sensitive-file guard.
*
* Verifies the sensitive-path blocklist is enforced at:
* - attachment registration (POST /api/sessions/:id/attachments)
* - raw / preview / thumbnail serving (defense-in-depth against a record that
* was crafted or registered before the guard existed)
* while still allowing legitimate cross-workspace attachment (codeman-publish
* skill + the ~/.codeman review-card loop) to succeed.
*
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Readable } from 'node:stream';
import { homedir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
// Mock fs/promises for file operations
vi.mock('node:fs/promises', () => ({
default: {
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => 'file content'),
writeFile: vi.fn(async () => undefined),
stat: vi.fn(async () => ({ size: 100, isFile: () => true, mtimeMs: 1 })),
mkdir: vi.fn(async () => undefined),
mkdtemp: vi.fn(async () => '/tmp/codeman-preview-test'),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
// Mock realpathSync for symlink resolution (identity by default)
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
realpathSync: vi.fn((p: string) => p),
createReadStream: vi.fn(() => Readable.from([Buffer.from('file content')])),
};
});
vi.mock('../../src/file-stream-manager.js', () => ({
fileStreamManager: {
createStream: vi.fn(async () => ({ success: true, streamId: 'stream-1' })),
closeStream: vi.fn(() => true),
},
}));
import fs from 'node:fs/promises';
import { createReadStream, realpathSync } from 'node:fs';
import {
attachmentRegistry,
registerExternalAttachment,
type AttachmentRecord,
} from '../../src/attachment-registry.js';
const mockedStat = vi.mocked(fs.stat);
const mockedRealpathSync = vi.mocked(realpathSync);
const mockedCreateReadStream = vi.mocked(createReadStream);
describe('file-routes attachment path guard (COD-53)', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
vi.clearAllMocks();
attachmentRegistry.clearSession('test-session-1');
mockedRealpathSync.mockImplementation((p: string) => p as never);
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, mtimeMs: 1 } as never);
mockedCreateReadStream.mockReturnValue(Readable.from([Buffer.from('file content')]) as never);
});
afterEach(async () => {
await harness.app.close();
attachmentRegistry.clearSession(harness.ctx._sessionId);
// Reset attachment-guard env knobs so one test can't leak into the next.
delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
delete process.env.CODEMAN_ATTACHMENT_CONFINE;
});
// ===== BLOCK: registration rejects a sensitive path =====
it('rejects registering a .env file that carries a supported extension', async () => {
// A dotenv-style secret file named with a supported extension still leaks
// secrets; the blocklist's /\.env\./ pattern catches `.env.<ext>`.
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/home/someone/project/.env.txt' },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('rejects registering an SSH key path even with a supported extension', async () => {
const sshTxt = `${homedir()}/.ssh/id_rsa.txt`;
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: sshTxt },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('rejects registering a sensitive path that a symlink resolves to', async () => {
// The requested path looks innocent (.md) but realpath resolves it to an SSH key dir.
mockedRealpathSync.mockReturnValue(`${homedir()}/.ssh/known_hosts.md` as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/home/someone/project/innocent.md' },
});
expect(res.statusCode).toBe(403);
});
// ===== BLOCK (defense-in-depth): raw serving rejects a sensitive record =====
it('refuses to serve raw bytes for a record whose path is sensitive', async () => {
// Simulate a record that was registered before the guard existed (or crafted).
const record: AttachmentRecord = {
attachmentId: 'att_sensitive',
sessionId: harness.ctx._sessionId,
filePath: `${homedir()}/.ssh/id_rsa.txt`,
fileName: 'id_rsa.txt',
extension: 'txt',
attachmentType: 'text',
size: 100,
mtimeMs: 1,
timestamp: Date.now(),
source: 'external',
};
attachmentRegistry.register(record);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/att_sensitive/raw`,
});
expect(res.statusCode).toBe(403);
expect(mockedCreateReadStream).not.toHaveBeenCalled();
});
// ===== PRESERVE: legitimate cross-workspace attachment still works =====
it('still registers a normal cross-workspace file (codeman-publish / loop review card)', async () => {
mockedStat.mockResolvedValue({ size: 512, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: `${homedir()}/.codeman/jira-autoloop-questions.md` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('jira-autoloop-questions.md');
expect(body.data.extension).toBe('md');
});
it('still registers an arbitrary project-dir file (WSL path)', async () => {
mockedStat.mockResolvedValue({ size: 4096, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/mnt/c/decks/board-update.pdf' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('board-update.pdf');
});
it('still serves raw bytes for a legitimately registered cross-workspace file', async () => {
const content = Buffer.from('# notes');
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
const registerRes = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: `${homedir()}/.codeman/review-card.md` },
});
const attachmentId = JSON.parse(registerRes.body).data.attachmentId;
const rawRes = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/raw`,
});
expect(rawRes.statusCode).toBe(200);
expect(rawRes.headers['content-type']).toBe('text/markdown');
});
// ===== BLOCK (broadened defaults): /root and /etc trees =====
it('rejects registering a file anywhere under /root by default', async () => {
// /root is the root account home — blocked as a whole tree by default,
// even for an ordinary-looking note with a supported extension.
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/root/secret-notes.md' },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('rejects registering a file anywhere under /etc by default', async () => {
// The whole /etc tree is blocked by default (not just /etc/shadow).
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/etc/codeman/config-dump.txt' },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('does not block a lookalike sibling dir like /etcetera (separator-aware)', async () => {
// The /etc tree block must be path-separator-aware so an unrelated
// /etcetera/... path is NOT caught by accident.
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/etcetera/notes.md' },
});
expect(res.statusCode).toBe(200);
});
// ===== CONFIG: extend the blocked set via env =====
it('rejects a path added via the extra-blocked-paths config', async () => {
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = '/srv/secrets,/data/private';
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/srv/secrets/keys.pdf' },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('still allows a normal path NOT in the configured blocked set', async () => {
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = '/srv/secrets,/data/private';
mockedStat.mockResolvedValue({ size: 20, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/srv/public/report.pdf' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('report.pdf');
});
// ===== CONFINEMENT MODE ON (opt-in) =====
it('confinement ON: rejects a file OUTSIDE the session workspace', async () => {
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
// Mock session workspace is /tmp/test-workdir; this file resolves elsewhere.
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/home/someone/elsewhere/report.pdf' },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('confinement ON: allows a file INSIDE the session workspace', async () => {
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
// Mock session workspace is /tmp/test-workdir (see MockSession).
const insidePath = '/tmp/test-workdir/docs/report.pdf';
mockedRealpathSync.mockReturnValue(insidePath as never);
mockedStat.mockResolvedValue({ size: 30, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: insidePath },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('report.pdf');
});
// ===== CONFINEMENT OFF (default) regression: legit cross-workspace attach =====
it('confinement OFF (default): legit cross-workspace attach still succeeds', async () => {
// No CODEMAN_ATTACHMENT_CONFINE set → default OFF. A ~/.codeman review-card
// file lives OUTSIDE the /tmp/test-workdir session workspace and must still
// attach (protects codeman-publish + the loop's review-card channel).
mockedStat.mockResolvedValue({ size: 64, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: `${homedir()}/.codeman/jira-autoloop-questions.md` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('jira-autoloop-questions.md');
});
// ===== Magic-link scan path: FORCED workspace confinement =====
// The terminal-output `codeman://attach` scanner registers with
// forceWorkspaceConfinement: true so a prompt-injected session printing an
// arbitrary path can't expose a host file, even though global confine is OFF.
describe('forced workspace confinement (magic-link scan path)', () => {
it('rejects an out-of-workspace path even when global confinement is OFF', async () => {
mockedRealpathSync.mockImplementation((p: string) => p as never);
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 1 } as never);
await expect(
registerExternalAttachment('test-session-mlc', '/home/someone/secret/report.pdf', {
sessionWorkingDir: '/tmp/test-workdir',
forceWorkspaceConfinement: true,
})
).rejects.toMatchObject({ statusCode: 403 });
attachmentRegistry.clearSession('test-session-mlc');
});
it('allows an in-workspace path on the forced path', async () => {
const inside = '/tmp/test-workdir/sub/report.pdf';
mockedRealpathSync.mockReturnValue(inside as never);
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 1 } as never);
const event = await registerExternalAttachment('test-session-mlc', inside, {
sessionWorkingDir: '/tmp/test-workdir',
forceWorkspaceConfinement: true,
});
expect(event.fileName).toBe('report.pdf');
attachmentRegistry.clearSession('test-session-mlc');
});
});
});
+33
View File
@@ -441,6 +441,39 @@ describe('session-routes', () => {
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
it('prepends the live tmux pane buffer (cleared) before the byte history', async () => {
harness.ctx._session.terminalBuffer = 'history-bytes';
harness.ctx.mux.captureActivePaneBuffer = vi.fn(() => 'LIVE-PANE-FRAME');
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const buf = JSON.parse(res.body).data.terminalBuffer as string;
// history, then a viewport clear, then the live pane frame
expect(buf).toContain('history-bytes');
expect(buf).toContain('\x1b[H\x1b[2J');
expect(buf).toContain('LIVE-PANE-FRAME');
expect(buf.indexOf('history-bytes')).toBeLessThan(buf.indexOf('LIVE-PANE-FRAME'));
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
});
it('falls back to the byte history when no live pane buffer is available', async () => {
harness.ctx._session.terminalBuffer = 'history-only';
// Empty string (the test-mode return) and null both mean "no live frame".
harness.ctx.mux.captureActivePaneBuffer = vi.fn(() => '');
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const buf = JSON.parse(res.body).data.terminalBuffer as string;
expect(buf).toContain('history-only');
expect(buf).not.toContain('\x1b[H\x1b[2J');
});
}); });
// ========== POST /api/sessions/:id/run ========== // ========== POST /api/sessions/:id/run ==========
@@ -0,0 +1,133 @@
/**
* @fileoverview COD-55 — tunnel password guard.
*
* Enabling the Cloudflare tunnel publishes the whole app (full terminal control =
* effectively RCE) to a public *.trycloudflare.com URL. When no CODEMAN_PASSWORD
* is set, requests through that URL are unauthenticated. These tests assert the
* PUT /api/settings tunnel-enable path REFUSES to start the tunnel unless a
* password is set OR the unauthenticated-network opt-in is acknowledged.
*
* Uses app.inject() — no real HTTP ports needed. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
// Settings are written to disk via fs/promises — stub so the guard test never
// touches the real settings.json, and so we can assert "not persisted on refusal".
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => undefined),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(() => true),
mkdirSync: vi.fn(),
readdirSync: vi.fn(() => []),
};
});
import fs from 'node:fs/promises';
const mockedWriteFile = vi.mocked(fs.writeFile);
/** Build a tunnelManager stub the route's ctx can use. */
function makeTunnelManager(running = false) {
return {
start: vi.fn(),
stop: vi.fn(),
isRunning: vi.fn(() => running),
getUrl: vi.fn(() => null),
getStatus: vi.fn(() => ({ running })),
};
}
describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () => {
let harness: RouteTestHarness;
let tunnel: ReturnType<typeof makeTunnelManager>;
const savedPassword = process.env.CODEMAN_PASSWORD;
const savedOptIn = process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
vi.clearAllMocks();
mockedWriteFile.mockResolvedValue(undefined);
tunnel = makeTunnelManager(false);
// tunnelManager is null in the default mock ctx — inject our spy.
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
});
afterEach(async () => {
await harness.app.close();
if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD;
else process.env.CODEMAN_PASSWORD = savedPassword;
if (savedOptIn === undefined) delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
else process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = savedOptIn;
});
it('REFUSES tunnel-enable with no password and no opt-in (4xx, start not called)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
// Message should tell the user how to fix it.
expect(body.error).toMatch(/CODEMAN_PASSWORD|CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK/);
// The tunnel must NOT have been started.
expect(tunnel.start).not.toHaveBeenCalled();
// And tunnelEnabled:true must NOT have been persisted.
expect(mockedWriteFile).not.toHaveBeenCalled();
});
it('ALLOWS tunnel-enable when CODEMAN_PASSWORD is set (start called, 200)', async () => {
process.env.CODEMAN_PASSWORD = 'hunter2';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
it('ALLOWS tunnel-enable with the unauthenticated-network opt-in acknowledged (start called, 200)', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = '1';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
tunnel = makeTunnelManager(true);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: false },
});
expect(res.statusCode).toBe(200);
expect(tunnel.stop).toHaveBeenCalledTimes(1);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadTerminalUiHarness(mode: string) {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
window: {},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
Blob: function Blob() {},
URL: {
createObjectURL: () => 'blob:yield',
revokeObjectURL: () => {},
},
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as any)();
const writes: string[] = [];
app.activeSessionId = 'session-1';
app.sessions = new Map([['session-1', { mode }]]);
app.pendingWrites = [];
app.writeFrameScheduled = false;
app._wasAtBottomBeforeWrite = false;
app._workerYield = () => {};
app._chunkedWriteGen = 0;
app.terminal = {
write: (data: string) => writes.push(data),
scrollToBottom: () => {},
scrollToLine: () => {},
};
return { app, writes };
}
describe('terminal flush budget', () => {
it('uses a smaller first-frame write budget for Codex output to reduce renderer stalls', () => {
const { app, writes } = loadTerminalUiHarness('codex');
app.pendingWrites.push('x'.repeat(96 * 1024));
app.flushPendingWrites();
expect(writes).toHaveLength(1);
expect(writes[0]).toHaveLength(32 * 1024);
expect(app.pendingWrites.join('')).toHaveLength(64 * 1024);
});
it('keeps the larger first-frame write budget for non-Codex terminal output', () => {
const { app, writes } = loadTerminalUiHarness('claude');
app.pendingWrites.push('x'.repeat(96 * 1024));
app.flushPendingWrites();
expect(writes).toHaveLength(1);
expect(writes[0]).toHaveLength(64 * 1024);
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
});
it('waits for xterm to process small buffer replays before completing buffer load', async () => {
const { app, writes } = loadTerminalUiHarness('codex');
let writeDone: (() => void) | undefined;
let resolved = false;
const finishBufferLoad = vi.fn();
app._finishBufferLoad = finishBufferLoad;
app.terminal.write = (data: string, callback?: () => void) => {
writes.push(data);
writeDone = callback;
};
const promise = app.chunkedTerminalWrite('fresh tmux pane frame').then(() => {
resolved = true;
});
await Promise.resolve();
expect(writes).toEqual(['fresh tmux pane frame']);
expect(writeDone).toBeTypeOf('function');
expect(resolved).toBe(false);
expect(finishBufferLoad).not.toHaveBeenCalled();
writeDone?.();
await promise;
expect(resolved).toBe(true);
expect(finishBufferLoad).toHaveBeenCalledOnce();
});
it('keeps stale buffer load owners from finishing a newer load', () => {
const { app } = loadTerminalUiHarness('codex');
app._beginBufferLoad('select-1');
app._beginBufferLoad('select-2');
expect(app._finishBufferLoad('select-1')).toBe(false);
expect(app._isLoadingBuffer).toBe(true);
expect(app._bufferLoadOwner).toBe('select-2');
expect(app._finishBufferLoad('select-2')).toBe(true);
expect(app._isLoadingBuffer).toBe(false);
expect(app._bufferLoadOwner).toBe(null);
});
it('does not snap back to bottom during Codex Working redraws right after the user scrolls up', () => {
const { app } = loadTerminalUiHarness('codex');
const scrollToBottom = vi.fn();
app.terminal.scrollToBottom = scrollToBottom;
app._wasAtBottomBeforeWrite = true;
app._lastUserScrollUpAt = 0;
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
expect(scrollToBottom).not.toHaveBeenCalled();
});
it('restores the user scroll position when Codex Working redraws move the viewport', () => {
const { app } = loadTerminalUiHarness('codex');
const buffer = { viewportY: 40, baseY: 100 };
app.terminal.buffer = { active: buffer };
app.terminal.write = vi.fn(() => {
buffer.viewportY = buffer.baseY;
});
app.terminal.scrollToLine = vi.fn((line: number) => {
buffer.viewportY = line;
});
app._wasAtBottomBeforeWrite = true;
app._lastUserScrollUpAt = 0;
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
expect(buffer.viewportY).toBe(40);
});
});
+24 -10
View File
@@ -164,10 +164,24 @@ describe('TmuxManager (unit)', () => {
cursorY: 1, cursorY: 1,
}); });
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(9)}\x1b[2;1Hnext line\x1b[2;3H`); // Full pane width is painted (10 cols); autowrap is avoided by the
// absolute cursor positioning, not by dropping the last column.
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(10)}\x1b[2;1Hnext line\x1b[2;3H`);
expect(snapshot).not.toContain('\n'); expect(snapshot).not.toContain('\n');
}); });
it('preserves the rightmost column of each captured row', () => {
const snapshot = formatPaneSnapshot(['abcd'], {
cols: 4,
rows: 1,
cursorX: 0,
cursorY: 0,
});
// Previously truncated to cols - 1 ('abc'); the full width is now kept.
expect(snapshot).toBe('\x1b[1;1Habcd\x1b[1;1H');
});
it('preserves SGR color while stripping non-style pane controls', () => { it('preserves SGR color while stripping non-style pane controls', () => {
const snapshot = formatPaneSnapshot(['\x1b[32mgreen\x1b[0m\x1b[2K\x1b[10;20Htail'], { const snapshot = formatPaneSnapshot(['\x1b[32mgreen\x1b[0m\x1b[2K\x1b[10;20Htail'], {
cols: 40, cols: 40,
@@ -190,18 +204,18 @@ describe('TmuxManager (unit)', () => {
cursorY: 0, cursorY: 0,
}); });
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabc\x1b[0m\x1b[1;1H'); expect(snapshot).toBe('\x1b[1;1H\x1b[31mabcd\x1b[0m\x1b[1;1H');
}); });
it('does not let full-width glyphs cross the paint boundary', () => { it('does not let full-width glyphs cross the paint boundary', () => {
const snapshot = formatPaneSnapshot(['abc\u754cdef'], { // cols 5 = 'abc' (3) + full-width \u754c (2) fits exactly; with cols 4 the
cols: 5, // wide glyph would straddle the boundary and is dropped.
rows: 1, expect(formatPaneSnapshot(['abc\u754cdef'], { cols: 5, rows: 1, cursorX: 0, cursorY: 0 })).toBe(
cursorX: 0, '\x1b[1;1Habc\u754c\x1b[1;1H'
cursorY: 0, );
}); expect(formatPaneSnapshot(['abc\u754cdef'], { cols: 4, rows: 1, cursorX: 0, cursorY: 0 })).toBe(
'\x1b[1;1Habc\x1b[1;1H'
expect(snapshot).toBe('\x1b[1;1Habc\x1b[1;1H'); );
}); });
it('keeps combining marks attached without consuming a terminal column', () => { it('keeps combining marks attached without consuming a terminal column', () => {