mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b10e354936 | ||
|
|
64559b60d1 | ||
|
|
6351b4143f | ||
|
|
3d6e3f3d6e | ||
|
|
5c20fcf464 | ||
|
|
683544a22e | ||
|
|
5181c9abb0 | ||
|
|
25c67f9415 | ||
|
|
d6917e3b21 | ||
|
|
524a096e14 | ||
|
|
8d9dd70b51 | ||
|
|
ed47a599be | ||
|
|
ccb3afc9ee | ||
|
|
c3b0dc345b | ||
|
|
0ab2416460 | ||
|
|
ac6fe6ef79 | ||
|
|
dafe3de185 | ||
|
|
2a06f7a5a8 | ||
|
|
453605a58f | ||
|
|
4d8857f72a | ||
|
|
f496e35d71 | ||
|
|
91070f5dda | ||
|
|
fdce57ce5a | ||
|
|
a21400614a | ||
|
|
9046b95b7e | ||
|
|
8b3fa5f37c | ||
|
|
4c6f96a2ef | ||
|
|
d1928f300e | ||
|
|
ca731c67b3 | ||
|
|
a3fe0ae728 | ||
|
|
82825cbfb3 | ||
|
|
5ec71ace5a | ||
|
|
b27a0e9188 | ||
|
|
35a0217ccd | ||
|
|
7a86cf87f7 | ||
|
|
5792c2d62e | ||
|
|
8807b3ff6d | ||
|
|
115ada1e9e | ||
|
|
b2ebdcbf47 | ||
|
|
6dba8b5227 | ||
|
|
897bfdff59 | ||
|
|
fb013e9de0 | ||
|
|
7f24a132d0 |
@@ -1,5 +1,89 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Remote tmux durability, Session Manager polish, and an opt-in Cron button.
|
||||
|
||||
**Remote sessions: durability, discovery, and auto-reconnect** (PR #156 by @aakhter, COD-104 to COD-109)
|
||||
- Durable remote launches survive an SSH drop: the agent runs inside `tmux -L codeman-remote new-session -A` on the remote host, and reconnecting lands back in the same session.
|
||||
- Discover + attach: a "Discover existing sessions" action per remote host lists `codeman-*` tmux sessions on the host's canonical socket (started by the remote's own Codeman or another instance) and attaches to one. Attached (non-owned) sessions detach on tab close, never kill; a structural early-return in `killSession()` guarantees no remote `kill-session` can ever be issued for a session Codeman doesn't own (COD-105).
|
||||
- Shared/collaborative sessions: per-session `window-size latest` so concurrent clients at different viewports don't clamp each other, plus a "shared - N clients" badge in discovery results (COD-106).
|
||||
- Auto-reconnect watcher: a bounded-backoff (5s to 5m, ~6 attempts) watcher detects a dead remote pane and reattaches the still-running remote tmux session; intentional kills/detaches are guarded and never revived. Kill-switch setting `remoteAutoReconnect` (default on). SSE `remote:sessionDropped`/`sessionReconnected`/`reconnectExhausted`, with a manual Reconnect toast after exhaustion (COD-108).
|
||||
- Owned durable sessions propagate `kill-session` to the remote on close (COD-109); the remote tmux prereq probe is skipped under the test runner (COD-104).
|
||||
- All ssh command lines continue to flow through the single shell-safe `buildSshConnectionArgs()` (COD-107). New design doc: `docs/remote-sessions.md`.
|
||||
- Maintainer additions: the discovery endpoint is admin-gated in multi-user mode, and the remote launch/attach chooser threads the multi-user permission downgrade (`claudeMode`/`allowedTools`) through to the remote agent.
|
||||
|
||||
**Session Manager: pinning, cross-device ordering, name/prompt retention** (PR #157 by @aakhter, COD-131/139/140/142/143/145)
|
||||
- Session pinning: pin a session to the top of the Session Manager list (`POST /api/sessions/:id/pin`, `session:pinned` SSE, amber highlight + pin glyph). Pinned group orders most-recently-pinned first (COD-139).
|
||||
- Pinned sessions survive kill: killing a pinned session demotes its record to a lightweight stopped entry instead of removing it, so it stays visible and resumable; cleanup skips pinned records (COD-142). The pin route also works on these persisted-only records, so a pinned-then-killed session can always be unpinned.
|
||||
- Cross-device tab order: tab order syncs via server state (`PUT /api/session-order`, `session:orderChanged` SSE, persisted in `state.json`); the pushing device wins and server-only ids fall to the end, never dropped (COD-131).
|
||||
- Resuming from the Session Manager keeps the session's original name instead of always synthesizing a fresh `w<N>-<dir>` one (COD-143).
|
||||
- firstPrompt backfill for sessions whose Codeman id is not the transcript UUID (claudeSessionId join, then newest transcript in the same workingDir), and the most recent prompt is shown alongside the first and included in search (COD-140/145).
|
||||
|
||||
**Cron button now opt-in** (hidden by default)
|
||||
- The Cron footer-toolbar button follows the same opt-in pattern as the Session Manager / Away Digest / File Viewer buttons: hidden by default, enable per device under App Settings -> Display -> Header Displays. Cron jobs themselves are unchanged.
|
||||
|
||||
Also: `docs/remote-sessions.md` synced with the shipped `-L codeman-remote` / `codeman-ssh-<id8>` naming.
|
||||
|
||||
## 1.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Docker session-mode deep-review fixes — the work intended for the skipped **1.4.2**, now merged onto the 1.5.x line — plus a recap of the multi-user mode shipped in 1.5.0.
|
||||
|
||||
**Docker resume actually works now.** `DockerCase.lastClaudeSessionId` was read at quick-start but never written, so the documented resume-after-container-stop never fired. Claude-mode docker panes now pin a deterministic conversation id (`claudeDockerPaneCommand()`): a fresh launch runs `claude --session-id <id> || claude --resume <id>` (a duplicate `--session-id` exits 1 "already in use", so the fallback resumes after a container stop/reboot — verified CLI behavior), an explicit resume runs `--resume <rid> || --session-id <sid>` so a stale id never dead-panes. The id is persisted at launch and again on hook / last-response conversation-id adoption. Verified end-to-end across a `docker stop` + relaunch and a full container recreate.
|
||||
|
||||
**Config-drift detection + recreate (was documented but entirely missing).** The `codeman.confighash` label was stamped but never read, so docker-host config edits silently never applied. Quick-start now compares via `checkDockerConfigDrift()` and refuses a drifted launch with `CONFLICT`; the UI confirms and calls the new `POST /api/docker-cases/:name/recreate` (refused while the case has live sessions), then relaunches with the new config. New SSE event `docker:containerRecreated`.
|
||||
|
||||
**Model picker now applies to docker sessions.** `modelOverride` was absent from `QuickStartSchema`, so the App Settings Claude Model choice was silently inert for docker runs. It is now accepted and applied via `updateCaseModel` for local and docker quick-starts (still rejected for remote, where the settings file would land on the wrong machine).
|
||||
|
||||
**Import hardening.** `importDockerBundle` validates the untrusted cross-machine manifest before trusting any field (`validateImportManifest`: engine/image/containerWorkdir/network/caseName/schemaVersion — a hostile `engine` could previously select the probe binary); the outer bundle tar gets the same member-traversal guard as the inner workspace tar; the quarantine image tag derives from the schema-validated case name.
|
||||
|
||||
**Remote-daemon correctness.** All docker probes and the base-image auto-build now honor a host's `context`/`daemonHost` (`dockerEngineArgv`) instead of always probing the local daemon.
|
||||
|
||||
**Smaller fixes:** commas are rejected in docker workspace/workdir/destination paths (a comma corrupts the `--mount type=bind,src=…` CSV spec, which shell escaping cannot protect); a dead `this.escapeHtml` reference in the exports refresh is fixed; `docker:importComplete` / `docker:containerRecreated` get frontend SSE listeners so other open tabs refresh; the File Viewer header button is hidden on phone headers like its siblings.
|
||||
|
||||
**Docs.** CLAUDE.md + READMEs synced with the current feature set, including a full zh-CN README re-translation.
|
||||
|
||||
**Multi-user mode (recap — shipped in 1.5.0).** Opt-in named users (`--multiuser` / `CODEMAN_MULTIUSER=1`, off by default) with per-user case spaces and full ownership scoping of sessions, cases, cron jobs, scheduled runs, search, file previews, and real-time SSE/WS streams. Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; raw shell mode, cron `launchCommand`, skip-permissions, and the Codex/Gemini bypass switches require an explicit per-user `canBypassPermissions` grant. Machine-level resources are admin-only. Admin API (`/api/admin/users*`) with one-time passwords, last-admin invariants, and an append-only audit log; self-service `/api/me` + password change; and a `codeman users add|passwd|list|rm` CLI. Off by default is byte-identical to single-user. Note: multi-user separates workspaces for a trusted team; it is not a security boundary between mutually-distrusting users (all sessions share the host OS account) — pair with Docker cases for real isolation.
|
||||
|
||||
## 1.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0ab2416: Opt-in multi-user mode (`--multiuser` / `CODEMAN_MULTIUSER=1`, off by default).
|
||||
|
||||
Named users with individually scrypt-hashed passwords in `~/.codeman/users.json`, per-user case spaces under `~/codeman-users/<name>/cases`, and ownership scoping of sessions (create/list/delete/mutate, incl. bulk delete), cases, cron jobs + run history, scheduled runs, search, file previews, session history, away digest, subagent/workflow monitors, and real-time SSE/WS streams (including the debounced session/task update path, clipboard, and push notifications). A non-admin's `workingDir` is realpath-confined to their own space at every spawn/link path (session create, quick-start, cron create/fire, scheduled runs, case link/docker-link, docker import). Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; raw shell mode, cron `launchCommand`, skip-permissions, and the Codex/Gemini bypass switches require an explicit per-user `canBypassPermissions` grant (enforced at every spawn site incl. one-shots, plan generation, scheduled runs, and remote launches). Machine-level resources (remote/Docker hosts + host reads, mux sessions, orchestrator, tunnel, self-update, settings) are admin-only. Admin API (`/api/admin/users*`) with one-time passwords, last-admin invariants (validated before any teardown), and an append-only audit log; self-service `/api/me` + password change; a frontend admin Users tab + change-password modal; and `codeman users add|passwd|list|rm` CLI. Also adds a global `auto` Claude startup permission mode. When off, behavior is byte-identical to single-user.
|
||||
|
||||
Auth hardening: the login throttle verifies the password before consulting the per-account failure bucket (a correct password can never be locked out); the `mustChangePassword` lockbox covers the WebSocket terminal; the cookie fast-path re-validates identity against the store each request (so a CLI/admin delete/disable/demote takes effect promptly); a role/grant change revokes the target's sessions. (Known limitation: a bare CLI `codeman users passwd` reset — no delete — does not by itself revoke an already-active cookie until it expires; use `codeman users rm`, the admin API, or a restart to force-revoke.) Data-integrity hardening: the store distinguishes a missing users file from a corrupt/unreadable one (so a transient read error can't overwrite all accounts) and writes via a unique per-process temp file; the earlier fire-and-forget `touchLastLogin` corruption race is serialized.
|
||||
|
||||
Note: multi-user mode separates workspaces for a trusted team; it is not a security boundary between users (all sessions share the host OS account). Pair with Docker cases for real isolation.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Docker session mode** hardening + fixes, plus a File Viewer header button.
|
||||
|
||||
**What Docker session mode is** (recap): a case can run inside an isolated, hardened Docker container instead of on the host, and any of the CLI backends (Claude, Codex, Gemini, OpenCode, or a plain shell) runs inside it. It is a location overlay on cases — not a new session mode — and the container analog of remote-SSH cases: a local tmux pane `docker exec`s into a durable in-container tmux, with exactly one long-lived container per case that multiple sessions share. The workspace, credentials, and conversation transcripts are bind-mounted so the agent is authenticated and resumable; containers are hardened by default (`--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, pids/memory caps, `--init`, never `--privileged` or the docker socket) and export-safe. Start one with the one-click "Run in Docker" checkbox on Create Case, or the Docker tab for full control.
|
||||
|
||||
This release fixes the rough edges found running it for real:
|
||||
|
||||
Docker cases:
|
||||
- **Seamless Claude auth in containers**: `~/.claude.json` is no longer bind-mounted as a single file (a mount point that broke Claude's atomic-rename config writes — forcing re-auth and, via failed in-place writes, corrupting the host `~/.claude.json`). It is now seeded as a writable, onboarding-complete copy, so a docker session boots straight to the prompt (no theme picker, login, or folder-trust prompt).
|
||||
- **Claude-state isolation**: containers no longer bind-mount the whole `~/.claude` directory (which wrote backups/tasks/teams/settings back into the host). Only `~/.claude/projects` transcripts are shared (host watchers + `--resume`); credentials, settings, and stats-cache are seeded as writable copies; everything else stays container-local.
|
||||
- **Codex/Gemini/gcloud/opencode isolation**: same treatment — codex shares `sessions/` + `history.jsonl` (response-viewer + resume) and seeds `auth.json`/`config.toml`; gemini/gcloud/opencode are whole seed-copies. Containers never write their credential state back into the host dirs.
|
||||
- **Base image auto-builds on first use**: a missing `codeman/agent:base` no longer blocks case creation or launch; it builds locally on first use (concurrency-safe, with SSE progress toasts).
|
||||
- **UTF-8 locale**: containers set `LANG`/`LC_ALL=C.UTF-8` so tmux renders Claude's box-drawing correctly (fixes `qqqq` line artifacts).
|
||||
- **Create Case UI**: larger, collapsed-by-default "Run in Docker" settings with a shorter hint; dockerized cases show a short `(docker)` tag (or the custom host id) in the case menus.
|
||||
- **Tab naming**: docker/remote (and codex/gemini/opencode) sessions now follow the `w<n>-<case>` convention instead of `codeman-<id>`.
|
||||
|
||||
Other:
|
||||
- **File Viewer header button** (opt-in via App Settings, Header Displays): toggle the file browser panel from the header.
|
||||
- Fixed a timezone-boundary flaky test in the away-digest route suite.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h2 align="center">Mission control for AI coding agents</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex • Terminal - One Dashboard • Any Device</em>
|
||||
<em>Claude Code • OpenCode • Codex • Gemini • Terminal - One Dashboard • Any Device</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -34,7 +34,7 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli) (any combination works). After install:
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli) (any combination works). After install:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
@@ -106,7 +106,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
```
|
||||
|
||||
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -365,17 +365,55 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
## More Features
|
||||
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, or **Codex** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, or **Gemini** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*`/`GOOGLE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
|
||||
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
|
||||
- **Image input** — paste or drag-and-drop images straight into a session
|
||||
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
|
||||
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
|
||||
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Display → Header Displays
|
||||
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
|
||||
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
|
||||
|
||||
---
|
||||
|
||||
## Isolated Docker Sessions
|
||||
|
||||
Run a case inside its own hardened Docker container instead of directly on your host — for security isolation, reproducible toolchains, and one-click portability.
|
||||
|
||||
- **One click** — on **New Case → Create New**, tick **🐳 Run in an isolated Docker container**. Codeman creates the case folder, spins up a container with default settings, and starts the agent inside it. No host/image/network fields to fill in.
|
||||
- **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap.
|
||||
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
|
||||
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away.
|
||||
- **Seamless auth, isolated credentials** — your host Claude / Codex / Gemini / OpenCode logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.
|
||||
- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
|
||||
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
|
||||
|
||||
Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## Multi-User Mode (opt-in)
|
||||
|
||||
Share one Codeman with a small trusted team, each person getting their own login and workspace. **Off by default** — without the flag, nothing changes.
|
||||
|
||||
Enable with `codeman web --multiuser` (or `CODEMAN_MULTIUSER=1`). Create the first admin, then manage users from the CLI or the **Users** tab in App Settings:
|
||||
|
||||
```bash
|
||||
codeman users add alice --admin # prompts for a password (or --password-stdin)
|
||||
codeman users add bob # a regular user
|
||||
codeman users list
|
||||
```
|
||||
|
||||
- **Per-user spaces** — each user's cases live under `~/codeman-users/<name>/cases`; sessions, cases, search, and real-time events are scoped to their owner. Admins see everything.
|
||||
- **Individually revocable logins** — named users with scrypt-hashed passwords in `~/.codeman/users.json`; disable, reset (one-time password), or delete an account at any time. Admin actions are audited to `~/.codeman/admin-audit.jsonl`.
|
||||
- **Safer defaults for regular users** — non-admins run Claude in `--permission-mode auto` (Anthropic's classifier-guarded mode); raw shell sessions, cron `launchCommand`, and skip-permissions require an explicit per-user grant.
|
||||
|
||||
> ⚠️ **This separates workspaces; it does not sandbox users from each other.** Every session runs as the same OS account, so a determined user's agent can still reach another user's files. For real isolation, pair users with **Docker cases** or run separate instances under separate OS accounts. See [`docs/multi-user-plan.md`](docs/multi-user-plan.md) and the multi-user section of [`docs/security-architecture.md`](docs/security-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Remote Access — Cloudflare Tunnel
|
||||
|
||||
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
|
||||
@@ -519,7 +557,7 @@ These run for **every** request — before auth, even on the default no-password
|
||||
|
||||
### Input, files & headers
|
||||
|
||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env-prefix allowlist gates which settings each CLI can receive
|
||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` env-prefix allowlist gates which settings each CLI can receive
|
||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
|
||||
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
||||
|
||||
@@ -755,7 +793,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph External["External"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Gemini</small>"]
|
||||
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||
end
|
||||
end
|
||||
|
||||
+289
-92
@@ -5,7 +5,7 @@
|
||||
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex —— 统一仪表盘 • 任意设备</em>
|
||||
<em>Claude Code • OpenCode • Codex • Gemini • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
|
||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
|
||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
|
||||
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
|
||||
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
|
||||
@@ -36,7 +36,7 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
|
||||
|
||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli)(任意组合均可)。安装完成后:
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli)(任意组合均可)。安装完成后:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
@@ -47,6 +47,7 @@ codeman web
|
||||
<summary><strong>作为后台服务运行</strong></summary>
|
||||
|
||||
**Linux(systemd):**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/codeman-web.service << EOF
|
||||
@@ -69,6 +70,7 @@ loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
**macOS(launchd):**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents
|
||||
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
@@ -96,6 +98,7 @@ cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
EOF
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -105,11 +108,79 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
```
|
||||
|
||||
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 使用 Codeman —— 人类操作指南
|
||||
|
||||
从头到尾走一遍如何在浏览器里驾驭 Codeman。如果你刚装好,就从这里开始。
|
||||
|
||||
### 1. 启动服务器
|
||||
|
||||
```bash
|
||||
codeman web # localhost:3000(仅环回 —— 安全默认值)
|
||||
codeman web --port 8080 # 自定义端口(或设置 CODEMAN_PORT)
|
||||
codeman web --https # 自签名 TLS(仅远程访问时需要)
|
||||
codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_PASSWORD(见「安全」)
|
||||
```
|
||||
|
||||
打开打印出的 URL。整个页面是一个单一仪表盘;下面的一切都在这里完成。
|
||||
|
||||
### 2. 创建你的第一个会话
|
||||
|
||||
点击 **+ New Session**(或 **Quick Start**)。一个会话就是一个运行在自己 tmux 终端里的 AI CLI。你可以选择:
|
||||
|
||||
| 字段 | 作用 |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。 |
|
||||
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Gemini` 或 `Terminal`(普通 shell)。 |
|
||||
| **模型** | 每会话模型(App Settings → Claude Model)。软默认值 —— 会话内 `/model` 依然有效。 |
|
||||
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
|
||||
|
||||
点击启动 —— Codeman 通过真实 PTY 拉起 CLI,并经 SSE 流式传输到你的浏览器。
|
||||
|
||||
### 3. 读懂仪表盘
|
||||
|
||||
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序。
|
||||
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
|
||||
- **侧边面板** —— Respawn、Ralph、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
|
||||
|
||||
### 4. 与智能体对话
|
||||
|
||||
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
|
||||
- **粘贴或拖放图片**,直接进入会话。
|
||||
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,自动静音停止)。
|
||||
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF。
|
||||
|
||||
### 5. 让它自主运行
|
||||
|
||||
| 模式 | 用途 | 位置 |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------- | ---------------------- |
|
||||
| **Respawn** | 长时间无人值守运行 —— 空闲/限额时自动重启 CLI,带自适应时序。预设:`solo-work`、`overnight-autonomous` 等 | Respawn 标签页 |
|
||||
| **Ralph / Todo** | 一个自驱循环,跟踪 todo 列表并持续工作直到完成。 | Ralph 标签页 |
|
||||
| **Orchestrator** | 把一个目标变成分阶段计划,并跨多个智能体推动完成。 | 编排器面板 |
|
||||
| **Cron** | 已保存的、命名的定时任务(`once`/`interval`/`daily`/`weekly`),到期时拉起会话并发送提示。 | ⏰ Cron 按钮 |
|
||||
| **Auto-resume** | 订阅限额重置后自动继续。 | Respawn 标签页(顶部) |
|
||||
|
||||
### 6. 随时随地访问
|
||||
|
||||
- **手机/平板** —— UI 完全触控优化;扫描桌面上的**二维码**即可免密码登录。
|
||||
- **网络之外** —— `./scripts/tunnel.sh start` 打开一条 Cloudflare 隧道(先设置 `CODEMAN_PASSWORD`)。
|
||||
- **SSH** —— `sc` 选择器可从终端附着任意会话(`sc` 交互式,`sc 2` 快速附着,`sc -l` 列表)。
|
||||
|
||||
### 7. 运维与维护
|
||||
|
||||
- **App Settings** —— 模型、effort、主题/皮肤、通知、显示开关、各 CLI 的专属选项。
|
||||
- **自更新** —— git-clone 安装可在 **Settings → Updates** 中原地更新。
|
||||
- **部署你自己的改动** —— 见[开发](#开发)。
|
||||
|
||||
> ⚠️ **安全提示:** 如果你正在 Codeman 受管会话*内部*工作(`echo $CODEMAN_MUX` → `1`),绝不要直接运行 `tmux kill-session` / `pkill claude` —— 请使用 Web UI 或 `./scripts/tmux-manager.sh`。
|
||||
|
||||
---
|
||||
|
||||
## 移动端优化的 Web UI
|
||||
|
||||
在任意手机上都能获得最跟手的 AI 编程智能体体验。完整的 xterm.js 终端、本地回显、滑动导航,以及为真正的远程办公而设计的触控优化界面 —— 而不是把桌面 UI 硬塞进小屏幕。
|
||||
@@ -216,7 +287,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
```
|
||||
|
||||
- **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性
|
||||
- **用量限额自动恢复**(*可选,默认关闭*)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
|
||||
- **用量限额自动恢复**(_可选,默认关闭_)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
|
||||
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
|
||||
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
|
||||
- **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min)
|
||||
@@ -262,10 +333,10 @@ codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈
|
||||
|
||||
### 智能 Token 管理
|
||||
|
||||
| 阈值 | 动作 | 结果 |
|
||||
|-----------|--------|--------|
|
||||
| 阈值 | 动作 | 结果 |
|
||||
| --------------- | --------------- | ---------------------- |
|
||||
| **110k tokens** | 自动 `/compact` | 上下文被摘要,工作继续 |
|
||||
| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 |
|
||||
| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 |
|
||||
|
||||
### 通知
|
||||
|
||||
@@ -296,17 +367,35 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
## 更多特性
|
||||
|
||||
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode** 或 **Codex**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*` 与 `CODEX_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex** 或 **Gemini**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker 会话** —— 在隔离且加固的容器中运行案例。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一案例的多个会话共享一个容器;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
|
||||
- **语音输入** —— 用 Deepgram Nova-3 口述提示(带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`)
|
||||
- **图像输入** —— 直接把图片粘贴或拖放进会话
|
||||
- **手势控制** *(可选)* —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用
|
||||
- **多显示器横跨** *(macOS)* —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
|
||||
- **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用
|
||||
- **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
|
||||
- **文件查看器按钮** _(可选)_ —— 头部新增一个按钮,一键切换内置文件浏览器面板;在 App Settings → Display → Header Displays 中启用
|
||||
- **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入
|
||||
- **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊
|
||||
|
||||
---
|
||||
|
||||
## 隔离的 Docker 会话
|
||||
|
||||
让案例(case)运行在专属的加固 Docker 容器里,而不是直接跑在主机上:获得安全隔离、可复现的工具链和一键可移植性。
|
||||
|
||||
- **一键启动** —— 在 **New Case → Create New** 中勾选 **🐳 Run in an isolated Docker container**。Codeman 会创建案例文件夹、用默认设置启动容器,并在容器内启动智能体。无需填写任何主机/镜像/网络字段。
|
||||
- **资源模板** —— 展开复选框可选 **Small / Medium / Large / GPU** 预设(内存、CPU、GPU),也可以完全自定义。**磁盘是弹性的** —— 存储随数据增长,没有固定上限。
|
||||
- **按案例共享容器** —— 多个会话可以 `docker exec` 进同一个容器;结束某个会话绝不会影响其他会话所在的容器。
|
||||
- **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。
|
||||
- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Gemini / OpenCode 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。
|
||||
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。
|
||||
- **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。
|
||||
|
||||
前置条件:只需 Docker(或 Podman)。智能体基础镜像会在首次使用时自动构建,构建进度实时显示在 UI 中(也可用 `node scripts/build-agent-image.mjs` 预构建)。完整指南:[`docs/docker-cases.md`](docs/docker-cases.md)。
|
||||
|
||||
---
|
||||
|
||||
## 远程访问 —— Cloudflare 隧道
|
||||
|
||||
使用免费的 [Cloudflare 快速隧道](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/),从手机或本地网络外的任意设备访问 Codeman —— 无需端口转发、无需 DNS、无需静态 IP。
|
||||
@@ -374,14 +463,14 @@ loginctl enable-linger $USER
|
||||
|
||||
该设计参考了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025),该研究发现 Top-100 网站中有 47 个因横跨 42 个 CVE 的 6 个关键设计缺陷而易受二维码认证攻击。Codeman 全部六个都做了应对:
|
||||
|
||||
| USENIX 缺陷 | 缓解措施 |
|
||||
|-------------|------------|
|
||||
| **缺陷 1**:缺少一次性强制 | 令牌首次扫描即原子性消费 —— 重放永远失败 |
|
||||
| **缺陷 2**:长生命周期令牌 | 60s TTL + 90s 宽限,由定时器自动轮换 |
|
||||
| **缺陷 3**:可预测的令牌生成 | `crypto.randomBytes(32)` —— 256 位熵。短码采用拒绝采样以消除取模偏差 |
|
||||
| **缺陷 4**:客户端令牌生成 | 仅服务端 —— 令牌在嵌入二维码前绝不离开服务器 |
|
||||
| **缺陷 5**:缺少状态通知 | 桌面提示:*「设备 [IP] 已通过二维码认证(Safari)。不是你?[吊销]」* —— 实时 QRLjacking 检测 |
|
||||
| **缺陷 6**:会话绑定不足 | 存储 IP + User-Agent 以供审计。通过 API 手动吊销会话。HttpOnly + Secure + SameSite=lax cookie |
|
||||
| USENIX 缺陷 | 缓解措施 |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| **缺陷 1**:缺少一次性强制 | 令牌首次扫描即原子性消费 —— 重放永远失败 |
|
||||
| **缺陷 2**:长生命周期令牌 | 60s TTL + 90s 宽限,由定时器自动轮换 |
|
||||
| **缺陷 3**:可预测的令牌生成 | `crypto.randomBytes(32)` —— 256 位熵。短码采用拒绝采样以消除取模偏差 |
|
||||
| **缺陷 4**:客户端令牌生成 | 仅服务端 —— 令牌在嵌入二维码前绝不离开服务器 |
|
||||
| **缺陷 5**:缺少状态通知 | 桌面提示:_「设备 [IP] 已通过二维码认证(Safari)。不是你?[吊销]」_ —— 实时 QRLjacking 检测 |
|
||||
| **缺陷 6**:会话绑定不足 | 存储 IP + User-Agent 以供审计。通过 API 手动吊销会话。HttpOnly + Secure + SameSite=lax cookie |
|
||||
|
||||
#### 时序安全的查找
|
||||
|
||||
@@ -406,23 +495,23 @@ URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符)
|
||||
|
||||
#### 威胁覆盖
|
||||
|
||||
| 威胁 | 为何无效 |
|
||||
|--------|-------------------|
|
||||
| **二维码截图被分享** | 一次性:首次扫描即消费。60s TTL:攻击者动手前已过期。桌面通知会立即提醒你。 |
|
||||
| **重放攻击** | 原子性一次性消费 + 60s TTL。旧 URL 始终返回 401。 |
|
||||
| 威胁 | 为何无效 |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------ |
|
||||
| **二维码截图被分享** | 一次性:首次扫描即消费。60s TTL:攻击者动手前已过期。桌面通知会立即提醒你。 |
|
||||
| **重放攻击** | 原子性一次性消费 + 60s TTL。旧 URL 始终返回 401。 |
|
||||
| **Cloudflare 边缘日志** | 短码是不透明的 6 字符查找键,而非真正的 256 位令牌。一次性意味着从日志重放永远失败。 |
|
||||
| **暴力破解** | 568 亿种组合、任意时刻约 2 个有效、双层速率限制,早在统计可行性之前就已拦截。 |
|
||||
| **QRLjacking** | 60s 轮换迫使实时转发。桌面提示提供即时检测。自托管单用户场景使钓鱼难以成立。 |
|
||||
| **时序攻击** | 基于哈希的 Map 查找 —— 无字符串比较时序泄露。 |
|
||||
| **会话 cookie 窃取** | HttpOnly + Secure + SameSite=lax + 24h TTL。可在 `POST /api/auth/revoke` 手动吊销。 |
|
||||
| **暴力破解** | 568 亿种组合、任意时刻约 2 个有效、双层速率限制,早在统计可行性之前就已拦截。 |
|
||||
| **QRLjacking** | 60s 轮换迫使实时转发。桌面提示提供即时检测。自托管单用户场景使钓鱼难以成立。 |
|
||||
| **时序攻击** | 基于哈希的 Map 查找 —— 无字符串比较时序泄露。 |
|
||||
| **会话 cookie 窃取** | HttpOnly + Secure + SameSite=lax + 24h TTL。可在 `POST /api/auth/revoke` 手动吊销。 |
|
||||
|
||||
#### 横向对比
|
||||
|
||||
| 平台 | 模型 | 对比 |
|
||||
|----------|-------|------------|
|
||||
| **Discord** | 长生命周期令牌、无确认、[屡被利用](https://owasp.org/www-community/attacks/Qrljacking) | Codeman:一次性 + TTL + 通知 |
|
||||
| **WhatsApp Web** | 手机确认「关联设备?」,约 60s 轮换 | 轮换相当;WhatsApp 额外加了显式确认(对单用户而言是可接受的取舍) |
|
||||
| **Signal** | 临时公钥、端到端加密信道 | 加密更强,但 [2025 年仍被俄罗斯国家级行为者](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger)通过社会工程攻破 |
|
||||
| 平台 | 模型 | 对比 |
|
||||
| ---------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Discord** | 长生命周期令牌、无确认、[屡被利用](https://owasp.org/www-community/attacks/Qrljacking) | Codeman:一次性 + TTL + 通知 |
|
||||
| **WhatsApp Web** | 手机确认「关联设备?」,约 60s 轮换 | 轮换相当;WhatsApp 额外加了显式确认(对单用户而言是可接受的取舍) |
|
||||
| **Signal** | 临时公钥、端到端加密信道 | 加密更强,但 [2025 年仍被俄罗斯国家级行为者](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger)通过社会工程攻破 |
|
||||
|
||||
> 完整设计理由、安全分析与实现细节:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
@@ -430,7 +519,7 @@ URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符)
|
||||
|
||||
## 安全
|
||||
|
||||
Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。
|
||||
Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。**发现了漏洞?** 私下披露方式与已知限制清单见 [`SECURITY.md`](SECURITY.md)。
|
||||
|
||||
### 网络与访问
|
||||
|
||||
@@ -450,7 +539,7 @@ Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设
|
||||
|
||||
### 输入、文件与响应头
|
||||
|
||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
|
||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
|
||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
|
||||
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
||||
|
||||
@@ -481,73 +570,177 @@ sc -l # 列出会话
|
||||
|
||||
> Ctrl 绑定在 macOS 上也接受 Cmd。
|
||||
|
||||
| 快捷键 | 动作 |
|
||||
|----------|--------|
|
||||
| `Ctrl/Cmd+W` | 杀掉当前会话 |
|
||||
| `Ctrl/Cmd+Tab` | 下一个会话 |
|
||||
| `Alt+1`–`Alt+9` | 切换到第 N 个标签 |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
|
||||
| `Ctrl/Cmd+L` | 清屏 |
|
||||
| `Ctrl+Shift+R` | 恢复终端尺寸 |
|
||||
| `Ctrl+Shift+V` | 切换语音输入 |
|
||||
| `Ctrl/Cmd +` / `-` | 字体大小 |
|
||||
| `Ctrl/Cmd+?` | 键盘帮助 |
|
||||
| `Shift+Enter` | 插入换行(发送到终端) |
|
||||
| `Escape` | 关闭面板与模态框 |
|
||||
| 快捷键 | 动作 |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `Ctrl/Cmd+W` | 杀掉当前会话 |
|
||||
| `Ctrl/Cmd/Option+K` | 查找已打开的会话或新建一个 |
|
||||
| `Ctrl/Cmd+Tab` | 下一个会话 |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
|
||||
| `Alt/Option+1`–`Alt/Option+9` | 切换到第 N 个标签(按物理键位,macOS Option 布局也适用) |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
|
||||
| `Ctrl/Cmd+L` | 清屏 |
|
||||
| `Ctrl+Shift+R` | 恢复终端尺寸 |
|
||||
| `Ctrl+Shift+V` | 切换语音输入 |
|
||||
| `Ctrl/Cmd +` / `-` | 字体大小 |
|
||||
| `Ctrl/Cmd+?` | 键盘帮助 |
|
||||
| `Shift+Enter` | 插入换行(发送到终端) |
|
||||
| `Escape` | 关闭面板与模态框 |
|
||||
|
||||
---
|
||||
|
||||
## 从智能体驱动 Codeman —— 编程指南
|
||||
|
||||
面向不经浏览器控制 Codeman 的 AI 智能体与自动化:一个拉起工作会话的智能体、一个 CI 机器人,或是**运行在 Codeman 会话*内部*、编排其他会话的 Claude Code**。UI 能做的一切都是 HTTP + CLI,因此智能体也能做。
|
||||
|
||||
### 检测自己身处 Codeman 内部
|
||||
|
||||
当 CLI 运行在 Codeman 受管会话中时,以下环境变量会被设置 —— 读取它们,别硬编码任何东西:
|
||||
|
||||
| 变量 | 含义 |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `CODEMAN_MUX=1` | 你在一个受管 tmux 会话里。**绝不要** `tmux kill-session` / `pkill claude` / `pkill tmux` —— 你会杀掉自己或兄弟会话。 |
|
||||
| `CODEMAN_API_URL` | API 的基础 URL(例如 `https://127.0.0.1:3000`)。下面每个调用都用它。 |
|
||||
| `CODEMAN_SESSION_ID` | *你自己的*会话 id。用它避免对自己下手。 |
|
||||
| `CODEMAN_HOOK_SECRET_FILE` | hook 密钥文件的路径(受管隧道开启时调用 `/api/hook-event` 必需)。 |
|
||||
|
||||
### 行路规则(POST 之前先读)
|
||||
|
||||
1. **只发单行输入。** 编程输入会作为字面文本 **+ Enter** 一次性发送。多行字符串会破坏智能体 TUI(Ink)—— 发送一行,或拆成多次调用。
|
||||
2. **让输入幂等。** 在 `POST …/input` 上带上稳定的 `clientId` 和按会话单调递增的 `seq`。服务端会去重,因此连接中断后的重试不会重复投递提示。
|
||||
3. **认证。** 若设置了 `CODEMAN_PASSWORD`,发送 HTTP Basic 认证(用户 `admin` 或 `CODEMAN_USERNAME`)或 `codeman_session` cookie。默认的环回安装无密码。缺失的 `Origin` 头被允许,因此普通 `curl` 可用;跨站的浏览器 origin 会被拒绝(CSRF 防护)。
|
||||
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
|
||||
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
|
||||
|
||||
### 常用配方
|
||||
|
||||
```bash
|
||||
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}"
|
||||
# (若设置了密码,给每个调用加上 -u admin:"$CODEMAN_PASSWORD")
|
||||
|
||||
# 1. 看看有什么在运行
|
||||
curl -s "$API/api/sessions" | jq '.data // .'
|
||||
|
||||
# 2. 拉起一个工作会话(「case」= 命名工作目录)
|
||||
curl -s -X POST "$API/api/quick-start" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq
|
||||
|
||||
# 3. 向会话发送提示(精确一次:clientId + seq)
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}'
|
||||
|
||||
# 4. 读回终端内容
|
||||
curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .'
|
||||
|
||||
# 5. 流式接收实时事件(会话输出、智能体活动、状态)
|
||||
curl -sN "$API/api/events" # Server-Sent Events
|
||||
|
||||
# 6. 调度周期性工作(cron 风格任务)
|
||||
curl -s -X POST "$API/api/cron/jobs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj",
|
||||
"promptMode":"inline_text","promptText":"Update dependencies and open a PR",
|
||||
"inputMode":"typed","scheduleType":"daily","dailyTime":"03:00",
|
||||
"enabled":true,"concurrencyPolicy":"warn_only"}' | jq
|
||||
|
||||
# 7. 查看后台子智能体及其活动记录
|
||||
curl -s "$API/api/subagents" | jq '.data // .'
|
||||
curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .'
|
||||
|
||||
# 8. 全系统快照(会话、设置、重生、统计)
|
||||
curl -s "$API/api/status" | jq
|
||||
```
|
||||
|
||||
### 或使用内置 CLI
|
||||
|
||||
同样的操作也有命令形式(`codeman <cmd>`,括号内为别名)—— 在会话内的 shell 工具里很顺手:
|
||||
|
||||
```bash
|
||||
codeman session start -d /path/to/repo # (s) 启动会话
|
||||
codeman session list # 列出会话
|
||||
codeman session logs <id> # 查看输出
|
||||
codeman task add "fix the failing test" # (t) 排入任务
|
||||
codeman ralph start --min-hours 8 # (r) 启动自主循环
|
||||
codeman attach <path> # 附着 Claude hook 上下文
|
||||
```
|
||||
|
||||
### Hook(事件*回流*到 Codeman)
|
||||
|
||||
Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission_prompt`、`idle_prompt`、`stop`、`task_completed` 等),让仪表盘实时响应。该端点在环回上免认证,但在受管隧道下需要 `X-Codeman-Hook-Secret` 头(从 `$CODEMAN_HOOK_SECRET_FILE` 读取)。通常你不需要手动调用它 —— Codeman 会自动接好 —— 但自主层正是靠它「看见」智能体在做什么。
|
||||
|
||||
> 完整端点列表与请求/响应形状见下文。
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
基于 Fastify 的 REST —— **15 个路由模块中约 140 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。以下是一个有代表性的子集:
|
||||
基于 Fastify 的 REST —— **18 个路由模块中约 160 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
|
||||
|
||||
### 会话(Sessions)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/sessions` | 列出全部 |
|
||||
| `POST` | `/api/quick-start` | 创建 case 并启动会话 |
|
||||
| `DELETE` | `/api/sessions/:id` | 删除会话 |
|
||||
| `POST` | `/api/sessions/:id/input` | 发送输入 |
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| -------- | -------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `GET` | `/api/sessions` | 列出全部 |
|
||||
| `POST` | `/api/quick-start` | 创建 case + 启动会话(`{caseName?, mode?, effort?, envOverrides?}`) |
|
||||
| `POST` | `/api/sessions/:id/input` | 发送输入(`{input, useMux?, clientId?, seq?}` —— `clientId`+`seq` = 精确一次) |
|
||||
| `GET` | `/api/sessions/:id/output` | 读取终端输出 |
|
||||
| `DELETE` | `/api/sessions/:id` | 删除会话 |
|
||||
|
||||
### 重生(Respawn)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ------ | ---------------------------------- | -------------------- |
|
||||
| `POST` | `/api/sessions/:id/respawn/enable` | 启用,带配置与定时器 |
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 |
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 |
|
||||
|
||||
### Ralph / Todo
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 |
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ------ | -------------------------------- | -------------------- |
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 |
|
||||
|
||||
### 编排器(Orchestrator)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/orchestrator/start` | 从目标启动编排 |
|
||||
| `POST` | `/api/orchestrator/approve` | 批准生成的计划 |
|
||||
| `GET` | `/api/orchestrator/status` | 当前阶段 + 进度 |
|
||||
| `POST` | `/api/orchestrator/stop` | 停止并清理 |
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ------ | --------------------------- | --------------- |
|
||||
| `POST` | `/api/orchestrator/start` | 从目标启动编排 |
|
||||
| `POST` | `/api/orchestrator/approve` | 批准生成的计划 |
|
||||
| `GET` | `/api/orchestrator/status` | 当前阶段 + 进度 |
|
||||
| `POST` | `/api/orchestrator/stop` | 停止并清理 |
|
||||
|
||||
### Cron(定时任务)
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ---------------- | ---------------------------- | --------------------- |
|
||||
| `GET` / `POST` | `/api/cron/jobs` | 列出 / 创建 cron 任务 |
|
||||
| `PUT` / `DELETE` | `/api/cron/jobs/:id` | 更新 / 删除任务 |
|
||||
| `PUT` | `/api/cron/jobs/:id/enabled` | 启用 / 禁用 |
|
||||
| `POST` | `/api/cron/jobs/:id/run` | 立即运行 |
|
||||
| `GET` | `/api/cron/jobs/:id/runs` | 运行历史 |
|
||||
|
||||
### 子智能体(Subagents)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/subagents` | 列出所有后台智能体 |
|
||||
| `GET` | `/api/subagents/:id` | 智能体信息与状态 |
|
||||
| `GET` | `/api/subagents/:id/transcript` | 完整活动记录 |
|
||||
| `DELETE` | `/api/subagents/:id` | 杀掉智能体进程 |
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| -------- | ------------------------------- | ------------------ |
|
||||
| `GET` | `/api/subagents` | 列出所有后台智能体 |
|
||||
| `GET` | `/api/subagents/:id` | 智能体信息与状态 |
|
||||
| `GET` | `/api/subagents/:id/transcript` | 完整活动记录 |
|
||||
| `DELETE` | `/api/subagents/:id` | 杀掉智能体进程 |
|
||||
|
||||
### 系统(System)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/events` | SSE 流 |
|
||||
| `GET` | `/api/status` | 完整应用状态 |
|
||||
| `POST` | `/api/hook-event` | Hook 回调 |
|
||||
| `GET` | `/api/system/update/check` | 检查新发行版 |
|
||||
| `POST` | `/api/system/update` | 自更新(git-clone 安装) |
|
||||
| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) |
|
||||
| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 |
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ------ | ------------------------------- | ---------------------------------------- |
|
||||
| `GET` | `/api/events` | SSE 流 |
|
||||
| `GET` | `/api/status` | 完整应用状态 |
|
||||
| `POST` | `/api/hook-event` | Hook 回调 |
|
||||
| `GET` | `/api/system/update/check` | 检查新发行版 |
|
||||
| `POST` | `/api/system/update` | 自更新(git-clone 安装) |
|
||||
| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) |
|
||||
| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 |
|
||||
|
||||
---
|
||||
|
||||
@@ -582,7 +775,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph External["外部"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Gemini</small>"]
|
||||
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
||||
end
|
||||
end
|
||||
@@ -625,14 +818,14 @@ npm test # 运行测试
|
||||
|
||||
本代码库经历了一次全面的 7 阶段重构,消除了上帝对象、集中了配置,并建立了模块化架构:
|
||||
|
||||
| 阶段 | 改了什么 | 影响 |
|
||||
|-------|-------------|--------|
|
||||
| **性能** | 缓存端点、SSE 自适应批处理、缓冲区分块 | 终端延迟低于 16ms |
|
||||
| **路由抽取** | `server.ts` 拆分为 15 个领域路由模块 + 认证中间件 + 端口接口 | server.ts 代码量 **−67%**(6,736 → 2,254) |
|
||||
| **领域拆分** | `types.ts` → 16 个领域文件、`ralph-tracker` → 7 个文件、`respawn-controller` → 5 个文件、`session` → 6 个文件 | 不再有上帝文件 |
|
||||
| **前端模块** | `app.js` → 18 个抽取模块,横跨基础设施、领域与特性层 | app.js 核心降至 **约 3.4K 行** |
|
||||
| **配置合并** | 约 70 个散落的魔法数字 → 10 个领域聚焦的配置文件 | 零跨文件重复 |
|
||||
| **测试基础设施** | 共享 mock 库、12 个路由测试文件、统一的 MockSession | 路由处理器可通过 `app.inject()` 测试 |
|
||||
| 阶段 | 改了什么 | 影响 |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| **性能** | 缓存端点、SSE 自适应批处理、缓冲区分块 | 终端延迟低于 16ms |
|
||||
| **路由抽取** | `server.ts` 拆分为 15 个领域路由模块 + 认证中间件 + 端口接口 | server.ts 代码量 **−67%**(6,736 → 2,254) |
|
||||
| **领域拆分** | `types.ts` → 16 个领域文件、`ralph-tracker` → 7 个文件、`respawn-controller` → 5 个文件、`session` → 6 个文件 | 不再有上帝文件 |
|
||||
| **前端模块** | `app.js` → 18 个抽取模块,横跨基础设施、领域与特性层 | app.js 核心降至 **约 3.4K 行** |
|
||||
| **配置合并** | 约 70 个散落的魔法数字 → 10 个领域聚焦的配置文件 | 零跨文件重复 |
|
||||
| **测试基础设施** | 共享 mock 库、12 个路由测试文件、统一的 MockSession | 路由处理器可通过 `app.inject()` 测试 |
|
||||
|
||||
完整细节:[`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
|
||||
|
||||
@@ -654,6 +847,10 @@ npm install xterm-zerolag-input
|
||||
|
||||
---
|
||||
|
||||
## 版本策略
|
||||
|
||||
Codeman 遵循 [SemVer](https://semver.org/)。版本号真正承诺的内容,以及哪些算内部实现(HTTP/SSE API、磁盘上的状态、实验性特性),都写在 [`docs/versioning-policy.md`](docs/versioning-policy.md) 中。如果你的脚本依赖 HTTP API,请锁定到确切版本。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT —— 见 [LICENSE](LICENSE)
|
||||
|
||||
@@ -40,9 +40,20 @@ RUN npm install -g \
|
||||
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
|
||||
# only matters for a hand-run / Docker Desktop container. gid 0 + group-writable
|
||||
# HOME (OpenShift arbitrary-uid convention) keeps $HOME writable for any uid.
|
||||
# UTF-8 locale so tmux/Ink render Unicode box-drawing instead of VT100 ACS `q`
|
||||
# glyphs (C.UTF-8 is built into glibc; no locales package needed). Codeman also
|
||||
# sets these at run time so containers built before this line still get UTF-8.
|
||||
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8
|
||||
ENV HOME=/home/agent
|
||||
# `.claude` (+ `.claude/projects` mount point) and `.codex` (+ `.codex/sessions`) are
|
||||
# pre-created gid-0 group-writable so the container owns its OWN credential config
|
||||
# dirs: tokens/settings/config are seeded in as writable copies and each CLI's runtime
|
||||
# state (backups, tasks, refreshed tokens) stays container-local, while ONLY the shared
|
||||
# transcript/rollout dirs (`.claude/projects`, `.codex/sessions`) are bind-mounted from
|
||||
# the host. (gemini/gcloud/opencode are whole seed-copies and need no pre-created dir.)
|
||||
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
||||
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
|
||||
/home/agent/.claude/projects /home/agent/.codex/sessions \
|
||||
&& chgrp -R 0 /home/agent \
|
||||
&& chmod -R g=u /home/agent
|
||||
|
||||
|
||||
@@ -52,8 +52,9 @@ curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"cl
|
||||
## Lifecycle
|
||||
|
||||
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
|
||||
- **Container stop / host reboot** recreates the container and, when a resume id was captured, **resumes** the last conversation from the bind-mounted transcript.
|
||||
- **Container stop / host reboot** restarts the container and **resumes** the last conversation from the bind-mounted transcript. Claude sessions launch with a pinned conversation id (`--session-id <sessionId>`, with a `--resume` fallback when the transcript already exists), and the case remembers its last conversation (`lastClaudeSessionId`), so a relaunch after the container was stopped, rebooted, or recreated continues where it left off.
|
||||
- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
|
||||
- **Editing the docker host config** (image, memory, network, ...) is detected on the next launch: the desired config hash is compared against the container's `codeman.confighash` label, and a mismatch refuses the launch with a "config changed, recreate?" confirm. Confirming calls `POST /api/docker-cases/:name/recreate` (refused while sessions of the case are live), which removes the container so the next launch recreates it with the new config; the workspace and the conversation survive.
|
||||
- **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.
|
||||
|
||||
## Isolation & security
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# Multi-User Mode: Design Plan
|
||||
|
||||
Status: **IMPLEMENTED on `feat/multiuser-mode`** (phases 1-5; opt-in, off by default). Target: opt-in multi-user support behind a `--multiuser` flag, with per-user case spaces and an admin panel for user management.
|
||||
|
||||
Shipped by phase:
|
||||
|
||||
- **Phase 1** (user store + mode plumbing + CLI): `src/user-store.ts` (scrypt, atomic 0600 writes, last-admin invariants, serialized read-modify-write), `src/config/multiuser.ts`, `codeman users add|passwd|list|rm`, `--multiuser` flag, bootstrap-on-first-boot. Tests: `test/user-store.test.ts`.
|
||||
- **Phase 2** (multi-user auth): parallel async auth branch (`src/web/middleware/auth.ts`), `req.authUser`, per-username rate bucket, `mustChangePassword` lockbox, `GET /api/me` + `POST /api/me/password`, QR identity-bound minting, network-bind + tunnel exemptions, new error codes. Tests: `test/multiuser-auth.test.ts`.
|
||||
- **Phase 3** (ownership threading): `Session.owner` at every create path + recovery mirror; `findSessionOrFail` owner check + list filtering; §6.3 permission policy (`resolveClaudeModeForUser` at all spawn sites incl. one-shots via `buildPromptArgs`; shell/launchCommand grant); per-user case spaces (`resolveCasesDir`) + owner-scoped case list + admin-only host CRUD; `workingDir` confinement; `sessionCapacityState` per-user cap. Tests: `test/ownership-scoping.test.ts`.
|
||||
- **Phase 4** (event fan-out): WS owner gate; SSE per-client identity + `broadcast`/terminal-batch routing (`deriveSseHint`, fail-closed); `getLightState` per-identity filtering; file-route preview/thumbnail/history + `GET /api/search` scoping.
|
||||
- **Phase 5** (admin API + frontend): `src/web/routes/admin-routes.ts` (user CRUD, one-time passwords, last-admin guards, session revoke/kill) + `src/web/admin-audit.ts`; `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab). Tests: `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
|
||||
|
||||
Deferred follow-ups (documented, non-blocking): away-digest + subagent/workflow REST-list scoping, push-subscription identity/routing, per-user screenshot subdirs, `linked-cases.json` v2 owner field, `ScheduledRun.owner`, plan-orchestrator internal one-shot mode resolution, and a Playwright browser pass. Phase 6 (login form replacing Basic) remains out of scope.
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Today Codeman is strictly single-user: one optional credential pair (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`), one shared `~/codeman-cases` folder, one global session list, and a global SSE/WS fan-out. This plan adds an opt-in **multi-user mode**:
|
||||
|
||||
- **Off by default.** Without the flag, behavior stays byte-identical to today (same auth path, same paths, same payloads). All new code is gated behind `isMultiUserMode()`.
|
||||
- **`codeman web --multiuser`** (or `CODEMAN_MULTIUSER=1`) enables named users with individually hashed passwords stored in `~/.codeman/users.json`.
|
||||
- **Each user gets their own space**: `~/codeman-users/<username>/cases/<case>` replaces the shared `~/codeman-cases` for that user. Sessions, cases, attachments, search, digests, and SSE events are scoped to their owner.
|
||||
- **Admin panel** (App Settings, admin-only "Users" tab): create/delete users, change/reset passwords, enable/disable accounts, delete a user's space, see per-user live sessions and disk usage, force logout.
|
||||
|
||||
## 2. Threat Model (read first, be honest about this)
|
||||
|
||||
Multi-user mode is **workspace separation for a trusted team, NOT security isolation between mutually distrusting users**:
|
||||
|
||||
- Every session still runs as the **same OS account** with `claude --dangerously-skip-permissions`. Any user can ask their agent to `cat /home/<host>/codeman-users/otheruser/...`. The web layer enforces scoping; the agent layer cannot.
|
||||
- **Shell sessions and custom launch commands are the bluntest holes**: `SessionMode = 'shell'` hands out a raw shell as the host account, and a cron job's `launchCommand` runs an arbitrary command; no Claude permission classifier is involved in either. These must be gated behind the same grant as bypass (section 6.3), otherwise the `auto`-mode mitigation below is theater.
|
||||
- All sessions share one tmux socket (`-L codeman`), one `~/.claude` (transcripts, credentials, plan usage), one Claude subscription.
|
||||
- Mitigation for stronger isolation: pair a user's cases with **Docker cases** (container per case, `docs/docker-cases.md`), or run separate Codeman instances per user (`CODEMAN_INSTANCE`, separate OS accounts). True per-user OS isolation is explicitly **out of scope** for this feature.
|
||||
- Partial mitigation at the agent layer: non-admin users default to Claude's `auto` permission mode (section 6.3), whose safety classifier blocks destructive actions and credential exfiltration. That reduces, but does not eliminate, cross-user snooping; the `canBypassPermissions` grant reopens it and should be given deliberately.
|
||||
|
||||
This must be stated loudly in `docs/security-architecture.md`, the README section, and the admin panel UI ("Users share the host account; this separates workspaces, it does not sandbox users from each other").
|
||||
|
||||
Also note the flip side: multi-user mode strictly _improves_ today's network posture, because it removes the single shared password and gives every person their own revocable credential.
|
||||
|
||||
## 3. Activation and Mode Rules
|
||||
|
||||
| Condition | Behavior |
|
||||
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No flag (default) | Exactly today's behavior. `users.json` is never read. Single-user auth via `CODEMAN_PASSWORD` if set. |
|
||||
| `--multiuser` / `CODEMAN_MULTIUSER=1`, `users.json` has users | Multi-user auth active. `CODEMAN_PASSWORD` is ignored for login (warn if set). |
|
||||
| `--multiuser`, no `users.json` (first boot) | Bootstrap: if `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` are set, create that user as the initial admin and continue. Otherwise refuse to start with instructions to run `codeman users add <name> --admin`. Never start multi-user with zero users (there would be no way in). |
|
||||
| `--multiuser` on a non-loopback bind | Allowed without `CODEMAN_PASSWORD`: `server.ts start()` treats "multi-user with >= 1 enabled user" as satisfying the auth requirement in the loud-warning check (wire into the existing `isLoopbackBindHost()` branch). |
|
||||
| Flag later removed | Single-user mode again. Sessions/state that carry `owner` fields keep working (owner is simply ignored); user spaces remain on disk untouched. |
|
||||
|
||||
Plumbing: flag in `src/cli.ts` (web command), env in a new `src/config/multiuser.ts` exporting `isMultiUserMode()`. Per-instance like everything else: a beta instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`.
|
||||
|
||||
## 4. Data Model and Disk Layout
|
||||
|
||||
### 4.1 `~/.codeman/users.json` (via `dataPath('users.json')`, mode 0600, atomic write: tmp + rename)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"users": [
|
||||
{
|
||||
"username": "alice", // canonical lowercase slug
|
||||
"role": "admin", // "admin" | "user"
|
||||
"password": {
|
||||
"algo": "scrypt", // node:crypto scrypt, no new deps
|
||||
"N": 16384,
|
||||
"r": 8,
|
||||
"p": 1,
|
||||
"salt": "<hex 32B>",
|
||||
"hash": "<hex 64B>",
|
||||
},
|
||||
"disabled": false,
|
||||
"mustChangePassword": false, // set by admin reset; gates all API access until changed
|
||||
"canBypassPermissions": false, // permission-mode grant, see section 6.3; false for new users
|
||||
"createdAt": 1752900000000,
|
||||
"lastLoginAt": 1752900000000,
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
- **Username rules**: `^[a-z0-9][a-z0-9_-]{1,31}$` (it becomes a folder name), stored lowercase, unique case-insensitively. Reserve `admin`? No: any name can be admin; role is a field, not a name.
|
||||
- **Hashing**: `scrypt` from `node:crypto` with per-user salt, compared via `timingSafeEqual`. Params stored per record so they can be raised later; verify tolerates old params and rehashes on next successful login.
|
||||
- New module `src/user-store.ts` (mirrors the `remote-hosts.ts` / `docker-hosts.ts` pattern): `readUsers()`, `writeUsers()`, `verifyPassword()`, `createUser()`, `setPassword()`, `deleteUser()`, plus pure helpers (`isValidUsername`, `hashPassword`) that are unit-testable without IO. In-process cache with short TTL like `readSettings`, invalidated on every write; the short TTL also covers the CLI (section 10) editing `users.json` while the server runs (cross-process changes picked up within the TTL).
|
||||
|
||||
### 4.2 User spaces
|
||||
|
||||
```
|
||||
~/codeman-users/
|
||||
alice/
|
||||
cases/
|
||||
my-project/ <- same layout as today's ~/codeman-cases/<case>
|
||||
bob/
|
||||
cases/
|
||||
```
|
||||
|
||||
- New helper in `route-helpers.ts`:
|
||||
`resolveCasesDir(user?: AuthUser): string`
|
||||
single-user mode: returns `CASES_DIR` (today's `~/codeman-cases`); multi-user: returns `join(USER_SPACES_DIR, user.username, 'cases')`, creating it lazily on first use.
|
||||
- `CASES_DIR` stays exported for single-user code paths, but every route usage (see 6) switches to the resolver.
|
||||
- The **user folder** (`~/codeman-users/<username>/`) is the deletion unit for "delete user + space" and leaves room for future per-user extras (uploads, exports) beside `cases/`.
|
||||
- Legacy `~/codeman-cases` in multi-user mode: surfaces to admins only, as a read-only "Unassigned (legacy)" group in the case list, with an admin action `POST /api/admin/cases/assign { case, username }` that `fs.rename`s the folder into a user's space (same-filesystem move, cheap). No automatic migration.
|
||||
|
||||
## 5. Auth Pipeline Changes (`src/web/middleware/auth.ts`)
|
||||
|
||||
Keep the existing single-user branch untouched. Add a parallel multi-user branch selected once at registration time:
|
||||
|
||||
1. **Credential check**: Basic header parsed into `username:password`, verified against the user store (scrypt + `timingSafeEqual`). Disabled users fail closed.
|
||||
2. **Cookie sessions**: same `codeman_session` cookie and `StaleExpirationMap`, but `AuthSessionRecord` gains `username` and `role`. All existing TTL/sliding/eviction logic reused. Eviction cap becomes per-user aware (evict oldest _of that user_ first) so one user cannot flush everyone's sessions by logging in 100 times.
|
||||
3. **Request identity**: decorate `req.authUser = { username, role }` (Fastify decorateRequest). In single-user mode `req.authUser` is `{ username: 'admin', role: 'admin' }` when auth is on, and a synthetic admin when auth is off, so downstream code has ONE code path.
|
||||
4. **Rate limiting**: keep the per-IP bucket; add a per-username failure bucket (same `StaleExpirationMap` pattern) so a botnet cannot brute-force one account across IPs, and one flaky user behind a NAT cannot lock out the rest.
|
||||
5. **`mustChangePassword` gate**: when set, every API request except `GET /api/me`, `POST /api/me/password`, and static assets returns 403 with `errorCode: 'PASSWORD_CHANGE_REQUIRED'`; the frontend intercepts that code and shows the change-password modal.
|
||||
6. **Password change vs Basic-auth caching**: browsers cache Basic credentials. After a password change we revoke all of that user's cookie sessions; the next request falls to Basic with stale creds, gets 401, and the browser re-prompts. Acceptable for v1; a proper login form is Phase 6 (see 15).
|
||||
7. **Unchanged**: hook-secret loopback bypass (hooks authenticate the _instance_, not a user; the event maps to a session which has an owner), host guard, Origin/CSRF guard, security headers.
|
||||
8. **WS upgrade identity** (`ws-routes.ts`): the global auth `onRequest` hook does run on the upgrade request (`@fastify/websocket` v11 runs hooks before the handshake; browsers send the session cookie), but the route handler itself only checks Host/Origin and never learns WHO authenticated. Multi-user: the handler reads the decorated `req.authUser` and closes 4003 unless owner or admin (section 6.4; identity plumbing lands in Phase 2, the owner check in Phase 4 once sessions have owners). Add a regression test that an upgrade with no credentials is rejected while auth is active: the handler-level Host/Origin gate alone must never be mistaken for auth.
|
||||
9. **QR auth** (`/q/:code` redemption in `system-routes.ts`, minting in `tunnel-manager.ts`): today there is ONE global token, auto-rotated every 60s with a 90s grace window. A globally-rotating token cannot carry an identity (every logged-in user sees the same code), so multi-user mode replaces rotation with **on-demand minting**: an authenticated `POST /api/tunnel/qr` mints a single-use, short-TTL token bound to `req.authUser.username` (field on `QrTokenRecord`); redemption creates a cookie session for that user. Existing rate-limit buckets (`qrAuthFailures`, global `QR_RATE_LIMIT_MAX`) apply unchanged. Single-user mode keeps the rotating token.
|
||||
|
||||
New error codes in `src/types/api.ts`: `FORBIDDEN`, `PASSWORD_CHANGE_REQUIRED`, `USER_EXISTS`, `USER_NOT_FOUND`, `LAST_ADMIN`.
|
||||
|
||||
Role guard helper in `route-helpers.ts`: `requireAdmin(req, reply): boolean` used as the first line of every admin handler (403 `FORBIDDEN`), plus `requireOwnerOrAdmin(req, session)`.
|
||||
|
||||
## 6. Ownership Threading (the big refactor)
|
||||
|
||||
### 6.1 Sessions
|
||||
|
||||
- `Session` gains `owner?: string` (constructor option), persisted in `SessionState.owner`, included in `toState()`, round-tripped through recovery (`mux-sessions.json` entries carry it, `restoreMuxSessions` passes it back, exactly like `remote`/`docker`).
|
||||
- Every session-creating path stamps the owner from `req.authUser`. Verified inventory of `new Session(...)` call sites: `POST /api/sessions` (session-routes.ts:444), `POST /api/quick-start` (:1956), `POST /api/run` one-shot (:1652), Ralph start (ralph-routes.ts:327), **cron** (cron-service.ts:352; `CronJob` gains `owner`, stamped at job create, launched as the job's owner), legacy `ScheduledRun` loop (server.ts:1603), plan generation + plan-orchestrator agents (plan-routes.ts:128, plan-orchestrator.ts:422/578; owner = requesting user), and recovery (server.ts:2225, next bullet). Two non-paths, also verified: **respawn never constructs a new Session** (it re-spawns the PTY on the same object, so `owner` survives automatically; no inheritance logic needed), and **orchestrator-loop creates no sessions** (it schedules work onto existing idle sessions via the task queue; its scoping requirement is different: it must only pick idle sessions owned by the goal's creator).
|
||||
- Recovery: `owner` must ALSO be mirrored on `MuxSession` (mux-sessions.json) and read back mux-first like `remote`/`docker` (`muxSession.owner ?? savedState?.owner`, the server.ts:2246-2250 pattern), or a reboot erases ownership on the next persist.
|
||||
- Every session-reading/mutating route filters: non-admin users only see and act on `session.owner === req.authUser.username`. Centralize in `findSessionOrFail` (route-helpers.ts:87; the owner check there covers the 6 route files that use it: system/session/respawn/ralph/file/plan-routes) and in the list endpoints (`GET /api/sessions`, `GET /api/sessions/unified`, `GET /api/status`). The Phase 3 audit must grep for BOTH `sessionManager.getSession` AND direct map access (`ctx.sessions.get(` / `.has(`): ws-routes and hook-event-routes reach sessions that way and bypass `findSessionOrFail`.
|
||||
- Admins see everything; every session row carries `owner` so the UI can badge it.
|
||||
|
||||
### 6.2 Cases
|
||||
|
||||
- All `CASES_DIR` call sites switch to `resolveCasesDir(req.authUser)`: `case-routes.ts` (list/create/delete/CLAUDE.md scaffolding, name-collision checks, docker quickcreate), `session-routes.ts` (quick-start case resolution, the workingDir-inside-cases env-strip check), `ralph-routes.ts` (case path resolution), and `plan-routes.ts:231` (easy to miss). Case-name-to-path resolution is currently DUPLICATED (`resolveCasePath` in case-routes.ts:82 and an inline copy in quick-start, session-routes.ts:1846-1863); consolidate into one owner-aware resolver as part of this refactor instead of patching both copies.
|
||||
- Registries that map case names to metadata become owner-scoped. `remote-cases.json`/`docker-cases.json` are arrays of objects, so entries simply gain `owner?: string` (absent = legacy: admin-only). `linked-cases.json` is a flat `Record<caseName, path>` with no room for a field: it needs a v2 shape (`{ "version": 2, "cases": { "<name>": { "path": "...", "owner": "..." } } }`) with read-time migration of the v1 form; it is read in two places (case-routes AND inline in quick-start), both must move to the new reader. Case names only need to be unique per user.
|
||||
- **Remote hosts and Docker hosts are machine-level resources**: CRUD on `/api/docker-hosts` and remote-host endpoints becomes admin-only in multi-user mode; regular users can _use_ hosts on their own cases but not define them. (Docker containers exec as the host account; letting any user define arbitrary `docker run` args is admin-equivalent.)
|
||||
- Case deletion, exports (`docker-exports/`), and imports check ownership; export filenames get an owner prefix to avoid collisions (fits the existing `^[a-zA-Z0-9._-]+\.tgz$` download guard).
|
||||
- **Workspace confinement for non-admins (the linchpin, do not skip)**: today `POST /api/sessions` accepts ANY host directory as `workingDir` (the only check is `statSync().isDirectory()`, session-routes.ts:305-318), and file-routes/attachments confine reads to `session.workingDir`. Without a new rule the whole scoping story is circular: a user points a session at `~/codeman-users/bob` (or `/home`) and the web layer itself serves that subtree, no agent needed. Rule: in multi-user mode a non-admin's `workingDir` must realpath-resolve inside their own space, enforced at `POST /api/sessions`, `POST /api/run`, cron job create AND fire time (the dir can change owners between the two), and Ralph auto-configure. Admins are unrestricted. This one rule is what makes the section 6.4 file-route line ("own space or own sessions' workingDirs") meaningful.
|
||||
|
||||
### 6.3 Per-user Claude permission-mode policy
|
||||
|
||||
Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab: `settings.claudeMode`, values `dangerously-skip-permissions` (default) | `auto` | `normal` | `allowedTools`; `auto` emits `--permission-mode auto`, Anthropic's classifier-guarded low-prompt mode). Multi-user mode layers a per-user policy on top of it:
|
||||
|
||||
- **Default for regular users: `auto` only.** A non-admin's Claude sessions are forced to `--permission-mode auto` regardless of the global `claudeMode` setting. `normal` and `allowedTools` are also permitted (they are strictly more restrictive than auto), but `dangerously-skip-permissions` is NOT.
|
||||
- **Bypass is an explicit admin grant**: `canBypassPermissions: true` on the user record (default `false`, section 4.1). Only with that grant does the global skip-permissions default (or a future per-user choice) apply to their sessions.
|
||||
- **Admins** are unrestricted; the global setting applies to them as-is.
|
||||
- **Single enforcement point**: a pure `resolveClaudeModeForUser(globalMode, user)` in `user-store.ts`, applied server-side at option-resolution time, BEFORE the Session constructor, so both downstream arg builders inherit it for free (`buildPermissionArgs` in session-cli-builder.ts for the direct-PTY path AND `buildClaudePermissionFlags` in tmux-manager.ts for tmux panes; there are two builders, not one). Call sites where `getClaudeModeConfig()` feeds a spawn: session-routes.ts:452/1964, ralph-routes.ts:334, cron-service.ts:360, and recovery (server.ts:2214/2233). Recovery re-reads the GLOBAL setting on reboot, so the resolver must run there with the RECOVERED owner, or a restart silently un-downgrades every restored session. Never resolved in the frontend, so it cannot be bypassed via payload.
|
||||
- **Downgrade, don't error**: a non-granted user whose effective mode would be bypass gets `auto` silently (logged + surfaced as a badge on the session), so shared presets keep working.
|
||||
- **Other CLIs' bypass equivalents** follow the same grant: Codex `--dangerously-bypass-approvals-and-sandbox` (`codexDangerouslyBypassApprovals`) and Gemini `--approval-mode yolo` are refused for non-granted users (Gemini falls back to `auto_edit`, Codex to its default sandbox). Whether this stays one grant or splits per-CLI is an open question (section 15).
|
||||
- **Shell mode and custom launch commands follow the grant too**: `mode: 'shell'` sessions and cron `launchCommand` are arbitrary command execution as the host account, strictly stronger than any bypass flag, and no permission-mode downgrade applies to them. Non-granted users get 403 `FORBIDDEN` on shell session/quick-start creation and on cron jobs carrying `launchCommand` (checked at create AND at fire time). Folding them under `canBypassPermissions` keeps the model one-bit; section 15 asks whether it should split.
|
||||
- **Admin UI**: a "Can skip permissions" toggle per user in the Users tab (PATCH field, section 8), with a warning echoing the section 2 threat model.
|
||||
- Revoking the grant takes effect on the user's NEXT session start; live sessions are listed so the admin can restart them.
|
||||
|
||||
### 6.4 Everything else that lists or streams
|
||||
|
||||
| Surface | Scoping rule |
|
||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| SSE `/api/events` | Per-connection filter (see 7) |
|
||||
| WS terminal (`ws-routes.ts`) | Handler reads `req.authUser` (section 5.8) and closes 4003 unless owner or admin; today it checks Host/Origin only and has no identity |
|
||||
| `GET /api/search` | `harvestSources()` only over owned sessions |
|
||||
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
||||
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
||||
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
||||
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
||||
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only |
|
||||
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
|
||||
|
||||
## 7. SSE Event Filtering
|
||||
|
||||
`/api/events` currently broadcasts everything to everyone. Ground truth first (verified): `broadcast()` lives in `SseStreamManager` (`sse-stream-manager.ts`), not server.ts; clients are keyed by the raw Fastify reply (`sseClients: Map<FastifyReply, Set<string> | null>`, plus `sseClientsById` for live filter updates); the existing `?sessions=` filter is a bandwidth optimization applied ONLY to `session:terminal` batches in `flushSessionTerminalBatch()`, while `broadcast()` itself loops ALL clients unconditionally. The single-client delivery primitive already exists (`sendSSE`, used for the per-connection init snapshot). Plan:
|
||||
|
||||
- At connection time, resolve `req.authUser` and store `{ username, role }` with the client. Concretely: extend `addClient(reply, sessionFilter, isRemote, clientId)` to take the identity and change the `sseClients` map value to `{ filter, identity }` (or add a parallel `Map<reply, identity>`); there is no per-client record object today to hang it on.
|
||||
- `broadcast()` gains an optional routing hint: `broadcast(event, data, { sessionId?, adminOnly?, username? })`. Resolution order per client: admin sees all; `username` targets one user; `sessionId` resolves owner via SessionManager; `adminOnly` for machine-level events (docker image builds, tunnel, self-update); no hint = broadcast to all (connection status etc.).
|
||||
- **Enforce the identity check in BOTH `broadcast()` AND `flushSessionTerminalBatch()`**: the terminal batch path does not go through `broadcast()`, and it carries the highest-value payload (raw terminal bytes).
|
||||
- Sweep of the ~120 backend event constants in `sse-events.ts`: mechanically, everything `session:*`, `ralph:*`, `respawn:*`, `subagent:*`, `workflow:*`, `attachment:*`, `cron:*` (job owner) carries or can resolve a sessionId/owner; `docker:*`, `system:*`, tunnel and update events are adminOnly; a short tail needs case-by-case decisions during implementation.
|
||||
- The existing `?sessions=` filter and `/api/events/subscribe` compose with (never override) the ownership filter: the subscription filter can only narrow within what the identity allows.
|
||||
|
||||
## 8. Admin API (`src/web/routes/admin-routes.ts`, new module + `AdminPort`)
|
||||
|
||||
All handlers: multi-user mode only (404 otherwise), `requireAdmin`, Zod schemas in `schemas.ts`, `ApiResponse` envelope, audit-logged.
|
||||
|
||||
| Endpoint | Behavior |
|
||||
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /api/admin/users` | List users + stats: role, disabled, createdAt, lastLoginAt, live session count, case count, space disk usage (best-effort async walk, cached 60s), active cookie-session count |
|
||||
| `POST /api/admin/users` | Create: `{ username, role, password? }`. No password given: generate a one-time password, return it ONCE in the response, set `mustChangePassword` |
|
||||
| `PATCH /api/admin/users/:username` | `{ role?, disabled?, canBypassPermissions? }`. Demoting/disabling the last enabled admin: 409 `LAST_ADMIN`. Disable also revokes cookie sessions. `canBypassPermissions` is the section 6.3 grant (default false) |
|
||||
| `POST /api/admin/users/:username/reset-password` | Generates one-time password (returned once), sets `mustChangePassword`, revokes cookie sessions |
|
||||
| `POST /api/admin/users/:username/logout` | Revoke all cookie sessions for that user. Honest limit under Basic auth: the browser silently re-sends cached credentials and gets a fresh cookie on the next request, so logout only truly ends QR-issued sessions; to actually lock someone out, disable the account or reset the password. Say so in the panel tooltip until Phase 6 |
|
||||
| `DELETE /api/admin/users/:username` | `{ deleteSpace?: boolean }` (default false). Refuses last admin. Kills the user's live sessions first (normal kill flow, incl. docker/remote teardown per case), revokes cookies, removes from store. With `deleteSpace`: guarded recursive delete of `~/codeman-users/<username>` (realpath must be inside `USER_SPACES_DIR`, top-level dir must not be a symlink), plus their registry entries and push subscriptions |
|
||||
| `POST /api/admin/cases/assign` | Move a legacy `~/codeman-cases/<case>` into a user's space (`fs.rename`) |
|
||||
| Self-service `GET /api/me` | `{ username, role, mustChangePassword }` (works in single-user mode too: synthetic admin; the frontend uses it to decide whether to render admin UI) |
|
||||
| Self-service `POST /api/me/password` | `{ currentPassword, newPassword }`, verifies current, min length 8, revokes other sessions, clears `mustChangePassword` |
|
||||
|
||||
**Audit log**: append-only `~/.codeman/admin-audit.jsonl` (same idiom as `session-lifecycle.jsonl`): timestamp, acting admin, action, target, request IP. User management without an audit trail is not acceptable even for a homelab tool.
|
||||
|
||||
SSE additions (both `sse-events.ts` and `constants.js`): `admin:usersChanged` (adminOnly; the panel re-fetches) and `auth:passwordChangeRequired` (targeted to the user).
|
||||
|
||||
## 9. Frontend
|
||||
|
||||
- **`GET /api/me` on boot** (app.js init): stores `window.__codemanUser`; everything below keys off it. Single-user mode returns the synthetic admin, so the UI needs no mode awareness beyond "am I admin".
|
||||
- **Admin panel**: new tab "Users" in the App Settings modal (settings-ui.js), rendered only for admins in multi-user mode. Table of users with actions (create, reset password showing the one-time password in a copy-to-clipboard reveal, enable/disable, role toggle, logout, delete with a typed-username confirm for the delete-space variant). No new header button (mobile header policy test stays green; the settings modal is already reachable everywhere).
|
||||
- **Change-password modal**: shown on `PASSWORD_CHANGE_REQUIRED` (fetch interceptor in api-client.js) and reachable from settings for self-service.
|
||||
- **Owner badges**: admin's session tabs and the session palette/manager show `owner` on foreign sessions; regular users see no change.
|
||||
- New module `admin-ui.js` if the settings-ui.js addition gets large (load order after settings-ui, before session-ui), else keep inside settings-ui.js. Follow the `@fileoverview` + `@loadorder` convention either way.
|
||||
|
||||
## 10. CLI Additions (`src/cli.ts`)
|
||||
|
||||
Headless bootstrap and recovery must not require the web UI:
|
||||
|
||||
```
|
||||
codeman users add <name> [--admin] # prompts for password (hidden input), or --password-stdin
|
||||
codeman users passwd <name> # reset password
|
||||
codeman users list
|
||||
codeman users rm <name> [--delete-space]
|
||||
```
|
||||
|
||||
These operate directly on `users.json` via `user-store.ts` (no server needed), honoring `CODEMAN_INSTANCE`. This is also the answer to "locked out: last admin forgot password".
|
||||
|
||||
## 11. Limits and Config
|
||||
|
||||
- New `src/config/multiuser.ts`: `isMultiUserMode()`, `USER_SPACES_DIR` (`~/codeman-users`, overridable via `CODEMAN_USER_SPACES_DIR` for tests), `MAX_USERS` (default 25), per-user session cap (default: global cap / 2, env `CODEMAN_MAX_SESSIONS_PER_USER`).
|
||||
- Cap enforcement is currently COPY-PASTED: the global `MAX_CONCURRENT_SESSIONS` (50, `config/map-limits.ts:25`) check appears at 6 independent sites (session-routes.ts:298/1622/1683, ralph-routes.ts:275, cron-service.ts:340, server.ts:1595). Do not add a 7th copy per site: extract one `assertSessionCapacity(ctx, owner?)` helper doing the global + per-user checks and use it everywhere, or the per-user cap WILL miss a path.
|
||||
- Global limits (50 sessions, SSE clients 100, terminal buffers) are unchanged and shared; the per-user session cap is the fairness lever.
|
||||
|
||||
## 12. Compatibility Matrix
|
||||
|
||||
| Concern | Guarantee |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
||||
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
||||
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
||||
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
||||
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
||||
|
||||
## 13. Implementation Phases
|
||||
|
||||
Each phase is independently shippable behind the flag and ends with its tests green.
|
||||
|
||||
**Phase 1: user store + mode plumbing** (no behavior change yet)
|
||||
`src/user-store.ts`, `src/config/multiuser.ts`, CLI `users` subcommands, bootstrap-on-first-boot logic, `users.json` schema + atomic writes.
|
||||
Tests: `test/user-store.test.ts` (hashing, verify, params upgrade, username validation, atomic write, last-admin invariants; pure, no server).
|
||||
|
||||
**Phase 2: multi-user auth**
|
||||
Auth middleware branch, `req.authUser` decoration, cookie records with username/role, per-username rate bucket, `mustChangePassword` gate, WS upgrade identity plumbing + unauthenticated-upgrade regression test (section 5.8), QR on-demand minting + identity binding (section 5.9), `GET /api/me`, `POST /api/me/password`, error codes, network-bind check integration.
|
||||
Tests: `test/multiuser-auth.test.ts` (live server, unique port 3170+; wrong password, disabled user, cookie carries identity, per-user rate limit isolation, mustChangePassword lockbox, QR redemption identity). Reuse the `delete process.env.CODEMAN_PASSWORD` idiom from `test/setup.ts`.
|
||||
|
||||
**Phase 3: ownership threading**
|
||||
Session `owner` + persistence + `MuxSession` mirror + recovery; `resolveCasesDir()` refactor across case/session/ralph/plan routes (consolidating the duplicated case-path resolution); registry owner fields incl. the linked-cases v2 shape; `findSessionOrFail` owner check + the direct-`sessions.get` audit; list filtering; owner stamping across ALL create paths from 6.1; **non-admin workingDir confinement** (6.2); permission-mode/shell/launchCommand policy (6.3); `assertSessionCapacity` helper + per-user cap.
|
||||
Tests: `test/routes/ownership-scoping.test.ts` (inject-based: user A cannot read/kill/input user B's session, case lists are disjoint, admin sees both), extend `test/cron-service.test.ts` for owner stamping, recovery round-trip in the existing mux-recovery tests.
|
||||
|
||||
**Phase 4: event fan-out + remaining surfaces**
|
||||
SSE routing hints + client identity (enforced in BOTH `broadcast()` and the terminal-batch flush), WS owner gate (identity landed in Phase 2), search/digest/subagent/workflow scoping, push subscription identity + owner routing, screenshot subdirs, file-route scoping, `getLightState` filtering + per-identity caching, admin-only system ops.
|
||||
Tests: `test/sse-ownership.test.ts` (two SSE clients, event for A's session reaches only A + admin), WS upgrade rejection test, search/digest scoping tests.
|
||||
|
||||
**Phase 5: admin API + frontend**
|
||||
`admin-routes.ts` + `AdminPort` + schemas + audit log + `admin:usersChanged`; settings-ui Users tab, change-password modal, owner badges, api-client interceptor.
|
||||
Tests: `test/routes/admin-routes.test.ts` (CRUD, last-admin 409, one-time password flow, delete-space guard rails incl. symlink refusal), frontend vm-sandbox test following `test/run-mode-ui.test.ts` pattern, Playwright pass per the always-end-to-end rule before calling it done.
|
||||
|
||||
**Phase 6 (optional, later): login page**
|
||||
Replace Basic with a form + `POST /api/login` in multi-user mode only (fixes browser credential caching UX, enables logout button). Explicitly deferred; Basic works for v1.
|
||||
|
||||
**Docs**: update `docs/security-architecture.md` (new section: multi-user model + threat model from section 2), `README.md` (short opt-in section), `CLAUDE.md` (Key Patterns entry + State Files + route/SSE counts), this file gets a "shipped" status stamp per phase.
|
||||
|
||||
## 14. Key Risks / Decisions Made
|
||||
|
||||
1. **Not a security boundary at the agent layer** (section 2). Decided: ship with loud documentation; Docker cases are the isolation story.
|
||||
2. **`findSessionOrFail` as the single enforcement point** for ~30 session routes: any route that fetches sessions another way must be audited in Phase 3 (grep for `sessionManager.getSession` outside route-helpers).
|
||||
3. **SSE sweep is the riskiest surface**: a missed event leaks metadata (not terminal content, which is session-scoped, but names/paths). Phase 4 includes a checklist pass over all ~138 events with the default flipped to "owner-scoped unless explicitly global": fail closed.
|
||||
4. **Basic-auth password-change UX** is mediocre (browser re-prompt). Accepted for v1; Phase 6 fixes it properly.
|
||||
5. **Legacy case migration** is manual (admin assigns). No silent moves of user data.
|
||||
6. **Case-name uniqueness becomes per-user**; tmux session names already include the session id so no collision, but the `w<n>-<case>` tab naming and lifecycle-log rows should include the owner for disambiguation in admin views.
|
||||
7. **`workingDir` confinement (6.2) is the single most load-bearing rule**: every file-serving and agent-spawning surface downstream trusts `session.workingDir`. Review and test it as carefully as the auth branch (foreign-space path, symlink into a foreign space, `..` traversal, cron fire-time re-check).
|
||||
8. **The WS handler never sees identity today** (auth happens only in the global hook): the 5.8 wiring is new code on a security-sensitive path; cover unauthenticated, foreign-user, and admin upgrades with tests.
|
||||
|
||||
## 15. Open Questions (answer before Phase 3)
|
||||
|
||||
1. Should admins' own cases live in `~/codeman-users/<admin>/cases` (symmetric, proposed) or keep using legacy `~/codeman-cases`? Proposed: symmetric; legacy dir is a migration source only.
|
||||
2. Per-user settings (respawn presets, notification prefs): global-only in v1. Worth a `users/<name>/settings.json` overlay later?
|
||||
3. Should regular users be allowed to create Docker cases on admin-defined hosts (proposed: yes) or is Docker entirely admin-only?
|
||||
4. Session handoff: does an admin need "reassign session/case to another user"? (Cheap to add next to `cases/assign`; not in v1 scope.)
|
||||
5. Permission-mode grants (section 6.3): one `canBypassPermissions` flag covering Claude/Codex/Gemini bypass equivalents PLUS shell mode and cron `launchCommand` (proposed: one flag, keep it one-bit), or split into `canBypassPermissions` + `canRunArbitraryCommands`? And should admins be able to set a per-user DEFAULT mode (for example force `normal` for an intern) rather than just gating bypass?
|
||||
6. OpenCode has no single bypass flag (its permission config rides `OPENCODE_CONFIG_CONTENT`): decide what the grant means there before Phase 3, or exclude OpenCode mode for non-granted users in v1.
|
||||
@@ -0,0 +1,246 @@
|
||||
# Remote Sessions (SSH)
|
||||
|
||||
Codeman can run a session's agent on a **remote host over SSH** instead of the
|
||||
local machine. The agent (Claude, OpenCode, Codex, Gemini, or a plain shell)
|
||||
runs inside a `tmux` server **on the remote host**, so it survives the SSH
|
||||
connection dropping; Codeman attaches to it the same way it attaches to a local
|
||||
managed session.
|
||||
|
||||
This document covers the data model, the shell-safe SSH command construction
|
||||
(COD-107), the durable-launch design (COD-104), and the operational caveats.
|
||||
For the local session/mux machinery this builds on, see the **Mux** and
|
||||
**Session** entries in `CLAUDE.md` → Architecture.
|
||||
|
||||
## Why it exists
|
||||
|
||||
A developer box (`AA-DESKTOP`) often needs to drive an agent on another machine —
|
||||
a NAS, a build server, a host reachable only through a jump box or a
|
||||
cloudflared SOCKS5 proxy. Rather than wrap `ssh` by hand per host, Codeman
|
||||
stores reusable **remote hosts** + **remote cases** and reproduces the exact
|
||||
connection the operator already uses (`ssh-aa-desktop`-style configs:
|
||||
custom port, identity file, `-J` jump host, `-o ProxyCommand`).
|
||||
|
||||
## Data model
|
||||
|
||||
Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. |
|
||||
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
|
||||
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
|
||||
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
|
||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini'>` — the modes that can run remotely. |
|
||||
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
|
||||
|
||||
Persistence is two flat JSON arrays in the instance data dir:
|
||||
|
||||
- `~/.codeman/remote-hosts.json` — `readRemoteHosts()` / `writeRemoteHosts()`
|
||||
- `~/.codeman/remote-cases.json` — `readRemoteCases()` / `writeRemoteCases()`
|
||||
|
||||
(Paths via `remoteHostsPath()` / `remoteCasesPath()`; both honor `CODEMAN_INSTANCE`
|
||||
because the config dir is the instance data dir.)
|
||||
|
||||
On the live `Session`, the remote rides as `_remote?: SessionRemote`. When
|
||||
attaching, `resolveMuxAttachCwd()` forces the cwd to `/tmp` for remote sessions —
|
||||
the local working directory is meaningless on the remote box.
|
||||
|
||||
## SSH command construction (COD-107 — the injection surface)
|
||||
|
||||
**All** SSH command lines flow through one function so user-controlled fields are
|
||||
escaped once and the launch + prereq probe can never drift apart:
|
||||
|
||||
```ts
|
||||
// src/remote-hosts.ts
|
||||
buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHost, 'port'>): string[]
|
||||
```
|
||||
|
||||
It returns the **ordered leading tokens** of an ssh command line (no `-t`, no
|
||||
target, no remote command):
|
||||
|
||||
```
|
||||
ssh -o BatchMode=yes
|
||||
[-p <port>]
|
||||
[-i <abs-identity>] # ~ / $HOME expanded, then shellescaped
|
||||
[-J <jumpHost>] # shellescaped, single token
|
||||
[-o ProxyCommand=nc -X 5 -x <socks> %h %p] # ONE shellescaped -o token
|
||||
[-o <KEY=VALUE>] … # each extra option, shellescaped
|
||||
```
|
||||
|
||||
Rules that keep this safe — **do not bypass them by hand-building an ssh line elsewhere:**
|
||||
|
||||
- **Every** user-controlled value (`-i`, `-J`, `-o`, ProxyCommand) is POSIX
|
||||
single-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors
|
||||
the one in `tmux-manager.ts`.
|
||||
- **`~`/`$HOME` in `identityFile` is expanded at build time** (`expandIdentityPath`),
|
||||
*before* escaping — ssh does not expand `~` inside `-i`, and the escaped value
|
||||
never reaches a shell that would.
|
||||
- **The ProxyCommand is one shellescaped `-o KEY=VALUE` token**, so its spaces and
|
||||
the `%h`/`%p` placeholders reach ssh as a single argument. `%h %p` survive
|
||||
verbatim — **ssh** expands them to the real host/port, not the shell.
|
||||
- **Empty options ⇒ `['ssh', '-o BatchMode=yes']`** (+ `-p` only when set) —
|
||||
byte-identical to the historical behavior.
|
||||
|
||||
Token construction is unit-tested independently of any live connection (see
|
||||
`test/` for `buildSshConnectionArgs` / `buildRemoteTmuxCheckCommand` cases).
|
||||
|
||||
## Durable launch (COD-104)
|
||||
|
||||
`buildRemoteLaunchCommand({ mode, remote, sessionId })` in `tmux-manager.ts`
|
||||
builds the command that launches (or **reattaches** to) the remote session:
|
||||
|
||||
```
|
||||
ssh -o BatchMode=yes -t <connection-args> user@host \
|
||||
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
|
||||
set -t codeman-ssh-<id8> status off \; set -t codeman-ssh-<id8> mouse off \; \
|
||||
set -t codeman-ssh-<id8> prefix C-q \; set -s escape-time 0 \; \
|
||||
set -t codeman-ssh-<id8> window-size latest'
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- **`new-session -A -s codeman-ssh-<id8>`** = attach-if-exists-else-create, so a
|
||||
reconnect (same deterministic `remoteTmuxSessionName(sessionId)` — `codeman-ssh-` +
|
||||
the first 8 chars of the session id) lands back in
|
||||
the **same** remote session rather than spawning a duplicate. This is what makes
|
||||
the remote agent survive an SSH drop. The name deliberately fails
|
||||
`SAFE_MUX_NAME_PATTERN` so a Codeman running ON the remote host never adopts it.
|
||||
- **`-L codeman-remote`** = a DEDICATED socket for sessions launched by remote
|
||||
Codemans, NOT the canonical `-L codeman` socket the remote host's own Codeman
|
||||
uses. Options are set per-session (`set -t`), never `-g`, so a shared remote
|
||||
tmux server's other sessions are untouched (#145 hardening). Note the
|
||||
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
|
||||
socket** — they join sessions the remote's own Codeman manages, while owned
|
||||
durable launches live on `-L codeman-remote`.
|
||||
- **`exec <cli>`** replaces the pane shell with the agent, so the pane PID *is*
|
||||
the agent. The per-mode command comes from `remote.commands?.[mode]` or
|
||||
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
|
||||
`exec codex` / `exec gemini` / `exec bash -l`).
|
||||
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
|
||||
pane command is independently quoted, so a `remotePath` with spaces is safe.
|
||||
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
|
||||
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
|
||||
preserving historical token order.
|
||||
|
||||
### tmux prerequisite probe
|
||||
|
||||
Because durable remote sessions require tmux on the remote host,
|
||||
`checkRemoteTmuxAvailable(host)` runs `command -v tmux` over SSH **before**
|
||||
creating a remote case/session and returns a structured, never-throwing result:
|
||||
|
||||
- empty stdout / non-zero exit → *"remote host `<host>` needs tmux installed for
|
||||
durable remote sessions"*
|
||||
- stderr present → *"could not verify tmux on remote host `<host>`: `<stderr>`"*
|
||||
(a real connection failure, surfaced to the operator)
|
||||
- success → `{ ok: true, tmuxPath }`
|
||||
|
||||
It connects with the **identical** options as the launch
|
||||
(`buildRemoteTmuxCheckCommand` reuses `buildSshConnectionArgs` and inserts
|
||||
`-o ConnectTimeout=10`), so a proxied/custom-port/identity host that the launch
|
||||
can reach also passes the probe (and vice-versa).
|
||||
|
||||
**Test-mode short-circuit:** under `VITEST` the probe returns
|
||||
`{ ok: true, tmuxPath: '(test-mode)' }` without opening a socket — mirroring
|
||||
`TmuxManager`'s no-op-shell-under-VITEST (`IS_TEST_MODE`). Without it, remote-case
|
||||
create-path tests would hit a real ~10s ssh timeout. Only the live probe is
|
||||
skipped; command construction is still asserted by unit tests.
|
||||
|
||||
## Ownership: launched vs. discovered-and-attached (COD-105)
|
||||
|
||||
COD-104 (above) was Phase 1 — Codeman *launches* a remote session and owns it.
|
||||
COD-105 is Phase 2 — Codeman can also **discover** `codeman-*` tmux sessions
|
||||
already running on a remote host (created by the remote's own Codeman or another
|
||||
instance) and **attach** to one it didn't launch. Ownership decides what happens
|
||||
when the tab closes.
|
||||
|
||||
`SessionRemote.owned` carries this:
|
||||
|
||||
- **`owned: true`** (or absent — legacy/COD-104 sessions persisted before this
|
||||
field) — we launched it via `buildRemoteLaunchCommand` and may explicitly kill it.
|
||||
- **`owned: false`** — discovered + attached; another Codeman owns the remote
|
||||
session. `remoteSessionName` holds its existing tmux name. Closing the tab
|
||||
**detaches**, never kills.
|
||||
|
||||
### Discovery
|
||||
|
||||
`listRemoteCodemanSessions(host)` lists the remote's `codeman-*` sessions:
|
||||
|
||||
- `buildRemoteListSessionsCommand()` runs `tmux -L codeman list-sessions -F "…"`
|
||||
over SSH (connection args from the shared `buildSshConnectionArgs`, so discovery
|
||||
connects identically to launch/probe). `2>/dev/null` swallows tmux's "no server
|
||||
running" stderr.
|
||||
- `parseRemoteSessionList()` is a **pure, unit-tested** parser. ⚠️ Quirk: the
|
||||
remote tmux's `-F "…\t…"` format emits the **literal two-character `\t`**, not a
|
||||
real tab (verified on tmux next-3.7), so the parser splits on `/\\t|\t/` (literal
|
||||
backslash-t **or** a real tab, for builds that do expand it). It keeps only
|
||||
`codeman-*` names, coerces types, and skips malformed lines.
|
||||
- `listRemoteCodemanSessions()` **never throws** — unreachable host / no tmux / no
|
||||
sessions all map to `[]`. Like the prereq probe, it **no-ops to `[]` under
|
||||
`VITEST`** so a request path never opens a real ssh connection.
|
||||
|
||||
Discovery is **explicit** — the UI has a "Discover existing sessions" button per
|
||||
host; Codeman never auto-discovers on host select.
|
||||
|
||||
### Attach vs. launch selection
|
||||
|
||||
`buildRemoteSessionCommand(mode, remote, sessionId)` in `tmux-manager.ts` picks the
|
||||
remote command line by ownership:
|
||||
|
||||
- **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits
|
||||
`ssh … -t … 'tmux -L codeman attach -t <remoteSessionName>'`. It uses **`attach`,
|
||||
NOT `new-session -A`**, so it only *joins* an existing session and never creates
|
||||
one.
|
||||
- **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above).
|
||||
|
||||
### Detach-not-kill
|
||||
|
||||
`TmuxManager.killSession()` has an **early return for non-owned remote sessions**:
|
||||
it tears down **only the LOCAL pane** holding the ssh client (`tmux -L codeman
|
||||
kill-session` on *this* host's socket). Killing the local ssh sends SIGHUP to the
|
||||
remote `tmux attach`, which **detaches** — the durable remote session survives.
|
||||
The early return is a structural guarantee that **no code path can ever issue a
|
||||
remote `kill-session` for a session we don't own** — the only `kill-session` run is
|
||||
on the local socket, which never reaches the remote socket.
|
||||
|
||||
## API
|
||||
|
||||
Routes are registered in `src/web/routes/case-routes.ts`:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/remote-hosts` | List saved hosts |
|
||||
| `POST` | `/api/remote-hosts` | Create a host |
|
||||
| `PUT` | `/api/remote-hosts/:id` | Update a host |
|
||||
| `DELETE` | `/api/remote-hosts/:id` | Delete a host |
|
||||
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
|
||||
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
|
||||
|
||||
Attaching to a discovered session is a **session-create** path, not a host route:
|
||||
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`
|
||||
(schema in `schemas.ts`; `remoteSessionName` must match `^codeman-[a-zA-Z0-9._-]+$`),
|
||||
which `session-routes.ts` turns into a non-owned (`owned: false`) session.
|
||||
|
||||
Frontend touchpoints: the remote-host management UI is in `session-ui.js` /
|
||||
`panels-ui.js`; a remote session is created by picking a remote host/case in the
|
||||
session-create flow, or via the per-host **"Discover existing sessions"** button →
|
||||
**Attach** action (creates an `owned: false` session).
|
||||
|
||||
## Security notes
|
||||
|
||||
- **`identityFile` is a path only — never key bytes.** Codeman stores the path and
|
||||
passes it to `ssh -i`; the key never enters Codeman's state or the wire.
|
||||
- The injection surface is the SSH option fields. The single-source
|
||||
`buildSshConnectionArgs` + `shellescape` discipline (COD-107) is the control —
|
||||
audit any new code path that constructs an ssh command to route through it
|
||||
rather than concatenating options inline.
|
||||
- `BatchMode=yes` means **no interactive password/passphrase prompts** — remote
|
||||
hosts must be reachable with key-based or agent auth (or an unencrypted key the
|
||||
agent has loaded). A host needing a passphrase will fail the probe with an ssh
|
||||
diagnostic rather than hang.
|
||||
|
||||
## Related
|
||||
|
||||
- `CLAUDE.md` → Architecture → **Remote** row, and the **Remote sessions (SSH)**
|
||||
Key Pattern.
|
||||
- `docs/security-architecture.md` — overall network/auth model.
|
||||
- COD-104 (tmux prereq + durable launch), COD-105 (discover + attach, detach-not-kill ownership), COD-107 (shell-safe connection args).
|
||||
@@ -30,7 +30,8 @@ an explicit, guided opt‑in.
|
||||
7. [Supply‑chain & build‑asset hardening](#7-supplychain--buildasset-hardening-cod28)
|
||||
8. [Multi‑instance isolation](#8-multiinstance-isolation)
|
||||
9. [Transport security headers](#9-transport-security-headers)
|
||||
10. [Quick reference](#10-quick-reference)
|
||||
10. [Docker container isolation](#10-docker-container-isolation)
|
||||
11. [Quick reference](#11-quick-reference)
|
||||
|
||||
---
|
||||
|
||||
@@ -471,7 +472,35 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick reference
|
||||
## 10. Docker container isolation
|
||||
|
||||
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
|
||||
|
||||
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
|
||||
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini`, `~/.config/{gcloud,opencode}`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
|
||||
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
|
||||
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
|
||||
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
|
||||
- **Instance isolation** — every managed container is labeled `codeman.instance=<CODEMAN_INSTANCE>`; the boot reaper reaps orphans of its OWN instance only, so a beta never removes a prod container. The in‑container tmux socket (`-L codeman-docker`) + session name (`codeman-dkr-*`) deliberately fail a nested Codeman's discovery pattern.
|
||||
|
||||
Full feature guide: [`docker-cases.md`](docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## 10a. Multi‑user mode (opt‑in)
|
||||
|
||||
`codeman web --multiuser` (or `CODEMAN_MULTIUSER=1`) turns on named users with individually scrypt‑hashed passwords in `~/.codeman/users.json` (mode 0600). OFF by default; when off, nothing here applies and behavior is byte‑identical to single‑user. Design + phase status: [`multi-user-plan.md`](multi-user-plan.md).
|
||||
|
||||
- **It is workspace separation, NOT a security boundary between users.** Every session still runs as the SAME OS account with agent code that can read the whole host. Any user can ask their agent to `cat` another user's files; the WEB layer enforces scoping, the AGENT layer cannot. Mitigations: give non‑admins the default `auto` permission mode (classifier‑guarded), pair users with **Docker cases** (container per case) for real isolation, or run separate Codeman instances under separate OS accounts. Stated loudly in the admin panel and the plan's threat model (section 2).
|
||||
- **It strictly improves network posture.** It removes the single shared `CODEMAN_PASSWORD` and gives each person a revocable credential; a non‑loopback bind and the tunnel‑enable guard are satisfied by "multi‑user with ≥1 enabled user" without a shared password.
|
||||
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
|
||||
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
|
||||
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
|
||||
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|------------|--------|
|
||||
@@ -482,6 +511,8 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
| `--https` | Enable TLS (adds HSTS) |
|
||||
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
|
||||
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
|
||||
| `CODEMAN_DOCKER_BRIDGE_HOOKS=1` | Serve the hook endpoints on the docker bridge gateway (host‑internal, hooks‑only, `403` elsewhere) so in‑container hooks reach a loopback‑bound server — see §10 |
|
||||
| `CODEMAN_DOCKER_BRIDGE_HOST` | Override the bridge gateway IP the hooks listener binds (default: auto‑detect) |
|
||||
|
||||
**Audit log:** session lifecycle and server start are recorded in
|
||||
`~/.codeman/session-lifecycle.jsonl`.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* capture-readme-gifs.mjs
|
||||
*
|
||||
* Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the
|
||||
* mock-injection pipeline from capture-readme-screenshots.mjs (static file
|
||||
* server + page.route mocks), drives a scripted timeline in the page, records
|
||||
* it with Playwright video, and converts to GIF via ffmpeg palette encoding.
|
||||
*
|
||||
* Scenes:
|
||||
* 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent
|
||||
* windows open one by one and stream tool-call activity live (driven
|
||||
* through the real _onSubagentDiscovered/_onSubagentToolCall handlers).
|
||||
* 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag)
|
||||
* vs bursty ~350 ms server echo, rendered with the vendored xterm.
|
||||
*
|
||||
* Usage: node scripts/capture-readme-gifs.mjs
|
||||
* SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs
|
||||
* Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR)
|
||||
* Requires: ffmpeg
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { execSync } from 'child_process';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
PORT,
|
||||
SESSION_IDS,
|
||||
STANDARD_SESSIONS,
|
||||
buildInitPayload,
|
||||
startStaticServer,
|
||||
setupRoutes,
|
||||
injectState,
|
||||
outPath,
|
||||
RST, GRN, YEL, MAG, CYN, GRY, BOLD,
|
||||
} from './capture-readme-screenshots.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const GIF_COLORS = 192;
|
||||
|
||||
// ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ────────
|
||||
|
||||
function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) {
|
||||
// One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame
|
||||
// palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch
|
||||
// on flat dark UI, and error-diffusion dither shimmers between frames.
|
||||
const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`;
|
||||
execSync(
|
||||
`ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` +
|
||||
`-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` +
|
||||
`[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scene 1: subagent demo ──────────────────────────────────────────────────
|
||||
|
||||
const SUBAGENT_VIEWPORT = { width: 1440, height: 810 };
|
||||
|
||||
// Terminal content visible before the agents spawn
|
||||
const TERMINAL_PRESPAWN = [
|
||||
'',
|
||||
`${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`,
|
||||
'',
|
||||
`${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`,
|
||||
` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`,
|
||||
'',
|
||||
`${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`,
|
||||
` ${GRY}░${RST} ./src/index.ts`,
|
||||
` ${GRY}░${RST} ./src/session.ts`,
|
||||
` ${GRY}░${RST} ./src/web/server.ts`,
|
||||
` ${GRY}░${RST} ${GRY}... (17 more)${RST}`,
|
||||
'',
|
||||
`${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
function makeAgent(agentId, description, startedOffsetMs) {
|
||||
return {
|
||||
agentId,
|
||||
sessionId: 'claude-sess-w1-0001',
|
||||
projectHash: 'abc123',
|
||||
filePath: `/tmp/${agentId}.jsonl`,
|
||||
startedAt: new Date(Date.now() - startedOffsetMs).toISOString(),
|
||||
lastActivityAt: Date.now(),
|
||||
status: 'active',
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: 4000,
|
||||
description,
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
modelShort: 'haiku',
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
parentSessionId: SESSION_IDS.w1,
|
||||
};
|
||||
}
|
||||
|
||||
// Timeline events: t (ms from scene start) + kind
|
||||
// term — write raw data to the session terminal
|
||||
// discover — register subagent + open + position its floating window
|
||||
// tool — stream a tool call into an agent window
|
||||
// msg — stream an assistant message into an agent window
|
||||
// complete — flip an agent to completed
|
||||
function buildSubagentTimeline() {
|
||||
const T = (lines) => lines.join('\r\n') + '\r\n';
|
||||
const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input });
|
||||
const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text });
|
||||
|
||||
return [
|
||||
{
|
||||
t: 600,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
{
|
||||
t: 1000,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000),
|
||||
x: 440, y: 45,
|
||||
},
|
||||
tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }),
|
||||
{
|
||||
t: 2000,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }),
|
||||
{
|
||||
t: 2500,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200),
|
||||
x: 880, y: 45,
|
||||
},
|
||||
tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }),
|
||||
{
|
||||
t: 3300,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }),
|
||||
{
|
||||
t: 3800,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400),
|
||||
x: 660, y: 400,
|
||||
},
|
||||
tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }),
|
||||
{
|
||||
t: 4500,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }),
|
||||
tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }),
|
||||
tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }),
|
||||
tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }),
|
||||
msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'),
|
||||
tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }),
|
||||
msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'),
|
||||
tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }),
|
||||
msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'),
|
||||
{
|
||||
t: 9300,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`,
|
||||
`${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`,
|
||||
`${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }),
|
||||
tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }),
|
||||
tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }),
|
||||
{
|
||||
t: 11600,
|
||||
kind: 'term',
|
||||
data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame
|
||||
|
||||
async function recordSubagentScene(browser, videoDir) {
|
||||
console.log('\n1/2 Recording subagent-demo...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: SUBAGENT_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT },
|
||||
});
|
||||
const recStart = Date.now();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
// Start with NO subagents — they appear during the recording
|
||||
const initPayload = buildInitPayload(STANDARD_SESSIONS);
|
||||
await setupRoutes(page, initPayload, TERMINAL_PRESPAWN);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
try { window.app?.fitAddon?.fit(); } catch {}
|
||||
window.app?.terminal?.scrollToBottom();
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
const timeline = buildSubagentTimeline();
|
||||
const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD;
|
||||
const sceneStart = Date.now();
|
||||
|
||||
// Run the whole timeline inside the page so events interleave naturally
|
||||
await page.evaluate((events) => {
|
||||
const app = window.app;
|
||||
for (const ev of events) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (ev.kind === 'term') {
|
||||
app.terminal.write(ev.data);
|
||||
app.terminal.scrollToBottom();
|
||||
} else if (ev.kind === 'discover') {
|
||||
app._onSubagentDiscovered(ev.agent);
|
||||
app.openSubagentWindow(ev.agent.agentId);
|
||||
// The spawn animation (400ms) lands on the auto-grid; glide to our tile after it
|
||||
setTimeout(() => {
|
||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
||||
if (win?.element) {
|
||||
win.element.style.transition = 'left 0.25s ease, top 0.25s ease';
|
||||
win.element.style.left = `${ev.x}px`;
|
||||
win.element.style.top = `${ev.y}px`;
|
||||
}
|
||||
}, 520);
|
||||
setTimeout(() => {
|
||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
||||
if (win?.element) win.element.style.transition = '';
|
||||
app.updateConnectionLines();
|
||||
}, 850);
|
||||
} else if (ev.kind === 'tool') {
|
||||
app._onSubagentToolCall({
|
||||
agentId: ev.agentId,
|
||||
tool: ev.tool,
|
||||
input: ev.input,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else if (ev.kind === 'msg') {
|
||||
app._onSubagentMessage({
|
||||
agentId: ev.agentId,
|
||||
role: 'assistant',
|
||||
text: ev.text,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else if (ev.kind === 'complete') {
|
||||
app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('timeline event failed', ev, err);
|
||||
}
|
||||
}, ev.t);
|
||||
}
|
||||
}, timeline);
|
||||
|
||||
await sleep(totalMs + 500);
|
||||
|
||||
await page.close();
|
||||
const videoPath = await page.video().path();
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
ss: (sceneStart - recStart) / 1000 - 0.4,
|
||||
duration: (totalMs + 400) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Scene 2: zerolag typing comparison ──────────────────────────────────────
|
||||
|
||||
const ZEROLAG_VIEWPORT = { width: 1280, height: 470 };
|
||||
const TYPED_TEXT = 'echo "zero lag typing from anywhere"';
|
||||
const TYPE_INTERVAL_MS = 110;
|
||||
const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts
|
||||
const ZEROLAG_TAIL_HOLD = 1800;
|
||||
|
||||
const ZEROLAG_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="http://localhost:${PORT}/vendor/xterm.css">
|
||||
<script src="http://localhost:${PORT}/vendor/xterm.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 1280px; height: 470px; background: #0a0a0c;
|
||||
display: flex; align-items: center; justify-content: center; gap: 48px;
|
||||
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.pane { width: 560px; }
|
||||
.card {
|
||||
background: #131316; border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
||||
}
|
||||
.card-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; align-self: center; }
|
||||
.title { font-size: 15px; font-weight: 600; color: #e8e8ea; }
|
||||
.sub { font-size: 12.5px; color: #8b8b92; }
|
||||
.term { padding: 16px 8px 12px 16px; height: 165px; }
|
||||
.good .dot { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.7); }
|
||||
.bad .dot { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.7); }
|
||||
.tag {
|
||||
margin-top: 14px; text-align: center; font-size: 14.5px; color: #7e7e86;
|
||||
}
|
||||
.tag b { color: #22c55e; font-weight: 600; }
|
||||
.bad-tag b { color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pane">
|
||||
<div class="card good">
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<span class="title">With zerolag-input</span>
|
||||
<span class="sub">instant local echo</span>
|
||||
</div>
|
||||
<div class="term" id="termLeft"></div>
|
||||
</div>
|
||||
<div class="tag">keystrokes echo in <b>0 ms</b></div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="card bad">
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<span class="title">Without</span>
|
||||
<span class="sub">server round-trip echo</span>
|
||||
</div>
|
||||
<div class="term" id="termRight"></div>
|
||||
</div>
|
||||
<div class="tag bad-tag">keystrokes echo after <b>~350 ms</b></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
async function recordZerolagScene(browser, videoDir) {
|
||||
console.log('\n2/2 Recording zerolag-demo...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: ZEROLAG_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT },
|
||||
});
|
||||
const recStart = Date.now();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => typeof Terminal !== 'undefined');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const theme = {
|
||||
background: '#131316',
|
||||
foreground: '#e8e8ea',
|
||||
cursor: '#22c55e',
|
||||
cursorAccent: '#131316',
|
||||
};
|
||||
const mk = (id) => {
|
||||
const term = new Terminal({
|
||||
cols: 44,
|
||||
rows: 5,
|
||||
fontSize: 20,
|
||||
fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace",
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
theme,
|
||||
});
|
||||
term.open(document.getElementById(id));
|
||||
term.write('\x1b[32m❯\x1b[0m ');
|
||||
return term;
|
||||
};
|
||||
window.termLeft = mk('termLeft');
|
||||
window.termRight = mk('termRight');
|
||||
});
|
||||
await sleep(600);
|
||||
|
||||
const sceneStart = Date.now();
|
||||
const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS;
|
||||
const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD;
|
||||
|
||||
await page.evaluate(
|
||||
({ text, interval, flushEvery }) => {
|
||||
let i = 0;
|
||||
const remoteQueue = [];
|
||||
const typer = setInterval(() => {
|
||||
if (i >= text.length) { clearInterval(typer); return; }
|
||||
const ch = text[i++];
|
||||
window.termLeft.write(ch); // local echo: instant
|
||||
remoteQueue.push(ch); // server echo: waits for the round-trip
|
||||
}, interval);
|
||||
const flusher = setInterval(() => {
|
||||
if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join(''));
|
||||
if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher);
|
||||
}, flushEvery);
|
||||
},
|
||||
{ text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS }
|
||||
);
|
||||
|
||||
await sleep(totalMs + 400);
|
||||
|
||||
await page.close();
|
||||
const videoPath = await page.video().path();
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors
|
||||
duration: (totalMs + 600) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('Codeman README GIF Capture');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const server = await startStaticServer();
|
||||
const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-'));
|
||||
let browser;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
|
||||
const sub = await recordSubagentScene(browser, videoDir);
|
||||
const subGif = outPath('images', 'subagent-demo.gif');
|
||||
webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 });
|
||||
console.log(` Saved: ${subGif}`);
|
||||
|
||||
const zl = await recordZerolagScene(browser, videoDir);
|
||||
const zlGif = outPath('images', 'zerolag-demo.gif');
|
||||
webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 });
|
||||
console.log(` Saved: ${zlGif}`);
|
||||
|
||||
console.log('\nDone.');
|
||||
} catch (err) {
|
||||
console.error('\nFatal error:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
server.close();
|
||||
rmSync(videoDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => process.exit(1));
|
||||
|
||||
main();
|
||||
@@ -51,6 +51,9 @@ async function newCtx(browser) {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
localStorage.setItem('codeman-font-size', String(font));
|
||||
const blob = { skin, showFileBrowser: false, showProjectInsights: false };
|
||||
// Don't auto-hide subagent windows that belong to a non-active tab — the
|
||||
// subagent scene re-homes agents and needs both windows visible at once.
|
||||
blob.subagentActiveTabOnly = false;
|
||||
if (planUsage) blob.showPlanUsageLimits = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||
} catch {
|
||||
@@ -136,9 +139,9 @@ async function sceneSubagent(browser) {
|
||||
const sessions = await listSessions(page);
|
||||
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
|
||||
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
|
||||
// Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents.
|
||||
// Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents.
|
||||
let agents = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
for (let i = 0; i < 45; i++) {
|
||||
agents = await page.evaluate(() =>
|
||||
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
|
||||
);
|
||||
@@ -151,6 +154,44 @@ async function sceneSubagent(browser) {
|
||||
await context.close();
|
||||
return;
|
||||
}
|
||||
// The window body renders from app.subagentActivity, which fills ONLY from live
|
||||
// SSE tool-call/progress events — a fresh client never gets past activity replayed.
|
||||
// So sit connected and wait for live activity to accumulate, then open the two
|
||||
// agents that actually have content (otherwise the windows read "No activity yet").
|
||||
let active = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
active = await page.evaluate(() =>
|
||||
Array.from(window.app.subagentActivity?.entries?.() || [])
|
||||
.filter(([, arr]) => Array.isArray(arr) && arr.length >= 1)
|
||||
.map(([id, arr]) => ({ id, n: arr.length }))
|
||||
.sort((a, b) => b.n - a.n)
|
||||
);
|
||||
if (active.length >= 2) break;
|
||||
// xhigh-effort agents churn in bursts between long thinking pauses, so be
|
||||
// patient (~150s); accept a single populated window after ~45s if that's all.
|
||||
if (i >= 30 && active.length >= 1) break;
|
||||
await sleep(1500);
|
||||
}
|
||||
console.log(' agents with live activity:', JSON.stringify(active));
|
||||
const openIds = (active.length ? active : agents).map((a) => a.id);
|
||||
// Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the
|
||||
// Codeman id and never becomes the real Claude conversation UUID, so the window
|
||||
// open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both
|
||||
// fail. Re-home the chosen agents onto the active tab and align its claudeSessionId
|
||||
// to the agents' (shared) sessionId so the windows open AND show their live activity.
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
const activeId = window.app.activeSessionId;
|
||||
const tab = window.app.sessions.get(activeId);
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
const a = window.app.subagents.get(id);
|
||||
if (!a) return;
|
||||
a.parentSessionId = activeId;
|
||||
if (tab && a.sessionId) tab.claudeSessionId = a.sessionId;
|
||||
});
|
||||
},
|
||||
openIds
|
||||
);
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
@@ -159,22 +200,33 @@ async function sceneSubagent(browser) {
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
agents.map((a) => a.id)
|
||||
openIds
|
||||
);
|
||||
await sleep(2000);
|
||||
await page.evaluate(() => {
|
||||
// Viewport-relative tiling: center two subagent windows over the terminal so
|
||||
// the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650
|
||||
// recipe) instead of overflowing at narrower widths.
|
||||
const wins = Array.from(window.app.subagentWindows.values());
|
||||
const place = [
|
||||
{ left: 360, top: 60, w: 430, h: 330 },
|
||||
{ left: 810, top: 60, w: 430, h: 330 },
|
||||
];
|
||||
const W = window.innerWidth;
|
||||
const H = window.innerHeight;
|
||||
const winW = Math.min(440, Math.floor((W - 60) / 2 - 10));
|
||||
const winH = Math.min(360, Math.floor(H * 0.56));
|
||||
const top = Math.floor(H * 0.16);
|
||||
const gap = 16;
|
||||
const totalW = winW * 2 + gap;
|
||||
const startLeft = Math.max(16, Math.floor((W - totalW) / 2));
|
||||
wins.slice(0, 2).forEach((win, i) => {
|
||||
const el = win.element;
|
||||
const p = place[i];
|
||||
el.style.left = p.left + 'px';
|
||||
el.style.top = p.top + 'px';
|
||||
el.style.width = p.w + 'px';
|
||||
el.style.height = p.h + 'px';
|
||||
// Force visible: a freshly opened window may be hidden by the activeTabOnly
|
||||
// rule before we override it (we also seed subagentActiveTabOnly:false).
|
||||
win.hidden = false;
|
||||
win.minimized = false;
|
||||
el.style.display = 'flex';
|
||||
el.style.left = startLeft + i * (winW + gap) + 'px';
|
||||
el.style.top = top + 'px';
|
||||
el.style.width = winW + 'px';
|
||||
el.style.height = winH + 'px';
|
||||
});
|
||||
});
|
||||
await sleep(1500);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+166
@@ -584,7 +584,11 @@ program
|
||||
'--allow-unauthenticated-network',
|
||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||
)
|
||||
.option('--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)')
|
||||
.action(async (options) => {
|
||||
// The flag is surfaced to the rest of the process via the env var so
|
||||
// isMultiUserMode() has a single source of truth (see config/multiuser.ts).
|
||||
if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const host = options.host;
|
||||
const port = parseInt(options.port, 10);
|
||||
@@ -626,6 +630,168 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Multi-user Commands ============
|
||||
//
|
||||
// Operate directly on ~/.codeman/users.json (via user-store) with NO running
|
||||
// server, honoring CODEMAN_INSTANCE. This is the headless bootstrap path and the
|
||||
// recovery answer to "locked out: last admin forgot password".
|
||||
|
||||
/** Read a password from stdin without echoing. Falls back to plain read on non-TTY. */
|
||||
function promptHiddenPassword(question: string): Promise<string> {
|
||||
const stdin = process.stdin;
|
||||
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
||||
// Non-interactive: read a single line from stdin.
|
||||
return new Promise((resolve) => {
|
||||
let buf = '';
|
||||
stdin.setEncoding('utf8');
|
||||
stdin.on('data', (d) => (buf += d));
|
||||
stdin.on('end', () => resolve(buf.replace(/\r?\n$/, '')));
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(question);
|
||||
let input = '';
|
||||
stdin.setRawMode(true);
|
||||
stdin.resume();
|
||||
stdin.setEncoding('utf8');
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk) {
|
||||
if (c === '\n' || c === '\r' || c === '\u0004') {
|
||||
stdin.setRawMode!(false);
|
||||
stdin.pause();
|
||||
stdin.removeListener('data', onData);
|
||||
process.stdout.write('\n');
|
||||
resolve(input);
|
||||
return;
|
||||
} else if (c === '\u0003') {
|
||||
process.stdout.write('\n');
|
||||
process.exit(1);
|
||||
} else if (c === '\u007f' || c === '\b') {
|
||||
input = input.slice(0, -1);
|
||||
} else {
|
||||
input += c;
|
||||
}
|
||||
}
|
||||
};
|
||||
stdin.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
function readAllStdin(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let buf = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (d) => (buf += d));
|
||||
process.stdin.on('end', () => resolve(buf.replace(/\r?\n$/, '')));
|
||||
});
|
||||
}
|
||||
|
||||
const usersCmd = program.command('users').description('Manage multi-user accounts (~/.codeman/users.json)');
|
||||
|
||||
usersCmd
|
||||
.command('add <name>')
|
||||
.description('Create a user (prompts for password; use --password-stdin for scripts)')
|
||||
.option('--admin', 'Create as an admin')
|
||||
.option('--password-stdin', 'Read the password from stdin instead of prompting')
|
||||
.action(async (name, options) => {
|
||||
const { createUser, isValidUsername } = await import('./user-store.js');
|
||||
if (!isValidUsername(name)) {
|
||||
console.error(chalk.red('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])'));
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
let password: string;
|
||||
if (options.passwordStdin) {
|
||||
password = await readAllStdin();
|
||||
} else {
|
||||
password = await promptHiddenPassword('New password: ');
|
||||
const confirm = await promptHiddenPassword('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error(chalk.red('✗ Passwords do not match'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
console.error(chalk.red('✗ Password must be at least 8 characters'));
|
||||
process.exit(1);
|
||||
}
|
||||
const user = await createUser({ username: name, role: options.admin ? 'admin' : 'user', password });
|
||||
console.log(chalk.green(`✓ Created ${user.role} "${user.username}"`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
usersCmd
|
||||
.command('passwd <name>')
|
||||
.description('Reset a user password')
|
||||
.option('--password-stdin', 'Read the new password from stdin instead of prompting')
|
||||
.action(async (name, options) => {
|
||||
const { setPassword } = await import('./user-store.js');
|
||||
try {
|
||||
let password: string;
|
||||
if (options.passwordStdin) {
|
||||
password = await readAllStdin();
|
||||
} else {
|
||||
password = await promptHiddenPassword('New password: ');
|
||||
const confirm = await promptHiddenPassword('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error(chalk.red('✗ Passwords do not match'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
await setPassword(name, password, { mustChangePassword: false });
|
||||
console.log(chalk.green(`✓ Password updated for "${name}"`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
usersCmd
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List all users')
|
||||
.action(async () => {
|
||||
const { readUsers } = await import('./user-store.js');
|
||||
const users = await readUsers(true);
|
||||
if (users.length === 0) {
|
||||
console.log(chalk.yellow('No users defined (run: codeman users add <name> --admin)'));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold('\nUsers:'));
|
||||
for (const u of users) {
|
||||
const role = u.role === 'admin' ? chalk.magenta('admin') : chalk.cyan('user ');
|
||||
const state = u.disabled ? chalk.red('disabled') : chalk.green('enabled ');
|
||||
const flags = [u.mustChangePassword ? 'must-change-pw' : '', u.canBypassPermissions ? 'can-bypass' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
console.log(` ${role} ${state} ${u.username}${flags ? chalk.gray(` [${flags}]`) : ''}`);
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
|
||||
usersCmd
|
||||
.command('rm <name>')
|
||||
.description('Delete a user')
|
||||
.option('--delete-space', "Also delete the user's ~/codeman-users/<name> space")
|
||||
.action(async (name, options) => {
|
||||
const { deleteUser, deleteUserSpace } = await import('./user-store.js');
|
||||
try {
|
||||
await deleteUser(name);
|
||||
if (options.deleteSpace) {
|
||||
await deleteUserSpace(name);
|
||||
console.log(chalk.green(`✓ Deleted user "${name}" and their space`));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Deleted user "${name}" (space left on disk)`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.alias('check-deps')
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @fileoverview Multi-user mode gating + limits (opt-in, off by default).
|
||||
*
|
||||
* Multi-user mode is enabled by `codeman web --multiuser` (which sets
|
||||
* `CODEMAN_MULTIUSER=1`) or the env var directly. When OFF, behavior is
|
||||
* byte-identical to today: `users.json` is never read and all ownership scoping
|
||||
* is bypassed. Everything here is per-instance like the rest of Codeman: a beta
|
||||
* instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`,
|
||||
* and its user spaces live under the same shared `~/codeman-users` as prod (like
|
||||
* `~/codeman-cases`), unless `CODEMAN_USER_SPACES_DIR` overrides it.
|
||||
*
|
||||
* See `docs/multi-user-plan.md` sections 3, 4.2, and 11.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { MAX_CONCURRENT_SESSIONS } from './map-limits.js';
|
||||
|
||||
/**
|
||||
* Whether multi-user mode is active. Read from the environment each call so it is
|
||||
* stable for the process lifetime (env does not change after boot) and trivially
|
||||
* overridable in tests. Accepts `1` or `true`.
|
||||
*/
|
||||
export function isMultiUserMode(): boolean {
|
||||
const v = process.env.CODEMAN_MULTIUSER;
|
||||
return v === '1' || v === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Root of per-user spaces: `~/codeman-users` (sibling of `~/codeman-cases`).
|
||||
* Overridable via `CODEMAN_USER_SPACES_DIR` (used by tests). Resolved lazily so a
|
||||
* test can point it at a temp dir before the first call.
|
||||
*/
|
||||
export function getUserSpacesDir(): string {
|
||||
return process.env.CODEMAN_USER_SPACES_DIR || join(homedir(), 'codeman-users');
|
||||
}
|
||||
|
||||
/** Absolute path to a user's top-level space: `<USER_SPACES_DIR>/<username>[/segments]`. */
|
||||
export function userSpacePath(username: string, ...segments: string[]): string {
|
||||
return join(getUserSpacesDir(), username, ...segments);
|
||||
}
|
||||
|
||||
/** Absolute path to a user's cases dir: `<USER_SPACES_DIR>/<username>/cases`. */
|
||||
export function userCasesDir(username: string): string {
|
||||
return join(getUserSpacesDir(), username, 'cases');
|
||||
}
|
||||
|
||||
/** Maximum number of user accounts (default 25, env `CODEMAN_MAX_USERS`). */
|
||||
export function maxUsers(): number {
|
||||
const n = Number(process.env.CODEMAN_MAX_USERS);
|
||||
return Number.isInteger(n) && n > 0 ? n : 25;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user concurrent-session cap (the fairness lever). Defaults to half the
|
||||
* global cap; overridable via `CODEMAN_MAX_SESSIONS_PER_USER`. The global cap
|
||||
* (MAX_CONCURRENT_SESSIONS) still applies on top and is shared across users.
|
||||
*/
|
||||
export function maxSessionsPerUser(): number {
|
||||
const n = Number(process.env.CODEMAN_MAX_SESSIONS_PER_USER);
|
||||
if (Number.isInteger(n) && n > 0) return n;
|
||||
return Math.max(1, Math.floor(MAX_CONCURRENT_SESSIONS / 2));
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { SseEvent } from '../web/sse-events.js';
|
||||
import { CronJobSchema } from '../web/schemas.js';
|
||||
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
|
||||
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
|
||||
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
||||
import {
|
||||
DEFAULT_BLOCKED_TREES,
|
||||
@@ -25,6 +27,7 @@ import { validateSessionFilePath } from '../web/route-helpers.js';
|
||||
import { computeNextRunAt, dueKeyFor } from './cron-time.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js';
|
||||
import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js';
|
||||
import type { GeminiConfig } from '../types/session.js';
|
||||
import type { CronJobInput } from './cron-input.js';
|
||||
|
||||
/** The subset of the route context the cron depends on. */
|
||||
@@ -108,7 +111,7 @@ export class CronService {
|
||||
|
||||
// ──────────────────────────── Mutations ───────────────────────────
|
||||
|
||||
createJob(input: CronJobInput): CronJob {
|
||||
createJob(input: CronJobInput, owner?: string): CronJob {
|
||||
if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) {
|
||||
throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`);
|
||||
}
|
||||
@@ -117,6 +120,7 @@ export class CronService {
|
||||
const job: CronJob = {
|
||||
id: uuidv4(),
|
||||
name: input.name,
|
||||
owner,
|
||||
agentType: input.agentType,
|
||||
workingDir: input.workingDir,
|
||||
launchCommand: input.launchCommand,
|
||||
@@ -328,6 +332,12 @@ export class CronService {
|
||||
return this.failRun(job, run, 'workingDir does not exist');
|
||||
}
|
||||
|
||||
// Section 6.3: defense-in-depth workingDir confinement re-check at FIRE time against the
|
||||
// owner's CURRENT space (complements the create/update gate). No-op in single-user / unset owner.
|
||||
if (!(await isWorkingDirAllowedForUsername(job.owner, job.workingDir))) {
|
||||
return this.failRun(job, run, 'workingDir is outside the owner workspace');
|
||||
}
|
||||
|
||||
// Recurring jobs: close the still-open session created by this job's
|
||||
// previous run before launching the next (default ON, opt-out via
|
||||
// autoClosePreviousSession:false) — otherwise an unattended interval/daily
|
||||
@@ -336,10 +346,21 @@ export class CronService {
|
||||
await this.closePreviousRunSessions(job, run.id);
|
||||
}
|
||||
|
||||
// Respect the global session cap.
|
||||
if (this.deps.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
// Respect the global cap AND the owner's per-user cap (multi-user).
|
||||
const cap = sessionCapacityState(this.deps.sessions, job.owner);
|
||||
if (cap.atGlobalCap) {
|
||||
return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
|
||||
}
|
||||
if (cap.atUserCap) {
|
||||
return this.failRun(job, run, `Owner's per-user session limit reached`);
|
||||
}
|
||||
|
||||
// Section 6.3: re-resolve the owner's grant at FIRE time (it may have been revoked
|
||||
// since create). Gates shell/launchCommand AND clamps the external-CLI bypass below.
|
||||
const ownerGranted = await canUsernameRunPrivilegedCommands(job.owner);
|
||||
if ((job.agentType === 'shell' || job.launchCommand) && !ownerGranted) {
|
||||
return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs');
|
||||
}
|
||||
|
||||
// Create + start the session (mirrors the quick-start route flow).
|
||||
let session: Session;
|
||||
@@ -348,7 +369,15 @@ export class CronService {
|
||||
const globalNice = await this.deps.getGlobalNiceConfig();
|
||||
const modelConfig = await this.deps.getModelConfig();
|
||||
const claudeModeConfig = await this.deps.getClaudeModeConfig();
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
|
||||
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
|
||||
// Section 6.3: cron carries no per-CLI config, so buildGeminiCommand(undefined)
|
||||
// would default a non-granted owner to `--approval-mode yolo` (classifier-free) —
|
||||
// materialize auto_edit for a non-granted gemini owner, mirroring the route clamp
|
||||
// (#15). Granted/admin/single-user leave it undefined → yolo parity. Codex's absent
|
||||
// config already defaults to the safe sandbox, so no clamp is needed there.
|
||||
const geminiConfig: GeminiConfig | undefined =
|
||||
mode === 'gemini' && !ownerGranted ? { approvalMode: 'auto_edit' } : undefined;
|
||||
session = new Session({
|
||||
workingDir: job.workingDir,
|
||||
mode,
|
||||
@@ -357,8 +386,10 @@ export class CronService {
|
||||
useMux: true,
|
||||
niceConfig: globalNice,
|
||||
model,
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
claudeMode: effectiveClaudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
geminiConfig,
|
||||
owner: job.owner,
|
||||
});
|
||||
this.deps.addSession(session);
|
||||
this.store.incrementSessionsCreated();
|
||||
|
||||
+57
-8
@@ -105,6 +105,45 @@ export function parseLoadedImageRef(loadOutput: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an imported bundle's manifest BEFORE any of its fields are trusted.
|
||||
* A bundle is cross-machine input (potentially authored by someone else), and its
|
||||
* fields flow into stored host/case config that the schema layer never sees:
|
||||
* `engine` becomes the probe/launch binary selector, `image`/`containerWorkdir`
|
||||
* reach the shellescaped launch string, `network` is a create arg. Mirror the
|
||||
* DockerHostSchema/DockerCaseLinkSchema constraints here (throwing, since this is
|
||||
* not a web-layer module). Exported for unit tests.
|
||||
*/
|
||||
export function validateImportManifest(manifest: DockerExportManifest): void {
|
||||
const fail = (msg: string): never => {
|
||||
throw new Error(`invalid bundle manifest: ${msg}`);
|
||||
};
|
||||
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
|
||||
fail(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
|
||||
}
|
||||
if (manifest.mode !== 'full' && manifest.mode !== 'workspace') fail(`unknown mode ${String(manifest.mode)}`);
|
||||
if (manifest.engine !== 'docker' && manifest.engine !== 'podman') fail(`unknown engine ${String(manifest.engine)}`);
|
||||
if (typeof manifest.caseName !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(manifest.caseName)) fail('bad caseName');
|
||||
if (
|
||||
typeof manifest.image !== 'string' ||
|
||||
manifest.image.length > 512 ||
|
||||
!/^[a-zA-Z0-9][\w./:@-]*$/.test(manifest.image)
|
||||
) {
|
||||
fail('bad image reference');
|
||||
}
|
||||
if (
|
||||
typeof manifest.containerWorkdir !== 'string' ||
|
||||
manifest.containerWorkdir.length > 2000 ||
|
||||
!manifest.containerWorkdir.startsWith('/') ||
|
||||
// comma: --mount specs are comma-delimited CSV; shell escaping cannot protect it
|
||||
/[`$\\"'\n\r;&|<>,]/.test(manifest.containerWorkdir)
|
||||
) {
|
||||
fail('bad containerWorkdir');
|
||||
}
|
||||
if (!['bridge', 'none', 'custom'].includes(manifest.network)) fail(`unknown network ${String(manifest.network)}`);
|
||||
if (typeof manifest.checksums !== 'object' || manifest.checksums === null) fail('missing checksums');
|
||||
}
|
||||
|
||||
// ========== IO helpers ==========
|
||||
|
||||
function run(
|
||||
@@ -338,25 +377,34 @@ export async function importDockerBundle(params: {
|
||||
destWorkspace: string;
|
||||
engine: DockerEngine;
|
||||
timestamp: number;
|
||||
/** Schema-validated destination case name; the quarantine tag derives from THIS,
|
||||
* never from the (attacker-authored) manifest.caseName. */
|
||||
newCaseName: string;
|
||||
}): Promise<ImportResult> {
|
||||
const { bundlePath, destWorkspace, engine, timestamp } = params;
|
||||
const { bundlePath, destWorkspace, engine, timestamp, newCaseName } = params;
|
||||
const argv: string[] = [engine === 'podman' ? 'podman' : 'docker'];
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}');
|
||||
return { manifest: JSON.parse(raw) as DockerExportManifest, workspacePath: destWorkspace };
|
||||
const manifest = JSON.parse(raw) as DockerExportManifest;
|
||||
validateImportManifest(manifest);
|
||||
return { manifest, workspacePath: destWorkspace };
|
||||
}
|
||||
|
||||
const stageDir = `${destWorkspace}.import-stage-${timestamp}`;
|
||||
mkdirSync(stageDir, { recursive: true });
|
||||
try {
|
||||
await run('tar', ['-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
|
||||
// Outer-bundle traversal guard (defense in depth: GNU/bsd tar already refuse
|
||||
// `..`/absolute members by default, but the bundle is cross-machine input).
|
||||
const { stdout: bundleMembers } = await run('tar', ['-tzf', bundlePath], { timeout: 60_000 });
|
||||
for (const member of bundleMembers.split('\n').filter(Boolean)) {
|
||||
if (!isSafeTarMember(member)) throw new Error(`unsafe path in bundle archive: ${member}`);
|
||||
}
|
||||
await run('tar', ['--no-same-owner', '-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
|
||||
|
||||
const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8');
|
||||
const manifest = JSON.parse(manifestRaw) as DockerExportManifest;
|
||||
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
|
||||
throw new Error(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
|
||||
}
|
||||
validateImportManifest(manifest);
|
||||
|
||||
// Integrity: verify checksums before trusting any member.
|
||||
const workspaceTar = join(stageDir, 'workspace.tar');
|
||||
@@ -385,8 +433,9 @@ export async function importDockerBundle(params: {
|
||||
const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 });
|
||||
const loadedRef = parseLoadedImageRef(stdout);
|
||||
if (!loadedRef) throw new Error('could not determine loaded image ref');
|
||||
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original tag.
|
||||
importedImage = importedImageTag(manifest.caseName, timestamp);
|
||||
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original
|
||||
// tag; the tag name derives from the caller's schema-validated newCaseName.
|
||||
importedImage = importedImageTag(newCaseName, timestamp);
|
||||
await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 });
|
||||
}
|
||||
|
||||
|
||||
+436
-34
@@ -21,13 +21,15 @@
|
||||
* @module docker-hosts
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import type {
|
||||
DockerCase,
|
||||
DockerCommandMode,
|
||||
@@ -107,6 +109,24 @@ export async function writeDockerCases(configDir: string, cases: DockerCase[]):
|
||||
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the case's last Claude conversation id (the `--resume` seed for the
|
||||
* container-recreated relaunch, docs/docker-cases-plan.md two-layer durability).
|
||||
* Keyed by container name so callers that only hold a SessionDocker can update it.
|
||||
* No-op when the id is unchanged or the case is gone.
|
||||
*/
|
||||
export async function persistDockerCaseClaudeSessionId(
|
||||
configDir: string,
|
||||
containerName: string,
|
||||
claudeSessionId: string
|
||||
): Promise<void> {
|
||||
const cases = await readDockerCases(configDir);
|
||||
const idx = cases.findIndex((c) => (c.container ?? dockerContainerName(c.name)) === containerName);
|
||||
if (idx === -1 || cases[idx].lastClaudeSessionId === claudeSessionId) return;
|
||||
cases[idx] = { ...cases[idx], lastClaudeSessionId: claudeSessionId };
|
||||
await writeDockerCases(configDir, cases);
|
||||
}
|
||||
|
||||
// ========== Naming / display / defaults ==========
|
||||
|
||||
/** Per-case container name. Mirrors how remote derives a stable name from the case. */
|
||||
@@ -388,37 +408,307 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE argv for building the agent base image locally (the programmatic mirror of
|
||||
* scripts/build-agent-image.mjs): `build -f <dockerfile> -t <image> [--no-cache]
|
||||
* <contextDir>`. Kept pure + unit-testable; the caller prepends the engine binary.
|
||||
*/
|
||||
export function agentImageBuildArgs(dockerfile: string, image: string, contextDir: string, noCache = false): string[] {
|
||||
return ['build', '-f', dockerfile, '-t', image, ...(noCache ? ['--no-cache'] : []), contextDir];
|
||||
}
|
||||
|
||||
// ========== Credential mount resolution (IO) ==========
|
||||
|
||||
/** Host cred paths mapped to their in-container HOME location. */
|
||||
const CREDENTIAL_PATHS: Array<{ rel: string }> = [
|
||||
{ rel: '.claude' },
|
||||
{ rel: '.claude.json' },
|
||||
{ rel: '.codex' },
|
||||
{ rel: '.gemini' },
|
||||
{ rel: '.config/gcloud' },
|
||||
{ rel: '.config/opencode' },
|
||||
/** Container Claude config dir (created gid-0 writable in the image). */
|
||||
export const CONTAINER_CLAUDE_DIR = `${CONTAINER_HOME}/.claude`;
|
||||
/** In-container path of the seeded (writable) `~/.claude.json`. */
|
||||
export const CLAUDE_JSON_HOME = `${CONTAINER_HOME}/.claude.json`;
|
||||
/** In-container path of the read-only host-seeded `~/.claude.json` (copied into HOME at launch). */
|
||||
export const CLAUDE_JSON_SEED = `${CONTAINER_HOME}/.codeman/claude.seed.json`;
|
||||
/** Read-only seed paths for the files copied into the container's `.claude`. */
|
||||
const CLAUDE_CREDS_SEED = `${CONTAINER_HOME}/.codeman/claude-creds.seed.json`;
|
||||
const CLAUDE_SETTINGS_SEED = `${CONTAINER_HOME}/.codeman/claude-settings.seed.json`;
|
||||
const CLAUDE_STATS_SEED = `${CONTAINER_HOME}/.codeman/claude-stats.seed.json`;
|
||||
/** Staging root for read-only host-cred seed mounts (codex/gemini/gcloud/opencode). */
|
||||
const CRED_SEED_DIR = `${CONTAINER_HOME}/.codeman/cred-seeds`;
|
||||
|
||||
/**
|
||||
* PURE: merge the host `~/.claude.json` into a config that makes an
|
||||
* already-authenticated Claude skip its INTERACTIVE onboarding inside the container
|
||||
* (the host file itself lacks these flags — the host install is grandfathered, so a
|
||||
* verbatim copy still triggers the theme picker + login wizard + folder-trust
|
||||
* prompt). Forces `hasCompletedOnboarding`, a `theme` (so the theme picker is
|
||||
* skipped), and marks the workspace project trusted + onboarded. Auth still comes
|
||||
* from the copied `oauthAccount` + the dir-mounted `~/.claude/.credentials.json`.
|
||||
*/
|
||||
export function buildSeamlessClaudeConfig(
|
||||
hostConfig: Record<string, unknown>,
|
||||
workspacePath: string,
|
||||
theme = 'dark'
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = { ...hostConfig };
|
||||
merged.hasCompletedOnboarding = true;
|
||||
if (typeof merged.theme !== 'string') merged.theme = theme;
|
||||
const projects = { ...((merged.projects as Record<string, Record<string, unknown>> | undefined) ?? {}) };
|
||||
const existing = (projects[workspacePath] as Record<string, unknown> | undefined) ?? {};
|
||||
const seenCount = existing.projectOnboardingSeenCount;
|
||||
projects[workspacePath] = {
|
||||
...existing,
|
||||
hasTrustDialogAccepted: true,
|
||||
hasCompletedProjectOnboarding: true,
|
||||
projectOnboardingSeenCount: typeof seenCount === 'number' && seenCount > 0 ? seenCount : 1,
|
||||
};
|
||||
merged.projects = projects;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Best-effort read of the host `~/.claude/settings.json` theme (drives the seed's theme). */
|
||||
function readHostClaudeTheme(home: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf-8')) as { theme?: unknown };
|
||||
return typeof parsed.theme === 'string' ? parsed.theme : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the read-only seed mount for `~/.claude.json`. Reads the host file, merges
|
||||
* in the seamless-onboarding flags + workspace trust (buildSeamlessClaudeConfig),
|
||||
* writes the result to a per-container seed file under `~/.codeman/docker-seeds/`,
|
||||
* and returns its mount. The launch chain copies it to `~/.claude.json` inside HOME
|
||||
* once — giving Claude a NORMAL writable, already-onboarded config (no atomic-rename
|
||||
* EBUSY, no re-auth, no theme/trust prompts). Falls back to the RAW host file when
|
||||
* parse/write fails (auth still works; the wizard may show). Returns null when the
|
||||
* host has no `~/.claude.json`. IO; under VITEST returns the raw mount (no write).
|
||||
*/
|
||||
export function resolveClaudeJsonSeedMount(
|
||||
home: string = homedir(),
|
||||
containerName?: string,
|
||||
workspacePath?: string
|
||||
): DockerMount | null {
|
||||
const src = join(home, '.claude.json');
|
||||
if (!existsSync(src)) return null;
|
||||
const rawMount: DockerMount = { src, dst: CLAUDE_JSON_SEED, readonly: true };
|
||||
if (IS_TEST_MODE || !containerName || !workspacePath) return rawMount;
|
||||
try {
|
||||
const hostConfig = JSON.parse(readFileSync(src, 'utf-8')) as Record<string, unknown>;
|
||||
const merged = buildSeamlessClaudeConfig(hostConfig, workspacePath, readHostClaudeTheme(home) ?? 'dark');
|
||||
const seedsDir = dataPath('docker-seeds');
|
||||
if (!existsSync(seedsDir)) mkdirSync(seedsDir, { recursive: true });
|
||||
const seedFile = join(seedsDir, `${containerName}.json`);
|
||||
writeFileSync(seedFile, JSON.stringify(merged), { mode: 0o600 });
|
||||
return { src: seedFile, dst: CLAUDE_JSON_SEED, readonly: true };
|
||||
} catch {
|
||||
return rawMount; // partial host write / unreadable — auth still carries, wizard may show
|
||||
}
|
||||
}
|
||||
|
||||
/** A file (or dir, when `recursive`) copied into the container HOME once at launch
|
||||
* (`[ -e to ] || cp [-a] from to`). */
|
||||
export interface DockerSeedCopy {
|
||||
from: string;
|
||||
to: string;
|
||||
/** `cp -a` for whole-directory credential seeds (gemini/gcloud/opencode). */
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerClaudeArtifacts {
|
||||
/** Bind mounts to add: the shared `projects/` transcripts (RW) + read-only seed files. */
|
||||
mounts: DockerMount[];
|
||||
/** Files copied into the container's writable HOME/.claude (+ HOME/.claude.json) at launch. */
|
||||
seedCopies: DockerSeedCopy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ISOLATED Claude artifacts for a docker session (replaces the old
|
||||
* whole-`~/.claude` RW mount that polluted the host). Shares ONLY what must cross
|
||||
* the boundary and seeds the rest as writable copies:
|
||||
* - `~/.claude/projects` → RW dir mount (transcripts: host watchers + `--resume`).
|
||||
* - `~/.claude.json` → merged onboarding seed, copied to HOME (no re-auth/wizard).
|
||||
* - `~/.claude/.credentials.json` + `~/.claude/settings.json` → read-only seeds
|
||||
* copied into the container's own `~/.claude` (token + global prefs carry in;
|
||||
* the container refreshes its own copy and never writes back to the host).
|
||||
* Everything else Claude writes (backups, tasks, teams, session-env, history) stays
|
||||
* container-local. IO (reads host files, writes the merged `.claude.json` seed).
|
||||
*/
|
||||
export function resolveDockerClaudeArtifacts(
|
||||
home: string,
|
||||
containerName: string,
|
||||
workspacePath: string
|
||||
): DockerClaudeArtifacts {
|
||||
const mounts: DockerMount[] = [];
|
||||
const seedCopies: DockerSeedCopy[] = [];
|
||||
|
||||
// The ONE genuinely-shared part: conversation transcripts (dir mount → renames work).
|
||||
const projectsSrc = join(home, '.claude', 'projects');
|
||||
if (existsSync(projectsSrc)) {
|
||||
mounts.push({ src: projectsSrc, dst: `${CONTAINER_CLAUDE_DIR}/projects` });
|
||||
}
|
||||
|
||||
// ~/.claude.json → merged, onboarding-complete seed at HOME root.
|
||||
const jsonSeed = resolveClaudeJsonSeedMount(home, containerName, workspacePath);
|
||||
if (jsonSeed) {
|
||||
mounts.push(jsonSeed);
|
||||
seedCopies.push({ from: CLAUDE_JSON_SEED, to: CLAUDE_JSON_HOME });
|
||||
}
|
||||
|
||||
// credentials (token) + settings (theme/model/effort/permissions) + stats-cache
|
||||
// (drives the model/effort status indicator) → writable copies inside the
|
||||
// container's own ~/.claude (never a wholesale mount → no host pollution).
|
||||
const files: Array<[rel: string, seed: string, dest: string]> = [
|
||||
['.credentials.json', CLAUDE_CREDS_SEED, `${CONTAINER_CLAUDE_DIR}/.credentials.json`],
|
||||
['settings.json', CLAUDE_SETTINGS_SEED, `${CONTAINER_CLAUDE_DIR}/settings.json`],
|
||||
['stats-cache.json', CLAUDE_STATS_SEED, `${CONTAINER_CLAUDE_DIR}/stats-cache.json`],
|
||||
];
|
||||
for (const [rel, seed, dest] of files) {
|
||||
const src = join(home, '.claude', rel);
|
||||
if (existsSync(src)) {
|
||||
mounts.push({ src, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: dest });
|
||||
}
|
||||
}
|
||||
|
||||
return { mounts, seedCopies };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-CLI credential-store isolation policy (the codex/gemini/gcloud/opencode analog
|
||||
* of resolveDockerClaudeArtifacts). Codex is the direct Claude-analog: its
|
||||
* `sessions/` rollouts + `history.jsonl` are read HOST-SIDE (response-viewer +
|
||||
* `codex resume`), so they are SHARED (RW), while `auth.json`/`config.toml` are
|
||||
* seeded. The other three have no host-read/resume dependency and are fully
|
||||
* seed-copied (writable copy in the container, no write-back to the host).
|
||||
*/
|
||||
interface CredStorePolicy {
|
||||
/** Path relative to HOME (host + container), e.g. '.codex' or '.config/gcloud'. */
|
||||
rel: string;
|
||||
/** Subdirs bind-mounted RW (shared: resume + host reads). */
|
||||
shareDirs?: string[];
|
||||
/** Files bind-mounted RW (append-only, e.g. codex history.jsonl — never renamed). */
|
||||
shareFiles?: string[];
|
||||
/** Files seeded (RO mount → cp) into the container's own copy. */
|
||||
seedFiles?: string[];
|
||||
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
|
||||
seedWhole?: boolean;
|
||||
}
|
||||
|
||||
const CRED_STORES: CredStorePolicy[] = [
|
||||
{ rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'] },
|
||||
{ rel: '.gemini', seedWhole: true },
|
||||
{ rel: '.config/gcloud', seedWhole: true },
|
||||
{ rel: '.config/opencode', seedWhole: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve which host credential dirs/files EXIST and map them to their container
|
||||
* HOME location. Only-existing avoids docker auto-creating root-owned empty dirs
|
||||
* in the user's home. `~/.claude` also carries the transcripts (bind-mounted so
|
||||
* host watchers + `--resume` see them) and is therefore mounted read-WRITE.
|
||||
* Resolve the ISOLATED codex/gemini/gcloud/opencode artifacts (replaces the old
|
||||
* whole-dir RW mounts that let each in-container CLI write its refreshed tokens +
|
||||
* session state back into the host). Every path is existsSync-gated (on most hosts
|
||||
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
|
||||
*/
|
||||
export function resolveCredentialMounts(home: string = homedir()): DockerMount[] {
|
||||
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
|
||||
const mounts: DockerMount[] = [];
|
||||
for (const { rel } of CREDENTIAL_PATHS) {
|
||||
const src = join(home, rel);
|
||||
if (existsSync(src)) {
|
||||
mounts.push({ src, dst: `${CONTAINER_HOME}/${rel}` });
|
||||
const seedCopies: DockerSeedCopy[] = [];
|
||||
for (const store of CRED_STORES) {
|
||||
const hostBase = join(home, store.rel);
|
||||
if (!existsSync(hostBase)) continue;
|
||||
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
|
||||
const seedName = store.rel.replace(/\//g, '-'); // '.config/gcloud' → '.config-gcloud'
|
||||
if (store.seedWhole) {
|
||||
const seed = `${CRED_SEED_DIR}/${seedName}`;
|
||||
mounts.push({ src: hostBase, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: containerBase, recursive: true });
|
||||
continue;
|
||||
}
|
||||
for (const sub of store.shareDirs ?? []) {
|
||||
const src = join(hostBase, sub);
|
||||
if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${sub}` });
|
||||
}
|
||||
for (const file of store.shareFiles ?? []) {
|
||||
const src = join(hostBase, file);
|
||||
if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${file}` });
|
||||
}
|
||||
for (const file of store.seedFiles ?? []) {
|
||||
const src = join(hostBase, file);
|
||||
if (existsSync(src)) {
|
||||
const seed = `${CRED_SEED_DIR}/${seedName}-${file}`;
|
||||
mounts.push({ src, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: `${containerBase}/${file}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return mounts;
|
||||
return { mounts, seedCopies };
|
||||
}
|
||||
|
||||
// ========== Daemon probes (IO; no-op under VITEST) ==========
|
||||
|
||||
/**
|
||||
* UNESCAPED argv prefix for execFile-based probes. The shellescaped
|
||||
* buildDockerBaseArgs variant is for interpolation into the `bash -c` launch
|
||||
* string; argv arrays must NOT carry literal quotes (mirror of docker-export's
|
||||
* dockerArgv).
|
||||
*/
|
||||
function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
||||
if (docker.context) argv.push('--context', docker.context);
|
||||
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
|
||||
return argv;
|
||||
}
|
||||
|
||||
export interface DockerDriftStatus {
|
||||
/** Container exists (daemon reachable AND a container with this name is present). */
|
||||
exists: boolean;
|
||||
running: boolean;
|
||||
/** The desired configHash no longer matches the container's codeman.confighash label. */
|
||||
drifted: boolean;
|
||||
currentHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift check (docs/docker-cases-plan.md §4): compare the DESIRED configHash
|
||||
* against the existing container's `codeman.confighash` label so docker-host
|
||||
* config edits actually take effect instead of being silently ignored by the
|
||||
* idempotent inspect-or-create launch chain. `exists:false` (no container /
|
||||
* daemon down) means there is nothing to drift. No-op under VITEST.
|
||||
*/
|
||||
export async function checkDockerConfigDrift(
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash'>
|
||||
): Promise<DockerDriftStatus> {
|
||||
if (IS_TEST_MODE) return { exists: false, running: false, drifted: false };
|
||||
const argv = dockerEngineArgv(docker);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
argv[0],
|
||||
[
|
||||
...argv.slice(1),
|
||||
'inspect',
|
||||
'-f',
|
||||
'{{.State.Running}}\t{{index .Config.Labels "codeman.confighash"}}',
|
||||
docker.containerName,
|
||||
],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
const [running = '', hash = ''] = stdout.trim().split('\t');
|
||||
return { exists: true, running: running === 'true', drifted: hash !== docker.configHash, currentHash: hash };
|
||||
} catch {
|
||||
return { exists: false, running: false, drifted: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `docker rm -f` the case container (the recreate-on-drift confirm action; the
|
||||
* launch chain recreates it with the new config on next start). Workspace +
|
||||
* transcripts ride bind mounts and survive; the conversation resumes via the
|
||||
* case's lastClaudeSessionId. No-op under VITEST.
|
||||
*/
|
||||
export async function removeDockerContainer(
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>
|
||||
): Promise<void> {
|
||||
if (IS_TEST_MODE) return;
|
||||
const argv = dockerEngineArgv(docker);
|
||||
await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export interface DockerAvailability {
|
||||
ok: boolean;
|
||||
engine: DockerEngine;
|
||||
@@ -497,11 +787,16 @@ export async function checkDockerAvailable(engine?: DockerEngine): Promise<Docke
|
||||
};
|
||||
}
|
||||
|
||||
/** Is the base image present locally? (never triggers an auto-pull). */
|
||||
export async function checkDockerImagePresent(engine: DockerEngine, image: string): Promise<boolean> {
|
||||
/** Is the base image present on the host's daemon? (never triggers an auto-pull).
|
||||
* Honors context/daemonHost so a remote-daemon host is probed on the RIGHT daemon. */
|
||||
export async function checkDockerImagePresent(
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
|
||||
image: string
|
||||
): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
const argv = dockerEngineArgv(docker);
|
||||
try {
|
||||
await execFileAsync(engine, ['image', 'inspect', '--format', '{{.Id}}', image], {
|
||||
await execFileAsync(argv[0], [...argv.slice(1), 'image', 'inspect', '--format', '{{.Id}}', image], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
return true;
|
||||
@@ -510,6 +805,108 @@ export async function checkDockerImagePresent(engine: DockerEngine, image: strin
|
||||
}
|
||||
}
|
||||
|
||||
export interface EnsureImageResult {
|
||||
ok: boolean;
|
||||
/** true when this call actually ran a build (vs. the image already existing). */
|
||||
built: boolean;
|
||||
alreadyPresent: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** In-flight builds keyed by `engine:image`, so concurrent callers share ONE build. */
|
||||
const inFlightImageBuilds = new Map<string, Promise<EnsureImageResult>>();
|
||||
|
||||
/**
|
||||
* Resolve the repo's Dockerfile + build context. Works from BOTH src (dev/tsx) and
|
||||
* dist/index.js (esbuild prod: dist sits at repo root), since both are one level
|
||||
* under the repo root. Returns null when the Dockerfile is absent (npm-global
|
||||
* installs don't ship docker/ — Docker cases are a git-clone feature).
|
||||
*/
|
||||
function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } | null {
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dockerfile = join(repoRoot, 'docker', 'agent.Dockerfile');
|
||||
return existsSync(dockerfile) ? { dockerfile, contextDir: repoRoot } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the agent base image exists, BUILDING it locally on first use so a missing
|
||||
* image is never a hard blocker (decision: "build locally on first use",
|
||||
* docs/docker-cases-plan.md). Idempotent, concurrency-safe (one build per
|
||||
* engine:image shared by concurrent callers), and a no-op under VITEST. Only the
|
||||
* DEFAULT image is auto-built — we can never build a user's custom ref, and the
|
||||
* `--pull=never` invariant forbids pulling. `onProgress` receives build output
|
||||
* lines for SSE surfacing.
|
||||
*/
|
||||
export async function ensureAgentBaseImage(
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
|
||||
image: string,
|
||||
opts: { onProgress?: (line: string) => void; noCache?: boolean } = {}
|
||||
): Promise<EnsureImageResult> {
|
||||
if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true };
|
||||
if (await checkDockerImagePresent(docker, image)) {
|
||||
return { ok: true, built: false, alreadyPresent: true };
|
||||
}
|
||||
if (image !== DEFAULT_AGENT_IMAGE) {
|
||||
return {
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`,
|
||||
};
|
||||
}
|
||||
const key = `${docker.engine}:${image}`;
|
||||
const existing = inFlightImageBuilds.get(key);
|
||||
if (existing) return existing;
|
||||
const build = buildAgentImage(docker, image, opts).finally(() => inFlightImageBuilds.delete(key));
|
||||
inFlightImageBuilds.set(key, build);
|
||||
return build;
|
||||
}
|
||||
|
||||
function buildAgentImage(
|
||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
|
||||
image: string,
|
||||
opts: { onProgress?: (line: string) => void; noCache?: boolean }
|
||||
): Promise<EnsureImageResult> {
|
||||
const resolved = resolveAgentDockerfile();
|
||||
if (!resolved) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
|
||||
});
|
||||
}
|
||||
const argv = dockerEngineArgv(docker);
|
||||
const args = [
|
||||
...argv.slice(1),
|
||||
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache),
|
||||
];
|
||||
return new Promise<EnsureImageResult>((resolve) => {
|
||||
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
||||
const child = spawn(argv[0], args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const forward = (buf: Buffer) => {
|
||||
for (const line of buf.toString('utf-8').split('\n')) {
|
||||
const trimmed = line.trimEnd();
|
||||
if (trimmed) opts.onProgress?.(trimmed);
|
||||
}
|
||||
};
|
||||
child.stdout?.on('data', forward);
|
||||
child.stderr?.on('data', forward);
|
||||
child.on('error', (err) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `could not spawn ${argv[0]} build: ${err.message}`,
|
||||
});
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false });
|
||||
else resolve({ ok: false, built: false, alreadyPresent: false, error: `${argv[0]} build failed (exit ${code})` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface DockerTmuxCheckResult {
|
||||
ok: boolean;
|
||||
tmuxPath?: string;
|
||||
@@ -524,21 +921,21 @@ export interface DockerTmuxCheckResult {
|
||||
* (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable.
|
||||
*/
|
||||
export async function checkDockerTmuxAvailable(
|
||||
docker: Pick<SessionDocker, 'engine' | 'image'>
|
||||
docker: Pick<SessionDocker, 'engine' | 'image' | 'context' | 'daemonHost'>
|
||||
): Promise<DockerTmuxCheckResult> {
|
||||
if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' };
|
||||
const engine = docker.engine;
|
||||
if (!(await checkDockerImagePresent(engine, docker.image))) {
|
||||
if (!(await checkDockerImagePresent(docker, docker.image))) {
|
||||
return {
|
||||
ok: false,
|
||||
imageMissing: true,
|
||||
error: `base image ${docker.image} not present: build it with 'node scripts/build-agent-image.mjs' (or pull it)`,
|
||||
error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`,
|
||||
};
|
||||
}
|
||||
const argv = dockerEngineArgv(docker);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
engine,
|
||||
['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
|
||||
argv[0],
|
||||
[...argv.slice(1), 'run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
const tmuxPath = stdout.trim();
|
||||
@@ -635,16 +1032,21 @@ export async function reapOrphanedDockerContainers(
|
||||
* Returns undefined on any failure. No-op under VITEST.
|
||||
*/
|
||||
export async function probeDockerCliVersion(
|
||||
docker: Pick<SessionDocker, 'engine' | 'containerName'>,
|
||||
docker: Pick<SessionDocker, 'engine' | 'containerName' | 'context' | 'daemonHost'>,
|
||||
mode: SessionMode
|
||||
): Promise<string | undefined> {
|
||||
if (IS_TEST_MODE) return undefined;
|
||||
const bin = mode === 'shell' ? null : mode;
|
||||
if (!bin) return undefined;
|
||||
const argv = dockerEngineArgv(docker);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(docker.engine, ['exec', docker.containerName, bin, '--version'], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
const { stdout } = await execFileAsync(
|
||||
argv[0],
|
||||
[...argv.slice(1), 'exec', docker.containerName, bin, '--version'],
|
||||
{
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
const match = stdout.trim().match(/\d+\.\d+\.\d+/);
|
||||
return match ? match[0] : stdout.trim() || undefined;
|
||||
} catch {
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface MuxSession {
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username in multi-user mode (round-tripped through recovery like remote/docker) */
|
||||
owner?: string;
|
||||
/** Session mode */
|
||||
mode: SessionMode;
|
||||
/** Whether webserver is attached to this session */
|
||||
@@ -84,6 +86,8 @@ export interface CreateSessionOptions {
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username in multi-user mode; persisted for recovery. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/** Options for respawning a dead pane. */
|
||||
@@ -110,6 +114,8 @@ export interface RespawnPaneOptions {
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username (multi-user); redundant on respawn since the Session object survives, kept for shape parity. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/** Options for pane buffer capture (COD-47 full-history mode). */
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
||||
import { getErrorMessage, type PlanItem } from './types.js';
|
||||
import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { PlanItem };
|
||||
@@ -130,18 +130,28 @@ export class PlanOrchestrator {
|
||||
private taskDescription = '';
|
||||
private researchModel: string;
|
||||
private plannerModel: string;
|
||||
// Multi-user permission threading: the resolved claudeMode/owner/allowedTools for the
|
||||
// internal research/planner one-shots. Left undefined = today's single-user behavior
|
||||
// (the caller threads the resolved global mode, byte-identical when !isMultiUserMode()).
|
||||
private claudeMode?: ClaudeMode;
|
||||
private owner?: string;
|
||||
private allowedTools?: string;
|
||||
|
||||
constructor(
|
||||
mux: TerminalMultiplexer,
|
||||
workingDir: string = process.cwd(),
|
||||
outputDir?: string,
|
||||
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }
|
||||
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> },
|
||||
security?: { claudeMode?: ClaudeMode; owner?: string; allowedTools?: string }
|
||||
) {
|
||||
this.mux = mux;
|
||||
this.workingDir = workingDir;
|
||||
this.outputDir = outputDir;
|
||||
this.researchModel = modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL;
|
||||
this.plannerModel = modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL;
|
||||
this.claudeMode = security?.claudeMode;
|
||||
this.owner = security?.owner;
|
||||
this.allowedTools = security?.allowedTools;
|
||||
}
|
||||
|
||||
private saveAgentOutput(agentType: string, prompt: string, result: unknown, durationMs: number): void {
|
||||
@@ -424,6 +434,12 @@ export class PlanOrchestrator {
|
||||
mux: this.mux,
|
||||
useMux: false,
|
||||
mode: 'claude',
|
||||
// Section 6.3: run this one-shot under the caller-resolved permission mode/owner so a
|
||||
// non-granted multi-user user cannot regain --dangerously-skip-permissions. Undefined
|
||||
// (single-user, not threaded) is byte-identical to today (Session keeps its default).
|
||||
claudeMode: this.claudeMode,
|
||||
allowedTools: this.allowedTools,
|
||||
owner: this.owner,
|
||||
});
|
||||
|
||||
this.runningSessions.add(session);
|
||||
@@ -580,6 +596,10 @@ export class PlanOrchestrator {
|
||||
mux: this.mux,
|
||||
useMux: false,
|
||||
mode: 'claude',
|
||||
// Section 6.3: same permission-mode/owner threading as the research one-shot above.
|
||||
claudeMode: this.claudeMode,
|
||||
allowedTools: this.allowedTools,
|
||||
owner: this.owner,
|
||||
});
|
||||
|
||||
this.runningSessions.add(session);
|
||||
|
||||
+25
-10
@@ -9,10 +9,23 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import webpush from 'web-push';
|
||||
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
|
||||
import type { VapidKeys, PushSubscriptionRecord, UserRole } from './types.js';
|
||||
import { Debouncer } from './utils/index.js';
|
||||
import { getDataDir } from './config/instance.js';
|
||||
|
||||
/**
|
||||
* A push subscription plus the multi-user owner identity stamped at subscribe time.
|
||||
* `username`/`role` are undefined in single-user mode (and for legacy records saved
|
||||
* before this field existed). sendPushNotifications uses them to scope a
|
||||
* session-notification to its owner's devices (+ admins) instead of fanning out to
|
||||
* every user. Kept as a store-local widening of PushSubscriptionRecord so the shared
|
||||
* type stays untouched; the extra keys serialize/persist transparently.
|
||||
*/
|
||||
export type OwnedPushSubscriptionRecord = PushSubscriptionRecord & {
|
||||
username?: string;
|
||||
role?: UserRole;
|
||||
};
|
||||
|
||||
const DATA_DIR = getDataDir();
|
||||
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
|
||||
const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json');
|
||||
@@ -20,7 +33,7 @@ const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
export class PushSubscriptionStore {
|
||||
private vapidKeys: VapidKeys | null = null;
|
||||
private subscriptions: Map<string, PushSubscriptionRecord> = new Map();
|
||||
private subscriptions: Map<string, OwnedPushSubscriptionRecord> = new Map();
|
||||
private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
|
||||
private _disposed = false;
|
||||
|
||||
@@ -67,17 +80,19 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
/** Register or update a push subscription (deduplicates by endpoint) */
|
||||
addSubscription(sub: Omit<PushSubscriptionRecord, 'lastUsedAt'>): PushSubscriptionRecord {
|
||||
addSubscription(sub: Omit<OwnedPushSubscriptionRecord, 'lastUsedAt'>): OwnedPushSubscriptionRecord {
|
||||
// Check for existing subscription with same endpoint
|
||||
for (const [existingId, existing] of this.subscriptions) {
|
||||
if (existing.endpoint === sub.endpoint) {
|
||||
// Update existing
|
||||
const updated: PushSubscriptionRecord = {
|
||||
// Update existing (re-stamp owner identity so it tracks the current caller)
|
||||
const updated: OwnedPushSubscriptionRecord = {
|
||||
...existing,
|
||||
keys: sub.keys,
|
||||
userAgent: sub.userAgent,
|
||||
lastUsedAt: Date.now(),
|
||||
pushPreferences: sub.pushPreferences,
|
||||
username: sub.username,
|
||||
role: sub.role,
|
||||
};
|
||||
this.subscriptions.set(existingId, updated);
|
||||
this.scheduleSave();
|
||||
@@ -86,7 +101,7 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
// New subscription
|
||||
const record: PushSubscriptionRecord = {
|
||||
const record: OwnedPushSubscriptionRecord = {
|
||||
...sub,
|
||||
lastUsedAt: Date.now(),
|
||||
};
|
||||
@@ -96,7 +111,7 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
/** Update push preferences for a subscription */
|
||||
updatePreferences(id: string, preferences: Record<string, boolean>): PushSubscriptionRecord | null {
|
||||
updatePreferences(id: string, preferences: Record<string, boolean>): OwnedPushSubscriptionRecord | null {
|
||||
const sub = this.subscriptions.get(id);
|
||||
if (!sub) return null;
|
||||
sub.pushPreferences = preferences;
|
||||
@@ -124,12 +139,12 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
/** Get all subscriptions */
|
||||
getAll(): PushSubscriptionRecord[] {
|
||||
getAll(): OwnedPushSubscriptionRecord[] {
|
||||
return Array.from(this.subscriptions.values());
|
||||
}
|
||||
|
||||
/** Get a single subscription by ID */
|
||||
get(id: string): PushSubscriptionRecord | null {
|
||||
get(id: string): OwnedPushSubscriptionRecord | null {
|
||||
return this.subscriptions.get(id) ?? null;
|
||||
}
|
||||
|
||||
@@ -138,7 +153,7 @@ export class PushSubscriptionStore {
|
||||
if (!existsSync(SUBS_FILE)) return;
|
||||
try {
|
||||
const raw = readFileSync(SUBS_FILE, 'utf-8');
|
||||
const arr = JSON.parse(raw) as PushSubscriptionRecord[];
|
||||
const arr = JSON.parse(raw) as OwnedPushSubscriptionRecord[];
|
||||
for (const sub of arr) {
|
||||
this.subscriptions.set(sub.id, sub);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RemoteCase,
|
||||
RemoteCommandMode,
|
||||
RemoteHost,
|
||||
RemoteSessionInfo,
|
||||
RemoteSshOptions,
|
||||
SessionMode,
|
||||
SessionRemote,
|
||||
@@ -173,6 +174,14 @@ export interface RemoteTmuxCheckResult {
|
||||
export async function checkRemoteTmuxAvailable(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): Promise<RemoteTmuxCheckResult> {
|
||||
// Under vitest, never open a real ssh connection — mirrors TmuxManager's
|
||||
// no-op-shell-under-VITEST (IS_TEST_MODE). Without this, remote-case
|
||||
// create-path tests hit a real ~10s ssh timeout. The command construction is
|
||||
// covered by buildRemoteTmuxCheckCommand unit tests; only the live probe is
|
||||
// short-circuited here.
|
||||
if (process.env.VITEST) {
|
||||
return { ok: true, tmuxPath: '(test-mode)' };
|
||||
}
|
||||
const command = buildRemoteTmuxCheckCommand(host);
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { timeout: 15_000 });
|
||||
@@ -202,6 +211,109 @@ export async function checkRemoteTmuxAvailable(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a
|
||||
* remote host's canonical `-L codeman` socket.
|
||||
*
|
||||
* `list-sessions` exits NON-ZERO with empty output when no sessions exist (and
|
||||
* the server isn't running), so `2>/dev/null` swallows tmux's "no server
|
||||
* running" stderr; the caller treats a non-zero exit / empty output as "no
|
||||
* sessions" rather than an error.
|
||||
*
|
||||
* COD-107 — connection options come from the shared `buildSshConnectionArgs`, so
|
||||
* discovery connects with the SAME port/identity/proxy/jump-host as the launch
|
||||
* and the tmux prereq probe.
|
||||
*/
|
||||
export function buildRemoteListSessionsCommand(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): string {
|
||||
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
|
||||
const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)];
|
||||
// The tmux list-sessions invocation is passed as ONE shell-quoted argument so
|
||||
// the remote login shell runs it verbatim. The `-F` format uses literal `\t`
|
||||
// separators (tmux expands them); `2>/dev/null` is inside the quoted command.
|
||||
const remoteCmd =
|
||||
'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null';
|
||||
parts.push(remoteSshTarget(host), shellescape(remoteCmd));
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — pure parser for the `tmux list-sessions -F` output emitted by
|
||||
* `buildRemoteListSessionsCommand`. Factored out so the parse is unit-testable
|
||||
* without opening a real ssh connection.
|
||||
*
|
||||
* - Splits each non-empty line into [name, attached, created, windows] on the
|
||||
* field separator. IMPORTANT: the remote tmux's `-F "…\t…"` format does NOT
|
||||
* expand `\t` to a real tab — it emits the LITERAL two-character sequence
|
||||
* `\t` (verified on aa-desktop / tmux next-3.7). So we split on the literal
|
||||
* backslash-t sequence; we also tolerate a real tab in case a tmux build
|
||||
* does expand it. (A real TAB is the regex `\t`; a literal backslash-t is the
|
||||
* regex `\\t`.)
|
||||
* - Keeps ONLY sessions whose name starts with `codeman-` (ignores foreign tmux
|
||||
* sessions that happen to share the socket).
|
||||
* - Coerces: `attached` → boolean (`'1'`), `created`/`windows` → finite ints.
|
||||
* - Skips malformed lines (wrong column count or non-numeric created/windows)
|
||||
* rather than emitting garbage.
|
||||
*/
|
||||
export function parseRemoteSessionList(stdout: string): RemoteSessionInfo[] {
|
||||
const out: RemoteSessionInfo[] = [];
|
||||
for (const rawLine of stdout.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
// Split on a literal `\t` (backslash + t, what the remote tmux emits) OR a
|
||||
// real tab character. `/\\t|\t/` = the two-char sequence, or a TAB.
|
||||
const cols = line.split(/\\t|\t/);
|
||||
if (cols.length !== 4) continue;
|
||||
const [name, attachedStr, createdStr, windowsStr] = cols;
|
||||
if (!name.startsWith('codeman-')) continue;
|
||||
const created = Number(createdStr);
|
||||
const windows = Number(windowsStr);
|
||||
if (!Number.isFinite(created) || !Number.isFinite(windows)) continue;
|
||||
// COD-106 — `session_attached` is the CLIENT COUNT (not a 0/1 flag); >1 = shared.
|
||||
const attachedNum = Number(attachedStr.trim());
|
||||
const attachedClients = Number.isFinite(attachedNum) ? Math.max(0, Math.trunc(attachedNum)) : 0;
|
||||
out.push({
|
||||
name,
|
||||
attached: attachedClients > 0,
|
||||
attachedClients,
|
||||
created: Math.trunc(created),
|
||||
windows: Math.trunc(windows),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — discover `codeman-*` tmux sessions already running on a remote host
|
||||
* (created by the remote's own Codeman, another instance, or this one), so the
|
||||
* operator can attach to one this Codeman didn't launch.
|
||||
*
|
||||
* NEVER throws: returns `[]` on unreachable host / no tmux / no sessions
|
||||
* (`list-sessions` exits non-zero with empty output when there are none).
|
||||
*
|
||||
* VITEST guard — like `checkRemoteTmuxAvailable`, returns `[]` under test so a
|
||||
* real ssh never runs in a request path (which would make route tests hit a
|
||||
* ~10s timeout). The command construction is covered by
|
||||
* `buildRemoteListSessionsCommand` and the parse by `parseRemoteSessionList`.
|
||||
*/
|
||||
export async function listRemoteCodemanSessions(
|
||||
remote: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): Promise<RemoteSessionInfo[]> {
|
||||
if (process.env.VITEST) {
|
||||
return [];
|
||||
}
|
||||
const command = buildRemoteListSessionsCommand(remote);
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { timeout: 15_000 });
|
||||
return parseRemoteSessionList(stdout);
|
||||
} catch {
|
||||
// Unreachable host, no tmux server, or no sessions (non-zero exit). All map
|
||||
// to "nothing to attach to" — never surface as an error to the caller.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteDisplayPath(
|
||||
remote: Pick<SessionRemote, 'username' | 'host' | 'remotePath'> | { username: string; host: string; path: string }
|
||||
): string {
|
||||
@@ -218,6 +330,10 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
|
||||
port: host.port,
|
||||
remotePath: remoteCase.remotePath,
|
||||
commands: host.commands,
|
||||
// COD-105 — the COD-104 launch path creates the remote session, so we own it
|
||||
// (an explicit kill may propagate a remote kill-session). Discovered+attached
|
||||
// sessions go through `toAttachedSessionRemote` with `owned: false`.
|
||||
owned: true,
|
||||
// COD-107 — carry the advanced SSH options from host config into the session
|
||||
// so the launch/prereq commands connect the same way the operator configured.
|
||||
identityFile: host.identityFile,
|
||||
@@ -226,3 +342,38 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build a NON-owned `SessionRemote` for ATTACHING to a `codeman-*`
|
||||
* session already running on a remote host (discovered via
|
||||
* `listRemoteCodemanSessions`). The resulting session's pane runs
|
||||
* `tmux -L codeman attach -t <remoteSessionName>` (see
|
||||
* `buildRemoteAttachCommand`), and because we did NOT create the remote session,
|
||||
* `owned: false` means closing the tab DETACHES rather than killing it.
|
||||
*
|
||||
* `remotePath` is informational here (the attached remote session keeps its own
|
||||
* cwd); we record the host's nominal path so display helpers still show
|
||||
* `user@host:path`.
|
||||
*/
|
||||
export function toAttachedSessionRemote(
|
||||
host: RemoteHost,
|
||||
remoteSessionName: string,
|
||||
remotePath: string
|
||||
): SessionRemote {
|
||||
return {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
port: host.port,
|
||||
remotePath,
|
||||
commands: host.commands,
|
||||
// Discovered + attached — another Codeman created it. Detach-not-kill.
|
||||
owned: false,
|
||||
remoteSessionName,
|
||||
identityFile: host.identityFile,
|
||||
socksProxy: host.socksProxy,
|
||||
jumpHost: host.jumpHost,
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* @fileoverview Pure logic for the remote-session auto-reconnect watcher (COD-108).
|
||||
*
|
||||
* COD-104 made remote tmux sessions durable + idempotently reattachable, but a
|
||||
* reconnect only fired at explicit trigger points. COD-108 adds a continuous
|
||||
* watcher (in `TmuxManager`) that detects a dead remote pane and emits
|
||||
* `remoteSessionDropped`; `SessionManager`/server then reassembles the respawn
|
||||
* options and reattaches (re-running the idempotent remote command).
|
||||
*
|
||||
* This module holds the SIDE-EFFECT-FREE pieces so they can be unit-tested
|
||||
* without real tmux:
|
||||
* - the bounded exponential **backoff schedule** (attempt → delay, capped),
|
||||
* - the per-session **reconnect state** shape,
|
||||
* - the **eligibility decision** (`decideReconnect`) given a session + its
|
||||
* reconnect state + the current time + the guard set.
|
||||
*
|
||||
* The watcher in `tmux-manager.ts` owns the live `isPaneDead` probe and the
|
||||
* timers; everything here is pure and deterministic (time is injected).
|
||||
*
|
||||
* @module remote-reconnect
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bounded exponential backoff delays (ms) between reconnect attempts.
|
||||
* Attempt N (1-based) waits `BACKOFF_SCHEDULE_MS[N-1]` from the previous emit
|
||||
* before the next emit is eligible. After the last entry the session is
|
||||
* considered `reconnect-exhausted` and the watcher stops emitting for it.
|
||||
*
|
||||
* 5s, 15s, 45s, 2m, 5m, 5m → ~6 attempts spanning ~13 minutes.
|
||||
*/
|
||||
export const BACKOFF_SCHEDULE_MS: readonly number[] = [5_000, 15_000, 45_000, 120_000, 300_000, 300_000];
|
||||
|
||||
/** Maximum number of reconnect attempts before exhaustion. */
|
||||
export const MAX_RECONNECT_ATTEMPTS = BACKOFF_SCHEDULE_MS.length;
|
||||
|
||||
/**
|
||||
* Delay (ms) to wait AFTER emitting attempt `attempt` (1-based) before the next
|
||||
* attempt is eligible. `attempt <= 0` returns the first delay; an attempt at or
|
||||
* beyond the cap returns the last delay (callers should check exhaustion via
|
||||
* {@link isExhausted} rather than relying on this for the stop decision).
|
||||
*
|
||||
* Pure — no clock, no I/O.
|
||||
*/
|
||||
export function reconnectDelayForAttempt(attempt: number): number {
|
||||
if (!Number.isFinite(attempt) || attempt <= 1) return BACKOFF_SCHEDULE_MS[0];
|
||||
const idx = Math.min(Math.floor(attempt) - 1, BACKOFF_SCHEDULE_MS.length - 1);
|
||||
return BACKOFF_SCHEDULE_MS[idx];
|
||||
}
|
||||
|
||||
/** Whether `attempts` reconnect emits have reached/exceeded the cap. Pure. */
|
||||
export function isExhausted(attempts: number): boolean {
|
||||
return attempts >= MAX_RECONNECT_ATTEMPTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session reconnect bookkeeping held by the watcher. All time values are
|
||||
* epoch ms. `inFlight` guards against stacking respawns when a tick fires while
|
||||
* a previous reattach is still running. `exhaustedEmitted` ensures the
|
||||
* `remoteReconnectExhausted` event fires at most once per session.
|
||||
*/
|
||||
export interface RemoteReconnectState {
|
||||
/** Number of `remoteSessionDropped` emits so far (advances per emit). */
|
||||
attempts: number;
|
||||
/** Earliest time (epoch ms) the next emit is eligible. 0 = eligible now. */
|
||||
nextEligibleAt: number;
|
||||
/** A reattach triggered by a prior emit is currently running. */
|
||||
inFlight: boolean;
|
||||
/** Cap reached — stop auto-retrying for this session. */
|
||||
exhausted: boolean;
|
||||
/** The `remoteReconnectExhausted` SSE event has already been emitted. */
|
||||
exhaustedEmitted: boolean;
|
||||
}
|
||||
|
||||
/** A fresh reconnect state (no attempts, immediately eligible). Pure. */
|
||||
export function freshReconnectState(): RemoteReconnectState {
|
||||
return { attempts: 0, nextEligibleAt: 0, inFlight: false, exhausted: false, exhaustedEmitted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the backoff after an emit at time `now`. Increments `attempts` and
|
||||
* schedules `nextEligibleAt = now + delay`. Returns a NEW state object (does
|
||||
* not mutate the input). Pure.
|
||||
*
|
||||
* NOTE: this does NOT set `exhausted`. Exhaustion is a decision the watcher
|
||||
* makes on the FOLLOWING tick (via {@link decideReconnect} → `exhaust`), so the
|
||||
* `remoteReconnectExhausted` event fires exactly once after the final attempt's
|
||||
* backoff window elapses — not pre-emptively on the last emit.
|
||||
*/
|
||||
export function advanceBackoff(state: RemoteReconnectState, now: number): RemoteReconnectState {
|
||||
const attempts = state.attempts + 1;
|
||||
const delay = reconnectDelayForAttempt(attempts);
|
||||
return {
|
||||
...state,
|
||||
attempts,
|
||||
nextEligibleAt: now + delay,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reset after a successful reattach — back to a fresh, eligible state. Pure. */
|
||||
export function resetReconnectState(): RemoteReconnectState {
|
||||
return freshReconnectState();
|
||||
}
|
||||
|
||||
/** Minimal session view the decision needs (avoids importing MuxSession here). */
|
||||
export interface ReconnectSessionView {
|
||||
sessionId: string;
|
||||
/** Truthy when this is a remote (SSH-wrapped) session. */
|
||||
isRemote: boolean;
|
||||
/** Result of `isPaneDead(muxName)` for this session. */
|
||||
paneDead: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision outcomes for a single watcher tick on one session.
|
||||
* - `emit` → emit `remoteSessionDropped { sessionId, attempt }`, then
|
||||
* advance backoff (attempt = the returned `attempt`).
|
||||
* - `exhaust` → cap reached this tick; emit `remoteReconnectExhausted` once.
|
||||
* - `skip` → do nothing (not remote / pane alive / guarded / in-flight /
|
||||
* not yet due / already exhausted).
|
||||
*/
|
||||
export type ReconnectAction =
|
||||
| { kind: 'emit'; attempt: number }
|
||||
| { kind: 'exhaust' }
|
||||
| { kind: 'skip'; reason: ReconnectSkipReason };
|
||||
|
||||
export type ReconnectSkipReason =
|
||||
| 'not-remote'
|
||||
| 'pane-alive'
|
||||
| 'guarded'
|
||||
| 'in-flight'
|
||||
| 'not-due'
|
||||
| 'exhausted'
|
||||
| 'disabled';
|
||||
|
||||
export interface DecideReconnectInput {
|
||||
session: ReconnectSessionView;
|
||||
state: RemoteReconnectState | undefined;
|
||||
/** Session is in the intentional-teardown guard set (killed/detached/stopping). */
|
||||
guarded: boolean;
|
||||
/** Kill-switch: `remoteAutoReconnect` setting. When false, never reconnect. */
|
||||
enabled: boolean;
|
||||
now: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE eligibility decision for one session on one tick. No clock, no I/O — all
|
||||
* inputs are passed in. The watcher translates the result into emits + state
|
||||
* transitions.
|
||||
*
|
||||
* Order of guards (most-decisive first):
|
||||
* 1. kill-switch off → skip:disabled
|
||||
* 2. not a remote session → skip:not-remote
|
||||
* 3. pane is alive → skip:pane-alive
|
||||
* 4. intentional teardown guard → skip:guarded (NEVER revive a killed tab)
|
||||
* 5. a reattach already running → skip:in-flight (no stacked respawns)
|
||||
* 6. already exhausted → skip:exhausted (one exhaust emit, then quiet)
|
||||
* 7. cap reached this tick → exhaust
|
||||
* 8. not yet due (backoff) → skip:not-due
|
||||
* 9. otherwise → emit (attempt = attempts + 1)
|
||||
*/
|
||||
export function decideReconnect(input: DecideReconnectInput): ReconnectAction {
|
||||
const { session, state, guarded, enabled, now } = input;
|
||||
|
||||
if (!enabled) return { kind: 'skip', reason: 'disabled' };
|
||||
if (!session.isRemote) return { kind: 'skip', reason: 'not-remote' };
|
||||
if (!session.paneDead) return { kind: 'skip', reason: 'pane-alive' };
|
||||
// Intentional kill / detach must NEVER be auto-revived.
|
||||
if (guarded) return { kind: 'skip', reason: 'guarded' };
|
||||
|
||||
const s = state ?? freshReconnectState();
|
||||
|
||||
// Only one reconnect in flight per session — don't stack respawns.
|
||||
if (s.inFlight) return { kind: 'skip', reason: 'in-flight' };
|
||||
|
||||
if (s.exhausted) return { kind: 'skip', reason: 'exhausted' };
|
||||
|
||||
// Cap reached: surface exhaustion once, then go quiet.
|
||||
if (isExhausted(s.attempts)) return { kind: 'exhaust' };
|
||||
|
||||
// Backoff gate — only emit when due.
|
||||
if (now < s.nextEligibleAt) return { kind: 'skip', reason: 'not-due' };
|
||||
|
||||
return { kind: 'emit', attempt: s.attempts + 1 };
|
||||
}
|
||||
@@ -28,9 +28,15 @@ export type UnifiedSessionItem = {
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
firstPrompt?: string;
|
||||
/** Most recent user prompt from the transcript (COD-145), parallel to firstPrompt. */
|
||||
lastPrompt?: string;
|
||||
sizeBytes?: number;
|
||||
projectKey?: string;
|
||||
remote?: boolean;
|
||||
/** Pinned to the top of the session manager list (COD-139). */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group desc. */
|
||||
pinnedAt?: number;
|
||||
sources: string[];
|
||||
stats?: { memoryMB: number; cpuPercent: number };
|
||||
};
|
||||
@@ -46,6 +52,8 @@ export type LiveSessionInput = {
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
};
|
||||
|
||||
/** Persisted session view (subset of `SessionState`). */
|
||||
@@ -59,6 +67,8 @@ export type PersistedSessionInput = {
|
||||
lastActivityAt?: number;
|
||||
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
|
||||
claudeSessionId?: string;
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
};
|
||||
|
||||
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
|
||||
@@ -77,6 +87,8 @@ export type HistoryInput = {
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
/** Most recent user prompt from the transcript (COD-145). */
|
||||
lastPrompt?: string;
|
||||
projectKey?: string;
|
||||
};
|
||||
|
||||
@@ -149,6 +161,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'workingDir', h.workingDir);
|
||||
overwrite(item, 'sizeBytes', h.sizeBytes);
|
||||
overwrite(item, 'firstPrompt', h.firstPrompt);
|
||||
overwrite(item, 'lastPrompt', h.lastPrompt);
|
||||
overwrite(item, 'projectKey', h.projectKey);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||
@@ -175,6 +188,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'workingDir', p.workingDir);
|
||||
overwrite(item, 'createdAt', p.createdAt);
|
||||
overwrite(item, 'lastActivityAt', p.lastActivityAt);
|
||||
overwrite(item, 'pinned', p.pinned);
|
||||
overwrite(item, 'pinnedAt', p.pinnedAt);
|
||||
}
|
||||
|
||||
// 4) live (highest precedence)
|
||||
@@ -189,6 +204,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'createdAt', v.createdAt);
|
||||
overwrite(item, 'lastActivityAt', v.lastActivityAt);
|
||||
overwrite(item, 'claudeSessionId', v.claudeSessionId);
|
||||
overwrite(item, 'pinned', v.pinned);
|
||||
overwrite(item, 'pinnedAt', v.pinnedAt);
|
||||
}
|
||||
|
||||
// 5) mux stats + remote flag (create item if mux-only)
|
||||
@@ -200,6 +217,64 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
if (m.remote !== undefined) item.remote = m.remote;
|
||||
}
|
||||
|
||||
// firstPrompt backfill (COD-140): the only source that sets firstPrompt is the
|
||||
// transcript-history view, keyed by the Claude transcript file's UUID. A live/persisted
|
||||
// row keyed by its Codeman id only inherits firstPrompt when that id happens to equal an
|
||||
// on-disk transcript UUID. When it doesn't (stale/wrong claudeSessionId, post-/clear new
|
||||
// uuid, resumed/attached/worktree session, transcript not yet flushed), the row shows
|
||||
// "(no prompt captured)" even though a real transcript for that working dir exists under a
|
||||
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
|
||||
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
|
||||
// firstPrompt (so rows keyed to their own transcript are untouched).
|
||||
const firstPromptByUuid = new Map<string, string>();
|
||||
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
|
||||
const lastPromptByUuid = new Map<string, string>();
|
||||
const lastPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||
for (const h of sources.history ?? []) {
|
||||
const ms = Date.parse(h.lastModified);
|
||||
const ts = Number.isNaN(ms) ? -Infinity : ms;
|
||||
if (h.firstPrompt) {
|
||||
firstPromptByUuid.set(h.sessionId, h.firstPrompt);
|
||||
if (h.workingDir) {
|
||||
const existing = firstPromptByWorkingDir.get(h.workingDir);
|
||||
if (!existing || ts > existing.ms) {
|
||||
firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (h.lastPrompt) {
|
||||
lastPromptByUuid.set(h.sessionId, h.lastPrompt);
|
||||
if (h.workingDir) {
|
||||
const existing = lastPromptByWorkingDir.get(h.workingDir);
|
||||
if (!existing || ts > existing.ms) {
|
||||
lastPromptByWorkingDir.set(h.workingDir, { prompt: h.lastPrompt, ms: ts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of map.values()) {
|
||||
if (!item.firstPrompt) {
|
||||
// never overwrite an existing non-empty prompt
|
||||
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||
if (byUuid) {
|
||||
item.firstPrompt = byUuid;
|
||||
} else if (item.workingDir) {
|
||||
const byDir = firstPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.firstPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
if (!item.lastPrompt) {
|
||||
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||
if (byUuid) {
|
||||
item.lastPrompt = byUuid;
|
||||
} else if (item.workingDir) {
|
||||
const byDir = lastPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.lastPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
||||
const kept: UnifiedSessionItem[] = [];
|
||||
for (const item of map.values()) {
|
||||
@@ -211,8 +286,24 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
if (isReal) kept.push(item);
|
||||
}
|
||||
|
||||
// Stable sort: lastActivityAt desc (undefined last), createdAt desc, sessionId asc.
|
||||
// Stable sort (COD-139): pinned group first (pinnedAt desc, most-recently-pinned
|
||||
// first), then unpinned by lastActivityAt desc (undefined last), createdAt desc,
|
||||
// sessionId asc.
|
||||
kept.sort((a, b) => {
|
||||
const pa = a.pinned === true;
|
||||
const pb = b.pinned === true;
|
||||
if (pa !== pb) return pa ? -1 : 1; // pinned floats above unpinned
|
||||
if (pa && pb) {
|
||||
// Both pinned: most-recently-pinned first (undefined pinnedAt sorts last).
|
||||
const ta = a.pinnedAt;
|
||||
const tb = b.pinnedAt;
|
||||
if (ta !== tb) {
|
||||
if (ta === undefined) return 1;
|
||||
if (tb === undefined) return -1;
|
||||
return tb - ta;
|
||||
}
|
||||
// tie-break falls through to the activity/createdAt/id rules below.
|
||||
}
|
||||
const la = a.lastActivityAt;
|
||||
const lb = b.lastActivityAt;
|
||||
if (la !== lb) {
|
||||
@@ -234,7 +325,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring filter (name + firstPrompt + workingDir + sessionId)
|
||||
* Case-insensitive substring filter (name + firstPrompt + lastPrompt + workingDir + sessionId)
|
||||
* with offset/limit paging. `total` is the filtered count BEFORE paging.
|
||||
*/
|
||||
export function filterAndPaginate(
|
||||
@@ -244,7 +335,7 @@ export function filterAndPaginate(
|
||||
const q = (opts.q ?? '').trim().toLowerCase();
|
||||
const filtered = q
|
||||
? items.filter((it) => {
|
||||
const hay = [it.name, it.firstPrompt, it.workingDir, it.sessionId]
|
||||
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId]
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
@@ -21,6 +21,8 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str
|
||||
switch (claudeMode) {
|
||||
case 'dangerously-skip-permissions':
|
||||
return ['--dangerously-skip-permissions'];
|
||||
case 'auto':
|
||||
return ['--permission-mode', 'auto'];
|
||||
case 'allowedTools':
|
||||
if (allowedTools) {
|
||||
return ['--allowedTools', allowedTools];
|
||||
@@ -80,8 +82,16 @@ export function buildInteractiveArgs(
|
||||
* @param model - Optional model override
|
||||
* @returns Array of CLI arguments
|
||||
*/
|
||||
export function buildPromptArgs(prompt: string, model?: string): string[] {
|
||||
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
|
||||
export function buildPromptArgs(
|
||||
prompt: string,
|
||||
model?: string,
|
||||
claudeMode: ClaudeMode = 'dangerously-skip-permissions',
|
||||
allowedTools?: string
|
||||
): string[] {
|
||||
// Respect the session's permission mode instead of always skipping, so a
|
||||
// multi-user non-granted user's one-shot runs classifier-guarded (auto) rather
|
||||
// than with full bypass. Defaults to skip-permissions (unchanged single-user).
|
||||
const args = ['-p', '--verbose', ...buildPermissionArgs(claudeMode, allowedTools), '--output-format', 'stream-json'];
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @fileoverview Pure helpers for the global session tab-order (COD-131).
|
||||
*
|
||||
* Tab order (drag-and-drop reorder + Ctrl+Shift+{/}) is persisted server-side
|
||||
* so it follows the user across devices. The server is authoritative; the
|
||||
* browser's localStorage (`codeman-session-order`) is the offline fallback.
|
||||
*
|
||||
* These helpers are pure (no IO) so they can be unit-tested in isolation and
|
||||
* reused by both the PUT /api/session-order route and the StateStore accessor.
|
||||
*
|
||||
* - `normalizeSessionOrder` coerces arbitrary input into a clean string[]
|
||||
* (non-empty strings only, deduped with first occurrence winning).
|
||||
* - `mergeSessionOrder` lets the pushing device's order win, while preserving
|
||||
* any server-only ids the pushing device didn't know about — they fall to the
|
||||
* END in their existing relative order, never dropped.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Coerce arbitrary input into a clean ordered list of session ids:
|
||||
* keep only non-empty strings and dedup (first occurrence wins).
|
||||
*
|
||||
* @param order - unknown input (expected to be a string[], but defensive)
|
||||
* @returns a normalized string[] (empty array for non-array / all-junk input)
|
||||
*/
|
||||
export function normalizeSessionOrder(order: unknown): string[] {
|
||||
if (!Array.isArray(order)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const entry of order) {
|
||||
if (typeof entry !== 'string' || entry.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(entry);
|
||||
result.push(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an incoming order from a pushing device with the existing server order.
|
||||
*
|
||||
* The incoming order wins; any ids present in `existing` but NOT in `incoming`
|
||||
* are appended at the END, preserving their relative order. This is the
|
||||
* "server-only ids the pushing device didn't know about fall to the end, never
|
||||
* dropped" rule.
|
||||
*
|
||||
* Both arguments are normalized first, so callers may pass raw input safely.
|
||||
*
|
||||
* @param incoming - the order the pushing device wants
|
||||
* @param existing - the current server-side order
|
||||
* @returns the merged, normalized order
|
||||
*/
|
||||
export function mergeSessionOrder(incoming: string[], existing: string[]): string[] {
|
||||
const normalizedIncoming = normalizeSessionOrder(incoming);
|
||||
const incomingSet = new Set(normalizedIncoming);
|
||||
const merged = [...normalizedIncoming];
|
||||
for (const id of normalizeSessionOrder(existing)) {
|
||||
if (!incomingSet.has(id)) {
|
||||
merged.push(id);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
+116
-20
@@ -342,6 +342,11 @@ export class Session extends EventEmitter {
|
||||
// Image watcher setting (per-session toggle)
|
||||
private _imageWatcherEnabled: boolean = false;
|
||||
|
||||
// Pin state (COD-139) — pinned sessions float to the top of the session
|
||||
// manager list, ordered by pinnedAt descending (most-recently-pinned first).
|
||||
private _pinned: boolean = false;
|
||||
private _pinnedAt: number | null = null;
|
||||
|
||||
// Flicker filter setting (per-session toggle, applied on frontend)
|
||||
private _flickerFilterEnabled: boolean = false;
|
||||
|
||||
@@ -412,6 +417,10 @@ export class Session extends EventEmitter {
|
||||
// local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions).
|
||||
private readonly _docker?: SessionDocker;
|
||||
|
||||
// Owning username in multi-user mode (undefined in single-user). Stamped at create
|
||||
// from req.authUser and round-tripped through recovery like _remote/_docker.
|
||||
private _owner?: string;
|
||||
|
||||
// Session color for visual differentiation
|
||||
private _color: import('./types.js').SessionColor = 'default';
|
||||
|
||||
@@ -487,6 +496,8 @@ export class Session extends EventEmitter {
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for sessions launched inside a container via local tmux. */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username (multi-user mode); undefined in single-user. */
|
||||
owner?: string;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -561,6 +572,7 @@ export class Session extends EventEmitter {
|
||||
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
this._remote = config.remote;
|
||||
this._docker = config.docker;
|
||||
this._owner = config.owner;
|
||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||
}
|
||||
@@ -667,6 +679,16 @@ export class Session extends EventEmitter {
|
||||
return this._docker;
|
||||
}
|
||||
|
||||
/** Owning username in multi-user mode, else undefined. */
|
||||
get owner(): string | undefined {
|
||||
return this._owner;
|
||||
}
|
||||
|
||||
/** Set the owning username (used by recovery to restore ownership). */
|
||||
set owner(username: string | undefined) {
|
||||
this._owner = username;
|
||||
}
|
||||
|
||||
// Adopt a Claude conversation ID observed from an external source (e.g. hook
|
||||
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
|
||||
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
|
||||
@@ -975,6 +997,26 @@ export class Session extends EventEmitter {
|
||||
this._imageWatcherEnabled = enabled;
|
||||
}
|
||||
|
||||
/** Whether this session is pinned to the top of the session manager (COD-139). */
|
||||
get pinned(): boolean {
|
||||
return this._pinned;
|
||||
}
|
||||
|
||||
/** When the session was pinned (epoch ms), or null when unpinned. */
|
||||
get pinnedAt(): number | null {
|
||||
return this._pinnedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set pin state (COD-139). Pinning stamps pinnedAt with now so the pinned
|
||||
* group orders most-recently-pinned first; unpinning clears it. Idempotent:
|
||||
* re-pinning an already-pinned session refreshes its pinnedAt.
|
||||
*/
|
||||
setPinned(pinned: boolean): void {
|
||||
this._pinned = pinned;
|
||||
this._pinnedAt = pinned ? Date.now() : null;
|
||||
}
|
||||
|
||||
get flickerFilterEnabled(): boolean {
|
||||
return this._flickerFilterEnabled;
|
||||
}
|
||||
@@ -1027,6 +1069,7 @@ export class Session extends EventEmitter {
|
||||
workingDir: this.workingDir,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
currentTaskId: this._currentTaskId,
|
||||
createdAt: this.createdAt,
|
||||
lastActivityAt: this._lastActivityAt,
|
||||
@@ -1040,6 +1083,8 @@ export class Session extends EventEmitter {
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
pinned: this._pinned || undefined,
|
||||
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||
totalCost: this._totalCost,
|
||||
inputTokens: this._totalInputTokens,
|
||||
outputTokens: this._totalOutputTokens,
|
||||
@@ -1230,6 +1275,70 @@ export class Session extends EventEmitter {
|
||||
return { isRestored };
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-108 — re-establish a dropped REMOTE session. Triggered by the
|
||||
* `TmuxManager` remote-reconnect watcher (via `remoteSessionDropped`): the
|
||||
* watcher detects a dead remote pane, the session owner reassembles the SAME
|
||||
* `RespawnPaneOptions` used for Claude-idle respawns and calls
|
||||
* `respawnPane()` directly. For a remote session that re-runs
|
||||
* `buildRemoteSessionCommand` (owned → `new-session -A`, non-owned →
|
||||
* `attach`), which idempotently REATTACHES the still-running durable remote
|
||||
* tmux session — scrollback + agent intact (proven COD-104/105).
|
||||
*
|
||||
* Deliberately does NOT route through the Claude-idle respawn-controller —
|
||||
* this is a transport re-establish, not a `/clear`/`/compact` cycle.
|
||||
*
|
||||
* @returns true if the pane was respawned (reattach issued), false otherwise.
|
||||
*/
|
||||
async reattachRemote(): Promise<boolean> {
|
||||
if (!this._remote) return false; // not a remote session
|
||||
if (!this._useMux || !this._mux || !this._muxSession) return false;
|
||||
const mux = this._mux;
|
||||
|
||||
// If tmux lost the whole session (not just a dead pane), there is nothing to
|
||||
// respawn into — a genuine death, leave it for normal recovery/reconcile.
|
||||
if (!mux.muxSessionExists(this._muxSession.muxName)) {
|
||||
console.log('[Session] reattachRemote: mux session gone, skipping:', this._muxSession.muxName);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
||||
if (!newPid) {
|
||||
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
|
||||
return false;
|
||||
}
|
||||
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the {@link RespawnPaneOptions} for this session. Single source of
|
||||
* truth shared by interactive start, shell start (via their inline copies),
|
||||
* and {@link reattachRemote} so the remote reattach path can never drift from
|
||||
* the spawn path.
|
||||
*/
|
||||
private _buildRespawnPaneOptions(): import('./mux-interface.js').RespawnPaneOptions {
|
||||
return {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
};
|
||||
}
|
||||
|
||||
private _handleTerminalOutput(data: string): void {
|
||||
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
|
||||
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
|
||||
@@ -1378,24 +1487,8 @@ export class Session extends EventEmitter {
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
const { isRestored } = await this._setupOrAttachMuxSession({
|
||||
respawnPaneOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
},
|
||||
// Single source of truth shared with reattachRemote() (COD-108).
|
||||
respawnPaneOptions: this._buildRespawnPaneOptions(),
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
@@ -1414,6 +1507,7 @@ export class Session extends EventEmitter {
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
@@ -1523,7 +1617,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// === Auto-accept workspace trust dialog ===
|
||||
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
||||
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
|
||||
// Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept.
|
||||
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
||||
this._trustDialogAccepted = true;
|
||||
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
||||
@@ -1785,6 +1879,7 @@ export class Session extends EventEmitter {
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1796,6 +1891,7 @@ export class Session extends EventEmitter {
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
spawnErrLabel: 'shell mux attachment',
|
||||
});
|
||||
@@ -1923,7 +2019,7 @@ export class Session extends EventEmitter {
|
||||
model ? `(model: ${model})` : ''
|
||||
);
|
||||
|
||||
const args = buildPromptArgs(prompt, model);
|
||||
const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools);
|
||||
|
||||
try {
|
||||
this.ptyProcess = pty.spawn('claude', args, {
|
||||
|
||||
@@ -278,6 +278,9 @@ export class StateStore {
|
||||
if (this.state.cronJobRuns) {
|
||||
parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`);
|
||||
}
|
||||
if (this.state.sessionOrder) {
|
||||
parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`);
|
||||
}
|
||||
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
@@ -485,6 +488,25 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-142: Remove a session's persisted record on kill UNLESS it is pinned.
|
||||
* A pinned session is demoted to a lightweight `stopped` record (pin retained)
|
||||
* so it stays visible in the session-manager pinned group and survives restart.
|
||||
* Unpinned sessions are fully removed (unchanged behavior).
|
||||
* @returns 'preserved' if demoted to stopped+pinned, 'removed' if deleted, 'absent' if no record existed.
|
||||
*/
|
||||
demoteOrRemoveSession(id: string): 'preserved' | 'removed' | 'absent' {
|
||||
const existing = this.state.sessions[id];
|
||||
if (!existing) return 'absent';
|
||||
if (existing.pinned === true) {
|
||||
// Demote in place: keep identity/resume fields + pin, mark stopped, clear live runtime.
|
||||
this.setSession(id, { ...existing, status: 'stopped', pid: null });
|
||||
return 'preserved';
|
||||
}
|
||||
this.removeSession(id);
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up stale sessions from state that don't have corresponding active sessions.
|
||||
* @param activeSessionIds - Set of currently active session IDs
|
||||
@@ -499,6 +521,7 @@ export class StateStore {
|
||||
|
||||
for (const sessionId of allSessionIds) {
|
||||
if (!activeSessionIds.has(sessionId)) {
|
||||
if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session
|
||||
const name = this.state.sessions[sessionId]?.name;
|
||||
cleaned.push({ id: sessionId, name });
|
||||
delete this.state.sessions[sessionId];
|
||||
@@ -630,6 +653,17 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Returns the global tab order (ordered sessionIds), [] if unset. COD-131. */
|
||||
getSessionOrder(): string[] {
|
||||
return this.state.sessionOrder ?? [];
|
||||
}
|
||||
|
||||
/** Persists the global tab order (ordered sessionIds) and triggers a debounced save. COD-131. */
|
||||
setSessionOrder(order: string[]): void {
|
||||
this.state.sessionOrder = order;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Resets all state to initial values and saves immediately. */
|
||||
reset(): void {
|
||||
this.state = createInitialState();
|
||||
|
||||
+350
-17
@@ -56,9 +56,11 @@ import {
|
||||
CONTAINER_HOME,
|
||||
defaultDockerCommandForMode,
|
||||
hostGatewayAlias,
|
||||
resolveCredentialMounts,
|
||||
resolveDockerClaudeArtifacts,
|
||||
resolveDockerCredentialArtifacts,
|
||||
type DockerCreateContext,
|
||||
type DockerMount,
|
||||
type DockerSeedCopy,
|
||||
} from './docker-hosts.js';
|
||||
import {
|
||||
wrapWithNice,
|
||||
@@ -76,6 +78,13 @@ import type {
|
||||
RespawnPaneOptions,
|
||||
PaneCaptureOptions,
|
||||
} from './mux-interface.js';
|
||||
import {
|
||||
decideReconnect,
|
||||
advanceBackoff,
|
||||
freshReconnectState,
|
||||
resetReconnectState,
|
||||
type RemoteReconnectState,
|
||||
} from './remote-reconnect.js';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
@@ -108,6 +117,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||||
/** Default stats collection interval (2 seconds) */
|
||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
|
||||
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
|
||||
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
|
||||
|
||||
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
|
||||
const TMUX_LAUNCH_CWD = '/tmp';
|
||||
|
||||
@@ -132,6 +144,20 @@ const IS_TEST_MODE = !!process.env.VITEST;
|
||||
/** Path to persisted mux session metadata */
|
||||
const MUX_SESSIONS_FILE = dataPath('mux-sessions.json');
|
||||
|
||||
/**
|
||||
* COD-108 kill-switch: `remoteAutoReconnect` app setting (default ON). Read at
|
||||
* call time (like headroom routing) so a settings change takes effect without a
|
||||
* restart. Absent/non-boolean ⇒ true (feature on).
|
||||
*/
|
||||
function isRemoteAutoReconnectEnabled(): boolean {
|
||||
try {
|
||||
const s = JSON.parse(readFileSync(dataPath('settings.json'), 'utf8')) as Record<string, unknown>;
|
||||
return typeof s.remoteAutoReconnect === 'boolean' ? s.remoteAutoReconnect : true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Regex to validate tmux session names (only allow safe characters) */
|
||||
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
|
||||
|
||||
@@ -561,6 +587,8 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri
|
||||
switch (mode) {
|
||||
case 'dangerously-skip-permissions':
|
||||
return ' --dangerously-skip-permissions';
|
||||
case 'auto':
|
||||
return ' --permission-mode auto';
|
||||
case 'allowedTools':
|
||||
if (allowedTools) {
|
||||
// Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated
|
||||
@@ -672,7 +700,7 @@ function buildEffortSettingsFlag(effort?: EffortLevel): string {
|
||||
return flag && value ? ` ${flag} '${value}'` : '';
|
||||
}
|
||||
|
||||
function buildSpawnCommand(options: {
|
||||
export function buildSpawnCommand(options: {
|
||||
mode: SessionMode;
|
||||
sessionId: string;
|
||||
model?: string;
|
||||
@@ -775,9 +803,22 @@ export function buildRemoteLaunchCommand(options: {
|
||||
mode: SessionMode;
|
||||
remote: SessionRemote;
|
||||
sessionId: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
}): string {
|
||||
const { mode, remote, sessionId } = options;
|
||||
const modeCommand = remote.commands?.[mode] || defaultRemoteCommandForMode(mode);
|
||||
const { mode, remote, sessionId, claudeMode, allowedTools } = options;
|
||||
// §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of
|
||||
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
||||
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
||||
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
|
||||
// (admin's explicit choice). For the DEFAULT single-user config (skip), the emitted
|
||||
// command is byte-identical to before. Non-claude modes are unchanged.
|
||||
const override = remote.commands?.[mode];
|
||||
const modeCommand = override
|
||||
? override
|
||||
: mode === 'claude'
|
||||
? `exec claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`
|
||||
: defaultRemoteCommandForMode(mode);
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
|
||||
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
|
||||
@@ -795,6 +836,13 @@ export function buildRemoteLaunchCommand(options: {
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
// COD-106 — shared/collaborative sessions: tmux defaults to sizing a window
|
||||
// to the SMALLEST attached client, so two Codemans at different viewports
|
||||
// would fight (clamp to the smaller). `window-size latest` sizes to the
|
||||
// most-recently-active client instead, so concurrent clients coexist.
|
||||
// Per-session scoped (`set -t <name>`, matching #145's hardening) so a shared
|
||||
// remote tmux server's other sessions keep their own sizing behavior.
|
||||
`set -t ${remoteName} window-size latest`,
|
||||
].join(' \\; ');
|
||||
|
||||
// ssh runs its trailing args through the remote login shell, so the entire
|
||||
@@ -856,15 +904,15 @@ export function dockerTmuxSessionName(sessionId: string): string {
|
||||
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/**
|
||||
* Append the CLI-specific resume flag to a pane command. Only fires when the
|
||||
* in-container tmux is RE-CREATED (`new-session -A` makes the flag inert on a
|
||||
* live reattach), i.e. exactly when the previous live agent was lost and we want
|
||||
* to resume the conversation from the bind-mounted transcript.
|
||||
* Append the CLI-specific resume flag to a pane command (codex/gemini). Only fires
|
||||
* when the in-container tmux is RE-CREATED (`new-session -A` makes the flag inert
|
||||
* on a live reattach), i.e. exactly when the previous live agent was lost and we
|
||||
* want to resume the conversation from the bind-mounted transcript. Claude mode
|
||||
* uses claudeDockerPaneCommand instead.
|
||||
*/
|
||||
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
|
||||
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
|
||||
switch (mode) {
|
||||
case 'claude':
|
||||
case 'gemini':
|
||||
return `${modeCommand} --resume ${resumeId}`;
|
||||
case 'codex':
|
||||
@@ -874,6 +922,30 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude-mode pane command with a DETERMINISTIC conversation id (the docker analog
|
||||
* of buildSpawnCommand's --resume/--session-id logic). A fresh launch passes
|
||||
* `--session-id <sessionId>`, so the in-container conversation id is knowable
|
||||
* host-side (resume-id capture + subagent/workflow correlation) WITHOUT relying on
|
||||
* hook reachability. When the in-container tmux was re-created after a container
|
||||
* stop/reboot, the same command re-runs against the surviving transcript:
|
||||
* `--session-id` exits 1 ("already in use") and the `||` fallback RESUMES that
|
||||
* conversation (verified CLI behavior). An explicit resumeId gets the local
|
||||
* builder's shape — resume first, session-id fallback — so a stale id never
|
||||
* dead-panes. The leading `exec ` is stripped: an exec'd first branch could never
|
||||
* fall back.
|
||||
*/
|
||||
function claudeDockerPaneCommand(modeCommand: string, sessionId: string, resumeId?: string): string {
|
||||
if (!RESUME_ID_SAFE.test(sessionId)) return modeCommand; // defensive — ids are server-minted uuids
|
||||
const cmd = modeCommand.replace(/^exec\s+/, '');
|
||||
const rid = resumeId && RESUME_ID_SAFE.test(resumeId) ? resumeId : undefined;
|
||||
if (rid && rid !== sessionId) {
|
||||
return `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`;
|
||||
}
|
||||
const cid = rid ?? sessionId;
|
||||
return `${cmd} --session-id ${cid} || ${cmd} --resume ${cid}`;
|
||||
}
|
||||
|
||||
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
|
||||
export interface DockerLaunchOptions {
|
||||
mode: SessionMode;
|
||||
@@ -885,6 +957,14 @@ export interface DockerLaunchOptions {
|
||||
execEnv: Record<string, string>;
|
||||
/** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */
|
||||
execEnvNames: string[];
|
||||
/**
|
||||
* Files to copy from read-only seed mounts into the container's writable HOME once
|
||||
* before launch (guarded so reconnects never clobber). Isolates Claude state: the
|
||||
* merged `~/.claude.json`, plus `~/.claude/.credentials.json` + `settings.json`,
|
||||
* are writable copies (not host mounts), so the container never re-auths and never
|
||||
* writes its runtime state back into the host `~/.claude`.
|
||||
*/
|
||||
seedCopies?: DockerSeedCopy[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -895,7 +975,7 @@ export interface DockerLaunchOptions {
|
||||
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
|
||||
*/
|
||||
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames } = opts;
|
||||
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts;
|
||||
const base = buildDockerBaseArgs(docker).join(' ');
|
||||
const createArgs = buildDockerCreateArgs(createContext).join(' ');
|
||||
const name = shellescape(docker.containerName);
|
||||
@@ -905,7 +985,11 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
const sid = sessionId.slice(0, 8);
|
||||
|
||||
let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode);
|
||||
if (resumeSessionId) modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
||||
if (mode === 'claude') {
|
||||
modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId);
|
||||
} else if (resumeSessionId) {
|
||||
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
||||
}
|
||||
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
|
||||
// pane PID the agent itself.
|
||||
const paneCommand = `cd ${workdir} && ${modeCommand}`;
|
||||
@@ -932,7 +1016,7 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
for (const extra of docker.extraExecArgs ?? []) execEnvFlags.push(shellescape(extra));
|
||||
|
||||
const imageMissingMsg = shellescape(
|
||||
`Codeman: base image ${docker.image} not present (build: node scripts/build-agent-image.mjs)`
|
||||
`Codeman: base image ${docker.image} not present (it is normally auto-built on first use)`
|
||||
);
|
||||
const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
|
||||
|
||||
@@ -940,7 +1024,18 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain.
|
||||
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`;
|
||||
const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
||||
const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(tmuxInvocation)}`;
|
||||
// Seed writable credential config from read-only host mounts ONCE per container
|
||||
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
|
||||
// whole-dir credential seeds). mkdir -p the parent so a file seed works even when
|
||||
// no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME
|
||||
// constants (no shell metachars), so the whole inner command is shell-quoted once.
|
||||
const seedSteps = (seedCopies ?? []).map((s) => {
|
||||
const cp = s.recursive ? 'cp -a' : 'cp';
|
||||
const parent = s.to.slice(0, s.to.lastIndexOf('/'));
|
||||
return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`;
|
||||
});
|
||||
const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation;
|
||||
const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`;
|
||||
|
||||
return [imageCheck, ensure, start, execCmd].join(' ; ');
|
||||
}
|
||||
@@ -991,12 +1086,29 @@ export function resolveDockerLaunchOptions(
|
||||
: ['--user', `${uid}:0`]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME)
|
||||
const gatewayAlias = hostGatewayAlias(docker.engine);
|
||||
|
||||
const credentialMounts: DockerMount[] = docker.mountCredentials ? resolveCredentialMounts(home) : [];
|
||||
const credentialMounts: DockerMount[] = [];
|
||||
const extraMounts: DockerMount[] = [];
|
||||
// Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store
|
||||
// shares ONLY what a host feature / --resume needs (Claude projects/, codex
|
||||
// sessions/+history) and seeds everything else (tokens, settings, configs) as
|
||||
// writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing
|
||||
// its runtime state back into the host dirs. Only when credentials are mounted.
|
||||
let seedCopies: DockerSeedCopy[] = [];
|
||||
if (docker.mountCredentials) {
|
||||
const claudeArtifacts = resolveDockerClaudeArtifacts(home, docker.containerName, docker.containerWorkdir);
|
||||
const credArtifacts = resolveDockerCredentialArtifacts(home);
|
||||
extraMounts.push(...claudeArtifacts.mounts, ...credArtifacts.mounts);
|
||||
seedCopies = [...claudeArtifacts.seedCopies, ...credArtifacts.seedCopies];
|
||||
}
|
||||
const envCreate: Record<string, string> = {
|
||||
HOME: CONTAINER_HOME,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
// Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux
|
||||
// runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw
|
||||
// VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen).
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
// Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-<uid>`
|
||||
// is refused when that path pre-exists root-owned — which happens when the
|
||||
// workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-<uid>).
|
||||
@@ -1031,6 +1143,11 @@ export function resolveDockerLaunchOptions(
|
||||
const execEnv: Record<string, string> = {
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
// UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and
|
||||
// renders box-drawing correctly even when reattaching to a container created
|
||||
// before this fix (client_utf8 is per-client, resolved from the exec's locale).
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
CODEMAN_SESSION_ID: sessionId.slice(0, 8),
|
||||
CODEMAN_MUX: '1',
|
||||
};
|
||||
@@ -1043,7 +1160,57 @@ export function resolveDockerLaunchOptions(
|
||||
? ['GEMINI_API_KEY', 'GOOGLE_API_KEY']
|
||||
: [];
|
||||
|
||||
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames };
|
||||
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies };
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build the SSH command that ATTACHES to an EXISTING `codeman-*` tmux
|
||||
* session on the remote host (one this Codeman didn't create — discovered via
|
||||
* `listRemoteCodemanSessions`). Sibling of `buildRemoteLaunchCommand`.
|
||||
*
|
||||
* Emits:
|
||||
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
|
||||
* 'tmux -L codeman attach -t <session>'
|
||||
*
|
||||
* - `attach` (NOT `new-session -A`) so we only join an existing session; the
|
||||
* remote session keeps running independent of us, which is exactly why the
|
||||
* resulting Codeman session is NON-OWNED (see `SessionRemote.owned`): closing
|
||||
* the local tab must detach, never `kill-session` the remote.
|
||||
* - The remote session name is shell-escaped so a value with metachars stays a
|
||||
* single token inside the quoted tmux invocation.
|
||||
* - COD-107 — connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
|
||||
* arbitrary `-o`) come from the shared `buildSshConnectionArgs`, so attach
|
||||
* connects identically to launch / discovery / the prereq probe. `-t` sits
|
||||
* right after `ssh -o BatchMode=yes` (a PTY is required for interactive tmux).
|
||||
*/
|
||||
export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionName: string): string {
|
||||
const tmuxInvocation = `tmux -L codeman attach -t ${shellescape(remoteSessionName)}`;
|
||||
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
|
||||
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
|
||||
return sshParts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — choose the right remote ssh command for a session's ownership:
|
||||
* - NON-owned (`remote.owned === false`): ATTACH to a discovered remote tmux
|
||||
* session by its EXISTING name (`remote.remoteSessionName`, falling back to
|
||||
* this session's deterministic name). We only join — never create.
|
||||
* - owned (default): LAUNCH/attach-or-create via `buildRemoteLaunchCommand`
|
||||
* (COD-104), which we then own and may explicitly kill.
|
||||
*/
|
||||
function buildRemoteSessionCommand(options: {
|
||||
mode: SessionMode;
|
||||
remote: SessionRemote;
|
||||
sessionId: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
}): string {
|
||||
const { remote, sessionId } = options;
|
||||
if (remote.owned === false) {
|
||||
const target = remote.remoteSessionName || remoteTmuxSessionName(sessionId);
|
||||
return buildRemoteAttachCommand(remote, target);
|
||||
}
|
||||
return buildRemoteLaunchCommand(options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1199,6 +1366,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
|
||||
private lastPaneCount: Map<string, number> = new Map();
|
||||
|
||||
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
|
||||
/** Periodic watcher that re-establishes dropped remote sessions. */
|
||||
private remoteReconnectInterval: NodeJS.Timeout | null = null;
|
||||
/** Per-session backoff/attempt bookkeeping (sessionId → state). */
|
||||
private reconnectState: Map<string, RemoteReconnectState> = new Map();
|
||||
/**
|
||||
* Sessions excluded from auto-reconnect because they are being intentionally
|
||||
* torn down (killed/detached/stopping). A guarded session is NEVER revived.
|
||||
*/
|
||||
private reconnectGuard: Set<string> = new Set();
|
||||
|
||||
private trueColorConfigured = false;
|
||||
|
||||
constructor() {
|
||||
@@ -1444,6 +1622,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
owner,
|
||||
} = options;
|
||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
@@ -1464,6 +1643,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
remote,
|
||||
docker,
|
||||
owner,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1512,7 +1692,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteLaunchCommand({ mode, remote, sessionId })
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
: localFullCmd;
|
||||
|
||||
// Create tmux session in three steps to handle cold-start (no server running)
|
||||
@@ -1640,6 +1820,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
remote,
|
||||
docker,
|
||||
owner,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1766,7 +1947,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteLaunchCommand({ mode, remote, sessionId })
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
: localFullCmd;
|
||||
|
||||
try {
|
||||
@@ -1882,9 +2063,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return false;
|
||||
}
|
||||
|
||||
// COD-108: an intentional kill/detach must NEVER be auto-revived by the
|
||||
// remote-reconnect watcher. Guard BEFORE any teardown so a tick that fires
|
||||
// mid-kill (especially the non-owned DETACH early-return below, where the
|
||||
// dead local pane would otherwise look reconnectable) sees the guard.
|
||||
this.guardRemoteReconnect(sessionId);
|
||||
|
||||
// TEST MODE: Remove from memory only — NEVER touch real tmux sessions
|
||||
if (IS_TEST_MODE) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.emit('sessionKilled', { sessionId });
|
||||
return true;
|
||||
}
|
||||
@@ -1896,6 +2084,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return false;
|
||||
}
|
||||
|
||||
// COD-105 — DETACH-NOT-KILL for NON-owned remote sessions.
|
||||
//
|
||||
// When this session was created by ATTACHING a remote tmux session another
|
||||
// Codeman owns (`remote.owned === false`), closing the tab must NOT propagate
|
||||
// a remote `tmux kill-session` — that would nuke work the remote's own
|
||||
// Codeman (or another instance) still relies on. We tear down ONLY the LOCAL
|
||||
// pane that holds the ssh client: killing the local ssh sends SIGHUP to its
|
||||
// remote `tmux attach`, which DETACHES (the durable remote session survives).
|
||||
//
|
||||
// This early return is the structural guarantee: no code below this point
|
||||
// (now or in future for owned sessions) can ever issue a remote kill-session
|
||||
// for a non-owned session. The only `kill-session` we run is on OUR LOCAL
|
||||
// socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the
|
||||
// local pane — it does NOT reach the REMOTE socket.
|
||||
if (session.remote && session.remote.owned === false) {
|
||||
console.log(`[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${session.muxName}`);
|
||||
if (isValidMuxName(session.muxName)) {
|
||||
try {
|
||||
// Local socket only — detaches the remote session by killing the local ssh pane.
|
||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
// Local pane may already be gone.
|
||||
}
|
||||
}
|
||||
this.lastPaneCount.delete(session.muxName);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.saveSessions();
|
||||
this.emit('sessionKilled', { sessionId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get current PID (may have changed)
|
||||
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
||||
|
||||
@@ -2000,6 +2222,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
this.lastPaneCount.delete(session.muxName);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.saveSessions();
|
||||
this.emit('sessionKilled', { sessionId });
|
||||
|
||||
@@ -2066,6 +2289,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
} else {
|
||||
dead.push(sessionId);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.emit('sessionDied', { sessionId });
|
||||
}
|
||||
}
|
||||
@@ -2337,9 +2561,118 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.lastPaneCount.clear();
|
||||
}
|
||||
|
||||
// ── COD-108 remote-session auto-reconnect watcher ─────────────────────────
|
||||
|
||||
/**
|
||||
* Start the remote-reconnect watcher (COD-108). Each tick, for every tracked
|
||||
* session with `session.remote` whose local pane is DEAD, not intentionally
|
||||
* guarded, and within its backoff budget, emit `remoteSessionDropped` so the
|
||||
* session owner reattaches (re-running the idempotent remote command rejoins
|
||||
* the durable remote tmux session). After the attempt cap, emit
|
||||
* `remoteReconnectExhausted` once and go quiet.
|
||||
*
|
||||
* No-op tick body under `IS_TEST_MODE` (mirrors `startMouseModeSync`): tests
|
||||
* drive the logic deterministically via {@link runRemoteReconnectTick}.
|
||||
*/
|
||||
startRemoteReconnectWatcher(intervalMs: number = DEFAULT_REMOTE_RECONNECT_INTERVAL_MS): void {
|
||||
if (this.remoteReconnectInterval) {
|
||||
clearInterval(this.remoteReconnectInterval);
|
||||
}
|
||||
this.remoteReconnectInterval = setInterval(() => {
|
||||
if (IS_TEST_MODE) return;
|
||||
try {
|
||||
this.runRemoteReconnectTick(Date.now(), isRemoteAutoReconnectEnabled());
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Remote reconnect watcher error:', err);
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stopRemoteReconnectWatcher(): void {
|
||||
if (this.remoteReconnectInterval) {
|
||||
clearInterval(this.remoteReconnectInterval);
|
||||
this.remoteReconnectInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ONE watcher tick. Extracted (and given an injected `now`/`enabled`) so
|
||||
* the reconnect logic is deterministically testable even though the live
|
||||
* `setInterval` body no-ops under test mode. For each remote session it
|
||||
* applies the pure {@link decideReconnect} decision and translates the result
|
||||
* into events + backoff/state transitions. Public for tests + the watcher.
|
||||
*/
|
||||
runRemoteReconnectTick(now: number, enabled: boolean): void {
|
||||
for (const session of this.sessions.values()) {
|
||||
if (!session.remote) continue;
|
||||
const sessionId = session.sessionId;
|
||||
const state = this.reconnectState.get(sessionId);
|
||||
const action = decideReconnect({
|
||||
session: {
|
||||
sessionId,
|
||||
isRemote: true,
|
||||
paneDead: this.isPaneDead(session.muxName),
|
||||
},
|
||||
state,
|
||||
guarded: this.reconnectGuard.has(sessionId),
|
||||
enabled,
|
||||
now,
|
||||
});
|
||||
|
||||
if (action.kind === 'emit') {
|
||||
const base = state ?? freshReconnectState();
|
||||
// Mark in-flight + advance backoff BEFORE emitting so a re-entrant tick
|
||||
// (or a synchronous listener) can never stack a second reconnect.
|
||||
this.reconnectState.set(sessionId, { ...advanceBackoff(base, now), inFlight: true });
|
||||
this.emit('remoteSessionDropped', { sessionId, attempt: action.attempt });
|
||||
} else if (action.kind === 'exhaust') {
|
||||
const base = state ?? freshReconnectState();
|
||||
if (!base.exhaustedEmitted) {
|
||||
this.reconnectState.set(sessionId, { ...base, exhausted: true, exhaustedEmitted: true });
|
||||
this.emit('remoteReconnectExhausted', { sessionId });
|
||||
}
|
||||
}
|
||||
// 'skip' → nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the watcher a reattach attempt for `sessionId` finished. On success,
|
||||
* reset the backoff so the session is healthy again; on failure, just clear
|
||||
* the in-flight flag so the next due tick can retry under the existing
|
||||
* backoff schedule. Called by the session owner after `respawnPane`.
|
||||
*/
|
||||
noteRemoteReconnect(sessionId: string, success: boolean): void {
|
||||
if (success) {
|
||||
this.reconnectState.set(sessionId, resetReconnectState());
|
||||
return;
|
||||
}
|
||||
const state = this.reconnectState.get(sessionId);
|
||||
if (state) this.reconnectState.set(sessionId, { ...state, inFlight: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude a session from auto-reconnect (intentional teardown). Adds it to the
|
||||
* guard set and drops any backoff state so a closed/killed tab — especially a
|
||||
* non-owned remote DETACH — is never auto-revived. Idempotent.
|
||||
*/
|
||||
guardRemoteReconnect(sessionId: string): void {
|
||||
this.reconnectGuard.add(sessionId);
|
||||
this.reconnectState.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Clear all per-session reconnect + guard state (e.g. when a session is removed). */
|
||||
clearRemoteReconnectState(sessionId: string): void {
|
||||
this.reconnectState.delete(sessionId);
|
||||
this.reconnectGuard.delete(sessionId);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stopStatsCollection();
|
||||
this.stopMouseModeSync();
|
||||
this.stopRemoteReconnectWatcher();
|
||||
this.reconnectState.clear();
|
||||
this.reconnectGuard.clear();
|
||||
}
|
||||
|
||||
registerSession(session: MuxSession): void {
|
||||
|
||||
+51
-8
@@ -43,6 +43,8 @@ interface QrTokenRecord {
|
||||
shortCode: string; // 6 chars base62 (for URL path)
|
||||
createdAt: number; // Date.now()
|
||||
consumed: boolean; // single-use flag
|
||||
/** Multi-user: the user this token logs in when redeemed (absent = rotating global token). */
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/** Rejection-sampled base62 short code — no modulo bias */
|
||||
@@ -378,23 +380,64 @@ export class TunnelManager extends EventEmitter {
|
||||
* Map.get() is hash-based — no timing side-channel from string comparison.
|
||||
*/
|
||||
consumeToken(shortCode: string): boolean {
|
||||
return this.consumeTokenWithIdentity(shortCode).ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like consumeToken, but also returns the bound username for multi-user tokens
|
||||
* (undefined for the rotating global token). Only the identity-less rotating
|
||||
* token triggers an immediate re-rotation (desktop gets a fresh QR); per-user
|
||||
* tokens are on-demand and self-expire.
|
||||
*/
|
||||
consumeTokenWithIdentity(shortCode: string): { ok: boolean; username?: string } {
|
||||
// Global rate limit (across all IPs)
|
||||
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false;
|
||||
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return { ok: false };
|
||||
this.qrAttemptCount++;
|
||||
|
||||
const record = this.qrTokensByCode.get(shortCode);
|
||||
if (!record) return false;
|
||||
if (record.consumed) return false;
|
||||
if (!record) return { ok: false };
|
||||
if (record.consumed) return { ok: false };
|
||||
|
||||
const now = Date.now();
|
||||
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false;
|
||||
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return { ok: false };
|
||||
|
||||
// Atomic consume (single-threaded JS = no race)
|
||||
record.consumed = true;
|
||||
// Immediately rotate so desktop gets a fresh QR
|
||||
this.rotateToken();
|
||||
this.emit('qrTokenRegenerated');
|
||||
return true;
|
||||
const username = record.username;
|
||||
if (!username) {
|
||||
// Rotating global token — immediately rotate so desktop gets a fresh QR.
|
||||
this.rotateToken();
|
||||
this.emit('qrTokenRegenerated');
|
||||
} else {
|
||||
this.qrTokensByCode.delete(shortCode);
|
||||
}
|
||||
return { ok: true, username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-user: mint a single-use token bound to a specific user (on-demand, no
|
||||
* rotation). Evicts expired/consumed tokens first. Returns the short code.
|
||||
*/
|
||||
mintUserToken(username: string): string {
|
||||
const now = Date.now();
|
||||
for (const [code, rec] of this.qrTokensByCode) {
|
||||
if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) this.qrTokensByCode.delete(code);
|
||||
}
|
||||
const record: QrTokenRecord = {
|
||||
token: randomBytes(32).toString('hex'),
|
||||
shortCode: generateShortCode(),
|
||||
createdAt: Date.now(),
|
||||
consumed: false,
|
||||
username,
|
||||
};
|
||||
this.qrTokensByCode.set(record.shortCode, record);
|
||||
return record.shortCode;
|
||||
}
|
||||
|
||||
/** Render a QR SVG for an arbitrary short code (used by per-user minting). */
|
||||
async getQrSvgForCode(tunnelUrl: string, code: string): Promise<string> {
|
||||
const QRCode = await import('qrcode');
|
||||
return QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 });
|
||||
}
|
||||
|
||||
/** Force-regenerate (manual revocation via API) */
|
||||
|
||||
@@ -37,6 +37,16 @@ export enum ApiErrorCode {
|
||||
RATE_LIMITED = 'RATE_LIMITED',
|
||||
/** Operation could not be completed (well-formed but unprocessable) */
|
||||
OPERATION_FAILED = 'OPERATION_FAILED',
|
||||
/** Authenticated but not permitted (e.g. non-admin hitting an admin route) */
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
/** User must change their password before any other action (multi-user) */
|
||||
PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED',
|
||||
/** A user with this name already exists (multi-user) */
|
||||
USER_EXISTS = 'USER_EXISTS',
|
||||
/** No user with this name (multi-user) */
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
/** Refusing to demote/disable/delete the last enabled admin (multi-user) */
|
||||
LAST_ADMIN = 'LAST_ADMIN',
|
||||
/** Internal server error */
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
||||
}
|
||||
@@ -53,6 +63,11 @@ const ErrorMessages: Record<ApiErrorCode, string> = {
|
||||
[ApiErrorCode.ALREADY_EXISTS]: 'Resource already exists',
|
||||
[ApiErrorCode.RATE_LIMITED]: 'Too many requests',
|
||||
[ApiErrorCode.OPERATION_FAILED]: 'The operation failed',
|
||||
[ApiErrorCode.FORBIDDEN]: 'You do not have permission to perform this action',
|
||||
[ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 'You must change your password before continuing',
|
||||
[ApiErrorCode.USER_EXISTS]: 'A user with that name already exists',
|
||||
[ApiErrorCode.USER_NOT_FOUND]: 'No such user',
|
||||
[ApiErrorCode.LAST_ADMIN]: 'Cannot remove the last enabled admin',
|
||||
[ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred',
|
||||
};
|
||||
|
||||
@@ -69,6 +84,11 @@ const ErrorStatus: Record<ApiErrorCode, number> = {
|
||||
[ApiErrorCode.CONFLICT]: 409,
|
||||
[ApiErrorCode.ALREADY_EXISTS]: 409,
|
||||
[ApiErrorCode.OPERATION_FAILED]: 422,
|
||||
[ApiErrorCode.FORBIDDEN]: 403,
|
||||
[ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 403,
|
||||
[ApiErrorCode.USER_EXISTS]: 409,
|
||||
[ApiErrorCode.USER_NOT_FOUND]: 404,
|
||||
[ApiErrorCode.LAST_ADMIN]: 409,
|
||||
[ApiErrorCode.RATE_LIMITED]: 429,
|
||||
[ApiErrorCode.INTERNAL_ERROR]: 500,
|
||||
};
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface AppState {
|
||||
cronJobs?: Record<string, CronJob>;
|
||||
/** Scheduled job run history, keyed by run ID. */
|
||||
cronJobRuns?: Record<string, CronJobRun>;
|
||||
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
|
||||
sessionOrder?: string[];
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
@@ -36,6 +36,8 @@ export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running';
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Owning username in multi-user mode; the job launches as this user. Undefined in single-user. */
|
||||
owner?: string;
|
||||
/** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */
|
||||
agentType: SessionMode;
|
||||
workingDir: string;
|
||||
|
||||
@@ -69,3 +69,4 @@ export * from './orchestrator.js';
|
||||
export * from './update.js';
|
||||
export * from './workflow-run.js';
|
||||
export * from './search.js';
|
||||
export * from './user.js';
|
||||
|
||||
+52
-2
@@ -9,7 +9,7 @@
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
|
||||
@@ -35,10 +35,11 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
/**
|
||||
* Claude CLI startup permission mode.
|
||||
* - `'dangerously-skip-permissions'`: Bypass all permission prompts (default)
|
||||
* - `'auto'`: Anthropic's classifier-guarded low-prompt mode (`--permission-mode auto`)
|
||||
* - `'normal'`: Standard mode with permission prompts
|
||||
* - `'allowedTools'`: Only allow specific tools (requires allowedTools list)
|
||||
*/
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
|
||||
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
|
||||
@@ -84,6 +85,8 @@ export interface RemoteHost extends RemoteSshOptions {
|
||||
export interface RemoteCase {
|
||||
name: string;
|
||||
type: 'remote';
|
||||
/** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */
|
||||
owner?: string;
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
}
|
||||
@@ -96,6 +99,45 @@ export interface SessionRemote extends RemoteSshOptions {
|
||||
port?: number;
|
||||
remotePath: string;
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
/**
|
||||
* COD-105 — whether THIS Codeman created the remote tmux session.
|
||||
*
|
||||
* - `true` (default for COD-104 launched sessions): we own the remote session;
|
||||
* an explicit "kill" may propagate a remote `tmux kill-session`.
|
||||
* - `false` (discovered + attached an existing remote session another Codeman
|
||||
* created): closing the local tab must DETACH only — we must NEVER issue a
|
||||
* remote `kill-session`, or we'd nuke work the remote's own Codeman (or
|
||||
* another instance) still relies on. See `killSession()` gate.
|
||||
*
|
||||
* Absent is treated as owned (legacy/COD-104 sessions persisted before this
|
||||
* field existed were all launched by us).
|
||||
*/
|
||||
owned?: boolean;
|
||||
/**
|
||||
* COD-105 — for a NON-owned (discovered + attached) session, the EXISTING
|
||||
* remote tmux session name to `attach -t` (e.g. `codeman-disco1`). It differs
|
||||
* from this Codeman's deterministic `codeman-<id>` name because the remote
|
||||
* session was created elsewhere. Only meaningful when `owned === false`.
|
||||
*/
|
||||
remoteSessionName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — a `codeman-*` tmux session discovered on a remote host's
|
||||
* `tmux -L codeman` socket (may have been created by the remote's own Codeman,
|
||||
* another instance, or this one). Returned by `listRemoteCodemanSessions`.
|
||||
*/
|
||||
export interface RemoteSessionInfo {
|
||||
/** tmux session name (always starts `codeman-`). */
|
||||
name: string;
|
||||
/** Whether at least one client is currently attached to the remote session. */
|
||||
attached: boolean;
|
||||
/** COD-106 — number of clients attached (tmux `session_attached`); >1 = shared. */
|
||||
attachedClients: number;
|
||||
/** tmux `session_created` epoch seconds. */
|
||||
created: number;
|
||||
/** Number of windows in the remote session. */
|
||||
windows: number;
|
||||
}
|
||||
|
||||
// ========== Docker cases (COD-Docker) ==========
|
||||
@@ -173,6 +215,8 @@ export interface DockerHost {
|
||||
export interface DockerCase {
|
||||
name: string;
|
||||
type: 'docker';
|
||||
/** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */
|
||||
owner?: string;
|
||||
hostId: string;
|
||||
/** Absolute HOST directory: the bind-mount source AND Session.workingDir (real host bytes). */
|
||||
hostWorkspacePath: string;
|
||||
@@ -335,6 +379,8 @@ export interface SessionState {
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username in multi-user mode; undefined in single-user (ignored when the flag is off) */
|
||||
owner?: string;
|
||||
/** ID of currently assigned task, null if none */
|
||||
currentTaskId: string | null;
|
||||
/** Timestamp when session was created */
|
||||
@@ -359,6 +405,10 @@ export interface SessionState {
|
||||
autoResumeEnabled?: boolean;
|
||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||
autoResumeAt?: number;
|
||||
/** Pinned to the top of the session manager list (COD-139) */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
|
||||
pinnedAt?: number;
|
||||
/** Image watcher enabled for this session */
|
||||
imageWatcherEnabled?: boolean;
|
||||
/** Total cost in USD */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @fileoverview Multi-user mode types (opt-in `--multiuser`).
|
||||
*
|
||||
* Users live in `~/.codeman/users.json` (via `dataPath`, mode 0600). Each record
|
||||
* carries a scrypt password hash with its own parameters so hashing cost can be
|
||||
* raised later and old records rehashed on next login. `AuthUser` is the
|
||||
* request-scoped identity decorated onto Fastify requests; in SINGLE-user mode a
|
||||
* synthetic `{ username: 'admin', role: 'admin' }` is used so downstream code has
|
||||
* one code path. See `src/user-store.ts` and `docs/multi-user-plan.md`.
|
||||
*/
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
|
||||
/** Per-record scrypt parameters + salt/hash (all hex). */
|
||||
export interface PasswordHash {
|
||||
algo: 'scrypt';
|
||||
N: number;
|
||||
r: number;
|
||||
p: number;
|
||||
salt: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface UserRecord {
|
||||
/** Canonical lowercase slug; also the user's folder name under USER_SPACES_DIR. */
|
||||
username: string;
|
||||
role: UserRole;
|
||||
password: PasswordHash;
|
||||
/** Disabled accounts fail auth closed but keep their space on disk. */
|
||||
disabled?: boolean;
|
||||
/** Set by an admin reset; gates all API access until the user changes it. */
|
||||
mustChangePassword?: boolean;
|
||||
/**
|
||||
* Permission-mode grant (section 6.3). When false (the default for new users),
|
||||
* the user's Claude sessions are forced to `--permission-mode auto`, shell mode
|
||||
* and cron `launchCommand` are refused, and other CLIs' bypass flags are dropped.
|
||||
*/
|
||||
canBypassPermissions?: boolean;
|
||||
createdAt: number;
|
||||
lastLoginAt?: number;
|
||||
}
|
||||
|
||||
/** On-disk shape of `users.json`. */
|
||||
export interface UsersFile {
|
||||
version: 1;
|
||||
users: UserRecord[];
|
||||
}
|
||||
|
||||
/** Request-scoped identity (decorated as `req.authUser`). */
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
/** Admin-facing projection of a user: never carries the password hash. */
|
||||
export interface PublicUser {
|
||||
username: string;
|
||||
role: UserRole;
|
||||
disabled: boolean;
|
||||
mustChangePassword: boolean;
|
||||
canBypassPermissions: boolean;
|
||||
createdAt: number;
|
||||
lastLoginAt?: number;
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* @fileoverview Multi-user store: `~/.codeman/users.json` (via `dataPath`, 0600).
|
||||
*
|
||||
* Mirrors the storage-module pattern of `remote-hosts.ts` / `docker-hosts.ts`, but
|
||||
* because it holds password hashes it writes atomically (tmp + rename) at mode
|
||||
* 0600 and keeps only a SHORT in-process cache so the CLI (`codeman users …`) can
|
||||
* edit the file while the server runs and have changes picked up within the TTL.
|
||||
*
|
||||
* Pure, IO-free helpers (`isValidUsername`, `hashPassword`, `verifyPasswordHash`,
|
||||
* `needsRehash`, `resolveClaudeModeForUser`, the last-admin invariants) are split
|
||||
* out so they are unit-testable without a server. Hashing is `scrypt` from
|
||||
* `node:crypto` (no new deps), compared via `timingSafeEqual`; parameters are
|
||||
* stored per record so cost can be raised later and old records rehashed on their
|
||||
* next successful login.
|
||||
*
|
||||
* See `docs/multi-user-plan.md` sections 4.1, 5, 6.3.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative } from 'node:path';
|
||||
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { dataPath, getDataDir } from './config/instance.js';
|
||||
import { getUserSpacesDir, isMultiUserMode, maxUsers } from './config/multiuser.js';
|
||||
import type { AuthUser, ClaudeMode, PasswordHash, PublicUser, UserRecord, UserRole, UsersFile } from './types.js';
|
||||
|
||||
const scrypt = promisify(scryptCb) as (
|
||||
password: string | Buffer,
|
||||
salt: string | Buffer,
|
||||
keylen: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number }
|
||||
) => Promise<Buffer>;
|
||||
|
||||
const USERS_FILE = 'users.json';
|
||||
const CACHE_TTL_MS = 1000;
|
||||
const KEYLEN = 64;
|
||||
const SALT_BYTES = 32;
|
||||
/** Generous ceiling so raising N/r later does not trip scrypt's memory guard. */
|
||||
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
|
||||
|
||||
/** Current hashing parameters. Stored per record; raise these to increase cost. */
|
||||
export const DEFAULT_SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 } as const;
|
||||
|
||||
/** Username: lowercase, first char alphanumeric, 2-32 chars total. Becomes a folder name. */
|
||||
const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,31}$/;
|
||||
|
||||
/** Typed error whose `.code` maps to an API errorCode at the route layer. */
|
||||
export class UserStoreError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: 'USER_EXISTS' | 'USER_NOT_FOUND' | 'LAST_ADMIN' | 'INVALID_INPUT'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'UserStoreError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────── pure helpers ───────────────────────────────
|
||||
|
||||
export function normalizeUsername(name: string): string {
|
||||
return String(name ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isValidUsername(name: string): boolean {
|
||||
return USERNAME_RE.test(normalizeUsername(name));
|
||||
}
|
||||
|
||||
/** Hash a password with the given (or current) scrypt params + a fresh random salt. */
|
||||
export async function hashPassword(
|
||||
password: string,
|
||||
params: { N: number; r: number; p: number } = DEFAULT_SCRYPT_PARAMS
|
||||
): Promise<PasswordHash> {
|
||||
const salt = randomBytes(SALT_BYTES);
|
||||
const derived = await scrypt(password, salt, KEYLEN, { ...params, maxmem: SCRYPT_MAXMEM });
|
||||
return {
|
||||
algo: 'scrypt',
|
||||
N: params.N,
|
||||
r: params.r,
|
||||
p: params.p,
|
||||
salt: salt.toString('hex'),
|
||||
hash: derived.toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Constant-time verify of a password against a stored hash record. Never throws. */
|
||||
export async function verifyPasswordHash(password: string, record: PasswordHash): Promise<boolean> {
|
||||
if (!record || record.algo !== 'scrypt') return false;
|
||||
let salt: Buffer;
|
||||
let expected: Buffer;
|
||||
try {
|
||||
salt = Buffer.from(record.salt, 'hex');
|
||||
expected = Buffer.from(record.hash, 'hex');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (expected.length === 0) return false;
|
||||
let derived: Buffer;
|
||||
try {
|
||||
derived = await scrypt(password, salt, expected.length, {
|
||||
N: record.N,
|
||||
r: record.r,
|
||||
p: record.p,
|
||||
maxmem: SCRYPT_MAXMEM,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (derived.length !== expected.length) return false;
|
||||
return timingSafeEqual(derived, expected);
|
||||
}
|
||||
|
||||
/** True when a stored hash uses weaker params than current and should be rehashed. */
|
||||
export function needsRehash(record: PasswordHash, params = DEFAULT_SCRYPT_PARAMS): boolean {
|
||||
return record.algo !== 'scrypt' || record.N !== params.N || record.r !== params.r || record.p !== params.p;
|
||||
}
|
||||
|
||||
/** URL-safe one-time password (16 chars) for admin create/reset flows. */
|
||||
export function generateOneTimePassword(): string {
|
||||
return randomBytes(12).toString('base64url');
|
||||
}
|
||||
|
||||
export function toPublicUser(u: UserRecord): PublicUser {
|
||||
return {
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
disabled: !!u.disabled,
|
||||
mustChangePassword: !!u.mustChangePassword,
|
||||
canBypassPermissions: !!u.canBypassPermissions,
|
||||
createdAt: u.createdAt,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function countEnabledAdmins(users: UserRecord[]): number {
|
||||
return users.filter((u) => u.role === 'admin' && !u.disabled).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section 6.3: resolve the effective Claude permission mode for a user. Admins and
|
||||
* granted users get the global mode as-is; a non-granted regular user whose mode
|
||||
* would be `dangerously-skip-permissions` is silently downgraded to `auto` (all
|
||||
* other modes are already <= auto and pass through). Pure.
|
||||
*/
|
||||
export function resolveClaudeModeForUser(
|
||||
globalMode: ClaudeMode | undefined,
|
||||
grant: { role: UserRole; canBypassPermissions?: boolean }
|
||||
): ClaudeMode {
|
||||
const mode: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
|
||||
if (grant.role === 'admin' || grant.canBypassPermissions) return mode;
|
||||
return mode === 'dangerously-skip-permissions' ? 'auto' : mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section 6.3: whether a user may run arbitrary commands as the host account
|
||||
* (shell-mode sessions, cron `launchCommand`, other CLIs' bypass flags). Same
|
||||
* one-bit grant as bypass. Admins always may.
|
||||
*/
|
||||
export function canRunPrivilegedCommands(grant: { role: UserRole; canBypassPermissions?: boolean }): boolean {
|
||||
return grant.role === 'admin' || !!grant.canBypassPermissions;
|
||||
}
|
||||
|
||||
// ─────────────────────────────── IO layer ───────────────────────────────
|
||||
|
||||
let cache: { users: UserRecord[]; ts: number } | null = null;
|
||||
|
||||
/** Drop the in-process cache (called after every write; exported for tests). */
|
||||
export function invalidateUsersCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
export async function readUsers(force = false): Promise<UserRecord[]> {
|
||||
const now = Date.now();
|
||||
if (!force && cache && now - cache.ts < CACHE_TTL_MS) return cache.users;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(dataPath(USERS_FILE), 'utf-8');
|
||||
} catch (err) {
|
||||
// ENOENT is the ONLY legitimately-empty store (first boot). Any other read
|
||||
// error (EIO/EACCES/EMFILE/EBUSY) is a transient/permission failure, NOT an
|
||||
// empty store — do NOT cache [] and do NOT let it look empty, or a following
|
||||
// createUser/bootstrap would overwrite users.json and destroy every account.
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
cache = { users: [], ts: now };
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// A present-but-corrupt file (invalid JSON) must also fail loud rather than
|
||||
// read as empty, so mutators/bootstrap abort instead of clobbering it.
|
||||
const parsed = JSON.parse(raw) as Partial<UsersFile>;
|
||||
const users = Array.isArray(parsed.users) ? parsed.users : [];
|
||||
cache = { users, ts: now };
|
||||
return users;
|
||||
}
|
||||
|
||||
async function writeUsers(users: UserRecord[]): Promise<void> {
|
||||
const dir = getDataDir();
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const finalPath = dataPath(USERS_FILE);
|
||||
// Unique per-writer tmp name (pid + random) so the CLI (`codeman users …`) and
|
||||
// the live server — designed to write this file concurrently across processes —
|
||||
// never share a single `users.json.tmp` inode and tear each other's payload.
|
||||
// Matches the state-store.ts / self-update.ts convention.
|
||||
const tmpPath = `${finalPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
||||
const payload: UsersFile = { version: 1, users };
|
||||
try {
|
||||
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
||||
await fs.chmod(tmpPath, 0o600).catch(() => {});
|
||||
await fs.rename(tmpPath, finalPath);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
cache = { users, ts: Date.now() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize every read-modify-write on users.json. Without this a fire-and-forget
|
||||
* touchLastLogin (fired on each Basic auth) can interleave with a route's
|
||||
* create/update and clobber records, since both do readUsers(true) → mutate →
|
||||
* writeUsers against a single shared file + tmp path.
|
||||
*/
|
||||
let mutateChain: Promise<unknown> = Promise.resolve();
|
||||
function withUsersLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = mutateChain.then(fn, fn);
|
||||
mutateChain = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function hasUsers(): Promise<boolean> {
|
||||
return (await readUsers()).length > 0;
|
||||
}
|
||||
|
||||
// A precomputed dummy hash so an unknown/disabled user costs the same scrypt work
|
||||
// as a real verify (defeats username-enumeration by timing). Created once, lazily.
|
||||
let dummyHashPromise: Promise<PasswordHash> | null = null;
|
||||
function getDummyHash(): Promise<PasswordHash> {
|
||||
if (!dummyHashPromise) dummyHashPromise = hashPassword('codeman-timing-equalization-placeholder');
|
||||
return dummyHashPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a username/password against the store. Returns the record (plus whether it
|
||||
* should be rehashed) on success, or null for wrong password / unknown / disabled
|
||||
* user. Runs a dummy scrypt on the miss path so timing does not reveal which users
|
||||
* exist. Never writes (the caller decides when to persist lastLogin / rehash).
|
||||
*/
|
||||
export async function verifyPassword(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<{ user: UserRecord; needsRehash: boolean } | null> {
|
||||
const user = await findUser(username);
|
||||
if (!user || user.disabled) {
|
||||
await verifyPasswordHash(password, await getDummyHash());
|
||||
return null;
|
||||
}
|
||||
const ok = await verifyPasswordHash(password, user.password);
|
||||
if (!ok) return null;
|
||||
return { user, needsRehash: needsRehash(user.password) };
|
||||
}
|
||||
|
||||
export async function findUser(username: string): Promise<UserRecord | undefined> {
|
||||
const norm = normalizeUsername(username);
|
||||
if (!norm) return undefined;
|
||||
const users = await readUsers();
|
||||
return users.find((u) => u.username === norm);
|
||||
}
|
||||
|
||||
export interface CreateUserOptions {
|
||||
username: string;
|
||||
role: UserRole;
|
||||
password: string;
|
||||
mustChangePassword?: boolean;
|
||||
canBypassPermissions?: boolean;
|
||||
}
|
||||
|
||||
export async function createUser(opts: CreateUserOptions): Promise<UserRecord> {
|
||||
const username = normalizeUsername(opts.username);
|
||||
if (!isValidUsername(username)) {
|
||||
throw new UserStoreError(
|
||||
'Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])',
|
||||
'INVALID_INPUT'
|
||||
);
|
||||
}
|
||||
if (opts.role !== 'admin' && opts.role !== 'user') {
|
||||
throw new UserStoreError('Role must be "admin" or "user"', 'INVALID_INPUT');
|
||||
}
|
||||
if (!opts.password || opts.password.length < 8) {
|
||||
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
|
||||
}
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
if (users.some((u) => u.username === username)) {
|
||||
throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS');
|
||||
}
|
||||
if (users.length >= maxUsers()) {
|
||||
throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT');
|
||||
}
|
||||
const record: UserRecord = {
|
||||
username,
|
||||
role: opts.role,
|
||||
password: await hashPassword(opts.password),
|
||||
disabled: false,
|
||||
mustChangePassword: !!opts.mustChangePassword,
|
||||
canBypassPermissions: !!opts.canBypassPermissions,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
users.push(record);
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/** Set a user's password. `mustChangePassword` is left unchanged unless specified. */
|
||||
export async function setPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
opts: { mustChangePassword?: boolean } = {}
|
||||
): Promise<UserRecord> {
|
||||
if (!password || password.length < 8) {
|
||||
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
|
||||
}
|
||||
const norm = normalizeUsername(username);
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
record.password = await hashPassword(password);
|
||||
if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword;
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
export interface UpdateUserPatch {
|
||||
role?: UserRole;
|
||||
disabled?: boolean;
|
||||
canBypassPermissions?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
export async function updateUser(username: string, patch: UpdateUserPatch): Promise<UserRecord> {
|
||||
const norm = normalizeUsername(username);
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
|
||||
// Guard the last-enabled-admin invariant against demote/disable.
|
||||
const before = countEnabledAdmins(users);
|
||||
const projected: UserRecord = {
|
||||
...record,
|
||||
role: patch.role ?? record.role,
|
||||
disabled: patch.disabled ?? record.disabled,
|
||||
};
|
||||
const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u)));
|
||||
if (before > 0 && after === 0) {
|
||||
throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN');
|
||||
}
|
||||
|
||||
if (patch.role !== undefined) record.role = patch.role;
|
||||
if (patch.disabled !== undefined) record.disabled = patch.disabled;
|
||||
if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions;
|
||||
if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword;
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful login timestamp. Best-effort + throttled: skips the write if
|
||||
* the last login was within the last minute (Basic clients re-send credentials on
|
||||
* every request, so this fires often — the throttle keeps disk churn bounded).
|
||||
*/
|
||||
export async function touchLastLogin(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
try {
|
||||
await withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) return;
|
||||
if (record.lastLoginAt && Date.now() - record.lastLoginAt < 60_000) return;
|
||||
record.lastLoginAt = Date.now();
|
||||
await writeUsers(users);
|
||||
});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
await withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
const before = countEnabledAdmins(users);
|
||||
const remaining = users.filter((u) => u.username !== norm);
|
||||
const after = countEnabledAdmins(remaining);
|
||||
if (before > 0 && after === 0) {
|
||||
throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN');
|
||||
}
|
||||
await writeUsers(remaining);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* First-boot bootstrap: in multi-user mode with no users yet, create the initial
|
||||
* admin from `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` if both are set. Returns a
|
||||
* status the caller (server start / CLI) uses to decide whether to refuse boot.
|
||||
*/
|
||||
export async function bootstrapInitialAdmin(): Promise<{
|
||||
status: 'created' | 'exists' | 'missing-env';
|
||||
username?: string;
|
||||
}> {
|
||||
if (await hasUsers()) return { status: 'exists' };
|
||||
const username = process.env.CODEMAN_USERNAME;
|
||||
const password = process.env.CODEMAN_PASSWORD;
|
||||
if (!username || !password) return { status: 'missing-env' };
|
||||
const created = await createUser({ username, role: 'admin', password });
|
||||
return { status: 'created', username: created.username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user's on-disk space (`<USER_SPACES_DIR>/<username>`) with the section 8
|
||||
* guard rails: the top-level dir must not be a symlink, and its realpath must
|
||||
* resolve strictly inside USER_SPACES_DIR (so a symlinked or `..`-escaping target
|
||||
* can never be used to rm an arbitrary tree). No-op if the space does not exist.
|
||||
*/
|
||||
export async function deleteUserSpace(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
if (!isValidUsername(norm)) throw new UserStoreError('Invalid username', 'INVALID_INPUT');
|
||||
const root = getUserSpacesDir();
|
||||
const target = join(root, norm);
|
||||
let lst;
|
||||
try {
|
||||
lst = await fs.lstat(target);
|
||||
} catch {
|
||||
return; // nothing to delete
|
||||
}
|
||||
if (lst.isSymbolicLink()) {
|
||||
throw new UserStoreError('Refusing to delete a symlinked user space', 'INVALID_INPUT');
|
||||
}
|
||||
const realRoot = await fs.realpath(root).catch(() => root);
|
||||
const realTarget = await fs.realpath(target);
|
||||
const rel = relative(realRoot, realTarget);
|
||||
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
||||
throw new UserStoreError('User space escapes USER_SPACES_DIR', 'INVALID_INPUT');
|
||||
}
|
||||
await fs.rm(realTarget, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** The synthetic admin used in single-user mode so downstream has one code path. */
|
||||
export const SYNTHETIC_ADMIN: AuthUser = { username: 'admin', role: 'admin' };
|
||||
|
||||
/**
|
||||
* Whether a username may run arbitrary commands (shell mode, cron launchCommand,
|
||||
* other CLIs' bypass). Single-user or an unset owner: allowed. In multi-user a
|
||||
* MISSING user (e.g. deleted) fails closed (non-privileged). Used at cron fire time.
|
||||
*/
|
||||
export async function canUsernameRunPrivilegedCommands(username: string | undefined): Promise<boolean> {
|
||||
if (!isMultiUserMode() || !username) return true;
|
||||
const user = await findUser(username);
|
||||
return canRunPrivilegedCommands(user ?? { role: 'user' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective Claude mode for a username by looking up the grant. In
|
||||
* single-user mode (or for an unknown owner) the global mode passes through.
|
||||
*/
|
||||
export async function resolveClaudeModeForUsername(
|
||||
globalMode: ClaudeMode | undefined,
|
||||
username: string | undefined
|
||||
): Promise<ClaudeMode> {
|
||||
const fallback: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
|
||||
if (!isMultiUserMode() || !username) return fallback;
|
||||
// Fail closed: an unknown/deleted owner in multi-user mode is treated as a
|
||||
// non-granted regular user so a stale-owned spawn (e.g. an orphaned cron job)
|
||||
// is downgraded to `auto` rather than inheriting the global bypass.
|
||||
const user = await findUser(username);
|
||||
return resolveClaudeModeForUser(globalMode, user ?? { role: 'user' });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @fileoverview Append-only admin audit log (~/.codeman/admin-audit.jsonl).
|
||||
*
|
||||
* Every user-management action (create/patch/reset/delete/logout/assign) writes one
|
||||
* JSON line: timestamp, acting admin, action, target, request IP. Same idiom as
|
||||
* session-lifecycle.jsonl. Best-effort: a write failure never blocks the action.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
ts: number;
|
||||
admin: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
ip?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function appendAdminAudit(entry: Omit<AdminAuditEntry, 'ts'>): Promise<void> {
|
||||
try {
|
||||
const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n';
|
||||
await fs.appendFile(dataPath('admin-audit.jsonl'), line, { mode: 0o600 });
|
||||
} catch {
|
||||
/* best-effort audit; never block the action */
|
||||
}
|
||||
}
|
||||
+277
-57
@@ -8,7 +8,7 @@
|
||||
* - CORS (localhost only)
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
@@ -20,6 +20,17 @@ import {
|
||||
AUTH_FAILURE_WINDOW_MS,
|
||||
} from '../../config/auth-config.js';
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||
|
||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||
// ownership helpers default to a synthetic admin (see route-helpers).
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
authUser?: AuthUser;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth session cookie name
|
||||
export const AUTH_COOKIE_NAME = 'codeman_session';
|
||||
@@ -30,6 +41,83 @@ interface AuthState {
|
||||
authFailures: StaleExpirationMap<string, number> | null;
|
||||
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||
hookSecretFailures: StaleExpirationMap<string, number> | null;
|
||||
/** Per-username Basic-auth failure bucket (multi-user only). */
|
||||
userFailures: StaleExpirationMap<string, number> | null;
|
||||
}
|
||||
|
||||
/** Rate-limit response for a client that exceeded the failure cap. */
|
||||
function sendAuthRateLimit(reply: FastifyReply, failures: StaleExpirationMap<string, number>, key: string): void {
|
||||
const remainingMs = failures.getRemainingTtl(key) ?? AUTH_FAILURE_WINDOW_MS;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
reply.header('Retry-After', String(retryAfterSeconds));
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
}
|
||||
|
||||
/** Parse a `Basic base64(user:pass)` header into its parts, or null if malformed. */
|
||||
function parseBasicAuth(header?: string): { username: string; password: string } | null {
|
||||
if (!header || !header.startsWith('Basic ')) return null;
|
||||
try {
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf-8');
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx < 0) return null;
|
||||
return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
|
||||
* single-user and multi-user auth hooks so the security-critical logic has ONE
|
||||
* source of truth. Returns:
|
||||
* - 'bypass' : loopback + valid hook secret; the caller should allow the request
|
||||
* - 'rejected' : a reply was already sent (wrong secret rate-limited / 401)
|
||||
* - 'continue' : not a hook request (or non-loopback); fall through to normal auth
|
||||
*
|
||||
* COD-91: the shared hook secret is required UNCONDITIONALLY on the loopback bypass
|
||||
* (a user's own loopback reverse proxy is indistinguishable from a real local hook).
|
||||
*/
|
||||
function checkHookSecretBypass(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>
|
||||
): 'bypass' | 'rejected' | 'continue' {
|
||||
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
|
||||
const ip = req.ip;
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
if (isLoopback) {
|
||||
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
|
||||
const expected = Buffer.from(getHookSecret());
|
||||
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
|
||||
return 'bypass';
|
||||
}
|
||||
const hookIp = req.ip;
|
||||
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
||||
if (hookFailures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, hookSecretFailures, hookIp);
|
||||
return 'rejected';
|
||||
}
|
||||
hookSecretFailures.set(hookIp, hookFailures + 1);
|
||||
reply.code(401).send('Unauthorized: hook secret required');
|
||||
return 'rejected';
|
||||
}
|
||||
// Non-localhost hook requests fall through to normal auth
|
||||
}
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests that a `mustChangePassword` user may still reach: the identity probe,
|
||||
* the password-change endpoint, and any non-API path (static assets / index.html,
|
||||
* so the browser can load the app and render the change-password modal).
|
||||
*/
|
||||
function isPasswordChangeExempt(req: FastifyRequest): boolean {
|
||||
const url = (req.url ?? '').split('?')[0];
|
||||
if (url === '/api/me' || url === '/api/me/password') return true;
|
||||
// Security: the WebSocket terminal (/ws/...) is a functional channel, not a static
|
||||
// asset, so it must NOT be exempt, or a locked user keeps a working terminal.
|
||||
if (url.startsWith('/ws/')) return false;
|
||||
return !url.startsWith('/api/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,13 +135,20 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
authFailures: null,
|
||||
qrAuthFailures: null,
|
||||
hookSecretFailures: null,
|
||||
userFailures: null,
|
||||
};
|
||||
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
if (!authPassword) return state;
|
||||
// Always declare req.authUser so downstream reads are safe (single-user leaves it
|
||||
// undefined; the ownership helpers then default to a synthetic admin).
|
||||
if (!app.hasRequestDecorator('authUser')) app.decorateRequest('authUser', undefined);
|
||||
|
||||
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
|
||||
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
||||
const multiUser = isMultiUserMode();
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
|
||||
// No auth at all: single-user with no password (byte-identical to legacy). In
|
||||
// multi-user mode auth is ALWAYS active (users authenticate individually), even
|
||||
// without CODEMAN_PASSWORD.
|
||||
if (!multiUser && !authPassword) return state;
|
||||
|
||||
// Session token store — active sessions extend TTL on access
|
||||
state.authSessions = new StaleExpirationMap<string, AuthSessionRecord>({
|
||||
@@ -87,57 +182,28 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
const authFailures = state.authFailures;
|
||||
const hookSecretFailures = state.hookSecretFailures;
|
||||
|
||||
function sendAuthRateLimit(
|
||||
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));
|
||||
reply.header('Retry-After', String(retryAfterSeconds));
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
if (multiUser) {
|
||||
// Per-username failure bucket: a botnet can't brute-force one account across
|
||||
// many IPs, and one user behind a NAT can't lock out everyone else.
|
||||
state.userFailures = new StaleExpirationMap<string, number>({
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures);
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Single-user Basic Auth (unchanged behavior; CODEMAN_PASSWORD required) ──
|
||||
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
|
||||
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
// Hook events + statusline telemetry come from local Claude Code (curl from
|
||||
// localhost) — no Basic-Auth credentials available. Validated downstream by
|
||||
// HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate.
|
||||
//
|
||||
// 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. COD-91: require the shared hook secret on the loopback bypass
|
||||
// UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
|
||||
// a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
|
||||
// serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
|
||||
// bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
|
||||
// from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it
|
||||
// always closes the gap without breaking the legitimate hook channel.
|
||||
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
|
||||
const ip = req.ip;
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
if (isLoopback) {
|
||||
// Always 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 — 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;
|
||||
}
|
||||
// Non-localhost hook requests fall through to normal auth
|
||||
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
|
||||
if (bypass === 'bypass') {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
if (bypass === 'rejected') return;
|
||||
|
||||
// QR auth path — handled by the route itself (token validation + rate limiting)
|
||||
if (req.url?.startsWith('/q/')) {
|
||||
@@ -153,10 +219,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
if (sessionToken && authSessions.get(sessionToken) !== undefined) {
|
||||
// Sliding cookie: re-issue on every authenticated request so the browser
|
||||
// cookie lifetime tracks the server-side sliding TTL (refreshOnGet above).
|
||||
// Without this the cookie has a fixed lifetime from login; the browser
|
||||
// drops it mid-use, the next request arrives cookie-less and falls through
|
||||
// to Basic Auth — popping the native username/password dialog, which reads
|
||||
// as a random logout while actively working.
|
||||
reply.setCookie(AUTH_COOKIE_NAME, sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: https,
|
||||
@@ -206,7 +268,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
// Rate limit only requests that failed to authenticate on this attempt.
|
||||
const failures = authFailures.get(clientIp) ?? 0;
|
||||
if (failures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, clientIp);
|
||||
sendAuthRateLimit(reply, authFailures, clientIp);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,6 +282,164 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-user auth hook (async, because password verification runs scrypt). Verifies
|
||||
* `username:password` against the user store, mints an identity-carrying cookie,
|
||||
* decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
|
||||
* `mustChangePassword` lockbox. The single-user hook above is left untouched.
|
||||
*/
|
||||
function registerMultiUserAuthHook(
|
||||
app: FastifyInstance,
|
||||
https: boolean,
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord>,
|
||||
authFailures: StaleExpirationMap<string, number>,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>,
|
||||
userFailures: StaleExpirationMap<string, number>
|
||||
): void {
|
||||
const setSessionCookie = (reply: FastifyReply, token: string) =>
|
||||
reply.setCookie(AUTH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: https,
|
||||
sameSite: 'lax',
|
||||
maxAge: AUTH_SESSION_TTL_MS / 1000,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
// Evict the oldest cookie session of the SAME user first (so one user logging in
|
||||
// 100 times cannot flush everyone else's sessions), falling back to global-oldest.
|
||||
const evictForCapacity = (username: string) => {
|
||||
let userKey: string | undefined;
|
||||
let userTs = Infinity;
|
||||
let globalKey: string | undefined;
|
||||
let globalTs = Infinity;
|
||||
for (const [k, v] of authSessions) {
|
||||
if (v.createdAt < globalTs) {
|
||||
globalTs = v.createdAt;
|
||||
globalKey = k;
|
||||
}
|
||||
if (v.username === username && v.createdAt < userTs) {
|
||||
userTs = v.createdAt;
|
||||
userKey = k;
|
||||
}
|
||||
}
|
||||
const key = userKey ?? globalKey;
|
||||
if (key !== undefined) authSessions.delete(key);
|
||||
};
|
||||
|
||||
const enforcePasswordChange = (req: FastifyRequest, reply: FastifyReply, mustChange: boolean): boolean => {
|
||||
if (mustChange && !isPasswordChangeExempt(req)) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.PASSWORD_CHANGE_REQUIRED));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
|
||||
if (bypass === 'bypass' || bypass === 'rejected') return;
|
||||
|
||||
// QR redemption path — handled by the route itself.
|
||||
if (req.url?.startsWith('/q/')) return;
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// 1. Cookie session (carries identity + mustChangePassword snapshot).
|
||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
const record = sessionToken ? authSessions.get(sessionToken) : undefined;
|
||||
if (record && record.username) {
|
||||
// Security: re-validate the cookie identity against the store on every request so
|
||||
// an out-of-band mutation the in-memory map can't see (the `codeman users` CLI,
|
||||
// a separate process, deleting/disabling/demoting a user) takes effect promptly
|
||||
// instead of riding the 24h cookie. findUser is cached ~1s, so this is cheap.
|
||||
let live: Awaited<ReturnType<typeof findUser>>;
|
||||
try {
|
||||
live = await findUser(record.username);
|
||||
} catch {
|
||||
// The store is transiently unreadable/corrupt (readUsers throws on a non-ENOENT
|
||||
// read, #23). Fall back to the cookie's snapshot for THIS request rather than
|
||||
// 500-ing an already-authenticated client (pre-#24 behaviour); a persistently
|
||||
// corrupt store still fails all WRITES loudly at the mutator/bootstrap layer.
|
||||
req.authUser = { username: record.username, role: record.role ?? 'user' };
|
||||
setSessionCookie(reply, sessionToken!);
|
||||
enforcePasswordChange(req, reply, !!record.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
if (!live || live.disabled) {
|
||||
authSessions.delete(sessionToken!);
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||
reply.code(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
// Trust the LIVE role/mustChangePassword, not the (possibly stale) cookie snapshot
|
||||
// (also defends #9/#13: a CLI demotion is reflected without a revoke).
|
||||
req.authUser = { username: live.username, role: live.role };
|
||||
setSessionCookie(reply, sessionToken!); // sliding re-issue
|
||||
enforcePasswordChange(req, reply, !!live.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Basic Auth against the user store (scrypt verify).
|
||||
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
|
||||
// per-username bucket here; see below).
|
||||
const ipFail = authFailures.get(clientIp) ?? 0;
|
||||
if (ipFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, authFailures, clientIp);
|
||||
return;
|
||||
}
|
||||
const creds = parseBasicAuth(req.headers.authorization);
|
||||
if (creds) {
|
||||
const normUser = creds.username.trim().toLowerCase();
|
||||
// Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
|
||||
// per-username bucket before verifying let throwaway IPs lock out a known account
|
||||
// (incl. admin) even with the correct password. A correct password must always
|
||||
// win and self-heal both buckets, regardless of the username-failure count.
|
||||
const result = await verifyPassword(creds.username, creds.password);
|
||||
if (result) {
|
||||
const { user, needsRehash: rehash } = result;
|
||||
if (rehash) void setPassword(user.username, creds.password).catch(() => {});
|
||||
void touchLastLogin(user.username).catch(() => {});
|
||||
authFailures.delete(clientIp);
|
||||
userFailures.delete(normUser);
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
|
||||
authSessions.set(token, {
|
||||
ip: clientIp,
|
||||
ua: req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
method: 'basic',
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: !!user.mustChangePassword,
|
||||
});
|
||||
req.authUser = { username: user.username, role: user.role };
|
||||
setSessionCookie(reply, token);
|
||||
enforcePasswordChange(req, reply, !!user.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
// Failed guess: count it against BOTH buckets. Once the per-username bucket
|
||||
// reaches the cap, further FAILED attempts get 429 (throttles distributed
|
||||
// brute-force), but this path is only reached on a wrong password, so it can
|
||||
// never deny a correct one.
|
||||
const uFail = (userFailures.get(normUser) ?? 0) + 1;
|
||||
userFailures.set(normUser, uFail);
|
||||
authFailures.set(clientIp, ipFail + 1);
|
||||
if (uFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, userFailures, normUser);
|
||||
return;
|
||||
}
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// No credentials presented: count against the per-IP bucket and challenge.
|
||||
authFailures.set(clientIp, ipFail + 1);
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
});
|
||||
}
|
||||
|
||||
/** Methods that don't change server state and so skip the cross-site Origin check. */
|
||||
const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
|
||||
@@ -11,6 +11,19 @@ export interface AuthSessionRecord {
|
||||
ua: string;
|
||||
createdAt: number;
|
||||
method: 'qr' | 'basic';
|
||||
/**
|
||||
* Multi-user identity carried by the cookie (single-user leaves these unset).
|
||||
* Snapshotted at mint time. Authorization-relevant admin changes (password reset,
|
||||
* disable, delete, role change, bypass-grant change) revoke the user's sessions so
|
||||
* a stale snapshot can't outlive the change; additionally the cookie fast-path
|
||||
* re-reads role/disabled/mustChangePassword live from the store each request, so an
|
||||
* out-of-band CLI mutation also takes effect promptly. See docs/multi-user-plan.md
|
||||
* section 5.
|
||||
*/
|
||||
username?: string;
|
||||
role?: 'admin' | 'user';
|
||||
/** Whether this user must change their password before other actions are allowed. */
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthPort {
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface ConfigPort {
|
||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||
getLightState(): unknown;
|
||||
getLightState(identity?: { username: string; role: 'admin' | 'user' }): unknown;
|
||||
getLightSessionsState(): unknown[];
|
||||
startTranscriptWatcher(sessionId: string, transcriptPath: string): void;
|
||||
stopTranscriptWatcher(sessionId: string): void;
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface ScheduledRun {
|
||||
completedTasks: number;
|
||||
totalCost: number;
|
||||
logs: string[];
|
||||
/** Multi-user owner (username) — undefined in single-user mode. Used to scope
|
||||
* list/delete and to downgrade the spawned Session's permission mode. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
export interface InfraPort {
|
||||
@@ -33,6 +36,6 @@ export interface InfraPort {
|
||||
readonly teamWatcher: TeamWatcher;
|
||||
readonly tunnelManager: TunnelManager;
|
||||
readonly pushStore: PushSubscriptionStore;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun>;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>;
|
||||
stopScheduledRun(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @fileoverview Multi-user frontend: identity boot, admin Users panel, and the
|
||||
* change-password flow. Self-contained (builds its own DOM) so it needs no
|
||||
* index.html surgery beyond the script tag and integrates with the existing App
|
||||
* Settings modal by injecting a "Users" tab (admins in multi-user mode only).
|
||||
*
|
||||
* @dependency app.js (window.app), settings-ui.js (App Settings modal + tab switch)
|
||||
* @loadorder after settings-ui.js / ultracode-panel.js, before session-ui.js
|
||||
*
|
||||
* In single-user mode GET /api/me returns a synthetic admin with multiUser:false,
|
||||
* so none of the admin UI is shown and behavior is unchanged.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const unwrap = (body) => (body && typeof body === 'object' && 'data' in body ? body.data : body);
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await window.fetch(path, { headers: { Accept: 'application/json' } });
|
||||
return unwrap(await res.json());
|
||||
}
|
||||
async function apiSend(method, path, body) {
|
||||
const res = await window.fetch(path, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let json = null;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
/* empty body */
|
||||
}
|
||||
return { ok: res.ok, status: res.status, body: json, data: unwrap(json) };
|
||||
}
|
||||
|
||||
// ── Change-password modal ─────────────────────────────────────────────────
|
||||
let cpModal = null;
|
||||
function buildChangePasswordModal() {
|
||||
if (cpModal) return cpModal;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'modal';
|
||||
el.id = 'changePasswordModal';
|
||||
el.style.zIndex = '3100';
|
||||
el.innerHTML = `
|
||||
<div class="modal-content" style="max-width:420px">
|
||||
<div class="modal-header"><h2>Change Password</h2></div>
|
||||
<div class="modal-body">
|
||||
<p id="cpMustNote" class="form-hint" style="display:none;color:var(--warning,#c80)">
|
||||
You must change your password before continuing.</p>
|
||||
<div class="form-row"><label>Current password</label>
|
||||
<input type="password" id="cpCurrent" class="form-input" autocomplete="current-password"></div>
|
||||
<div class="form-row"><label>New password (min 8)</label>
|
||||
<input type="password" id="cpNew" class="form-input" autocomplete="new-password"></div>
|
||||
<div class="form-row"><label>Confirm new password</label>
|
||||
<input type="password" id="cpConfirm" class="form-input" autocomplete="new-password"></div>
|
||||
<p id="cpError" style="color:var(--error,#c33);min-height:1.2em"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" id="cpCancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="cpSubmit">Change password</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#cpCancel').onclick = () => (el.style.display = 'none');
|
||||
el.querySelector('#cpSubmit').onclick = async () => {
|
||||
const current = el.querySelector('#cpCurrent').value;
|
||||
const nw = el.querySelector('#cpNew').value;
|
||||
const confirm = el.querySelector('#cpConfirm').value;
|
||||
const err = el.querySelector('#cpError');
|
||||
err.textContent = '';
|
||||
if (nw.length < 8) return (err.textContent = 'New password must be at least 8 characters.');
|
||||
if (nw !== confirm) return (err.textContent = 'Passwords do not match.');
|
||||
const r = await apiSend('POST', '/api/me/password', { currentPassword: current, newPassword: nw });
|
||||
if (!r.ok) return (err.textContent = (r.body && r.body.error) || 'Change failed.');
|
||||
el.style.display = 'none';
|
||||
if (window.app && window.app.showToast) window.app.showToast('Password changed');
|
||||
};
|
||||
cpModal = el;
|
||||
return el;
|
||||
}
|
||||
function openChangePassword(forced) {
|
||||
const el = buildChangePasswordModal();
|
||||
el.querySelector('#cpMustNote').style.display = forced ? '' : 'none';
|
||||
el.querySelector('#cpCancel').style.display = forced ? 'none' : '';
|
||||
el.querySelector('#cpError').textContent = '';
|
||||
el.style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── Fetch interceptor: surface PASSWORD_CHANGE_REQUIRED ───────────────────
|
||||
function installInterceptor() {
|
||||
const orig = window.fetch;
|
||||
window.fetch = async function (...args) {
|
||||
const res = await orig.apply(this, args);
|
||||
if (res.status === 403) {
|
||||
try {
|
||||
const clone = res.clone();
|
||||
const j = await clone.json();
|
||||
if (j && j.errorCode === 'PASSWORD_CHANGE_REQUIRED') openChangePassword(true);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin Users panel (injected into the App Settings modal) ──────────────
|
||||
function injectUsersTab() {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
if (!modal || modal.querySelector('[data-tab="settings-users"]')) return;
|
||||
const tabs = modal.querySelector('.modal-tabs');
|
||||
const body = modal.querySelector('.modal-body');
|
||||
if (!tabs || !body) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'modal-tab-btn';
|
||||
btn.dataset.tab = 'settings-users';
|
||||
btn.textContent = 'Users';
|
||||
tabs.appendChild(btn);
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-tab-content hidden';
|
||||
content.id = 'settings-users';
|
||||
content.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>Users</strong>
|
||||
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
|
||||
</div>
|
||||
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
|
||||
users from each other. Pair with Docker cases for isolation.</p>
|
||||
<div id="adminUsersTable"></div>
|
||||
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
|
||||
body.appendChild(content);
|
||||
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
|
||||
btn.addEventListener('click', renderUsers);
|
||||
content.querySelector('#adminAddUser').onclick = addUserFlow;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||||
}
|
||||
|
||||
async function renderUsers() {
|
||||
const table = document.getElementById('adminUsersTable');
|
||||
if (!table) return;
|
||||
table.innerHTML = 'Loading…';
|
||||
let users;
|
||||
try {
|
||||
users = await apiGet('/api/admin/users');
|
||||
} catch {
|
||||
table.innerHTML = 'Failed to load users.';
|
||||
return;
|
||||
}
|
||||
const rows = users
|
||||
.map((u) => {
|
||||
const flags = [
|
||||
u.role === 'admin' ? 'admin' : 'user',
|
||||
u.disabled ? 'disabled' : 'enabled',
|
||||
u.canBypassPermissions ? 'can-bypass' : '',
|
||||
u.mustChangePassword ? 'must-change-pw' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const st = u.stats || {};
|
||||
return `<tr data-u="${esc(u.username)}">
|
||||
<td>${esc(u.username)}</td>
|
||||
<td style="font-size:.85em;color:var(--muted,#888)">${esc(flags)}</td>
|
||||
<td style="font-size:.85em">${st.liveSessions ?? 0} live · ${st.caseCount ?? 0} cases</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn btn-xs" data-act="role">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
||||
<button class="btn btn-xs" data-act="disabled">${u.disabled ? 'Enable' : 'Disable'}</button>
|
||||
<button class="btn btn-xs" data-act="bypass">${u.canBypassPermissions ? 'Revoke bypass' : 'Grant bypass'}</button>
|
||||
<button class="btn btn-xs" data-act="reset">Reset pw</button>
|
||||
<button class="btn btn-xs" data-act="delete">Delete</button>
|
||||
</td></tr>`;
|
||||
})
|
||||
.join('');
|
||||
table.innerHTML = `<table style="width:100%;border-collapse:collapse" class="admin-users">
|
||||
<thead><tr><th align="left">User</th><th align="left">Flags</th><th align="left">Usage</th><th></th></tr></thead>
|
||||
<tbody>${rows}</tbody></table>`;
|
||||
table.querySelectorAll('button[data-act]').forEach((b) => {
|
||||
b.onclick = () =>
|
||||
userAction(
|
||||
b.closest('tr').dataset.u,
|
||||
b.dataset.act,
|
||||
users.find((x) => x.username === b.closest('tr').dataset.u)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function setMsg(t) {
|
||||
const m = document.getElementById('adminUsersMsg');
|
||||
if (m) m.textContent = t || '';
|
||||
}
|
||||
|
||||
async function userAction(username, act, u) {
|
||||
if (act === 'role') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, {
|
||||
role: u.role === 'admin' ? 'user' : 'admin',
|
||||
});
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'disabled') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { disabled: !u.disabled });
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'bypass') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, {
|
||||
canBypassPermissions: !u.canBypassPermissions,
|
||||
});
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'reset') {
|
||||
if (!window.confirm(`Reset ${username}'s password? They must set a new one on next login.`)) return;
|
||||
const r = await apiSend('POST', `/api/admin/users/${encodeURIComponent(username)}/reset-password`);
|
||||
if (r.ok && r.data && r.data.oneTimePassword) {
|
||||
window.prompt(`One-time password for ${username} (copy it now — shown once):`, r.data.oneTimePassword);
|
||||
} else setMsg((r.body && r.body.error) || 'Reset failed.');
|
||||
} else if (act === 'delete') {
|
||||
const typed = window.prompt(`Type "${username}" to delete this user. Add " +space" to also delete their files.`);
|
||||
if (typed !== username && typed !== `${username} +space`) return setMsg('Delete cancelled.');
|
||||
const deleteSpace = typed.endsWith(' +space');
|
||||
const r = await apiSend('DELETE', `/api/admin/users/${encodeURIComponent(username)}`, { deleteSpace });
|
||||
setMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.');
|
||||
}
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
async function addUserFlow() {
|
||||
const username = window.prompt('New username (lowercase, 2-32 chars, [a-z0-9_-]):');
|
||||
if (!username) return;
|
||||
const admin = window.confirm('Make this user an admin? (OK = admin, Cancel = regular user)');
|
||||
const r = await apiSend('POST', '/api/admin/users', { username: username.trim(), role: admin ? 'admin' : 'user' });
|
||||
if (r.ok && r.data && r.data.oneTimePassword) {
|
||||
window.prompt(`Created ${username}. One-time password (copy it now — shown once):`, r.data.oneTimePassword);
|
||||
} else setMsg((r.body && r.body.error) || 'Create failed.');
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
installInterceptor();
|
||||
let me = null;
|
||||
try {
|
||||
me = await apiGet('/api/me');
|
||||
} catch {
|
||||
/* server may be pre-auth */
|
||||
}
|
||||
window.__codemanUser = me || { username: 'admin', role: 'admin', multiUser: false };
|
||||
document.dispatchEvent(new CustomEvent('codeman:me', { detail: window.__codemanUser }));
|
||||
if (window.__codemanUser.mustChangePassword) openChangePassword(true);
|
||||
if (window.__codemanUser.multiUser && window.__codemanUser.role === 'admin') {
|
||||
injectUsersTab();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
|
||||
window.codemanAdmin = { openChangePassword, renderUsers };
|
||||
})();
|
||||
+108
-1
@@ -216,6 +216,10 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.MUX_DIED, '_onMuxDied'],
|
||||
[SSE_EVENTS.MUX_STATS_UPDATED, '_onMuxStatsUpdated'],
|
||||
|
||||
// Remote auto-reconnect (COD-108)
|
||||
[SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'],
|
||||
[SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'],
|
||||
|
||||
// Ralph
|
||||
[SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'],
|
||||
[SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'],
|
||||
@@ -287,6 +291,9 @@ const _SSE_HANDLER_MAP = [
|
||||
|
||||
// Clipboard
|
||||
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
|
||||
|
||||
// Session order (global tab order sync, COD-131)
|
||||
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
|
||||
];
|
||||
|
||||
|
||||
@@ -1452,6 +1459,70 @@ class CodemanApp {
|
||||
console.error('[SSE] docker export failed:', err);
|
||||
}
|
||||
});
|
||||
// Import + drift-recreate completions: refresh case lists in EVERY open tab
|
||||
// (the initiating tab already refreshes via its own fetch response).
|
||||
addListener(SSE_EVENTS.DOCKER_IMPORT_COMPLETE, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Docker bundle imported as case "${d.name}"`, 'success');
|
||||
this.loadQuickStartCases?.();
|
||||
this.refreshDockerExports?.();
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker import complete:', err);
|
||||
}
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_CONTAINER_RECREATED, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Container for "${d.name}" removed — next launch recreates it with the new config`, 'info');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker container recreated:', err);
|
||||
}
|
||||
});
|
||||
// Base image auto-build on first Docker case (build-on-first-use). A single
|
||||
// multi-minute event; surface start/finish so the Run spinner is explained.
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
|
||||
this.showToast('Building the Codeman agent image (first Docker case, a few minutes)...', 'info', {
|
||||
duration: 8000,
|
||||
});
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_COMPLETE, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
if (d.error) this.showToast(`Agent image build failed: ${d.error}`, 'error');
|
||||
else this.showToast('Agent image ready. Starting the container...', 'success');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker image build complete:', err);
|
||||
}
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_FAILED, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Agent image build failed: ${d.error || 'unknown error'}`, 'error');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker image build failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// COD-139: a session:pinned event updates the local live-session pin flag (so
|
||||
// a subsequent render is consistent) and re-sorts the open session manager /
|
||||
// welcome list so pinned sessions float to the top.
|
||||
addListener(SSE_EVENTS.SESSION_PINNED, (e) => {
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch {
|
||||
/* ignore malformed payload */
|
||||
}
|
||||
if (data && data.id) {
|
||||
const live = this.sessions.get(data.id);
|
||||
if (live) {
|
||||
live.pinned = data.pinned === true;
|
||||
live.pinnedAt = data.pinned ? data.pinnedAt : undefined;
|
||||
}
|
||||
}
|
||||
this._onSessionListMaybeChanged();
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -2975,6 +3046,13 @@ class CodemanApp {
|
||||
// on another device).
|
||||
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
|
||||
|
||||
// COD-131: server is authoritative for global tab order. Seed localStorage
|
||||
// from the server snapshot (if present) so syncSessionOrder() reconciles
|
||||
// against the cross-device order rather than this device's stale local copy.
|
||||
if (Array.isArray(data.sessionOrder) && data.sessionOrder.length) {
|
||||
try { localStorage.setItem('codeman-session-order', JSON.stringify(data.sessionOrder)); } catch {}
|
||||
}
|
||||
|
||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||
this.syncSessionOrder();
|
||||
|
||||
@@ -3539,13 +3617,42 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Save session order to localStorage
|
||||
// Save session order to localStorage and (debounced) sync to the server so it
|
||||
// follows the user across devices (COD-131). localStorage stays the offline
|
||||
// fallback; the server is authoritative and echoes back via SSE.
|
||||
saveSessionOrder() {
|
||||
try {
|
||||
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
const order = [...this.sessionOrder];
|
||||
this._debouncedCall('saveSessionOrderServer', () => {
|
||||
fetch('/api/session-order', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
}).catch(() => {});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// COD-131: another device (or our own debounced push) reordered tabs. Adopt the
|
||||
// server order as the new base and reconcile to our currently-open sessions.
|
||||
// Guard against no-op churn so an echo of our own push doesn't flicker the tabs.
|
||||
_onSessionOrderChanged(data) {
|
||||
if (!data || !Array.isArray(data.order)) return;
|
||||
try {
|
||||
localStorage.setItem('codeman-session-order', JSON.stringify(data.order));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
const before = JSON.stringify(this.sessionOrder);
|
||||
this.syncSessionOrder();
|
||||
// Only re-render when the reconciled order actually changed (avoids flicker
|
||||
// when the broadcast is just an echo of the order we already have).
|
||||
if (JSON.stringify(this.sessionOrder) !== before) {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
|
||||
@@ -322,6 +322,7 @@ const SSE_EVENTS = {
|
||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
SESSION_PINNED: 'session:pinned',
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
SESSION_RUNNING: 'session:running',
|
||||
@@ -379,6 +380,11 @@ const SSE_EVENTS = {
|
||||
MUX_DIED: 'mux:died',
|
||||
MUX_STATS_UPDATED: 'mux:statsUpdated',
|
||||
|
||||
// Remote auto-reconnect (COD-108)
|
||||
REMOTE_SESSION_DROPPED: 'remote:sessionDropped',
|
||||
REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected',
|
||||
REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted',
|
||||
|
||||
// Ralph
|
||||
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
|
||||
SESSION_RALPH_TODO_UPDATE: 'session:ralphTodoUpdate',
|
||||
@@ -477,6 +483,17 @@ const SSE_EVENTS = {
|
||||
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
|
||||
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
|
||||
DOCKER_IMPORT_COMPLETE: 'docker:importComplete',
|
||||
DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted',
|
||||
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
|
||||
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
|
||||
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
|
||||
// Multi-user (admin-only / targeted)
|
||||
ADMIN_USERS_CHANGED: 'admin:usersChanged',
|
||||
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
|
||||
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SESSION_ORDER_CHANGED: 'session:orderChanged',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer btn-file-viewer--hidden" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
@@ -557,7 +558,7 @@
|
||||
<div class="toolbar-right">
|
||||
<!-- Orchestrator button hidden until feature is ready -->
|
||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||
<button class="btn-toolbar btn-sm btn-cron" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1268,6 +1269,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the file viewer button in header (opens the file browser panel for the active session)">
|
||||
<span class="settings-item-label">File Viewer</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowFileViewerButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
|
||||
<span class="settings-item-label">Attachments Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1441,10 +1449,11 @@
|
||||
<label>Startup Mode</label>
|
||||
<select id="appSettingsClaudeMode" class="form-select">
|
||||
<option value="dangerously-skip-permissions">Skip Permissions (default)</option>
|
||||
<option value="auto">Auto (classifier-guarded, low prompts)</option>
|
||||
<option value="normal">Normal (with prompts)</option>
|
||||
<option value="allowedTools">Allowed Tools Only</option>
|
||||
</select>
|
||||
<span class="form-hint">How Claude CLI is started in screen sessions</span>
|
||||
<span class="form-hint">How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)</span>
|
||||
</div>
|
||||
<div class="form-row" id="allowedToolsRow" style="display: none;">
|
||||
<label>Allowed Tools</label>
|
||||
@@ -1492,6 +1501,14 @@
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions — ignored when a Claude Model is selected above</span>
|
||||
</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Remote auto-reconnect</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsRemoteAutoReconnect">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Automatically re-establish remote (SSH) sessions when the connection drops, reattaching to the durable remote tmux session (on by default; bounded backoff)</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Thinking Effort</label>
|
||||
<select id="appSettingsThinkingEffort" class="form-select">
|
||||
@@ -1864,12 +1881,12 @@
|
||||
<label>Description (optional)</label>
|
||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker" onchange="app.toggleDockerQuickSettings()"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">One checkbox is enough — it creates the case folder AND a hardened container with default settings, then starts the session inside it. Click to expand for optional presets. Requires the base image (<code>node scripts/build-agent-image.mjs</code>).</span>
|
||||
<div class="form-row docker-quick-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
|
||||
</div>
|
||||
<details class="advanced-options" id="dockerQuickSettings" style="display:none;">
|
||||
<summary>Container settings (optional — sensible defaults)</summary>
|
||||
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
|
||||
<summary>Container settings (optional, sensible defaults)</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Template</label>
|
||||
@@ -1880,7 +1897,7 @@
|
||||
<option value="gpu">GPU — 8 GB RAM, 4 CPU, all GPUs</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<span class="form-hint">Disk is elastic — storage grows automatically as data flows in (no fixed cap).</span>
|
||||
<span class="form-hint">Disk is elastic: storage grows automatically as data flows in (no fixed cap).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Memory</label>
|
||||
@@ -1984,6 +2001,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<!-- COD-105 — discover + attach existing remote tmux sessions this Codeman didn't create. -->
|
||||
<details class="advanced-options" id="remoteDiscoverSection">
|
||||
<summary>Discover existing sessions</summary>
|
||||
<div class="advanced-options-content">
|
||||
<span class="form-hint">Find <code>codeman-*</code> tmux sessions already running on this host (started by the remote's own Codeman or another instance) and attach to one. Attaching shares the session; closing the tab detaches it — it is never killed.</span>
|
||||
<div class="form-row" style="margin-top: 8px;">
|
||||
<button type="button" class="btn-toolbar" id="remoteDiscoverBtn" onclick="app.discoverRemoteSessions()">Discover existing sessions</button>
|
||||
</div>
|
||||
<div id="remoteDiscoverResults" class="remote-discover-results"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Docker Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-docker">
|
||||
@@ -2486,6 +2514,7 @@
|
||||
<script defer src="settings-ui.js"></script>
|
||||
<script defer src="panels-ui.js"></script>
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -459,16 +459,18 @@ html.mobile-init .file-browser-panel {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Hide header settings gear, lifecycle log, away digest, and session manager on
|
||||
mobile - settings moved to toolbar; away digest and the session manager are
|
||||
secondary controls that don't belong on the cramped phone header (the session
|
||||
manager stays reachable via the Ctrl+K palette's "Browse all sessions" item).
|
||||
/* Hide header settings gear, lifecycle log, away digest, session manager, and
|
||||
file viewer on mobile - settings moved to toolbar; the others are secondary /
|
||||
desktop-oriented controls that don't belong on the cramped phone header (the
|
||||
session manager stays reachable via the Ctrl+K palette's "Browse all sessions"
|
||||
item; the file viewer button is opt-in but its panel is desktop-sized).
|
||||
(The attachments button is opt-in / default-hidden everywhere via its own
|
||||
--hidden marker, so it needs no mobile-specific rule here.) */
|
||||
.btn-icon-header.btn-settings,
|
||||
.btn-icon-header.btn-lifecycle-log,
|
||||
.btn-icon-header.btn-away-digest,
|
||||
.btn-icon-header.btn-session-manager {
|
||||
.btn-icon-header.btn-session-manager,
|
||||
.btn-icon-header.btn-file-viewer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,33 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Remote auto-reconnect (COD-108)
|
||||
_onRemoteSessionReconnected(data) {
|
||||
const id = this.getShortId(data.sessionId);
|
||||
this.showToast(`Remote session ${id} reconnected`, 'success');
|
||||
},
|
||||
|
||||
_onRemoteReconnectExhausted(data) {
|
||||
const sessionId = data.sessionId;
|
||||
const id = this.getShortId(sessionId);
|
||||
// Auto-reconnect gave up after the bounded backoff. Surface a manual
|
||||
// "Reconnect" affordance that re-triggers the attach path (force-reload the
|
||||
// session, which re-runs the create/attach flow against the durable remote).
|
||||
this.showToast(`Remote session ${id} dropped — auto-reconnect gave up`, 'error', {
|
||||
duration: 15000,
|
||||
action: {
|
||||
label: 'Reconnect',
|
||||
onClick: () => {
|
||||
if (this.sessions && this.sessions.has(sessionId)) {
|
||||
this.selectSession(sessionId, { forceReload: true });
|
||||
} else {
|
||||
this.showToast('Session no longer available', 'warning');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// Bash tools
|
||||
_onBashToolStart(data) {
|
||||
@@ -3098,6 +3125,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
|
||||
// File Viewer). Toggles the file browser panel open/closed without a trip
|
||||
// through settings. Persists via the same `showFileBrowser` flag the Panels
|
||||
// section + the panel's own close (X) use, so the three stay in sync.
|
||||
toggleFileBrowserButton() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
const isOpen = panel?.classList.contains('visible');
|
||||
const btn = document.querySelector('.btn-file-viewer');
|
||||
if (isOpen) {
|
||||
this.closeFileBrowserPanel();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
return;
|
||||
}
|
||||
if (!this.activeSessionId) {
|
||||
this.showToast('Open a session to browse its files', 'info');
|
||||
return;
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = true;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
this.applyMonitorVisibility();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||
},
|
||||
|
||||
closeFileBrowserPanel() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
if (panel) {
|
||||
@@ -3130,6 +3183,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = false;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
const headerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
|
||||
+230
-36
@@ -46,10 +46,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
formatCasePickerLabel(c) {
|
||||
if (c?.location === 'remote' && c.remote?.hostId) return `${c.name} @ ${c.remote.hostId}`;
|
||||
if (c?.location === 'docker' && c.docker?.container) return `${c.name} @ ${c.docker.container}`;
|
||||
if (c?.location === 'docker') return `${c.name} (${this.dockerCaseTag(c.docker?.hostId)})`;
|
||||
return c?.name || '';
|
||||
},
|
||||
|
||||
// Short parenthetical tag for a dockerized case: '(docker)' for the default /
|
||||
// auto-provisioned host (one-click "Run in Docker", the Docker-tab 'local'
|
||||
// default, or a per-case 'q-<name>' resource-override host), otherwise the custom
|
||||
// docker host id the user named (e.g. '(gpu-box)'). Keeps the case name short.
|
||||
dockerCaseTag(hostId) {
|
||||
if (!hostId || hostId === 'default' || hostId === 'local' || /^q-/.test(hostId)) return 'docker';
|
||||
return hostId;
|
||||
},
|
||||
|
||||
buildCasePickerOptions(cases = []) {
|
||||
const normalized = [];
|
||||
const seen = new Set();
|
||||
@@ -429,7 +438,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.append(dirSpan, metaSpan);
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir);
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
@@ -485,6 +494,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.value = Math.max(1, current - 1);
|
||||
},
|
||||
|
||||
// Next free <prefix><n> index for a case's session tabs (e.g. w1-<case>,
|
||||
// w2-<case> for agents, s1-<case> for shells), shared by the local and
|
||||
// remote/docker launch paths so all tabs follow the same naming convention.
|
||||
_nextCaseSessionStartNumber(caseName, prefix = 'w') {
|
||||
const re = new RegExp(`^${prefix}(\\d+)-([a-zA-Z0-9_-]+)`);
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions || []) {
|
||||
const match = session.name && session.name.match(re);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
return startNumber;
|
||||
},
|
||||
|
||||
async runClaude() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
||||
@@ -523,14 +548,53 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
|
||||
// through /api/quick-start, which resolves the remote case + launches via ssh.
|
||||
if (caseData.location === 'remote' || caseData.location === 'docker') {
|
||||
// Name remote/docker tabs with the same w<n>-<case> convention as local
|
||||
// sessions (quick-start would otherwise auto-generate codeman-<id>).
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||
// Docker (NOT remote): the App Settings Claude Model choice applies — the
|
||||
// workspace is a real host dir, so quick-start writes it to the case's
|
||||
// .claude/settings.local.json and the in-container claude reads it.
|
||||
// Remote quick-starts REJECT modelOverride (the file would land on the
|
||||
// wrong machine), so never send it there.
|
||||
let dockerModelOverride;
|
||||
if (caseData.location === 'docker') {
|
||||
const dockerGlobalSettings = this.loadAppSettingsFromStorage();
|
||||
const dockerCaseSettings = this.getCaseSettings(caseName);
|
||||
const dockerUseOpus1m = dockerCaseSettings.opusContext1m || dockerGlobalSettings.opusContext1mEnabled;
|
||||
dockerModelOverride = dockerGlobalSettings.claudeModel || (dockerUseOpus1m ? 'opus[1m]' : '');
|
||||
}
|
||||
const remoteIds = [];
|
||||
let driftHandled = false;
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'claude' })
|
||||
const quickStartBody = JSON.stringify({
|
||||
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
|
||||
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
|
||||
});
|
||||
const data = await res.json();
|
||||
const doQuickStart = async () => {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: quickStartBody
|
||||
});
|
||||
return res.json();
|
||||
};
|
||||
let data = await doQuickStart();
|
||||
// Docker config drift: the host config changed since the container was
|
||||
// created (CONFLICT from quick-start). Confirm once, recreate, retry.
|
||||
if (!data.success && data.errorCode === 'CONFLICT' && caseData.location === 'docker' && !driftHandled) {
|
||||
driftHandled = true;
|
||||
const recreate = confirm(
|
||||
`Container config for "${caseName}" changed since its container was created.\n\n` +
|
||||
'Recreate the container to apply the new config? Workspace files and the ' +
|
||||
'conversation survive (the conversation resumes on launch).'
|
||||
);
|
||||
if (recreate) {
|
||||
const recRes = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/recreate`, { method: 'POST' });
|
||||
const recData = await recRes.json();
|
||||
if (!recData.success) throw new Error(recData.error || 'Failed to recreate container');
|
||||
data = await doQuickStart();
|
||||
}
|
||||
}
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
@@ -546,16 +610,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
let firstSessionId = null;
|
||||
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||
|
||||
// Get global Ralph tracker setting
|
||||
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
|
||||
@@ -709,12 +764,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
|
||||
if (caseData.location === 'remote' || caseData.location === 'docker') {
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'shell' })
|
||||
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
@@ -730,16 +786,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
||||
|
||||
// Create all shell sessions in parallel
|
||||
const sessionNames = [];
|
||||
@@ -828,6 +875,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -880,6 +928,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
@@ -934,6 +983,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'gemini',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -1681,13 +1731,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Show/hide the expandable container-settings section under the Docker checkbox.
|
||||
toggleDockerQuickSettings() {
|
||||
const on = document.getElementById('newCaseDocker')?.checked;
|
||||
const el = document.getElementById('dockerQuickSettings');
|
||||
if (el) el.style.display = on ? '' : 'none';
|
||||
},
|
||||
|
||||
// Fill the memory/cpu/gpu fields from a resource template. `medium` clears them so
|
||||
// the server uses its defaults (no per-case host); `custom` leaves them editable.
|
||||
applyDockerTemplate() {
|
||||
@@ -1951,7 +1994,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
listEl.innerHTML = exports
|
||||
.map(e => {
|
||||
const mb = (e.sizeBytes / 1e6).toFixed(1);
|
||||
const nm = this.escapeHtml ? this.escapeHtml(e.name) : e.name;
|
||||
// escapeHtml is the free function from constants.js (never a method on `this`)
|
||||
const nm = escapeHtml(e.name);
|
||||
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
|
||||
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
|
||||
<span style="flex-shrink:0;">
|
||||
@@ -2024,6 +2068,156 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// COD-105 — Discover + attach existing remote tmux sessions
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Read the remote-host fields from the remote-case form into a host payload. */
|
||||
_readRemoteHostFromForm() {
|
||||
const hostId = document.getElementById('remoteHostId').value.trim();
|
||||
const host = document.getElementById('remoteHostAddress').value.trim();
|
||||
const username = document.getElementById('remoteHostUsername').value.trim();
|
||||
const portRaw = document.getElementById('remoteHostPort').value.trim();
|
||||
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
|
||||
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
|
||||
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
|
||||
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
|
||||
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
let port;
|
||||
if (portRaw) {
|
||||
const n = Number(portRaw);
|
||||
if (Number.isInteger(n) && n >= 1 && n <= 65535) port = n;
|
||||
}
|
||||
return {
|
||||
id: hostId,
|
||||
label: hostId,
|
||||
host,
|
||||
username,
|
||||
...(port ? { port } : {}),
|
||||
...(identityFile ? { identityFile } : {}),
|
||||
...(socksProxy ? { socksProxy } : {}),
|
||||
...(jumpHost ? { jumpHost } : {}),
|
||||
...(extraSshOptions.length ? { extraSshOptions } : {}),
|
||||
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Explicit Discover action (Decision A — never auto-runs on host select).
|
||||
* Saves the host config (idempotent), then queries the host for `codeman-*`
|
||||
* tmux sessions it didn't create and renders an Attach action per session.
|
||||
*/
|
||||
async discoverRemoteSessions() {
|
||||
const results = document.getElementById('remoteDiscoverResults');
|
||||
const btn = document.getElementById('remoteDiscoverBtn');
|
||||
const hostPayload = this._readRemoteHostFromForm();
|
||||
if (!hostPayload.id || !hostPayload.host || !hostPayload.username) {
|
||||
this.showToast('Fill in Host ID, address, and username first', 'error');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(hostPayload.id)) {
|
||||
this.showToast('Invalid Host ID. Use letters, numbers, hyphens, underscores.', 'error');
|
||||
return;
|
||||
}
|
||||
if (btn) btn.disabled = true;
|
||||
if (results) results.innerHTML = '<div class="form-hint">Discovering…</div>';
|
||||
try {
|
||||
// Persist the host so the discovery endpoint can resolve it by id (idempotent).
|
||||
const hostRes = await fetch('/api/remote-hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hostPayload)
|
||||
});
|
||||
const hostData = await hostRes.json();
|
||||
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
|
||||
throw new Error(hostData.error || 'Failed to save remote host');
|
||||
}
|
||||
const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostPayload.id)}/sessions`);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Discovery failed');
|
||||
this._renderDiscoveredSessions(hostPayload.id, data.data.sessions || []);
|
||||
} catch (err) {
|
||||
console.error('Discover remote sessions failed:', err);
|
||||
if (results) results.innerHTML = `<div class="form-hint" style="color: var(--error, #e06c75);">${escapeHtml(err.message)}</div>`;
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Render the discovered remote sessions with an Attach action each. */
|
||||
_renderDiscoveredSessions(hostId, sessions) {
|
||||
const results = document.getElementById('remoteDiscoverResults');
|
||||
if (!results) return;
|
||||
if (!sessions.length) {
|
||||
results.innerHTML = '<div class="form-hint">No <code>codeman-*</code> sessions running on this host (or it is unreachable).</div>';
|
||||
return;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const rows = sessions.map(s => {
|
||||
const ageSecs = Math.max(0, now - (s.created || 0));
|
||||
const age = ageSecs < 3600 ? `${Math.floor(ageSecs / 60)}m` : ageSecs < 86400 ? `${Math.floor(ageSecs / 3600)}h` : `${Math.floor(ageSecs / 86400)}d`;
|
||||
// COD-106 — show "shared · N clients" when more than one client is attached
|
||||
// (genuinely collaborative), else a plain "attached" badge for a single client.
|
||||
const clients = s.attachedClients != null ? s.attachedClients : s.attached ? 1 : 0;
|
||||
const attachedBadge =
|
||||
clients > 1
|
||||
? `<span class="case-location-badge" style="background: var(--warning, #e5c07b); color: #000;">shared · ${clients} clients</span>`
|
||||
: clients === 1
|
||||
? '<span class="case-location-badge" style="background: var(--accent, #61afef);">attached</span>'
|
||||
: '';
|
||||
return `
|
||||
<div class="remote-discover-item">
|
||||
<div class="remote-discover-info">
|
||||
<span class="remote-discover-name">${escapeHtml(s.name)} ${attachedBadge}</span>
|
||||
<span class="form-hint">age ${age} · ${s.windows || 1} window(s)</span>
|
||||
</div>
|
||||
<button type="button" class="btn-toolbar" onclick="app.attachDiscoveredSession('${escapeHtml(hostId)}', '${escapeHtml(s.name)}')">Attach</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
results.innerHTML = rows;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a NON-owned session that attaches to a discovered remote tmux session.
|
||||
* Closing this tab detaches — it never kills the remote session.
|
||||
*/
|
||||
async attachDiscoveredSession(hostId, remoteSessionName) {
|
||||
try {
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode: 'shell',
|
||||
name: remoteSessionName,
|
||||
attachRemoteSession: { hostId, remoteSessionName },
|
||||
})
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error || 'Failed to create session');
|
||||
const id = createData.data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (dims) {
|
||||
await fetch(`/api/sessions/${id}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dims)
|
||||
});
|
||||
}
|
||||
this.closeCreateCaseModal();
|
||||
this.showToast(`Attached to ${remoteSessionName} (detach on close)`, 'success');
|
||||
this.activeSessionId = id;
|
||||
await this.selectSession(id);
|
||||
if (this.terminal && typeof this.terminal.focus === 'function') this.terminal.focus();
|
||||
} catch (err) {
|
||||
console.error('Attach discovered session failed:', err);
|
||||
this.showToast('Failed to attach: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case Management (reorder + delete)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -307,6 +307,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
|
||||
@@ -327,7 +328,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
|
||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? true;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -363,6 +364,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
|
||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
|
||||
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
|
||||
// CPU Priority settings
|
||||
const niceSettings = settings.nice || {};
|
||||
@@ -1426,6 +1428,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
||||
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
|
||||
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
@@ -1460,6 +1463,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
|
||||
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
|
||||
// CPU Priority settings
|
||||
nice: {
|
||||
@@ -1618,6 +1622,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
skin: _skin,
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Per-device header/toolbar button toggles — client-only, and absent from
|
||||
@@ -1781,10 +1786,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
showFileViewerButton: false,
|
||||
showRedrawButton: false,
|
||||
showSessionButton: false,
|
||||
showAwayDigestButton: false,
|
||||
showCronButton: true,
|
||||
showCronButton: false,
|
||||
// Remote auto-reconnect (COD-108) — on by default
|
||||
remoteAutoReconnect: true,
|
||||
// Input
|
||||
gestureControlEnabled: false,
|
||||
// Feature toggles - keep tracking on even on mobile
|
||||
@@ -1897,6 +1905,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
|
||||
}
|
||||
|
||||
// File Viewer header button — opt-in, default OFF. Marker class (base is
|
||||
// display:inline-flex !important); clicking it toggles the file browser panel.
|
||||
const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
const fileViewerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (fileViewerBtn) {
|
||||
fileViewerBtn.classList.toggle('btn-file-viewer--hidden', !showFileViewerButton);
|
||||
}
|
||||
|
||||
// Multi-monitor button — hidden by default (App Settings → Display → "Header
|
||||
// Displays"). The server renders the correct initial state on every reload;
|
||||
// this handles a live toggle from a settings save (no reload). Toggle the
|
||||
@@ -1948,8 +1964,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
awayDigestBtn.classList.toggle('btn-away-digest--hidden', !showAwayDigestButton);
|
||||
}
|
||||
|
||||
// Cron button (footer toolbar) — shown by default; hide when disabled.
|
||||
const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? true;
|
||||
// Cron button (footer toolbar) — opt-in, hidden by default. Same marker pattern.
|
||||
const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||
const cronBtn = document.querySelector('.btn-cron');
|
||||
if (cronBtn) {
|
||||
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
|
||||
@@ -2191,7 +2207,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
]);
|
||||
|
||||
@@ -2794,6 +2794,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* COD-139: pinned sessions float to the top with a subtle accent highlight. */
|
||||
.history-item.is-pinned {
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
box-shadow: inset 3px 0 0 rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
|
||||
.history-item.is-pinned:hover {
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.history-item-pin {
|
||||
margin-right: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.history-item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3933,6 +3952,37 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
transition: background var(--transition-smooth);
|
||||
}
|
||||
|
||||
/* COD-105 — discovered remote tmux sessions list (remote-case flow). */
|
||||
.remote-discover-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.remote-discover-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.remote-discover-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.remote-discover-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.case-manage-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@@ -5383,6 +5433,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
/* Modal Tabs */
|
||||
.modal-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0 1rem 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -9447,6 +9498,24 @@ kbd {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* "Run in Docker" quick option — the primary one-click entry point, so its
|
||||
label + hint read larger and brighter than the standard form label/hint. */
|
||||
.docker-quick-row .checkbox-row {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.docker-quick-row .form-hint {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* The container-settings panel is always shown + expanded (no longer gated on
|
||||
the checkbox), so its summary header reads a touch larger too. */
|
||||
.docker-quick-settings > summary {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Response Viewer — native-scroll overlay for reading Claude responses
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@@ -9489,9 +9558,20 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Cron" footer-toolbar button — shown by default (App Settings → Display can
|
||||
hide it). Toolbar button, not a header icon, so only the hide marker is
|
||||
needed; out-specify any base .btn-toolbar display. */
|
||||
/* "File Viewer" header button — opt-in (App Settings → Header Displays), hidden
|
||||
by default. Clicking it toggles the file browser panel. Same marker pattern as
|
||||
the response viewer: a base inline-flex !important so an inline style can't
|
||||
override it, and a more-specific marker rule to hide. */
|
||||
.btn-file-viewer {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-file-viewer.btn-file-viewer--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Cron" footer-toolbar button — opt-in, hidden by default (enable under App
|
||||
Settings → Display → Header Displays). Toolbar button, not a header icon, so
|
||||
only the hide marker is needed; out-specify any base .btn-toolbar display. */
|
||||
.btn-toolbar.btn-cron--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1239,8 +1239,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
|
||||
|
||||
const isPinned = s.pinned === true;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.className = 'history-item' + (isPinned ? ' is-pinned' : '');
|
||||
item.title = s.workingDir || '';
|
||||
|
||||
// Main row: clickable surface. A caller-supplied onActivate wins (the
|
||||
@@ -1258,7 +1260,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (isLive && this.sessions.has(s.sessionId)) {
|
||||
this.selectSession(s.sessionId);
|
||||
} else {
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -1268,7 +1270,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'history-item-title';
|
||||
titleSpan.textContent = s.name || s.firstPrompt || shortDir;
|
||||
if (isPinned) {
|
||||
// Filled pin glyph indicating the session is pinned to the top (COD-139).
|
||||
const pin = document.createElement('span');
|
||||
pin.className = 'history-item-pin';
|
||||
pin.textContent = '📌';
|
||||
pin.setAttribute('aria-label', 'Pinned');
|
||||
pin.title = 'Pinned';
|
||||
titleSpan.appendChild(pin);
|
||||
}
|
||||
titleSpan.appendChild(document.createTextNode(s.name || s.firstPrompt || shortDir));
|
||||
|
||||
// Badge row: mode (claude/codex/opencode/gemini/shell) + a LIVE pill.
|
||||
const badgeRow = document.createElement('div');
|
||||
@@ -1327,6 +1338,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
promptText.textContent = s.firstPrompt || '(no prompt captured)';
|
||||
promptRow.append(promptLabel, promptText);
|
||||
|
||||
// COD-145: show the most recent user prompt too, but collapse single-prompt
|
||||
// sessions (omit when there's no last prompt or it duplicates the first).
|
||||
let lastPromptRow = null;
|
||||
if (s.lastPrompt && s.lastPrompt !== s.firstPrompt) {
|
||||
lastPromptRow = document.createElement('div');
|
||||
lastPromptRow.className = 'history-detail-row';
|
||||
const lastPromptLabel = document.createElement('span');
|
||||
lastPromptLabel.className = 'history-detail-label';
|
||||
lastPromptLabel.textContent = 'Last prompt';
|
||||
const lastPromptText = document.createElement('span');
|
||||
lastPromptText.className = 'history-detail-value history-detail-prompt';
|
||||
lastPromptText.textContent = s.lastPrompt;
|
||||
lastPromptRow.append(lastPromptLabel, lastPromptText);
|
||||
}
|
||||
|
||||
const pathRow = document.createElement('div');
|
||||
pathRow.className = 'history-detail-row';
|
||||
const pathLabel = document.createElement('span');
|
||||
@@ -1345,7 +1371,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
metaParts.push(s.sessionId.slice(0, 8));
|
||||
metaRow.textContent = metaParts.join(' · ');
|
||||
|
||||
detail.append(promptRow, pathRow, metaRow);
|
||||
detail.append(promptRow);
|
||||
if (lastPromptRow) detail.append(lastPromptRow);
|
||||
detail.append(pathRow, metaRow);
|
||||
|
||||
if (showViewAll && s.projectKey) {
|
||||
const actionRow = document.createElement('div');
|
||||
@@ -1463,13 +1491,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else {
|
||||
// Resume by the Claude conversation UUID when present (resumed sessions
|
||||
// carry theirs separately from their Codeman id).
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
|
||||
}
|
||||
this.closeSessionManager?.();
|
||||
closeMenu();
|
||||
}
|
||||
);
|
||||
|
||||
// Pin / Unpin (COD-139) — floats the session to the top of the list.
|
||||
const isPinned = s.pinned === true;
|
||||
addItem(isPinned ? 'Unpin session' : 'Pin to top', async () => {
|
||||
const ok = await this._setSessionPinned(s.sessionId, !isPinned);
|
||||
if (ok) {
|
||||
// Optimistic local flip so a re-render before the SSE event is consistent.
|
||||
s.pinned = !isPinned;
|
||||
this.showToast(!isPinned ? 'Pinned to top' : 'Unpinned', 'success');
|
||||
} else {
|
||||
this.showToast('Pin failed', 'error');
|
||||
}
|
||||
closeMenu();
|
||||
});
|
||||
|
||||
// Open folder (only for a live+open session — file browser is session-scoped).
|
||||
if (isLiveOpen) {
|
||||
addItem('Open folder', () => {
|
||||
@@ -1536,6 +1578,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._openRowMenuClose = closeMenu;
|
||||
},
|
||||
|
||||
/**
|
||||
* COD-139: Toggle a session's pin via POST /api/sessions/:id/pin.
|
||||
* Pinned sessions float to the top of the session manager list. Returns true
|
||||
* on success. The live re-sort happens when the session:pinned SSE event
|
||||
* fires (handled in app.js), so callers don't need to re-render themselves.
|
||||
* @param {string} sessionId
|
||||
* @param {boolean} pinned explicit desired pin state (idempotent)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _setSessionPinned(sessionId, pinned) {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ pinned }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return data?.success === true;
|
||||
} catch (err) {
|
||||
console.error('[_setSessionPinned]', err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Number of history items shown before "Show More" */
|
||||
_HISTORY_INITIAL_COUNT: 4,
|
||||
|
||||
@@ -1721,7 +1789,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._folderHistoryState = null;
|
||||
},
|
||||
|
||||
async resumeHistorySession(sessionId, workingDir) {
|
||||
// Choose the name for a resumed session: keep the session's own name when it
|
||||
// has one, otherwise synthesize a fresh w<N>-<dir> name (next free w-number
|
||||
// across open sessions). COD-143 — resume used to always generate a new name.
|
||||
_resolveResumeName(existingName, workingDir) {
|
||||
if (typeof existingName === 'string' && existingName.trim()) return existingName;
|
||||
const dirName = (workingDir || '').split('/').pop() || 'session';
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
return `w${startNumber}-${dirName}`;
|
||||
},
|
||||
|
||||
async resumeHistorySession(sessionId, workingDir, existingName) {
|
||||
// Close the run mode menu if open
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
// Close folder history modal if open
|
||||
@@ -1730,17 +1815,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
|
||||
|
||||
// Generate a session name from the working dir
|
||||
const dirName = workingDir.split('/').pop() || 'session';
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
const name = `w${startNumber}-${dirName}`;
|
||||
// Keep the session's own name when resuming; only synthesize a w<N>-<dir>
|
||||
// name when the source row had none (COD-143).
|
||||
const name = this._resolveResumeName(existingName, workingDir);
|
||||
|
||||
// Create session with resumeSessionId — include envOverrides so resumed
|
||||
// conversations inherit current UI settings (effort, agent teams, etc.).
|
||||
|
||||
+183
-4
@@ -6,17 +6,23 @@
|
||||
*/
|
||||
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { realpathSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import type { z } from 'zod';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { SessionPort } from './ports/session-port.js';
|
||||
import type { EventPort } from './ports/event-port.js';
|
||||
import type { AuthSessionRecord } from './ports/auth-port.js';
|
||||
import type { StaleExpirationMap } from '../utils/index.js';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
|
||||
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
|
||||
|
||||
// Shared path constants used across route modules. CASES_DIR (project folders)
|
||||
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
|
||||
@@ -79,13 +85,186 @@ export function validateSessionFilePath(
|
||||
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||||
const MAX_HOOK_DATA_SIZE = 8 * 1024;
|
||||
|
||||
/**
|
||||
* Effective identity for a request. In multi-user mode this is the auth-decorated
|
||||
* user; in single-user mode (or when unset) it defaults to a synthetic admin so
|
||||
* downstream ownership checks are no-ops and there is ONE code path.
|
||||
*/
|
||||
export function getAuthUser(req: FastifyRequest): AuthUser {
|
||||
return req.authUser ?? SYNTHETIC_ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an identity may see/act on a resource with the given owner. Always true
|
||||
* in single-user mode; in multi-user, admins see everything and regular users only
|
||||
* their own (an absent owner is legacy/unassigned = admin-only).
|
||||
*/
|
||||
export function canAccessOwned(user: AuthUser, owner: string | undefined): boolean {
|
||||
if (!isMultiUserMode()) return true;
|
||||
if (user.role === 'admin') return true;
|
||||
return !!owner && owner === user.username;
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner to stamp on a resource created by this request: the requesting user in
|
||||
* multi-user mode, or undefined in single-user (so state stays owner-free and the
|
||||
* flag can be removed later without leaving stray owners).
|
||||
*/
|
||||
export function ownerFor(req: FastifyRequest): string | undefined {
|
||||
return isMultiUserMode() ? getAuthUser(req).username : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cases directory for a request/user: the shared ~/codeman-cases in single-user
|
||||
* mode, or the per-user ~/codeman-users/<username>/cases in multi-user (created
|
||||
* lazily). Admins are NOT auto-scoped here — an admin acting on a specific user's
|
||||
* case resolves through the owner-aware case resolver instead.
|
||||
*/
|
||||
export function resolveCasesDir(user?: AuthUser): string {
|
||||
if (!isMultiUserMode() || !user) return CASES_DIR;
|
||||
const dir = userCasesDir(user.username);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Realpath-confine a non-admin's requested working directory to their own case
|
||||
* space in multi-user mode. Returns true if allowed. Admins and single-user mode
|
||||
* are unrestricted. The path need not exist yet (checked against its nearest
|
||||
* existing ancestor) so newly-created case dirs pass. This is the load-bearing
|
||||
* rule (plan 6.2/14.7): every file-serving surface downstream trusts workingDir.
|
||||
*/
|
||||
export function isWorkingDirAllowed(user: AuthUser, workingDir: string): boolean {
|
||||
if (!isMultiUserMode() || user.role === 'admin') return true;
|
||||
const base = userCasesDir(user.username);
|
||||
// Resolve the deepest existing ancestor to defeat symlink escapes without
|
||||
// requiring the leaf to exist yet.
|
||||
const resolveExisting = (p: string): string => {
|
||||
let cur = resolve(p);
|
||||
// walk up until an existing path is found
|
||||
for (;;) {
|
||||
try {
|
||||
return realpathSync(cur);
|
||||
} catch {
|
||||
const parent = resolve(cur, '..');
|
||||
if (parent === cur) return cur;
|
||||
cur = parent;
|
||||
}
|
||||
}
|
||||
};
|
||||
let realBase: string;
|
||||
try {
|
||||
realBase = realpathSync(base);
|
||||
} catch {
|
||||
// base does not exist yet — create it so confinement has a stable anchor
|
||||
mkdirSync(base, { recursive: true });
|
||||
realBase = realpathSync(base);
|
||||
}
|
||||
const realTarget = resolveExisting(workingDir);
|
||||
if (realTarget === realBase) return true;
|
||||
const rel = relative(realBase, realTarget);
|
||||
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Username-keyed variant of `isWorkingDirAllowed` for spawn sites that only carry
|
||||
* an owner username (cron fire-time, scheduled-run loop) rather than a live request.
|
||||
* Resolves the owner's role from the store; a missing/deleted user is treated as a
|
||||
* non-privileged regular user (fails closed to their deterministic case space).
|
||||
* No-op (true) in single-user mode or for an unset owner.
|
||||
*/
|
||||
export async function isWorkingDirAllowedForUsername(
|
||||
username: string | undefined,
|
||||
workingDir: string
|
||||
): Promise<boolean> {
|
||||
if (!isMultiUserMode() || !username) return true;
|
||||
const user = await findUser(username);
|
||||
return isWorkingDirAllowed({ username, role: user?.role ?? 'user' }, workingDir);
|
||||
}
|
||||
|
||||
/** Whether the caller is an admin (or single-user mode, where the sole user is admin). */
|
||||
export function isAdmin(req: FastifyRequest): boolean {
|
||||
return !isMultiUserMode() || getAuthUser(req).role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* First line of admin-only handlers: 403 FORBIDDEN + returns false when the caller
|
||||
* is not an admin. Always true in single-user mode (the sole user is the admin).
|
||||
*/
|
||||
export function requireAdmin(req: FastifyRequest, reply: FastifyReply): boolean {
|
||||
if (isAdmin(req)) return true;
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-capacity check, centralized so the global cap AND the per-user cap are
|
||||
* enforced everywhere a session is created (the check was copy-pasted at 6 sites).
|
||||
* Pure: takes the sessions Map so it composes with ctx.sessions / this.sessions /
|
||||
* this.deps.sessions callers. Per-user cap only applies in multi-user mode.
|
||||
*/
|
||||
export function sessionCapacityState(
|
||||
sessions: ReadonlyMap<string, Session>,
|
||||
owner?: string
|
||||
): { atGlobalCap: boolean; atUserCap: boolean } {
|
||||
const atGlobalCap = sessions.size >= MAX_CONCURRENT_SESSIONS;
|
||||
let atUserCap = false;
|
||||
if (isMultiUserMode() && owner) {
|
||||
let count = 0;
|
||||
for (const s of sessions.values()) if (s.owner === owner) count++;
|
||||
atUserCap = count >= maxSessionsPerUser();
|
||||
}
|
||||
return { atGlobalCap, atUserCap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Route sugar: the human-readable error message when at capacity, else null. The
|
||||
* caller wraps it in createErrorResponse with its own error code (OPERATION_FAILED
|
||||
* vs SESSION_BUSY, matching the pre-existing per-route codes).
|
||||
*/
|
||||
export function sessionCapacityMessage(sessions: ReadonlyMap<string, Session>, owner?: string): string | null {
|
||||
const { atGlobalCap, atUserCap } = sessionCapacityState(sessions, owner);
|
||||
if (atGlobalCap) {
|
||||
return `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`;
|
||||
}
|
||||
if (atUserCap) {
|
||||
return `Your session limit (${maxSessionsPerUser()}) reached. Delete some of your sessions first.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke every cookie session belonging to a user (optionally keeping one token,
|
||||
* e.g. the caller's own during a self-service password change). Returns the count.
|
||||
*/
|
||||
export function revokeUserSessions(
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null,
|
||||
username: string,
|
||||
exceptToken?: string
|
||||
): number {
|
||||
if (!authSessions) return 0;
|
||||
const norm = username.trim().toLowerCase();
|
||||
let removed = 0;
|
||||
for (const [token, record] of authSessions) {
|
||||
if (record.username === norm && token !== exceptToken) {
|
||||
authSessions.delete(token);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a session by ID or throw a structured error.
|
||||
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
|
||||
*
|
||||
* When `req` is passed in multi-user mode, a session the caller does not own is
|
||||
* reported as NOT_FOUND (never 403), so existence of other users' sessions is not
|
||||
* leaked. Single-user / admin callers are unaffected.
|
||||
*/
|
||||
export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session {
|
||||
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session {
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) {
|
||||
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @fileoverview Admin user-management routes (multi-user mode only).
|
||||
*
|
||||
* All handlers: 404 unless multi-user mode is active, requireAdmin, and audit-logged
|
||||
* to ~/.codeman/admin-audit.jsonl. Endpoints (docs/multi-user-plan.md section 8):
|
||||
* GET /api/admin/users
|
||||
* POST /api/admin/users
|
||||
* PATCH /api/admin/users/:username
|
||||
* POST /api/admin/users/:username/reset-password
|
||||
* POST /api/admin/users/:username/logout
|
||||
* DELETE /api/admin/users/:username
|
||||
*
|
||||
* Self-service GET /api/me + POST /api/me/password live in me-routes.ts.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js';
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
deleteUserSpace,
|
||||
findUser,
|
||||
generateOneTimePassword,
|
||||
readUsers,
|
||||
setPassword,
|
||||
toPublicUser,
|
||||
updateUser,
|
||||
UserStoreError,
|
||||
} from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import type { EventPort } from '../ports/event-port.js';
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
role: z.enum(['admin', 'user']).default('user'),
|
||||
password: z.string().min(8).max(1024).optional(),
|
||||
canBypassPermissions: z.boolean().optional(),
|
||||
});
|
||||
const UpdateUserSchema = z.object({
|
||||
role: z.enum(['admin', 'user']).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
canBypassPermissions: z.boolean().optional(),
|
||||
});
|
||||
const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() });
|
||||
|
||||
/** Map a UserStoreError's code onto the API error code + status. */
|
||||
function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof createErrorResponse> {
|
||||
if (err instanceof UserStoreError) {
|
||||
const code = ApiErrorCode[err.code as keyof typeof ApiErrorCode] ?? ApiErrorCode.INVALID_INPUT;
|
||||
reply.code(
|
||||
err.code === 'USER_EXISTS' || err.code === 'LAST_ADMIN' ? 409 : err.code === 'USER_NOT_FOUND' ? 404 : 400
|
||||
);
|
||||
return createErrorResponse(code, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error');
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void {
|
||||
// Gate: admin routes exist only in multi-user mode, and only for admins.
|
||||
const gate = (req: FastifyRequest, reply: FastifyReply): boolean => {
|
||||
if (!isMultiUserMode()) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Not found'));
|
||||
return false;
|
||||
}
|
||||
return requireAdmin(req, reply);
|
||||
};
|
||||
const audit = (req: FastifyRequest, action: string, target?: string, detail?: Record<string, unknown>) =>
|
||||
void appendAdminAudit({ admin: getAuthUser(req).username, action, target, ip: req.ip, detail });
|
||||
|
||||
// Count a user's live sessions + active cookie sessions + case folders.
|
||||
const statsFor = (username: string) => {
|
||||
let liveSessions = 0;
|
||||
for (const s of ctx.sessions.values()) if (s.owner === username) liveSessions++;
|
||||
let activeSessions = 0;
|
||||
if (ctx.authSessions) for (const [, rec] of ctx.authSessions) if (rec.username === username) activeSessions++;
|
||||
let caseCount = 0;
|
||||
try {
|
||||
caseCount = readdirSync(userCasesDir(username), { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
||||
} catch {
|
||||
/* no cases dir yet */
|
||||
}
|
||||
return { liveSessions, activeSessions, caseCount };
|
||||
};
|
||||
|
||||
app.get('/api/admin/users', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const users = await readUsers(true);
|
||||
return {
|
||||
success: true,
|
||||
data: users.map((u) => ({ ...toPublicUser(u), stats: statsFor(u.username) })),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/admin/users', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const parsed = CreateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
// No password given: generate a one-time password, returned ONCE, force change.
|
||||
const oneTime = parsed.data.password ? undefined : generateOneTimePassword();
|
||||
try {
|
||||
const user = await createUser({
|
||||
username: parsed.data.username,
|
||||
role: parsed.data.role,
|
||||
password: parsed.data.password ?? oneTime!,
|
||||
canBypassPermissions: parsed.data.canBypassPermissions,
|
||||
mustChangePassword: !parsed.data.password,
|
||||
});
|
||||
audit(req, 'user.create', user.username, { role: user.role });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { user: toPublicUser(user), oneTimePassword: oneTime } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/admin/users/:username', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const parsed = UpdateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
try {
|
||||
const user = await updateUser(username, parsed.data);
|
||||
// Security: revoke the target's cookie sessions on ANY successful update. role,
|
||||
// disabled, and canBypassPermissions are all authorization-relevant, and the
|
||||
// cookie snapshots role, so a stale cookie could otherwise retain old privileges
|
||||
// (a demoted admin staying admin). Idempotent, affects only the target, and
|
||||
// forces a re-auth that re-snapshots the new record.
|
||||
revokeUserSessions(ctx.authSessions, user.username);
|
||||
audit(req, 'user.update', user.username, parsed.data);
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { user: toPublicUser(user) } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:username/reset-password', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
if (!(await findUser(username))) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
|
||||
}
|
||||
const oneTime = generateOneTimePassword();
|
||||
try {
|
||||
await setPassword(username, oneTime, { mustChangePassword: true });
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.reset-password', username);
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { oneTimePassword: oneTime } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:username/logout', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const revoked = revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.logout', username, { revoked });
|
||||
return { success: true, data: { revoked } };
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:username', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const parsed = DeleteUserSchema.safeParse(req.body ?? {});
|
||||
const deleteSpace = parsed.success ? parsed.data.deleteSpace : false;
|
||||
try {
|
||||
// Security: validate BEFORE any teardown. deleteUser runs the authoritative
|
||||
// existence + last-admin guard under lock with no side effects, so a refusal
|
||||
// (409 LAST_ADMIN / 404 USER_NOT_FOUND) leaves the user's live sessions and
|
||||
// cookies untouched. Only after it succeeds do we irreversibly kill sessions and
|
||||
// revoke cookies. (owned is captured from the in-memory map, independent of the
|
||||
// record, so it is safe to read before the delete.)
|
||||
const owned = [...ctx.sessions.values()].filter((s) => s.owner === username).map((s) => s.id);
|
||||
await deleteUser(username); // throws LAST_ADMIN / USER_NOT_FOUND (no side effects)
|
||||
for (const id of owned) {
|
||||
await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {});
|
||||
}
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
if (deleteSpace) await deleteUserSpace(username);
|
||||
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { username, deletedSpace: !!deleteSpace } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
+328
-98
@@ -11,7 +11,7 @@ import fs from 'node:fs/promises';
|
||||
import { join, resolve, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo, DockerHost } from '../../types.js';
|
||||
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
@@ -28,18 +28,35 @@ import {
|
||||
import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
import {
|
||||
canAccessOwned,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
resolveCasesDir,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
parseBody,
|
||||
readJsonConfig,
|
||||
} from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { AuthUser } from '../../types.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerImagePresent,
|
||||
checkDockerTmuxAvailable,
|
||||
ensureAgentBaseImage,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
removeDockerContainer,
|
||||
toSessionDocker,
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
@@ -47,6 +64,7 @@ import {
|
||||
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
listRemoteCodemanSessions,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
remoteDisplayPath,
|
||||
@@ -76,61 +94,115 @@ async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||
}
|
||||
|
||||
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
|
||||
async function resolveCasePath(name: string): Promise<string> {
|
||||
/**
|
||||
* Resolve a case name to its directory path, checking linked cases first, then the
|
||||
* user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise).
|
||||
*/
|
||||
async function resolveCasePath(name: string, user?: AuthUser): Promise<string> {
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) return linkedCases[name];
|
||||
return join(CASES_DIR, name);
|
||||
// Linked cases carry no owner (legacy/admin-only registry): a non-admin must not
|
||||
// resolve arbitrary linked paths by name in multi-user mode (path-escape guard).
|
||||
if (linkedCases[name] && (!isMultiUserMode() || user?.role === 'admin')) return linkedCases[name];
|
||||
return join(resolveCasesDir(user), name);
|
||||
}
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
||||
/**
|
||||
* Gate a docker case on its base image, AUTO-BUILDING the default image on first
|
||||
* use so a missing image is never a blocker (the user's ask: "create it when it's
|
||||
* used for the first time"). Present image → verify tmux (hard prerequisite).
|
||||
* Default image missing → kick off a BACKGROUND build with SSE progress and return
|
||||
* `imageBuilding: true` (the case is created regardless; first launch awaits the
|
||||
* same dedup'd build). Custom image missing → a real error (we can't build a
|
||||
* foreign ref, and `--pull=never` forbids pulling).
|
||||
*/
|
||||
async function ensureCaseImage(
|
||||
broadcast: EventPort['broadcast'],
|
||||
sessionDocker: SessionDocker,
|
||||
name: string
|
||||
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
|
||||
if (await checkDockerImagePresent(sessionDocker, sessionDocker.image)) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
|
||||
return { ok: true, imageBuilding: false };
|
||||
}
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `base image ${sessionDocker.image} not present; only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull it first.`,
|
||||
};
|
||||
}
|
||||
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
|
||||
void ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
|
||||
})
|
||||
.then((r) =>
|
||||
broadcast(r.ok ? SseEvent.DockerImageBuildComplete : SseEvent.DockerImageBuildFailed, {
|
||||
name,
|
||||
image: sessionDocker.image,
|
||||
error: r.error,
|
||||
})
|
||||
)
|
||||
.catch((err) =>
|
||||
broadcast(SseEvent.DockerImageBuildFailed, { name, image: sessionDocker.image, error: getErrorMessage(err) })
|
||||
);
|
||||
return { ok: true, imageBuilding: true };
|
||||
}
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case CRUD (list, create, link, detail, fix-plan)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== List Cases ==========
|
||||
|
||||
app.get('/api/cases', async (): Promise<CaseInfo[]> => {
|
||||
app.get('/api/cases', async (req): Promise<CaseInfo[]> => {
|
||||
const cases: CaseInfo[] = [];
|
||||
const user = getAuthUser(req);
|
||||
const admin = isAdmin(req);
|
||||
// Non-admins enumerate their OWN case space; admins see the shared CASES_DIR.
|
||||
const listBase = resolveCasesDir(user);
|
||||
|
||||
// Get cases from CASES_DIR
|
||||
// Get cases from the user's (or shared) cases dir
|
||||
try {
|
||||
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
|
||||
const entries = await fs.readdir(listBase, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
||||
cases.push({
|
||||
name: e.name,
|
||||
path: join(CASES_DIR, e.name),
|
||||
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
|
||||
path: join(listBase, e.name),
|
||||
hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')),
|
||||
location: 'local',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// CASES_DIR may not exist yet
|
||||
// dir may not exist yet
|
||||
}
|
||||
|
||||
// Get linked cases
|
||||
// Linked cases (v1 registry has no owner) are admin-only in multi-user mode.
|
||||
const linkedCases = await readLinkedCases();
|
||||
const existingNames = new Set(cases.map((c) => c.name));
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
});
|
||||
if (admin) {
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get remote cases
|
||||
// Get remote cases (owner-scoped; legacy no-owner = admin-only)
|
||||
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
|
||||
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
|
||||
const host = remoteHostMap.get(remoteCase.hostId);
|
||||
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
|
||||
if (!admin && !canAccessOwned(user, remoteCase.owner)) continue;
|
||||
existingNames.add(remoteCase.name);
|
||||
const remoteCaseInfo: CaseInfo = {
|
||||
name: remoteCase.name,
|
||||
@@ -158,6 +230,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) {
|
||||
const host = dockerHostMap.get(dockerCase.hostId);
|
||||
if (!host || !SAFE_CASE_NAME.test(dockerCase.name)) continue;
|
||||
if (!admin && !canAccessOwned(user, dockerCase.owner)) continue;
|
||||
existingNames.add(dockerCase.name);
|
||||
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
|
||||
const dockerCaseInfo: CaseInfo = {
|
||||
@@ -199,7 +272,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||
|
||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
@@ -228,9 +301,41 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR));
|
||||
// Hosts are machine-level infra config (ssh users/identity paths): non-admins get an
|
||||
// empty list in multi-user mode, matching the admin-only write side. No-op otherwise.
|
||||
app.get('/api/remote-hosts', async (req) =>
|
||||
isMultiUserMode() && !isAdmin(req) ? [] : readRemoteHosts(CODEMAN_CONFIG_DIR)
|
||||
);
|
||||
|
||||
app.post('/api/remote-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
// Hosts are machine-level resources: only admins may define them in multi-user mode.
|
||||
const adminOnly = (req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse<never> | null =>
|
||||
isAdmin(req)
|
||||
? null
|
||||
: (reply.code(403), createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'));
|
||||
|
||||
// COD-105 — discover `codeman-*` tmux sessions already running on a remote
|
||||
// host (created by the remote's own Codeman, another instance, or this one)
|
||||
// so the operator can attach to one this Codeman didn't launch. Explicit
|
||||
// trigger only (Decision A): the frontend calls this on a "Discover" click,
|
||||
// never automatically on host select. listRemoteCodemanSessions never throws
|
||||
// (returns [] on unreachable/no-tmux/no-sessions) and is ssh-guarded under test.
|
||||
// Hosts are admin-only infra in multi-user mode, so discovery is too.
|
||||
app.get(
|
||||
'/api/remote-hosts/:hostId/sessions',
|
||||
async (req, reply): Promise<ApiResponse<{ sessions: RemoteSessionInfo[] }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { hostId } = req.params as { hostId: string };
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
const sessions = await listRemoteCodemanSessions(host);
|
||||
return { success: true, data: { sessions } };
|
||||
}
|
||||
);
|
||||
|
||||
app.post('/api/remote-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const host = parseBody(RemoteHostSchema, req.body);
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
@@ -240,7 +345,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.put('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
app.put('/api/remote-hosts/:id', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(RemoteHostSchema, { ...(req.body as object), id });
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
@@ -252,7 +359,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.delete('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ id: string }>> => {
|
||||
app.delete('/api/remote-hosts/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (cases.some((item) => item.hostId === id)) {
|
||||
@@ -267,7 +376,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
});
|
||||
|
||||
app.post('/api/cases/remote-link', async (req): Promise<ApiResponse<{ case: unknown }>> => {
|
||||
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const };
|
||||
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const, owner: ownerFor(req) };
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -277,7 +386,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
if (
|
||||
remoteCases.some((item) => item.name === remoteCase.name) ||
|
||||
linkedCases[remoteCase.name] ||
|
||||
existsSync(join(CASES_DIR, remoteCase.name))
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), remoteCase.name))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
@@ -297,9 +406,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
// ========== Docker hosts + docker cases (COD-Docker) ==========
|
||||
|
||||
app.get('/api/docker-hosts', async () => readDockerHosts(CODEMAN_CONFIG_DIR));
|
||||
// Hosts are machine-level infra config (images/mounts/env): non-admins get an empty
|
||||
// list in multi-user mode, matching the admin-only write side. No-op otherwise.
|
||||
app.get('/api/docker-hosts', async (req) =>
|
||||
isMultiUserMode() && !isAdmin(req) ? [] : readDockerHosts(CODEMAN_CONFIG_DIR)
|
||||
);
|
||||
|
||||
app.post('/api/docker-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
app.post('/api/docker-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const host = parseBody(DockerHostSchema, req.body);
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
@@ -309,7 +424,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.put('/api/docker-hosts/:id', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
app.put('/api/docker-hosts/:id', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(DockerHostSchema, { ...(req.body as object), id });
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
@@ -321,7 +438,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.delete('/api/docker-hosts/:id', async (req): Promise<ApiResponse<{ id: string }>> => {
|
||||
app.delete('/api/docker-hosts/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (cases.some((item) => item.hostId === id)) {
|
||||
@@ -337,8 +456,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
app.post(
|
||||
'/api/cases/docker-link',
|
||||
async (req): Promise<ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean }>> => {
|
||||
const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' as const };
|
||||
async (
|
||||
req
|
||||
): Promise<
|
||||
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
|
||||
> => {
|
||||
const dockerCase = {
|
||||
...parseBody(DockerCaseLinkSchema, req.body),
|
||||
type: 'docker' as const,
|
||||
owner: ownerFor(req),
|
||||
};
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
@@ -348,11 +475,17 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
if (
|
||||
dockerCases.some((item) => item.name === dockerCase.name) ||
|
||||
linkedCases[dockerCase.name] ||
|
||||
existsSync(join(CASES_DIR, dockerCase.name))
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// Confine the bind-mounted workspace to the caller's own space BEFORE creating it
|
||||
// (also removes the arbitrary-dir-creation primitive). No-op for admins/single-user.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace');
|
||||
}
|
||||
|
||||
// The workspace is a REAL host directory (bind-mounted into the container), so
|
||||
// create it now if missing. Scaffolding (.claude/settings.local.json + CLAUDE.md)
|
||||
// is written by quick-start on first launch, matching local-case behaviour.
|
||||
@@ -367,9 +500,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
}
|
||||
|
||||
// Courtesy validation: docker daemon must be reachable AND the base image must
|
||||
// contain tmux (a hard prerequisite for durable in-container sessions). Surfaces
|
||||
// a clear error at link time instead of a dead pane on first launch.
|
||||
// Courtesy validation: docker daemon must be reachable. The base image is
|
||||
// auto-built on first use (default image) rather than being a link-time
|
||||
// blocker, so a missing image kicks off a background build instead of erroring.
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
@@ -377,9 +510,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(toSessionDocker(host, dockerCase));
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
||||
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), dockerCase.name);
|
||||
if (!imageGate.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
|
||||
}
|
||||
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
|
||||
@@ -390,7 +523,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: { case: dockerCase, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop },
|
||||
data: {
|
||||
case: dockerCase,
|
||||
capsEnforced: availability.capsEnforced,
|
||||
isDesktop: availability.isDesktop,
|
||||
imageBuilding: imageGate.imageBuilding,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -400,10 +538,14 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// shared `default` docker host so the user never touches host/image/network fields.
|
||||
app.post(
|
||||
'/api/cases/docker-quickcreate',
|
||||
async (req): Promise<ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean }>> => {
|
||||
async (
|
||||
req
|
||||
): Promise<
|
||||
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
|
||||
> => {
|
||||
const body = parseBody(DockerQuickCreateSchema, req.body);
|
||||
const { name, description } = body;
|
||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
|
||||
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
|
||||
// Collision across every case kind.
|
||||
@@ -458,8 +600,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
);
|
||||
}
|
||||
|
||||
// Probe the daemon + base image BEFORE scaffolding, so a missing docker/image
|
||||
// surfaces a clear error instead of leaving an orphaned case folder.
|
||||
// Probe the daemon BEFORE scaffolding so a missing docker surfaces a clear
|
||||
// error instead of leaving an orphaned case folder. The base image is NOT a
|
||||
// blocker: a missing default image auto-builds in the background on first use.
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
@@ -467,13 +610,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
const dockerCase = { name, type: 'docker' as const, hostId: host.id, hostWorkspacePath: casePath };
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(toSessionDocker(host, dockerCase));
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
tmuxCheck.error || 'base image is missing tmux (build it: node scripts/build-agent-image.mjs)'
|
||||
);
|
||||
const dockerCase = {
|
||||
name,
|
||||
type: 'docker' as const,
|
||||
hostId: host.id,
|
||||
hostWorkspacePath: casePath,
|
||||
owner: ownerFor(req),
|
||||
};
|
||||
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
|
||||
if (!imageGate.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
|
||||
}
|
||||
|
||||
// Scaffold the case folder exactly like a normal case.
|
||||
@@ -492,7 +638,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
|
||||
return {
|
||||
success: true,
|
||||
data: { case: dockerCase, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop },
|
||||
data: {
|
||||
case: dockerCase,
|
||||
capsEnforced: availability.capsEnforced,
|
||||
isDesktop: availability.isDesktop,
|
||||
imageBuilding: imageGate.imageBuilding,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -584,51 +735,113 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
if (
|
||||
dockerCases.some((item) => item.name === newCaseName) ||
|
||||
linkedCases[newCaseName] ||
|
||||
existsSync(join(CASES_DIR, newCaseName))
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), newCaseName))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// Import extracts a tar into destWorkspacePath (later becomes Session.workingDir):
|
||||
// confine it to the caller's own space. No-op for admins/single-user.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), destWorkspacePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'destWorkspacePath is outside your workspace');
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
let result;
|
||||
try {
|
||||
result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp });
|
||||
result = await importDockerBundle({
|
||||
bundlePath,
|
||||
destWorkspace: destWorkspacePath,
|
||||
engine: 'docker',
|
||||
timestamp,
|
||||
newCaseName,
|
||||
});
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
// Create a dedicated docker host pointing at the quarantined imported image
|
||||
// (full mode) or the manifest's base image (workspace-only).
|
||||
// Create (or REFRESH) the dedicated docker host pointing at the quarantined
|
||||
// imported image (full mode) or the manifest's base image (workspace-only).
|
||||
// Refresh matters: after a case-delete + re-import of the same name, a stale
|
||||
// `imported-<name>` host would silently pin the PREVIOUS import's image tag.
|
||||
const hostId = `imported-${newCaseName}`;
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
if (!hosts.some((h) => h.id === hostId)) {
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [
|
||||
...hosts,
|
||||
{
|
||||
id: hostId,
|
||||
label: `Imported: ${newCaseName}`,
|
||||
engine: result.manifest.engine,
|
||||
image: result.importedImage ?? result.manifest.image,
|
||||
network: (['bridge', 'none', 'custom'].includes(result.manifest.network)
|
||||
? result.manifest.network
|
||||
: 'bridge') as 'bridge' | 'none' | 'custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
const importedHost = {
|
||||
id: hostId,
|
||||
label: `Imported: ${newCaseName}`,
|
||||
engine: result.manifest.engine,
|
||||
image: result.importedImage ?? result.manifest.image,
|
||||
network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as
|
||||
| 'bridge'
|
||||
| 'none'
|
||||
| 'custom',
|
||||
};
|
||||
await writeDockerHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.some((h) => h.id === hostId)
|
||||
? hosts.map((h) => (h.id === hostId ? { ...h, ...importedHost } : h))
|
||||
: [...hosts, importedHost]
|
||||
);
|
||||
const newCase = {
|
||||
name: newCaseName,
|
||||
type: 'docker' as const,
|
||||
hostId,
|
||||
hostWorkspacePath: destWorkspacePath,
|
||||
containerWorkdir: result.manifest.containerWorkdir,
|
||||
owner: ownerFor(req),
|
||||
};
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
|
||||
ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' });
|
||||
return { success: true, data: { case: newCase } };
|
||||
});
|
||||
|
||||
// Recreate-on-drift confirm (docs/docker-cases-plan.md §4): remove the case
|
||||
// container so the next launch recreates it with the CURRENT host config. The
|
||||
// workspace + transcripts ride bind mounts and survive; the conversation resumes
|
||||
// via the case's lastClaudeSessionId. Refused while sessions of the case are live
|
||||
// (removal would yank the container out from under their panes).
|
||||
app.post(
|
||||
'/api/docker-cases/:name/recreate',
|
||||
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.docker?.containerName === sessionDocker.containerName && session.pid) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
`Sessions of case "${name}" are still running — stop them first, then recreate the container.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await removeDockerContainer(sessionDocker);
|
||||
} catch (err) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Failed to remove container: ${getErrorMessage(err)}`
|
||||
);
|
||||
}
|
||||
// Drop the per-container claude-config seed; it is regenerated at next launch.
|
||||
await fs
|
||||
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
|
||||
.catch(() => {});
|
||||
ctx.broadcast(SseEvent.DockerContainerRecreated, { name, container: sessionDocker.containerName });
|
||||
return { success: true, data: { name, container: sessionDocker.containerName } };
|
||||
}
|
||||
);
|
||||
|
||||
// Link an existing folder as a case
|
||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
app.post('/api/cases/link', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
// Linking writes an arbitrary absolute path into the shared ownerless registry:
|
||||
// admin-only in multi-user mode (mirrors host CRUD + the admin-only GET listing).
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Expand ~ to home directory
|
||||
@@ -640,7 +853,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
|
||||
// Check if case name already exists in CASES_DIR
|
||||
const casePath = join(CASES_DIR, name);
|
||||
const casePath = join(resolveCasesDir(getAuthUser(req)), name);
|
||||
if (existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
|
||||
}
|
||||
@@ -675,45 +888,56 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const user = getAuthUser(req);
|
||||
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(user))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Fold ownership INTO the match (don't early-return): a non-owned same-named remote/
|
||||
// docker case is skipped so control falls through to the caller's own local delete.
|
||||
// canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (remoteCases.some((item) => item.name === name)) {
|
||||
if (remoteCases.some((item) => item.name === name && canAccessOwned(user, item.owner))) {
|
||||
await writeRemoteCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
remoteCases.filter((item) => item.name !== name)
|
||||
remoteCases.filter((item) => !(item.name === name && canAccessOwned(user, item.owner)))
|
||||
);
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const dockerCase = dockerCases.find((item) => item.name === name);
|
||||
const dockerCase = dockerCases.find((item) => item.name === name && canAccessOwned(user, item.owner));
|
||||
if (dockerCase) {
|
||||
await writeDockerCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
dockerCases.filter((item) => item.name !== name)
|
||||
dockerCases.filter((item) => item !== dockerCase)
|
||||
);
|
||||
// Best-effort `docker rm -f` the per-case container (case-delete is the
|
||||
// explicit teardown that removes it; the bind-mounted workspace survives).
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (host) {
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
try {
|
||||
exec(buildDockerRemoveCommand(toSessionDocker(host, dockerCase)), { timeout: 15_000 }, () => {});
|
||||
exec(buildDockerRemoveCommand(sessionDocker), { timeout: 15_000 }, () => {});
|
||||
} catch {
|
||||
/* best-effort — never blocks the unlink */
|
||||
}
|
||||
// Remove the per-container claude-config seed file (account metadata copy).
|
||||
await fs
|
||||
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
// Check linked cases first — unlink only, don't delete the actual directory
|
||||
// Check linked cases first — unlink only, don't delete the actual directory.
|
||||
// Linked cases carry no owner (admin-only WRITE in multi-user mode), so a non-admin
|
||||
// must not unlink one either; skip so control falls through to their local delete.
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) {
|
||||
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) {
|
||||
delete linkedCases[name];
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
@@ -725,7 +949,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
|
||||
// Case in CASES_DIR — delete the entire directory
|
||||
const casePath = join(CASES_DIR, name);
|
||||
const casePath = join(resolveCasesDir(getAuthUser(req)), name);
|
||||
if (!existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
|
||||
}
|
||||
@@ -767,12 +991,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.get('/api/cases/:name', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Fold ownership INTO the match (don't early-return): a non-owned same-named remote/
|
||||
// docker case is skipped so control falls through to the caller's own LOCAL case
|
||||
// (remote/docker names are globally unique, local names per-user). No metadata is
|
||||
// disclosed for a foreign case. canAccessOwned is allow-all for admins/single-user.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === name);
|
||||
const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner));
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -790,7 +1018,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
};
|
||||
}
|
||||
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
|
||||
(item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
if (dockerCase) {
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
@@ -810,13 +1040,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
};
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(name);
|
||||
const casePath = await resolveCasePath(name, getAuthUser(req));
|
||||
|
||||
if (!existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
||||
}
|
||||
|
||||
const linked = casePath !== join(CASES_DIR, name);
|
||||
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
|
||||
return {
|
||||
name,
|
||||
path: casePath,
|
||||
@@ -829,12 +1059,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.get('/api/cases/:name/fix-plan', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Get case path (check linked cases first, then CASES_DIR)
|
||||
const casePath = await resolveCasePath(name);
|
||||
const casePath = await resolveCasePath(name, getAuthUser(req));
|
||||
|
||||
const fixPlanPath = join(casePath, '@fix_plan.md');
|
||||
|
||||
@@ -934,11 +1164,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||||
const { caseName } = req.params as { caseName: string };
|
||||
if (!validatePathWithinBase(caseName, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(caseName);
|
||||
const casePath = await resolveCasePath(caseName, getAuthUser(req));
|
||||
|
||||
const wizardDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
@@ -977,7 +1207,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// Cache disabled to ensure fresh prompts when starting new plan generations
|
||||
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
||||
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
|
||||
if (!validatePathWithinBase(caseName, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
@@ -986,7 +1216,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
reply.header('Pragma', 'no-cache');
|
||||
reply.header('Expires', '0');
|
||||
|
||||
const casePath = await resolveCasePath(caseName);
|
||||
const casePath = await resolveCasePath(caseName, getAuthUser(req));
|
||||
|
||||
const wizardDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
|
||||
@@ -5,19 +5,29 @@
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort } from '../ports/index.js';
|
||||
import type { EventPort, SessionPort } from '../ports/index.js';
|
||||
import { getAuthUser, canAccessOwned } from '../route-helpers.js';
|
||||
import { createErrorResponse, ApiErrorCode } from '../../types.js';
|
||||
|
||||
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort & SessionPort): void {
|
||||
app.post('/api/clipboard', async (req) => {
|
||||
const body = req.body as { text?: string; sessionId?: string };
|
||||
const text = body?.text;
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing or empty "text" field');
|
||||
}
|
||||
// Multi-user: a supplied sessionId must belong to the caller — never let a
|
||||
// client target another user's session (no-op in single-user).
|
||||
if (body.sessionId && !canAccessOwned(getAuthUser(req), ctx.sessions.get(body.sessionId)?.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Cannot target another user session');
|
||||
}
|
||||
ctx.broadcast(SseEvent.ClipboardWrite, {
|
||||
text,
|
||||
sessionId: body.sessionId ?? null,
|
||||
// Stamp the trusted caller identity so deriveSseHint routes this write to the
|
||||
// caller's own tabs only (multi-user). Undefined in single-user → JSON drops
|
||||
// the field and delivery stays global to that one user's browsers.
|
||||
callerUsername: req.authUser?.username,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return {};
|
||||
|
||||
@@ -9,33 +9,77 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { canAccessOwned, getAuthUser, isWorkingDirAllowed, ownerFor, parseBody } from '../route-helpers.js';
|
||||
import { canUsernameRunPrivilegedCommands } from '../../user-store.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { CronJob } from '../../types/cron.js';
|
||||
import type { CronPort } from '../ports/index.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
// A job the caller may see/act on (own, or admin/single-user).
|
||||
const canTouch = (req: FastifyRequest, job: CronJob | null | undefined): job is CronJob =>
|
||||
!!job && canAccessOwned(getAuthUser(req), job.owner);
|
||||
|
||||
// ── Jobs ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/cron/jobs', async () => {
|
||||
return ctx.cron.listJobs();
|
||||
app.get('/api/cron/jobs', async (req) => {
|
||||
const jobs = ctx.cron.listJobs();
|
||||
if (!isMultiUserMode()) return jobs;
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'admin') return jobs;
|
||||
return (jobs as CronJob[]).filter((j) => canAccessOwned(user, j.owner));
|
||||
});
|
||||
|
||||
app.post('/api/cron/jobs', async (req) => {
|
||||
// No custom errorMessage: surface the schema's field-specific messages
|
||||
// (e.g. "runAt is required for a one-time schedule").
|
||||
const body = parseBody(CronJobSchema, req.body);
|
||||
return { job: ctx.cron.createJob(body) };
|
||||
// Section 6.2: confine the job's workingDir to the owner's case space (mirrors
|
||||
// POST /api/sessions). No-op allow-all for admins/single-user. workingDir is
|
||||
// required by CronJobSchema so it is always present here.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), body.workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
// Section 6.3: shell mode / a launchCommand is arbitrary host-account execution.
|
||||
// Resolve the owner's grant from the store (AuthUser.role alone can't tell a GRANTED
|
||||
// regular user from a plain one); mirrors session-routes + the cron fire-time re-check.
|
||||
if (
|
||||
(body.agentType === 'shell' || body.launchCommand) &&
|
||||
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
'Shell/launchCommand cron jobs require the can-bypass-permissions grant'
|
||||
);
|
||||
}
|
||||
return { job: ctx.cron.createJob(body, ownerFor(req)) };
|
||||
});
|
||||
|
||||
app.get('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = ctx.cron.getJob(id);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
if (!canTouch(req, job)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return job;
|
||||
});
|
||||
|
||||
app.put('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const body = parseBody(CronJobUpdateSchema, req.body);
|
||||
// Section 6.2: the update body is partial, so only confine when workingDir is set.
|
||||
if (body.workingDir !== undefined && !isWorkingDirAllowed(getAuthUser(req), body.workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
if (
|
||||
(body.agentType === 'shell' || body.launchCommand) &&
|
||||
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
'Shell/launchCommand cron jobs require the can-bypass-permissions grant'
|
||||
);
|
||||
}
|
||||
const job = ctx.cron.updateJob(id, body);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return { job };
|
||||
@@ -43,7 +87,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
|
||||
app.delete('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!ctx.cron.deleteJob(id)) {
|
||||
if (!canTouch(req, ctx.cron.getJob(id)) || !ctx.cron.deleteJob(id)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
}
|
||||
return {};
|
||||
@@ -51,6 +95,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
|
||||
app.put('/api/cron/jobs/:id/enabled', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const { enabled } = parseBody(CronJobEnabledSchema, req.body, 'Invalid request body');
|
||||
const job = ctx.cron.setEnabled(id, enabled);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
@@ -62,7 +107,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
app.post('/api/cron/jobs/:id/run', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = ctx.cron.getJob(id);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
if (!canTouch(req, job)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const run = await ctx.cron.runNow(id);
|
||||
return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType, job.id) };
|
||||
});
|
||||
@@ -71,10 +116,22 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
|
||||
app.get('/api/cron/jobs/:id/runs', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Owner-gate like every other :id handler so a foreign job's run history (session
|
||||
// ids, names, deep links) isn't leaked; NOT_FOUND avoids disclosing existence.
|
||||
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return ctx.cron.listRuns(id);
|
||||
});
|
||||
|
||||
app.get('/api/cron/runs', async () => {
|
||||
return ctx.cron.listRuns();
|
||||
app.get('/api/cron/runs', async (req) => {
|
||||
const runs = ctx.cron.listRuns();
|
||||
if (!isMultiUserMode()) return runs;
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'admin') return runs;
|
||||
// Non-admin: keep only runs whose owning job the caller can access (drops runs
|
||||
// whose job is absent from the map — defensive; deleteJob already cascades).
|
||||
const ownerByJobId = new Map<string, string | undefined>(
|
||||
ctx.cron.listJobs().map((j): [string, string | undefined] => [j.id, j.owner])
|
||||
);
|
||||
return runs.filter((run) => canAccessOwned(user, ownerByJobId.get(run.cronJobId)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -227,13 +228,17 @@ async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extensi
|
||||
function getKnownSessionWorkingDir(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
req: FastifyRequest
|
||||
): string | undefined {
|
||||
// Multi-user: a non-admin may only reach their OWN session's files. A foreign
|
||||
// (or missing) session is reported identically as 404 so existence isn't leaked.
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) return liveSession.workingDir;
|
||||
if (liveSession && canAccessOwned(user, liveSession.owner)) return liveSession.workingDir;
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
if (stored) return stored.workingDir;
|
||||
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) return stored.workingDir;
|
||||
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
||||
return undefined;
|
||||
@@ -261,10 +266,13 @@ function appendDownloadFlag(url: string): string {
|
||||
|
||||
function getSessionAttachmentHistory(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
req: FastifyRequest
|
||||
): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined {
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) {
|
||||
if (!canAccessOwned(user, liveSession.owner)) return undefined;
|
||||
return {
|
||||
workingDir: liveSession.workingDir,
|
||||
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
|
||||
@@ -272,7 +280,7 @@ function getSessionAttachmentHistory(
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined;
|
||||
if (!stored) return undefined;
|
||||
if (!stored || !canAccessOwned(user, (stored as { owner?: string }).owner)) return undefined;
|
||||
|
||||
return {
|
||||
workingDir: stored.workingDir,
|
||||
@@ -371,7 +379,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { depth, showHidden } = req.query as { depth?: string; showHidden?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||||
const includeHidden = showHidden === 'true';
|
||||
@@ -495,7 +503,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-content', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
|
||||
@@ -648,7 +656,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, download } = req.query as { path?: string; download?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
@@ -737,7 +745,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// 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 session = findSessionOrFail(ctx, id, req);
|
||||
const body = (req.body || {}) as { path?: string };
|
||||
|
||||
if (!body.path || typeof body.path !== 'string') {
|
||||
@@ -766,7 +774,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// each entry to current metadata + routes. External entries are re-registered.
|
||||
app.get('/api/sessions/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const sessionHistory = getSessionAttachmentHistory(ctx, id);
|
||||
const sessionHistory = getSessionAttachmentHistory(ctx, id, req);
|
||||
if (!sessionHistory) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
|
||||
return;
|
||||
@@ -794,7 +802,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// size/mtime as the underlying file is rewritten).
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -831,7 +839,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
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 session = findSessionOrFail(ctx, id, req);
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
|
||||
@@ -850,7 +858,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// convert server-side; PDF/PNG/text redirect to the raw route.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -870,7 +878,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// Serve a first-page thumbnail of a registered attachment by id.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -884,7 +892,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
@@ -912,7 +920,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
@@ -941,7 +949,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, lines } = req.query as { path?: string; lines?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
@@ -1003,7 +1011,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// malformed error envelope instead of wrapping it).
|
||||
app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
|
||||
const { id, streamId } = req.params as { id: string; streamId: string };
|
||||
findSessionOrFail(ctx, id); // Validates session exists
|
||||
findSessionOrFail(ctx, id, req); // Validates session exists
|
||||
const closed = fileStreamManager.closeStream(streamId);
|
||||
return { closed };
|
||||
});
|
||||
@@ -1024,7 +1032,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
return;
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, sessionId);
|
||||
const session = findSessionOrFail(ctx, sessionId, req);
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
|
||||
@@ -8,6 +8,8 @@ import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
||||
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
||||
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerHookEventRoutes(
|
||||
@@ -48,7 +50,18 @@ export function registerHookEventRoutes(
|
||||
// the user ran `/clear` (which spins up a new conversation jsonl).
|
||||
if (data && typeof data.session_id === 'string' && data.session_id) {
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
const prevClaudeSessionId = session?.claudeSessionId;
|
||||
session?.adoptClaudeSessionId(data.session_id);
|
||||
// Docker sessions: keep the case's resume seed following the LIVE
|
||||
// conversation (post-/clear id switches), so a container stop/reboot
|
||||
// relaunch resumes the right transcript.
|
||||
if (session?.docker && session.claudeSessionId && session.claudeSessionId !== prevClaudeSessionId) {
|
||||
void persistDockerCaseClaudeSessionId(
|
||||
getDataDir(),
|
||||
session.docker.containerName,
|
||||
session.claudeSessionId
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize forwarded data: only include known safe fields, limit size
|
||||
|
||||
@@ -19,4 +19,6 @@ export { registerPlanRoutes } from './plan-routes.js';
|
||||
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
||||
export { registerClipboardRoutes } from './clipboard-routes.js';
|
||||
export { registerSearchRoutes } from './search-routes.js';
|
||||
export { registerMeRoutes } from './me-routes.js';
|
||||
export { registerAdminRoutes } from './admin-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @fileoverview Self-service identity routes (multi-user + single-user).
|
||||
*
|
||||
* - GET /api/me : who am I ({ username, role, mustChangePassword }).
|
||||
* Works in single-user mode too, returning the synthetic
|
||||
* admin so the frontend has one "am I admin" code path.
|
||||
* - POST /api/me/password : change my own password (verifies the current one,
|
||||
* clears mustChangePassword, revokes my OTHER sessions).
|
||||
*
|
||||
* These are the two endpoints a `mustChangePassword` user may still reach (the auth
|
||||
* middleware's lockbox exempts them). See docs/multi-user-plan.md sections 5, 8.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, verifyPassword } from '../../user-store.js';
|
||||
import { getAuthUser, revokeUserSessions } from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
|
||||
const PasswordChangeSchema = z.object({
|
||||
currentPassword: z.string().min(1).max(1024),
|
||||
newPassword: z.string().min(8).max(1024),
|
||||
});
|
||||
|
||||
export function registerMeRoutes(app: FastifyInstance, ctx: AuthPort): void {
|
||||
// GET /api/me — identity probe. Synthetic admin in single-user mode. The
|
||||
// `multiUser` flag lets the frontend distinguish a single-user admin (no admin
|
||||
// UI) from a real multi-user admin.
|
||||
app.get('/api/me', async (req) => {
|
||||
if (!isMultiUserMode()) {
|
||||
return { success: true, data: { username: 'admin', role: 'admin', mustChangePassword: false, multiUser: false } };
|
||||
}
|
||||
const user = getAuthUser(req);
|
||||
const record = await findUser(user.username);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: !!record?.mustChangePassword,
|
||||
multiUser: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/me/password — self-service password change.
|
||||
app.post('/api/me/password', async (req, reply) => {
|
||||
if (!isMultiUserMode()) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Multi-user mode is not enabled');
|
||||
}
|
||||
const parsed = PasswordChangeSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
parsed.error.issues[0]?.message ?? 'New password must be at least 8 characters'
|
||||
);
|
||||
}
|
||||
const { username } = getAuthUser(req);
|
||||
const verified = await verifyPassword(username, parsed.data.currentPassword);
|
||||
if (!verified) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Current password is incorrect');
|
||||
}
|
||||
await setPassword(username, parsed.data.newPassword, { mustChangePassword: false });
|
||||
|
||||
// Revoke this user's OTHER cookie sessions; keep the caller's own session alive
|
||||
// and clear its mustChangePassword snapshot so they aren't re-locked immediately.
|
||||
const currentToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
revokeUserSessions(ctx.authSessions, username, currentToken);
|
||||
if (currentToken) {
|
||||
const rec = ctx.authSessions?.get(currentToken);
|
||||
if (rec) rec.mustChangePassword = false;
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
@@ -6,9 +6,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { InfraPort } from '../ports/index.js';
|
||||
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
|
||||
import { requireAdmin } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
|
||||
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
app.get('/api/mux-sessions', async () => {
|
||||
app.get('/api/mux-sessions', async (req, reply) => {
|
||||
// Multi-user: this recovery/debug surface exposes every user's tmux + workdirs → admin-only
|
||||
// (requireAdmin is a no-op allow-all in single-user mode, so flag-off is unchanged).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const sessions = await ctx.mux.getSessionsWithStats();
|
||||
return {
|
||||
sessions,
|
||||
@@ -16,23 +21,31 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/mux-sessions/:sessionId', async (req) => {
|
||||
app.delete('/api/mux-sessions/:sessionId', async (req, reply) => {
|
||||
// Multi-user: killing any tmux session by name is a cross-user destructive action → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { sessionId } = req.params as { sessionId: string };
|
||||
const success = await ctx.mux.killSession(sessionId);
|
||||
return { killed: success };
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/reconcile', async () => {
|
||||
app.post('/api/mux-sessions/reconcile', async (req, reply) => {
|
||||
// Multi-user: process-wide reconcile → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const result = await ctx.mux.reconcileSessions();
|
||||
return result;
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/stats/start', async () => {
|
||||
app.post('/api/mux-sessions/stats/start', async (req, reply) => {
|
||||
// Multi-user: process-wide stats collection toggle → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
return {};
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/stats/stop', async () => {
|
||||
app.post('/api/mux-sessions/stats/stop', async (req, reply) => {
|
||||
// Multi-user: process-wide stats collection toggle → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
ctx.mux.stopStatsCollection();
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { parseBody, requireAdmin } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, OrchestratorPort } from '../ports/index.js';
|
||||
|
||||
@@ -79,7 +80,10 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Start
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/start', async (req) => {
|
||||
app.post('/api/orchestrator/start', async (req, reply) => {
|
||||
// Multi-user: the orchestrator is a process-wide singleton with no per-user
|
||||
// isolation → admin-only (requireAdmin is a no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { goal, config } = parseBody(OrchestratorStartSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Initialize loop if needed
|
||||
@@ -115,7 +119,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Approve / Reject Plan
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/approve', async () => {
|
||||
app.post('/api/orchestrator/approve', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -128,7 +134,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/reject', async (req) => {
|
||||
app.post('/api/orchestrator/reject', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
const { feedback } = parseBody(OrchestratorRejectSchema, req.body, 'Feedback is required');
|
||||
@@ -147,7 +155,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Pause / Resume / Stop
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/pause', async () => {
|
||||
app.post('/api/orchestrator/pause', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -158,7 +168,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/resume', async () => {
|
||||
app.post('/api/orchestrator/resume', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -171,7 +183,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/stop', async () => {
|
||||
app.post('/api/orchestrator/stop', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -186,7 +200,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Status / Plan
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.get('/api/orchestrator/status', async () => {
|
||||
app.get('/api/orchestrator/status', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = ctx.orchestratorLoop;
|
||||
if (!loop) {
|
||||
return { ok: true, state: 'idle', plan: null, stats: null };
|
||||
@@ -198,7 +214,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/orchestrator/plan', async () => {
|
||||
app.get('/api/orchestrator/plan', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = ctx.orchestratorLoop;
|
||||
if (!loop) {
|
||||
return { ok: true, plan: null };
|
||||
@@ -215,7 +233,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Phase Operations
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/phase/:id/skip', async (req) => {
|
||||
app.post('/api/orchestrator/phase/:id/skip', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -227,7 +247,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/phase/:id/retry', async (req) => {
|
||||
app.post('/api/orchestrator/phase/:id/retry', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
|
||||
@@ -17,7 +17,15 @@ import {
|
||||
PlanTaskUpdateSchema,
|
||||
PlanTaskAddSchema,
|
||||
} from '../schemas.js';
|
||||
import { findSessionOrFail, parseBody, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import {
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
resolveCasesDir,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
@@ -124,12 +132,19 @@ Return ONLY a JSON array. Each item MUST have:
|
||||
|
||||
NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
// Create temporary session for the AI call using Opus 4.5 for deep reasoning
|
||||
// Create temporary session for the AI call using Opus 4.5 for deep reasoning.
|
||||
// Section 6.3: downgrade a non-granted user's one-shot to a classifier-guarded mode.
|
||||
const planOwner = ownerFor(req);
|
||||
const planClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const planClaudeMode = await resolveClaudeModeForUsername(planClaudeModeConfig.claudeMode, planOwner);
|
||||
const session = new Session({
|
||||
workingDir: process.cwd(),
|
||||
mux: ctx.mux,
|
||||
useMux: false, // No mux needed for one-shot
|
||||
mode: 'claude',
|
||||
claudeMode: planClaudeMode,
|
||||
allowedTools: planClaudeModeConfig.allowedTools,
|
||||
owner: planOwner,
|
||||
});
|
||||
|
||||
// Use configured model for plan generation, falling back to opus
|
||||
@@ -228,7 +243,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Determine output directory for saving wizard results
|
||||
let outputDir: string | undefined;
|
||||
if (caseName) {
|
||||
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
const casePath = validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
||||
if (casePath && existsSync(casePath)) {
|
||||
outputDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
@@ -246,7 +261,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
const detailedModelConfig = await ctx.getModelConfig();
|
||||
const orchestrator = new PlanOrchestrator(ctx.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined);
|
||||
// Section 6.3: resolve the owner's permission mode (mirrors /api/generate-plan above) and
|
||||
// thread it + owner + allowedTools into the orchestrator's internal research/planner one-shots
|
||||
// so a non-granted multi-user user cannot run them under --dangerously-skip-permissions.
|
||||
// In single-user, resolveClaudeModeForUsername returns the global mode = byte-identical.
|
||||
const detailedOwner = ownerFor(req);
|
||||
const detailedClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const detailedClaudeMode = await resolveClaudeModeForUsername(detailedClaudeModeConfig.claudeMode, detailedOwner);
|
||||
const orchestrator = new PlanOrchestrator(ctx.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined, {
|
||||
claudeMode: detailedClaudeMode,
|
||||
owner: detailedOwner,
|
||||
allowedTools: detailedClaudeModeConfig.allowedTools,
|
||||
});
|
||||
|
||||
// Store orchestrator for potential cancellation via API (not on disconnect)
|
||||
// Plan generation continues even if browser disconnects - only explicit cancel stops it
|
||||
@@ -359,7 +385,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
|
||||
const { id, taskId } = req.params as { id: string; taskId: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -385,7 +411,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -401,7 +427,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.get('/api/sessions/:id/plan/history', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -415,7 +441,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
|
||||
const { id, version } = req.params as { id: string; version: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -435,7 +461,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.post('/api/sessions/:id/plan/task', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
|
||||
@@ -24,6 +24,10 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
userAgent: userAgent ?? req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
pushPreferences: pushPreferences ?? {},
|
||||
// Multi-user: stamp the trusted caller identity so sendPushNotifications can
|
||||
// scope session notifications to the owner (+ admins). Undefined in single-user.
|
||||
username: req.authUser?.username,
|
||||
role: req.authUser?.role,
|
||||
});
|
||||
return { success: true, data: { id: record.id } };
|
||||
});
|
||||
|
||||
@@ -13,13 +13,22 @@ import { Session, isExternalCliMode } from '../../session.js';
|
||||
import { RespawnController } from '../../respawn-controller.js';
|
||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
getAuthUser,
|
||||
ownerFor,
|
||||
resolveCasesDir,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
findSessionOrFail,
|
||||
parseBody,
|
||||
} from '../route-helpers.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { buildRalphLoopPrompt } from '../../prompts/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
|
||||
export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
@@ -42,7 +51,7 @@ export function registerRalphRoutes(
|
||||
reset?: boolean | 'full';
|
||||
disableAutoEnable?: boolean;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
@@ -118,7 +127,7 @@ export function registerRalphRoutes(
|
||||
// Reset circuit breaker for Ralph tracker
|
||||
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.ralphTracker.resetCircuitBreaker();
|
||||
return {};
|
||||
@@ -127,7 +136,7 @@ export function registerRalphRoutes(
|
||||
// Get Ralph status block and circuit breaker state
|
||||
app.get('/api/sessions/:id/ralph-status', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -147,7 +156,7 @@ export function registerRalphRoutes(
|
||||
// Generate @fix_plan.md content from todos
|
||||
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const content = session.ralphTracker.generateFixPlanMarkdown();
|
||||
return {
|
||||
@@ -163,7 +172,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||||
ctx.persistSessionState(session);
|
||||
@@ -180,7 +189,7 @@ export function registerRalphRoutes(
|
||||
// Write @fix_plan.md to session's working directory
|
||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -207,7 +216,7 @@ export function registerRalphRoutes(
|
||||
// Read @fix_plan.md from session's working directory and import
|
||||
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -246,7 +255,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -271,13 +280,9 @@ export function registerRalphRoutes(
|
||||
|
||||
// Start a Ralph Loop — creates a new session with autonomous cycling
|
||||
app.post('/api/ralph-loop/start', async (req): Promise<ApiResponse> => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.SESSION_BUSY,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
||||
);
|
||||
}
|
||||
const rlOwner = ownerFor(req);
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, rlOwner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
const {
|
||||
caseName,
|
||||
@@ -290,11 +295,13 @@ export function registerRalphRoutes(
|
||||
effort,
|
||||
} = parseBody(RalphLoopStartSchema, req.body);
|
||||
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
// Multi-user: cases live in the requesting user's space.
|
||||
const rlCasesBase = resolveCasesDir(getAuthUser(req));
|
||||
const casePath = join(rlCasesBase, caseName);
|
||||
|
||||
// Security: Path traversal protection
|
||||
const rlResolvedPath = resolve(casePath);
|
||||
const rlResolvedBase = resolve(CASES_DIR);
|
||||
const rlResolvedBase = resolve(rlCasesBase);
|
||||
const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
|
||||
if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
@@ -324,6 +331,7 @@ export function registerRalphRoutes(
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
const rlModelConfig = await ctx.getModelConfig();
|
||||
const rlClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const rlClaudeMode = await resolveClaudeModeForUsername(rlClaudeModeConfig.claudeMode, rlOwner);
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
mux: ctx.mux,
|
||||
@@ -331,10 +339,11 @@ export function registerRalphRoutes(
|
||||
mode: 'claude',
|
||||
niceConfig,
|
||||
model: rlModelConfig?.defaultModel || undefined,
|
||||
claudeMode: rlClaudeModeConfig.claudeMode,
|
||||
claudeMode: rlClaudeMode,
|
||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||
envOverrides,
|
||||
effort,
|
||||
owner: rlOwner,
|
||||
});
|
||||
|
||||
// Configure Ralph tracker
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespa
|
||||
import { RespawnController, type RespawnConfig } from '../../respawn-controller.js';
|
||||
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
|
||||
import { findSessionOrFail, autoConfigureRalph, parseBody, canAccessOwned, getAuthUser } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { isExternalCliMode } from '../../session.js';
|
||||
@@ -46,7 +46,11 @@ export function registerRespawnRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
// Multi-user: gate on the owner from the same source the data comes from, and
|
||||
// return the existing neutral shape (not 404) when foreign so existence isn't
|
||||
// leaked. canAccessOwned is allow-all in single-user mode → byte-identical.
|
||||
const owner = ctx.sessions.get(id)?.owner ?? ctx.mux.getSession(id)?.owner;
|
||||
if (!controller || !canAccessOwned(getAuthUser(req), owner)) {
|
||||
return { enabled: false, status: null };
|
||||
}
|
||||
|
||||
@@ -60,16 +64,21 @@ export function registerRespawnRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Multi-user: owner-gate each branch against the source of the data, preserving
|
||||
// the neutral {config:null,active:false} shape when foreign (no existence leak).
|
||||
// canAccessOwned is allow-all in single-user mode → byte-identical, and this keeps
|
||||
// the mux-only pre-config path working (findSessionOrFail would break it).
|
||||
const user = getAuthUser(req);
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
if (controller) {
|
||||
if (controller && canAccessOwned(user, ctx.sessions.get(id)?.owner)) {
|
||||
return { config: controller.getConfig(), active: true };
|
||||
}
|
||||
|
||||
// Return pre-saved config from mux-sessions.json
|
||||
const preConfig = ctx.mux.getSession(id)?.respawnConfig;
|
||||
if (preConfig) {
|
||||
return { config: preConfig, active: false };
|
||||
const mux = ctx.mux.getSession(id);
|
||||
if (mux?.respawnConfig && canAccessOwned(user, mux.owner)) {
|
||||
return { config: mux.respawnConfig, active: false };
|
||||
}
|
||||
|
||||
return { config: null, active: false };
|
||||
@@ -87,7 +96,7 @@ export function registerRespawnRoutes(
|
||||
if (req.body) {
|
||||
body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
@@ -122,6 +131,9 @@ export function registerRespawnRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/respawn/stop', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Owner-gate before any side effects (matches start/config/enable): a non-owner
|
||||
// gets NOT_FOUND and never reaches stop/delete/clearRespawnConfig/persist.
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
@@ -144,10 +156,7 @@ export function registerRespawnRoutes(
|
||||
ctx.mux.clearRespawnConfig(id);
|
||||
|
||||
// Update state.json (respawnConfig removed)
|
||||
const session = ctx.sessions.get(id);
|
||||
if (session) {
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id });
|
||||
|
||||
@@ -160,7 +169,7 @@ export function registerRespawnRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
// Validate respawn config to prevent arbitrary field injection
|
||||
const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
@@ -226,7 +235,7 @@ export function registerRespawnRoutes(
|
||||
respawnConfig?: Partial<RespawnConfig>;
|
||||
durationMinutes?: number;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -299,7 +308,7 @@ export function registerRespawnRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
|
||||
@@ -7,17 +7,35 @@ import { FastifyInstance } from 'fastify';
|
||||
import { statSync } from 'node:fs';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { ScheduledRunSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
parseBody,
|
||||
getAuthUser,
|
||||
ownerFor,
|
||||
isWorkingDirAllowed,
|
||||
canAccessOwned,
|
||||
resolveCasesDir,
|
||||
} from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
|
||||
|
||||
export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void {
|
||||
app.get('/api/scheduled', async () => {
|
||||
return Array.from(ctx.scheduledRuns.values());
|
||||
app.get('/api/scheduled', async (req) => {
|
||||
// Multi-user: non-admins see only their own runs (no-op in single-user).
|
||||
const user = getAuthUser(req);
|
||||
return Array.from(ctx.scheduledRuns.values()).filter((r) => canAccessOwned(user, r.owner));
|
||||
});
|
||||
|
||||
app.post('/api/scheduled', async (req): Promise<{ run: ScheduledRun } | ApiResponse<never>> => {
|
||||
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Multi-user: confine the run's workingDir to the caller's own case space.
|
||||
// The spawned Session (--dangerously-skip-permissions by default) trusts this
|
||||
// dir; without confinement a non-admin could point it at another user's files.
|
||||
// No-op for admins / single-user (isWorkingDirAllowed returns true).
|
||||
if (workingDir && !isWorkingDirAllowed(getAuthUser(req), workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is not within your allowed workspace');
|
||||
}
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (workingDir) {
|
||||
try {
|
||||
@@ -30,7 +48,11 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
}
|
||||
}
|
||||
|
||||
const run = await ctx.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
|
||||
// Multi-user: default a missing workingDir to the user's own cases dir rather
|
||||
// than the server's cwd. Single-user keeps process.cwd() (byte-identical).
|
||||
const effectiveWorkingDir = workingDir || (isMultiUserMode() ? resolveCasesDir(getAuthUser(req)) : process.cwd());
|
||||
|
||||
const run = await ctx.startScheduledRun(prompt, effectiveWorkingDir, durationMinutes ?? 60, ownerFor(req));
|
||||
return { run };
|
||||
});
|
||||
|
||||
@@ -38,7 +60,9 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
const { id } = req.params as { id: string };
|
||||
const run = ctx.scheduledRuns.get(id);
|
||||
|
||||
if (!run) {
|
||||
// NOT_FOUND (not FORBIDDEN) for a foreign run so existence isn't leaked; no-op
|
||||
// for admins / single-user (canAccessOwned returns true).
|
||||
if (!run || !canAccessOwned(getAuthUser(req), run.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
||||
}
|
||||
|
||||
@@ -50,7 +74,8 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
const { id } = req.params as { id: string };
|
||||
const run = ctx.scheduledRuns.get(id);
|
||||
|
||||
if (!run) {
|
||||
// Owner-scoped read: a foreign run reads as NOT_FOUND (no-op in single-user).
|
||||
if (!run || !canAccessOwned(getAuthUser(req), run.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { canAccessOwned, getAuthUser, parseBody } from '../route-helpers.js';
|
||||
import { SearchQuerySchema } from '../schemas.js';
|
||||
import {
|
||||
searchSources,
|
||||
@@ -62,13 +62,14 @@ interface SessionLike {
|
||||
* Harvest the three source arrays from the live in-memory stores. Reads only
|
||||
* bounded, already-loaded data — no disk I/O, no terminal buffers.
|
||||
*/
|
||||
function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
|
||||
function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) => boolean): SearchSources {
|
||||
const sessions: SessionSearchInput[] = [];
|
||||
const events: EventSearchInput[] = [];
|
||||
const files: FileSearchInput[] = [];
|
||||
|
||||
for (const raw of ctx.sessions.values()) {
|
||||
const s = raw as unknown as SessionLike;
|
||||
const s = raw as unknown as SessionLike & { owner?: string };
|
||||
if (canSee && !canSee(s.owner)) continue; // multi-user ownership scope
|
||||
const sessionName = s.name ?? '';
|
||||
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
|
||||
|
||||
@@ -96,7 +97,8 @@ function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
|
||||
|
||||
// Events: from the live run-summary trackers, keyed by session id.
|
||||
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
|
||||
const session = ctx.sessions.get(sessionId) as unknown as SessionLike | undefined;
|
||||
const session = ctx.sessions.get(sessionId) as unknown as (SessionLike & { owner?: string }) | undefined;
|
||||
if (canSee && !canSee(session?.owner)) continue; // multi-user ownership scope
|
||||
const sessionName = session?.name ?? '';
|
||||
const summary = tracker.getSummary();
|
||||
// Newest events are most relevant; cap the per-session harvest.
|
||||
@@ -120,6 +122,8 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
|
||||
app.get('/api/search', async (req) => {
|
||||
// Zod-validate the query. parseBody throws a structured 400 on failure.
|
||||
const { q, types, limit } = parseBody(SearchQuerySchema, req.query);
|
||||
const user = getAuthUser(req);
|
||||
const canSee = (owner?: string) => canAccessOwned(user, owner);
|
||||
|
||||
const allowed: Set<SearchSourceType> | null = types
|
||||
? new Set(
|
||||
@@ -130,7 +134,7 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
|
||||
)
|
||||
: null;
|
||||
|
||||
const sources = harvestSources(ctx);
|
||||
const sources = harvestSources(ctx, canSee);
|
||||
|
||||
// Apply the optional source-type filter before searching so excluded
|
||||
// sources never contribute to (or consume budget in) the result set.
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type SessionColor,
|
||||
type CodexConfig,
|
||||
type GeminiConfig,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -30,21 +32,33 @@ import {
|
||||
AutoClearSchema,
|
||||
AutoCompactSchema,
|
||||
AutoResumeSchema,
|
||||
PinSessionSchema,
|
||||
ImageWatcherSchema,
|
||||
FlickerFilterSchema,
|
||||
QuickRunSchema,
|
||||
QuickStartSchema,
|
||||
InteractiveStartSchema,
|
||||
SessionOrderUpdateSchema,
|
||||
} from '../schemas.js';
|
||||
import { mergeSessionOrder } from '../../session-order.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
canAccessOwned,
|
||||
CASES_DIR,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
persistAndBroadcastSession,
|
||||
resolveCasesDir,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import {
|
||||
writeHooksConfig,
|
||||
@@ -67,16 +81,25 @@ import {
|
||||
type MuxStatInput,
|
||||
} from '../../services/unified-session-service.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
toAttachedSessionRemote,
|
||||
toSessionRemote,
|
||||
} from '../../remote-hosts.js';
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerConfigDrift,
|
||||
checkDockerTmuxAvailable,
|
||||
ensureAgentBaseImage,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
persistDockerCaseClaudeSessionId,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
toSessionDocker,
|
||||
@@ -257,6 +280,29 @@ export function _resetPasteRateBuckets(): void {
|
||||
pasteRateBuckets.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
|
||||
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`
|
||||
* and Gemini `--approval-mode yolo` disable the safety classifier the non-granted-user
|
||||
* downgrade is meant to keep on, so clamp them for a non-granted owner. buildGeminiCommand
|
||||
* defaults an ABSENT approvalMode to yolo, so the gemini config must be MATERIALIZED
|
||||
* (auto_edit) even when the request sent none. No-op in single-user mode / for a granted
|
||||
* owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
|
||||
*/
|
||||
async function clampExternalCliBypassForOwner(
|
||||
owner: string | undefined,
|
||||
codexConfig: CodexConfig | undefined,
|
||||
geminiConfig: GeminiConfig | undefined
|
||||
): Promise<{ codexConfig: CodexConfig | undefined; geminiConfig: GeminiConfig | undefined }> {
|
||||
const granted = await canUsernameRunPrivilegedCommands(owner);
|
||||
if (granted) return { codexConfig, geminiConfig };
|
||||
// Non-granted: force codex bypass off (only meaningful when a config was sent) and
|
||||
// materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
|
||||
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
|
||||
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
|
||||
return { codexConfig: clampedCodex, geminiConfig: clampedGemini };
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
@@ -283,23 +329,64 @@ export function registerSessionRoutes(
|
||||
|
||||
// ========== Session Listing ==========
|
||||
|
||||
app.get('/api/sessions', async () => {
|
||||
return ctx.getLightSessionsState();
|
||||
app.get('/api/sessions', async (req) => {
|
||||
const list = ctx.getLightSessionsState();
|
||||
if (!isMultiUserMode()) return list;
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'admin') return list;
|
||||
return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner));
|
||||
});
|
||||
|
||||
// ========== Session Tab Order (global sync, COD-131) ==========
|
||||
|
||||
app.put('/api/session-order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
|
||||
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
|
||||
// Server is authoritative but never drops ids it knows about that the
|
||||
// pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder).
|
||||
const merged = mergeSessionOrder(order, ctx.store.getSessionOrder());
|
||||
ctx.store.setSessionOrder(merged);
|
||||
ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged });
|
||||
return { success: true, data: { order: merged } };
|
||||
});
|
||||
|
||||
// ========== Session Creation ==========
|
||||
|
||||
app.post('/api/sessions', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`
|
||||
);
|
||||
}
|
||||
const owner = ownerFor(req);
|
||||
// Global + per-user session cap.
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
|
||||
|
||||
const body = parseBody(CreateSessionSchema, req.body);
|
||||
const workingDir = body.workingDir || process.cwd();
|
||||
let workingDir = body.workingDir || process.cwd();
|
||||
let remote = undefined;
|
||||
|
||||
// COD-105 — attach to a discovered (non-owned) remote tmux session. The
|
||||
// remote session is already running, so we skip the tmux-prereq probe and
|
||||
// build a NON-owned SessionRemote (detach-not-kill on close). Remote CASE
|
||||
// creation (owned durable sessions) is handled by the dedicated case-create
|
||||
// endpoint below, which #145 consolidated remote-host resolution into.
|
||||
if (body.attachRemoteSession) {
|
||||
const { hostId, remoteSessionName } = body.attachRemoteSession;
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
workingDir = `${host.username}@${host.host}:${remoteSessionName}`;
|
||||
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
|
||||
}
|
||||
|
||||
// Multi-user: shell mode is arbitrary command execution as the host account,
|
||||
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
|
||||
// from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
|
||||
if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||||
}
|
||||
|
||||
// Multi-user linchpin (section 6.2): a non-admin's workingDir must resolve
|
||||
// inside their own case space. Enforced BEFORE any disk-mutating call below so
|
||||
// a foreign path can never be written into.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (body.workingDir) {
|
||||
@@ -318,16 +405,18 @@ export function registerSessionRoutes(
|
||||
// For keys the caller is actively setting, strip any stale disk entry a prior
|
||||
// Codeman version may have written. Scope limited to:
|
||||
// - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json)
|
||||
// - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
|
||||
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
|
||||
// can target, because those may have hand-authored values).
|
||||
// - workingDir inside CASES_DIR / the per-user case space (Codeman's managed
|
||||
// territory — we never mutate .claude/settings.local.json in arbitrary user
|
||||
// repos that POST /api/sessions can target, as those may have hand-authored
|
||||
// values).
|
||||
const managedCasesBase = resolveCasesDir(getAuthUser(req));
|
||||
const canStripDisk =
|
||||
body.mode !== 'opencode' &&
|
||||
body.mode !== 'codex' &&
|
||||
body.mode !== 'gemini' &&
|
||||
body.envOverrides &&
|
||||
Object.keys(body.envOverrides).length > 0 &&
|
||||
workingDir.startsWith(CASES_DIR + '/');
|
||||
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||||
if (canStripDisk) {
|
||||
await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides!));
|
||||
}
|
||||
@@ -436,6 +525,14 @@ export function registerSessionRoutes(
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig } = await clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
body.codexConfig,
|
||||
body.geminiConfig
|
||||
);
|
||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
@@ -445,15 +542,17 @@ export function registerSessionRoutes(
|
||||
useMux: true,
|
||||
niceConfig: globalNice,
|
||||
model,
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
claudeMode: effectiveClaudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? body.geminiConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: body.envOverrides,
|
||||
effort: body.effort,
|
||||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||
remote,
|
||||
owner,
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
@@ -474,7 +573,7 @@ export function registerSessionRoutes(
|
||||
app.put('/api/sessions/:id/name', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||
session.name = name;
|
||||
@@ -489,7 +588,7 @@ export function registerSessionRoutes(
|
||||
app.put('/api/sessions/:id/color', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
||||
if (!validColors.includes(body.color)) {
|
||||
@@ -508,18 +607,22 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { killMux?: string };
|
||||
const killMux = query.killMux !== 'false'; // Default to true
|
||||
|
||||
if (!ctx.sessions.has(id)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
// Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill).
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
await ctx.cleanupSession(id, killMux, 'user_delete');
|
||||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Delete All Sessions ==========
|
||||
|
||||
app.delete('/api/sessions', async (): Promise<ApiResponse<{ killed: number }>> => {
|
||||
const sessionIds = Array.from(ctx.sessions.keys());
|
||||
app.delete('/api/sessions', async (req): Promise<ApiResponse<{ killed: number }>> => {
|
||||
// Security: scope the bulk sweep to sessions the caller can access — a non-admin
|
||||
// must not wipe other users' sessions (canAccessOwned is allow-all for admin/single-user).
|
||||
const user = getAuthUser(req);
|
||||
const sessionIds = Array.from(ctx.sessions.values())
|
||||
.filter((s) => canAccessOwned(user, s.owner))
|
||||
.map((s) => s.id);
|
||||
let killed = 0;
|
||||
|
||||
for (const id of sessionIds) {
|
||||
@@ -536,7 +639,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
||||
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
||||
@@ -551,7 +654,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/output', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -567,7 +670,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -583,7 +686,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/run-summary', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = ctx.runSummaryTrackers.get(id);
|
||||
if (!tracker) {
|
||||
@@ -603,7 +706,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/active-tools', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -622,7 +725,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/run', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { prompt } = parseBody(RunPromptSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -649,7 +752,7 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { clearBreaker } = bodyResult.data;
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -705,7 +808,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/shell', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -738,7 +841,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/input', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const inputStr = String(input);
|
||||
if (inputStr.length > MAX_INPUT_LENGTH) {
|
||||
@@ -797,7 +900,7 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const muxName = session.muxName;
|
||||
if (!muxName) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||||
@@ -829,7 +932,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/resize', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.resize(cols, rows, { viewportType, force });
|
||||
return {};
|
||||
@@ -915,7 +1018,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
||||
@@ -934,6 +1037,12 @@ export function registerSessionRoutes(
|
||||
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
|
||||
if (activeId && activeId !== session.claudeSessionId) {
|
||||
session.adoptClaudeSessionId(activeId);
|
||||
// Docker sessions: keep the case's resume seed following the live conversation.
|
||||
if (session.docker) {
|
||||
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The Claude conversation ID (used as JSONL filename)
|
||||
@@ -1366,7 +1475,7 @@ export function registerSessionRoutes(
|
||||
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { tail?: string; full?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
||||
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
||||
@@ -1499,7 +1608,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1520,7 +1629,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1542,7 +1651,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.setAutoResume(body.enabled);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1558,12 +1667,56 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Pin (float to top of the session manager list, COD-139) ==========
|
||||
|
||||
app.post('/api/sessions/:id/pin', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(PinSessionSchema, req.body, 'Invalid request body');
|
||||
|
||||
const session = ctx.sessions.get(id);
|
||||
if (session) {
|
||||
if (!canAccessOwned(getAuthUser(req), session.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
|
||||
}
|
||||
session.setPinned(body.pinned);
|
||||
// Persist + broadcast session:updated (keeps tabs/state consistent), then a
|
||||
// dedicated session:pinned event so the session manager list re-sorts live.
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
ctx.broadcast(SseEvent.SessionPinned, {
|
||||
id,
|
||||
pinned: session.pinned,
|
||||
pinnedAt: session.pinnedAt ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
pinned: session.pinned,
|
||||
pinnedAt: session.pinnedAt ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// COD-142 keeps a pinned session's record after kill (demoteOrRemoveSession),
|
||||
// so pin toggles must also work WITHOUT a live Session — otherwise a
|
||||
// pinned-then-killed record could never be unpinned (cleanup skips pinned
|
||||
// records, and the record has no live session to route through).
|
||||
const persisted = ctx.store.getSession(id);
|
||||
if (!persisted || !canAccessOwned(getAuthUser(req), persisted.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
|
||||
}
|
||||
const pinnedAt = body.pinned ? Date.now() : undefined;
|
||||
ctx.store.setSession(id, { ...persisted, pinned: body.pinned || undefined, pinnedAt });
|
||||
ctx.broadcast(SseEvent.SessionPinned, { id, pinned: body.pinned, pinnedAt });
|
||||
return { success: true, data: { pinned: body.pinned, pinnedAt } };
|
||||
});
|
||||
|
||||
// ========== Image Watcher ==========
|
||||
|
||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (body.enabled) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
@@ -1588,7 +1741,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.flickerFilterEnabled = body.enabled;
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1608,13 +1761,9 @@ export function registerSessionRoutes(
|
||||
// ========== Quick Run ==========
|
||||
|
||||
app.post('/api/run', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.SESSION_BUSY,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`
|
||||
);
|
||||
}
|
||||
const runOwner = ownerFor(req);
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, runOwner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
const {
|
||||
prompt,
|
||||
@@ -1627,6 +1776,11 @@ export function registerSessionRoutes(
|
||||
}
|
||||
const dir = workingDir || process.cwd();
|
||||
|
||||
// Multi-user: confine a non-admin's one-shot working dir to their space.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), dir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (workingDir) {
|
||||
try {
|
||||
@@ -1639,7 +1793,17 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const session = new Session({ workingDir: dir, envOverrides: runEnvOverrides });
|
||||
// Section 6.3: the one-shot spawn path (runPrompt/buildPromptArgs) respects the
|
||||
// session's claudeMode, so resolve it for the owner (bypass -> auto for non-granted).
|
||||
const runClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const runClaudeMode = await resolveClaudeModeForUsername(runClaudeModeConfig.claudeMode, runOwner);
|
||||
const session = new Session({
|
||||
workingDir: dir,
|
||||
envOverrides: runEnvOverrides,
|
||||
claudeMode: runClaudeMode,
|
||||
allowedTools: runClaudeModeConfig.allowedTools,
|
||||
owner: runOwner,
|
||||
});
|
||||
ctx.addSession(session);
|
||||
ctx.store.incrementSessionsCreated();
|
||||
ctx.persistSessionState(session);
|
||||
@@ -1669,17 +1833,15 @@ export function registerSessionRoutes(
|
||||
// ========== Quick Start ==========
|
||||
|
||||
app.post('/api/quick-start', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.SESSION_BUSY,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
||||
);
|
||||
}
|
||||
const owner = ownerFor(req);
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
const {
|
||||
caseName = 'testcase',
|
||||
sessionName,
|
||||
mode = 'claude',
|
||||
modelOverride,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
@@ -1687,6 +1849,12 @@ export function registerSessionRoutes(
|
||||
effort,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
||||
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
||||
if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||||
}
|
||||
|
||||
// Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
|
||||
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
|
||||
// would wrongly reject a machine that hasn't got the CLI installed locally.
|
||||
@@ -1694,11 +1862,20 @@ export function registerSessionRoutes(
|
||||
let docker = undefined;
|
||||
let dockerResumeId: string | undefined;
|
||||
let casePath: string | null = null;
|
||||
// Security: fold ownership INTO the match (don't early-return) so a NON-OWNED
|
||||
// same-named remote/docker case is skipped and control falls through to the caller's
|
||||
// own LOCAL case — remote/docker names are globally unique but local names are
|
||||
// per-user, so a name collision must not shadow the caller's own case. canAccessOwned
|
||||
// is allow-all for admins/single-user, so flag-OFF stays byte-identical.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === caseName);
|
||||
const remoteCase = remoteCases.find(
|
||||
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
const dockerCase = remoteCase
|
||||
? undefined
|
||||
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName);
|
||||
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
|
||||
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -1710,13 +1887,14 @@ export function registerSessionRoutes(
|
||||
if (
|
||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||
effort ||
|
||||
modelOverride !== undefined ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1734,7 +1912,7 @@ export function registerSessionRoutes(
|
||||
// Docker case: the CLI executes INSIDE a container via local tmux + `docker
|
||||
// exec`, so the LOCAL availability gates below don't apply. Mirror the remote
|
||||
// branch's rejection of per-session config that would not cross into the
|
||||
// container (it would silently no-op).
|
||||
// container (it would silently no-op). (Ownership is enforced in the .find above.)
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
if (
|
||||
@@ -1758,10 +1936,39 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
// Ensure the base image exists, auto-building the default image on first use so
|
||||
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
||||
// this awaits the SAME in-flight build rather than starting a second one.
|
||||
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
|
||||
}
|
||||
if (ensured.built) {
|
||||
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
|
||||
}
|
||||
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
||||
// Skip the extra container-run probe for our OWN default image (the baked
|
||||
// Dockerfile always contains tmux); still verify a custom image.
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
||||
}
|
||||
}
|
||||
|
||||
// Config drift (docs/docker-cases-plan.md §4): the desired create-config no
|
||||
// longer matches the existing container's codeman.confighash label. Refuse to
|
||||
// silently launch into the stale container — the frontend confirms a recreate
|
||||
// (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
|
||||
// mounts and the conversation resumes), or the user reverts the host edit.
|
||||
const drift = await checkDockerConfigDrift(sessionDocker);
|
||||
if (drift.exists && drift.drifted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
`Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`
|
||||
);
|
||||
}
|
||||
|
||||
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
||||
@@ -1815,7 +2022,11 @@ export function registerSessionRoutes(
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
|
||||
// Multi-user: the linked-cases registry is ownerless/global, so only admins may
|
||||
// resolve a name to an arbitrary linked path. A non-admin resolves inside their
|
||||
// OWN case space only (single-user: isAdmin true, so linked cases still honoured).
|
||||
const linked = isAdmin(req) ? linkedCases[caseName] : undefined;
|
||||
casePath = linked || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
@@ -1825,6 +2036,15 @@ export function registerSessionRoutes(
|
||||
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
|
||||
const resolvedCasePath = casePath as string;
|
||||
|
||||
// Multi-user linchpin (section 6.2): confine the resolved workingDir to the caller's
|
||||
// own case space BEFORE any mkdir/scaffold below creates or mutates it. Applies to
|
||||
// LOCAL and DOCKER cases (docker.hostWorkspacePath is a real host dir the file routes
|
||||
// trust); skipped for REMOTE, whose path is an ssh path that would spuriously fail
|
||||
// realpath confinement. No-op for admins / single-user mode.
|
||||
if (!remote && !isWorkingDirAllowed(getAuthUser(req), resolvedCasePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
|
||||
}
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
||||
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
||||
if (!remote && !docker && !existsSync(resolvedCasePath)) {
|
||||
@@ -1875,6 +2095,13 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
|
||||
// docker — the docker workspace is a real host dir, so the settings file crosses
|
||||
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
||||
if (mode === 'claude' && modelOverride !== undefined) {
|
||||
await updateCaseModel(resolvedCasePath, modelOverride || null);
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
// see POST /api/sessions for full rationale).
|
||||
if (
|
||||
@@ -1903,19 +2130,28 @@ export function registerSessionRoutes(
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
codexConfig,
|
||||
geminiConfig
|
||||
);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir: resolvedCasePath,
|
||||
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
mode: mode,
|
||||
niceConfig: niceConfig,
|
||||
model: qsModel,
|
||||
claudeMode: qsClaudeModeConfig.claudeMode,
|
||||
claudeMode: qsEffectiveClaudeMode,
|
||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||
owner,
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
||||
envOverrides,
|
||||
effort,
|
||||
remote,
|
||||
@@ -1970,6 +2206,19 @@ export function registerSessionRoutes(
|
||||
}
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
// Docker + claude: the pane command pins the conversation id (--session-id /
|
||||
// --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
|
||||
// NOW — a later container stop/reboot relaunch resumes this conversation even
|
||||
// if no in-container hook ever reaches the host (loopback bind, no bridge
|
||||
// listener). Hook/last-response adoption updates it again after /clear.
|
||||
if (docker && mode === 'claude') {
|
||||
void persistDockerCaseClaudeSessionId(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
docker.containerName,
|
||||
session.claudeSessionId || session.id
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
// Save lastUsedCase to settings for TUI/web sync
|
||||
try {
|
||||
const settingsFilePath = SETTINGS_PATH;
|
||||
@@ -2059,6 +2308,59 @@ export function registerSessionRoutes(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text of the LAST user message from a JSONL transcript chunk
|
||||
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
|
||||
* detection, same noise/secret/slash-command filters, same 120-char cap — but
|
||||
* keeps the last qualifying match instead of returning on the first. Scan the
|
||||
* file tail for this (the most recent prompt lives near the end).
|
||||
*/
|
||||
function extractLastUserPrompt(text: string): string | undefined {
|
||||
const MAX_PROMPT_LEN = 120;
|
||||
let result: string | undefined;
|
||||
let start = 0;
|
||||
while (start < text.length) {
|
||||
const end = text.indexOf('\n', start);
|
||||
const line = end === -1 ? text.slice(start) : text.slice(start, end);
|
||||
start = end === -1 ? text.length : end + 1;
|
||||
if (!line.includes('"type":"user"')) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type !== 'user' || !entry.message) continue;
|
||||
const content = entry.message.content;
|
||||
let msgText: string | undefined;
|
||||
if (typeof content === 'string') {
|
||||
msgText = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
const textBlock = content.find((b: { type: string }) => b.type === 'text');
|
||||
if (textBlock) msgText = textBlock.text;
|
||||
}
|
||||
if (!msgText) continue;
|
||||
msgText = msgText
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
if (!msgText) continue;
|
||||
if (
|
||||
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(msgText) ||
|
||||
/^(Please )?(analyze|review) this codebase/i.test(msgText) ||
|
||||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(msgText) ||
|
||||
/^\d+ vulnerabilit/i.test(msgText) ||
|
||||
/\btoolu_/.test(msgText) ||
|
||||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(msgText) ||
|
||||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(msgText) ||
|
||||
msgText.length < 8
|
||||
)
|
||||
continue;
|
||||
result = msgText.length > MAX_PROMPT_LEN ? msgText.slice(0, MAX_PROMPT_LEN) + '…' : msgText;
|
||||
} catch {
|
||||
// Malformed line — skip
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
|
||||
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
|
||||
@@ -2197,6 +2499,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
lastPrompt?: string;
|
||||
};
|
||||
|
||||
// Scan a single project directory and return all valid history sessions in it.
|
||||
@@ -2242,6 +2545,17 @@ export function registerSessionRoutes(
|
||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||
}
|
||||
|
||||
// COD-145: last (most recent) user prompt lives near the END of the file, so
|
||||
// prefer the tail. For large files where no tail was read yet, read one
|
||||
// (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
|
||||
// then contains the whole transcript — scan it for the last match instead.
|
||||
if (!tail && fileStat.size > 65536) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
}
|
||||
const lastPrompt =
|
||||
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
|
||||
|
||||
out.push({
|
||||
sessionId,
|
||||
workingDir,
|
||||
@@ -2249,6 +2563,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
lastPrompt,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -2258,6 +2573,12 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const headBuf = Buffer.alloc(16384);
|
||||
// Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
|
||||
// must only see history whose decoded workingDir is inside their own case space.
|
||||
// Do NOT trust the caller-supplied projectKey — confine on the decoded path.
|
||||
// No-op for admins / single-user mode.
|
||||
const user = getAuthUser(req);
|
||||
const scopeHistory = isMultiUserMode() && user.role !== 'admin';
|
||||
|
||||
// Single-folder drill-down: when projectKey is provided, scan only that
|
||||
// directory, bypass the 50-cap, and honor offset/limit pagination.
|
||||
@@ -2269,13 +2590,15 @@ export function registerSessionRoutes(
|
||||
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
||||
const projPath = join(projectsDir, query.projectKey);
|
||||
const all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
||||
let all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
||||
// Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
|
||||
if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
||||
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
return { sessions: all.slice(offset, offset + limit), total: all.length };
|
||||
}
|
||||
|
||||
// Global overview: scan all projects, return up to 50 most-recent sessions.
|
||||
const results: HistorySession[] = [];
|
||||
let results: HistorySession[] = [];
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
@@ -2287,6 +2610,8 @@ export function registerSessionRoutes(
|
||||
// Projects dir may not exist
|
||||
}
|
||||
|
||||
// Multi-user: drop rows outside the non-admin caller's own case space.
|
||||
if (scopeHistory) results = results.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
||||
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
return { sessions: results.slice(0, 50) };
|
||||
});
|
||||
@@ -2314,6 +2639,8 @@ export function registerSessionRoutes(
|
||||
createdAt: st.createdAt,
|
||||
lastActivityAt: st.lastActivityAt,
|
||||
claudeSessionId: s.claudeSessionId ?? undefined,
|
||||
pinned: st.pinned,
|
||||
pinnedAt: st.pinnedAt,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2329,6 +2656,8 @@ export function registerSessionRoutes(
|
||||
createdAt: p.createdAt,
|
||||
lastActivityAt: p.lastActivityAt,
|
||||
claudeSessionId: p.resumeSessionId,
|
||||
pinned: p.pinned,
|
||||
pinnedAt: p.pinnedAt,
|
||||
}));
|
||||
|
||||
// Lifecycle audit log (newest-first, capped).
|
||||
@@ -2362,6 +2691,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: h.sizeBytes,
|
||||
lastModified: h.lastModified,
|
||||
firstPrompt: h.firstPrompt,
|
||||
lastPrompt: h.lastPrompt,
|
||||
projectKey: h.projectKey,
|
||||
});
|
||||
}
|
||||
@@ -2394,7 +2724,37 @@ export function registerSessionRoutes(
|
||||
// Mux stats are optional.
|
||||
}
|
||||
|
||||
const merged = mergeUnifiedSessions({ live, persisted, lifecycle, history, mux });
|
||||
// Multi-user: a non-admin only sees their own sessions; host-wide transcript
|
||||
// history (not tied to an owned session) is admin-only.
|
||||
let sLive = live;
|
||||
let sPersisted = persisted;
|
||||
let sLifecycle = lifecycle;
|
||||
let sHistory = history;
|
||||
const uUser = getAuthUser(req);
|
||||
if (isMultiUserMode() && uUser.role !== 'admin') {
|
||||
const ownedLive = new Set(
|
||||
[...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id)
|
||||
);
|
||||
const stored = ctx.store.getState().sessions as Record<string, { id: string; owner?: string }>;
|
||||
const ownedPersisted = new Set(
|
||||
Object.values(stored)
|
||||
.filter((p) => canAccessOwned(uUser, p.owner))
|
||||
.map((p) => p.id)
|
||||
);
|
||||
const isOwned = (id: string) => ownedLive.has(id) || ownedPersisted.has(id);
|
||||
sLive = live.filter((l) => isOwned(l.id));
|
||||
sPersisted = persisted.filter((p) => isOwned(p.id));
|
||||
sLifecycle = lifecycle.filter((e) => isOwned(e.sessionId));
|
||||
sHistory = [];
|
||||
}
|
||||
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: sLive,
|
||||
persisted: sPersisted,
|
||||
lifecycle: sLifecycle,
|
||||
history: sHistory,
|
||||
mux,
|
||||
});
|
||||
const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
|
||||
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
||||
return filterAndPaginate(merged, {
|
||||
@@ -2453,7 +2813,7 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!req.isMultipart()) {
|
||||
reply.code(400);
|
||||
|
||||
+112
-30
@@ -15,6 +15,9 @@ import { randomBytes } from 'node:crypto';
|
||||
import { dataPath } from '../../config/instance.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser } from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||
import {
|
||||
ConfigUpdateSchema,
|
||||
SettingsUpdateSchema,
|
||||
@@ -136,7 +139,7 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== Status ==========
|
||||
|
||||
app.get('/api/status', async () => ctx.getLightState());
|
||||
app.get('/api/status', async (req) => ctx.getLightState(req.authUser));
|
||||
|
||||
// ========== Tunnel ==========
|
||||
|
||||
@@ -159,12 +162,19 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/tunnel/qr', async (_req, reply) => {
|
||||
app.get('/api/tunnel/qr', async (req, reply) => {
|
||||
const url = ctx.tunnelManager.getUrl();
|
||||
if (!url) {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
|
||||
}
|
||||
try {
|
||||
if (isMultiUserMode()) {
|
||||
// A rotating global token cannot carry identity — mint a single-use token
|
||||
// bound to the requesting user so the scanned code logs THEM in.
|
||||
const shortCode = ctx.tunnelManager.mintUserToken(getAuthUser(req).username);
|
||||
const svg = await ctx.tunnelManager.getQrSvgForCode(url, shortCode);
|
||||
return { svg, authEnabled: true };
|
||||
}
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
if (authPassword) {
|
||||
// Auth enabled — use cached SVG with embedded short code
|
||||
@@ -188,10 +198,11 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/q/:code', async (req, reply) => {
|
||||
const shortCode = (req.params as { code: string }).code;
|
||||
const multiUser = isMultiUserMode();
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
|
||||
// No point if auth isn't enabled — just redirect
|
||||
if (!authPassword) {
|
||||
// No point if auth isn't enabled — just redirect. Multi-user is always "enabled".
|
||||
if (!multiUser && !authPassword) {
|
||||
return reply.redirect('/');
|
||||
}
|
||||
|
||||
@@ -203,12 +214,28 @@ export function registerSystemRoutes(
|
||||
return reply.code(429).send('Too Many Requests');
|
||||
}
|
||||
|
||||
// Validate and atomically consume the token
|
||||
if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) {
|
||||
// Validate and atomically consume the token (with any bound identity).
|
||||
const consumed = shortCode ? ctx.tunnelManager.consumeTokenWithIdentity(shortCode) : { ok: false };
|
||||
// In multi-user mode a token MUST carry an identity (an identity-less rotating
|
||||
// token can't create a scoped session), so reject those too.
|
||||
if (!consumed.ok || (multiUser && !consumed.username)) {
|
||||
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
|
||||
return reply.code(401).send('Invalid or expired QR code');
|
||||
}
|
||||
|
||||
// Resolve the role for the bound user (disabled/deleted users fail closed).
|
||||
// Carry the bound user's real mustChangePassword flag out of this block so the
|
||||
// minted cookie enforces the lockbox instead of hardcoding false.
|
||||
let identity: { username: string; role: 'admin' | 'user'; mustChangePassword: boolean } | undefined;
|
||||
if (multiUser && consumed.username) {
|
||||
const user = await findUser(consumed.username);
|
||||
if (!user || user.disabled) {
|
||||
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
|
||||
return reply.code(401).send('Invalid or expired QR code');
|
||||
}
|
||||
identity = { username: user.username, role: user.role, mustChangePassword: !!user.mustChangePassword };
|
||||
}
|
||||
|
||||
// Issue session cookie (same pattern as Basic Auth success path)
|
||||
const sessionToken = randomBytes(32).toString('hex');
|
||||
const clientUA = req.headers['user-agent'] ?? '';
|
||||
@@ -217,6 +244,9 @@ export function registerSystemRoutes(
|
||||
ua: clientUA,
|
||||
createdAt: Date.now(),
|
||||
method: 'qr',
|
||||
username: identity?.username,
|
||||
role: identity?.role,
|
||||
mustChangePassword: !!identity?.mustChangePassword,
|
||||
});
|
||||
ctx.qrAuthFailures?.delete(clientIp);
|
||||
|
||||
@@ -465,23 +495,52 @@ export function registerSystemRoutes(
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values()).map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
}));
|
||||
// Multi-user: scope the digest's aggregated activity to sessions the caller
|
||||
// owns (canAccessOwned is a no-op allow-all for admins/single-user).
|
||||
const user = getAuthUser(req);
|
||||
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values())
|
||||
.filter((session) => canAccessOwned(user, session.owner))
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
}));
|
||||
|
||||
const runSummaries = Array.from(ctx.runSummaryTrackers.values()).map((tracker) => tracker.getSummary());
|
||||
// Run-summary trackers are keyed by Codeman session id → filter by that session's owner.
|
||||
const runSummaries = Array.from(ctx.runSummaryTrackers.entries())
|
||||
.filter(([id]) => canAccessOwned(user, ctx.sessions.get(id)?.owner))
|
||||
.map(([, tracker]) => tracker.getSummary());
|
||||
|
||||
// Map each subagent's Claude conversation id back to its owning session so the
|
||||
// recent-subagent lookback is owner-scoped too (fails closed when unattributable).
|
||||
const ownerByClaudeSessionId = new Map<string, string | undefined>();
|
||||
for (const s of ctx.sessions.values()) {
|
||||
if (s.claudeSessionId) ownerByClaudeSessionId.set(s.claudeSessionId, s.owner);
|
||||
}
|
||||
const subagents = subagentWatcher
|
||||
.getRecentSubagents(60)
|
||||
.filter((sa) => canAccessOwned(user, ownerByClaudeSessionId.get(sa.sessionId))) as AwayDigestSubagent[];
|
||||
|
||||
// Multi-user: the lifecycle log and daily token stats carry no owner, so scope them
|
||||
// for a non-admin: keep only lifecycle entries attributable to an owned LIVE session
|
||||
// (fail closed — an ended session's owner can't be resolved, so it is dropped rather
|
||||
// than leaked), and withhold the machine-wide daily token totals entirely (they can't
|
||||
// be per-user attributed, same as globalStats in #29). Admins/single-user keep all
|
||||
// (canAccessOwned allow-all, role check false → byte-identical).
|
||||
const scopedLifecycle = lifecycleEntries.filter((e) =>
|
||||
canAccessOwned(user, ctx.sessions.get(e.sessionId ?? '')?.owner)
|
||||
);
|
||||
const nonAdminScoped = isMultiUserMode() && user.role !== 'admin';
|
||||
const digest = buildAwayDigest({
|
||||
range,
|
||||
lifecycleEntries,
|
||||
lifecycleEntries: scopedLifecycle,
|
||||
runSummaries,
|
||||
sessions,
|
||||
dailyTokenStats: ctx.store.getDailyStats(30),
|
||||
subagents: subagentWatcher.getRecentSubagents(60) as AwayDigestSubagent[],
|
||||
dailyTokenStats: nonAdminScoped ? [] : ctx.store.getDailyStats(30),
|
||||
subagents,
|
||||
now: range.until,
|
||||
});
|
||||
|
||||
@@ -585,7 +644,10 @@ export function registerSystemRoutes(
|
||||
// letting an operator opt in from the browser without setting the env var.
|
||||
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
|
||||
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
|
||||
const acknowledged = isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
|
||||
// Multi-user mode makes the tunnel authenticated (every person has their own
|
||||
// credential), so it satisfies the same requirement as CODEMAN_PASSWORD.
|
||||
const acknowledged =
|
||||
isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
|
||||
if (!acknowledged) {
|
||||
const msg =
|
||||
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
|
||||
@@ -727,7 +789,7 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/cpu-limit', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
return {
|
||||
nice: session.niceConfig,
|
||||
};
|
||||
@@ -735,7 +797,7 @@ export function registerSystemRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/cpu-limit', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial<NiceConfig>;
|
||||
|
||||
@@ -796,7 +858,10 @@ export function registerSystemRoutes(
|
||||
// ========== Workflow Run Monitoring (ultracode) ==========
|
||||
|
||||
// LEFT-pane list: lightweight run summaries (no agents[]).
|
||||
app.get('/api/workflows', async (req) => {
|
||||
app.get('/api/workflows', async (req, reply) => {
|
||||
// Multi-user stopgap: these aggregates are process-wide (no owner concept), so
|
||||
// restrict cross-user reads to admins (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const runs = minutes
|
||||
? workflowRunWatcher.getRecentRunSummaries(parseInt(minutes, 10))
|
||||
@@ -805,7 +870,9 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// RIGHT-pane detail: full run incl. agents[] (tokens/toolCalls/state per agent).
|
||||
app.get('/api/workflows/:runId', async (req) => {
|
||||
app.get('/api/workflows/:runId', async (req, reply) => {
|
||||
// Multi-user stopgap: cross-user run detail is admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { runId } = req.params as { runId: string };
|
||||
const run = workflowRunWatcher.getRun(runId);
|
||||
if (!run) {
|
||||
@@ -816,7 +883,10 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== Subagent Monitoring ==========
|
||||
|
||||
app.get('/api/subagents', async (req) => {
|
||||
app.get('/api/subagents', async (req, reply) => {
|
||||
// Multi-user stopgap: the global subagent list spans all users → admin-only
|
||||
// (no-op allow-all in single-user mode). Per-session variant below stays scoped.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const subagents = minutes
|
||||
? subagentWatcher.getRecentSubagents(parseInt(minutes, 10))
|
||||
@@ -826,12 +896,14 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/subagents', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const subagents = subagentWatcher.getSubagentsForSession(session.workingDir);
|
||||
return { success: true, data: subagents };
|
||||
});
|
||||
|
||||
app.get('/api/subagents/:agentId', async (req) => {
|
||||
app.get('/api/subagents/:agentId', async (req, reply) => {
|
||||
// Multi-user stopgap: cross-user subagent metadata is admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const info = subagentWatcher.getSubagent(agentId);
|
||||
if (!info) {
|
||||
@@ -840,7 +912,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: info };
|
||||
});
|
||||
|
||||
app.get('/api/subagents/:agentId/transcript', async (req) => {
|
||||
app.get('/api/subagents/:agentId/transcript', async (req, reply) => {
|
||||
// Multi-user stopgap: transcript CONTENT of any user's subagent is admin-only
|
||||
// (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const { limit, format } = req.query as { limit?: string; format?: 'raw' | 'formatted' };
|
||||
const limitNum = limit ? parseInt(limit, 10) : undefined;
|
||||
@@ -854,7 +929,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: transcript };
|
||||
});
|
||||
|
||||
app.delete('/api/subagents/:agentId', async (req) => {
|
||||
app.delete('/api/subagents/:agentId', async (req, reply) => {
|
||||
// Multi-user stopgap: killing any user's subagent is a cross-user write → admin-only
|
||||
// (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const info = subagentWatcher.getSubagent(agentId);
|
||||
if (!info) {
|
||||
@@ -868,12 +946,16 @@ export function registerSystemRoutes(
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed');
|
||||
});
|
||||
|
||||
app.post('/api/subagents/cleanup', async () => {
|
||||
app.post('/api/subagents/cleanup', async (req, reply) => {
|
||||
// Multi-user stopgap: process-wide cleanup affects every user → admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const removed = subagentWatcher.cleanupNow();
|
||||
return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } };
|
||||
});
|
||||
|
||||
app.delete('/api/subagents', async () => {
|
||||
app.delete('/api/subagents', async (req, reply) => {
|
||||
// Multi-user stopgap: clearing ALL users' subagents is a cross-user write → admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const cleared = subagentWatcher.clearAll();
|
||||
return { success: true, data: { cleared } };
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { SessionPort } from '../ports/session-port.js';
|
||||
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
|
||||
import { WsConnectionRegistry } from '../ws-connection-registry.js';
|
||||
import { canAccessOwned, getAuthUser } from '../route-helpers.js';
|
||||
|
||||
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
|
||||
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
|
||||
@@ -93,6 +94,17 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-user owner gate: writing to this socket injects keystrokes into the
|
||||
// agent, so a non-admin may only attach to their OWN session. The global auth
|
||||
// hook already ran on the upgrade request and decorated req.authUser (an
|
||||
// unauthenticated upgrade never reaches here — the hook 401s the handshake).
|
||||
// findSessionOrFail throws an HTTP-shaped error, so the check is inlined here
|
||||
// as a 4003 close. No-op in single-user mode (canAccessOwned returns true).
|
||||
if (!canAccessOwned(getAuthUser(req), session.owner)) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Structured transport logging — surfaces WS open/close/timeout churn so the
|
||||
// tunnel-flap behavior (COD-134) is observable in the server logs. Fastify is
|
||||
// configured logger:false, so we log via console (→ journald under systemd).
|
||||
|
||||
@@ -192,6 +192,22 @@ export const CreateSessionSchema = z.object({
|
||||
.max(100)
|
||||
.regex(/^[a-f0-9-]+$/, 'resumeSessionId must be a valid UUID')
|
||||
.optional(),
|
||||
/**
|
||||
* COD-105 — attach to an EXISTING remote tmux session discovered via
|
||||
* `GET /api/remote-hosts/:hostId/sessions` (one this Codeman didn't create).
|
||||
* The resulting session is NON-owned (closing it detaches, never kills the
|
||||
* remote). `remoteSessionName` is a discovered `codeman-*` tmux session name.
|
||||
*/
|
||||
attachRemoteSession: z
|
||||
.object({
|
||||
hostId: z.string().min(1).max(200),
|
||||
remoteSessionName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.regex(/^codeman-[a-zA-Z0-9._-]+$/, 'remoteSessionName must be a codeman-* tmux session name'),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -449,17 +465,22 @@ export const DockerHostSchema = z.object({
|
||||
export const DockerCaseLinkSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
// No commas: the path is embedded in a `--mount type=bind,src=<path>,dst=<path>`
|
||||
// CSV spec, and docker's --mount parser splits fields on commas (shell escaping
|
||||
// cannot protect it). Spaces are fine.
|
||||
hostWorkspacePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Workspace path must be absolute')
|
||||
.regex(/^[^,]*$/, 'Workspace path must not contain commas (docker --mount is comma-delimited)')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in workspace path'),
|
||||
containerWorkdir: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Container workdir must be absolute')
|
||||
.regex(/^[^,]*$/, 'Container workdir must not contain commas (docker --mount is comma-delimited)')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in container workdir')
|
||||
.optional(),
|
||||
container: z
|
||||
@@ -487,6 +508,7 @@ export const DockerImportSchema = z.object({
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Destination path must be absolute')
|
||||
.regex(/^[^,]*$/, 'Destination path must not contain commas (docker --mount is comma-delimited)')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in destination path'),
|
||||
});
|
||||
|
||||
@@ -536,6 +558,14 @@ export const QuickStartSchema = z.object({
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
||||
.optional(),
|
||||
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
|
||||
* mux/container names derive from the session id, not this. Defaults server-side. */
|
||||
sessionName: z.string().max(128).optional(),
|
||||
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
|
||||
* Empty string clears. Applied for local AND docker cases (the docker workspace is
|
||||
* a real host dir, so the settings file crosses the bind mount); rejected for
|
||||
* remote cases (the file would be written on the WRONG machine). */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
@@ -644,6 +674,10 @@ export const SettingsUpdateSchema = z
|
||||
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
|
||||
claudeModel: z.string().max(50).optional(),
|
||||
opusContext1mEnabled: z.boolean().optional(),
|
||||
// COD-108 remote-session auto-reconnect kill-switch (default ON). When false,
|
||||
// the TmuxManager watcher does nothing — dropped remote sessions are NOT
|
||||
// auto-reattached.
|
||||
remoteAutoReconnect: z.boolean().optional(),
|
||||
thinkingEffort: z.string().max(20).optional(),
|
||||
// UI visibility
|
||||
showFontControls: z.boolean().optional(),
|
||||
@@ -833,6 +867,11 @@ export const AutoResumeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
|
||||
export const PinSessionSchema = z.object({
|
||||
pinned: z.boolean(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/image-watcher */
|
||||
export const ImageWatcherSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
@@ -935,6 +974,14 @@ export const CaseOrderSchema = z.object({
|
||||
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
|
||||
});
|
||||
|
||||
/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */
|
||||
export const SessionOrderUpdateSchema = z.object({
|
||||
// Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only
|
||||
// its open-tab order (max sessions is 50) — 500 leaves ample headroom while
|
||||
// keeping a hostile/buggy client from persisting megabytes into state.json.
|
||||
order: z.array(z.string().max(100)).max(500),
|
||||
});
|
||||
|
||||
/** POST /api/auth/revoke */
|
||||
export const RevokeSessionSchema = z.object({
|
||||
sessionToken: z.string().min(1).max(200).optional(),
|
||||
|
||||
+264
-11
@@ -135,6 +135,8 @@ import { SseEvent } from './sse-events.js';
|
||||
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
||||
import { isMultiUserMode } from '../config/multiuser.js';
|
||||
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
|
||||
import { installRouteErrorHandler } from './route-error-handler.js';
|
||||
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
|
||||
import {
|
||||
@@ -155,6 +157,8 @@ import {
|
||||
registerSearchRoutes,
|
||||
registerOrchestratorRoutes,
|
||||
registerCronRoutes,
|
||||
registerMeRoutes,
|
||||
registerAdminRoutes,
|
||||
registerWsRoutes,
|
||||
} from './routes/index.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
@@ -279,6 +283,7 @@ export class WebServer extends EventEmitter {
|
||||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private userFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
@@ -330,6 +335,7 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session ? this.getSessionStateWithRespawn(session) : null;
|
||||
},
|
||||
resolveSessionOwner: (sessionId) => this.sessions.get(sessionId)?.owner,
|
||||
},
|
||||
this.cleanup
|
||||
);
|
||||
@@ -353,6 +359,22 @@ export class WebServer extends EventEmitter {
|
||||
this.broadcast(SseEvent.MuxStatsUpdated, sessions);
|
||||
});
|
||||
|
||||
// COD-108 — remote-session auto-reconnect. The TmuxManager watcher detects a
|
||||
// dead remote pane and emits `remoteSessionDropped`; the session owner (here)
|
||||
// reassembles the respawn options and reattaches via Session.reattachRemote()
|
||||
// (D1: the watcher does NOT reassemble options itself). On success we reset
|
||||
// the watcher's backoff; on failure the backoff schedules the next attempt.
|
||||
this.mux.on('remoteSessionDropped', (data) => {
|
||||
const { sessionId, attempt } = data as { sessionId: string; attempt: number };
|
||||
this.broadcast(SseEvent.RemoteSessionDropped, { sessionId, attempt });
|
||||
void this.handleRemoteSessionDropped(sessionId);
|
||||
});
|
||||
this.mux.on('remoteReconnectExhausted', (data) => {
|
||||
const { sessionId } = data as { sessionId: string };
|
||||
console.warn(`[Server] Remote auto-reconnect exhausted for session ${sessionId}`);
|
||||
this.broadcast(SseEvent.RemoteReconnectExhausted, { sessionId });
|
||||
});
|
||||
|
||||
// Set up subagent watcher listeners
|
||||
this.setupSubagentWatcherListeners();
|
||||
this.setupWorkflowRunWatcherListeners();
|
||||
@@ -680,6 +702,7 @@ export class WebServer extends EventEmitter {
|
||||
this.authFailures = authState.authFailures;
|
||||
this.qrAuthFailures = authState.qrAuthFailures;
|
||||
this.hookSecretFailures = authState.hookSecretFailures;
|
||||
this.userFailures = authState.userFailures;
|
||||
}
|
||||
|
||||
// WebSocket support (terminal I/O — low-latency bidirectional channel)
|
||||
@@ -790,12 +813,12 @@ export class WebServer extends EventEmitter {
|
||||
// Track tunnel clients — cloudflared proxies locally so req.ip is always
|
||||
// 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
|
||||
const isRemote = !!req.headers['cf-connecting-ip'];
|
||||
this.sse.addClient(reply, sessionFilter, isRemote, clientId);
|
||||
this.sse.addClient(reply, sessionFilter, isRemote, clientId, req.authUser);
|
||||
|
||||
// Send initial state
|
||||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||||
// Buffers are fetched on-demand when switching tabs
|
||||
this.sse.sendSSE(reply, SseEvent.Init, this.getLightState());
|
||||
this.sse.sendSSE(reply, SseEvent.Init, this.getLightState(req.authUser));
|
||||
// Flush Cloudflare tunnel buffer with padding — ensures the init event
|
||||
// (and any immediately following events) are delivered without proxy delay.
|
||||
this.sse.sendPadding(reply);
|
||||
@@ -899,6 +922,8 @@ export class WebServer extends EventEmitter {
|
||||
registerPlanRoutes(this.app, ctx);
|
||||
registerClipboardRoutes(this.app, ctx);
|
||||
registerSearchRoutes(this.app, ctx);
|
||||
registerMeRoutes(this.app, ctx);
|
||||
registerAdminRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
@@ -1187,7 +1212,7 @@ export class WebServer extends EventEmitter {
|
||||
// Only remove from state.json if we're also killing the mux session.
|
||||
// When killMux=false (server shutdown), preserve state for recovery.
|
||||
if (killMux) {
|
||||
this.store.removeSession(sessionId);
|
||||
this.store.demoteOrRemoveSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1514,7 +1539,12 @@ export class WebServer extends EventEmitter {
|
||||
const claudeMode = settings.claudeMode as string | undefined;
|
||||
const allowedTools = settings.allowedTools as string | undefined;
|
||||
// Only return valid modes
|
||||
if (claudeMode === 'dangerously-skip-permissions' || claudeMode === 'normal' || claudeMode === 'allowedTools') {
|
||||
if (
|
||||
claudeMode === 'dangerously-skip-permissions' ||
|
||||
claudeMode === 'auto' ||
|
||||
claudeMode === 'normal' ||
|
||||
claudeMode === 'allowedTools'
|
||||
) {
|
||||
return { claudeMode, allowedTools };
|
||||
}
|
||||
return {};
|
||||
@@ -1540,7 +1570,12 @@ export class WebServer extends EventEmitter {
|
||||
);
|
||||
}
|
||||
|
||||
private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun> {
|
||||
private async startScheduledRun(
|
||||
prompt: string,
|
||||
workingDir: string,
|
||||
durationMinutes: number,
|
||||
owner?: string
|
||||
): Promise<ScheduledRun> {
|
||||
const id = uuidv4();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -1556,6 +1591,9 @@ export class WebServer extends EventEmitter {
|
||||
completedTasks: 0,
|
||||
totalCost: 0,
|
||||
logs: [`[${new Date().toISOString()}] Scheduled run started`],
|
||||
// Multi-user: stamp the requesting user so the spawned Session is owned +
|
||||
// permission-downgraded, and list/delete stay owner-scoped.
|
||||
owner,
|
||||
};
|
||||
|
||||
this.scheduledRuns.set(id, run);
|
||||
@@ -1594,8 +1632,23 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
let session: Session | null = null;
|
||||
try {
|
||||
// Create a session for this iteration
|
||||
session = new Session({ workingDir: run.workingDir });
|
||||
// Create a session for this iteration.
|
||||
if (isMultiUserMode()) {
|
||||
// §6.3: resolve the permission mode with the RUN OWNER (a non-granted user
|
||||
// must not regain --dangerously-skip-permissions here) and stamp the owner so
|
||||
// list/delete stay scoped. owner + mode + allowedTools mirror quick-start.
|
||||
const scheduledClaudeCfg = await this.getClaudeModeConfig();
|
||||
session = new Session({
|
||||
workingDir: run.workingDir,
|
||||
owner: run.owner,
|
||||
claudeMode: await resolveClaudeModeForUsername(scheduledClaudeCfg.claudeMode, run.owner),
|
||||
allowedTools: scheduledClaudeCfg.allowedTools,
|
||||
});
|
||||
} else {
|
||||
// Single-user: build EXACTLY as master (bare workingDir → Session's default
|
||||
// mode) so the flag-off path stays byte-identical.
|
||||
session = new Session({ workingDir: run.workingDir });
|
||||
}
|
||||
this.sessions.set(session.id, session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.persistSessionState(session);
|
||||
@@ -1740,7 +1793,54 @@ export class WebServer extends EventEmitter {
|
||||
* Get lightweight state for SSE init - excludes full terminal buffers
|
||||
* to prevent browser freezes. Terminal buffers are fetched on-demand.
|
||||
*/
|
||||
private getLightState() {
|
||||
private getLightState(identity?: import('../types/user.js').AuthUser) {
|
||||
const base = this.computeLightState();
|
||||
// Multi-user: filter the shared cached blob per connection identity (the plan's
|
||||
// "filter AFTER the cache" approach). No-op for admins / single-user.
|
||||
if (isMultiUserMode() && identity && identity.role !== 'admin') {
|
||||
return this.filterLightStateForUser(base, identity.username);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Shallow-filter the light-state blob to what a non-admin user may see. */
|
||||
private filterLightStateForUser(base: Record<string, unknown>, username: string): Record<string, unknown> {
|
||||
const ownedIds = new Set<string>();
|
||||
const ownedClaudeIds = new Set<string>();
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s.owner === username) {
|
||||
ownedIds.add(id);
|
||||
if (s.claudeSessionId) ownedClaudeIds.add(s.claudeSessionId);
|
||||
}
|
||||
}
|
||||
const sessions = Array.isArray(base.sessions)
|
||||
? (base.sessions as Array<{ owner?: string }>).filter((s) => s.owner === username)
|
||||
: base.sessions;
|
||||
const respawnStatus: Record<string, unknown> = {};
|
||||
for (const [id, v] of Object.entries((base.respawnStatus as Record<string, unknown>) ?? {})) {
|
||||
if (ownedIds.has(id)) respawnStatus[id] = v;
|
||||
}
|
||||
const bySession = (arr: unknown, key: 'sessionId' | 'sessionUuid') =>
|
||||
Array.isArray(arr)
|
||||
? (arr as Array<Record<string, unknown>>).filter((x) => ownedClaudeIds.has(String(x[key])))
|
||||
: arr;
|
||||
const filtered: Record<string, unknown> = {
|
||||
...base,
|
||||
sessions,
|
||||
respawnStatus,
|
||||
scheduledRuns: [], // legacy ScheduledRun has no owner yet → admin-only
|
||||
subagents: bySession(base.subagents, 'sessionId'),
|
||||
workflowRuns: bySession(base.workflowRuns, 'sessionUuid'),
|
||||
planUsage: null, // host-plan telemetry is admin-only
|
||||
};
|
||||
// #29: globalStats is a machine-wide aggregate (all users' tokens/cost + active
|
||||
// count) with no per-user attribution — never expose it to a non-admin. The
|
||||
// header falls back to per-active-session totals when it is absent.
|
||||
delete filtered.globalStats;
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private computeLightState() {
|
||||
const now = Date.now();
|
||||
if (this.cachedLightState && now - this.cachedLightState.timestamp < WebServer.LIGHT_STATE_CACHE_TTL_MS) {
|
||||
return this.cachedLightState.data;
|
||||
@@ -1771,6 +1871,7 @@ export class WebServer extends EventEmitter {
|
||||
timestamp: now,
|
||||
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
|
||||
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
|
||||
sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131)
|
||||
};
|
||||
|
||||
this.cachedLightState = { data: result, timestamp: now };
|
||||
@@ -1785,7 +1886,65 @@ export class WebServer extends EventEmitter {
|
||||
this.cachedLightState = null;
|
||||
this.cachedSessionsList = null;
|
||||
}
|
||||
this.sse.broadcast(event, data);
|
||||
// Multi-user: derive an ownership routing hint so an event only reaches the
|
||||
// clients entitled to it (no-op in single-user — hint stays undefined).
|
||||
this.sse.broadcast(event, data, isMultiUserMode() ? this.deriveSseHint(event, data) : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an SSE event + payload to a routing hint (multi-user). Session-scoped
|
||||
* families resolve the owner from a sessionId in the payload (fail closed if it
|
||||
* can't be resolved); machine-level families are admin-only; host-plan telemetry
|
||||
* is admin-only; everything else stays global. Default is fail-closed for the
|
||||
* session-scoped prefixes so a missed field starves rather than leaks.
|
||||
*/
|
||||
private deriveSseHint(event: string, data: unknown): import('./sse-stream-manager.js').SseRoutingHint | undefined {
|
||||
// Machine-level / host-wide: admins only.
|
||||
if (
|
||||
event.startsWith('docker:') ||
|
||||
event.startsWith('tunnel:') ||
|
||||
event.startsWith('update:') ||
|
||||
event.startsWith('system:') ||
|
||||
event.startsWith('cron:') ||
|
||||
event === SseEvent.SessionStatusTelemetry
|
||||
) {
|
||||
return { adminOnly: true };
|
||||
}
|
||||
// Session-scoped families: resolve the owner from the payload's session id.
|
||||
const SESSION_PREFIXES = [
|
||||
'session:',
|
||||
'ralph:',
|
||||
'respawn:',
|
||||
'subagent:',
|
||||
'workflow:',
|
||||
'attachment:',
|
||||
'task:',
|
||||
'mux:',
|
||||
'transcript:',
|
||||
'plan:',
|
||||
'orchestrator:',
|
||||
'hook:',
|
||||
'image:',
|
||||
'scheduled:',
|
||||
'team:',
|
||||
'case:',
|
||||
];
|
||||
if (SESSION_PREFIXES.some((p) => event.startsWith(p))) {
|
||||
const d = (data ?? {}) as { sessionId?: string; id?: string; session?: { id?: string } };
|
||||
const sessionId = d.sessionId ?? d.id ?? d.session?.id;
|
||||
const owner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
||||
return { owner, sessionScoped: true };
|
||||
}
|
||||
// #20/#38: clipboard:write writes into the receiver's OS clipboard — route it to
|
||||
// the POSTING user's own tabs only (never other users). The route stamps the
|
||||
// trusted caller identity as `callerUsername`. sessionScoped:true fails closed
|
||||
// (withhold from non-admins) if the caller identity is somehow unresolved, rather
|
||||
// than falling through to global delivery.
|
||||
if (event.startsWith('clipboard:')) {
|
||||
return { username: (data as { callerUsername?: string }).callerUsername, sessionScoped: true };
|
||||
}
|
||||
// Unrecognized / genuinely global events (connection status, needsRefresh): all.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private batchTerminalData(sessionId: string, data: string): void {
|
||||
@@ -1842,6 +2001,13 @@ export class WebServer extends EventEmitter {
|
||||
const sessionName = (data.sessionName as string) || '';
|
||||
const sessionId = (data.sessionId as string) || '';
|
||||
|
||||
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
|
||||
// must reach only the owner's devices (+ admins) — the body embeds the session
|
||||
// name + activity, so cross-user delivery would leak it. Resolved once here; the
|
||||
// per-subscription gate below is a no-op in single-user (send to all).
|
||||
const multiUserPush = isMultiUserMode();
|
||||
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
||||
|
||||
// Build body text from event data
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
@@ -1878,6 +2044,16 @@ export class WebServer extends EventEmitter {
|
||||
// Check per-subscription preferences
|
||||
if (sub.pushPreferences[event] === false) continue;
|
||||
|
||||
// Multi-user recipient scoping: admins receive all; a session-scoped event
|
||||
// reaches only subscriptions owned by the session owner (fail closed if the
|
||||
// owner is unresolved — legacy subs with no stamped username are excluded);
|
||||
// a genuinely session-less event reaches everyone.
|
||||
if (multiUserPush && sub.role !== 'admin') {
|
||||
if (sessionId) {
|
||||
if (sub.username === undefined || sub.username !== pushSessionOwner) continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate the stored endpoint before fetching it server-side (SSRF, M7).
|
||||
// Defense-in-depth: subscribe-time validation already rejects unsafe URLs.
|
||||
if (!isSafePushEndpoint(sub.endpoint)) {
|
||||
@@ -1925,6 +2101,24 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Multi-user first boot: create the initial admin from CODEMAN_USERNAME/PASSWORD
|
||||
// if there are no users yet, else refuse to start (there would be no way in).
|
||||
if (isMultiUserMode() && !this.testMode) {
|
||||
const boot = await bootstrapInitialAdmin();
|
||||
if (boot.status === 'missing-env') {
|
||||
throw new Error(
|
||||
'Multi-user mode is enabled but users.json has no users. Create the first admin with ' +
|
||||
'`codeman users add <name> --admin` (or set CODEMAN_USERNAME/CODEMAN_PASSWORD for one-time bootstrap).'
|
||||
);
|
||||
}
|
||||
if (boot.status === 'created') {
|
||||
console.log(
|
||||
`✓ Multi-user: bootstrapped initial admin "${boot.username}" from CODEMAN_USERNAME/CODEMAN_PASSWORD`
|
||||
);
|
||||
}
|
||||
console.log('✓ Multi-user mode active (per-user accounts in users.json; CODEMAN_PASSWORD is ignored for login)');
|
||||
}
|
||||
|
||||
await this.setupRoutes();
|
||||
|
||||
const lifecycleLog = getLifecycleLog();
|
||||
@@ -2001,7 +2195,10 @@ export class WebServer extends EventEmitter {
|
||||
// "just worked" before. Instead we start and warn loudly, pointing at the ways
|
||||
// to secure it. --allow-unauthenticated-network just acknowledges the risk (a
|
||||
// terser note). See docs/security-architecture.md.
|
||||
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD) {
|
||||
// Multi-user mode with >= 1 enabled user satisfies the auth requirement even
|
||||
// without CODEMAN_PASSWORD (every person has their own credential).
|
||||
const authActive = !!process.env.CODEMAN_PASSWORD || (isMultiUserMode() && (await hasUsers()));
|
||||
if (!isLoopbackBindHost(this.host) && !authActive) {
|
||||
if (this.allowUnauthenticatedNetwork) {
|
||||
console.warn(
|
||||
`\n⚠ Codeman is reachable WITHOUT a password on ${displayHost}:${this.port} ` +
|
||||
@@ -2206,7 +2403,16 @@ export class WebServer extends EventEmitter {
|
||||
const sessionName = savedState?.name || muxSession.name || muxSession.muxName;
|
||||
|
||||
// Create a session object for this mux session
|
||||
const recoveryClaudeMode = await this.getClaudeModeConfig();
|
||||
// Owner round-trips like remote/docker: mux-sessions.json carries
|
||||
// MuxSession.owner, state.json carries SessionState.owner. Recovery must
|
||||
// re-resolve the permission mode with the RECOVERED owner or a reboot
|
||||
// would silently un-downgrade a non-granted user's restored session.
|
||||
const recoveredOwner = muxSession.owner ?? savedState?.owner;
|
||||
const recoveryClaudeModeConfig = await this.getClaudeModeConfig();
|
||||
const recoveryClaudeMode = {
|
||||
claudeMode: await resolveClaudeModeForUsername(recoveryClaudeModeConfig.claudeMode, recoveredOwner),
|
||||
allowedTools: recoveryClaudeModeConfig.allowedTools,
|
||||
};
|
||||
// Recover envOverrides from the internal __envOverrides field written by
|
||||
// session-manager (see updateSessionState). Cast to read the non-public field.
|
||||
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
|
||||
@@ -2243,6 +2449,7 @@ export class WebServer extends EventEmitter {
|
||||
// MuxSession.docker; state.json carries SessionState.docker), so recovery
|
||||
// rebuilds the `docker exec` launch instead of a broken local command.
|
||||
docker: muxSession.docker ?? savedState?.docker,
|
||||
owner: recoveredOwner,
|
||||
});
|
||||
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
@@ -2413,6 +2620,13 @@ export class WebServer extends EventEmitter {
|
||||
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
|
||||
}
|
||||
|
||||
// COD-108 — start the remote-session auto-reconnect watcher (tmux only).
|
||||
// Always-on (D3) with a `remoteAutoReconnect` kill-switch the watcher reads
|
||||
// each tick. Start even with no sessions — remote sessions may arrive later.
|
||||
if ('startRemoteReconnectWatcher' in this.mux) {
|
||||
(this.mux as { startRemoteReconnectWatcher: (ms?: number) => void }).startRemoteReconnectWatcher();
|
||||
}
|
||||
|
||||
if (dead.length > 0) {
|
||||
console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`);
|
||||
}
|
||||
@@ -2421,6 +2635,41 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-108 — handle a `remoteSessionDropped` emit from the watcher: reattach
|
||||
* the dropped remote session and report the outcome back to the watcher so it
|
||||
* can reset/advance its backoff. Re-running the idempotent remote command
|
||||
* REATTACHES the durable remote tmux session (does NOT recreate it).
|
||||
*/
|
||||
private async handleRemoteSessionDropped(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
// No live Session object (e.g. detached/restored-but-not-attached) — nothing
|
||||
// to drive the reattach; report failure so the watcher backs off and retries.
|
||||
if (!session) {
|
||||
this.noteRemoteReconnect(sessionId, false);
|
||||
return;
|
||||
}
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await session.reattachRemote();
|
||||
} catch (err) {
|
||||
console.error(`[Server] Remote reattach failed for ${sessionId}:`, err);
|
||||
ok = false;
|
||||
}
|
||||
this.noteRemoteReconnect(sessionId, ok);
|
||||
if (ok) {
|
||||
this.persistSessionState(session);
|
||||
this.broadcast(SseEvent.RemoteSessionReconnected, { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Forward a reattach outcome to the TmuxManager watcher (resets/clears backoff). */
|
||||
private noteRemoteReconnect(sessionId: string, success: boolean): void {
|
||||
if ('noteRemoteReconnect' in this.mux) {
|
||||
(this.mux as { noteRemoteReconnect: (id: string, ok: boolean) => void }).noteRemoteReconnect(sessionId, success);
|
||||
}
|
||||
}
|
||||
|
||||
private initOrchestratorLoop(): import('../orchestrator-loop.js').OrchestratorLoop {
|
||||
if (this._orchestratorLoop) return this._orchestratorLoop;
|
||||
|
||||
@@ -2632,6 +2881,10 @@ export class WebServer extends EventEmitter {
|
||||
this.hookSecretFailures.dispose();
|
||||
this.hookSecretFailures = null;
|
||||
}
|
||||
if (this.userFailures) {
|
||||
this.userFailures.dispose();
|
||||
this.userFailures = null;
|
||||
}
|
||||
this.activePlanOrchestrators.clear();
|
||||
this.cleaningUp.clear();
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as con
|
||||
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
|
||||
/** CLI version/model info detected from session output. */
|
||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
||||
/** Session pin state changed (COD-139): pinned/unpinned in the session manager list. */
|
||||
export const SessionPinned = 'session:pinned' as const;
|
||||
/** General session message (e.g. status text). */
|
||||
export const SessionMessage = 'session:message' as const;
|
||||
/** Session entered interactive mode (claude or shell). */
|
||||
@@ -150,6 +152,15 @@ export const MuxDied = 'mux:died' as const;
|
||||
/** tmux session stats refreshed. */
|
||||
export const MuxStatsUpdated = 'mux:statsUpdated' as const;
|
||||
|
||||
// ─── Remote auto-reconnect (COD-108) ─────────────────────────────────────────
|
||||
|
||||
/** A remote session's local ssh pane died; an auto-reconnect attempt is starting. */
|
||||
export const RemoteSessionDropped = 'remote:sessionDropped' as const;
|
||||
/** A dropped remote session was successfully re-established (reattached). */
|
||||
export const RemoteSessionReconnected = 'remote:sessionReconnected' as const;
|
||||
/** Auto-reconnect gave up after the bounded backoff cap — manual reconnect needed. */
|
||||
export const RemoteReconnectExhausted = 'remote:reconnectExhausted' as const;
|
||||
|
||||
// ─── Respawn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Respawn loop started for a session. */
|
||||
@@ -377,6 +388,26 @@ export const DockerExportComplete = 'docker:exportComplete' as const;
|
||||
export const DockerExportFailed = 'docker:exportFailed' as const;
|
||||
/** A docker bundle was imported into a new case. */
|
||||
export const DockerImportComplete = 'docker:importComplete' as const;
|
||||
/** The agent base image started building (first Docker case; auto-build on first use). */
|
||||
export const DockerImageBuildStarted = 'docker:imageBuildStarted' as const;
|
||||
/** A line of agent base-image build output (progress surfacing). */
|
||||
export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const;
|
||||
/** The agent base image finished building successfully. */
|
||||
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
|
||||
/** The agent base image build failed. */
|
||||
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
|
||||
/** A case container was removed after a config-drift confirm (recreated with the new config on next launch). */
|
||||
export const DockerContainerRecreated = 'docker:containerRecreated' as const;
|
||||
|
||||
// ─── Multi-user (admin-only / targeted) ──────────────────────────────────────
|
||||
|
||||
/** The user roster changed (admin-only); the Users panel re-fetches. */
|
||||
export const AdminUsersChanged = 'admin:usersChanged' as const;
|
||||
/** A user must change their password (targeted); the frontend shows the modal. */
|
||||
export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const;
|
||||
|
||||
/** Global session tab order changed (synced across devices). COD-131. */
|
||||
export const SessionOrderChanged = 'session:orderChanged' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -407,6 +438,7 @@ export const SseEvent = {
|
||||
SessionLimitResumeCancelled,
|
||||
SessionRespawnBreakerTripped,
|
||||
SessionCliInfo,
|
||||
SessionPinned,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
@@ -443,6 +475,11 @@ export const SseEvent = {
|
||||
MuxDied,
|
||||
MuxStatsUpdated,
|
||||
|
||||
// Remote auto-reconnect (COD-108)
|
||||
RemoteSessionDropped,
|
||||
RemoteSessionReconnected,
|
||||
RemoteReconnectExhausted,
|
||||
|
||||
// Respawn
|
||||
RespawnStarted,
|
||||
RespawnStopped,
|
||||
@@ -564,4 +601,14 @@ export const SseEvent = {
|
||||
DockerExportComplete,
|
||||
DockerExportFailed,
|
||||
DockerImportComplete,
|
||||
DockerImageBuildStarted,
|
||||
DockerImageBuildProgress,
|
||||
DockerImageBuildComplete,
|
||||
DockerImageBuildFailed,
|
||||
AdminUsersChanged,
|
||||
AuthPasswordChangeRequired,
|
||||
DockerContainerRecreated,
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SessionOrderChanged,
|
||||
} as const;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type { BackgroundTask } from '../session.js';
|
||||
import type { AuthUser } from '../types.js';
|
||||
import { CleanupManager, StaleExpirationMap } from '../utils/index.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import {
|
||||
@@ -38,6 +39,26 @@ const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
|
||||
interface SseStreamManagerDeps {
|
||||
/** Get session state with respawn info for session:updated broadcasts */
|
||||
getSessionStateWithRespawn(sessionId: string): unknown;
|
||||
/** Resolve a session's owner (multi-user) for SSE routing; undefined = unknown. */
|
||||
resolveSessionOwner?(sessionId: string): string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional per-broadcast routing hint (multi-user). Resolved by WebServer.broadcast
|
||||
* before delegation. When absent, an event is delivered to all clients (global).
|
||||
*/
|
||||
export interface SseRoutingHint {
|
||||
/** Deliver only to this session's owner (+ admins). */
|
||||
owner?: string;
|
||||
/** Deliver only to admins (machine-level events: docker builds, tunnel, update). */
|
||||
adminOnly?: boolean;
|
||||
/** Deliver only to this exact user (+ admins). */
|
||||
username?: string;
|
||||
/**
|
||||
* The event is session-scoped but the owner could not be resolved — non-admins
|
||||
* are starved (fail closed) rather than leaked to.
|
||||
*/
|
||||
sessionScoped?: boolean;
|
||||
}
|
||||
|
||||
export class SseStreamManager {
|
||||
@@ -50,6 +71,8 @@ export class SseStreamManager {
|
||||
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
|
||||
/** Optional client-supplied IDs → reply, for live filter updates without reconnecting */
|
||||
private sseClientsById: Map<string, FastifyReply> = new Map();
|
||||
/** Per-client identity (multi-user); absent for single-user clients → no filtering. */
|
||||
private sseClientIdentity: Map<FastifyReply, AuthUser> = new Map();
|
||||
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
|
||||
private remoteSseClients: Set<FastifyReply> = new Set();
|
||||
/** Clients with backpressure — skip writes until 'drain' fires */
|
||||
@@ -105,8 +128,15 @@ export class SseStreamManager {
|
||||
this._isTunnelActive = active;
|
||||
}
|
||||
|
||||
addClient(reply: FastifyReply, sessionFilter: Set<string> | null, isRemote: boolean, clientId?: string): void {
|
||||
addClient(
|
||||
reply: FastifyReply,
|
||||
sessionFilter: Set<string> | null,
|
||||
isRemote: boolean,
|
||||
clientId?: string,
|
||||
identity?: AuthUser
|
||||
): void {
|
||||
this.sseClients.set(reply, sessionFilter);
|
||||
if (identity) this.sseClientIdentity.set(reply, identity);
|
||||
if (isRemote) {
|
||||
this.remoteSseClients.add(reply);
|
||||
}
|
||||
@@ -117,6 +147,7 @@ export class SseStreamManager {
|
||||
this.sseClients.delete(prev);
|
||||
this.remoteSseClients.delete(prev);
|
||||
this.backpressuredClients.delete(prev);
|
||||
this.sseClientIdentity.delete(prev);
|
||||
}
|
||||
this.sseClientsById.set(clientId, reply);
|
||||
}
|
||||
@@ -126,12 +157,31 @@ export class SseStreamManager {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
this.sseClientIdentity.delete(reply);
|
||||
// Clear any clientId mappings pointing at this reply
|
||||
for (const [id, r] of this.sseClientsById) {
|
||||
if (r === reply) this.sseClientsById.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an SSE event carrying `hint` may be delivered to `reply`. Clients with
|
||||
* no identity (single-user) always receive everything. Admins receive everything.
|
||||
* A non-admin receives an event only when the hint targets them (owner/username)
|
||||
* or the event is unrouted/global; session-scoped events with an unresolved owner
|
||||
* are withheld (fail closed).
|
||||
*/
|
||||
private canDeliver(reply: FastifyReply, hint?: SseRoutingHint): boolean {
|
||||
const identity = this.sseClientIdentity.get(reply);
|
||||
if (!identity || identity.role === 'admin') return true;
|
||||
if (!hint) return true;
|
||||
if (hint.adminOnly) return false;
|
||||
if (hint.username !== undefined) return hint.username === identity.username;
|
||||
if (hint.owner !== undefined) return hint.owner === identity.username;
|
||||
if (hint.sessionScoped) return false; // session-scoped but owner unknown → fail closed
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing client's session subscription filter without forcing
|
||||
* an SSE reconnect. Returns true if the client was found and updated.
|
||||
@@ -197,7 +247,7 @@ export class SseStreamManager {
|
||||
|
||||
// ========== Broadcasting ==========
|
||||
|
||||
broadcast(event: string, data: unknown): void {
|
||||
broadcast(event: string, data: unknown, hint?: SseRoutingHint): void {
|
||||
// Skip serialization entirely when no clients are listening
|
||||
if (this.sseClients.size === 0) return;
|
||||
|
||||
@@ -224,6 +274,8 @@ export class SseStreamManager {
|
||||
// active session's terminal output. Terminal events bypass this method
|
||||
// entirely (see flushSessionTerminalBatch — it applies the filter).
|
||||
for (const [client] of this.sseClients) {
|
||||
// Multi-user ownership routing (no-op for identity-less single-user clients).
|
||||
if (!this.canDeliver(client, hint)) continue;
|
||||
this.sendSSEPreformatted(client, message);
|
||||
}
|
||||
}
|
||||
@@ -314,9 +366,15 @@ export class SseStreamManager {
|
||||
// terminal data is high-frequency and latency-sensitive.
|
||||
const padding = this._isTunnelActive ? SSE_PADDING : '';
|
||||
const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n` + padding;
|
||||
// Raw terminal bytes are the highest-value payload: resolve the session owner
|
||||
// ONCE and withhold the batch from any non-admin who is not the owner (fail
|
||||
// closed if the owner is unknown). No-op for identity-less single-user clients.
|
||||
const owner = this.deps.resolveSessionOwner?.(sessionId);
|
||||
const termHint: SseRoutingHint = { owner, sessionScoped: true };
|
||||
for (const [client, filter] of this.sseClients) {
|
||||
// Skip clients that have a session filter and aren't subscribed to this session
|
||||
if (filter && !filter.has(sessionId)) continue;
|
||||
if (!this.canDeliver(client, termHint)) continue;
|
||||
this.sendSSEPreformatted(client, message);
|
||||
}
|
||||
}
|
||||
@@ -355,7 +413,11 @@ export class SseStreamManager {
|
||||
return;
|
||||
}
|
||||
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
|
||||
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
|
||||
// Multi-user: batched task updates carry session state — route to the owner
|
||||
// only (fail closed if unknown), matching flushSessionTerminalBatch. No-op for
|
||||
// identity-less single-user clients (canDeliver short-circuits on no identity).
|
||||
const owner = this.deps.resolveSessionOwner?.(sessionId);
|
||||
this.broadcast(SseEvent.TaskUpdated, { sessionId, task }, { owner, sessionScoped: true });
|
||||
}
|
||||
this.taskUpdateBatches.clear();
|
||||
}
|
||||
@@ -395,7 +457,11 @@ export class SseStreamManager {
|
||||
// Single expensive serialization per batch interval
|
||||
const state = this.deps.getSessionStateWithRespawn(sessionId);
|
||||
if (state) {
|
||||
this.broadcast(SseEvent.SessionUpdated, state);
|
||||
// Multi-user: the debounced session:updated blob carries name/workingDir/
|
||||
// tokens/cost — route to the session owner only (fail closed if unknown),
|
||||
// matching flushSessionTerminalBatch. No-op for single-user clients.
|
||||
const owner = this.deps.resolveSessionOwner?.(sessionId);
|
||||
this.broadcast(SseEvent.SessionUpdated, state, { owner, sessionScoped: true });
|
||||
}
|
||||
}
|
||||
this.stateUpdatePending.clear();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview Phase 5 admin API tests (live server, port 3173).
|
||||
*
|
||||
* Covers the admin user-management endpoints: multi-user gate, requireAdmin,
|
||||
* create (one-time password), patch + last-admin invariant, reset-password,
|
||||
* disable-revokes-sessions, and delete (last-admin refusal + delete-space).
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3173;
|
||||
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' };
|
||||
const adminNoBody = { Authorization: basic('root', 'rootpass123') };
|
||||
const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' };
|
||||
|
||||
let server: WebServer;
|
||||
let dataDir: string;
|
||||
let spacesDir: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-data-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_PASSWORD',
|
||||
'CODEMAN_USERNAME',
|
||||
]) {
|
||||
saved[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
invalidateUsersCache();
|
||||
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||
await createUser({ username: 'joe', role: 'user', password: 'joepass1234' });
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('admin API', () => {
|
||||
it('rejects a non-admin (403)', async () => {
|
||||
const res = await fetch(url('/api/admin/users'), { headers: regular });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('lists users for an admin', async () => {
|
||||
const res = await fetch(url('/api/admin/users'), { headers: admin });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.map((u: { username: string }) => u.username).sort()).toEqual(['joe', 'root']);
|
||||
expect(data[0]).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('creates a user with a one-time password', async () => {
|
||||
const res = await fetch(url('/api/admin/users'), {
|
||||
method: 'POST',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ username: 'newbie', role: 'user' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.oneTimePassword).toBeTypeOf('string');
|
||||
expect(data.user).toMatchObject({ username: 'newbie', mustChangePassword: true });
|
||||
});
|
||||
|
||||
it('toggles canBypassPermissions via PATCH', async () => {
|
||||
const res = await fetch(url('/api/admin/users/joe'), {
|
||||
method: 'PATCH',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ canBypassPermissions: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.user.canBypassPermissions).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to demote the last admin (409)', async () => {
|
||||
const res = await fetch(url('/api/admin/users/root'), {
|
||||
method: 'PATCH',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ role: 'user' }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).errorCode).toBe('LAST_ADMIN');
|
||||
});
|
||||
|
||||
it('resets a password (one-time) and forces change', async () => {
|
||||
const res = await fetch(url('/api/admin/users/joe/reset-password'), { method: 'POST', headers: adminNoBody });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.oneTimePassword).toBeTypeOf('string');
|
||||
// joe must now change password before other actions.
|
||||
const gated = await fetch(url('/api/status'), { headers: { Authorization: basic('joe', data.oneTimePassword) } });
|
||||
expect(gated.status).toBe(403);
|
||||
expect((await gated.json()).errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
|
||||
});
|
||||
|
||||
it('refuses to delete the last admin, deletes a regular user + space', async () => {
|
||||
const del = await fetch(url('/api/admin/users/root'), { method: 'DELETE', headers: adminNoBody });
|
||||
expect(del.status).toBe(409);
|
||||
|
||||
await fs.mkdir(path.join(spacesDir, 'newbie', 'cases'), { recursive: true });
|
||||
const del2 = await fetch(url('/api/admin/users/newbie'), {
|
||||
method: 'DELETE',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ deleteSpace: true }),
|
||||
});
|
||||
expect(del2.status).toBe(200);
|
||||
await expect(fs.stat(path.join(spacesDir, 'newbie'))).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it('404s admin routes in single-user mode', async () => {
|
||||
// Flip the flag off for one request path check.
|
||||
process.env.CODEMAN_MULTIUSER = '';
|
||||
try {
|
||||
const res = await fetch(url('/api/admin/users'), { headers: admin });
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @fileoverview Frontend test for admin-ui.js (multi-user identity boot + admin
|
||||
* Users tab + change-password modal). Builds a JSDOM window in-test under the
|
||||
* default node env (constructing the DOM in-test avoids the vitest environment
|
||||
* comment-directive gotcha) and evaluates the real module against it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const ADMIN_UI = readFileSync(new URL('../src/web/public/admin-ui.js', import.meta.url), 'utf-8');
|
||||
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
|
||||
|
||||
function resp(status: number, body: unknown) {
|
||||
const r = {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
clone() {
|
||||
return r;
|
||||
},
|
||||
};
|
||||
return r;
|
||||
}
|
||||
|
||||
async function bootWith(me: Record<string, unknown>) {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
|
||||
</body>`,
|
||||
{ url: 'http://localhost/', runScripts: 'outside-only' }
|
||||
);
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { __codemanUser?: Record<string, unknown> };
|
||||
win.fetch = (async (path: string) => {
|
||||
if (path === '/api/me') return resp(200, { success: true, data: me });
|
||||
if (path === '/api/admin/users') return resp(200, { success: true, data: [] });
|
||||
return resp(200, { success: true });
|
||||
}) as unknown as typeof fetch;
|
||||
(win as unknown as { eval: (s: string) => void }).eval(ADMIN_UI);
|
||||
// Let the async boot() (fetch /api/me → DOM inject) settle.
|
||||
for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
return { dom, win };
|
||||
}
|
||||
|
||||
describe('admin-ui boot', () => {
|
||||
it('exposes the identity and injects the Users tab for a multi-user admin', async () => {
|
||||
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
|
||||
expect(win.__codemanUser).toMatchObject({ username: 'root', role: 'admin', multiUser: true });
|
||||
const btn = win.document.querySelector('[data-tab="settings-users"]');
|
||||
expect(btn).toBeTruthy();
|
||||
expect(win.document.getElementById('settings-users')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does NOT inject the Users tab for a regular user', async () => {
|
||||
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
|
||||
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does NOT inject the Users tab in single-user mode', async () => {
|
||||
const { win } = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
|
||||
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('shows the change-password modal when mustChangePassword is set', async () => {
|
||||
const { win } = await bootWith({ username: 'dave', role: 'user', multiUser: true, mustChangePassword: true });
|
||||
const modal = win.document.getElementById('changePasswordModal') as HTMLElement | null;
|
||||
expect(modal).toBeTruthy();
|
||||
expect(modal!.style.display).toBe('flex');
|
||||
// Forced: the cancel button is hidden.
|
||||
expect((modal!.querySelector('#cpCancel') as HTMLElement).style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.html wiring', () => {
|
||||
it('loads admin-ui.js after settings-ui.js and before session-ui.js', () => {
|
||||
const settings = INDEX_HTML.indexOf('settings-ui.js');
|
||||
const admin = INDEX_HTML.indexOf('admin-ui.js');
|
||||
const session = INDEX_HTML.indexOf('session-ui.js');
|
||||
expect(admin).toBeGreaterThan(settings);
|
||||
expect(session).toBeGreaterThan(admin);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @fileoverview Tests for Claude CLI startup permission modes, focused on the
|
||||
* 'auto' mode (`--permission-mode auto`, Anthropic's recommended low-prompt mode)
|
||||
* added alongside the default `--dangerously-skip-permissions`.
|
||||
*
|
||||
* Covers BOTH spawn paths, which build the permission flags independently:
|
||||
* - session-cli-builder.buildInteractiveArgs (direct PTY, non-mux fallback)
|
||||
* - tmux-manager.buildSpawnCommand (tmux pane command string)
|
||||
* The default must stay 'dangerously-skip-permissions' when the setting is unset.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildInteractiveArgs } from '../src/session-cli-builder.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
|
||||
describe('buildInteractiveArgs permission modes (direct PTY path)', () => {
|
||||
it('keeps --dangerously-skip-permissions as the skip-mode flag', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'dangerously-skip-permissions');
|
||||
expect(args).toContain('--dangerously-skip-permissions');
|
||||
expect(args).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('auto mode emits --permission-mode auto and never the skip flag', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'auto');
|
||||
const idx = args.indexOf('--permission-mode');
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(args[idx + 1]).toBe('auto');
|
||||
expect(args).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('normal mode emits no permission flag at all', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'normal');
|
||||
expect(args).not.toContain('--dangerously-skip-permissions');
|
||||
expect(args).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('allowedTools mode is unchanged by the auto addition', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'allowedTools', undefined, 'Read,Grep');
|
||||
expect(args).toEqual(expect.arrayContaining(['--allowedTools', 'Read,Grep']));
|
||||
expect(args).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('auto mode composes with model and effort flags', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'auto', 'opus', undefined, 'high');
|
||||
expect(args).toEqual(expect.arrayContaining(['--permission-mode', 'auto', '--model', 'opus', '--effort', 'high']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSpawnCommand permission modes (tmux path)', () => {
|
||||
it('unset claudeMode defaults to --dangerously-skip-permissions', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1' });
|
||||
expect(cmd).toContain('claude --dangerously-skip-permissions --session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('auto mode emits --permission-mode auto and never the skip flag', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'auto' });
|
||||
expect(cmd).toContain('claude --permission-mode auto --session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('auto mode carries into BOTH legs of the resume fallback command', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
claudeMode: 'auto',
|
||||
resumeSessionId: 'abc-123',
|
||||
});
|
||||
const [resumeLeg, fallbackLeg] = cmd.split('||');
|
||||
expect(resumeLeg).toContain('--permission-mode auto');
|
||||
expect(resumeLeg).toContain('--resume "abc-123"');
|
||||
expect(fallbackLeg).toContain('--permission-mode auto');
|
||||
expect(fallbackLeg).toContain('--session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('normal mode emits no permission flag', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'normal' });
|
||||
expect(cmd).toContain('claude --session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--permission-mode');
|
||||
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
});
|
||||
@@ -88,11 +88,25 @@ describe('buildDockerLaunchCommand', () => {
|
||||
expect(cmd).toContain("sh -lc '");
|
||||
});
|
||||
|
||||
it('injects the resume flag ONLY when a resume id is passed', () => {
|
||||
it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
// --session-id first (fresh start), || --resume so a container stop/reboot
|
||||
// relaunch of the SAME session resumes instead of dead-paning on
|
||||
// "Session ID already in use".
|
||||
expect(cmd).toContain(
|
||||
'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f || ' +
|
||||
'claude --dangerously-skip-permissions --resume 1a2b3c4d5e6f'
|
||||
);
|
||||
// exec is stripped from the claude pane command — an exec'd first branch could never fall back.
|
||||
expect(cmd).not.toContain('exec claude');
|
||||
});
|
||||
|
||||
it('resumes an explicit id with a --session-id fallback (stale id never dead-panes)', () => {
|
||||
const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' }));
|
||||
expect(withResume).toContain('exec claude --dangerously-skip-permissions --resume abc-123-def');
|
||||
const without = buildDockerLaunchCommand(launchOpts());
|
||||
expect(without).not.toContain('--resume');
|
||||
expect(withResume).toContain(
|
||||
'claude --dangerously-skip-permissions --resume abc-123-def || ' +
|
||||
'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses codex resume syntax and drops an unsafe resume id', () => {
|
||||
@@ -101,8 +115,10 @@ describe('buildDockerLaunchCommand', () => {
|
||||
);
|
||||
expect(codex).toContain('exec codex resume 01H-codex-id');
|
||||
const unsafe = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'x; rm -rf /' }));
|
||||
expect(unsafe).not.toContain('--resume');
|
||||
expect(unsafe).not.toContain('x; rm'); // unsafe id dropped entirely
|
||||
expect(unsafe).not.toContain('rm -rf');
|
||||
// falls back to the deterministic fresh-launch chain on the session's own id
|
||||
expect(unsafe).toContain('--session-id 1a2b3c4d5e6f');
|
||||
});
|
||||
|
||||
it('forwards codex/gemini keys NAME-ONLY (no value in argv)', () => {
|
||||
@@ -128,10 +144,43 @@ describe('buildDockerLaunchCommand', () => {
|
||||
expect(cmd).toContain('/home/arkon/my cases/proj');
|
||||
});
|
||||
|
||||
it('honors a per-host command override', () => {
|
||||
it('honors a per-host command override (exec stripped for the session-id chain)', () => {
|
||||
const docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } };
|
||||
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
|
||||
expect(cmd).toContain('exec claude --model opus');
|
||||
expect(cmd).toContain('claude --model opus --session-id 1a2b3c4d5e6f');
|
||||
const shellOverride = { ...toSessionDocker(HOST, CASE), commands: { shell: 'exec zsh -l' } };
|
||||
const shellCmd = buildDockerLaunchCommand(launchOpts({ mode: 'shell' as SessionMode, docker: shellOverride }));
|
||||
expect(shellCmd).toContain('exec zsh -l'); // non-claude overrides keep their exec
|
||||
});
|
||||
|
||||
it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => {
|
||||
const cmd = buildDockerLaunchCommand(
|
||||
launchOpts({
|
||||
seedCopies: [
|
||||
{ from: '/home/agent/.codeman/claude.seed.json', to: '/home/agent/.claude.json' },
|
||||
{ from: '/home/agent/.codeman/claude-creds.seed.json', to: '/home/agent/.claude/.credentials.json' },
|
||||
// whole-dir credential seed → cp -a
|
||||
{ from: '/home/agent/.codeman/cred-seeds/.gemini', to: '/home/agent/.gemini', recursive: true },
|
||||
],
|
||||
})
|
||||
);
|
||||
// Each copy mkdir -p's its parent then is guarded so a reconnect never clobbers config.
|
||||
expect(cmd).toContain(
|
||||
'mkdir -p /home/agent 2>/dev/null; [ -e /home/agent/.claude.json ] || cp /home/agent/.codeman/claude.seed.json /home/agent/.claude.json'
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
'mkdir -p /home/agent/.claude 2>/dev/null; [ -e /home/agent/.claude/.credentials.json ] || cp /home/agent/.codeman/claude-creds.seed.json /home/agent/.claude/.credentials.json'
|
||||
);
|
||||
// recursive whole-dir seed uses cp -a
|
||||
expect(cmd).toContain(
|
||||
'[ -e /home/agent/.gemini ] || cp -a /home/agent/.codeman/cred-seeds/.gemini /home/agent/.gemini'
|
||||
);
|
||||
expect(cmd.indexOf('.claude.json')).toBeLessThan(cmd.indexOf('tmux -L codeman-docker'));
|
||||
});
|
||||
|
||||
it('omits the seed-copy step when there are no seedCopies', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
expect(cmd).not.toContain('claude.seed.json');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
isSafeTarMember,
|
||||
parseLoadedImageRef,
|
||||
exportDockerCase,
|
||||
validateImportManifest,
|
||||
DOCKER_EXPORT_SCHEMA,
|
||||
type DockerExportManifest,
|
||||
} from '../src/docker-export.js';
|
||||
import { toSessionDocker } from '../src/docker-hosts.js';
|
||||
import type { DockerCase, DockerHost } from '../src/types.js';
|
||||
@@ -63,6 +65,43 @@ describe('isSafeTarMember (import traversal guard)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateImportManifest (untrusted cross-machine input)', () => {
|
||||
const good = (): DockerExportManifest => ({
|
||||
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||
caseName: 'myproj',
|
||||
mode: 'full',
|
||||
engine: 'docker',
|
||||
image: 'codeman/agent:base',
|
||||
containerWorkdir: '/home/arkon/cases/myproj',
|
||||
network: 'bridge',
|
||||
createdAt: 1,
|
||||
codemanVersion: '1.4.1',
|
||||
mountCredentials: true,
|
||||
secretFree: true,
|
||||
checksums: {},
|
||||
});
|
||||
|
||||
it('accepts a well-formed manifest', () => {
|
||||
expect(() => validateImportManifest(good())).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a hostile engine (would select the probe/launch binary)', () => {
|
||||
expect(() => validateImportManifest({ ...good(), engine: 'rm' as never })).toThrow(/engine/);
|
||||
});
|
||||
|
||||
it('rejects shell metacharacters in containerWorkdir', () => {
|
||||
expect(() => validateImportManifest({ ...good(), containerWorkdir: '/w; rm -rf ~' })).toThrow(/containerWorkdir/);
|
||||
expect(() => validateImportManifest({ ...good(), containerWorkdir: 'relative/path' })).toThrow(/containerWorkdir/);
|
||||
});
|
||||
|
||||
it('rejects bad image refs, case names, networks, and schema versions', () => {
|
||||
expect(() => validateImportManifest({ ...good(), image: '-bad$(x)' })).toThrow(/image/);
|
||||
expect(() => validateImportManifest({ ...good(), caseName: '../evil' })).toThrow(/caseName/);
|
||||
expect(() => validateImportManifest({ ...good(), network: 'host' })).toThrow(/network/);
|
||||
expect(() => validateImportManifest({ ...good(), schemaVersion: 99 })).toThrow(/schema version/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLoadedImageRef', () => {
|
||||
it('parses "Loaded image ID: sha256:..."', () => {
|
||||
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
|
||||
|
||||
+185
-9
@@ -3,16 +3,19 @@
|
||||
* (src/docker-hosts.ts). Mirrors test/remote-hosts.test.ts. All docker IO no-ops
|
||||
* under VITEST, so probes return canned values and never spawn a real daemon.
|
||||
*/
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
agentImageBuildArgs,
|
||||
buildDockerBaseArgs,
|
||||
buildDockerCreateArgs,
|
||||
buildSeamlessClaudeConfig,
|
||||
checkDockerAvailable,
|
||||
checkDockerImagePresent,
|
||||
checkDockerTmuxAvailable,
|
||||
CLAUDE_JSON_SEED,
|
||||
containerApiUrl,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
DEFAULT_DOCKER_RESOURCES,
|
||||
@@ -20,11 +23,15 @@ import {
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
defaultDockerCommandForMode,
|
||||
ensureAgentBaseImage,
|
||||
hostGatewayAlias,
|
||||
persistDockerCaseClaudeSessionId,
|
||||
probeDockerCliVersion,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
resolveCredentialMounts,
|
||||
resolveClaudeJsonSeedMount,
|
||||
resolveDockerClaudeArtifacts,
|
||||
resolveDockerCredentialArtifacts,
|
||||
toSessionDocker,
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
@@ -61,6 +68,17 @@ describe('docker-hosts storage', () => {
|
||||
expect(await readDockerHosts(dir)).toEqual([]);
|
||||
expect(await readDockerCases(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists the last Claude conversation id keyed by container name', async () => {
|
||||
await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]);
|
||||
await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1');
|
||||
await persistDockerCaseClaudeSessionId(dir, 'custom-name', 'conv-2');
|
||||
await persistDockerCaseClaudeSessionId(dir, 'no-such-container', 'conv-3'); // no-op
|
||||
const cases = await readDockerCases(dir);
|
||||
expect(cases.find((c) => c.name === 'myproj')?.lastClaudeSessionId).toBe('conv-1');
|
||||
expect(cases.find((c) => c.name === 'other')?.lastClaudeSessionId).toBe('conv-2');
|
||||
expect(cases.some((c) => c.lastClaudeSessionId === 'conv-3')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('naming / display / defaults', () => {
|
||||
@@ -251,7 +269,7 @@ describe('buildDockerCreateArgs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCredentialMounts', () => {
|
||||
describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
|
||||
@@ -260,11 +278,169 @@ describe('resolveCredentialMounts', () => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('only mounts credential paths that exist', () => {
|
||||
mkdirSync(join(home, '.claude'), { recursive: true });
|
||||
const mounts = resolveCredentialMounts(home);
|
||||
expect(mounts).toContainEqual({ src: join(home, '.claude'), dst: '/home/agent/.claude' });
|
||||
expect(mounts.find((m) => m.dst.endsWith('.codex'))).toBeUndefined();
|
||||
it('NEVER whole-dir RW-mounts a credential store (no host pollution)', () => {
|
||||
mkdirSync(join(home, '.codex'), { recursive: true });
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
const { mounts } = resolveDockerCredentialArtifacts(home);
|
||||
// no wholesale RW mount at the store's HOME path
|
||||
expect(mounts.some((m) => m.dst === '/home/agent/.codex' && !m.readonly)).toBe(false);
|
||||
expect(mounts.some((m) => m.dst === '/home/agent/.gemini' && !m.readonly)).toBe(false);
|
||||
});
|
||||
|
||||
it('codex: shares sessions/+history.jsonl RW, seeds auth.json/config.toml', () => {
|
||||
mkdirSync(join(home, '.codex', 'sessions'), { recursive: true });
|
||||
writeFileSync(join(home, '.codex', 'history.jsonl'), '');
|
||||
writeFileSync(join(home, '.codex', 'auth.json'), '{}');
|
||||
writeFileSync(join(home, '.codex', 'config.toml'), '');
|
||||
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
|
||||
expect(mounts).toContainEqual({ src: join(home, '.codex', 'sessions'), dst: '/home/agent/.codex/sessions' });
|
||||
expect(mounts).toContainEqual({
|
||||
src: join(home, '.codex', 'history.jsonl'),
|
||||
dst: '/home/agent/.codex/history.jsonl',
|
||||
});
|
||||
const dests = seedCopies.map((s) => s.to);
|
||||
expect(dests).toContain('/home/agent/.codex/auth.json');
|
||||
expect(dests).toContain('/home/agent/.codex/config.toml');
|
||||
// seed copies of individual files are NOT recursive
|
||||
expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.codex')).every((s) => !s.recursive)).toBe(true);
|
||||
});
|
||||
|
||||
it('gemini/gcloud/opencode: whole-dir seed-copy (cp -a, recursive)', () => {
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
mkdirSync(join(home, '.config', 'gcloud'), { recursive: true });
|
||||
mkdirSync(join(home, '.config', 'opencode'), { recursive: true });
|
||||
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
|
||||
// each is mounted read-only at a seed staging path and cp -a'd into the container HOME
|
||||
expect(seedCopies).toContainEqual({
|
||||
from: '/home/agent/.codeman/cred-seeds/.gemini',
|
||||
to: '/home/agent/.gemini',
|
||||
recursive: true,
|
||||
});
|
||||
expect(seedCopies).toContainEqual({
|
||||
from: '/home/agent/.codeman/cred-seeds/.config-gcloud',
|
||||
to: '/home/agent/.config/gcloud',
|
||||
recursive: true,
|
||||
});
|
||||
expect(mounts.filter((m) => m.readonly && m.dst.includes('cred-seeds')).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('gates every artifact on existsSync (absent stores contribute nothing)', () => {
|
||||
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
|
||||
expect(mounts).toEqual([]);
|
||||
expect(seedCopies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDockerClaudeArtifacts (isolated claude state)', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('shares only projects (RW) and seeds .claude.json + credentials + settings + stats-cache', () => {
|
||||
mkdirSync(join(home, '.claude', 'projects'), { recursive: true });
|
||||
writeFileSync(join(home, '.claude.json'), JSON.stringify({ oauthAccount: { id: 1 } }));
|
||||
writeFileSync(join(home, '.claude', '.credentials.json'), '{"claudeAiOauth":{}}');
|
||||
writeFileSync(join(home, '.claude', 'settings.json'), JSON.stringify({ theme: 'dark' }));
|
||||
writeFileSync(join(home, '.claude', 'stats-cache.json'), '{}');
|
||||
|
||||
const art = resolveDockerClaudeArtifacts(home, 'codeman-case-x', '/ws/x');
|
||||
// transcripts shared RW (no readonly), whole ~/.claude never mounted
|
||||
expect(art.mounts).toContainEqual({ src: join(home, '.claude', 'projects'), dst: '/home/agent/.claude/projects' });
|
||||
expect(art.mounts.some((m) => m.dst === '/home/agent/.claude')).toBe(false);
|
||||
// credentials + settings + stats-cache + .claude.json seeded (copied into the container's own HOME)
|
||||
const dests = art.seedCopies.map((s) => s.to);
|
||||
expect(dests).toContain('/home/agent/.claude.json');
|
||||
expect(dests).toContain('/home/agent/.claude/.credentials.json');
|
||||
expect(dests).toContain('/home/agent/.claude/settings.json');
|
||||
expect(dests).toContain('/home/agent/.claude/stats-cache.json'); // restores the model/effort status indicator
|
||||
// the seed mounts are read-only
|
||||
expect(art.mounts.filter((m) => m.readonly).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('omits mounts/seeds for artifacts that do not exist', () => {
|
||||
const art = resolveDockerClaudeArtifacts(home, 'codeman-case-y', '/ws/y');
|
||||
expect(art.mounts).toEqual([]);
|
||||
expect(art.seedCopies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudeJsonSeedMount', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns a read-only seed mount when ~/.claude.json exists', () => {
|
||||
writeFileSync(join(home, '.claude.json'), '{}');
|
||||
expect(resolveClaudeJsonSeedMount(home)).toEqual({
|
||||
src: join(home, '.claude.json'),
|
||||
dst: CLAUDE_JSON_SEED,
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when ~/.claude.json is absent', () => {
|
||||
expect(resolveClaudeJsonSeedMount(home)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSeamlessClaudeConfig', () => {
|
||||
it('forces onboarding-complete + theme + workspace trust while preserving host fields', () => {
|
||||
const merged = buildSeamlessClaudeConfig({ oauthAccount: { id: 1 }, projects: {} }, '/ws/proj');
|
||||
expect(merged.hasCompletedOnboarding).toBe(true);
|
||||
expect(merged.theme).toBe('dark');
|
||||
expect(merged.oauthAccount).toEqual({ id: 1 }); // host auth account preserved
|
||||
const proj = (merged.projects as Record<string, Record<string, unknown>>)['/ws/proj'];
|
||||
expect(proj.hasTrustDialogAccepted).toBe(true);
|
||||
expect(proj.hasCompletedProjectOnboarding).toBe(true);
|
||||
expect(proj.projectOnboardingSeenCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps an existing theme and merges into an existing project entry', () => {
|
||||
const merged = buildSeamlessClaudeConfig(
|
||||
{ theme: 'light', projects: { '/ws/proj': { allowedTools: ['a'], projectOnboardingSeenCount: 5 } } },
|
||||
'/ws/proj',
|
||||
'dark'
|
||||
);
|
||||
expect(merged.theme).toBe('light'); // not overwritten when already set
|
||||
const proj = (merged.projects as Record<string, Record<string, unknown>>)['/ws/proj'];
|
||||
expect(proj.allowedTools).toEqual(['a']); // existing project fields kept
|
||||
expect(proj.hasTrustDialogAccepted).toBe(true);
|
||||
expect(proj.projectOnboardingSeenCount).toBe(5); // preserved, not reset to 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentImageBuildArgs', () => {
|
||||
it('builds the docker build argv in order', () => {
|
||||
expect(agentImageBuildArgs('/repo/docker/agent.Dockerfile', 'codeman/agent:base', '/repo')).toEqual([
|
||||
'build',
|
||||
'-f',
|
||||
'/repo/docker/agent.Dockerfile',
|
||||
'-t',
|
||||
'codeman/agent:base',
|
||||
'/repo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds --no-cache before the context dir when requested', () => {
|
||||
const args = agentImageBuildArgs('/df', 'img', '/ctx', true);
|
||||
expect(args).toContain('--no-cache');
|
||||
expect(args.indexOf('--no-cache')).toBeLessThan(args.indexOf('/ctx'));
|
||||
expect(args[args.length - 1]).toBe('/ctx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureAgentBaseImage (no-op under VITEST)', () => {
|
||||
it('reports the image as already present without spawning a build', async () => {
|
||||
const r = await ensureAgentBaseImage({ engine: 'docker' }, DEFAULT_AGENT_IMAGE);
|
||||
expect(r).toEqual({ ok: true, built: false, alreadyPresent: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,7 +454,7 @@ describe('daemon probes (no-op under VITEST)', () => {
|
||||
|
||||
it('checkDockerTmuxAvailable + image present are canned-true', async () => {
|
||||
expect((await checkDockerTmuxAvailable({ engine: 'docker', image: DEFAULT_AGENT_IMAGE })).ok).toBe(true);
|
||||
expect(await checkDockerImagePresent('docker', DEFAULT_AGENT_IMAGE)).toBe(true);
|
||||
expect(await checkDockerImagePresent({ engine: 'docker' }, DEFAULT_AGENT_IMAGE)).toBe(true);
|
||||
});
|
||||
|
||||
it('probeDockerCliVersion is undefined under test', async () => {
|
||||
|
||||
@@ -221,7 +221,10 @@ describe('Edge Cases and Error Handling', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Respawn controller not found');
|
||||
// respawn/stop now owner-gates via findSessionOrFail first (multi-user #18), so a
|
||||
// non-existent session id 404s as "Session ... not found" (same not-found semantics,
|
||||
// matching the sibling start/config/enable handlers).
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle updating config on non-existent session', async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
|
||||
// that removes a hide rule fails loudly (not silently). The attachments button is
|
||||
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
|
||||
// it's excluded from the default-visible enumeration rather than mobile-hidden.
|
||||
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager'];
|
||||
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer'];
|
||||
|
||||
function attrOf(openTag: string, name: string): string {
|
||||
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
|
||||
|
||||
@@ -21,6 +21,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
const sessions = new Map<string, MockSession>();
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Stateful backing for the global tab order (COD-131) so route tests can
|
||||
// assert that setSessionOrder() actually persists what the handler computed.
|
||||
let sessionOrder: string[] = [];
|
||||
|
||||
return {
|
||||
// -- SessionPort --
|
||||
sessions,
|
||||
@@ -65,6 +69,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
load: vi.fn(),
|
||||
incrementSessionsCreated: vi.fn(),
|
||||
setConfig: vi.fn(),
|
||||
getSessionOrder: vi.fn(() => sessionOrder),
|
||||
setSessionOrder: vi.fn((order: string[]) => {
|
||||
sessionOrder = order;
|
||||
}),
|
||||
getAggregateStats: vi.fn(() => ({ totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 })),
|
||||
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
|
||||
getDailyStats: vi.fn(() => []),
|
||||
|
||||
@@ -229,6 +229,8 @@ export class MockSession extends EventEmitter {
|
||||
color: this.color,
|
||||
mode: this.mode,
|
||||
muxName: this._muxName,
|
||||
pinned: this.pinned || undefined,
|
||||
pinnedAt: this.pinned ? (this.pinnedAt ?? undefined) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,6 +243,14 @@ export class MockSession extends EventEmitter {
|
||||
if (!enabled) this.autoResumeAt = null;
|
||||
});
|
||||
|
||||
/** Pin state (COD-139) */
|
||||
pinned: boolean = false;
|
||||
pinnedAt: number | null = null;
|
||||
setPinned = vi.fn((pinned: boolean) => {
|
||||
this.pinned = pinned;
|
||||
this.pinnedAt = pinned ? Date.now() : null;
|
||||
});
|
||||
|
||||
/** Check if session is busy */
|
||||
isBusy = vi.fn(() => false);
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @fileoverview Phase 2 multi-user auth integration tests (live server, port 3170+).
|
||||
*
|
||||
* Verifies the multi-user auth branch end to end: per-user Basic verify, cookie
|
||||
* identity, wrong-password / disabled-user rejection, the mustChangePassword
|
||||
* lockbox + self-service change, per-account rate limiting, and QR identity binding
|
||||
* (tunnel-manager unit level). Single-user auth is covered by auth-security.test.ts.
|
||||
*
|
||||
* Ports: 3170 (multi-user server), 3171 (rate-limit server).
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { TunnelManager } from '../src/tunnel-manager.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3170;
|
||||
const RATE_PORT = 3171;
|
||||
|
||||
function basic(user: string, pass: string): string {
|
||||
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
}
|
||||
|
||||
function cookieFrom(res: Response): string | null {
|
||||
const raw = res.headers.get('set-cookie');
|
||||
const m = raw?.match(/codeman_session=([^;]+)/);
|
||||
return m ? `codeman_session=${m[1]}` : null;
|
||||
}
|
||||
|
||||
let server: WebServer;
|
||||
let rateServer: WebServer;
|
||||
let dataDir: string;
|
||||
let spacesDir: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-data-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_PASSWORD',
|
||||
'CODEMAN_USERNAME',
|
||||
]) {
|
||||
saved[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
invalidateUsersCache();
|
||||
|
||||
await createUser({ username: 'alice', role: 'admin', password: 'alicepass1' });
|
||||
await createUser({ username: 'bob', role: 'user', password: 'bobpass123' });
|
||||
await createUser({ username: 'carol', role: 'user', password: 'carolpass1' });
|
||||
await createUser({ username: 'carol', role: 'user', password: 'x' }).catch(() => {}); // no-op dup guard
|
||||
await createUser({ username: 'dave', role: 'user', password: 'davepass12', mustChangePassword: true });
|
||||
// Disable carol after creation.
|
||||
const { updateUser } = await import('../src/user-store.js');
|
||||
await updateUser('carol', { disabled: true });
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
await rateServer?.stop().catch(() => {});
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
|
||||
describe('multi-user auth', () => {
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
const res = await fetch(url('/api/status'));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('authenticates a valid user and issues an identity cookie', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('alice', 'alicepass1') } });
|
||||
expect(res.status).toBe(200);
|
||||
const cookie = cookieFrom(res);
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
|
||||
expect(me.status).toBe(200);
|
||||
const body = await me.json();
|
||||
expect(body.data).toMatchObject({ username: 'alice', role: 'admin', mustChangePassword: false });
|
||||
});
|
||||
|
||||
it('reports role for a regular user', async () => {
|
||||
const res = await fetch(url('/api/me'), { headers: { Authorization: basic('bob', 'bobpass123') } });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toMatchObject({ username: 'bob', role: 'user' });
|
||||
});
|
||||
|
||||
it('rejects a wrong password', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('bob', 'wrongwrong') } });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a disabled user even with the correct password', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('carol', 'carolpass1') } });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('is case-insensitive on the username', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('ALICE', 'alicepass1') } });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('enforces the mustChangePassword lockbox and clears it on self-service change', async () => {
|
||||
// Basic auth as dave succeeds (cookie issued) but non-exempt routes 403.
|
||||
const authed = await fetch(url('/api/status'), { headers: { Authorization: basic('dave', 'davepass12') } });
|
||||
expect(authed.status).toBe(403);
|
||||
const body = await authed.json();
|
||||
expect(body.errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
|
||||
const cookie = cookieFrom(authed);
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
// /api/me is exempt.
|
||||
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
|
||||
expect(me.status).toBe(200);
|
||||
expect((await me.json()).data.mustChangePassword).toBe(true);
|
||||
|
||||
// Wrong current password is refused.
|
||||
const bad = await fetch(url('/api/me/password'), {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword: 'nope', newPassword: 'brandnew123' }),
|
||||
});
|
||||
expect(bad.status).toBe(403);
|
||||
|
||||
// Correct change clears the flag.
|
||||
const ok = await fetch(url('/api/me/password'), {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword: 'davepass12', newPassword: 'brandnew123' }),
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
// Same cookie now reaches a non-exempt route.
|
||||
const after = await fetch(url('/api/status'), { headers: { Cookie: cookie! } });
|
||||
expect(after.status).toBe(200);
|
||||
});
|
||||
|
||||
it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => {
|
||||
rateServer = new WebServer(RATE_PORT, false, true);
|
||||
await rateServer.start();
|
||||
const rurl = (p: string) => `http://localhost:${RATE_PORT}${p}`;
|
||||
|
||||
// Nine wrong passwords (one below the cap) are each rejected 401 — not throttled yet.
|
||||
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
|
||||
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `bad-${i}`) } });
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
// Finding #17: the CORRECT password must ALWAYS win (verified BEFORE the per-username
|
||||
// throttle) — the accumulated failures can never lock the account out — and success
|
||||
// clears the failure buckets. Previously this returned 429 (the DoS being fixed).
|
||||
const good = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'bobpass123') } });
|
||||
expect(good.status).toBe(200);
|
||||
// Self-heal: a fresh wrong attempt is 401 again (the counter was reset by the success).
|
||||
const afterReset = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'nope') } });
|
||||
expect(afterReset.status).toBe(401);
|
||||
|
||||
// Sustained wrong passwords ARE still throttled: 429 once the cap is reached.
|
||||
let limited = false;
|
||||
for (let i = 0; i < AUTH_FAILURE_MAX + 1 && !limited; i++) {
|
||||
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `x-${i}`) } });
|
||||
limited = res.status === 429;
|
||||
}
|
||||
expect(limited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QR token identity (tunnel-manager)', () => {
|
||||
it('binds a minted token to a user and returns it on consume (single-use)', () => {
|
||||
const tm = new TunnelManager();
|
||||
const code = tm.mintUserToken('alice');
|
||||
expect(code).toHaveLength(6);
|
||||
const first = tm.consumeTokenWithIdentity(code);
|
||||
expect(first).toEqual({ ok: true, username: 'alice' });
|
||||
// single-use
|
||||
expect(tm.consumeTokenWithIdentity(code)).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('unknown code is rejected', () => {
|
||||
const tm = new TunnelManager();
|
||||
expect(tm.consumeTokenWithIdentity('ZZZZZZ')).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @fileoverview Phase 3 ownership-scoping tests (live server, port 3172).
|
||||
*
|
||||
* Verifies multi-user isolation at the API level: case lists are disjoint per user,
|
||||
* a non-admin cannot read/kill another user's session, workingDir confinement +
|
||||
* shell gate + host-CRUD admin gate are enforced, and admins see everything.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
import { canAccessOwned, findSessionOrFail, sessionCapacityState } from '../src/web/route-helpers.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3172;
|
||||
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||
|
||||
let server: WebServer;
|
||||
let dataDir: string;
|
||||
let spacesDir: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
|
||||
// Route returns are wrapped in the {success,data} envelope; unwrap to the payload.
|
||||
async function getJson(p: string, headers: Record<string, string>): Promise<unknown> {
|
||||
const body = await (await fetch(url(p), { headers })).json();
|
||||
return body && typeof body === 'object' && 'data' in body ? (body as { data: unknown }).data : body;
|
||||
}
|
||||
const alice = { Authorization: basic('alice', 'alicepass1') };
|
||||
const bob = { Authorization: basic('bob', 'bobpass1234') };
|
||||
const admin = { Authorization: basic('root', 'rootpass123') };
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-data-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_PASSWORD',
|
||||
'CODEMAN_USERNAME',
|
||||
]) {
|
||||
saved[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
invalidateUsersCache();
|
||||
|
||||
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||
await createUser({ username: 'alice', role: 'user', password: 'alicepass1' });
|
||||
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('case scoping', () => {
|
||||
it('creates cases in per-user spaces and lists them disjointly', async () => {
|
||||
const mk = await fetch(url('/api/cases'), {
|
||||
method: 'POST',
|
||||
headers: { ...alice, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'aliceproj' }),
|
||||
});
|
||||
expect(mk.status).toBe(200);
|
||||
|
||||
// Case folder is under alice's space.
|
||||
expect(await exists(path.join(spacesDir, 'alice', 'cases', 'aliceproj'))).toBe(true);
|
||||
|
||||
const aliceList = (await getJson('/api/cases', alice)) as Array<{ name: string }>;
|
||||
expect(aliceList.map((c) => c.name)).toContain('aliceproj');
|
||||
|
||||
const bobList = (await getJson('/api/cases', bob)) as Array<{ name: string }>;
|
||||
expect(bobList.map((c) => c.name)).not.toContain('aliceproj');
|
||||
});
|
||||
});
|
||||
|
||||
describe('host CRUD is admin-only', () => {
|
||||
it('rejects a non-admin defining a docker host', async () => {
|
||||
const res = await fetch(url('/api/docker-hosts'), {
|
||||
method: 'POST',
|
||||
headers: { ...bob, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: 'h1', label: 'x', image: 'codeman/agent:base' }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows an admin to list docker hosts', async () => {
|
||||
const res = await fetch(url('/api/docker-hosts'), { headers: admin });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session creation gates', () => {
|
||||
it('confines a non-admin workingDir to their space', async () => {
|
||||
const foreign = path.join(spacesDir, 'alice', 'cases', 'aliceproj');
|
||||
const res = await fetch(url('/api/sessions'), {
|
||||
method: 'POST',
|
||||
headers: { ...bob, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: foreign }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses shell mode for a non-granted user', async () => {
|
||||
const mine = path.join(spacesDir, 'bob', 'cases');
|
||||
await fs.mkdir(mine, { recursive: true });
|
||||
const res = await fetch(url('/api/sessions'), {
|
||||
method: 'POST',
|
||||
headers: { ...bob, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: mine, mode: 'shell' }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// The session-scoping logic (findSessionOrFail owner check, list filter, per-user
|
||||
// cap) is tested directly against the helpers under the same multi-user env, since
|
||||
// real session spawning is no-op'd in test mode and does not durably populate the
|
||||
// live map. These are the exact functions every session route uses.
|
||||
describe('session-scoping helpers (multi-user)', () => {
|
||||
const fakeSession = (owner?: string) => ({ owner }) as unknown as import('../src/session.js').Session;
|
||||
const ctxWith = (map: Map<string, unknown>) => ({ sessions: map }) as never;
|
||||
const reqAs = (username: string, role: 'admin' | 'user') => ({ authUser: { username, role } }) as never;
|
||||
|
||||
it('canAccessOwned isolates non-admins to their own', () => {
|
||||
expect(canAccessOwned({ username: 'alice', role: 'user' }, 'alice')).toBe(true);
|
||||
expect(canAccessOwned({ username: 'alice', role: 'user' }, 'bob')).toBe(false);
|
||||
expect(canAccessOwned({ username: 'alice', role: 'user' }, undefined)).toBe(false);
|
||||
expect(canAccessOwned({ username: 'root', role: 'admin' }, 'bob')).toBe(true);
|
||||
});
|
||||
|
||||
it('findSessionOrFail 404s a foreign session for a non-admin, returns it for owner/admin', () => {
|
||||
const map = new Map<string, unknown>([['s1', fakeSession('alice')]]);
|
||||
expect(() => findSessionOrFail(ctxWith(map), 's1', reqAs('bob', 'user'))).toThrow();
|
||||
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('alice', 'user'))).toBeDefined();
|
||||
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('root', 'admin'))).toBeDefined();
|
||||
});
|
||||
|
||||
it('per-user session cap counts only the owner sessions', () => {
|
||||
const map = new Map<string, unknown>([
|
||||
['a', fakeSession('alice')],
|
||||
['b', fakeSession('alice')],
|
||||
['c', fakeSession('bob')],
|
||||
]);
|
||||
process.env.CODEMAN_MAX_SESSIONS_PER_USER = '2';
|
||||
expect(sessionCapacityState(map as never, 'alice').atUserCap).toBe(true);
|
||||
expect(sessionCapacityState(map as never, 'bob').atUserCap).toBe(false);
|
||||
delete process.env.CODEMAN_MAX_SESSIONS_PER_USER;
|
||||
});
|
||||
});
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @fileoverview COD-108 — remote-session auto-reconnect watcher tests.
|
||||
*
|
||||
* Three layers, all tmux-safe (under VITEST TmuxManager no-ops real tmux and
|
||||
* `isPaneDead` returns false, so live behavior is driven via injected stubs):
|
||||
*
|
||||
* (a) PURE backoff schedule — attempt→delay, cap, reset-on-success.
|
||||
* (b) PURE eligibility decision — dead remote pane + due → emit; guarded →
|
||||
* never; non-remote / pane-alive / not-due → skip; over-cap → exhaust.
|
||||
* (c) MANAGER integration — drive ticks with a stubbed pane-death signal +
|
||||
* controllable clock and assert the emit → backoff → exhausted progression,
|
||||
* and that a guarded (intentionally-killed) session emits nothing.
|
||||
*
|
||||
* Port: N/A (no server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
BACKOFF_SCHEDULE_MS,
|
||||
MAX_RECONNECT_ATTEMPTS,
|
||||
reconnectDelayForAttempt,
|
||||
isExhausted,
|
||||
freshReconnectState,
|
||||
advanceBackoff,
|
||||
resetReconnectState,
|
||||
decideReconnect,
|
||||
} from '../src/remote-reconnect.js';
|
||||
import type { ReconnectSessionView } from '../src/remote-reconnect.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
const REMOTE: SessionRemote = {
|
||||
hostId: 'aa-desktop',
|
||||
label: 'aa-desktop',
|
||||
host: 'aa-desktop',
|
||||
username: 'aakhter',
|
||||
remotePath: '/home/aakhter',
|
||||
owned: true,
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (a) PURE backoff schedule
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('reconnect backoff schedule (pure)', () => {
|
||||
it('returns the documented bounded exponential delays per attempt', () => {
|
||||
expect(reconnectDelayForAttempt(1)).toBe(5_000);
|
||||
expect(reconnectDelayForAttempt(2)).toBe(15_000);
|
||||
expect(reconnectDelayForAttempt(3)).toBe(45_000);
|
||||
expect(reconnectDelayForAttempt(4)).toBe(120_000);
|
||||
expect(reconnectDelayForAttempt(5)).toBe(300_000);
|
||||
expect(reconnectDelayForAttempt(6)).toBe(300_000);
|
||||
});
|
||||
|
||||
it('clamps below-range and above-range attempts to the schedule bounds', () => {
|
||||
expect(reconnectDelayForAttempt(0)).toBe(BACKOFF_SCHEDULE_MS[0]);
|
||||
expect(reconnectDelayForAttempt(-3)).toBe(BACKOFF_SCHEDULE_MS[0]);
|
||||
expect(reconnectDelayForAttempt(99)).toBe(BACKOFF_SCHEDULE_MS[BACKOFF_SCHEDULE_MS.length - 1]);
|
||||
});
|
||||
|
||||
it('flags exhaustion only at/after the cap', () => {
|
||||
expect(isExhausted(0)).toBe(false);
|
||||
expect(isExhausted(MAX_RECONNECT_ATTEMPTS - 1)).toBe(false);
|
||||
expect(isExhausted(MAX_RECONNECT_ATTEMPTS)).toBe(true);
|
||||
expect(isExhausted(MAX_RECONNECT_ATTEMPTS + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('advanceBackoff increments attempts and schedules next-eligible from now (immutable)', () => {
|
||||
const s0 = freshReconnectState();
|
||||
const s1 = advanceBackoff(s0, 1_000);
|
||||
expect(s0.attempts).toBe(0); // input untouched
|
||||
expect(s1.attempts).toBe(1);
|
||||
expect(s1.nextEligibleAt).toBe(1_000 + 5_000);
|
||||
expect(s1.exhausted).toBe(false);
|
||||
|
||||
const s2 = advanceBackoff(s1, 10_000);
|
||||
expect(s2.attempts).toBe(2);
|
||||
expect(s2.nextEligibleAt).toBe(10_000 + 15_000);
|
||||
});
|
||||
|
||||
it('reaches the attempt cap after the scheduled number of advances', () => {
|
||||
let s = freshReconnectState();
|
||||
let now = 0;
|
||||
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) {
|
||||
s = advanceBackoff(s, now);
|
||||
now += reconnectDelayForAttempt(s.attempts);
|
||||
}
|
||||
expect(s.attempts).toBe(MAX_RECONNECT_ATTEMPTS);
|
||||
// advanceBackoff does not itself set `exhausted`; the watcher decides that
|
||||
// on the following tick via isExhausted(attempts).
|
||||
expect(isExhausted(s.attempts)).toBe(true);
|
||||
});
|
||||
|
||||
it('reset-on-success returns to a fresh, immediately-eligible state', () => {
|
||||
const advanced = advanceBackoff(advanceBackoff(freshReconnectState(), 0), 100);
|
||||
expect(advanced.attempts).toBe(2);
|
||||
const reset = resetReconnectState();
|
||||
expect(reset.attempts).toBe(0);
|
||||
expect(reset.nextEligibleAt).toBe(0);
|
||||
expect(reset.exhausted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (b) PURE eligibility decision
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decideReconnect (pure eligibility)', () => {
|
||||
const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true };
|
||||
|
||||
it('emits for a dead remote pane that is not guarded and is due', () => {
|
||||
const action = decideReconnect({
|
||||
session: deadRemote,
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'emit', attempt: 1 });
|
||||
});
|
||||
|
||||
it('NEVER reconnects a guarded (intentionally killed/detached) session', () => {
|
||||
const action = decideReconnect({
|
||||
session: deadRemote,
|
||||
state: freshReconnectState(),
|
||||
guarded: true,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'guarded' });
|
||||
});
|
||||
|
||||
it('skips non-remote sessions', () => {
|
||||
const action = decideReconnect({
|
||||
session: { sessionId: 's1', isRemote: false, paneDead: true },
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'not-remote' });
|
||||
});
|
||||
|
||||
it('skips when the pane is alive', () => {
|
||||
const action = decideReconnect({
|
||||
session: { sessionId: 's1', isRemote: true, paneDead: false },
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'pane-alive' });
|
||||
});
|
||||
|
||||
it('skips when the kill-switch is off', () => {
|
||||
const action = decideReconnect({
|
||||
session: deadRemote,
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: false,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'disabled' });
|
||||
});
|
||||
|
||||
it('skips when not yet due (within backoff window)', () => {
|
||||
const state = advanceBackoff(freshReconnectState(), 0); // nextEligibleAt = 5000
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 4_999 });
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'not-due' });
|
||||
});
|
||||
|
||||
it('emits again once the backoff window elapses', () => {
|
||||
const state = advanceBackoff(freshReconnectState(), 0); // nextEligibleAt = 5000
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 5_000 });
|
||||
expect(action).toEqual({ kind: 'emit', attempt: 2 });
|
||||
});
|
||||
|
||||
it('skips while a reconnect is already in flight (no stacked respawns)', () => {
|
||||
const state = { ...freshReconnectState(), inFlight: true };
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 10_000 });
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'in-flight' });
|
||||
});
|
||||
|
||||
it('exhausts once the attempt cap is reached', () => {
|
||||
const state = { ...freshReconnectState(), attempts: MAX_RECONNECT_ATTEMPTS, nextEligibleAt: 0 };
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 1_000_000 });
|
||||
expect(action).toEqual({ kind: 'exhaust' });
|
||||
});
|
||||
|
||||
it('stays quiet after exhaustion has been recorded', () => {
|
||||
const state = { ...freshReconnectState(), attempts: MAX_RECONNECT_ATTEMPTS, exhausted: true };
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 1_000_000 });
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'exhausted' });
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (c) MANAGER integration — drive ticks with a stubbed pane-death + clock
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TmuxManager remote reconnect watcher (integration)', () => {
|
||||
let manager: TmuxManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new TmuxManager();
|
||||
});
|
||||
|
||||
function registerRemote(sessionId: string): void {
|
||||
manager.registerSession({
|
||||
sessionId,
|
||||
muxName: `codeman-${sessionId}`,
|
||||
pid: 4242,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/aakhter',
|
||||
mode: 'shell',
|
||||
attached: true,
|
||||
remote: REMOTE,
|
||||
});
|
||||
}
|
||||
|
||||
it('emits remoteSessionDropped when a dead remote pane is observed, then backs off and exhausts', () => {
|
||||
registerRemote('aaaa1111');
|
||||
// Force the watcher to see a dead pane regardless of test-mode isPaneDead.
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
|
||||
const dropped: Array<{ sessionId: string; attempt: number }> = [];
|
||||
const exhausted: Array<{ sessionId: string }> = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
manager.on('remoteReconnectExhausted', (d) => exhausted.push(d));
|
||||
|
||||
// Drive ticks with a controllable clock. Each emit marks the session
|
||||
// in-flight; a failed reattach (host still down) releases it via
|
||||
// noteRemoteReconnect(false), mirroring the real server loop.
|
||||
let now = 0;
|
||||
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS + 3; i++) {
|
||||
manager.runRemoteReconnectTick(now, /* enabled */ true);
|
||||
manager.noteRemoteReconnect('aaaa1111', false); // reattach failed → clear in-flight
|
||||
// jump the clock past the just-scheduled backoff window
|
||||
now += BACKOFF_SCHEDULE_MS[Math.min(i, BACKOFF_SCHEDULE_MS.length - 1)] + 1;
|
||||
}
|
||||
|
||||
expect(dropped.map((d) => d.attempt)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
expect(dropped.every((d) => d.sessionId === 'aaaa1111')).toBe(true);
|
||||
expect(exhausted).toEqual([{ sessionId: 'aaaa1111' }]); // fired exactly once
|
||||
});
|
||||
|
||||
it('emits nothing for a guarded (intentionally killed) session', () => {
|
||||
registerRemote('bbbb2222');
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
manager.guardRemoteReconnect('bbbb2222'); // simulate killSession/detach guard
|
||||
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
|
||||
let now = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.runRemoteReconnectTick(now, true);
|
||||
now += 600_000;
|
||||
}
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it('resets backoff on a successful reattach (noteRemoteReconnect)', () => {
|
||||
registerRemote('cccc3333');
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
|
||||
const dropped: Array<{ attempt: number }> = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
|
||||
manager.runRemoteReconnectTick(0, true); // emit attempt 1
|
||||
manager.noteRemoteReconnect('cccc3333', true); // reattach succeeded → reset
|
||||
manager.runRemoteReconnectTick(1, true); // immediately eligible again → attempt 1
|
||||
|
||||
expect(dropped.map((d) => d.attempt)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('does nothing when the kill-switch (enabled=false) is off', () => {
|
||||
registerRemote('dddd4444');
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
|
||||
manager.runRemoteReconnectTick(0, false);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears per-session reconnect/guard state when the session is removed', () => {
|
||||
registerRemote('eeee5555');
|
||||
manager.guardRemoteReconnect('eeee5555');
|
||||
manager.clearRemoteReconnectState('eeee5555');
|
||||
// After clearing the guard, a fresh dead-pane observation should emit again.
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
manager.runRemoteReconnectTick(0, true);
|
||||
expect(dropped).toEqual([{ sessionId: 'eeee5555', attempt: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @fileoverview COD-105 — discover & attach existing remote tmux sessions.
|
||||
*
|
||||
* Phase 2 of the remote-tmux arc (builds on COD-104 durable remote sessions +
|
||||
* COD-107 connection args). These tests are tmux-safe / ssh-safe: they exercise
|
||||
* the PURE parse helper, the pure attach-command builder, and the killSession
|
||||
* ownership gate — none open a real ssh connection or a real tmux server.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseRemoteSessionList } from '../src/remote-hosts.js';
|
||||
import { buildRemoteAttachCommand } from '../src/tmux-manager.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
const baseRemote: SessionRemote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
};
|
||||
|
||||
describe('COD-105 parseRemoteSessionList', () => {
|
||||
it('parses tab-delimited -F output and coerces fields', () => {
|
||||
const stdout = 'codeman-disco1\t0\t1700000000\t1\n' + 'codeman-abcd1234\t1\t1700000123\t3\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1700000000, windows: 1 },
|
||||
{ name: 'codeman-abcd1234', attached: true, attachedClients: 1, created: 1700000123, windows: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses the LITERAL backslash-t separator the remote tmux actually emits', () => {
|
||||
// tmux next-3.7's `-F "…\t…"` does NOT expand \t — it prints a literal
|
||||
// backslash-t (verified on aa-desktop). The parser must split on that.
|
||||
const stdout = 'codeman-disco1\\t0\\t1781362858\\t1\n' + 'codeman-real\\t1\\t1781329905\\t2\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1781362858, windows: 1 },
|
||||
{ name: 'codeman-real', attached: true, attachedClients: 1, created: 1781329905, windows: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps only codeman-* sessions, dropping foreign tmux sessions', () => {
|
||||
const stdout = 'work\t1\t1700000000\t2\n' + 'codeman-keep\t0\t1700000001\t1\n' + 'scratch\t0\t1700000002\t1\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list.map((s) => s.name)).toEqual(['codeman-keep']);
|
||||
});
|
||||
|
||||
it('returns [] for empty / whitespace output (the no-sessions case)', () => {
|
||||
expect(parseRemoteSessionList('')).toEqual([]);
|
||||
expect(parseRemoteSessionList(' \n \n')).toEqual([]);
|
||||
});
|
||||
|
||||
it('tolerates malformed lines (missing columns) by skipping them', () => {
|
||||
const stdout = 'codeman-ok\t0\t1700000000\t1\n' + 'codeman-bad\tnotanumber\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-ok', attached: false, attachedClients: 0, created: 1700000000, windows: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-105 buildRemoteAttachCommand', () => {
|
||||
it('emits ssh -t <target> tmux -L codeman attach -t <session>', () => {
|
||||
const command = buildRemoteAttachCommand(baseRemote, 'codeman-disco1');
|
||||
expect(command).toContain('ssh');
|
||||
expect(command).toContain('BatchMode=yes');
|
||||
expect(command).toContain('-t');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
// The tmux invocation is nested-quoted (inner session name escaped, whole
|
||||
// invocation re-escaped as one ssh arg). Assert the stable prefix here; the
|
||||
// exact re-parsed token is verified by the argv-reparse test below.
|
||||
expect(command).toContain('tmux -L codeman attach -t ');
|
||||
expect(command).toContain('codeman-disco1');
|
||||
});
|
||||
|
||||
it('threads the COD-107 connection args (port / identity / proxy) into the ssh invocation', () => {
|
||||
const command = buildRemoteAttachCommand(
|
||||
{ ...baseRemote, port: 2222, identityFile: '/keys/id_ed25519', socksProxy: '127.0.0.1:1080' },
|
||||
'codeman-disco1'
|
||||
);
|
||||
expect(command).toContain('-p 2222');
|
||||
expect(command).toContain("-i '/keys/id_ed25519'");
|
||||
expect(command).toContain('ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p');
|
||||
// Port/identity/proxy belong to ssh, ahead of the target.
|
||||
expect(command).toMatch(/ssh[\s\S]*-p 2222[\s\S]*ubuntu@10\.0\.0\.42/);
|
||||
});
|
||||
|
||||
it('shell-escapes the session name so it stays a single token', () => {
|
||||
const command = buildRemoteAttachCommand(baseRemote, 'codeman-disco1');
|
||||
// Re-parse: stub ssh to dump argv, confirm the trailing tmux invocation is one arg.
|
||||
const dumpArgs = (name: string, prefix: string) =>
|
||||
`${name}() { for a in "$@"; do printf '${prefix}:%s\\n' "$a"; done; }`;
|
||||
const out = execFileSync('/bin/sh', ['-c', `${dumpArgs('ssh', 'A')}\n${command}`], { encoding: 'utf8' });
|
||||
const sshArgs = out
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('A:'))
|
||||
.map((l) => l.slice(2));
|
||||
expect(sshArgs).toContain('ubuntu@10.0.0.42');
|
||||
const tmuxArg = sshArgs.find((a) => a.includes('attach'));
|
||||
expect(tmuxArg).toBe("tmux -L codeman attach -t 'codeman-disco1'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-105 killSession ownership gate (detach-not-kill)', () => {
|
||||
it('never issues a remote tmux kill-session for a non-owned remote session', async () => {
|
||||
const mgr = new TmuxManager();
|
||||
// Register a discovered+attached (non-owned) remote session.
|
||||
mgr.registerSession({
|
||||
sessionId: 'disco-1',
|
||||
muxName: 'codeman-disco-1',
|
||||
pid: 0,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'shell',
|
||||
attached: false,
|
||||
remote: { ...baseRemote, owned: false },
|
||||
});
|
||||
|
||||
// killSession under VITEST is in-memory only (IS_TEST_MODE), so it physically
|
||||
// cannot run a remote kill-session. We assert the contract: the session's
|
||||
// ownership flag is the gate, and tearing it down removes only local state.
|
||||
const session = mgr.getSession('disco-1');
|
||||
expect(session?.remote?.owned).toBe(false);
|
||||
|
||||
const ok = await mgr.killSession('disco-1');
|
||||
expect(ok).toBe(true);
|
||||
// Local tracking removed; no remote kill was (or could be) issued.
|
||||
expect(mgr.getSession('disco-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats COD-104 launched remote sessions as owned by default', () => {
|
||||
const mgr = new TmuxManager();
|
||||
mgr.registerSession({
|
||||
sessionId: 'owned-1',
|
||||
muxName: 'codeman-owned-1',
|
||||
pid: 0,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'shell',
|
||||
attached: false,
|
||||
remote: { ...baseRemote, owned: true },
|
||||
});
|
||||
expect(mgr.getSession('owned-1')?.remote?.owned).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildRemoteKillCommand } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
// COD-109 — terminate an OWNED durable remote tmux session by propagating
|
||||
// `kill-session` to the remote host. Since COD-104 an owned remote session lives
|
||||
// in the dedicated `-L codeman-remote` tmux server on the host and outlives the
|
||||
// local ssh pane, so ending a session we OWN must reach the remote socket.
|
||||
//
|
||||
// The owned-kill command builder was consolidated upstream (PR #145) into the
|
||||
// `{ remote, sessionId }` form, which derives the durable session name and kills
|
||||
// on the dedicated `codeman-remote` socket (matching buildRemoteLaunchCommand).
|
||||
// This suite pins that builder's contract; the killSession integration
|
||||
// (owned-only, after COD-105's non-owned detach-only early-return) is exercised
|
||||
// against the real remote.
|
||||
describe('COD-109 buildRemoteKillCommand (owned durable remote kill)', () => {
|
||||
const base: SessionRemote = {
|
||||
hostId: 'h',
|
||||
label: 'aa',
|
||||
host: '192.168.55.170',
|
||||
username: 'aakht',
|
||||
remotePath: '/tmp',
|
||||
};
|
||||
|
||||
it('kills the durable session on the dedicated codeman-remote socket (no ssh -t — non-interactive)', () => {
|
||||
const cmd = buildRemoteKillCommand({ remote: base, sessionId: 'abc12345def' });
|
||||
expect(cmd.startsWith('ssh -o BatchMode=yes ')).toBe(true);
|
||||
expect(cmd).toContain('aakht@192.168.55.170');
|
||||
// Owned sessions launch on `-L codeman-remote`; the kill MUST target the same socket.
|
||||
expect(cmd).toContain('tmux -L codeman-remote kill-session -t');
|
||||
// Deterministic session name derived from the sessionId (codeman-ssh-<first 8>).
|
||||
expect(cmd).toContain('codeman-ssh-abc12345');
|
||||
// kill-session needs no PTY — must NOT request the ssh `-t` flag (attach uses
|
||||
// `ssh -o BatchMode=yes -t …`; kill must not). The ` -t ` inside the quoted
|
||||
// `kill-session -t <name>` is the tmux target flag, which is expected.
|
||||
expect(cmd).not.toContain('-o BatchMode=yes -t');
|
||||
});
|
||||
|
||||
it('shares the default ConnectTimeout so an unreachable host fails fast (never blocks kill)', () => {
|
||||
const cmd = buildRemoteKillCommand({ remote: base, sessionId: 'abc12345def' });
|
||||
expect(cmd).toContain('-o ConnectTimeout=10');
|
||||
});
|
||||
|
||||
it('reuses the COD-107 connection options (port / identity / SOCKS proxy)', () => {
|
||||
const cmd = buildRemoteKillCommand({
|
||||
remote: { ...base, port: 2222, identityFile: '~/.ssh/remote_ed25519', socksProxy: '127.0.0.1:1080' },
|
||||
sessionId: 'abc12345def',
|
||||
});
|
||||
expect(cmd).toContain('-p 2222');
|
||||
expect(cmd).toMatch(/-i '.*\/\.ssh\/remote_ed25519'/);
|
||||
expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
});
|
||||
|
||||
it('shell-escapes the derived session name so metachars stay one token', () => {
|
||||
const cmd = buildRemoteKillCommand({ remote: base, sessionId: "x'; rm -rf /" });
|
||||
expect(cmd).not.toMatch(/rm -rf \/\s*$/); // not a bare trailing command
|
||||
expect(cmd).toContain('kill-session -t');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
|
||||
import { parseRemoteSessionList } from '../src/remote-hosts.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
// COD-106 — shared/collaborative remote sessions: window-size policy so concurrent
|
||||
// clients don't fight, and a client-count surfaced for the "shared · N" badge.
|
||||
describe('COD-106 shared remote sessions', () => {
|
||||
const remote: SessionRemote = {
|
||||
hostId: 'h',
|
||||
label: 'aa',
|
||||
host: '192.168.55.170',
|
||||
username: 'aakht',
|
||||
remotePath: '/tmp',
|
||||
commands: { shell: 'exec bash -l' },
|
||||
};
|
||||
|
||||
it('launch command sets window-size latest (so multi-client attach does not clamp to smallest)', () => {
|
||||
const cmd = buildRemoteLaunchCommand({ mode: 'shell', remote, sessionId: 'cod106aaa' });
|
||||
// Per-session scoped on the dedicated `codeman-remote` socket (PR #145 hardening).
|
||||
expect(cmd).toContain('set -t codeman-ssh-cod106aa window-size latest');
|
||||
// still has the COD-104 config (no regression)
|
||||
expect(cmd).toContain('set -t codeman-ssh-cod106aa status off');
|
||||
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
|
||||
});
|
||||
|
||||
it('parses session_attached as a CLIENT COUNT (>1 = shared)', () => {
|
||||
const rows = parseRemoteSessionList(
|
||||
['codeman-solo\\t1\\t100\\t1', 'codeman-shared\\t2\\t200\\t3', 'codeman-idle\\t0\\t300\\t1'].join('\n')
|
||||
);
|
||||
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
|
||||
expect(byName['codeman-solo'].attachedClients).toBe(1);
|
||||
expect(byName['codeman-solo'].attached).toBe(true);
|
||||
expect(byName['codeman-shared'].attachedClients).toBe(2); // shared
|
||||
expect(byName['codeman-shared'].attached).toBe(true);
|
||||
expect(byName['codeman-idle'].attachedClients).toBe(0);
|
||||
expect(byName['codeman-idle'].attached).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -157,6 +157,8 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
// COD-106 — shared/collaborative sizing, per-session scoped (never -g).
|
||||
`set -t ${remoteName} window-size latest`,
|
||||
].join(' \\; ');
|
||||
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
|
||||
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview COD-143 — resuming a session from the Session Manager must retain its
|
||||
* original tab name, not synthesize a fresh `w<N>-<dir>` name every time.
|
||||
*
|
||||
* Root cause: `resumeHistorySession(sessionId, workingDir)` ignored the row's `name` and
|
||||
* always built `w<N>-<dir>` from the working dir. The fix threads the name through and
|
||||
* extracts the choice into a pure `_resolveResumeName(existingName, workingDir)` helper:
|
||||
* prefer a non-empty existing name; otherwise generate the next free `w<N>-<dir>` by
|
||||
* scanning open sessions' names.
|
||||
*
|
||||
* This pins the helper's contract:
|
||||
* 1. a non-empty existing name is returned verbatim (custom name retained),
|
||||
* 2. a missing/empty/whitespace name falls back to `w<N>-<dir>`,
|
||||
* 3. the generated number is the next free w-index across `this.sessions`,
|
||||
* 4. the generated dir segment is the basename of workingDir (or `session` when empty).
|
||||
*
|
||||
* Loaded via `vm` against a stub `CodemanApp` (no jsdom — same harness as
|
||||
* file-browser-reveal.test.ts / connection-indicator.test.ts). terminal-ui.js does
|
||||
* `Object.assign(CodemanApp.prototype, {...})` at module-eval, so we capture the real
|
||||
* `_resolveResumeName` off the prototype and invoke it against a minimal host whose
|
||||
* `sessions` is a Map.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** Eval the shipping terminal-ui.js into a vm with a stub CodemanApp, return its prototype. */
|
||||
function loadTerminalUiPrototype(): Record<string, (...args: unknown[]) => unknown> {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
document: { addEventListener: vi.fn(), getElementById: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
return (context as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
|
||||
}
|
||||
|
||||
const proto = loadTerminalUiPrototype();
|
||||
|
||||
/** Minimal host carrying the real `_resolveResumeName` + a sessions Map. */
|
||||
function makeApp(sessionNames: string[] = []) {
|
||||
const sessions = new Map<string, { name: string }>();
|
||||
sessionNames.forEach((name, i) => sessions.set(`s${i}`, { name }));
|
||||
return {
|
||||
sessions,
|
||||
_resolveResumeName: proto._resolveResumeName as (existingName: unknown, workingDir: unknown) => string,
|
||||
};
|
||||
}
|
||||
|
||||
describe('COD-143 _resolveResumeName', () => {
|
||||
it('returns a non-empty existing name verbatim (custom name retained)', () => {
|
||||
const app = makeApp(['w1-foo', 'w2-bar']);
|
||||
expect(app._resolveResumeName.call(app, 'my-custom-tab', '/home/me/proj')).toBe('my-custom-tab');
|
||||
});
|
||||
|
||||
it('falls back to w<N>-<dir> when no name is given', () => {
|
||||
const app = makeApp([]);
|
||||
expect(app._resolveResumeName.call(app, undefined, '/home/me/proj')).toBe('w1-proj');
|
||||
});
|
||||
|
||||
it('treats empty / whitespace names as no-name (falls back)', () => {
|
||||
const app = makeApp([]);
|
||||
expect(app._resolveResumeName.call(app, '', '/a/b/widgets')).toBe('w1-widgets');
|
||||
expect(app._resolveResumeName.call(app, ' ', '/a/b/widgets')).toBe('w1-widgets');
|
||||
});
|
||||
|
||||
it('generated w-number is the next free index across open sessions', () => {
|
||||
const app = makeApp(['w1-foo', 'w3-bar', 'plain-name']);
|
||||
// highest w<N> is 3 → next is 4
|
||||
expect(app._resolveResumeName.call(app, null, '/x/y/svc')).toBe('w4-svc');
|
||||
});
|
||||
|
||||
it('uses "session" as the dir segment when workingDir is empty', () => {
|
||||
const app = makeApp([]);
|
||||
expect(app._resolveResumeName.call(app, '', '')).toBe('w1-session');
|
||||
});
|
||||
|
||||
it('does not let a generated fallback clobber an explicit name even when sessions exist', () => {
|
||||
const app = makeApp(['w1-foo', 'w2-bar']);
|
||||
expect(app._resolveResumeName.call(app, 'keepme', '/p/q')).toBe('keepme');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @fileoverview COD-105 — GET /api/remote-hosts/:hostId/sessions discovery endpoint.
|
||||
*
|
||||
* The endpoint reads the saved host config by id, runs listRemoteCodemanSessions
|
||||
* (ssh-guarded under VITEST), and returns the discovered sessions in the
|
||||
* ApiResponse envelope. We mock the remote-hosts module so the test controls the
|
||||
* host record and the session list WITHOUT any real ssh / filesystem.
|
||||
*
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import type { RemoteHost, RemoteSessionInfo } from '../../src/types.js';
|
||||
|
||||
// Mock the remote-hosts module: control readRemoteHosts + listRemoteCodemanSessions.
|
||||
const mockHosts: RemoteHost[] = [];
|
||||
let mockSessions: RemoteSessionInfo[] = [];
|
||||
let lastListArg: unknown = undefined;
|
||||
|
||||
vi.mock('../../src/remote-hosts.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
readRemoteHosts: vi.fn(async () => mockHosts),
|
||||
readRemoteCases: vi.fn(async () => []),
|
||||
listRemoteCodemanSessions: vi.fn(async (remote: unknown) => {
|
||||
lastListArg = remote;
|
||||
return mockSessions;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/templates/claude-md.js', () => ({
|
||||
generateClaudeMd: vi.fn(() => '# CLAUDE.md'),
|
||||
}));
|
||||
vi.mock('../../src/hooks-config.js', () => ({ writeHooksConfig: vi.fn(async () => {}) }));
|
||||
|
||||
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||
|
||||
async function createHarness(): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
const ctx = createMockRouteContext();
|
||||
registerCaseRoutes(app, ctx as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('COD-105 GET /api/remote-hosts/:hostId/sessions', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createHarness();
|
||||
mockHosts.length = 0;
|
||||
mockSessions = [];
|
||||
lastListArg = undefined;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns discovered sessions for a known host in the envelope', async () => {
|
||||
mockHosts.push({ id: 'aa-desktop', label: 'aa', host: '1.2.3.4', username: 'aakht', port: 2222 });
|
||||
mockSessions = [{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }];
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/aa-desktop/sessions' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.sessions).toEqual([{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }]);
|
||||
// The host config (incl. port) was threaded to the discovery call.
|
||||
expect((lastListArg as { host?: string; port?: number }).host).toBe('1.2.3.4');
|
||||
expect((lastListArg as { port?: number }).port).toBe(2222);
|
||||
});
|
||||
|
||||
it('404s when the host id is unknown', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/nope/sessions' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe(ApiErrorCode.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('returns an empty list (not an error) when no sessions are discovered', async () => {
|
||||
mockHosts.push({ id: 'aa-desktop', label: 'aa', host: '1.2.3.4', username: 'aakht' });
|
||||
mockSessions = [];
|
||||
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/aa-desktop/sessions' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -174,8 +174,8 @@ describe('scheduled-routes', () => {
|
||||
// Bare { run } return (envelope-wrapped to { success:true, data:{ run } }
|
||||
// in production; harness sees the bare return).
|
||||
expect(body.run).toBeDefined();
|
||||
// Should default to 60 minutes
|
||||
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60);
|
||||
// Should default to 60 minutes; 4th arg is the multi-user owner (undefined in single-user).
|
||||
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131).
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Asserts the uniform envelope contract:
|
||||
* SUCCESS -> 2xx, { success: true, data: { order } }
|
||||
* ERROR -> 4xx/5xx, { success: false, error, errorCode }
|
||||
* and that the order is persisted to the (mock) StateStore + broadcast over SSE.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
|
||||
// registerSessionRoutes pulls in session.js which can shell out; stub the bits
|
||||
// that would touch the OS at import/registration time. None are needed by the
|
||||
// session-order handler itself, but the module imports them.
|
||||
vi.mock('node:child_process', async (orig) => {
|
||||
const actual = await orig<typeof import('node:child_process')>();
|
||||
return { ...actual, execFile: vi.fn(), spawn: vi.fn() };
|
||||
});
|
||||
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface LocalHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function buildHarness(): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]);
|
||||
|
||||
// Mirror production's uniform-envelope preSerialization hook (server.ts).
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
describe('PUT /api/session-order', () => {
|
||||
let harness: LocalHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await buildHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('persists the order and returns it in the envelope', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
payload: { order: ['a', 'b', 'c'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } });
|
||||
// Persisted to the store.
|
||||
expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']);
|
||||
expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']);
|
||||
// Broadcast over SSE.
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] });
|
||||
});
|
||||
|
||||
it('preserves a server-only id (unknown to the pushing device) at the end', async () => {
|
||||
// Seed the store with an order containing a server-only id "z".
|
||||
harness.ctx.store.setSessionOrder(['a', 'z', 'b']);
|
||||
(harness.ctx.broadcast as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
payload: { order: ['b', 'a'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
// Incoming order wins, server-only "z" falls to the end.
|
||||
expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } });
|
||||
expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']);
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] });
|
||||
});
|
||||
|
||||
it('normalizes junk input (dedup + drop empties) before persisting', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
payload: { order: ['a', 'a', '', 'b'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } });
|
||||
});
|
||||
|
||||
it('rejects a non-array order with a 4xx envelope', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
payload: { order: 'nope' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @fileoverview Route tests for POST /api/sessions/:id/pin (COD-139).
|
||||
*
|
||||
* Pinning floats a session to the top of the unified session list. The route
|
||||
* sets the session's pin flag, persists it, and broadcasts session:pinned.
|
||||
* Uses app.inject() with the production-mirroring envelope harness.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface LocalHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function createEnvelopeHarness(): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx as never);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
describe('POST /api/sessions/:id/pin', () => {
|
||||
let harness: LocalHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('pins a session: sets state, persists, and broadcasts session:pinned', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/pin',
|
||||
payload: { pinned: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.pinned).toBe(true);
|
||||
expect(typeof body.data.pinnedAt).toBe('number');
|
||||
|
||||
expect(harness.ctx._session.pinned).toBe(true);
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
||||
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
|
||||
expect(broadcastCalls).toContain('session:pinned');
|
||||
});
|
||||
|
||||
it('unpins a session and clears pinnedAt', async () => {
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/pin',
|
||||
payload: { pinned: true },
|
||||
});
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/pin',
|
||||
payload: { pinned: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data.pinned).toBe(false);
|
||||
expect(body.data.pinnedAt).toBeUndefined();
|
||||
expect(harness.ctx._session.pinned).toBe(false);
|
||||
});
|
||||
|
||||
it('is idempotent for an explicit pinned value', async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/pin',
|
||||
payload: { pinned: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.pinned).toBe(true);
|
||||
}
|
||||
expect(harness.ctx._session.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/does-not-exist/pin',
|
||||
payload: { pinned: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing/invalid body with 400', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/pin',
|
||||
payload: { pinned: 'yes' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
// COD-142 keeps a pinned session's persisted record after kill, so pin toggles
|
||||
// must work WITHOUT a live Session — otherwise a pinned-then-killed record could
|
||||
// never be unpinned (cleanupStaleSessions deliberately skips pinned records).
|
||||
it('unpins a persisted-only (killed but pinned) record via the store fallback', async () => {
|
||||
const persisted = { id: 'dead-1', name: 'Dead', status: 'stopped', pinned: true, pinnedAt: 123 };
|
||||
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/dead-1/pin',
|
||||
payload: { pinned: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data.pinned).toBe(false);
|
||||
expect(body.data.pinnedAt).toBeUndefined();
|
||||
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
|
||||
'dead-1',
|
||||
expect.objectContaining({ pinned: undefined, pinnedAt: undefined })
|
||||
);
|
||||
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
|
||||
expect(broadcastCalls).toContain('session:pinned');
|
||||
});
|
||||
|
||||
it('re-pins a persisted-only record via the store fallback', async () => {
|
||||
const persisted = { id: 'dead-2', name: 'Dead2', status: 'stopped' };
|
||||
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/dead-2/pin',
|
||||
payload: { pinned: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data.pinned).toBe(true);
|
||||
expect(typeof body.data.pinnedAt).toBe('number');
|
||||
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
|
||||
'dead-2',
|
||||
expect.objectContaining({ pinned: true, pinnedAt: expect.any(Number) })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -225,7 +225,11 @@ describe('system-routes', () => {
|
||||
harness.ctx._session.status = 'working';
|
||||
harness.ctx.store.getDailyStats.mockReturnValue([
|
||||
{
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
// LOCAL date (not toISOString/UTC): away-digest's dayOverlapsRange parses
|
||||
// the date as local midnight, so a UTC date near the local-midnight boundary
|
||||
// (e.g. running at 01:xx CEST = prior-day UTC) would fall outside the 1h
|
||||
// window and make this assertion TZ/hour-flaky.
|
||||
date: new Date().toLocaleDateString('en-CA'),
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
estimatedCost: 0.02,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user