From 4cda15049330eef92f4a7b6044fe1fbe501c4410 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 24 Aug 2026 03:37:56 +0200 Subject: [PATCH 1/6] feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode Adds `mode: 'deepseek'` alongside claude/shell/opencode/codex/gemini/ antigravity/pi/grok, plus a shortcut that opens the harness's own browser UI as a Codeman web tab. DeepSeek is wired unlike its siblings in three ways, each of which is the reason for a design decision rather than an accident: 1. The agent is a PROFILE, not the binary. `dsh` is a launcher over $DSH_HOME/profiles/, and DeepSeek ships only `web`, `headless` and `base` -- the interactive terminal front door is always a third-party plugin. So availability is two questions: `isDeepSeekAvailable()` (binary) and `isDeepSeekRunnable()` (binary AND a pane-capable profile). The Run button gates on the latter, because reporting only the binary would spawn a pane that dies on arrival. When the binary is present but no profile is, the run menu offers to install one (POST /api/deepseek/install-profile). 2. The permission switch is an env var, not a flag. The harness has no command-line permission option; its sandbox/approval rows read DSH_PERMISSION_MODE (read-only / workspace-write / danger-full-access). Exported via `tmux setenv`, never on the spawn line. Absent = the harness's own workspace-write, which still asks, so the multi-user clamp is the only-if-sent branch and clamps to workspace-write, never read-only. 3. It is the only non-claude mode that passes hooksAvailableForMode(), and it earned that. The terminal front door reports idle/working/blocked to a supervising process over a generic env-gated contract; a generated shim (deepseek-status-shim.ts) makes Codeman that supervisor and forwards each report to /api/hook-event as stop / agent_working / permission_prompt. So a dsh session gets definitive respawn triggers, real wait-endpoint signals and real Approvals Inbox items instead of output-stabilization guesswork. `agent_working` is new (157th SSE constant) and joins APPROVAL_RESOLVING_EVENTS so a dialog answered in the terminal clears its alert at once. The resolver needs the strictest identity probe of the family: `dsh` is not merely a squattable npm name, Debian ships an unrelated `dsh` (dancer's shell), so `dsh --help` must print the harness's own banner before a candidate is handed a spawn line. Model is deliberately not a session field -- it is a composition entry in the profile's config tree. Env allowlist gains DSH_* and DEEPSEEK_* only; provider keys named by a settings-file `apiKeyEnv` stay out, which is pi's 34-provider-key problem in a new shape. Verified live against dsh 0.1.1-rc.2 and @deepseek-harness-tui/dsh-tui: the status endpoint's two-part answer, the no-profile refusal, the profile bootstrap, a real session whose pane runs `dsh --profile dsh-tui` with the permission mode injected via setenv, and the full status bridge -- a send-and-wait returned signal "stop" from a real turn, and blocked/working created and cleared an Approvals Inbox item. Docs: docs/deepseek-integration.md (guide), docs/deepseek-integration-plan.md (decisions + honest gaps). Tests: test/deepseek-mode.test.ts, test/deepseek-cli-resolver.test.ts. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 17 +- docker/agent.Dockerfile | 22 ++ docs/architecture-invariants.md | 18 +- docs/deepseek-integration-plan.md | 124 ++++++++++ docs/deepseek-integration.md | 218 +++++++++++++++++ install.sh | 59 ++++- package.json | 1 + skills/codeman/reference/endpoints.md | 8 +- skills/codeman/reference/messaging.md | 4 +- skills/codeman/reference/recipes.md | 2 +- skills/codeman/reference/verbs.md | 6 +- src/config/dependency-registry.ts | 27 +++ src/cron/cron-service.ts | 5 +- src/deepseek-status-shim.ts | 233 ++++++++++++++++++ src/docker-hosts.ts | 12 + src/mux-interface.ts | 3 + src/remote-hosts.ts | 4 + src/session.ts | 28 ++- src/tmux-manager.ts | 148 ++++++++++- src/tui/tui-app.ts | 1 + src/tui/tui-client.ts | 2 +- src/types/session.ts | 75 +++++- src/utils/deepseek-cli-resolver.ts | 337 ++++++++++++++++++++++++++ src/utils/index.ts | 13 + src/web/public/app.js | 15 +- src/web/public/constants.js | 7 + src/web/public/home-sessions.js | 1 + src/web/public/i18n.js | 1 + src/web/public/index.html | 24 +- src/web/public/mobile-overview.js | 1 + src/web/public/mobile.css | 23 ++ src/web/public/panels-ui.js | 2 +- src/web/public/session-ui.js | 204 +++++++++++++++- src/web/public/settings-ui.js | 14 ++ src/web/public/styles.css | 40 +++ src/web/public/terminal-ui.js | 2 +- src/web/response-viewer-transcript.ts | 2 +- src/web/routes/hook-event-routes.ts | 13 +- src/web/routes/session-routes.ts | 99 +++++++- src/web/routes/system-routes.ts | 123 +++++++++- src/web/schemas.ts | 87 ++++++- src/web/server.ts | 10 + src/web/session-wait-registry.ts | 8 +- src/web/sse-events.ts | 13 +- test/deepseek-cli-resolver.test.ts | 232 ++++++++++++++++++ test/deepseek-mode.test.ts | 240 ++++++++++++++++++ test/mobile-overview.test.ts | 12 +- test/render-index-html.test.ts | 15 ++ 48 files changed, 2489 insertions(+), 66 deletions(-) create mode 100644 docs/deepseek-integration-plan.md create mode 100644 docs/deepseek-integration.md create mode 100644 src/deepseek-status-shim.ts create mode 100644 src/utils/deepseek-cli-resolver.ts create mode 100644 test/deepseek-cli-resolver.test.ts create mode 100644 test/deepseek-mode.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 35e0addc9..240f87dc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the b Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs. -**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev) and Grok Build (`grok`, xAI) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'`). +**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI) and DeepSeek Harness (`dsh`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'`). **TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. @@ -126,10 +126,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_*` / `PI_*` / `GROK_*` / `XAI_*` 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 `/.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 /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) +- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` 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 `/.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 /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 ` 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 `/.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_*` vs `PI_*` vs `GROK_*`) 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), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md` +- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) 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), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-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 - **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`. - **`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` @@ -148,6 +148,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | **Entry** | `src/index.ts`, `src/cli.ts`, `daemon-control`, `service-installer`, `config/service-names`, `cli-style` | The last three back `web -d` / `service install`; `cli-style` is the shared palette/table/spinner/confirm kit | | **TUI** | `src/tui/`: `tui-app` ★ + `tui-client` (the only IO) over a pure core (`-model`, `-layout`, `-render`, `-keys`, `-ansi`, `-composer`, `-approvals`, `-digest`, `-sse`, `-types`) | `codeman tui`, a CLIENT of the server, never a second brain. Design doc: `docs/tui-plan.md`; user guide `docs/tui.md` | +| **DeepSeek** | `src/utils/deepseek-cli-resolver.ts`, `src/deepseek-status-shim.ts` | `dsh` is a PROFILE LAUNCHER, not an agent; read `docs/deepseek-integration.md` first | | **Session** | `src/session.ts` ★, `session-manager`, `session-auto-ops`, `session-cli-builder`, `session-task-cache`, `session-order` (pure), `session-pty-exit-breaker`, `usage-limit-patterns`, `usage-telemetry`; `src/services/unified-session-service.ts` | Pure/unit-tested helpers are split out of `session.ts` on purpose | | **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | | | **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | @@ -186,7 +187,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) -**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` come from Claude Code hooks and therefore fire for **`claude` mode ONLY** (`shell` installs none either); asking for one explicitly on another mode is a 400, the default set silently drops them. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case ]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` +**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` are hook-driven and fire for **`claude` and `deepseek` ONLY** (`shell` installs none either); asking for one explicitly on any other mode is a 400, the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred — `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case ]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. @@ -204,7 +205,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All six **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok) +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All seven **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`; user guide `docs/deepseek-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek) **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) @@ -331,18 +332,18 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L | **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login | | **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) | | **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) | -| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`) | +| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`) | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | **Security-relevant env vars**: `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS=1` (opt-in hooks-only listener on the docker bridge gateway). ### SSE Event Registry -156 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 156 = 156, no drift either direction). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events. +157 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 157 = 157, no drift either direction). ⚠️ `hook:agent_working` is the one hook event with no Claude Code hook behind it — the DeepSeek status bridge reports it (see External CLI modes). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events. ### API Routes -~218 handlers across 24 route files in `src/web/routes/`: system (49), sessions (34), cases (29), files (17), 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 (4), readmymind (4), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. +~220 handlers across 24 route files in `src/web/routes/`: system (51), sessions (34), cases (29), files (17), 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 (4), readmymind (4), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), 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` 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`). diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index e81994806..36213aaaa 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -68,6 +68,18 @@ RUN curl -fsSL https://x.ai/cli/install.sh | bash \ && rm -rf /root/.grok /root/.local/bin/grok /root/.local/bin/agent \ && grok --version +# DeepSeek Harness (`dsh`). A normal npm package, but the ONLY entry here whose +# binary runs nothing on its own: `dsh` is a profile launcher, and DeepSeek ships +# only `web` and `headless`, so without an interactive profile a +# `mode: 'deepseek'` container would start a pane that dies on arrival. The +# profile itself is installed further down, into the `agent` HOME, because +# Codeman deliberately does NOT seed `profiles/` from the host: it is a +# per-profile node_modules tree, host-arch-specific and far too large to copy on +# every container start. +RUN npm install -g @deepseek-ai/dsh \ + && npm cache clean --force \ + && dsh --version + # `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is # auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at # runtime Codeman overrides with `--user :0` on Linux, so the baked uid @@ -88,9 +100,19 @@ ENV HOME=/home/agent # `.pi/agent` and `.grok` ARE pre-created: both are seeded per-FILE (pi: # auth/settings/trust/models; grok: auth.json/config.toml/pager.toml), and a # per-file seed copy, unlike a whole-dir one, does not create its parent directory. +# `.dsh` is pre-created for the same per-file reason (.env/settings.yaml/ +# cordis.patch.yml), and the interactive profile is built into it HERE rather than +# after `USER agent`: this layer's closing chgrp/chmod is what makes the whole tree +# writable by the arbitrary uid the container actually runs as, and a profile +# installed after it would miss that fixup. DSH_HOME points the launcher at the +# agent's dir while this still runs as root. RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \ && mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \ /home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \ + /home/agent/.dsh \ + && DSH_HOME=/home/agent/.dsh HOME=/home/agent \ + dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui \ + && test -f /home/agent/.dsh/profiles/dsh-tui/package.json \ && chgrp -R 0 /home/agent \ && chmod -R g=u /home/agent diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2cf6c08cd..d81ef2b12 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -18,9 +18,23 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ## Session launch modes -### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok) +### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All six modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All six modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. + +**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. + +⚠️ **The agent is a PROFILE, not the binary.** `dsh` is a launcher over `$DSH_HOME/profiles/` (an ordered stack of plugin-bundle patch layers), and DeepSeek ships only `web` (browser UI), `headless` (one-shot) and `base` (no app). The interactive terminal front door is ALWAYS third-party. So availability is TWO questions, not one, and `isDeepSeekRunnable()` (binary AND a pane-capable profile) is what the Run button gates on while `isDeepSeekAvailable()` (binary only) gates the "add a profile" affordance and the web-UI shortcut. Reporting only the binary would let Run spawn a pane that dies on arrival, which is this mode's single most confusing failure. `buildDeepSeekCommand()` emits `dsh --profile [--resume [id]]`; an absent profile resolves through `resolveDefaultDeepSeekProfile()`, which prefers a recognized TUI, then an UNRECOGNIZED profile (third-party by construction — a classifier that has not heard of a bundle must not hide it), and refuses `web`/`headless`, which cannot drive a pane. + +⚠️ **The permission switch is an ENV VAR, not a flag.** The harness has no `--dangerously-skip-permissions` equivalent; its sandbox/approval rows read `DSH_PERMISSION_MODE` with three presets (`read-only` / `workspace-write` / `danger-full-access`; measured from `dsh --dump-default-config`). It is exported via `tmux setenv` in `_configureDeepSeek()`, never on the command line, and `test/deepseek-mode.test.ts` pins that nothing permission-shaped ever reaches the spawn line. This is the ONE place a Codeman env export is the right mechanism rather than the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks in-session `/effort`), the harness reads it with `??` as a boot-time DEFAULT, so it stays soft. Absent = `workspace-write`, which still asks, so the multi-user clamp is the only-if-sent branch (codex/antigravity/grok shape, not pi's materialize) — and it clamps down to `workspace-write`, NOT `read-only`, because the clamp removes privilege without breaking a session's ability to edit its own workspace. + +⚠️ **It is the only non-claude mode that passes `hooksAvailableForMode()`, and it earned that.** The community terminal front door reports its own lifecycle to a supervising process through a generic env-var-gated contract inherited from Herdr: with `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` set it shells out ` pane report-agent --state idle|working|blocked …` on every state change and treats exit 0 as delivered. `deepseek-status-shim.ts` GENERATES a small script into the data dir (like `self-update-runner.sh`, so npm installs and git clones behave alike) and points `HERDR_BIN_PATH` at it; it forwards to `POST /api/hook-event` as `idle→stop`, `blocked→permission_prompt`, `working→agent_working`. So a dsh session gets real respawn triggers, real `wait` stop/blocked signals and real Approvals Inbox items instead of output-stabilization guesswork. This is an interface implementation, not an impersonation — no real `herdr` binary is ever executed. A TUI that does not implement the contract simply never calls the shim and falls back to stabilization, so the feature is inert rather than harmful there. + +⚠️ **`agent_working` is a hook event with no Claude Code hook behind it** (157th SSE constant). It exists because a harness turn cannot run while one of its own modal approvals is on screen, so "the agent started working" proves a dialog was answered in the terminal. It joins `APPROVAL_RESOLVING_EVENTS`; without it a dsh session's red alert would survive until the next `stop`, the exact stuck-alert bug the claude path already had to fix once — and the pane-capture staleness sweep that fixed it there is Claude-dialog-shaped and cannot help here. + +⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity. + +Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. **Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session ` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). diff --git a/docs/deepseek-integration-plan.md b/docs/deepseek-integration-plan.md new file mode 100644 index 000000000..af7f65448 --- /dev/null +++ b/docs/deepseek-integration-plan.md @@ -0,0 +1,124 @@ +# DeepSeek Harness (`dsh`) integration plan + +> **Status**: Executed. This document records the plan, the decision behind each +> wiring point, and what was and was not verified. The user-facing guide is +> [`deepseek-integration.md`](./deepseek-integration.md); the per-decision +> invariants live in +> [`architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek`](./architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek). +> Template: the grok integration ([`grok-integration-plan.md`](./grok-integration-plan.md)), +> itself calibrated against pi. Every fact below was measured against a live +> **dsh 0.1.1-rc.2** install and **@deepseek-harness-tui/dsh-tui 0.9.0**, not read +> from documentation. + +## 1. What the DeepSeek Harness is + +[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) +(open-sourced 2026-08-13, MIT) is a plugin-native agent framework: tools, skills, +sessions, sandboxes and whole APPS are Cordis plugins composed into *profiles*. +`dsh` is the launcher — `dsh --profile ` boots +`$DSH_HOME/profiles/`, an ordered stack of plugin-bundle patch layers under +the user's own overrides. State lives in `~/.dsh` (`.env` 0600, `settings.yaml`, +`cordis.patch.yml`, `profiles/`, `sessions/`, `storages/`). + +## 2. Shape decisions (why DeepSeek is wired the way it is) + +DeepSeek is a ninth run mode. Never a location overlay, never a web tab (the +browser UI is handled separately, §3). Three of its decisions have no precedent +in the six external CLIs before it. + +| Question | Decision | Why | +| --- | --- | --- | +| What does a pane run? | `dsh --profile `, profile discovered | **The decision that shapes everything else.** DeepSeek ships `web`, `headless` and `base` — no terminal agent. The interactive front door is always a third-party plugin, so Codeman resolves a binary AND a profile inventory, and "available" means both. `resolveDefaultDeepSeekProfile()` prefers a recognized TUI, then an UNRECOGNIZED profile (anyone can publish an app bundle; a classifier that has not heard of one must not hide it), and refuses `web`/`headless`, which cannot occupy a pane. | +| Which TUI? | none blessed; default for BOOTSTRAP only | `POST /api/deepseek/install-profile` defaults to `@deepseek-harness-tui/dsh-tui` (~27.5k weekly downloads, ~4x the next, MIT, and it speaks the status contract in §2.3), but accepts any npm name and the resolver never assumes that profile exists. Codeman offers a default; it does not pick a winner. | +| Permission bypass | `DSH_PERMISSION_MODE` env export, no flag | The harness has NO command-line permission option; its sandbox/approval rows read one env var with three presets (`read-only` / `workspace-write` / `danger-full-access`, read off `dsh --dump-default-config`). This is the one legitimate exception to the `CLAUDE_CODE_EFFORT_LEVEL` ban: that var hard-locks in-session switching, whereas the harness reads this with `??` as a boot-time DEFAULT, so it stays soft. Exported via `tmux setenv`, never on the command line. The Run button sends `danger-full-access`, matching every sibling Run button. | +| Multi-user clamp branch | only-if-sent, clamped to `workspace-write` | Omitting the export leaves the harness on `workspace-write`, which still ASKS, so an absent config is already safe (the codex/antigravity/grok shape, not pi's materialize). Clamping to `workspace-write` rather than `read-only` is deliberate: the clamp removes privilege, it must not break a session's ability to edit its own workspace. | +| Idle detection | **real hook events via a status shim** | The standout decision. The TUI already reports its lifecycle to a supervising process through a generic env-gated contract inherited from Herdr: `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` make it run ` pane report-agent --state idle\|working\|blocked …` on every state change, exit 0 = delivered. `deepseek-status-shim.ts` generates a script into the data dir and points `HERDR_BIN_PATH` at it. So deepseek is the only non-claude mode that passes `hooksAvailableForMode()` — earned by emitting definitive signals, not granted. An interface implementation, not an impersonation: no real `herdr` binary is ever executed, and a TUI that ignores the contract simply falls back to output stabilization. | +| `agent_working` event | new, 157th SSE constant | The one hook event with no Claude Code hook behind it. A harness turn cannot run while its own modal approval is on screen, so "started working" proves a dialog was answered in the terminal. Without it a dsh red alert would survive until the next `stop` — the exact stuck-alert bug the claude path already fixed once, and its pane-capture staleness sweep is Claude-dialog-shaped and cannot help here. | +| Resolver | identity probe THEN version probe | Strictest of the family, and not by preference. `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) which would answer a version probe convincingly and then be handed a spawn line. `dsh --help` must match `DeepSeek Harness` first. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release. | +| Env allowlist | `DSH_*` + `DEEPSEEK_*` | `DSH_*` covers the launcher's documented inputs (`DSH_HOME`, `DSH_PERMISSION_MODE`, `DSH_TELEMETRY_MODE`, the `DSH_TUI_*` knobs); `DEEPSEEK_*` is the vendor namespace holding `DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`, same reasoning that admitted `XAI_*` for grok. ⚠️ Pi's lesson repeats exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), and the allowlist is one GLOBAL list, so admitting those would widen every mode at once. They stay out. | +| Model | NOT a session field | The model is a composition entry (`agent-default-model`) in the profile's config tree, set in `~/.dsh/settings.yaml` + `cordis.patch.yml`. Both create paths deliberately resolve no model for this mode rather than inventing a flag. | +| Alt-screen strip | OUT of `isAltScreenStripMode()` | Third-party fullscreen TUIs with their own scrollback and mouse handling — the opencode case, not the Ink case. | +| Local echo | `'buffer'` via the `_updateLocalEchoState` fallthrough | UNMEASURED against a live authenticated session (see §5), same honest gap grok shipped with. The leading TUI's composer supports `@` completion and history search, which *may* make it per-keystroke reactive like codex; if so the fallback is the `'off'` branch. | +| Docker | image installs dsh AND a profile | Profiles are deliberately NOT seeded from the host: each is a per-profile `node_modules` tree, host-arch-specific and far too large to copy per container start. Only `~/.dsh/.env`, `settings.yaml`, `cordis.patch.yml` are seeded (auth + model composition). The profile install rides the `useradd` layer so the closing `chgrp`/`chmod g=u` covers it, which is what keeps it usable under the arbitrary uid the container runs as. | +| Remote SSH | `exec "$SHELL" -i -l -c 'dsh'` | Boots the remote box's default profile; a remote with several needs the per-host `commands.deepseek` override, since `deepSeekConfig` does not cross ssh. | + +## 3. The web profile + +The browser UI is the only interactive surface DeepSeek ships itself, so it gets +a **shortcut, not a run mode**: `Run ▸ DeepSeek web UI…` starts +`dsh web --no-open --host 127.0.0.1 --port 3080 --trusted-host ` +in an ordinary shell session and opens the URL as an ordinary web tab. + +Built entirely from parts that already exist: the server is a shell session +(visible, scrollable, killable, dies with its tab) and the UI is a web tab. +Nothing new supervises a long-lived HTTP server, because Codeman already does. +`--trusted-host` is load-bearing — dsh fences its `/api` behind a browser-trust +check on the request authority, and a Codeman web tab reaches it through +Codeman's own origin via the webview proxy, not directly. + +## 4. Touch points (the checklist) + +Backend: `types/session.ts` (SessionMode + `DeepSeekConfig` + SessionState), +`utils/deepseek-cli-resolver.ts` (new) + barrel, `deepseek-status-shim.ts` (new), +`tmux-manager.ts` (`buildDeepSeekCommand`, dispatch, resume flag, PATH export, +truecolor, `_configureDeepSeek`, availability error, plumbing), `session.ts` +(external-mode gate, label, config plumbing, tmux-required error, attach env), +`mux-interface.ts`, `schemas.ts` (prefixes, `DeepSeekConfigSchema`, +`DeepSeekInstallProfileSchema`, both mode enums, remote overrides, cron agentType, +`agent_working`), `session-wait-registry.ts` (`hooksAvailableForMode`), +`hook-event-routes.ts` (`APPROVAL_RESOLVING_EVENTS`), `session-routes.ts` (clamp + +both create paths + `resolveDeepSeekLaunchError`), `system-routes.ts` +(`GET /api/deepseek/status`, `POST /api/deepseek/install-profile`), `server.ts` +(availability inject + mux restore), `sse-events.ts`, `docker-hosts.ts`, +`remote-hosts.ts`, `config/dependency-registry.ts`, +`response-viewer-transcript.ts`, `cron/cron-service.ts` (comment), +`tui/tui-client.ts` + `tui-app.ts`. + +Frontend: `index.html` (welcome button, run-mode entry, install affordance, web-UI +shortcut, cron option, clone Brain option), `session-ui.js` (`runDeepSeek()`, +`runDeepSeekWeb()`, `installDeepSeekProfile()`, dispatch, availability, "Run DS" +label, external-CLI gates), `app.js` (label, `ds` tab badge, kill-menu, SSE map), +`settings-ui.js` (welcome gate + `_onHookAgentWorking`), `constants.js`, +`mobile-overview.js`, `home-sessions.js`, `panels-ui.js`, `i18n.js`, +`terminal-ui.js`, `styles.css` + `mobile.css` (brand-indigo identity; the non-og +skin block and the mobile `!important` pair are both load-bearing). + +Meta: `docker/agent.Dockerfile`, `install.sh`, `package.json` keyword, +`skills/codeman/reference/*`, CLAUDE.md, `architecture-invariants.md`. + +Tests: `test/deepseek-mode.test.ts` + `test/deepseek-cli-resolver.test.ts` (new); +`run-mode-ui`, `render-index-html`, `mobile-overview`, `agent-skill-mode-lists` +(extended). + +## 5. Verification performed + +See the summary at the end of the implementing session for the live run. In +short: the CI gate green; the resolver, profile inventory, spawn-line and clamp +behaviour covered by 31 new unit tests; and an isolated instance used to exercise +`GET /api/deepseek/status` and a real session against the live dsh install. + +**Not verified (honest gaps):** + +- The local-echo `'buffer'` policy against the TUI's real composer (§2). If it + turns out per-keystroke reactive like codex's, flip it to the `'off'` branch; + teaching `PredictiveEchoAddon` its composer row is the larger follow-up. +- Scrollback/repaint behaviour of a third-party fullscreen TUI under the narrow + strip during a long session. +- A Docker case with `mode: 'deepseek'` (needs a `--no-cache` agent-image + rebuild — see the `--no-cache` rule in CLAUDE.md). +- A remote-SSH deepseek case. +- The web-UI shortcut end to end through the webview proxy, in particular whether + `--trusted-host ` is the right authority for dsh's `/api` + fence in every deployment shape (loopback, tailscale, tunnel). + +## 6. Follow-ups + +- **Response viewer**: read `~/.dsh/sessions/**` (JSONL) the way codex rollouts + are read back. Highest-value follow-up, and very achievable. +- **`headless` as an execution backend** for Codeman's own AI checks + (`ai-idle-checker`, `ai-plan-checker`), today Claude-only. +- **Profile/model picker in Session Options**, reading `GET /api/deepseek/status` + `.profiles`. +- **`--patch` overlays per session**, which is the harness-native way to change + agent composition without touching the user's profile. +- Measure the local-echo policy and pin the result the way pi did. diff --git a/docs/deepseek-integration.md b/docs/deepseek-integration.md new file mode 100644 index 000000000..2e941d561 --- /dev/null +++ b/docs/deepseek-integration.md @@ -0,0 +1,218 @@ +# DeepSeek Harness (`dsh`) in Codeman + +Codeman can run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) +as a session backend, alongside Claude Code, OpenCode, Codex, Gemini, +Antigravity, Pi and Grok. It is the ninth run mode, and the one that is wired +least like the others, for two reasons worth understanding before you use it. + +## 1. The agent is a profile, not the binary + +`dsh` is a **launcher**, not an agent. It boots a *profile*: an ordered stack of +plugin-bundle patch layers under `$DSH_HOME/profiles/` (`$DSH_HOME` +defaults to `~/.dsh`). DeepSeek ships three bundles and none of them is a +terminal agent: + +| Profile | What it is | Can Codeman run it in a tab? | +| ------------ | --------------------------------- | ---------------------------- | +| `web` | the browser UI, served on :3080 | no — but see §5 | +| `headless` | answers one task and exits | no | +| (`base`) | the shared core, no app at all | no | + +The interactive terminal front door is **always a third-party plugin**. So +"DeepSeek is installed" and "Codeman can start a DeepSeek session" are different +questions, and Codeman answers both separately: + +```bash +curl -s localhost:3000/api/deepseek/status | jq +{ + "available": true, # the `dsh` binary resolved and proved its identity + "runnable": false, # ...but nothing installed can drive a pane + "path": "/home/you/.local/bin", + "version": "0.1.1-rc.2", + "dshHome": "/home/you/.dsh", + "defaultProfile": null, + "profiles": [ { "name": "web", "kind": "web", "bundles": [...] } ] +} +``` + +### Installing a terminal profile + +From the UI: open the **Run** dropdown. When `dsh` is installed but no +pane-capable profile is, the menu shows **DeepSeek — add a terminal profile…**. +One click installs one and the normal DeepSeek entry appears. + +By hand, or to pick a different front door: + +```bash +dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui +``` + +Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide +margin the most used community TUI, it is MIT, and it implements the status +contract described in §3. It is a **default, not a requirement**: any profile +under `$DSH_HOME/profiles` that is not `web` or `headless` shows up in the +inventory and can be launched, including one you compose yourself. The endpoint +accepts any npm package name: + +```bash +curl -sX POST localhost:3000/api/deepseek/install-profile \ + -H 'Content-Type: application/json' \ + -d '{"profile":"my-tui","package":"@someone/dsh-tui"}' +``` + +Installing a plugin is arbitrary code execution on the host, so in multi-user +mode this endpoint requires the can-bypass-permissions grant (the same bar as a +`shell` session). + +> **`dsh` is also a Debian program.** `apt install dsh` gives you "dancer's +> shell", a distributed shell, which would answer `--version` convincingly. +> Codeman's resolver therefore demands the harness's own help banner before it +> will point a spawn line at a candidate, and `GET /api/deepseek/status` reports +> `path` and `version` so a misresolution is diagnosable rather than presenting +> as "the mode just doesn't work". + +## 2. Permissions are an env var, not a flag + +The harness has **no `--dangerously-skip-permissions` equivalent**. Its sandbox +and approval rows are configuration, driven by one documented input, +`DSH_PERMISSION_MODE`, with three presets (read off `dsh --dump-default-config`): + +| `DSH_PERMISSION_MODE` | sandbox | approvals | notes | +| --------------------- | -------------------- | --------- | ------------------------- | +| `read-only` | `read-only` | ask | | +| `workspace-write` | `workspace-write` | ask | the harness's own default | +| `danger-full-access` | `danger-full-access` | **never** | what the Run button sends | + +Codeman exports it via `tmux setenv`, never on the command line. Because the +harness reads it with `??`, it is a **soft default**: it sets the boot-time +preset and you can still change permission mode inside the session. + +Omitting it entirely leaves the harness on `workspace-write`, which still asks — +which is why the multi-user clamp only needs to force a *sent* value down. A +non-granted owner's `danger-full-access` becomes `workspace-write`, not +`read-only`: the clamp removes privilege without breaking the session's ability +to edit its own workspace. + +## 3. Real idle detection (the interesting part) + +Every other external CLI mode in Codeman is **readiness-guessed**: Codeman +watches the PTY go quiet and infers that a turn ended. Claude is the exception, +because Claude Code fires hooks. + +DeepSeek is the second exception. The community terminal front door already +reports its own lifecycle to a supervising process through a generic, +env-var-gated contract (inherited from [Herdr](https://herdr.dev)): when +`HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set, it shells out on +every state change with + +``` +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source custom:dsh-tui --agent dsh-tui \ + --state idle|working|blocked [--message ...] --seq N +``` + +Codeman points `HERDR_BIN_PATH` at a small generated shim +(`~/.codeman/dsh-status-shim.mjs`, written at session create) which forwards each +report to `POST /api/hook-event`. The mapping: + +| Harness state | Codeman hook event | What you get | +| ------------- | ------------------ | -------------------------------------------------------- | +| `blocked` | `permission_prompt`| red "needs you" tab alert + an Approvals Inbox item | +| `idle` | `stop` | definitive end-of-turn: respawn triggers, `wait` returns | +| `working` | `agent_working` | clears an alert answered in the terminal, at once | + +So a DeepSeek session gets Claude-grade signals: `GET /api/sessions/:id/wait` +really can block on `stop` and `blocked` for it, and it is the only non-Claude +mode for which that is true (`hooksAvailableForMode`). + +This is an interface implementation, not an impersonation — nothing on your +machine executes a real `herdr` binary. If you use a terminal profile that does +*not* implement the contract, the shim is simply never called and the mode falls +back to output-stabilization readiness like its siblings. Turn it off per session +with `deepSeekConfig.statusReporting: false`. + +## 4. Starting a session + +From the UI, pick **DeepSeek** in the Run dropdown (or the **Run DeepSeek** +welcome button) and press Run. Over the API: + +```bash +curl -sX POST localhost:3000/api/quick-start \ + -H 'Content-Type: application/json' \ + -d '{ + "caseName": "myproject", + "mode": "deepseek", + "deepSeekConfig": { + "profile": "dsh-tui", + "permissionMode": "danger-full-access" + } + }' +``` + +`deepSeekConfig` fields: `profile`, `permissionMode`, `resumeSession`, +`resumeSessionId`, `statusReporting`. Resume prefers an explicit id over the +most-recent form, and both are passed through to the profile's app, which is +where `--resume` is understood. + +**Models are not a session field.** The model is a composition entry in the +profile's config tree (`agent-default-model`), not a CLI flag, so Codeman does +not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml` +plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the +profile. That is also how you point dsh at a local or third-party provider. + +**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides` +(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL` +all flow through). Provider keys with *other* names are deliberately not: a dsh +`settings.yaml` can nominate any env var as a credential via `apiKeyEnv`, and +Codeman's allowlist is global, so admitting them would widen it for every mode at +once. Authenticate those the way dsh does, from the file or the server's own +environment. + +## 5. The web UI as a tab + +The browser UI is the one interactive surface DeepSeek ships itself, so it gets a +shortcut rather than a run mode: **Run ▸ DeepSeek web UI…** starts +`dsh web --no-open --host 127.0.0.1 --port 3080 --trusted-host ` in +an ordinary shell session and opens `http://127.0.0.1:3080` as a Codeman web tab. + +Nothing bespoke supervises it: the server is a normal shell session (visible, +scrollable, killable, dies with its tab) and the UI is a normal web tab. The +`--trusted-host` flag is load-bearing — dsh fences its `/api` behind a +browser-trust check on the request authority, and a Codeman web tab reaches it +through Codeman's own origin via the webview proxy, not directly. Without it the +page renders and every API call fails. + +## 6. Docker and remote cases + +Docker cases work: the agent image installs `dsh` and bootstraps a `dsh-tui` +profile into the container. Profiles are deliberately **not** seeded from the +host (each is a per-profile `node_modules` tree, host-arch-specific and far too +large to copy on every container start); only `~/.dsh/.env`, `settings.yaml` and +`cordis.patch.yml` are seeded, which is what carries auth and model composition +in. As with pi and grok, in-container sessions are invisible host-side: +`~/.dsh/sessions` inside a container is that container's own. + +Remote SSH cases default to `dsh` through a login shell, which boots the remote +box's default profile. If the remote has several, name one with the per-host +`commands.deepseek` override — the local `deepSeekConfig` does not cross ssh. + +## 7. What is not wired + +Deliberately minimal, on the same reasoning as the grok integration: the harness +is a fast-moving developer preview and every flag added is a flag validated +forever. + +- `--patch` overlays per session (the profile's own layers apply as normal). +- `dsh plugin` management beyond first-time profile install. +- The `headless` profile as a one-shot execution backend for Codeman's own + internal AI checks (today those are Claude-only). +- Reading `~/.dsh/sessions/**` into the response viewer, the way codex rollouts + are read back. DeepSeek sessions are JSONL and this is very achievable; it is + the highest-value follow-up. +- Model/provider selection from Session Options. + +## Verified against + +`dsh 0.1.1-rc.2` and `@deepseek-harness-tui/dsh-tui 0.9.0`. The permission +presets, the profile layout, and the supervisor contract above were all read off +the live install rather than from documentation. diff --git a/install.sh b/install.sh index e7e9a066e..34ae863dc 100755 --- a/install.sh +++ b/install.sh @@ -125,6 +125,14 @@ PI_SEARCH_PATHS=( "$HOME/bin/pi" ) +# DeepSeek Harness search paths (from src/utils/deepseek-cli-resolver.ts) +DSH_SEARCH_PATHS=( + "$HOME/.local/bin/dsh" + "/usr/local/bin/dsh" + "$HOME/.npm-global/bin/dsh" + "$HOME/bin/dsh" +) + # Grok CLI search paths (from src/utils/grok-cli-resolver.ts) GROK_SEARCH_PATHS=( "$HOME/.grok/bin/grok" @@ -594,6 +602,46 @@ check_grok() { return 1 } +# `dsh` is the hardest name of the lot: Debian ships an unrelated `dsh` +# (dancer's shell). The server-side resolver settles it by demanding the +# harness's own help banner; detection here only feeds the "you have no AI CLI" +# hint, so the same cheap banner grep is enough and costs one exec. +check_dsh() { + local candidate + if command -v dsh &>/dev/null; then + candidate="$(command -v dsh)" + if "$candidate" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then + return 0 + fi + fi + + for path in "${DSH_SEARCH_PATHS[@]}"; do + if [[ -x "$path" ]] && "$path" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then + return 0 + fi + done + + return 1 +} + +get_dsh_path() { + local candidate + if command -v dsh &>/dev/null; then + candidate="$(command -v dsh)" + if "$candidate" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then + echo "$candidate" + return + fi + fi + + for path in "${DSH_SEARCH_PATHS[@]}"; do + if [[ -x "$path" ]] && "$path" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then + echo "$path" + return + fi + done +} + get_grok_path() { if command -v grok &>/dev/null; then command -v grok @@ -2123,6 +2171,7 @@ main() { local has_antigravity=false local has_pi=false local has_grok=false + local has_dsh=false info "Checking AI CLI tools..." if check_claude; then @@ -2153,10 +2202,14 @@ main() { has_grok=true success "Grok CLI found at $(get_grok_path)" fi + if check_dsh; then + has_dsh=true + success "DeepSeek Harness found at $(get_dsh_path)" + fi - if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" ]]; then + if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" ]]; then echo "" - warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok." + warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or DeepSeek Harness." headless_guard "install an AI CLI (curl | bash from its vendor)" echo "" echo -e " ${BOLD}Which AI CLI would you like to install?${NC}" @@ -2512,7 +2565,7 @@ main() { echo -e " https://github.com/Ark0N/Codeman" echo "" - if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok; then + if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh; then echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:" echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code" echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode" diff --git a/package.json b/package.json index 39f3790f0..0e74f3b92 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "antigravity", "pi", "grok", + "deepseek", "gemini-cli", "ai-agents", "agent", diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 015ad9b99..e941849bb 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -237,7 +237,7 @@ minutes, never retry the credential. flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait returns is too early (verified live: empty on the first call, full prose seconds later). It is also `""` before the worker's first completed turn, and permanently `""` for -`shell`, `opencode`, `gemini`, `antigravity`, `pi` and `grok`, which write no Claude transcript. +`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `deepseek`, which write no Claude transcript. **Fix** Poll it, bounded (10 tries, 1 s apart). If it is still empty on a hook-less mode, that is expected, not a failure: read `terminal?tail=` and strip ANSI instead. @@ -336,14 +336,14 @@ ESC=$(printf '\033') `POST /api/v1/quick-start` body (all optional): `{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}` -, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok`; response is +, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek`; response is `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory on the user's disk) if missing, do not retry it in a loop, and remember the name. ⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with `OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, -`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status` +`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status` and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed). Pi's and grok's also carry `.data.version`, because `pi` is a short generic name and `grok` is a name with npm squatters, so an unrelated binary on `$PATH` can shadow either: @@ -464,7 +464,7 @@ Quirks that will bite you: - ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser, which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers` returns early for every external CLI mode (`session.ts:2261`), so it is permanently - `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`. ⚠️ **`shell` is NOT one of those** + `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`. ⚠️ **`shell` is NOT one of those** (`isExternalCliMode`, `session.ts:174-183`, lists only those six), so the parser does run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches bare `tail|cat|head|less|grep|watch|multitail ` lines with no `● Bash(` wrapper: diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index 6f30b6562..414ef3d28 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) | | liveness / death check | HTTP `wait?until=exit` | | interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` | -| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`) | HTTP only (no other CLI has messaging) | +| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`) | HTTP only (no other CLI has messaging) | | delete | HTTP, via SKILL.md's `delete_session` guard | ## Availability: probe, never assume @@ -347,7 +347,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw ### Mixed fleets: the pairing matrix -Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`) cannot be peers +Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`) cannot be peers at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention messaging in their briefs. The claude half of the fleet can use messaging among itself, subject to the namespace rule: **messaging works between two sessions that share one diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index c43e6610b..f338b08bb 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -188,7 +188,7 @@ for _ in $(seq 1 10); do done printf '%s\n' "$TXT" # (.data is {text,timestamp}; text is also "" before the first completed turn and -# always "" for shell/opencode/gemini/antigravity/pi/grok, which have no transcript, use +# always "" for shell/opencode/gemini/antigravity/pi/grok/deepseek, which have no transcript, use # the terminal tail there, and here only to diagnose an unsubmitted prompt.) # 6. clean up: exact id, own list only, through the fail-closed preamble helper diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index e92759242..9a8dff76e 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -343,7 +343,7 @@ recovered by submitting it with `{"input":"\r"}`. ⚠️ `stop` and `blocked` fire for `claude` sessions only (they are Claude Code hooks, and only when the workspace actually has them, see [§5.1](#51-where-to-spawn)). On -`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`, requesting them explicitly is a +`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`, requesting them explicitly is a 400, and lifecycle transitions there are coarse (a short shell command may emit **no** `idle` transition at all, verified live), so synchronize those with markers. @@ -369,7 +369,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire single read taken the instant send-and-wait returns comes back `""` even though the turn finished (verified live: empty on the first call, full text seconds later). `text` is also `""` before the worker's first completed turn, and always `""` for modes with -no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`; the first four +no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`; the first four verified live, pi from the same source path), which is why the loop above is bounded rather than open-ended. Fall back to the terminal buffer there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive @@ -454,7 +454,7 @@ turn), and both better than diffing terminal samples: ``` ⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for -`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`** (those parsers are skipped wholesale) and +`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`** (those parsers are skipped wholesale) and in practice empty for `shell`. Source-verified, not measured live. Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index 722f19b17..65ff9ca84 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -9,6 +9,7 @@ import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js'; import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js'; +import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js'; export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl'; @@ -163,6 +164,32 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ }, ], }, + { + id: 'dsh', + label: 'DeepSeek Harness CLI', + category: 'core', + required: false, + usedBy: ['DeepSeek sessions'], + // Version match required, and for a sharper reason than pi or grok: `dsh` is + // not merely a squattable npm name, it is an existing Debian program + // (dancer's shell, `apt install dsh`). The run mode's resolver additionally + // demands the harness's own help banner before it will point a spawn line at + // a candidate; the doctor is advisory and settles for the shared + // DEEPSEEK_VERSION_REGEX, so the two cannot disagree about the VERSION even + // though the resolver is the stricter of the pair about IDENTITY. + resolvers: [ + { + match: ALL, + resolver: { + kind: 'path', + bins: ['dsh'], + versionArg: '--version', + versionRegex: DEEPSEEK_VERSION_REGEX, + requireVersionMatch: true, + }, + }, + ], + }, { id: 'libreoffice', label: 'LibreOffice', diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index c42be3523..2c8a0cc4c 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -48,8 +48,9 @@ const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms * answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript, * so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve` * is NOT a clamp. - * Codex, antigravity and grok need nothing here: their absent config already spawns safe - * (grok's bare spawn is its own ask-mode default; --always-approve is only ever sent). + * Codex, antigravity, grok and deepseek need nothing here: their absent config already spawns safe + * (grok's bare spawn is its own ask-mode default and deepseek's omits DSH_PERMISSION_MODE + * entirely, leaving the harness on workspace-write, which asks; both switches are only ever sent). * Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched. */ export function clampCronExternalCliConfigs( diff --git a/src/deepseek-status-shim.ts b/src/deepseek-status-shim.ts new file mode 100644 index 000000000..19dbfe9a5 --- /dev/null +++ b/src/deepseek-status-shim.ts @@ -0,0 +1,233 @@ +/** + * @fileoverview The DeepSeek Harness -> Codeman status bridge. + * + * ## Why this exists + * + * Every external CLI mode before this one (opencode, codex, gemini, antigravity, + * pi, grok) is READINESS-GUESSED: Codeman watches the PTY go quiet and infers a + * turn ended. Claude is the exception, because Claude Code fires real hooks. The + * DeepSeek Harness TUI gives us a third option, and a much better one than + * guessing: the community terminal front door already reports its own lifecycle + * to an owning supervisor, and it does so through a fully GENERIC, env-var-gated + * contract it inherited from Herdr (herdr.dev). + * + * When all three of `HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set, + * the TUI shells out on every state change: + * + * "$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + * --source custom:dsh-tui --agent dsh-tui \ + * --state idle|working|blocked [--message ] --seq + * + * and treats exit code 0 as "delivered" (retrying with backoff otherwise). So + * Codeman points `HERDR_BIN_PATH` at the script below and gets DEFINITIVE + * idle/working/blocked signals for dsh sessions: real respawn triggers, real + * `wait`/`wait-output` stop+blocked signals, and real Approvals Inbox items, + * on par with Claude's hooks rather than with output stabilization. + * + * This is an interface implementation, not an impersonation: we implement the + * one verb (`pane report-agent`) that the contract defines, and nothing on the + * machine ever executes a real `herdr` binary — `HERDR_BIN_PATH` is our own + * script, in our own data dir. `HERDR_ENV=1` is the flag the TUI checks to know + * a supervisor is present; a supervisor IS present, it is Codeman. + * + * ## Why it is generated rather than committed + * + * The shim must be an executable file at a stable absolute path in every + * install shape: a git clone (where `scripts/` exists), an `npm i -g aicodeman` + * (where `files` ships only `dist` plus two named scripts), and any + * `CODEMAN_INSTANCE`. Writing it into the data dir at session-create time makes + * one code path cover all of them, single-sources the content here in TS, and + * follows the precedent of `self-update-runner.sh`. It is rewritten whenever the + * embedded version marker changes, so an upgraded Codeman refreshes a stale shim + * without the user knowing it exists. + * + * @module deepseek-status-shim + */ + +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { dataPath } from './config/instance.js'; + +/** + * Bumped whenever SHIM_SOURCE changes. The marker is embedded in the generated + * file, so `ensureDeepSeekStatusShim()` can tell a current shim from one written + * by an older Codeman and rewrite only when needed (rather than rewriting on + * every session create, or — worse — leaving a stale one in place forever). + */ +const SHIM_VERSION = 1; +const SHIM_MARKER = `codeman-dsh-status-shim v${SHIM_VERSION}`; + +/** + * Mapping from the harness's three lifecycle states to Codeman hook events. + * + * - `blocked` -> `permission_prompt`: the TUI reports blocked when a tool + * approval or an `ask_user_question` questionnaire is on screen, which is + * exactly the red "needs you" alert and an answerable Approvals Inbox item. + * - `idle` -> `stop`: the definitive end-of-turn signal, the one respawn and the + * wait endpoints care about. + * - `working` -> `agent_working`: a turn STARTED. Codeman infers "working" from + * PTY output well enough on its own, but the event is what RESOLVES a pending + * approval when the user answers a dialog in the terminal instead of in the + * inbox. Without it a dsh session's red alert would survive until the next + * `stop`, which is the exact stuck-alert bug the claude path already had to + * fix once (and the pane-capture staleness sweep that fixed it there is + * Claude-dialog-shaped, so it cannot help here). + */ +export const DEEPSEEK_STATE_TO_HOOK_EVENT: Readonly> = Object.freeze({ + idle: 'stop', + blocked: 'permission_prompt', + working: 'agent_working', +}); + +/** + * The generated script. + * + * Constraints it must satisfy, each learned from an existing Codeman hook bug: + * - **TLS**: `CODEMAN_API_URL` is loopback HTTPS with a self-signed cert on + * `--https`/tailscale installs, so certificate verification is disabled for + * the request. Without this the whole bridge dies silently, exactly as the + * claude hook curls did before they grew `-k`. + * - **Secret**: the hook-secret file is read AT EXECUTION TIME, never baked in, + * so rotation needs no respawn and the value never lands on a command line. + * - **Exit codes**: 0 means delivered. Anything else makes the TUI retry with + * backoff, so transport failures self-heal, but an unknown verb or an + * unmapped state exits 0 to avoid a pointless retry storm over something that + * will never succeed. + * - **Timeout**: bounded below the caller's own 2s budget, so we lose the race + * deliberately rather than being killed mid-flight. + */ +const SHIM_SOURCE = `#!/usr/bin/env node +// ${SHIM_MARKER} +// GENERATED BY CODEMAN — do not edit. Rewritten from src/deepseek-status-shim.ts +// whenever its version marker changes. +// +// Implements the one verb the DeepSeek Harness TUI's supervisor contract uses: +// pane report-agent --state [--message ] ... +// and forwards it to this Codeman instance as a hook event. +import { readFileSync } from 'node:fs' +import http from 'node:http' +import https from 'node:https' + +const STATE_TO_EVENT = ${JSON.stringify(DEEPSEEK_STATE_TO_HOOK_EVENT)} +const TIMEOUT_MS = 1500 + +const argv = process.argv.slice(2) +const flag = (name) => { + const i = argv.indexOf(name) + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined +} + +// Unknown verb: succeed silently. Retrying could never make it succeed, and a +// non-zero exit here would make the caller retry four times per state change. +if (argv[0] !== 'pane' || argv[1] !== 'report-agent') process.exit(0) + +const event = STATE_TO_EVENT[String(flag('--state') ?? '')] +if (!event) process.exit(0) + +// The pane id we hand the TUI IS the Codeman session id, but prefer the ambient +// env: it is set by the same code that set HERDR_PANE_ID and cannot be spoofed +// by an argument the agent itself could influence. +const sessionId = process.env.CODEMAN_SESSION_ID || argv[2] +const apiUrl = process.env.CODEMAN_API_URL +if (!sessionId || !apiUrl) process.exit(1) + +let secret = '' +try { + secret = readFileSync(process.env.CODEMAN_HOOK_SECRET_FILE || '', 'utf-8').trim() +} catch { + // Missing file: the loopback bypass still applies when no tunnel is running. +} + +const body = JSON.stringify({ + event, + sessionId, + data: { + source: 'dsh-status-shim', + agent: flag('--agent') || 'dsh', + ...(flag('--message') ? { message: flag('--message') } : {}), + }, +}) + +let url +try { + url = new URL('/api/hook-event', apiUrl) +} catch { + process.exit(1) +} + +const transport = url.protocol === 'https:' ? https : http +const req = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + timeout: TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'X-Codeman-Hook-Secret': secret, + }, + // Loopback HTTPS with a self-signed cert (--https / tailscale installs). + rejectUnauthorized: false, + }, + (res) => { + res.resume() + process.exit(res.statusCode && res.statusCode >= 200 && res.statusCode < 300 ? 0 : 1) + } +) +req.on('timeout', () => { + req.destroy() + process.exit(1) +}) +req.on('error', () => process.exit(1)) +req.end(body) +`; + +/** Absolute path of the generated shim for this instance. */ +export function deepSeekStatusShimPath(): string { + return dataPath('dsh-status-shim.mjs'); +} + +let ensuredThisProcess = false; + +/** + * Write the shim if it is missing or stale, and return its path. + * + * Idempotent and cheap: after the first call in a process it does nothing, and + * even the first call only rewrites when the on-disk marker differs. Never + * throws — a data dir that cannot be written is a degraded status bridge, not a + * failed session start, so callers fall back to output-stabilization readiness + * by receiving null. + */ +export function ensureDeepSeekStatusShim(): string | null { + const path = deepSeekStatusShimPath(); + if (ensuredThisProcess) return path; + try { + let current = ''; + try { + current = readFileSync(path, 'utf-8'); + } catch { + // Missing — fall through to the write. + } + if (!current.includes(SHIM_MARKER)) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, SHIM_SOURCE, { mode: 0o700 }); + } + // Re-assert the mode even when the content matched: a shim that lost its + // executable bit (a restored backup, a copied data dir) would make every + // report fail, and the TUI would retry four times per state change forever. + chmodSync(path, 0o700); + ensuredThisProcess = true; + return path; + } catch (err) { + console.warn(`[DeepSeek] Could not install the status shim at ${path}: ${(err as Error).message}`); + return null; + } +} + +/** Test seam: forget the per-process memo so a fresh temp HOME is re-provisioned. */ +export function resetDeepSeekStatusShimForTest(): void { + ensuredThisProcess = false; +} diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 40319d244..e2a924865 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -146,6 +146,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string { antigravity: 'exec agy', pi: 'exec pi', grok: 'exec grok', + deepseek: 'exec dsh', }; return commands[mode as DockerCommandMode] || commands.shell; } @@ -625,6 +626,17 @@ const CRED_STORES: CredStorePolicy[] = [ rel: '.grok', seedFiles: ['auth.json', 'config.toml', 'pager.toml'], }, + // DeepSeek Harness keeps credentials in `~/.dsh/.env` (0600) and composition in + // `settings.yaml` / `cordis.patch.yml`. `profiles/` is deliberately NOT seeded: + // it is a pnpm workspace holding a full node_modules tree per profile, which is + // both enormous and host-arch-specific. An in-container dsh therefore needs its + // profile installed IN the image (see docker/agent.Dockerfile), and the seeded + // files only supply auth and model composition. Same host-invisibility trade-off + // as pi and grok: `~/.dsh/sessions` inside a container is that container's own. + { + rel: '.dsh', + seedFiles: ['.env', 'settings.yaml', 'cordis.patch.yml'], + }, { rel: '.config/gcloud', seedWhole: true }, { rel: '.config/opencode', seedWhole: true }, ]; diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 3a9cf5741..6cb9693fd 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -20,6 +20,7 @@ import type { AntigravityConfig, PiConfig, GrokConfig, + DeepSeekConfig, SessionRemote, SessionDocker, } from './types.js'; @@ -80,6 +81,7 @@ export interface CreateSessionOptions { antigravityConfig?: AntigravityConfig; piConfig?: PiConfig; grokConfig?: GrokConfig; + deepSeekConfig?: DeepSeekConfig; /** When restoring after reboot, resume a previous Claude conversation by its session ID */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */ @@ -113,6 +115,7 @@ export interface RespawnPaneOptions { antigravityConfig?: AntigravityConfig; piConfig?: PiConfig; grokConfig?: GrokConfig; + deepSeekConfig?: DeepSeekConfig; /** Resume a previous Claude conversation when respawning */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (preserved across respawns). */ diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 617c986ab..a82bdb373 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -115,6 +115,10 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string { antigravity: remoteLoginShellCommand('agy'), pi: remoteLoginShellCommand('pi'), grok: remoteLoginShellCommand('grok'), + // `dsh` alone boots nothing: the launcher needs a profile, and the remote box's + // profile inventory is unknown here. The per-host `commands.deepseek` override + // is the escape hatch for naming one. + deepseek: remoteLoginShellCommand('dsh'), }; return commands[mode as RemoteCommandMode] || commands.shell; } diff --git a/src/session.ts b/src/session.ts index 593ffc379..6559a6019 100644 --- a/src/session.ts +++ b/src/session.ts @@ -52,6 +52,7 @@ import { type AntigravityConfig, type PiConfig, type GrokConfig, + type DeepSeekConfig, type SessionRemote, type SessionDocker, } from './types.js'; @@ -178,7 +179,8 @@ export function isExternalCliMode(mode: SessionMode): boolean { mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || - mode === 'grok' + mode === 'grok' || + mode === 'deepseek' ); } @@ -196,6 +198,8 @@ function getModeLabel(mode: SessionMode): string { return 'Pi'; case 'grok': return 'Grok'; + case 'deepseek': + return 'DeepSeek'; case 'shell': return 'Shell'; case 'claude': @@ -521,6 +525,9 @@ export class Session extends EventEmitter { private _piConfig: PiConfig | undefined; // Grok configuration (only for mode === 'grok') private _grokConfig: GrokConfig | undefined; + + // DeepSeek Harness configuration (only for mode === 'deepseek') + private _deepSeekConfig: DeepSeekConfig | undefined; private _resumeSessionId: string | undefined; // Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux @@ -618,6 +625,8 @@ export class Session extends EventEmitter { piConfig?: PiConfig; /** Grok configuration (only for mode === 'grok') */ grokConfig?: GrokConfig; + /** DeepSeek Harness configuration (only for mode === 'deepseek') */ + deepSeekConfig?: DeepSeekConfig; /** Resume a previous Claude conversation (used after server reboot) */ resumeSessionId?: string; /** Extra env vars exported to the CLI at spawn time (no disk persistence) */ @@ -727,6 +736,11 @@ export class Session extends EventEmitter { this._piConfig = config.piConfig; } + // Apply DeepSeek Harness configuration + if (config.deepSeekConfig) { + this._deepSeekConfig = config.deepSeekConfig; + } + // Apply Grok configuration if (config.grokConfig) { this._grokConfig = config.grokConfig; @@ -1325,6 +1339,7 @@ export class Session extends EventEmitter { antigravityConfig: this._antigravityConfig, piConfig: this._piConfig, grokConfig: this._grokConfig, + deepSeekConfig: this._deepSeekConfig, resumeSessionId: this._resumeSessionId, effort: this._effort, // COD-118: runtime-only — surfaced so the frontend can require explicit user @@ -1501,7 +1516,8 @@ export class Session extends EventEmitter { this.mode === 'gemini' || this.mode === 'antigravity' || this.mode === 'pi' || - this.mode === 'grok' + this.mode === 'grok' || + this.mode === 'deepseek' ), }) ); @@ -1572,6 +1588,7 @@ export class Session extends EventEmitter { antigravityConfig: this._antigravityConfig, piConfig: this._piConfig, grokConfig: this._grokConfig, + deepSeekConfig: this._deepSeekConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1831,6 +1848,7 @@ export class Session extends EventEmitter { antigravityConfig: this._antigravityConfig, piConfig: this._piConfig, grokConfig: this._grokConfig, + deepSeekConfig: this._deepSeekConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1924,6 +1942,12 @@ export class Session extends EventEmitter { if (this.mode === 'grok') { throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.'); } + // DeepSeek sessions require tmux for DEEPSEEK_API_KEY / DSH_PERMISSION_MODE + // injection via setenv — and for the HERDR_* status-bridge triple, without + // which the mode silently loses its definitive idle/blocked signals. + if (this.mode === 'deepseek') { + throw new Error('DeepSeek Harness sessions require tmux. Direct PTY fallback is not supported.'); + } try { // Pass --session-id to use the SAME ID as the Codeman session // This ensures subagents can be directly matched to the correct tab diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index a97bc33cd..5088793df 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -53,6 +53,7 @@ import { type AntigravityConfig, type PiConfig, type GrokConfig, + type DeepSeekConfig, type SessionRemote, type SessionDocker, type DockerCommandMode, @@ -95,6 +96,9 @@ import { getPiNotFoundMessage, resolveGrokDir, getGrokNotFoundMessage, + resolveDeepSeekDir, + getDeepSeekNotFoundMessage, + resolveDefaultDeepSeekProfile, resolveLocalShell, loginShellArgs, } from './utils/index.js'; @@ -119,6 +123,7 @@ import { // ============================================================================ import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; +import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js'; /** How long a cached process snapshot stays usable. */ const PROC_SNAPSHOT_TTL_MS = 2000; @@ -846,6 +851,51 @@ function buildGrokCommand(config?: GrokConfig): string { return parts.join(' '); } +/** + * Build the DeepSeek Harness (`dsh`) command with appropriate flags. + * + * Unlike every sibling builder, the interesting decision here is not a flag but + * WHICH PROFILE to boot: `dsh` is a launcher over `$DSH_HOME/profiles/`, + * and DeepSeek ships no interactive terminal profile of its own, so the agent a + * pane runs is always one the user installed. An absent `profile` resolves to + * the first pane-capable profile on the box; when there is none we still emit a + * bare `dsh --profile ` rather than inventing a name, because the + * availability gate in createSession() has already refused the spawn by then and + * this path only runs for a session that passed it. + * + * There is deliberately NO permission flag: the harness has none. The sandbox + * and approval rows read `DSH_PERMISSION_MODE`, exported through `tmux setenv` + * in buildEnvExports() so it never lands on this command line. + * + * Like the sibling builders, every user value is regex-allowlisted and silently + * DROPPED on failure: the result is interpolated into a `bash -c "..."` string. + */ +function buildDeepSeekCommand(config?: DeepSeekConfig): string { + const parts = ['dsh']; + + // A profile name is a single path segment: it is both interpolated into the + // shell line and joined into a filesystem path. + const requested = config?.profile; + const safeProfile = + requested && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(requested) + ? requested + : (resolveDefaultDeepSeekProfile() ?? undefined); + if (safeProfile) parts.push('--profile', safeProfile); + + // The launcher forwards everything after its own flags to the profile's app, + // which is where `--resume` is understood. An explicit id wins over the + // most-recent-session form, mirroring the sibling builders. + const safeSessionId = + config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; + if (safeSessionId) { + parts.push('--resume', safeSessionId); + } else if (config?.resumeSession) { + parts.push('--resume'); + } + + return parts.join(' '); +} + /** * Build the spawn command for any session mode. * Shared by createSession() and respawnPane() to avoid duplication. @@ -890,6 +940,7 @@ export function buildSpawnCommand(options: { antigravityConfig?: AntigravityConfig; piConfig?: PiConfig; grokConfig?: GrokConfig; + deepSeekConfig?: DeepSeekConfig; resumeSessionId?: string; effort?: EffortLevel; /** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */ @@ -942,6 +993,9 @@ export function buildSpawnCommand(options: { if (options.mode === 'grok') { return buildGrokCommand(options.grokConfig); } + if (options.mode === 'deepseek') { + return buildDeepSeekCommand(options.deepSeekConfig); + } // #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"` // argument of the respawn-pane line, which execSync runs through `/bin/sh -c`, // so a `$SHELL` here is expanded by the SERVER process's shell against the @@ -1159,6 +1213,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri return `${modeCommand} --session ${resumeId}`; case 'grok': return `${modeCommand} --resume ${resumeId}`; + case 'deepseek': + return `${modeCommand} --resume ${resumeId}`; default: return modeCommand; // shell / opencode: no resume } @@ -1749,10 +1805,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const exports = [ 'export LANG=en_US.UTF-8', 'export LC_ALL=en_US.UTF-8', - mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' + mode === 'codex' || + mode === 'gemini' || + mode === 'antigravity' || + mode === 'pi' || + mode === 'grok' || + mode === 'deepseek' ? 'export COLORTERM=truecolor' : 'unset COLORTERM', - ...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' + ...(mode === 'codex' || + mode === 'gemini' || + mode === 'antigravity' || + mode === 'pi' || + mode === 'grok' || + mode === 'deepseek' ? ['unset NO_COLOR'] : []), // Stamp each Codex pane with a unique originator so the response-viewer @@ -1853,6 +1919,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const dir = resolveGrokDir(); return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; } + if (mode === 'deepseek') { + const dir = resolveDeepSeekDir(); + return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; + } return { pathExport: '', dir: null }; } @@ -1883,6 +1953,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { setGeminiEnvVars(this.tmux(), muxName); } + /** + * Configure DeepSeek Harness environment on a tmux session. + * + * Two independent things, both via `tmux setenv` so they are inherited by the + * pane without appearing in `ps`: + * + * 1. `DSH_PERMISSION_MODE` — the harness's only permission input. Exported + * ONLY when the caller sent one, so an absent config lands on the harness's + * own `workspace-write` default (which asks) rather than on ours. That + * "only if sent" shape is what the multi-user clamp relies on. + * 2. The `HERDR_*` triple — the supervisor contract the terminal front door + * uses to report idle/working/blocked. Pointing `HERDR_BIN_PATH` at our own + * generated shim is what upgrades this mode from output-stabilization + * guessing to definitive hook events (see deepseek-status-shim.ts). The + * pane id IS the Codeman session id, which is how the shim attributes a + * report without trusting anything the agent could influence. + * + * Also forwards DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from the server env when + * present, matching the codex/gemini precedent for headless auth. + */ + private _configureDeepSeek(muxName: string, sessionId: string, config?: DeepSeekConfig): void { + const tmuxCmd = this.tmux(); + const setenv = (key: string, value: string): void => { + const escaped = value.replace(/'/g, "'\\''"); + try { + execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + /* Non-critical */ + } + }; + + for (const key of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DSH_HOME']) { + const val = process.env[key]; + if (val) setenv(key, val); + } + + // Enum-validated at the schema boundary; re-checked here because this value + // reaches a shell line, and a builder must never trust its caller. + if ( + config?.permissionMode && + ['read-only', 'workspace-write', 'danger-full-access'].includes(config.permissionMode) + ) { + setenv('DSH_PERMISSION_MODE', config.permissionMode); + } + + if (config?.statusReporting !== false) { + const shim = ensureDeepSeekStatusShim(); + if (shim) { + setenv('HERDR_ENV', '1'); + setenv('HERDR_BIN_PATH', shim); + setenv('HERDR_PANE_ID', sessionId); + } + } + } + /** * Creates a new tmux session wrapping Claude CLI or a shell. * In test mode: creates an in-memory session only (no real tmux session). @@ -1903,6 +2032,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { antigravityConfig, piConfig, grokConfig, + deepSeekConfig, resumeSessionId, envOverrides, effort, @@ -1963,6 +2093,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (mode === 'pi' && !cliDir) { throw new Error(getPiNotFoundMessage()); } + if (mode === 'deepseek' && !cliDir) { + throw new Error(getDeepSeekNotFoundMessage()); + } if (mode === 'grok' && !cliDir) { throw new Error(getGrokNotFoundMessage()); } @@ -1981,6 +2114,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { antigravityConfig, piConfig, grokConfig, + deepSeekConfig, resumeSessionId, effort, sessionName: name, @@ -2049,6 +2183,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (mode === 'gemini') { this._configureGemini(muxName); } + // For DeepSeek: permission mode + the Herdr-compatible status bridge. + if (mode === 'deepseek') { + this._configureDeepSeek(muxName, sessionId, deepSeekConfig); + } // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv // so secret values stay off the bash command line. Must run before respawn-pane. @@ -2206,6 +2344,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { antigravityConfig, piConfig, grokConfig, + deepSeekConfig, resumeSessionId, envOverrides, effort, @@ -2236,6 +2375,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { antigravityConfig, piConfig, grokConfig, + deepSeekConfig, resumeSessionId, effort, sessionName: name, @@ -2260,6 +2400,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (mode === 'gemini') { this._configureGemini(muxName); } + // For DeepSeek: permission mode + the Herdr-compatible status bridge. + if (mode === 'deepseek') { + this._configureDeepSeek(muxName, sessionId, deepSeekConfig); + } // Re-apply user env overrides before respawn so the new shell inherits them. this.applyEnvOverrides(muxName, envOverrides); diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index c2074c853..b482ad157 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -1014,6 +1014,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string { id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' }, { id: 'pi', label: 'pi', detail: 'pi.dev' }, { id: 'grok', label: 'grok', detail: 'xAI Grok Build' }, + { id: 'deepseek', label: 'deepseek', detail: 'DeepSeek Harness (dsh)' }, ]; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 64c61d891..56e515db5 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -149,7 +149,7 @@ export type TuiAnswerResult = export interface TuiQuickStartOptions { caseName: string; - mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'; + mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'; sessionName?: string; /** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */ parentSessionId?: string; diff --git a/src/types/session.ts b/src/types/session.ts index 5297701fa..8e429459d 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -8,7 +8,7 @@ * - SessionConfig — creation-time config (id, workingDir, createdAt) * - SessionOutput — captured stdout/stderr/exitCode * - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' - * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' (which CLI backend) + * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' (which CLI backend) * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools') * - SessionColor — visual differentiation color * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) @@ -17,6 +17,7 @@ * - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId) * - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust) * - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue) + * - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge) * * Cross-domain relationships: * - SessionState.respawnConfig embeds RespawnConfig (respawn domain) @@ -45,11 +46,20 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error'; export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools'; /** Session mode: which CLI backend a session runs */ -export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'; +export type SessionMode = + | 'claude' + | 'shell' + | 'opencode' + | 'codex' + | 'gemini' + | 'antigravity' + | 'pi' + | 'grok' + | 'deepseek'; export type RemoteCommandMode = Extract< SessionMode, - 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' + 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' >; /** @@ -158,7 +168,7 @@ export interface RemoteSessionInfo { /** Which CLI backends a Docker case can run (same set as remote). */ export type DockerCommandMode = Extract< SessionMode, - 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' + 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' >; /** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */ @@ -388,6 +398,61 @@ export interface GrokConfig { resumeSessionId?: string; } +/** + * DeepSeek Harness (`dsh`) session configuration. + * + * Two things make this config shaped unlike every sibling above it. + * + * **1. The agent is a PROFILE, not the binary.** `dsh` is a launcher: it boots + * `$DSH_HOME/profiles/`, an ordered stack of plugin-bundle patch layers. + * DeepSeek ships only `web`, `headless` and `base`, so the interactive terminal + * agent is always a third-party profile the user installed. `profile` is + * therefore the primary knob, and an absent one resolves to the first + * pane-capable profile found (see resolveDefaultDeepSeekProfile). + * + * **2. Permissions are an ENV VAR, not a flag.** The harness has no + * `--dangerously-skip-permissions` equivalent; its sandbox and approval rows are + * config, driven by one documented input, `DSH_PERMISSION_MODE`, with three + * presets (measured from `dsh --dump-default-config`): + * + * read-only sandbox read-only, approval ask + * workspace-write sandbox workspace-write, approval ask <- default + * danger-full-access sandbox danger-full-access, approval never + * + * This is the one place a Codeman env export is the RIGHT mechanism rather than + * the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks + * in-session `/effort`), `DSH_PERMISSION_MODE` is read with `??` as a boot-time + * DEFAULT, so it stays a soft default the user can still change in-session. It + * is exported via `tmux setenv`, never on the spawn command line. + */ +export interface DeepSeekConfig { + /** + * Profile under `$DSH_HOME/profiles` to boot (`dsh --profile `). Absent + * = the first pane-capable profile installed. A `web`/`headless` profile is + * refused at spawn time: neither can drive an interactive pane. + */ + profile?: string; + /** + * Sandbox + approval preset, exported as `DSH_PERMISSION_MODE`. Absent = the + * harness's own `workspace-write` default, which still ASKS — which is why the + * multi-user clamp only needs the only-if-sent branch here, like + * codex/antigravity/grok rather than pi. + */ + permissionMode?: 'read-only' | 'workspace-write' | 'danger-full-access'; + /** Resume the most recent session for this workspace (`--resume`). */ + resumeSession?: boolean; + /** Resume a specific session by ID (`--resume `). Wins over resumeSession. */ + resumeSessionId?: string; + /** + * Report idle/working/blocked back to Codeman through the Herdr-compatible + * status shim (see `deepseek-status-shim.ts`). Default ON: it upgrades this + * mode from output-stabilization guessing to definitive hook events. Only + * TUIs that implement the contract report; for one that does not, this is + * inert rather than harmful. + */ + statusReporting?: boolean; +} + /** * Configuration for creating a new session */ @@ -553,6 +618,8 @@ export interface SessionState { piConfig?: PiConfig; /** Grok-specific configuration (only for mode === 'grok') */ grokConfig?: GrokConfig; + /** DeepSeek Harness configuration (only for mode === 'deepseek') */ + deepSeekConfig?: DeepSeekConfig; /** Claude conversation session ID to resume after reboot (set by restore script) */ resumeSessionId?: string; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ diff --git a/src/utils/deepseek-cli-resolver.ts b/src/utils/deepseek-cli-resolver.ts new file mode 100644 index 000000000..c30604b31 --- /dev/null +++ b/src/utils/deepseek-cli-resolver.ts @@ -0,0 +1,337 @@ +/** + * @fileoverview Resolve the DeepSeek Harness CLI (`dsh`) binary and its bootable profiles. + * + * Mirrors pi-cli-resolver.ts / grok-cli-resolver.ts, but the identity probe here + * is STRICTER than either, and deliberately so: `dsh` is not merely a short name + * with npm squatters, it is an EXISTING, widely packaged Unix program. Debian and + * Ubuntu ship `dsh` = "dancer's shell" / distributed shell (`apt install dsh`), + * which like nearly every Unix tool prints a version-shaped string of its own. + * A version-token probe alone (which is all pi and grok need) would + * therefore ACCEPT dancer's shell as the DeepSeek Harness and hand it to a spawn + * line, so every candidate must additionally prove its identity by printing the + * harness's own help banner. + * + * Two probes per candidate, both bounded and both cached behind the shared + * resolver's positive/negative caching: + * 1. `dsh --help` must match DEEPSEEK_IDENTITY_REGEX (`DeepSeek Harness`) + * 2. `dsh --version` must yield a version token (real output: `0.1.1-rc.2`) + * Order matters: identity is checked FIRST, so a foreign `dsh` is rejected on the + * cheaper, more discriminating signal and never contributes a version number. + * + * `dsh` is a profile LAUNCHER, not an agent: `dsh --profile ` boots an + * ordered stack of plugin-bundle patch layers, and DeepSeek ships only `web` + * (browser UI), `headless` (one-shot) and `base` (no app). The interactive + * terminal agent Codeman actually drives is a THIRD-PARTY profile the user + * installs. That is why this module resolves two independent things — a binary + * AND a profile inventory — and why "available" for the deepseek run mode means + * both (`isDeepSeekRunnable`, and `resolveDeepSeekLaunchError` in session-routes.ts + * for the actionable per-half message). + * + * @module utils/deepseek-cli-resolver + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { + createCliExecutableResolver, + formatCliNotFoundMessage, + type CliResolverHost, +} from './cli-executable-resolver.js'; + +/** + * Common directories where the `dsh` binary may be installed. + * + * `dsh` is an npm package (`@deepseek-ai/dsh`), so unlike grok there is no + * vendor-owned install dir to lead with: the global npm bin is wherever the + * user's prefix points. `~/.local/bin` heads the list because it is the default + * for a prefix-relocated npm (and is where this box's install landed). + */ +const DEEPSEEK_SEARCH_DIRS = [ + join(homedir(), '.local', 'bin'), + '/usr/local/bin', + join(homedir(), '.npm-global', 'bin'), + join(homedir(), 'bin'), +]; + +/** + * A real `dsh --version` prints a bare `0.1.1-rc.2` (measured, 0.1.1-rc.2), so + * the prerelease suffix is part of the token — truncating it to `0.1.1` would + * misreport a release-candidate as a release in `codeman doctor`. + * + * Exported and SHARED with the `dsh` entry in `config/dependency-registry.ts`, + * so the doctor and the run mode cannot disagree about what counts as an + * installed dsh (the same single-source rule as PI_VERSION_REGEX / + * GROK_VERSION_REGEX). Shape is dictated by the doctor's `extractVersion()` + * (first capture group, whole-output scan): hence a capturing group and a + * leading boundary instead of `^`. No `g` flag, so there is no shared + * `lastIndex` to reset. + */ +export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)/; + +/** + * The identity marker that separates DeepSeek's `dsh` from Debian's dancer's + * shell. The real launcher's `--help` banner reads: + * + * dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle … + * + * Matched case-insensitively against the help output. This is the check that + * makes the resolver safe to point a spawn line at; see the module header. + */ +export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i; + +const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'; + +/** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */ +export function resolveDshHome(): string { + const fromEnv = process.env.DSH_HOME?.trim(); + return fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh'); +} + +/** + * What a profile is FOR, inferred from the bundles it composes. + * + * `interactive` is the only kind a tmux pane can drive: `web` serves a browser + * UI and would occupy the pane with a logging server, `headless` answers one + * task and exits (which reads as an instantly-dead pane). `unknown` is treated + * as interactive-capable on purpose — the whole point of the harness is that + * anyone can publish an app bundle, so an unrecognized third-party profile must + * not be hidden from the picker just because this list has not heard of it. + */ +export type DeepSeekProfileKind = 'interactive' | 'web' | 'headless' | 'unknown'; + +export interface DeepSeekProfile { + /** Directory name under `$DSH_HOME/profiles`, i.e. the `--profile` argument. */ + name: string; + /** Bundle package names composed by the profile, in order. */ + bundles: string[]; + kind: DeepSeekProfileKind; +} + +/** Bundles that positively identify a non-interactive profile. */ +const WEB_BUNDLE_PATTERN = /dsh-web-app|dsh-web-frontend/i; +const HEADLESS_BUNDLE_PATTERN = /dsh-headless/i; +/** + * Bundles that positively identify a terminal app. Intentionally a loose + * community-wide pattern rather than one blessed package: the terminal front + * door is third-party by construction (DeepSeek ships none), and a dozen + * scoped `dsh-tui` packages from a dozen different authors compete. Anything + * matching is a TUI; anything unmatched is `unknown`, which still counts as + * launchable. + */ +const TUI_BUNDLE_PATTERN = /dsh-tui|dsh-terminal-app|tui/i; + +/** Profile directory names that are not profiles. */ +const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']); + +function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind { + const haystack = [name, ...bundles].join(' '); + // Order matters: a profile that composes BOTH a web app and a tui bundle is a + // web profile as far as a tmux pane is concerned, because the web app owns the + // process and blocks. + if (WEB_BUNDLE_PATTERN.test(haystack)) return 'web'; + if (HEADLESS_BUNDLE_PATTERN.test(haystack)) return 'headless'; + if (TUI_BUNDLE_PATTERN.test(haystack)) return 'interactive'; + return 'unknown'; +} + +/** + * Read a single profile directory's `package.json` and return its bundle list. + * Returns null for anything that is not a readable dsh profile, so a stray + * directory under `profiles/` cannot break the inventory. + */ +function readProfile(profilesDir: string, name: string): DeepSeekProfile | null { + try { + const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8'); + const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } }; + const rawBundles = parsed?.dsh?.profile?.bundles; + const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : []; + return { name, bundles, kind: classifyProfile(name, bundles) }; + } catch { + return null; + } +} + +/** + * Inventory the profiles installed under `$DSH_HOME/profiles`. + * + * Never throws: a missing DSH_HOME (dsh installed but never run) is an empty + * list, which the callers render as "no profile yet" rather than an error. + * Deliberately un-cached — a user can create a profile at any moment (including + * through Codeman's own bootstrap), and the directory scan is cheap next to the + * two process spawns the binary probe already costs. + */ +export function listDeepSeekProfiles(): DeepSeekProfile[] { + const profilesDir = join(resolveDshHome(), 'profiles'); + let entries: string[]; + try { + entries = readdirSync(profilesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.')) + .map((e) => e.name); + } catch { + return []; + } + return entries + .map((name) => readProfile(profilesDir, name)) + .filter((p): p is DeepSeekProfile => p !== null) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * The profile a session should boot when the user picked none. + * + * Prefers a positively-identified terminal profile, then an unrecognized one + * (third-party by construction — see TUI_BUNDLE_PATTERN), and refuses to fall + * back to `web`/`headless`, which cannot drive a pane. Returns null when nothing + * launchable is installed, which is what makes the mode report unavailable + * instead of spawning a pane that dies on arrival. + */ +export function resolveDefaultDeepSeekProfile(profiles: DeepSeekProfile[] = listDeepSeekProfiles()): string | null { + return ( + profiles.find((p) => p.kind === 'interactive')?.name ?? profiles.find((p) => p.kind === 'unknown')?.name ?? null + ); +} + +/** True when the profile can occupy a tmux pane as an interactive agent. */ +export function isLaunchableProfile(profile: DeepSeekProfile): boolean { + return profile.kind === 'interactive' || profile.kind === 'unknown'; +} + +/** + * Run the two-stage identity+version probe on a candidate path. + * + * Returns the version token only when the binary proves it is the DeepSeek + * Harness launcher. Returns null for anything else: a missing binary, a + * non-zero exit, a hang (timeout), a help banner without the harness marker + * (this is the dancer's-shell rejection), or output with no version-shaped + * token. + * + * Never runs under vitest: the suites must stay hermetic and must not depend on + * whether the dev box happens to have dsh installed — and since `dsh` names a + * real Debian program, this probe would EXECUTE whatever binary of that name the + * machine carries. The shared resolver host is already inert under vitest, so + * this gate is defense in depth for any opted-in host that still carries the + * default probe; tests drive resolution via `createDeepSeekResolverForTest`, + * whose injected probe bypasses it. Pinned by test/deepseek-cli-resolver.test.ts. + */ +function probeDeepSeekVersion(binPath: string): string | null { + if (process.env.VITEST) return null; + const run = (args: string[]): string | null => { + try { + return execFileSync(binPath, args, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + // A stuck or hostile `dsh` that ignores SIGTERM would survive the timeout + // and block the server (execFileSync keeps waiting after the signal). + killSignal: 'SIGKILL', + }).trim(); + } catch (err) { + console.warn( + `[DeepSeekResolver] Ignoring ${binPath}: "dsh ${args.join(' ')}" failed (${(err as Error).message})` + ); + return null; + } + }; + + // Identity first — the discriminating signal, and the one that keeps Debian's + // dancer's shell out of a spawn line. + const help = run(['--help']); + if (help === null) return null; + if (!DEEPSEEK_IDENTITY_REGEX.test(help)) { + console.warn( + `[DeepSeekResolver] Ignoring ${binPath}: "dsh --help" is not the DeepSeek Harness launcher ` + + `(printed ${JSON.stringify(help.slice(0, 80))}). A different program named "dsh" (e.g. Debian's ` + + `dancer's shell) is earlier on PATH.` + ); + return null; + } + + const out = run(['--version']); + if (out === null) return null; + const candidate = DEEPSEEK_VERSION_REGEX.exec(out)?.[1]; + if (candidate) return candidate; + console.warn(`[DeepSeekResolver] Ignoring ${binPath}: "dsh --version" printed ${JSON.stringify(out.slice(0, 80))}`); + return null; +} + +type DeepSeekVersionProbe = (binPath: string) => string | null; + +function createDeepSeekResolver( + host?: CliResolverHost, + versionProbe: DeepSeekVersionProbe = probeDeepSeekVersion, + now?: () => number +) { + return createCliExecutableResolver( + { + binary: 'dsh', + searchDirs: DEEPSEEK_SEARCH_DIRS, + validateCandidate: (binPath) => { + const version = versionProbe(binPath); + return version ? { accepted: true, metadata: version } : { accepted: false }; + }, + now, + }, + host + ); +} + +/** + * Creates an isolated DeepSeek wrapper around an injected host, version probe + * and clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, + * which is exactly what the hermeticity test exercises. + */ +export function createDeepSeekResolverForTest( + host: CliResolverHost, + versionProbe?: DeepSeekVersionProbe, + now?: () => number +) { + return createDeepSeekResolver(host, versionProbe ?? probeDeepSeekVersion, now); +} + +const deepSeekResolver = createDeepSeekResolver(); + +/** + * Finds the directory containing a verified `dsh` binary. + * Checks the server PATH first, then the common install locations. Every + * candidate must pass the identity+version probe before it is accepted. + * + * @returns Directory path, or null if not found + */ +export function resolveDeepSeekDir(): string | null { + return deepSeekResolver.resolve()?.directory ?? null; +} + +/** + * Whether the `dsh` BINARY is installed. Note this is deliberately weaker than + * what the run mode needs: a dsh with no launchable profile cannot start a + * session. Callers gating the Run button want `isDeepSeekRunnable()`. + */ +export function isDeepSeekAvailable(): boolean { + return resolveDeepSeekDir() !== null; +} + +/** Binary present AND at least one profile that can occupy a pane. */ +export function isDeepSeekRunnable(): boolean { + return isDeepSeekAvailable() && resolveDefaultDeepSeekProfile() !== null; +} + +export function getDeepSeekNotFoundMessage(): string { + return formatCliNotFoundMessage(DEEPSEEK_NOT_FOUND, deepSeekResolver.diagnostics()); +} + +/** + * Version reported by the resolved `dsh` binary, or null when dsh is + * unavailable. Surfaced through `GET /api/deepseek/status` so a misresolution + * is diagnosable from the UI. + */ +export function getDeepSeekCliVersion(): string | null { + return deepSeekResolver.resolve()?.metadata ?? null; +} + +/** Does the named profile exist and can it drive a pane? */ +export function profileExists(name: string): boolean { + return existsSync(join(resolveDshHome(), 'profiles', name, 'package.json')); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 625005594..44dab03da 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -46,5 +46,18 @@ export { } from './antigravity-cli-resolver.js'; export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js'; export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-cli-resolver.js'; +export { + resolveDeepSeekDir, + isDeepSeekAvailable, + isDeepSeekRunnable, + getDeepSeekCliVersion, + getDeepSeekNotFoundMessage, + listDeepSeekProfiles, + resolveDefaultDeepSeekProfile, + isLaunchableProfile, + resolveDshHome, + profileExists, +} from './deepseek-cli-resolver.js'; +export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js'; export { compileFileQuery, matchFileQuery } from './file-query.js'; export type { FileQueryMatcher } from './file-query.js'; diff --git a/src/web/public/app.js b/src/web/public/app.js index 52e97b149..3b51a2f86 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -240,6 +240,7 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'], [SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'], [SSE_EVENTS.HOOK_STOP, '_onHookStop'], + [SSE_EVENTS.HOOK_AGENT_WORKING, '_onHookAgentWorking'], [SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'], [SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'], @@ -2252,9 +2253,11 @@ class CodemanApp { ? 'Pi' : mode === 'grok' ? 'Grok' - : mode === 'opencode' - ? 'OpenCode' - : 'Claude'; + : mode === 'deepseek' + ? 'DeepSeek' + : mode === 'opencode' + ? 'OpenCode' + : 'Claude'; } async toggleResponseViewer() { @@ -4805,7 +4808,7 @@ class CodemanApp { - ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : mode === 'antigravity' ? '' : mode === 'pi' ? '' : mode === 'grok' ? '' : ''} + ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : mode === 'antigravity' ? '' : mode === 'pi' ? '' : mode === 'grok' ? '' : mode === 'deepseek' ? '' : ''} ${tabLabel} ${inlineSessionActions ? tabActionsHtml : ''} @@ -6238,7 +6241,9 @@ class CodemanApp { ? 'Kill Tmux & Pi' : session.mode === 'grok' ? 'Kill Tmux & Grok' - : 'Kill Tmux & Claude Code'; + : session.mode === 'deepseek' + ? 'Kill Tmux & DeepSeek' + : 'Kill Tmux & Claude Code'; } document.getElementById('closeConfirmModal').classList.add('active'); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 8fcc4c4b2..851e5c7c5 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -54,6 +54,12 @@ const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notificati const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications const THROTTLE_DELAY_MS = 100; // General UI throttle delay +/** + * Port the DeepSeek Harness browser UI is started on by the run-menu shortcut. + * dsh's own default, so a hand-started `dsh web` and the shortcut land on the + * same place and share one saved tab. + */ +const DEEPSEEK_WEB_PORT = 3080; const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch) const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync @@ -949,6 +955,7 @@ const SSE_EVENTS = { HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete', HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response', HOOK_STOP: 'hook:stop', + HOOK_AGENT_WORKING: 'hook:agent_working', HOOK_TEAMMATE_IDLE: 'hook:teammate_idle', HOOK_TASK_COMPLETED: 'hook:task_completed', diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index 840cf2094..5a102eaa8 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -79,6 +79,7 @@ const HOME_SESSIONS_MODE_BADGE = { antigravity: 'ag', pi: 'pi', grok: 'gk', + deepseek: 'ds', }; Object.assign(CodemanApp.prototype, { diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index a948dc584..fbdcc2962 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -110,6 +110,7 @@ 'Run Antigravity': '运行 Antigravity', 'Run Pi': '运行 Pi', 'Run Grok': '运行 Grok', + 'Run DeepSeek': '运行 DeepSeek', 'Run Shell': '运行 Shell', 'Select AI backend': '选择 AI 后端', 'Create New Case': '新建案例', diff --git a/src/web/public/index.html b/src/web/public/index.html index 2031f5e26..c8e7df5c0 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -448,6 +448,10 @@

Codeman

Run Grok +
@@ -630,6 +634,15 @@

Resume Conversation

+ + +
+ +
Recent Sessions
@@ -908,6 +928,7 @@

Cron Jobs

+
@@ -2676,6 +2697,7 @@

Clone Repo

+ Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown. @@ -2816,7 +2838,7 @@

Docker

- Build it once with node scripts/build-agent-image.mjs. Contains node + claude/codex/gemini/opencode/agy/pi/grok + tmux. + Build it once with node scripts/build-agent-image.mjs. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.
diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index 6e9bf40a6..2fbcf70ef 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -55,6 +55,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [ { mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' }, { mode: 'pi', label: 'Pi', short: 'Pi' }, { mode: 'grok', label: 'Grok', short: 'Grok' }, + { mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' }, { mode: 'shell', label: 'Terminal / Shell', short: 'Shell' }, ]; diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index a8796851d..e0ddb53de 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -986,6 +986,23 @@ html.mobile-init .file-browser-panel { border-color: rgba(212, 212, 216, 0.5) !important; } + /* DeepSeek mode colors on mobile. Same `!important` rationale as the pi and + grok blocks above: styles.css nests its skin rules inside + `html:not([data-skin="og"])`, so a bare `.btn-toolbar` rule there outranks a + `.btn-toolbar.btn-x` rule here regardless of load order. */ + .btn-toolbar.btn-run.mode-deepseek, + .btn-toolbar.btn-run-gear.mode-deepseek { + background: #16225f !important; + border-color: rgba(124, 147, 255, 0.35) !important; + color: #eef2ff !important; + } + + .btn-toolbar.btn-run.mode-deepseek:active, + .btn-toolbar.btn-run-gear.mode-deepseek:active { + background: #3350e6 !important; + border-color: rgba(150, 170, 255, 0.55) !important; + } + /* Run mode dropdown menu — positioned above toolbar on mobile */ .run-mode-menu { bottom: 100%; @@ -3075,6 +3092,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat color: #ffffff; } +html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-deepseek, .btn-toolbar.btn-run-gear.mode-deepseek) { + background: linear-gradient(135deg, #2740c4, #4d6bfe); + border-color: #1b2a8f; + color: #ffffff; +} + html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear { border-left-color: var(--control-border-hover) !important; } diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 52a4c8ea7..b4f8cc9f5 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, { _buildCommandPaletteNewSessionItem(query = '') { const mode = this.runMode || this._runMode || 'claude'; - const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok' }; + const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek' }; const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase'; return { id: 'new-session', diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index ed014e3b7..3599cd93a 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1,5 +1,5 @@ /** - * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok), + * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok/DeepSeek), * session options modal (per-session settings, color picker, rename), * session options tabs (Ralph config tab), case settings (CRUD, links), * create case modal, and mobile case picker. @@ -406,6 +406,9 @@ Object.assign(CodemanApp.prototype, { if (mode === 'grok') { return await this.runGrok(); } + if (mode === 'deepseek') { + return await this.runDeepSeek(); + } if (mode === 'shell') { return await this.runShell(); } @@ -471,10 +474,121 @@ Object.assign(CodemanApp.prototype, { * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']) { + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; } + // DeepSeek is the one mode whose availability has two halves: `dsh` can be + // perfectly installed while no pane-capable profile exists, because DeepSeek + // ships no terminal front door. In that state the honest offer is "add one", + // not a hidden entry with no explanation anywhere. + const avail = window.__codemanCliAvailable || {}; + const dsInstall = menu.querySelector('#runModeDeepSeekInstall'); + if (dsInstall) { + dsInstall.style.display = !avail.deepseek && avail.deepseekBinary ? 'flex' : 'none'; + } + // The web UI needs only the BINARY: it is the one interactive surface + // DeepSeek ships itself, so it works on a box with no terminal profile at + // all (and is the honest thing to offer there). + const dsWeb = menu.querySelector('#runModeDeepSeekWeb'); + if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none'; + }, + + /** + * Start the DeepSeek Harness browser UI and open it as a Codeman web tab. + * + * Deliberately built from parts that already exist rather than a new process + * manager: the server runs in an ordinary SHELL session, so it is visible, + * scrollable, killable and dies with its tab like anything else, and the UI + * itself is an ordinary web tab. Nothing here needs to know how to supervise a + * long-lived HTTP server, because Codeman already does. + * + * `--trusted-host` is the load-bearing flag: dsh fences its `/api` behind a + * browser-trust check on the request authority, and a Codeman web tab reaches + * it through Codeman's own origin via the webview proxy, not directly. Without + * passing Codeman's authority the page renders and every API call fails. + */ + async runDeepSeekWeb() { + document.getElementById('runModeMenu')?.classList.remove('active'); + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + const port = DEEPSEEK_WEB_PORT; + const url = `http://127.0.0.1:${port}`; + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting the DeepSeek web UI in ${caseName}...`); + + try { + const res = await fetch('/api/quick-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + caseName, + mode: 'shell', + sessionName: `dsh-web-${caseName}`, + }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to start the shell session'); + const sessionId = data.data.sessionId; + await this._ensureCreatedSessionVisible(sessionId, data.data.session); + + // The shell needs a moment to reach its prompt before it will accept a + // command; the same settle the other shell-driven flows use. + await new Promise((r) => setTimeout(r, 1200)); + const cmd = `dsh web --no-open --host 127.0.0.1 --port ${port} --trusted-host ${location.host}`; + await fetch(`/api/sessions/${sessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: `${cmd}\r` }), + }); + + // Reuse a saved tab for the same URL rather than stacking duplicates every + // time the server is restarted. + let webview = [...(this.webviews?.values() || [])].find((w) => w.url === url); + if (!webview) { + const wvRes = await fetch('/api/webviews', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'DeepSeek Harness', url, icon: '🐳' }), + }); + const wvData = await wvRes.json(); + if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab'); + webview = wvData.data.webview || wvData.data; + await this.loadWebviews?.(); + } + + this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} — opening it as a tab.`); + if (webview?.id) await this.openWebview(webview.id); + } catch (err) { + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); + } + }, + + /** + * Install a DeepSeek Harness terminal profile from the run menu. + * + * Held open for as long as the package manager takes (the endpoint bounds it), + * so the button reports progress rather than appearing to do nothing. On + * success the availability map is patched in place, which is what makes the + * real DeepSeek entry appear without a reload. + */ + async installDeepSeekProfile() { + const label = 'Installing a DeepSeek terminal profile (this can take a minute)...'; + const ownsLaunchTerminal = this._beginSessionLaunchStatus(label); + try { + const res = await fetch('/api/deepseek/install-profile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to install the profile'); + window.__codemanCliAvailable = { ...(window.__codemanCliAvailable || {}), deepseek: !!data.data.runnable }; + this._appendSessionLaunchStatus(ownsLaunchTerminal, `Installed ${data.data.package} into profile "${data.data.profile}".`); + this.showToast?.(`DeepSeek profile "${data.data.profile}" installed`, 'success'); + const menu = document.getElementById('runModeMenu'); + if (menu) this._refreshRunModeAvailability(menu); + } catch (err) { + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); + } }, async _loadRunModeHistory() { @@ -568,7 +682,7 @@ Object.assign(CodemanApp.prototype, { gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; } if (label) { - label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'shell' ? 'Run SH' : 'Run'; + label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'shell' ? 'Run SH' : 'Run'; } }, @@ -1341,6 +1455,84 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Launch a DeepSeek Harness (`dsh`) session. + * + * Sends `permissionMode: 'danger-full-access'` for the same reason every + * sibling Run button sends its bypass switch: Codeman sessions exist for + * autonomous work. The harness has no bypass FLAG, so this rides the + * `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it + * back down to `workspace-write` for non-granted owners server-side. + * + * `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from + * output-stabilization guessing to definitive idle/blocked hook events. + * + * The two-part availability check is deliberate. `dsh` being installed is not + * enough — DeepSeek ships no terminal front door, so a box can have a perfect + * binary and nothing a pane can run. Reporting that precisely, with the exact + * command that fixes it, is the difference between "the Run button is broken" + * and a 30-second fix. + */ + async runDeepSeek() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + // Remote/docker cases run dsh on the OTHER side: skip the local status probe and the + // local-only config/env below (quick-start rejects them for remote cases). + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; + + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting DeepSeek session in ${caseName}...`); + this.terminal.focus(); + + try { + if (!isRemote) { + const statusRes = await fetch('/api/deepseek/status'); + const status = (await statusRes.json()).data; + if (!status.available) { + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh' + ); + return; + } + if (!status.runnable) { + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' + + 'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' + + 'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui' + ); + return; + } + } + + const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); + const res = await fetch('/api/quick-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + caseName, + mode: 'deepseek', + sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, + ...(isRemote ? {} : { + deepSeekConfig: { permissionMode: 'danger-full-access' }, + ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), + }), + }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to start DeepSeek'); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + + if (data.data.sessionId) { + await this.selectSession(data.data.sessionId); + } + + this.terminal.focus(); + } catch (err) { + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); + } + }, + // ═══════════════════════════════════════════════════════════════ // Session Options Modal @@ -1406,7 +1598,7 @@ Object.assign(CodemanApp.prototype, { if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId); // Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only) - const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok'; + const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek'; this.switchOptionsTab(isAltMode ? 'summary' : 'respawn'); // Update respawn status display and buttons @@ -1436,7 +1628,7 @@ Object.assign(CodemanApp.prototype, { } // Hide Claude-specific options for external CLI sessions - const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok'; + const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek'; const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; }); @@ -3217,7 +3409,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', { }, set(mode) { this._runMode = - mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'claude' + mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'claude' ? mode : 'claude'; }, diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 9d9a42bd0..15015911a 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -67,6 +67,19 @@ Object.assign(CodemanApp.prototype, { this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding'); }, + _onHookAgentWorking(data) { + // The agent started a turn, so whatever it was blocked on is gone. Reported + // by the DeepSeek status bridge; a harness turn cannot run while one of its + // own modal approvals is on screen, so this means the dialog was answered in + // the terminal. Same clearing as _onHookElicitationComplete, and notably NOT + // a notification: a turn STARTING is not news. + if (data.sessionId) { + this.clearPendingHooks(data.sessionId, 'elicitation_dialog'); + this.clearPendingHooks(data.sessionId, 'permission_prompt'); + this.clearPendingHooks(data.sessionId, 'idle_prompt'); + } + }, + _onHookTeammateIdle(data) { const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`); @@ -1211,6 +1224,7 @@ Object.assign(CodemanApp.prototype, { ['welcomeGeminiBtn', 'gemini'], ['welcomePiBtn', 'pi'], ['welcomeGrokBtn', 'grok'], + ['welcomeDeepSeekBtn', 'deepseek'], // Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box // without cloudflared can only ever produce "cloudflared not found". ['welcomeTunnelBtn', 'cloudflared'], diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 7533ea50f..0fbe23cfa 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2501,6 +2501,16 @@ body.solo-mode .btn-lifecycle-log { color: #d4d4d8; } +/* DeepSeek: the vendor's own brand blue. Deliberately NOT added to the + light-skin `--accent-d` override list above (which rescues gemini/antigravity/ + pi/grok, whose pastels wash out on paper backgrounds) — this indigo already + carries enough contrast on the light skins, and overriding it would throw away + the one cue that separates a dsh tab from its neighbours. */ +.session-tab .tab-mode.deepseek { + background: rgba(77, 107, 254, 0.18); + color: #7c93ff; +} + /* Timer Banner - Compact */ .timer-banner { display: flex; @@ -4975,6 +4985,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: #fafafa; } +/* DeepSeek mode colors. Same cascade note as pi/grok above: this base-sheet pair + only renders on the `og` skin — the nested `html:not([data-skin="og"])` block + re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so deepseek also + carries a rule inside that block (search `.btn-toolbar.btn-run.mode-deepseek`). */ +.btn-toolbar.btn-run.mode-deepseek, +.btn-toolbar.btn-run-gear.mode-deepseek { + background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%); + border-color: rgba(124, 147, 255, 0.55); + color: #eef2ff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} +.btn-toolbar.btn-run.mode-deepseek:hover, +.btn-toolbar.btn-run-gear.mode-deepseek:hover { + background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%); + box-shadow: 0 0 12px rgba(77, 107, 254, 0.35), 0 2px 8px rgba(39, 64, 196, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(150, 170, 255, 0.65); + color: #f8faff; +} + /* Dropdown menu */ .run-mode-menu { display: none; @@ -5059,6 +5088,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { .run-mode-dot.antigravity { background: #22d3ee; } .run-mode-dot.pi { background: #f472b6; } .run-mode-dot.grok { background: #a1a1aa; } +.run-mode-dot.deepseek { background: #4d6bfe; } .run-mode-dot.shell { background: #94a3b8; } /* Phone-only Enter button (see index.html). Hidden by default at every width; @@ -14403,6 +14433,16 @@ html:not([data-skin="og"]) { color: #fafafa; } .btn-toolbar.btn-run.mode-grok:hover { box-shadow: 0 0 14px -2px rgba(161, 161, 170, 0.5); } +/* DeepSeek keeps its indigo on the non-og skins — same specificity trap as pi + and grok above: without this rule the generic `.btn-toolbar.btn-run` in this + nested block wins and deepseek renders as generic claude blue, which is the + one colour it must not be mistaken for. */ +.btn-toolbar.btn-run.mode-deepseek { + background: linear-gradient(135deg, #2740c4, #4d6bfe); + border-color: #1b2a8f; + color: #f8faff; +} +.btn-toolbar.btn-run.mode-deepseek:hover { box-shadow: 0 0 14px -2px rgba(77, 107, 254, 0.55); } .btn-toolbar.btn-run-gear { background: var(--accent-d); border-color: var(--accent); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 79a593490..9d5078679 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2214,7 +2214,7 @@ Object.assign(CodemanApp.prototype, { } titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir))); - // Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/shell) + a LIVE pill. + // Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/deepseek/shell) + a LIVE pill. const badgeRow = document.createElement('div'); badgeRow.className = 'history-item-badges'; if (s.mode) { diff --git a/src/web/response-viewer-transcript.ts b/src/web/response-viewer-transcript.ts index bb9039257..f27b4ed64 100644 --- a/src/web/response-viewer-transcript.ts +++ b/src/web/response-viewer-transcript.ts @@ -11,7 +11,7 @@ export interface ResponseViewerTranscriptBlock { // Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here // would drag node-pty and the whole session layer into this pure module, so the // list is duplicated and test/response-viewer-transcript.test.ts pins the parity. -const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok']); +const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']); function isPromptLine(line: string): boolean { return /^\s*›\s*/.test(line); diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index 440b18c99..338e8459b 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -24,8 +24,17 @@ const APPROVAL_KIND_BY_EVENT: Record = { idle_prompt: 'idle', }; -/** Hook events that close a session's pending item without an inbox answer. */ -const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response']); +/** + * Hook events that close a session's pending item without an inbox answer. + * + * `agent_working` is here because it is the DeepSeek status bridge's report that + * a turn STARTED, and a harness turn cannot be running while one of its own + * modal approvals is on screen — so the agent moving means the dialog was + * answered, in the terminal, by the user. That is the same conclusion the claude + * path reaches through pane capture, which cannot help here because its frame + * parser is Claude-dialog-shaped. + */ +const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response', 'agent_working']); export function registerHookEventRoutes( app: FastifyInstance, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index c5aeed84e..d0e766b28 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -25,6 +25,7 @@ import { type AntigravityConfig, type PiConfig, type GrokConfig, + type DeepSeekConfig, } from '../../types.js'; import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { SseEvent } from '../sse-events.js'; @@ -337,6 +338,14 @@ export function _resetPasteRateBuckets(): void { * Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove` * (`--always-approve`), and an ABSENT config already spawns in grok's own * ask-mode default, so only a sent config needs the flag forced off. + * + * DeepSeek joins the same only-if-sent branch, but its switch is not a flag: the + * harness has no command-line permission option, and its sandbox/approval rows + * read `DSH_PERMISSION_MODE`. Omitting that export leaves the harness on its own + * `workspace-write` preset, which still asks, so an absent config is already + * safe; a sent one is forced down to `workspace-write` rather than to + * `read-only`, because the clamp exists to remove PRIVILEGE, not to break a + * session's ability to edit its own workspace. */ async function clampExternalCliBypassForOwner( owner: string | undefined, @@ -344,16 +353,18 @@ async function clampExternalCliBypassForOwner( geminiConfig: GeminiConfig | undefined, antigravityConfig: AntigravityConfig | undefined, piConfig: PiConfig | undefined, - grokConfig: GrokConfig | undefined + grokConfig: GrokConfig | undefined, + deepSeekConfig: DeepSeekConfig | undefined ): Promise<{ codexConfig: CodexConfig | undefined; geminiConfig: GeminiConfig | undefined; antigravityConfig: AntigravityConfig | undefined; piConfig: PiConfig | undefined; grokConfig: GrokConfig | undefined; + deepSeekConfig: DeepSeekConfig | undefined; }> { const granted = await canUsernameRunPrivilegedCommands(owner); - if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig }; + if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig }; // Non-granted: force codex/antigravity bypass off (only meaningful when a config was // sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default) // and pi to --no-approve (clamps an explicit true AND pi's own "ask" default). @@ -364,18 +375,63 @@ async function clampExternalCliBypassForOwner( : antigravityConfig; const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false }; const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig; + const clampedDeepSeek = deepSeekConfig + ? { ...deepSeekConfig, permissionMode: 'workspace-write' as const } + : deepSeekConfig; return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity, piConfig: clampedPi, grokConfig: clampedGrok, + deepSeekConfig: clampedDeepSeek, }; } /** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner; +/** + * Why a DeepSeek session cannot start, or null when it can. + * + * Availability for this mode is TWO questions, not one, because `dsh` is a + * profile launcher rather than an agent: the binary must resolve (and prove it + * is the harness and not Debian's dancer's shell), AND a profile that can occupy + * a pane must exist. Reporting only the first would let the Run button spawn a + * pane that dies instantly, which is the single most confusing failure this mode + * can produce, so each half gets its own actionable message. + * + * A profile named EXPLICITLY is checked on both counts: existence, and whether + * it is pane-capable — `web` serves a browser UI and `headless` answers one task + * and exits, so both would present as "the tab immediately died". + */ +async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise { + const { isDeepSeekAvailable, getDeepSeekNotFoundMessage, listDeepSeekProfiles, resolveDefaultDeepSeekProfile } = + await import('../../utils/deepseek-cli-resolver.js'); + if (!isDeepSeekAvailable()) return getDeepSeekNotFoundMessage(); + + const profiles = listDeepSeekProfiles(); + if (requestedProfile) { + const match = profiles.find((p) => p.name === requestedProfile); + if (!match) { + return `DeepSeek Harness profile "${requestedProfile}" does not exist. Create it with: dsh plugin --profile ${requestedProfile} add `; + } + if (match.kind === 'web' || match.kind === 'headless') { + return `DeepSeek Harness profile "${requestedProfile}" is a ${match.kind} profile and cannot run in a terminal session. Pick an interactive profile, or open the web profile as a Codeman web tab.`; + } + return null; + } + + if (!resolveDefaultDeepSeekProfile()) { + return ( + 'No interactive DeepSeek Harness profile is installed. DeepSeek ships only the web and headless ' + + 'profiles, so the terminal agent comes from a plugin — install one with: ' + + 'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui' + ); + } + return null; +} + // ═══════════════════════════════════════════════════════════════ // Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input) // ═══════════════════════════════════════════════════════════════ @@ -770,6 +826,7 @@ export function registerSessionRoutes( body.mode !== 'antigravity' && body.mode !== 'pi' && body.mode !== 'grok' && + body.mode !== 'deepseek' && body.envOverrides && Object.keys(body.envOverrides).length > 0 && (workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/')); @@ -859,6 +916,10 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage()); } } + if (body.mode === 'deepseek') { + const err = await resolveDeepSeekLaunchError(body.deepSeekConfig?.profile); + if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err); + } if (body.mode === 'grok') { const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js'); if (!isGrokAvailable()) { @@ -912,7 +973,10 @@ export function registerSessionRoutes( ? body.piConfig?.model : mode === 'grok' ? body.grokConfig?.model - : mode !== 'shell' + : // DeepSeek's model is a composition entry in the profile's config + // tree, not a session flag, so there is deliberately nothing to + // read here (see docs/deepseek-integration.md). + mode !== 'shell' && mode !== 'deepseek' ? modelConfig?.defaultModel || undefined : undefined; const claudeModeConfig = await ctx.getClaudeModeConfig(); @@ -925,13 +989,15 @@ export function registerSessionRoutes( antigravityConfig: gatedAntigravityConfig, piConfig: gatedPiConfig, grokConfig: gatedGrokConfig, + deepSeekConfig: gatedDeepSeekConfig, } = await clampExternalCliBypassForOwner( owner, body.codexConfig, body.geminiConfig, body.antigravityConfig, body.piConfig, - body.grokConfig + body.grokConfig, + body.deepSeekConfig ); const terminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const session = new Session({ @@ -950,6 +1016,7 @@ export function registerSessionRoutes( antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined, piConfig: mode === 'pi' ? gatedPiConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, + deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, resumeSessionId: validatedResumeId, envOverrides: body.envOverrides, effort: body.effort, @@ -2717,6 +2784,7 @@ export function registerSessionRoutes( antigravityConfig, piConfig, grokConfig, + deepSeekConfig, envOverrides, effort, parentSessionId, @@ -2766,6 +2834,7 @@ export function registerSessionRoutes( antigravityConfig || piConfig || grokConfig || + deepSeekConfig || openCodeConfig ) { return createErrorResponse( @@ -2909,6 +2978,12 @@ export function registerSessionRoutes( } } + // Check DeepSeek Harness availability if requested (binary AND a pane-capable profile). + if (mode === 'deepseek') { + const err = await resolveDeepSeekLaunchError(deepSeekConfig?.profile); + if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err); + } + // Resolve case path: check linked-cases registry first, then fall back to CASES_DIR. // This mirrors the behaviour of resolveCasePath() in case-routes so that linked // external project directories are honoured by quick-start just like regular case routes. @@ -3036,6 +3111,7 @@ export function registerSessionRoutes( mode !== 'antigravity' && mode !== 'pi' && mode !== 'grok' && + mode !== 'deepseek' && !remote && envOverrides && Object.keys(envOverrides).length > 0 @@ -3060,7 +3136,8 @@ export function registerSessionRoutes( ? piConfig?.model : mode === 'grok' ? grokConfig?.model - : mode !== 'shell' + : // DeepSeek's model lives in the profile's config tree, not here. + mode !== 'shell' && mode !== 'deepseek' ? qsModelConfig?.defaultModel || undefined : undefined; const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); @@ -3072,7 +3149,16 @@ export function registerSessionRoutes( antigravityConfig: qsGatedAntigravityConfig, piConfig: qsGatedPiConfig, grokConfig: qsGatedGrokConfig, - } = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig); + deepSeekConfig: qsGatedDeepSeekConfig, + } = await clampExternalCliBypassForOwner( + owner, + codexConfig, + geminiConfig, + antigravityConfig, + piConfig, + grokConfig, + deepSeekConfig + ); const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const session = new Session({ workingDir: resolvedCasePath, @@ -3091,6 +3177,7 @@ export function registerSessionRoutes( antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined, piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, + deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, envOverrides, effort, remote, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 433829aba..ff83a0ea2 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -16,7 +16,7 @@ import { dataPath } from '../../config/instance.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; import { isMultiUserMode } from '../../config/multiuser.js'; -import { findUser } from '../../user-store.js'; +import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js'; import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js'; import { ConfigUpdateSchema, @@ -26,6 +26,7 @@ import { SubagentWindowStatesSchema, SubagentParentMapSchema, RevokeSessionSchema, + DeepSeekInstallProfileSchema, } from '../schemas.js'; import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; @@ -48,6 +49,7 @@ import { } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; + import { getRepositoryStatus } from '../repo-status.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; @@ -55,6 +57,20 @@ import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js'; import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js'; +/** + * Defaults for `POST /api/deepseek/install-profile`. + * + * The package is the community terminal front door with by far the widest use + * (~27.5k weekly downloads at time of writing, roughly 4x the next), MIT, and + * the one whose supervisor-reporting contract Codeman's status bridge speaks. + * It is a DEFAULT, not a hardcoding: the endpoint accepts any npm name, and the + * resolver never assumes this profile exists. + */ +const DEEPSEEK_DEFAULT_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui'; +const DEEPSEEK_DEFAULT_PROFILE = 'dsh-tui'; +/** A plugin install compiles and links a dependency tree; npm-scale, not curl-scale. */ +const DEEPSEEK_INSTALL_TIMEOUT_MS = 300_000; + // Maximum screenshot upload size (10MB) const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024; // Screenshots directory @@ -461,6 +477,111 @@ export function registerSystemRoutes( }; }); + // ========== DeepSeek Harness ========== + + // The widest of the per-CLI status shapes, because this mode has the widest + // failure surface. Three fields beyond the sibling `available`/`path`: + // + // - `version`, like pi/grok, so a misresolution is diagnosable — and here the + // stakes are higher, since `dsh` is also an existing Debian program + // (dancer's shell) rather than merely a squattable npm name. + // - `profiles`, because `dsh` is a LAUNCHER: a perfectly installed binary with + // no pane-capable profile cannot start a session, and the UI has to be able + // to say which of the two halves is missing. + // - `runnable` + `defaultProfile`, the answer the Run button actually needs, + // so no caller has to re-derive it from the parts and get it subtly wrong. + app.get('/api/deepseek/status', async () => { + const { + isDeepSeekAvailable, + isDeepSeekRunnable, + resolveDeepSeekDir, + getDeepSeekCliVersion, + listDeepSeekProfiles, + resolveDefaultDeepSeekProfile, + resolveDshHome, + } = await import('../../utils/deepseek-cli-resolver.js'); + return { + available: isDeepSeekAvailable(), + runnable: isDeepSeekRunnable(), + path: resolveDeepSeekDir(), + version: getDeepSeekCliVersion(), + dshHome: resolveDshHome(), + defaultProfile: resolveDefaultDeepSeekProfile(), + profiles: listDeepSeekProfiles(), + }; + }); + + // Bootstrap an interactive profile so the mode becomes usable. + // + // This exists because DeepSeek ships NO terminal front door: `dsh` on its own + // can only serve a browser UI or answer one headless task, and the agent a + // Codeman pane runs is always a plugin the user installed. Without this the + // mode's first-run experience is a dead Run button and a paragraph of shell + // instructions. + // + // It is the only endpoint in Codeman that installs third-party code, so it is + // fenced accordingly: + // - the privileged grant is required in multi-user mode (same bar as a + // `shell` session, which can already do strictly more); + // - the specifier is regex-confined to an npm name at the schema boundary — + // no path, URL, git spec, or leading dash; + // - the spawn is an argv ARRAY through the resolved `dsh`, never a shell + // string, so even a specifier that slipped the regex could not become a + // second command; + // - the request is held open with a bounded timeout, mirroring the + // synchronous-clone precedent in `POST /api/cases/clone` rather than + // introducing a job store for a once-per-install action. + app.post('/api/deepseek/install-profile', async (req) => { + const body = parseBody(DeepSeekInstallProfileSchema, req.body); + if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) { + return createErrorResponse( + ApiErrorCode.FORBIDDEN, + 'Installing a DeepSeek Harness profile requires the can-bypass-permissions grant' + ); + } + + const { resolveDeepSeekDir, getDeepSeekNotFoundMessage } = await import('../../utils/deepseek-cli-resolver.js'); + const dir = resolveDeepSeekDir(); + if (!dir) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getDeepSeekNotFoundMessage()); + + const profile = body.profile || DEEPSEEK_DEFAULT_PROFILE; + const pkg = body.package || DEEPSEEK_DEFAULT_TUI_PACKAGE; + const result = await new Promise<{ code: number | null; output: string }>((resolve) => { + const child = spawn(join(dir, 'dsh'), ['plugin', '--profile', profile, 'add', pkg], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: DEEPSEEK_INSTALL_TIMEOUT_MS, + // dsh bundles its own package manager, so no system pnpm is required — + // but it still needs a HOME to resolve $DSH_HOME against. + env: process.env, + }); + let output = ''; + const capture = (chunk: Buffer) => { + // Bounded: a package manager can emit megabytes of progress. + if (output.length < 16_384) output += chunk.toString('utf-8'); + }; + child.stdout?.on('data', capture); + child.stderr?.on('data', capture); + child.on('error', (err) => resolve({ code: null, output: `${output}\n${err.message}` })); + child.on('close', (code) => resolve({ code, output })); + }); + + if (result.code !== 0) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Installing ${pkg} into profile "${profile}" failed: ${result.output.slice(-1000).trim() || 'no output'}` + ); + } + const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } = + await import('../../utils/deepseek-cli-resolver.js'); + return { + profile, + package: pkg, + runnable: isDeepSeekRunnable(), + defaultProfile: resolveDefaultDeepSeekProfile(), + profiles: listDeepSeekProfiles(), + }; + }); + // ═══════════════════════════════════════════════════════════════ // State & Lifecycle (cleanup, lifecycle log, stats) // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 3e09b01ab..dd6f2acdb 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -132,6 +132,15 @@ const ALLOWED_ENV_PREFIXES = [ 'PI_', 'GROK_', 'XAI_', + // DeepSeek Harness: `DSH_*` carries the launcher's own documented inputs + // (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, and the DSH_TUI_* knobs + // the terminal front door reads); `DEEPSEEK_*` is the vendor namespace holding + // DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL, the same narrow-vendor reasoning that + // admitted XAI_* for grok. Foreign provider keys stay out: a dsh settings.yaml + // can name ANY env var as a provider credential (apiKeyEnv), which is pi's + // 34-provider-key problem in a new shape, and the answer is the same one. + 'DSH_', + 'DEEPSEEK_', ]; /** @@ -171,7 +180,7 @@ const safeEnvOverridesSchema = z }, { message: - 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_* keys and CLAUDE_CONFIG_DIR are allowed.', + 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_* keys and CLAUDE_CONFIG_DIR are allowed.', } ); @@ -336,6 +345,68 @@ const GrokConfigSchema = z }) .optional(); +/** + * Schema for DeepSeek Harness (`dsh`)-specific configuration. + * + * `permissionMode` maps to the `DSH_PERMISSION_MODE` env export, NOT to a flag — + * the harness has no command-line permission switch. An ABSENT config spawns the + * profile under the harness's own `workspace-write` default, which still asks + * for approval, so the multi-user clamp only needs the only-if-sent branch (like + * codex/antigravity/grok). + * + * `profile` is a directory name under `$DSH_HOME/profiles`, so it is constrained + * to a single path SEGMENT: no separators, no dots-only names. It is interpolated + * into the `bash -c "…"` spawn line and joined into a filesystem path, and this + * regex is what keeps both safe. + */ +const DeepSeekConfigSchema = z + .object({ + profile: z + .string() + .min(1) + .max(64) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/) + .optional(), + permissionMode: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), + resumeSession: z.boolean().optional(), + resumeSessionId: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._-]+$/) + .optional(), + statusReporting: z.boolean().optional(), + }) + .optional(); + +/** + * Body of POST /api/deepseek/install-profile. + * + * `package` is a package SPECIFIER handed to `dsh plugin … add`, which runs a + * real package-manager install, so it is the security-relevant field. Two things + * contain it: this regex (an npm name, optionally scoped, optionally with an + * `@version` tail, and NOTHING else — no path, no URL, no git spec, no leading + * dash that could be read as a flag), and the route, which spawns an argv ARRAY + * with no shell. The route additionally requires the privileged grant in + * multi-user mode: installing a plugin is arbitrary code execution on the host, + * the same bar as a `shell` session. + */ +export const DeepSeekInstallProfileSchema = z + .object({ + profile: z + .string() + .min(1) + .max(64) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/) + .optional(), + package: z + .string() + .min(1) + .max(214) + .regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[a-zA-Z0-9][a-zA-Z0-9._-]*)?$/) + .optional(), + }) + .strict(); + /** * The session that spawned the one being created — pure UI decoration, drawn as a * lineage line between the two tabs. Accepted here and, equivalently, as the @@ -349,7 +420,7 @@ const parentSessionIdSchema = z.string().max(100).optional(); export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(), + mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(), name: z.string().max(100).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, @@ -366,6 +437,7 @@ export const CreateSessionSchema = z.object({ antigravityConfig: AntigravityConfigSchema, piConfig: PiConfigSchema, grokConfig: GrokConfigSchema, + deepSeekConfig: DeepSeekConfigSchema, /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ resumeSessionId: z .string() @@ -502,6 +574,7 @@ const RemoteCommandOverridesSchema = z antigravity: z.string().min(1).max(300).optional(), pi: z.string().min(1).max(300).optional(), grok: z.string().min(1).max(300).optional(), + deepseek: z.string().min(1).max(300).optional(), }) .strict() .optional(); @@ -776,13 +849,14 @@ export const QuickStartSchema = z.object({ * a real host dir, so the settings file crosses the bind mount); rejected for * remote cases (the file would be written on the WRONG machine). */ modelOverride: z.string().max(50).optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(), + mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, antigravityConfig: AntigravityConfigSchema, piConfig: PiConfigSchema, grokConfig: GrokConfigSchema, + deepSeekConfig: DeepSeekConfigSchema, envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, @@ -804,6 +878,11 @@ export const HookEventSchema = z.object({ 'stop', 'teammate_idle', 'task_completed', + // A turn STARTED. Unlike the others this one has no Claude Code hook behind + // it: it is reported by the DeepSeek Harness status shim, and exists so a + // dialog answered in the terminal resolves its Approvals Inbox item at once + // instead of lingering red until the next `stop`. + 'agent_working', ]), sessionId: z.string().min(1), data: z.record(z.string(), z.unknown()).nullable().optional(), @@ -1310,7 +1389,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ const CronJobBaseSchema = z.object({ name: z.string().min(1).max(200), - agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']), + agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']), workingDir: safePathSchema, launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(), promptMode: z.enum(['inline_text', 'prompt_file_path']), diff --git a/src/web/server.ts b/src/web/server.ts index 594f53371..98e3bc80d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1436,6 +1436,7 @@ export class WebServer extends EventEmitter { { isAntigravityAvailable }, { isPiAvailable }, { isGrokAvailable }, + { isDeepSeekRunnable, isDeepSeekAvailable }, { isCloudflaredAvailable }, { isGitAvailable }, ] = await Promise.all([ @@ -1446,6 +1447,7 @@ export class WebServer extends EventEmitter { import('../utils/antigravity-cli-resolver.js'), import('../utils/pi-cli-resolver.js'), import('../utils/grok-cli-resolver.js'), + import('../utils/deepseek-cli-resolver.js'), import('../utils/cloudflared-resolver.js'), import('../git-clone.js'), ]); @@ -1457,6 +1459,13 @@ export class WebServer extends EventEmitter { antigravity: isAntigravityAvailable(), pi: isPiAvailable(), grok: isGrokAvailable(), + // RUNNABLE, not merely installed: `dsh` is a profile launcher, and a dsh + // with no pane-capable profile would offer a Run button that spawns a + // pane which dies on arrival. The Add-Profile affordance in the run menu + // keys off `deepseekBinary` instead, so a user who has the binary but no + // profile is offered the fix rather than a greyed-out entry. + deepseek: isDeepSeekRunnable(), + deepseekBinary: isDeepSeekAvailable(), 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. @@ -2738,6 +2747,7 @@ export class WebServer extends EventEmitter { antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined, piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined, grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined, + deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined, envOverrides: savedEnvOverrides, effort: savedState?.effort, attachmentHistory: savedAttachmentHistory, diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index 6675f2e88..cf7107e0c 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -182,7 +182,13 @@ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked']; * infinite-wait-dressed-as-a-timeout this guard exists to prevent. */ export function hooksAvailableForMode(mode: SessionMode): boolean { - return mode === 'claude'; + // `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE + // signals rather than having them inferred. The DeepSeek Harness terminal + // front door reports idle/working/blocked to its supervisor, and Codeman is + // that supervisor (see deepseek-status-shim.ts), so a dsh session really can + // deliver `stop` and `blocked`. Every other mode is output-stabilization + // guesswork and must keep failing the ask. + return mode === 'claude' || mode === 'deepseek'; } /** Outcome of resolving a caller-supplied wait target against a session's mode. */ diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 0c6ffba06..2253b0aca 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -5,7 +5,7 @@ * and referenced by the frontend (`SSE_EVENTS` in `constants.js`). * Both files MUST be kept in sync. * - * 156 event constants organized by category: + * 157 event constants organized by category: * - **Core** (1): init * - **Transport** (1): sse:heartbeat * - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ... @@ -25,7 +25,8 @@ * - **Plan orchestration** (5): started, progress, subagent, completed, cancelled * - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed * - **Image / attachments** (2): image:detected, attachment:detected - * - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed + * - **Hooks** (9): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed + * (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook) * - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox) * - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error * - **Clipboard** (1): write @@ -360,6 +361,13 @@ export const HookElicitationComplete = 'hook:elicitation_complete' as const; export const HookElicitationResponse = 'hook:elicitation_response' as const; /** Claude Code hook: response complete. */ export const HookStop = 'hook:stop' as const; +/** + * Agent started a turn. NOT a Claude Code hook: this one is reported by the + * DeepSeek Harness status bridge, which is why the name is agent-generic. It + * exists so a dialog answered in the terminal clears its alert immediately + * instead of waiting for the turn to end. + */ +export const HookAgentWorking = 'hook:agent_working' as const; /** Claude Code hook: teammate went idle. */ export const HookTeammateIdle = 'hook:teammate_idle' as const; /** Claude Code hook: teammate task completed. */ @@ -619,6 +627,7 @@ export const SseEvent = { HookElicitationComplete, HookElicitationResponse, HookStop, + HookAgentWorking, HookTeammateIdle, HookTaskCompleted, diff --git a/test/deepseek-cli-resolver.test.ts b/test/deepseek-cli-resolver.test.ts new file mode 100644 index 000000000..c458110df --- /dev/null +++ b/test/deepseek-cli-resolver.test.ts @@ -0,0 +1,232 @@ +/** + * @fileoverview Tests for the DeepSeek Harness (`dsh`) resolver and profile inventory. + * + * `dsh` needs the strictest identity probe of any CLI Codeman resolves. pi and + * grok are short names with npm squatters; `dsh` is worse — it is an EXISTING, + * widely packaged Unix program (Debian's dancer's shell, `apt install dsh`), + * which would sail through a version-token probe and then be handed a spawn + * line. So the resolver demands the harness's own help banner first, and the + * headline test below is the one that pins that rejection. + * + * The second half covers something no sibling resolver has: a profile + * inventory. `dsh` is a launcher, so "is it installed" and "can it run a + * session" are different questions, and the availability gate needs both. + */ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createDeepSeekResolverForTest, + DEEPSEEK_VERSION_REGEX, + DEEPSEEK_IDENTITY_REGEX, + listDeepSeekProfiles, + resolveDefaultDeepSeekProfile, + isLaunchableProfile, + resolveDshHome, +} from '../src/utils/deepseek-cli-resolver.js'; +import { + cliResolveRetryDelayMs, + createProductionCliResolverHost, + type CliResolverHost, +} from '../src/utils/cli-executable-resolver.js'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function createHost( + options: { + processPathResult?: string | null; + loginShellResults?: Array; + existingPaths?: string[]; + } = {} +): CliResolverHost { + const loginShellResults = [...(options.loginShellResults ?? [])]; + const existingPaths = new Set(options.existingPaths ?? []); + return { + processPath: '/service/bin', + shellPath: '/bin/zsh', + shellArgs: ['-l'], + findOnProcessPath: () => options.processPathResult ?? null, + findInLoginShell: () => loginShellResults.shift() ?? null, + exists: (path) => existingPaths.has(path), + }; +} + +describe('DeepSeek CLI resolver', () => { + it('accepts a candidate the probe verifies and carries the version as metadata', () => { + const binaryPath = '/service/bin/dsh'; + const probe = vi.fn(() => '0.1.1-rc.2'); + const resolver = createDeepSeekResolverForTest( + createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }), + probe + ); + + expect(resolver.resolve()).toMatchObject({ + binaryPath, + directory: '/service/bin', + source: 'process-path', + metadata: '0.1.1-rc.2', + }); + expect(probe).toHaveBeenCalledWith(binaryPath); + }); + + it('does not let a foreign `dsh` earlier on PATH mask the real one', () => { + // The dancer's-shell case, at resolver level: a `dsh` that is a real program + // and answers --version must still be refused, and must not stop the search. + const impostor = '/usr/bin/dsh'; + const genuine = '/login-shell/bin/dsh'; + const probe = vi.fn((binPath: string) => (binPath === genuine ? '0.1.1-rc.2' : null)); + const resolver = createDeepSeekResolverForTest( + createHost({ + processPathResult: impostor, + loginShellResults: [genuine], + existingPaths: [impostor, genuine], + }), + probe + ); + + expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell' }); + }); + + it('negative-caches a miss and retries only after the backoff elapses', () => { + const binaryPath = '/late/bin/dsh'; + let now = 0; + const probe = vi.fn(() => '0.1.1-rc.2'); + const resolver = createDeepSeekResolverForTest( + createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }), + probe, + () => now + ); + + expect(resolver.resolve()).toBeNull(); + expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run + expect(probe).not.toHaveBeenCalled(); + now = cliResolveRetryDelayMs(1); + expect(resolver.resolve()?.metadata).toBe('0.1.1-rc.2'); + }); + + it('extracts the version from the real output shape (a bare `0.1.1-rc.2`)', () => { + // Shared with the dependency registry (doctor), so the accepted shape is + // contract. The prerelease tail is part of the token on purpose: dropping it + // would report a release candidate as a release. + expect(DEEPSEEK_VERSION_REGEX.exec('0.1.1-rc.2')?.[1]).toBe('0.1.1-rc.2'); + expect(DEEPSEEK_VERSION_REGEX.exec('dsh 1.2.3')?.[1]).toBe('1.2.3'); + expect(DEEPSEEK_VERSION_REGEX.exec('not a version')).toBeNull(); + }); + + it('identifies the harness by its help banner and rejects a foreign dsh', () => { + expect(DEEPSEEK_IDENTITY_REGEX.test('dsh: boot a DeepSeek Harness profile — an ordered stack')).toBe(true); + // Debian's dancer's shell: a real program, a real version, not our agent. + expect(DEEPSEEK_IDENTITY_REGEX.test('Usage: dsh [options] [command] ...\nDistributed shell')).toBe(false); + }); + + it('never executes a dsh candidate under vitest (the ambient probe is VITEST-gated)', () => { + // A REAL executable fixture that answers BOTH probes convincingly. If the + // guard in probeDeepSeekVersion is ever removed, this script runs, the + // resolution SUCCEEDS, and this test fails — pinning hermeticity by + // behavior rather than by source text. That matters more here than for any + // sibling: `dsh` is a name real machines genuinely carry. + const root = mkdtempSync(join(tmpdir(), 'codeman-dsh-vitest-gate-')); + temporaryDirectories.push(root); + const binaryPath = join(root, 'dsh'); + writeFileSync( + binaryPath, + '#!/bin/sh\ncase "$1" in --help) echo "dsh: boot a DeepSeek Harness profile";; *) echo "9.9.9";; esac\n' + ); + chmodSync(binaryPath, 0o755); + const hostOptions = { + processPath: root, + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'] as string[], + runCommand: () => '', + isExecutableFile: (path: string) => path === binaryPath, + }; + + const gated = createDeepSeekResolverForTest(createProductionCliResolverHost(hostOptions)); + expect(gated.resolve()).toBeNull(); + + // Control: identical setup with an injected probe resolves, proving the null + // above comes from the gate, not from the fixture or the host. + const control = createDeepSeekResolverForTest(createProductionCliResolverHost(hostOptions), () => '9.9.9'); + expect(control.resolve()).toMatchObject({ binaryPath, metadata: '9.9.9' }); + }); +}); + +describe('DeepSeek profile inventory', () => { + let home: string; + const ORIGINAL_DSH_HOME = process.env.DSH_HOME; + + function writeProfile(name: string, bundles: string[]): void { + const dir = join(home, 'profiles', name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: `dsh-profile-${name}`, dsh: { profile: { bundles } } }) + ); + } + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'codeman-dsh-home-')); + temporaryDirectories.push(home); + process.env.DSH_HOME = home; + }); + + afterEach(() => { + if (ORIGINAL_DSH_HOME === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = ORIGINAL_DSH_HOME; + }); + + it('honours DSH_HOME over the default ~/.dsh', () => { + expect(resolveDshHome()).toBe(home); + }); + + it('is empty (not an error) when dsh has never been run', () => { + rmSync(home, { recursive: true, force: true }); + expect(listDeepSeekProfiles()).toEqual([]); + expect(resolveDefaultDeepSeekProfile()).toBeNull(); + }); + + it('classifies the profiles DeepSeek ships as unable to drive a pane', () => { + writeProfile('web', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']); + writeProfile('headless', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']); + + const profiles = listDeepSeekProfiles(); + expect(profiles.map((p) => `${p.name}:${p.kind}`).sort()).toEqual(['headless:headless', 'web:web']); + expect(profiles.every((p) => !isLaunchableProfile(p))).toBe(true); + // The whole point: a perfectly installed dsh with only the shipped profiles + // still cannot start a Codeman session. + expect(resolveDefaultDeepSeekProfile()).toBeNull(); + }); + + it('prefers an interactive profile and ignores node_modules', () => { + writeProfile('web', ['@deepseek-ai/dsh-web-app']); + writeProfile('dsh-tui', ['@deepseek-ai/dsh-base', '@deepseek-harness-tui/dsh-tui']); + mkdirSync(join(home, 'profiles', 'node_modules', 'something'), { recursive: true }); + + const names = listDeepSeekProfiles().map((p) => p.name); + expect(names).not.toContain('node_modules'); + expect(resolveDefaultDeepSeekProfile()).toBe('dsh-tui'); + }); + + it('treats an unrecognized third-party profile as launchable', () => { + // Anyone can publish an app bundle, so an unknown profile must not be hidden + // from the picker just because this classifier has not heard of it. + writeProfile('custom', ['@someone/dsh-my-own-surface']); + const profile = listDeepSeekProfiles().find((p) => p.name === 'custom')!; + expect(profile.kind).toBe('unknown'); + expect(isLaunchableProfile(profile)).toBe(true); + expect(resolveDefaultDeepSeekProfile()).toBe('custom'); + }); + + it('survives a stray directory under profiles/', () => { + mkdirSync(join(home, 'profiles', 'not-a-profile'), { recursive: true }); + writeProfile('dsh-tui', ['@deepseek-harness-tui/dsh-tui']); + expect(listDeepSeekProfiles().map((p) => p.name)).toEqual(['dsh-tui']); + }); +}); diff --git a/test/deepseek-mode.test.ts b/test/deepseek-mode.test.ts new file mode 100644 index 000000000..264129fa8 --- /dev/null +++ b/test/deepseek-mode.test.ts @@ -0,0 +1,240 @@ +/** + * DeepSeek Harness (`dsh`) run mode. + * + * The interesting assertions here are the ones that differ from every sibling + * CLI, because dsh is shaped differently in two ways: + * + * 1. the agent is a PROFILE, not the binary, so the spawn line carries + * `--profile ` and a profile name has to be treated as a path segment; + * 2. the permission switch is an ENV VAR (`DSH_PERMISSION_MODE`), not a flag, + * so the thing to pin is that nothing permission-shaped ever reaches the + * command line. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { CreateSessionSchema, QuickStartSchema, HookEventSchema } from '../src/web/schemas.js'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; +import { defaultDockerCommandForMode } from '../src/docker-hosts.js'; +import { defaultRemoteCommandForMode } from '../src/remote-hosts.js'; +import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js'; +import { hooksAvailableForMode } from '../src/web/session-wait-registry.js'; +import { _clampExternalCliBypassForOwner } from '../src/web/routes/session-routes.js'; +import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js'; + +vi.mock('../src/utils/deepseek-cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveDefaultDeepSeekProfile: vi.fn(() => 'dsh-tui') }; +}); + +describe('DeepSeek mode schemas', () => { + it('accepts DeepSeek session creation config', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'deepseek', + deepSeekConfig: { profile: 'dsh-tui', permissionMode: 'danger-full-access' }, + }); + + expect(parsed.mode).toBe('deepseek'); + expect(parsed.deepSeekConfig).toEqual({ profile: 'dsh-tui', permissionMode: 'danger-full-access' }); + }); + + it('accepts DeepSeek quick-start config', () => { + const parsed = QuickStartSchema.parse({ + caseName: 'dsh-case', + mode: 'deepseek', + deepSeekConfig: { resumeSessionId: 'sess_01H9', statusReporting: false }, + }); + + expect(parsed.mode).toBe('deepseek'); + expect(parsed.deepSeekConfig?.resumeSessionId).toBe('sess_01H9'); + expect(parsed.deepSeekConfig?.statusReporting).toBe(false); + }); + + it('rejects a profile name that is not a single path segment', () => { + // A profile is BOTH interpolated into a `bash -c "…"` line and joined into a + // filesystem path under $DSH_HOME/profiles, so separators and traversal have + // to die at the schema boundary. + for (const profile of ['../../etc/passwd', 'a/b', './x', '-rf', 'has space', 'semi;colon']) { + expect(() => + CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'deepseek', deepSeekConfig: { profile } }) + ).toThrow(); + } + }); + + it('rejects an unknown permission preset', () => { + // The three presets are the harness's own; anything else would be exported + // verbatim as DSH_PERMISSION_MODE and silently fall back to its default. + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'deepseek', + deepSeekConfig: { permissionMode: 'yolo' }, + }) + ).toThrow(); + }); + + it('rejects unsafe resumeSessionId values', () => { + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'deepseek', + deepSeekConfig: { resumeSessionId: '../../etc/passwd' }, + }) + ).toThrow(); + }); + + it('allows DSH_* and DEEPSEEK_* env overrides but not a foreign provider key', () => { + const ok = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'deepseek', + envOverrides: { DSH_HOME: '/tmp/dsh', DEEPSEEK_API_KEY: 'sk-test' }, + }); + expect(ok.envOverrides).toEqual({ DSH_HOME: '/tmp/dsh', DEEPSEEK_API_KEY: 'sk-test' }); + + // A dsh settings.yaml can name ANY env var as a provider credential + // (apiKeyEnv), which is pi's 34-provider-key problem in a new shape. The + // allowlist is global, so admitting them would widen every mode at once. + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'deepseek', + envOverrides: { QWEN5090_API_KEY: 'sk-test' }, + }) + ).toThrow(); + }); +}); + +describe('DeepSeek spawn command', () => { + it('boots the requested profile', () => { + const cmd = buildSpawnCommand({ + mode: 'deepseek', + sessionId: 's1', + deepSeekConfig: { profile: 'dsh-tui' }, + }); + expect(cmd).toBe('dsh --profile dsh-tui'); + }); + + it('falls back to the resolved default profile when none was requested', () => { + const cmd = buildSpawnCommand({ mode: 'deepseek', sessionId: 's1' }); + expect(cmd).toBe('dsh --profile dsh-tui'); + }); + + it('never puts anything permission-shaped on the command line', () => { + // The harness has NO permission flag: the switch is the DSH_PERMISSION_MODE + // env export, applied via `tmux setenv`. If this ever starts failing, someone + // has invented a flag that does not exist. + const cmd = buildSpawnCommand({ + mode: 'deepseek', + sessionId: 's1', + deepSeekConfig: { profile: 'dsh-tui', permissionMode: 'danger-full-access' }, + }); + expect(cmd).toBe('dsh --profile dsh-tui'); + expect(cmd).not.toMatch(/danger|approve|permission|yolo|dangerously/i); + }); + + it('prefers an explicit resume id over the most-recent form', () => { + const cmd = buildSpawnCommand({ + mode: 'deepseek', + sessionId: 's1', + deepSeekConfig: { profile: 'p', resumeSession: true, resumeSessionId: 'sess_42' }, + }); + expect(cmd).toBe('dsh --profile p --resume sess_42'); + }); + + it('resumes the most recent session when only the flag is set', () => { + const cmd = buildSpawnCommand({ + mode: 'deepseek', + sessionId: 's1', + deepSeekConfig: { profile: 'p', resumeSession: true }, + }); + expect(cmd).toBe('dsh --profile p --resume'); + }); + + it('drops an unsafe profile rather than interpolating it', () => { + // Defense in depth behind the schema: builders must not trust their callers, + // because this string is interpolated into a `bash -c "…"` argument. + const cmd = buildSpawnCommand({ + mode: 'deepseek', + sessionId: 's1', + deepSeekConfig: { profile: 'evil; rm -rf /' }, + }); + expect(cmd).not.toContain('rm -rf'); + expect(cmd).toBe('dsh --profile dsh-tui'); + }); +}); + +describe('DeepSeek mode wiring', () => { + it('is an external CLI mode', () => { + expect(isExternalCliMode('deepseek')).toBe(true); + }); + + it('is NOT an alt-screen strip mode', () => { + // The strip is for Ink-style repaint TUIs (claude/codex/gemini). A dsh + // terminal profile is a third-party fullscreen TUI, i.e. the opencode case. + expect(isAltScreenStripMode('deepseek')).toBe(false); + }); + + it('has default remote and docker commands', () => { + expect(defaultRemoteCommandForMode('deepseek')).toContain('dsh'); + expect(defaultDockerCommandForMode('deepseek')).toBe('exec dsh'); + }); +}); + +describe('DeepSeek status bridge', () => { + it('is the only non-claude mode allowed to deliver hook signals', () => { + // Earned, not granted: the harness terminal front door REPORTS its state to + // a supervisor, so `stop` and `blocked` for a dsh session are definitive + // rather than inferred. Every other external CLI must keep failing this. + expect(hooksAvailableForMode('deepseek')).toBe(true); + expect(hooksAvailableForMode('claude')).toBe(true); + for (const mode of ['shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok'] as const) { + expect(hooksAvailableForMode(mode)).toBe(false); + } + }); + + it('maps the harness lifecycle states onto real hook events', () => { + expect(DEEPSEEK_STATE_TO_HOOK_EVENT.idle).toBe('stop'); + expect(DEEPSEEK_STATE_TO_HOOK_EVENT.blocked).toBe('permission_prompt'); + expect(DEEPSEEK_STATE_TO_HOOK_EVENT.working).toBe('agent_working'); + // Every mapped event must be one the hook endpoint actually accepts, or the + // bridge would post reports the schema silently rejects. + for (const event of Object.values(DEEPSEEK_STATE_TO_HOOK_EVENT)) { + expect(() => HookEventSchema.parse({ event, sessionId: 's1' })).not.toThrow(); + } + }); +}); + +describe('DeepSeek multi-user clamp', () => { + const ORIGINAL = process.env.CODEMAN_MULTIUSER; + beforeEach(() => { + process.env.CODEMAN_MULTIUSER = '1'; + }); + afterEach(() => { + if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = ORIGINAL; + }); + + it('clamps a sent danger-full-access down to workspace-write, not read-only', () => { + // The clamp removes PRIVILEGE; it must not also break the session's ability + // to edit its own workspace, which read-only would. + return _clampExternalCliBypassForOwner('nobody', undefined, undefined, undefined, undefined, undefined, { + permissionMode: 'danger-full-access', + }).then((out) => { + expect(out.deepSeekConfig?.permissionMode).toBe('workspace-write'); + }); + }); + + it('leaves an ABSENT config absent (the only-if-sent branch)', async () => { + // Omitting DSH_PERMISSION_MODE leaves the harness on its own workspace-write + // preset, which still asks — so there is nothing to materialize, unlike pi. + const out = await _clampExternalCliBypassForOwner( + 'nobody', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); + expect(out.deepSeekConfig).toBeUndefined(); + }); +}); diff --git a/test/mobile-overview.test.ts b/test/mobile-overview.test.ts index b54c0382a..1bfc8eb85 100644 --- a/test/mobile-overview.test.ts +++ b/test/mobile-overview.test.ts @@ -424,7 +424,17 @@ describe('mobile overview run picker (CLI availability gating)', () => { isCliAvailable: () => true, }); const menu = app._buildMobileOverviewRunMenu(); - expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']); + expect(modeButtons(menu)).toEqual([ + 'claude', + 'opencode', + 'codex', + 'gemini', + 'antigravity', + 'pi', + 'grok', + 'deepseek', + 'shell', + ]); }); it('gates every mode the picker actually offers', () => { diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index 9a2ff4e53..21c5f8a3f 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -19,6 +19,7 @@ import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js'; import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js'; import { isPiAvailable } from '../src/utils/pi-cli-resolver.js'; import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js'; +import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js'; import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js'; import { isGitAvailable } from '../src/git-clone.js'; @@ -55,6 +56,16 @@ vi.mock('../src/utils/grok-cli-resolver.js', () => ({ resolveGrokDir: vi.fn(() => null), getGrokCliVersion: vi.fn(() => null), })); +// DeepSeek is the one mode with a two-part availability answer (binary AND a +// pane-capable profile), so both probes are mocked independently. +vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({ + isDeepSeekAvailable: vi.fn(() => false), + isDeepSeekRunnable: vi.fn(() => false), + resolveDeepSeekDir: vi.fn(() => null), + getDeepSeekCliVersion: vi.fn(() => null), + listDeepSeekProfiles: vi.fn(() => []), + resolveDefaultDeepSeekProfile: vi.fn(() => null), +})); vi.mock('../src/utils/cloudflared-resolver.js', () => ({ isCloudflaredAvailable: vi.fn(() => false), resolveCloudflaredPath: vi.fn(() => null), @@ -160,6 +171,8 @@ describe('WebServer.renderIndexHtml', () => { antigravity: false, pi: true, grok: false, + deepseek: false, + deepseekBinary: false, cloudflared: true, git: true, }); @@ -176,6 +189,8 @@ describe('WebServer.renderIndexHtml', () => { isAntigravityAvailable, isPiAvailable, isGrokAvailable, + isDeepSeekAvailable, + isDeepSeekRunnable, isCloudflaredAvailable, isGitAvailable, ]) { From 2034719d61dd04297b9e0493aee63c5b06fd3115 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 24 Aug 2026 16:01:02 +0200 Subject: [PATCH 2/6] fix(deepseek): close the env-var clamp hole, bound the profile install, make the hook gate per-session Three review findings on the DeepSeek Harness mode, plus one the third exposed. 1. The multi-user clamp was bypassable by a sibling field on the same request. clampExternalCliBypassForOwner() clamps deepSeekConfig.permissionMode, but DSH_* is an allowlisted envOverrides prefix and applyEnvOverrides() runs AFTER _configureDeepSeek(), so a non-granted owner sending envOverrides.DSH_PERMISSION_MODE landed last and won. Measured on an isolated instance: a session created with permissionMode "read-only" and that override ran with DSH_PERMISSION_MODE=danger-full-access in its pane. Every other CLI's bypass is a command-line flag reachable only through the per-CLI config, which is why the config clamp alone is the whole gate for them. clampEnvOverridesForOwner() adds the env-var half: for a non-granted owner it DROPS DSH_PERMISSION_MODE and DSH_HOME (dropping falls through to what _configureDeepSeek() exports, i.e. the clamped value). DSH_HOME is on that list because it aims the launcher at a profile tree whose plugin code runs at boot, before any approval row can apply. Verified end to end in real multi-user mode: a non-granted user sending both now gets workspace-write and no DSH_HOME, while an unrelated DSH_TELEMETRY_MODE passes through untouched. 2. POST /api/deepseek/install-profile could hang forever. spawn's own `timeout` signals only the direct child, and a plugin install fans out into package-manager children that keep the inherited stdio pipes open, so `close` never fires and the held-open request leaks with no route-level deadline. Reproduced: with a 1.5s built-in timeout the promise was still unsettled after 6s and both fan-out children were alive. Now detached: true plus negative-pid SIGTERM/SIGKILL, the same escalation runGit() uses for the same reason, with a last-resort reap for a grandchild that escaped the group. Same probe after the change: close fires, direct child and both grandchildren dead. 3. hooksAvailableForMode() promised more than a dsh session can deliver. deepSeekConfig.statusReporting: false disarms the HERDR_* export, and that triple is the only reason a dsh session posts hook events, so `until=stop` was accepted and then blocked for the caller's whole timeout: the exact infinite-wait-dressed-as-a-timeout the predicate exists to prevent. It now takes HookCapabilityOptions and every call site passes sessionHookOptions(), with the deepseek arm reading `!== false` so a forgotten one degrades to the old behaviour. The refusal names the setting rather than saying "no Claude Code hooks", which would send the caller hunting a bug that is really a setting they chose. Profile conformance stays unknowable at request time and is documented as such. The stale "True for `claude` and nothing else" docblock is corrected. 4. Exposed by (3): hooksAvailableForMode() was doing double duty as "is this a claude session". Read My Mind (POST /api/sessions/:id/readmymind) and intent capture read Claude's own transcript, and adding deepseek silently widened both to a mode that has none. They compare mode === 'claude' directly now, and a static check pins them there. Verified: full CI gate green (6132 passed), typecheck/lint/format clean, and the wait-signal gating exercised against a live server with a real dsh 0.1.1-rc.2 -- bridge off plus explicit until=stop is a 400 naming the setting, bridge off with no `until` still 200s on idle/exit, bridge on accepts stop. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 6 +- docs/deepseek-integration-plan.md | 4 +- docs/deepseek-integration.md | 33 +++++++- src/session.ts | 14 ++++ src/web/routes/approval-routes.ts | 4 +- src/web/routes/hook-event-routes.ts | 6 +- src/web/routes/readmymind-routes.ts | 7 +- src/web/routes/session-routes.ts | 62 +++++++++++++- src/web/routes/system-routes.ts | 85 ++++++++++++++++--- src/web/server.ts | 7 +- src/web/session-wait-registry.ts | 83 ++++++++++++++++--- test/deepseek-mode.test.ts | 123 +++++++++++++++++++++++++++- 13 files changed, 391 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 240f87dc6..32aa8449b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,7 +205,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All seven **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`; user guide `docs/deepseek-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek) +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All seven **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE` and `DSH_HOME` for a non-granted owner (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`; user guide `docs/deepseek-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek) **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d81ef2b12..fcebbbccd 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -26,15 +26,15 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ⚠️ **The agent is a PROFILE, not the binary.** `dsh` is a launcher over `$DSH_HOME/profiles/` (an ordered stack of plugin-bundle patch layers), and DeepSeek ships only `web` (browser UI), `headless` (one-shot) and `base` (no app). The interactive terminal front door is ALWAYS third-party. So availability is TWO questions, not one, and `isDeepSeekRunnable()` (binary AND a pane-capable profile) is what the Run button gates on while `isDeepSeekAvailable()` (binary only) gates the "add a profile" affordance and the web-UI shortcut. Reporting only the binary would let Run spawn a pane that dies on arrival, which is this mode's single most confusing failure. `buildDeepSeekCommand()` emits `dsh --profile [--resume [id]]`; an absent profile resolves through `resolveDefaultDeepSeekProfile()`, which prefers a recognized TUI, then an UNRECOGNIZED profile (third-party by construction — a classifier that has not heard of a bundle must not hide it), and refuses `web`/`headless`, which cannot drive a pane. -⚠️ **The permission switch is an ENV VAR, not a flag.** The harness has no `--dangerously-skip-permissions` equivalent; its sandbox/approval rows read `DSH_PERMISSION_MODE` with three presets (`read-only` / `workspace-write` / `danger-full-access`; measured from `dsh --dump-default-config`). It is exported via `tmux setenv` in `_configureDeepSeek()`, never on the command line, and `test/deepseek-mode.test.ts` pins that nothing permission-shaped ever reaches the spawn line. This is the ONE place a Codeman env export is the right mechanism rather than the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks in-session `/effort`), the harness reads it with `??` as a boot-time DEFAULT, so it stays soft. Absent = `workspace-write`, which still asks, so the multi-user clamp is the only-if-sent branch (codex/antigravity/grok shape, not pi's materialize) — and it clamps down to `workspace-write`, NOT `read-only`, because the clamp removes privilege without breaking a session's ability to edit its own workspace. +⚠️ **The permission switch is an ENV VAR, not a flag.** The harness has no `--dangerously-skip-permissions` equivalent; its sandbox/approval rows read `DSH_PERMISSION_MODE` with three presets (`read-only` / `workspace-write` / `danger-full-access`; measured from `dsh --dump-default-config`). It is exported via `tmux setenv` in `_configureDeepSeek()`, never on the command line, and `test/deepseek-mode.test.ts` pins that nothing permission-shaped ever reaches the spawn line. This is the ONE place a Codeman env export is the right mechanism rather than the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks in-session `/effort`), the harness reads it with `??` as a boot-time DEFAULT, so it stays soft. Absent = `workspace-write`, which still asks, so the multi-user clamp is the only-if-sent branch (codex/antigravity/grok shape, not pi's materialize) — and it clamps down to `workspace-write`, NOT `read-only`, because the clamp removes privilege without breaking a session's ability to edit its own workspace. ⚠️ **Clamping the config is only HALF the gate here, and this is the only CLI where that is true.** Every sibling's bypass is a command-line flag, reachable only through the per-CLI config `clampExternalCliBypassForOwner()` already owns. DeepSeek's is an env var, `DSH_*` is an allowlisted `envOverrides` prefix (it must be — that is also how the harness's ordinary knobs are set), and `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so `envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'}` sent on the SAME request as a clamped config lands last and wins. `clampEnvOverridesForOwner()` (session-routes.ts, exported as `_clampEnvOverridesForOwner` for tests) DROPS `DSH_PERMISSION_MODE` and `DSH_HOME` for a non-granted owner rather than rewriting them, since dropping falls through to what `_configureDeepSeek()` exports, which is already the clamped value. `DSH_HOME` is on that list because it aims the launcher at a profile tree and a profile's plugin code executes at BOOT, before any approval row can apply — the wider of the two holes. No-op in single-user mode and for a granted owner, like every other clamp. -⚠️ **It is the only non-claude mode that passes `hooksAvailableForMode()`, and it earned that.** The community terminal front door reports its own lifecycle to a supervising process through a generic env-var-gated contract inherited from Herdr: with `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` set it shells out ` pane report-agent --state idle|working|blocked …` on every state change and treats exit 0 as delivered. `deepseek-status-shim.ts` GENERATES a small script into the data dir (like `self-update-runner.sh`, so npm installs and git clones behave alike) and points `HERDR_BIN_PATH` at it; it forwards to `POST /api/hook-event` as `idle→stop`, `blocked→permission_prompt`, `working→agent_working`. So a dsh session gets real respawn triggers, real `wait` stop/blocked signals and real Approvals Inbox items instead of output-stabilization guesswork. This is an interface implementation, not an impersonation — no real `herdr` binary is ever executed. A TUI that does not implement the contract simply never calls the shim and falls back to stabilization, so the feature is inert rather than harmful there. +⚠️ **It is the only non-claude mode that passes `hooksAvailableForMode()`, and it earned that.** The community terminal front door reports its own lifecycle to a supervising process through a generic env-var-gated contract inherited from Herdr: with `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` set it shells out ` pane report-agent --state idle|working|blocked …` on every state change and treats exit 0 as delivered. `deepseek-status-shim.ts` GENERATES a small script into the data dir (like `self-update-runner.sh`, so npm installs and git clones behave alike) and points `HERDR_BIN_PATH` at it; it forwards to `POST /api/hook-event` as `idle→stop`, `blocked→permission_prompt`, `working→agent_working`. So a dsh session gets real respawn triggers, real `wait` stop/blocked signals and real Approvals Inbox items instead of output-stabilization guesswork. This is an interface implementation, not an impersonation — no real `herdr` binary is ever executed. A TUI that does not implement the contract simply never calls the shim and falls back to stabilization, so the feature is inert rather than harmful there. ⚠️ **For deepseek alone, `hooksAvailableForMode()` is a per-SESSION question**, which is why it takes a `HookCapabilityOptions` second argument and every call site passes `sessionHookOptions(session)`: `deepSeekConfig.statusReporting: false` skips the `HERDR_*` export, and that triple is the only reason a dsh session posts anything, so answering from the mode alone would accept `until=stop` on a session where nothing can ever send one — the infinite-wait-dressed-as-a-timeout the predicate exists to prevent. The option defaults permissive (`!== false`), so a call site that forgets it degrades to the old behaviour instead of 400ing a working session. ⚠️ Profile conformance is the LIMIT of what is knowable at request time: `resolveDefaultDeepSeekProfile()` deliberately treats an unrecognized profile as launchable, so a non-conforming TUI still answers true and still times out on an explicit `stop` — which is why the DEFAULT signal set keeps `idle`/`exit`. ⚠️ **The predicate is not a stand-in for "is this a claude session"**, though it read like one while `claude` was the only true answer: Read My Mind (`POST /api/sessions/:id/readmymind`) and intent capture (`captureIntentPrompt`) read Claude's own transcript and were silently widened to deepseek by this change, so both compare `mode === 'claude'` directly and a static check in `test/deepseek-mode.test.ts` keeps them there. ⚠️ **`agent_working` is a hook event with no Claude Code hook behind it** (157th SSE constant). It exists because a harness turn cannot run while one of its own modal approvals is on screen, so "the agent started working" proves a dialog was answered in the terminal. It joins `APPROVAL_RESOLVING_EVENTS`; without it a dsh session's red alert would survive until the next `stop`, the exact stuck-alert bug the claude path already had to fix once — and the pane-capture staleness sweep that fixed it there is Claude-dialog-shaped and cannot help here. ⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity. -Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. +Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. **Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session ` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). diff --git a/docs/deepseek-integration-plan.md b/docs/deepseek-integration-plan.md index af7f65448..f1c294ae6 100644 --- a/docs/deepseek-integration-plan.md +++ b/docs/deepseek-integration-plan.md @@ -31,7 +31,9 @@ in the six external CLIs before it. | What does a pane run? | `dsh --profile `, profile discovered | **The decision that shapes everything else.** DeepSeek ships `web`, `headless` and `base` — no terminal agent. The interactive front door is always a third-party plugin, so Codeman resolves a binary AND a profile inventory, and "available" means both. `resolveDefaultDeepSeekProfile()` prefers a recognized TUI, then an UNRECOGNIZED profile (anyone can publish an app bundle; a classifier that has not heard of one must not hide it), and refuses `web`/`headless`, which cannot occupy a pane. | | Which TUI? | none blessed; default for BOOTSTRAP only | `POST /api/deepseek/install-profile` defaults to `@deepseek-harness-tui/dsh-tui` (~27.5k weekly downloads, ~4x the next, MIT, and it speaks the status contract in §2.3), but accepts any npm name and the resolver never assumes that profile exists. Codeman offers a default; it does not pick a winner. | | Permission bypass | `DSH_PERMISSION_MODE` env export, no flag | The harness has NO command-line permission option; its sandbox/approval rows read one env var with three presets (`read-only` / `workspace-write` / `danger-full-access`, read off `dsh --dump-default-config`). This is the one legitimate exception to the `CLAUDE_CODE_EFFORT_LEVEL` ban: that var hard-locks in-session switching, whereas the harness reads this with `??` as a boot-time DEFAULT, so it stays soft. Exported via `tmux setenv`, never on the command line. The Run button sends `danger-full-access`, matching every sibling Run button. | -| Multi-user clamp branch | only-if-sent, clamped to `workspace-write` | Omitting the export leaves the harness on `workspace-write`, which still ASKS, so an absent config is already safe (the codex/antigravity/grok shape, not pi's materialize). Clamping to `workspace-write` rather than `read-only` is deliberate: the clamp removes privilege, it must not break a session's ability to edit its own workspace. | +| Multi-user clamp branch | only-if-sent, clamped to `workspace-write`, **plus an env-var half** | Omitting the export leaves the harness on `workspace-write`, which still ASKS, so an absent config is already safe (the codex/antigravity/grok shape, not pi's materialize). Clamping to `workspace-write` rather than `read-only` is deliberate: the clamp removes privilege, it must not break a session's ability to edit its own workspace. ⚠️ Unlike every sibling, clamping the CONFIG is only half the gate: the switch is an env var, `DSH_*` is an allowlisted `envOverrides` prefix, and `applyEnvOverrides()` runs AFTER `_configureDeepSeek()`, so `envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'}` on the same request would land last and win. `clampEnvOverridesForOwner()` drops `DSH_PERMISSION_MODE` and `DSH_HOME` for a non-granted owner (dropping falls through to the clamped export). `DSH_HOME` because it aims the launcher at a profile tree whose plugin code runs at BOOT, before any approval row. | +| `hooksAvailableForMode()` granularity | per SESSION for deepseek, per mode for everything else | `deepSeekConfig.statusReporting: false` disarms the `HERDR_*` export, and the triple is the only reason a dsh session posts anything, so a mode-only answer would accept `until=stop` where nothing can send one — the infinite-wait the predicate exists to prevent. Call sites pass `sessionHookOptions(session)`; the default stays permissive so a forgotten one degrades to the old behaviour. ⚠️ Profile conformance stays unknowable at request time (an unrecognized profile is deliberately launchable), so a non-conforming TUI still times out on an explicit `stop`; the default set keeps `idle`/`exit` for that. ⚠️ The predicate is NOT "is this claude": Read My Mind and intent capture read Claude's transcript and were silently widened by this change, so they compare `mode === 'claude'` directly now. | +| Profile install spawn | own process group, hand-rolled timeout | `dsh plugin add` fans out into package-manager children, and spawn's built-in `timeout` signals only the direct child: survivors keep the inherited stdio pipes open, `close` never fires, and the held-open request leaks with no route-level deadline. `detached: true` + negative-pid SIGTERM→SIGKILL, the same escalation `runGit()` uses for the same reason, plus a last-resort reap for a grandchild that escaped the group. | | Idle detection | **real hook events via a status shim** | The standout decision. The TUI already reports its lifecycle to a supervising process through a generic env-gated contract inherited from Herdr: `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` make it run ` pane report-agent --state idle\|working\|blocked …` on every state change, exit 0 = delivered. `deepseek-status-shim.ts` generates a script into the data dir and points `HERDR_BIN_PATH` at it. So deepseek is the only non-claude mode that passes `hooksAvailableForMode()` — earned by emitting definitive signals, not granted. An interface implementation, not an impersonation: no real `herdr` binary is ever executed, and a TUI that ignores the contract simply falls back to output stabilization. | | `agent_working` event | new, 157th SSE constant | The one hook event with no Claude Code hook behind it. A harness turn cannot run while its own modal approval is on screen, so "started working" proves a dialog was answered in the terminal. Without it a dsh red alert would survive until the next `stop` — the exact stuck-alert bug the claude path already fixed once, and its pane-capture staleness sweep is Claude-dialog-shaped and cannot help here. | | Resolver | identity probe THEN version probe | Strictest of the family, and not by preference. `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) which would answer a version probe convincingly and then be handed a spawn line. `dsh --help` must match `DeepSeek Harness` first. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release. | diff --git a/docs/deepseek-integration.md b/docs/deepseek-integration.md index 2e941d561..7b97ccc99 100644 --- a/docs/deepseek-integration.md +++ b/docs/deepseek-integration.md @@ -62,7 +62,9 @@ curl -sX POST localhost:3000/api/deepseek/install-profile \ Installing a plugin is arbitrary code execution on the host, so in multi-user mode this endpoint requires the can-bypass-permissions grant (the same bar as a -`shell` session). +`shell` session). The request is held open while the package manager runs and is +bounded at five minutes; the install runs in its own process group, so hitting +that bound kills the whole tree rather than just the launcher. > **`dsh` is also a Debian program.** `apt install dsh` gives you "dancer's > shell", a distributed shell, which would answer `--version` convincingly. @@ -93,6 +95,23 @@ non-granted owner's `danger-full-access` becomes `workspace-write`, not `read-only`: the clamp removes privilege without breaking the session's ability to edit its own workspace. +Because the switch is an env var rather than a flag, that clamp has a second half +no other CLI needs. `DSH_*` is an allowlisted `envOverrides` prefix (it has to be: +that is also how you set the harness's ordinary knobs), and env overrides are +applied *after* the permission export, so in multi-user mode a non-granted owner +sending + +```json +{ "mode": "deepseek", "envOverrides": { "DSH_PERMISSION_MODE": "danger-full-access" } } +``` + +would otherwise hand back the privilege the config clamp just removed. For a +non-granted owner Codeman therefore **drops `DSH_PERMISSION_MODE` and `DSH_HOME` +from `envOverrides`**; dropping them falls through to the clamped config and the +server's own `DSH_HOME`. `DSH_HOME` is in that list because it points the +launcher at a profile tree, and a profile's plugin code runs at boot, before any +approval row can apply. Single-user installs and granted owners are unaffected. + ## 3. Real idle detection (the interesting part) Every other external CLI mode in Codeman is **readiness-guessed**: Codeman @@ -125,6 +144,18 @@ So a DeepSeek session gets Claude-grade signals: `GET /api/sessions/:id/wait` really can block on `stop` and `blocked` for it, and it is the only non-Claude mode for which that is true (`hooksAvailableForMode`). +That is a per-*session* answer, not a per-mode one. Turning the bridge off with +`deepSeekConfig.statusReporting: false` means nothing will ever post a hook event +for that session, so an explicit `until=stop` is refused up front (with a message +naming the setting) rather than blocking for your whole timeout. Omitting `until` +never fails: the hook-only signals are dropped from the default set and you still +get `idle` and `exit`. + +One limit worth knowing: whether the *profile* implements the contract cannot be +known at request time (Codeman deliberately treats an unrecognized profile as +launchable). A dsh session running a non-conforming TUI therefore still accepts +`until=stop` and will time out on it. `idle`/`exit` are the reliable pair there. + This is an interface implementation, not an impersonation — nothing on your machine executes a real `herdr` binary. If you use a terminal profile that does *not* implement the contract, the shim is simply never called and the mode falls diff --git a/src/session.ts b/src/session.ts index 6559a6019..2e5e74408 100644 --- a/src/session.ts +++ b/src/session.ts @@ -893,6 +893,20 @@ export class Session extends EventEmitter { return this._remote; } + /** + * `deepSeekConfig.statusReporting` verbatim: `undefined` when the caller sent + * none (i.e. ON), `false` when the user disarmed the status bridge for this + * session. + * + * Exposed because whether a dsh session can deliver `stop`/`blocked` is a + * per-SESSION fact, not a per-mode one, and `hooksAvailableForMode()` is pure + * and holds no `Session` reference by design. Undefined for every other mode, + * where the flag is meaningless. + */ + get deepSeekStatusReporting(): boolean | undefined { + return this._deepSeekConfig?.statusReporting; + } + /** Owning username in multi-user mode, else undefined. */ get owner(): string | undefined { return this._owner; diff --git a/src/web/routes/approval-routes.ts b/src/web/routes/approval-routes.ts index 8ad06f4bf..df5360578 100644 --- a/src/web/routes/approval-routes.ts +++ b/src/web/routes/approval-routes.ts @@ -23,7 +23,7 @@ import { ApiErrorCode, createErrorResponse } from '../../types.js'; import { ApprovalAnswerSchema } from '../schemas.js'; import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js'; import { approvalInbox, type ApprovalItem } from '../approval-inbox.js'; -import { hooksAvailableForMode } from '../session-wait-registry.js'; +import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js'; import type { SessionPort } from '../ports/index.js'; /** @@ -99,7 +99,7 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort): // Throws 404 (not 403) for sessions the caller does not own, same // no-existence-leak rule as every other session route. const session = findSessionOrFail(ctx, item.sessionId, req); - if (!hooksAvailableForMode(session.mode)) { + if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) { return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals'); } diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index 338e8459b..bd754e72a 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -13,7 +13,7 @@ import { HookEventSchema, isValidWorkingDir } from '../schemas.js'; import { sanitizeHookData, parseBody } from '../route-helpers.js'; import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js'; import { getDataDir } from '../../config/instance.js'; -import { sessionWaits, hooksAvailableForMode } from '../session-wait-registry.js'; +import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js'; import { approvalInbox, type ApprovalKind } from '../approval-inbox.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; @@ -59,7 +59,7 @@ export function registerHookEventRoutes( // could never legitimately emit one is now dropped instead of steering another // agent's control flow. const waitSession = ctx.sessions.get(sessionId); - if (waitSession && hooksAvailableForMode(waitSession.mode)) { + if (waitSession && hooksAvailableForMode(waitSession.mode, sessionHookOptions(waitSession))) { if (event === 'stop') { sessionWaits.notifySignal(sessionId, 'stop'); } else if (event === 'permission_prompt' || event === 'elicitation_dialog') { @@ -120,7 +120,7 @@ export function registerHookEventRoutes( // session that can never show one must not create an answerable item). let approvalId: string | undefined; const approvalKind = APPROVAL_KIND_BY_EVENT[event]; - if (session && hooksAvailableForMode(session.mode)) { + if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) { if (approvalKind) { const toolInput = safeData.tool_input && typeof safeData.tool_input === 'object' diff --git a/src/web/routes/readmymind-routes.ts b/src/web/routes/readmymind-routes.ts index 25394f711..27a1da60c 100644 --- a/src/web/routes/readmymind-routes.ts +++ b/src/web/routes/readmymind-routes.ts @@ -38,7 +38,6 @@ import { IntentGoalsSchema, ReadMyMindPredictSchema } from '../schemas.js'; import { parseBody, findSessionOrFail } from '../route-helpers.js'; import { intentStore } from '../../intent-store.js'; import { approvalInbox } from '../approval-inbox.js'; -import { hooksAvailableForMode } from '../session-wait-registry.js'; import { buildPredictionContext, type PredictionContextInputs } from '../../readmymind-context.js'; import { collectWorkspaceSignals, readTranscriptSignals } from '../../readmymind-collectors.js'; import { readMyMindPredictor } from '../../readmymind-predictor.js'; @@ -72,7 +71,11 @@ export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort const body = parseBody(ReadMyMindPredictSchema, req.body ?? {}); const session = findSessionOrFail(ctx, id, req); - if (!hooksAvailableForMode(session.mode)) { + // `mode === 'claude'` directly, NOT hooksAvailableForMode(): that predicate + // answers "can this session deliver stop/blocked", and once `deepseek` earned + // a yes it silently widened this gate to a mode whose sessions have no Claude + // transcript for readTranscriptSignals() to read. + if (session.mode !== 'claude') { reply.code(400); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Read My Mind predicts claude-mode sessions only'); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index d0e766b28..1c84777ab 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -54,6 +54,7 @@ import { TabLayoutValidationError } from '../../tab-layout.js'; import { sessionWaits, resolveWaitSignals, + sessionHookOptions, signalForStatus, WaitCapacityError, type WaitSignal, @@ -391,6 +392,55 @@ async function clampExternalCliBypassForOwner( /** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner; +/** + * Env-var keys a non-granted owner must not be able to set, because each one + * hands back privilege the config clamp above just removed. + * + * Both are DeepSeek's, and both are reachable because `DSH_*` is an allowlisted + * `envOverrides` prefix (schemas.ts) — which it has to be, since that is also how + * a user configures the harness's non-privileged knobs. + * + * - `DSH_PERMISSION_MODE` IS the harness's permission switch. Every other CLI's + * bypass is a command-line FLAG, reachable only through the per-CLI config the + * clamp already owns; this one is an env var, so the config clamp alone is + * half a gate. + * - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code + * executes at BOOT, before any approval row can apply. A user who can write a + * workspace can put a profile in it, so this is the wider of the two. + */ +const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME'] as const; + +/** + * Env-var half of the multi-user bypass clamp. + * + * `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI + * but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs + * AFTER `_configureDeepSeek()` in tmux-manager, so an override sent on the SAME + * request lands last and wins, and a non-granted owner could restore + * `danger-full-access` on the very request the config clamp downgraded. + * + * Keys are DROPPED rather than rewritten: dropping falls through to what + * `_configureDeepSeek()` exports, which is the clamped config and the server's own + * `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a + * granted owner, like every other clamp here + * (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`), + * and it returns the caller's own object untouched when there is nothing to strip. + */ +async function clampEnvOverridesForOwner( + owner: string | undefined, + envOverrides: Record | undefined +): Promise | undefined> { + if (!envOverrides) return envOverrides; + if (!OWNER_CLAMPED_ENV_KEYS.some((key) => key in envOverrides)) return envOverrides; + if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides; + const clamped = { ...envOverrides }; + for (const key of OWNER_CLAMPED_ENV_KEYS) delete clamped[key]; + return clamped; +} + +/** Test hook: the env-var half of the same multi-user safety gate. */ +export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner; + /** * Why a DeepSeek session cannot start, or null when it can. * @@ -1018,7 +1068,7 @@ export function registerSessionRoutes( grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, resumeSessionId: validatedResumeId, - envOverrides: body.envOverrides, + envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit, remote, @@ -1344,7 +1394,10 @@ export function registerSessionRoutes( wait === true || (typeof wait === 'string' && wait.trim().length > 0) || (Array.isArray(wait) && wait.length > 0); let until: readonly WaitSignal[] = []; if (wantsWait) { - const resolved = resolveWaitSignals(wait === true ? undefined : wait, { mode: session.mode }); + const resolved = resolveWaitSignals(wait === true ? undefined : wait, { + mode: session.mode, + ...sessionHookOptions(session), + }); if (resolved.error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, resolved.error); until = resolved.until; } @@ -1521,7 +1574,7 @@ export function registerSessionRoutes( // Shared with the `wait` field on POST .../input: unknown token is a 400, // hook-only signals are rejected explicitly but dropped from the default. - const { until, error } = resolveWaitSignals(query.until, { mode: session.mode }); + const { until, error } = resolveWaitSignals(query.until, { mode: session.mode, ...sessionHookOptions(session) }); if (error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, error); // The value actually applied after clamping, echoed below: a caller that asked @@ -3160,6 +3213,7 @@ export function registerSessionRoutes( deepSeekConfig ); const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig(); + const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides); const session = new Session({ workingDir: resolvedCasePath, name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '', @@ -3178,7 +3232,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, - envOverrides, + envOverrides: qsGatedEnvOverrides, effort, remote, docker, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index ff83a0ea2..7f906f36b 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -530,7 +530,10 @@ export function registerSystemRoutes( // second command; // - the request is held open with a bounded timeout, mirroring the // synchronous-clone precedent in `POST /api/cases/clone` rather than - // introducing a job store for a once-per-install action. + // introducing a job store for a once-per-install action — and the bound is + // real, because the install runs in its own process GROUP and the timeout + // kills the whole tree (see the spawn below for why the built-in one is + // not enough). app.post('/api/deepseek/install-profile', async (req) => { const body = parseBody(DeepSeekInstallProfileSchema, req.body); if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) { @@ -546,29 +549,87 @@ export function registerSystemRoutes( const profile = body.profile || DEEPSEEK_DEFAULT_PROFILE; const pkg = body.package || DEEPSEEK_DEFAULT_TUI_PACKAGE; - const result = await new Promise<{ code: number | null; output: string }>((resolve) => { - const child = spawn(join(dir, 'dsh'), ['plugin', '--profile', profile, 'add', pkg], { - stdio: ['ignore', 'pipe', 'pipe'], - timeout: DEEPSEEK_INSTALL_TIMEOUT_MS, - // dsh bundles its own package manager, so no system pnpm is required — - // but it still needs a HOME to resolve $DSH_HOME against. - env: process.env, - }); + const result = await new Promise<{ code: number | null; output: string; timedOut: boolean }>((resolve) => { + let child: ReturnType; + try { + child = spawn(join(dir, 'dsh'), ['plugin', '--profile', profile, 'add', pkg], { + stdio: ['ignore', 'pipe', 'pipe'], + // Own process group, and the timeout enforced by hand rather than by + // spawn's `timeout` option. A plugin install fans out into + // package-manager resolver/build children, and spawn's own timeout + // signals ONLY the direct child: the survivors keep the inherited stdio + // pipes open, `close` never fires, and this request hangs forever with + // no route-level deadline behind it. Same fan-out, same escalation and + // same negative-pid signal as runGit() in git-clone.ts, which is the + // synchronous-spawn precedent this endpoint is modelled on. + detached: true, + // dsh bundles its own package manager, so no system pnpm is required — + // but it still needs a HOME to resolve $DSH_HOME against. + env: process.env, + }); + } catch (err) { + resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false }); + return; + } + let output = ''; + let timedOut = false; + let settled = false; + let killTimer: NodeJS.Timeout | undefined; + let reapTimer: NodeJS.Timeout | undefined; + const capture = (chunk: Buffer) => { // Bounded: a package manager can emit megabytes of progress. if (output.length < 16_384) output += chunk.toString('utf-8'); }; child.stdout?.on('data', capture); child.stderr?.on('data', capture); - child.on('error', (err) => resolve({ code: null, output: `${output}\n${err.message}` })); - child.on('close', (code) => resolve({ code, output })); + + const killTree = (signal: NodeJS.Signals) => { + try { + if (child.pid) process.kill(-child.pid, signal); + } catch { + try { + child.kill(signal); + } catch { + /* already gone */ + } + } + }; + + const finish = (code: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); + if (reapTimer) clearTimeout(reapTimer); + resolve({ code, output, timedOut }); + }; + + const timer = setTimeout(() => { + timedOut = true; + killTree('SIGTERM'); + killTimer = setTimeout(() => killTree('SIGKILL'), 3_000); + // Last resort: a grandchild that escaped the group (double-fork/setsid) + // can hold the pipes open past SIGKILL, and `close` would still never + // arrive. Answer the caller anyway rather than leaking the request. + reapTimer = setTimeout(() => finish(null), 8_000); + }, DEEPSEEK_INSTALL_TIMEOUT_MS); + + child.on('error', (err) => { + output = `${output}\n${err.message}`; + finish(null); + }); + child.on('close', (code) => finish(code)); }); if (result.code !== 0) { + const detail = result.timedOut + ? `timed out after ${Math.round(DEEPSEEK_INSTALL_TIMEOUT_MS / 1000)}s` + : result.output.slice(-1000).trim() || 'no output'; return createErrorResponse( ApiErrorCode.OPERATION_FAILED, - `Installing ${pkg} into profile "${profile}" failed: ${result.output.slice(-1000).trim() || 'no output'}` + `Installing ${pkg} into profile "${profile}" failed: ${detail}` ); } const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } = diff --git a/src/web/server.ts b/src/web/server.ts index 98e3bc80d..f24fee632 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -91,7 +91,7 @@ import { attachSessionListeners, detachSessionListeners, } from './session-listener-wiring.js'; -import { sessionWaits, hooksAvailableForMode } from './session-wait-registry.js'; +import { sessionWaits } from './session-wait-registry.js'; import { intentStore } from '../intent-store.js'; import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { approvalInbox } from './approval-inbox.js'; @@ -1093,7 +1093,10 @@ export class WebServer extends EventEmitter { */ private async captureIntentPrompt(sessionId: string, text: string): Promise { const session = this.sessions.get(sessionId); - if (!session || !hooksAvailableForMode(session.mode)) return; + // `mode === 'claude'` directly: the intent profile is fed from Claude's own + // transcript, so this is a claude question, not a hooks-available one (which + // `deepseek` now answers yes to). + if (!session || session.mode !== 'claude') return; try { const settings = await this.readSettings(); if (settings.readMyMindEnabled !== true) return; diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index cf7107e0c..bf1f6c97b 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -170,25 +170,72 @@ export function signalForStatus(status: SessionStatus): WaitSignal | null { /** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked']; +/** Per-session facts that can turn a mode's hook capability OFF for one session. */ +export interface HookCapabilityOptions { + /** + * `deepSeekConfig.statusReporting`, verbatim (so `undefined` means "not sent", + * i.e. ON). `false` is the per-session opt-out that stops `_configureDeepSeek()` + * exporting the `HERDR_*` triple, which is the ONLY thing that makes a dsh + * session emit hook events at all. + */ + deepSeekStatusReporting?: boolean; +} + /** - * Whether a session in this mode ever POSTs Codeman hook events, and therefore - * whether `stop` / `blocked` can ever fire for it. + * Whether this session ever POSTs Codeman hook events, and therefore whether + * `stop` / `blocked` can ever fire for it. * - * True for `claude` and nothing else. The tempting predicate is + * `claude` always (Claude Code fires the hooks itself), `deepseek` when its + * status bridge is armed, nothing else. The tempting predicate is * `!isExternalCliMode(mode)`, and it is WRONG: that helper covers only * opencode/codex/gemini/antigravity, so `shell` falls through it — and a shell session * is a plain bash PTY with no Claude Code and no hooks installed. `until=stop` on one * was accepted and then blocked for the caller's whole timeout, which is precisely the * infinite-wait-dressed-as-a-timeout this guard exists to prevent. + * + * ⚠️ `deepseek` is a per-SESSION answer, not a per-mode one, which is why the + * options argument exists: `deepSeekConfig.statusReporting: false` disarms the + * bridge for one session, and answering from the mode alone re-creates the exact + * infinite-wait this guard is for. Every call site therefore passes the session's + * own flag; the default stays permissive so a forgotten one degrades to the old + * behavior rather than 400ing a session that works. + * + * ⚠️ It is also the LIMIT of what can be known at request time. Whether the + * installed profile actually implements the supervisor contract is only + * observable once it reports, and `resolveDefaultDeepSeekProfile()` deliberately + * treats an unrecognized profile as launchable, so a dsh session running a + * non-conforming TUI still answers true here and still times out on an explicit + * `until=stop`. The default signal set keeps `idle`/`exit` for exactly that case. + * + * ⚠️ NOT a stand-in for "is this a claude session". It reads like one and it was + * used as one (Read My Mind, intent capture) until `deepseek` joined and silently + * widened both. Those sites compare `mode === 'claude'` directly now; ask this + * function only about hook SIGNALS. */ -export function hooksAvailableForMode(mode: SessionMode): boolean { +export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean { + if (mode === 'claude') return true; // `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE // signals rather than having them inferred. The DeepSeek Harness terminal // front door reports idle/working/blocked to its supervisor, and Codeman is // that supervisor (see deepseek-status-shim.ts), so a dsh session really can - // deliver `stop` and `blocked`. Every other mode is output-stabilization - // guesswork and must keep failing the ask. - return mode === 'claude' || mode === 'deepseek'; + // deliver `stop` and `blocked` — unless the user turned the bridge off, in + // which case nothing on the box will ever post one. Every other mode is + // output-stabilization guesswork and must keep failing the ask. + if (mode === 'deepseek') return options.deepSeekStatusReporting !== false; + return false; +} + +/** + * Lift the per-session hook facts off a live session. + * + * Structurally typed on purpose: this module is pure and deliberately imports no + * `Session` (importing it would drag node-pty and the session layer into every + * consumer). One helper rather than an inline object literal at each of the four + * call sites, so a future per-session fact is added in one place instead of + * being forgotten at three of them. + */ +export function sessionHookOptions(session: { deepSeekStatusReporting?: boolean }): HookCapabilityOptions { + return { deepSeekStatusReporting: session.deepSeekStatusReporting }; } /** Outcome of resolving a caller-supplied wait target against a session's mode. */ @@ -212,10 +259,15 @@ export interface ResolvedWaitSignals { * not drift; the second-guessing that produces is worse than the duplication. * * @param raw - the caller's value (comma string, array, `true` for "the default") - * @param options - `mode` decides whether the hook-only signals are available, and - * names the mode in the error message so the caller can see why + * @param options - `mode` plus the per-session facts `hooksAvailableForMode()` needs + * (a dsh session with its status bridge disarmed emits no hooks even + * though the mode can). The mode also names itself in the error + * message so the caller can see why. */ -export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode }): ResolvedWaitSignals { +export function resolveWaitSignals( + raw: unknown, + options: { mode: SessionMode } & HookCapabilityOptions +): ResolvedWaitSignals { const parsed = parseWaitSignals(raw); if (parsed.invalid.length > 0) { return { @@ -224,7 +276,7 @@ export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode }) }; } - const unsupported = new Set(hooksAvailableForMode(options.mode) ? [] : HOOK_ONLY_SIGNALS); + const unsupported = new Set(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS); if (parsed.signals.length === 0) { return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null }; @@ -234,7 +286,14 @@ export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode }) if (rejected.length > 0) { return { until: [], - error: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`, + // A dsh session is the one case where the mode is capable and THIS session + // is not, so saying "never fire for deepseek sessions" would send the + // caller looking for a bug that is really a setting they chose. + error: + options.mode === 'deepseek' + ? `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` + + `(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.` + : `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`, }; } return { until: parsed.signals, error: null }; diff --git a/test/deepseek-mode.test.ts b/test/deepseek-mode.test.ts index 264129fa8..1cdb7b71c 100644 --- a/test/deepseek-mode.test.ts +++ b/test/deepseek-mode.test.ts @@ -16,9 +16,11 @@ import { buildSpawnCommand } from '../src/tmux-manager.js'; import { defaultDockerCommandForMode } from '../src/docker-hosts.js'; import { defaultRemoteCommandForMode } from '../src/remote-hosts.js'; import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js'; -import { hooksAvailableForMode } from '../src/web/session-wait-registry.js'; -import { _clampExternalCliBypassForOwner } from '../src/web/routes/session-routes.js'; +import { hooksAvailableForMode, resolveWaitSignals } from '../src/web/session-wait-registry.js'; +import { _clampExternalCliBypassForOwner, _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js'; import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; vi.mock('../src/utils/deepseek-cli-resolver.js', async (importOriginal) => { const actual = await importOriginal(); @@ -191,6 +193,62 @@ describe('DeepSeek status bridge', () => { } }); + it('is a per-SESSION answer for deepseek: a disarmed status bridge emits nothing', () => { + // `statusReporting: false` is what stops _configureDeepSeek() exporting the + // HERDR_* triple, and the triple is the ONLY reason a dsh session posts hook + // events. Answering from the mode alone would accept `until=stop` on a + // session where nothing can ever send one, which is the exact + // infinite-wait-dressed-as-a-timeout this predicate exists to prevent. + expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: false })).toBe(false); + expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: true })).toBe(true); + // Not sent = ON, so an ordinary session is unaffected. + expect(hooksAvailableForMode('deepseek', {})).toBe(true); + expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: undefined })).toBe(true); + // The flag is meaningless for every other mode and must not move them. + expect(hooksAvailableForMode('claude', { deepSeekStatusReporting: false })).toBe(true); + expect(hooksAvailableForMode('codex', { deepSeekStatusReporting: true })).toBe(false); + }); + + it('refuses an explicit stop/blocked on a dsh session whose bridge is off, and says why', () => { + const off = { mode: 'deepseek' as const, deepSeekStatusReporting: false }; + const on = { mode: 'deepseek' as const }; + + expect(resolveWaitSignals('stop', on)).toEqual({ until: ['stop'], error: null }); + + const rejected = resolveWaitSignals('stop', off); + expect(rejected.until).toEqual([]); + // The generic "no Claude Code hooks" wording would send the caller hunting a + // bug that is really a setting they chose, so this arm names the setting. + expect(rejected.error).toContain('statusReporting'); + expect(rejected.error).not.toContain('no Claude Code hooks'); + + // An OMITTED `until` must never 400: the hook-only signals are dropped from + // the default set instead, leaving the two that still work. + expect(resolveWaitSignals(undefined, off)).toEqual({ until: ['idle', 'exit'], error: null }); + expect(resolveWaitSignals(undefined, on).until).toContain('stop'); + }); + + it('keeps the hook predicate out of the two gates that mean "is this claude"', () => { + // Read My Mind and intent capture read Claude's own transcript, so they mean + // mode === 'claude'. They used to ask hooksAvailableForMode(), which was the + // same question until `deepseek` earned a yes and silently widened both to a + // mode with no transcript to read. Static, because the alternative is + // standing up a predictor and a transcript watcher to observe one `if`. + const rmm = readFileSync(join(process.cwd(), 'src/web/routes/readmymind-routes.ts'), 'utf-8'); + expect(rmm).toContain("session.mode !== 'claude'"); + // Comment lines dropped first: the comment above that `if` names the + // predicate in order to explain why it is NOT the one being called there. + const uncommented = (src: string) => + src + .split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n'); + expect(uncommented(rmm)).not.toMatch(/hooksAvailableForMode\(/); + + const server = readFileSync(join(process.cwd(), 'src/web/server.ts'), 'utf-8'); + expect(server).toContain("if (!session || session.mode !== 'claude') return;"); + }); + it('maps the harness lifecycle states onto real hook events', () => { expect(DEEPSEEK_STATE_TO_HOOK_EVENT.idle).toBe('stop'); expect(DEEPSEEK_STATE_TO_HOOK_EVENT.blocked).toBe('permission_prompt'); @@ -238,3 +296,64 @@ describe('DeepSeek multi-user clamp', () => { expect(out.deepSeekConfig).toBeUndefined(); }); }); + +describe('DeepSeek multi-user clamp: the env-var half', () => { + const ORIGINAL = process.env.CODEMAN_MULTIUSER; + beforeEach(() => { + process.env.CODEMAN_MULTIUSER = '1'; + }); + afterEach(() => { + if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = ORIGINAL; + }); + + it('strips DSH_PERMISSION_MODE, which would otherwise undo the config clamp on the same request', async () => { + // applyEnvOverrides() runs AFTER _configureDeepSeek() in tmux-manager, so an + // override sent alongside the config lands last and WINS. Clamping the config + // alone is therefore half a gate: this is the other half. + const out = await _clampEnvOverridesForOwner('nobody', { + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_TELEMETRY_MODE: 'off', + }); + expect(out).toEqual({ DSH_TELEMETRY_MODE: 'off' }); + }); + + it('strips DSH_HOME, which points the launcher at a profile tree that executes at boot', async () => { + const out = await _clampEnvOverridesForOwner('nobody', { DSH_HOME: '/home/attacker/evil-dsh' }); + expect(out).toEqual({}); + }); + + it('leaves unrelated overrides alone, and returns the same object when there is nothing to strip', async () => { + const input = { DEEPSEEK_API_KEY: 'sk-test', CODEX_HOME: '/tmp/cx' }; + const out = await _clampEnvOverridesForOwner('nobody', input); + expect(out).toBe(input); + expect(await _clampEnvOverridesForOwner('nobody', undefined)).toBeUndefined(); + }); + + it('is a no-op in single-user mode', async () => { + delete process.env.CODEMAN_MULTIUSER; + const input = { DSH_PERMISSION_MODE: 'danger-full-access', DSH_HOME: '/opt/dsh' }; + // canUsernameRunPrivilegedCommands() returns true when !isMultiUserMode(), so + // the single-user behaviour has to be byte-identical to before this clamp. + expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input); + }); +}); + +describe('DeepSeek profile install is bounded for real', () => { + it('runs in its own process group and escalates the kill to the whole tree', () => { + // `dsh plugin add` fans out into package-manager resolver/build children, and + // spawn's own `timeout` signals only the direct child: survivors hold the + // inherited stdio pipes open, `close` never fires, and the held-open request + // leaks forever. Same failure and same fix as runGit() in git-clone.ts. + // Static, because reproducing it needs a real package manager that hangs. + const src = readFileSync(join(process.cwd(), 'src/web/routes/system-routes.ts'), 'utf-8'); + const handler = src.slice(src.indexOf("app.post('/api/deepseek/install-profile'")); + const body = handler.slice(0, handler.indexOf('app.post(', 1) + 1 || handler.length); + expect(body).toContain('detached: true'); + expect(body).toContain('process.kill(-child.pid, signal)'); + expect(body).toContain("killTree('SIGTERM')"); + expect(body).toContain("killTree('SIGKILL')"); + // The built-in option is the thing that did NOT work here; it must not come back. + expect(body).not.toContain('timeout: DEEPSEEK_INSTALL_TIMEOUT_MS'); + }); +}); From cdceede33db3148495cd9df285f1a37d785dd2e4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 24 Aug 2026 18:00:52 +0200 Subject: [PATCH 3/6] fix(deepseek): atomic shim write, honest attribution comment, name-fallback profile classifier The three smaller review nits, plus the first real test coverage for the status shim (it had none: it is emitted as a STRING, so tsc never sees it). 1. The shim was written with a plain writeFileSync. The TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a reader catching a half-written file gets a syntax error, exits non-zero, and is retried four times per state change for a file that will never parse. Now temp + rename (atomic within the directory), with the temp chmod'ed before the rename since writeFileSync's mode only applies on create, and removed if the write throws. SHIM_VERSION bumped to 2, because SHIM_SOURCE changed and an existing v1 shim would otherwise keep matching the embedded marker and never be refreshed. 2. The pane-id comment claimed the ambient env "cannot be spoofed by an argument the agent itself could influence". The agent runs IN that pane and can invoke the shim with CODEMAN_SESSION_ID unset and any argv it likes. It buys nothing it did not already have (the hook-secret file is readable from the same pane, so it can POST /api/hook-event directly), but the comment read like a security boundary. Rewritten to say what the preference actually buys: correct attribution when a TUI mangles or re-uses the pane argument. Accidents, not adversaries. 3. classifyProfile() folded the directory name into the same haystack as the bundles, but only the TUI arm could match a bare name, so a stock profile whose package.json has no dsh.profile.bundles (hand-edited, older layout, mid-install) classified as `unknown` -> launchable -> eligible as the DEFAULT pick, which is exactly the pane-dies-on-arrival failure the two-part availability gate exists to prevent. The stock names are now a LAST-resort fallback consulted after the bundle patterns, so real bundle evidence still wins over a name the user chose. The loose `tui` arm gained word boundaries: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. New test/deepseek-status-shim.test.ts runs the generated script the way the harness does -- real node process, real argv, real env, real listener -- and covers the exit-code contract that makes the retry behaviour safe: mapped states post and exit 0, an unknown verb or unmapped state exits 0 WITHOUT posting (a non-zero there would be four HTTP requests per state change forever), a rejecting server or an unreachable one exits non-zero so the caller retries, the hook secret is read at execution time, and `node --check` parses the file (a template-literal typo in SHIM_SOURCE is invisible to tsc). Trap worth recording, hit while writing it: the tests must spawn the shim ASYNCHRONOUSLY. The listener lives in the test process, so spawnSync blocks the event loop that has to accept the connection, the shim waits out its own 1500ms socket timeout and exits 1, and it reads exactly like a broken shim (measured: Socket._onTimeout in its --trace-exit output, server logging nothing). Verified: full gate green (6142 passed, +10), typecheck/lint/format clean. --- docs/architecture-invariants.md | 2 +- src/deepseek-status-shim.ts | 34 ++++- src/utils/deepseek-cli-resolver.ts | 32 ++++- test/deepseek-cli-resolver.test.ts | 45 +++++++ test/deepseek-status-shim.test.ts | 207 +++++++++++++++++++++++++++++ 5 files changed, 312 insertions(+), 8 deletions(-) create mode 100644 test/deepseek-status-shim.test.ts diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index fcebbbccd..6fbb5dbe8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -34,7 +34,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity. -Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. +Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. **Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session ` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). diff --git a/src/deepseek-status-shim.ts b/src/deepseek-status-shim.ts index 19dbfe9a5..fa3b385e7 100644 --- a/src/deepseek-status-shim.ts +++ b/src/deepseek-status-shim.ts @@ -44,7 +44,7 @@ * @module deepseek-status-shim */ -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { dataPath } from './config/instance.js'; @@ -54,7 +54,7 @@ import { dataPath } from './config/instance.js'; * by an older Codeman and rewrite only when needed (rather than rewriting on * every session create, or — worse — leaving a stale one in place forever). */ -const SHIM_VERSION = 1; +const SHIM_VERSION = 2; const SHIM_MARKER = `codeman-dsh-status-shim v${SHIM_VERSION}`; /** @@ -125,8 +125,13 @@ const event = STATE_TO_EVENT[String(flag('--state') ?? '')] if (!event) process.exit(0) // The pane id we hand the TUI IS the Codeman session id, but prefer the ambient -// env: it is set by the same code that set HERDR_PANE_ID and cannot be spoofed -// by an argument the agent itself could influence. +// env: it is set by the same code that set HERDR_PANE_ID, so a TUI that mangles, +// truncates or re-uses the pane argument still reports against the right session. +// NOT a security boundary, and do not read it as one: the agent runs IN this pane +// and can invoke the shim with CODEMAN_SESSION_ID unset and any argv it likes. +// That buys it nothing it did not already have, since the hook-secret file is +// readable from the same pane and any process there can POST /api/hook-event +// directly. Attribution here is about accidents, not adversaries. const sessionId = process.env.CODEMAN_SESSION_ID || argv[2] const apiUrl = process.env.CODEMAN_API_URL if (!sessionId || !apiUrl) process.exit(1) @@ -213,7 +218,26 @@ export function ensureDeepSeekStatusShim(): string | null { } if (!current.includes(SHIM_MARKER)) { mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, SHIM_SOURCE, { mode: 0o700 }); + // Temp + rename, not a plain write: the TUI can be executing this exact + // path at the moment an upgraded Codeman refreshes it (every state change + // runs it, and session create is when the rewrite happens), and a reader + // that catches a half-written file gets a syntax error, exits non-zero, + // and is retried four times per state change for a file that will never + // parse. rename(2) is atomic within the directory, so a concurrent exec + // sees either the old shim or the new one, never a truncated one. + // Same reasoning as the state-store writes; pid-suffixed so two instances + // sharing a data dir cannot collide on the temp name. + const tempPath = `${path}.${process.pid}.tmp`; + try { + writeFileSync(tempPath, SHIM_SOURCE, { mode: 0o700 }); + // The mode argument only applies when writeFileSync CREATES the file, so + // a leftover temp from a crashed run would keep its old permissions. + chmodSync(tempPath, 0o700); + renameSync(tempPath, path); + } catch (err) { + rmSync(tempPath, { force: true }); + throw err; + } } // Re-assert the mode even when the content matched: a shim that lost its // executable bit (a restored backup, a copied data dir) would make every diff --git a/src/utils/deepseek-cli-resolver.ts b/src/utils/deepseek-cli-resolver.ts index c30604b31..9868d01f8 100644 --- a/src/utils/deepseek-cli-resolver.ts +++ b/src/utils/deepseek-cli-resolver.ts @@ -120,8 +120,36 @@ const HEADLESS_BUNDLE_PATTERN = /dsh-headless/i; * scoped `dsh-tui` packages from a dozen different authors compete. Anything * matching is a TUI; anything unmatched is `unknown`, which still counts as * launchable. + * + * `tui` carries word boundaries so the loose arm stays a TOKEN match: `-` and + * `/` are non-word characters, so `@someone/tui-app` and `dsh-tui` both match + * while `intuition` and `gratuitous` do not. Being wrong here is cheap (an + * unmatched profile is `unknown`, which is launchable too) but it decides which + * profile a session boots by DEFAULT, and "the one whose name happens to contain + * t-u-i" is not a rule anyone could predict. + */ +const TUI_BUNDLE_PATTERN = /dsh-tui|dsh-terminal-app|\btui\b/i; + +/** + * The profile names DeepSeek itself ships for its non-interactive surfaces. + * + * Consulted only AFTER the bundle patterns have found nothing, and only against + * the directory name. `readProfile()` yields an empty bundle list for any + * `package.json` without a `dsh.profile.bundles` array — a hand-edited file, an + * older layout, a profile mid-install — and with no bundles to read, the stock + * `web` and `headless` profiles look exactly like an unrecognized third-party + * one and inherit its launchable-by-default treatment. That is the single + * "unknown" that is knowably wrong, and it produces precisely the + * pane-dies-on-arrival failure the two-part availability gate exists to prevent. + * + * Deliberately a fallback rather than a first check: a third-party profile that + * legitimately composes a terminal app is identified by its BUNDLES, and its + * directory name (which the user chose) must never override that evidence. */ -const TUI_BUNDLE_PATTERN = /dsh-tui|dsh-terminal-app|tui/i; +const STOCK_NON_INTERACTIVE_PROFILES = new Map([ + ['web', 'web'], + ['headless', 'headless'], +]); /** Profile directory names that are not profiles. */ const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']); @@ -134,7 +162,7 @@ function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind { if (WEB_BUNDLE_PATTERN.test(haystack)) return 'web'; if (HEADLESS_BUNDLE_PATTERN.test(haystack)) return 'headless'; if (TUI_BUNDLE_PATTERN.test(haystack)) return 'interactive'; - return 'unknown'; + return STOCK_NON_INTERACTIVE_PROFILES.get(name.toLowerCase()) ?? 'unknown'; } /** diff --git a/test/deepseek-cli-resolver.test.ts b/test/deepseek-cli-resolver.test.ts index c458110df..83397c7cd 100644 --- a/test/deepseek-cli-resolver.test.ts +++ b/test/deepseek-cli-resolver.test.ts @@ -224,6 +224,51 @@ describe('DeepSeek profile inventory', () => { expect(resolveDefaultDeepSeekProfile()).toBe('custom'); }); + it('does not treat a bundle-less stock profile as launchable', () => { + // readProfile() yields an empty bundle list for any package.json without a + // `dsh.profile.bundles` array (hand-edited, older layout, mid-install), and + // with no bundles to read the shipped web/headless profiles used to look + // exactly like an unrecognized third-party one — inheriting its + // launchable-by-default treatment and producing the pane-dies-on-arrival + // failure the two-part availability gate exists to prevent. + const bare = (name: string) => { + const dir = join(home, 'profiles', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: `dsh-profile-${name}` })); + }; + bare('web'); + bare('headless'); + + const profiles = listDeepSeekProfiles(); + expect(profiles.map((p) => `${p.name}:${p.kind}`).sort()).toEqual(['headless:headless', 'web:web']); + expect(profiles.every((p) => !isLaunchableProfile(p))).toBe(true); + expect(resolveDefaultDeepSeekProfile()).toBeNull(); + }); + + it('lets bundle evidence beat the name fallback', () => { + // The name check is a LAST resort, so a profile the user happened to call + // `web` that really composes a terminal app is still interactive. Otherwise + // a directory name would override what the profile actually contains. + writeProfile('web', ['@deepseek-ai/dsh-base', '@someone/dsh-tui']); + const profile = listDeepSeekProfiles().find((p) => p.name === 'web')!; + expect(profile.kind).toBe('interactive'); + expect(resolveDefaultDeepSeekProfile()).toBe('web'); + }); + + it('does not read `tui` out of the middle of an unrelated word', () => { + // The loose arm is a TOKEN match: `@someone/tui-app` is a TUI, `intuition` + // is a word. Being wrong is cheap (unknown is launchable too) but it decides + // which profile boots by DEFAULT, and "its name contains t-u-i" is not a + // rule anyone could predict. + writeProfile('intuition', ['@someone/gratuitous-surface']); + expect(listDeepSeekProfiles().find((p) => p.name === 'intuition')!.kind).toBe('unknown'); + + writeProfile('mine', ['@someone/tui-app']); + expect(listDeepSeekProfiles().find((p) => p.name === 'mine')!.kind).toBe('interactive'); + // Preferred over the merely-unknown one, which is the whole point of ranking. + expect(resolveDefaultDeepSeekProfile()).toBe('mine'); + }); + it('survives a stray directory under profiles/', () => { mkdirSync(join(home, 'profiles', 'not-a-profile'), { recursive: true }); writeProfile('dsh-tui', ['@deepseek-harness-tui/dsh-tui']); diff --git a/test/deepseek-status-shim.test.ts b/test/deepseek-status-shim.test.ts new file mode 100644 index 000000000..15b51c814 --- /dev/null +++ b/test/deepseek-status-shim.test.ts @@ -0,0 +1,207 @@ +/** + * The generated DeepSeek Harness status shim. + * + * This file is the one piece of DeepSeek's wiring that is neither TypeScript we + * typecheck nor a route we can `inject()` into: it is a script emitted as a + * string, dropped in the data dir, and executed by a third-party TUI as a + * SUBPROCESS. So the assertions here run it the way the harness does — a real + * `node` process, real argv, real env, against a real listener — rather than + * inspecting the source text. + * + * The exit codes are the contract's load-bearing half: the caller retries with + * backoff on any non-zero, so "cannot ever succeed" (unknown verb, unmapped + * state) must exit 0 or one typo becomes four HTTP requests per state change, + * forever. + */ +import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import { createServer, type Server } from 'node:http'; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { + ensureDeepSeekStatusShim, + deepSeekStatusShimPath, + resetDeepSeekStatusShimForTest, + DEEPSEEK_STATE_TO_HOOK_EVENT, +} from '../src/deepseek-status-shim.js'; + +const PORT = 3251; + +describe('DeepSeek status shim: provisioning', () => { + beforeEach(() => { + resetDeepSeekStatusShimForTest(); + }); + + it('writes an executable shim that node can actually parse', () => { + const path = ensureDeepSeekStatusShim(); + expect(path).toBeTruthy(); + expect(existsSync(path!)).toBe(true); + // 0700: the TUI execs it directly, so a lost exec bit means every report + // fails and is retried four times per state change. + expect(statSync(path!).mode & 0o777).toBe(0o700); + // `node --check` on the real file, because a template-literal typo in + // SHIM_SOURCE is invisible to tsc — the shim is a STRING as far as the + // compiler is concerned. + expect(() => execFileSync(process.execPath, ['--check', path!], { stdio: 'pipe' })).not.toThrow(); + }); + + it('refreshes a shim written by an older Codeman, and leaves no temp file behind', () => { + const path = deepSeekStatusShimPath(); + ensureDeepSeekStatusShim(); + const current = readFileSync(path, 'utf-8'); + + // A v1 shim from an older install: right path, stale content. + writeFileSync(path, '#!/usr/bin/env node\n// codeman-dsh-status-shim v1\nprocess.exit(0)\n', { mode: 0o700 }); + resetDeepSeekStatusShimForTest(); + ensureDeepSeekStatusShim(); + + expect(readFileSync(path, 'utf-8')).toBe(current); + // The rewrite goes through a temp + rename so a TUI exec'ing this path mid + // refresh can never read a half-written file. The temp must not survive it. + const strays = readdirSync(dirname(path)).filter((f) => f.startsWith('dsh-status-shim') && f.endsWith('.tmp')); + expect(strays).toEqual([]); + }); + + it('re-asserts the exec bit even when the content already matches', () => { + const path = ensureDeepSeekStatusShim()!; + chmodSync(path, 0o600); // a restored backup / copied data dir + resetDeepSeekStatusShimForTest(); + ensureDeepSeekStatusShim(); + expect(statSync(path).mode & 0o777).toBe(0o700); + }); +}); + +describe('DeepSeek status shim: the supervisor contract', () => { + let server: Server | undefined; + const received: Array<{ body: unknown; secret: string | undefined }> = []; + let status = 200; + + const listen = () => + new Promise((resolve) => { + server = createServer((req, res) => { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + received.push({ + body: (() => { + try { + return JSON.parse(raw); + } catch { + return raw; + } + })(), + secret: req.headers['x-codeman-hook-secret'] as string | undefined, + }); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + }); + server.listen(PORT, '127.0.0.1', resolve); + }); + + beforeAll(() => listen()); + + afterAll(() => { + server?.close(); + }); + + /** + * Run the shim the way the TUI does, and ASYNCHRONOUSLY. + * + * Never spawnSync here: the listener above lives in this same process, so a + * synchronous spawn blocks the event loop that has to accept the connection. + * The shim then waits out its own 1500ms socket timeout and exits 1, which + * reads exactly like a broken shim (measured: `Socket._onTimeout` in its exit + * trace, and the server logging nothing). + */ + const run = (args: string[], env: Record = {}) => + new Promise<{ status: number | null; stderr: string }>((resolve) => { + const path = ensureDeepSeekStatusShim()!; + const child = spawn(process.execPath, [path, ...args], { + env: { + ...process.env, + CODEMAN_API_URL: `http://127.0.0.1:${PORT}`, + CODEMAN_SESSION_ID: 'sess-from-env', + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (c: Buffer) => (stderr += c.toString('utf-8'))); + child.on('close', (status) => resolve({ status, stderr })); + }); + + // The exact command line the harness TUI runs, from the Herdr contract. + const report = (state: string, extra: string[] = []) => [ + 'pane', + 'report-agent', + 'pane-arg-id', + '--source', + 'custom:dsh-tui', + '--agent', + 'dsh-tui', + '--state', + state, + ...extra, + '--seq', + '7', + ]; + + it('forwards each harness state as its mapped hook event, and exits 0 on delivery', async () => { + resetDeepSeekStatusShimForTest(); + for (const [state, event] of Object.entries(DEEPSEEK_STATE_TO_HOOK_EVENT)) { + received.length = 0; + const out = await run(report(state, ['--message', 'needs a decision'])); + expect(out.status, `${state}: ${out.stderr}`).toBe(0); + expect(received).toHaveLength(1); + const body = received[0].body as { event: string; sessionId: string; data: Record }; + expect(body.event).toBe(event); + // The ambient env wins over the pane argument: same code set both, and the + // argument is whatever the TUI chose to pass. + expect(body.sessionId).toBe('sess-from-env'); + expect(body.data.agent).toBe('dsh-tui'); + expect(body.data.message).toBe('needs a decision'); + } + }); + + it('sends the hook secret read at EXECUTION time, so rotation needs no respawn', async () => { + resetDeepSeekStatusShimForTest(); + const secretFile = `${deepSeekStatusShimPath()}.secret-fixture`; + writeFileSync(secretFile, 'rotated-secret\n', { mode: 0o600 }); + received.length = 0; + const out = await run(report('idle'), { CODEMAN_HOOK_SECRET_FILE: secretFile }); + expect(out.status).toBe(0); + expect(received[0].secret).toBe('rotated-secret'); + }); + + it('exits 0 without posting for anything a retry could never fix', async () => { + resetDeepSeekStatusShimForTest(); + for (const args of [ + ['pane', 'list'], // unknown verb + ['something-else', 'report-agent', 'id', '--state', 'idle'], // unknown noun + [...report('rebooting')], // a state this bridge does not map + ['pane', 'report-agent', 'id'], // no --state at all + ]) { + received.length = 0; + const out = await run(args); + expect(out.status, `args ${args.join(' ')}`).toBe(0); + expect(received).toEqual([]); + } + }); + + it('exits non-zero when the post genuinely fails, so the caller retries', async () => { + resetDeepSeekStatusShimForTest(); + + // A rejecting server: transport worked, Codeman said no. + status = 500; + received.length = 0; + expect((await run(report('idle'))).status).not.toBe(0); + expect(received).toHaveLength(1); + status = 200; + + // Nothing listening at all. + expect((await run(report('idle'), { CODEMAN_API_URL: 'http://127.0.0.1:1' })).status).not.toBe(0); + // No API url to post to. + expect((await run(report('idle'), { CODEMAN_API_URL: '' })).status).not.toBe(0); + }); +}); From 15ae5f5d81eb678018c052584104d777c05c5ee9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 25 Aug 2026 02:39:57 +0200 Subject: [PATCH 4/6] fix(deepseek): make the web-UI shortcut pick a free port, verify it, and trust its frame The `Run > DeepSeek web UI...` shortcut failed three ways at once against a real install, and the three are independent. 1. It hardcoded `--port 3080`. That is dsh web's OWN default, which makes it precisely the port a DeepSeek user is most likely to be serving on already, so the launch died with EADDRINUSE against the user's own server. The port now comes from `GET /api/deepseek/web-port`, which walks 3080..3119 for a free loopback port by BINDING it (a connect probe cannot tell "free" from "listening but not answering yet"). 2. It opened the tab unconditionally. The crashed server left a saved dashboard pointing at nothing, with the failure only visible in a shell tab nobody had a reason to look at. The launch now polls the existing webview probe until the URL answers, and on timeout reports the error naming the shell tab instead of persisting a dead dashboard. 3. The saved tab was untrusted, so the frame was sandboxed without `allow-same-origin` and the dashboard was broken twice over: the dsh client-runtime reads `localStorage` while loading its plugins and died there ("the document is sandboxed and lacks the 'allow-same-origin' flag"), and an opaque-origin frame sends `Origin: null`, so dsh's own trust fence 403'd every `/api` call no matter which authority `--trusted-host` named. Passing `location.host` only means anything once the frame actually carries that origin, so `--trusted-host` had never once done its job. The managed tab is now created `trusted: true`. That trade is real and deliberate: a trusted proxied frame is same-origin with Codeman and can reach Codeman's API. It is defensible only because this dashboard is an agent harness Codeman just started itself, on loopback, which can already run code as the user. It is not a precedent for trusting third-party dashboards, which is why it is set at this one call site rather than defaulted. Separately, the shortcut listed its own dashboard twice: once as the menu entry that starts it and once as the row that entry had written on the previous click. Webviews now carry an optional `managed` marker, managed rows are filtered out of the saved-dashboard list, and a relaunch repoints the existing row rather than stacking one dead dashboard per restart (which the per-launch port would otherwise guarantee). `managed` is declared in the schema because a plain `z.object` strips undeclared keys, so an undeclared marker would never survive the round trip. `DEEPSEEK_WEB_PORT` is gone from constants.js; its doc comment asserted that a hand-started `dsh web` and the shortcut "land on the same place and share one saved tab", which is the bug stated as a feature. Verified on a real install with the user's own `dsh web` holding 3080: the shortcut takes 3081, the server answers, exactly one DeepSeek entry shows in the run menu, and the proxied dashboard renders its workspaces and completes its own API calls (the previously-403'd `api/settings.describe` now succeeds). Full gate green (6142 passed), typecheck/lint/format/public-assets clean. --- docs/deepseek-integration-plan.md | 43 +++++++++-- src/types/webview.ts | 16 +++++ src/web/public/constants.js | 6 -- src/web/public/session-ui.js | 114 ++++++++++++++++++++++++++---- src/web/public/webview-tabs.js | 5 +- src/web/routes/system-routes.ts | 45 ++++++++++++ src/web/routes/webview-routes.ts | 1 + src/web/schemas.ts | 6 ++ 8 files changed, 211 insertions(+), 25 deletions(-) diff --git a/docs/deepseek-integration-plan.md b/docs/deepseek-integration-plan.md index f1c294ae6..aa4a7ad21 100644 --- a/docs/deepseek-integration-plan.md +++ b/docs/deepseek-integration-plan.md @@ -48,7 +48,7 @@ in the six external CLIs before it. The browser UI is the only interactive surface DeepSeek ships itself, so it gets a **shortcut, not a run mode**: `Run ▸ DeepSeek web UI…` starts -`dsh web --no-open --host 127.0.0.1 --port 3080 --trusted-host ` +`dsh web --no-open --host 127.0.0.1 --port --trusted-host ` in an ordinary shell session and opens the URL as an ordinary web tab. Built entirely from parts that already exist: the server is a shell session @@ -58,6 +58,41 @@ Nothing new supervises a long-lived HTTP server, because Codeman already does. check on the request authority, and a Codeman web tab reaches it through Codeman's own origin via the webview proxy, not directly. +Three things about this shortcut are load-bearing and each came from it failing +in exactly that way against a real install: + +- **The port is chosen, never hardcoded.** `GET /api/deepseek/web-port` walks + 3080..3119 for a free loopback port. 3080 is dsh's own default, which makes it + precisely the port a DeepSeek user is most likely to already be serving on: + binding it unconditionally killed the launch with `EADDRINUSE` against the + user's own `dsh web`. +- **The tab is opened only after the server answers.** The launch polls + `POST /api/webviews/probe` until the URL responds, so a server that dies on + startup reports the failure and points at its shell tab, instead of silently + persisting a dashboard aimed at nothing. +- **The saved tab is `trusted: true`, and must be.** An untrusted webview is + sandboxed without `allow-same-origin`, which breaks this dashboard twice: the + dsh client-runtime reads `localStorage` while loading plugins and dies there, + and an opaque-origin frame sends `Origin: null`, so dsh's trust check 403s + every `/api` call regardless of what `--trusted-host` names. Passing + `location.host` only means anything once the frame actually carries that + origin. The trade is real — a trusted proxied frame is same-origin with + Codeman and can reach Codeman's API — and is defensible only because this + particular dashboard is an agent harness Codeman just started itself on + loopback, which can already run code as the user. It is not a precedent for + trusting third-party dashboards generally. + +The record is marked `managed: 'deepseek-web'`, which keeps it out of the +saved-dashboard list: the shortcut that maintains it is already a menu entry, so +listing both showed the same dashboard twice. Being managed is also what lets a +relaunch repoint the existing row instead of stacking one dead dashboard per +restart, since the port is now chosen per launch. + +⚠️ The authority baked into `--trusted-host` is the one the launch was clicked +from. Codeman reachable at several authorities (loopback *and* a tailnet name) +therefore needs the server restarted from whichever one is in use; the reuse +path checks that the server is reachable, not that it trusts the current origin. + ## 4. Touch points (the checklist) Backend: `types/session.ts` (SessionMode + `DeepSeekConfig` + SessionState), @@ -109,9 +144,9 @@ behaviour covered by 31 new unit tests; and an isolated instance used to exercis - A Docker case with `mode: 'deepseek'` (needs a `--no-cache` agent-image rebuild — see the `--no-cache` rule in CLAUDE.md). - A remote-SSH deepseek case. -- The web-UI shortcut end to end through the webview proxy, in particular whether - `--trusted-host ` is the right authority for dsh's `/api` - fence in every deployment shape (loopback, tailscale, tunnel). +- The web-UI shortcut against a tunnel authority. Loopback is verified end to end + through the webview proxy (dashboard renders, its `/api` calls succeed); the + cross-authority case above is a known limitation rather than an open question. ## 6. Follow-ups diff --git a/src/types/webview.ts b/src/types/webview.ts index 246f76759..7071d071e 100644 --- a/src/types/webview.ts +++ b/src/types/webview.ts @@ -32,6 +32,16 @@ export type WebviewEmbedMode = 'proxy' | 'direct'; /** A saved dashboard, persisted to `~/.codeman/webviews.json`. */ +/** + * Dashboards Codeman creates and maintains on the user's behalf. + * + * A managed record is hidden from the saved-dashboard list, because the shortcut + * that maintains it is already a menu entry of its own: listing both showed the + * same dashboard twice, once as "DeepSeek web UI..." and once as the row it had + * just written. + */ +export type WebviewManagedKind = 'deepseek-web'; + export interface Webview { id: string; /** Display name shown on the tab. */ @@ -49,6 +59,12 @@ export interface Webview { * cookies/localStorage, only for dashboards the user fully trusts. */ trusted: boolean; + /** + * Set when Codeman owns this record rather than the user (see + * `WebviewManagedKind`). Managed rows are maintained by the shortcut that + * created them, including repointing the URL when the port changes. + */ + managed?: WebviewManagedKind; /** Multi-user owner (username). Undefined in single-user mode. */ owner?: string; createdAt: number; diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 851e5c7c5..08b127108 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -54,12 +54,6 @@ const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notificati const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications const THROTTLE_DELAY_MS = 100; // General UI throttle delay -/** - * Port the DeepSeek Harness browser UI is started on by the run-menu shortcut. - * dsh's own default, so a hand-started `dsh web` and the shortcut land on the - * same place and share one saved tab. - */ -const DEEPSEEK_WEB_PORT = 3080; const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch) const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 3599cd93a..3e28a68a4 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -507,23 +507,54 @@ Object.assign(CodemanApp.prototype, { * browser-trust check on the request authority, and a Codeman web tab reaches * it through Codeman's own origin via the webview proxy, not directly. Without * passing Codeman's authority the page renders and every API call fails. + * + * The tab is saved `trusted: true`, and that is REQUIRED rather than a + * convenience: an untrusted webview is sandboxed without `allow-same-origin`, + * which breaks this dashboard twice over. The dsh client-runtime reads + * `localStorage` while loading its plugins and dies there ("the document is + * sandboxed and lacks the 'allow-same-origin' flag"), and an opaque-origin + * frame sends `Origin: null`, so dsh's own trust check 403s every `/api` call + * no matter which authority `--trusted-host` names. Passing `location.host` + * only means anything once the frame actually carries that origin. + * + * The trade this makes is real and worth stating: a trusted proxied frame is + * same-origin with Codeman and can therefore reach Codeman's own API. It is + * defensible only because of what this specific dashboard already is - an + * agent harness Codeman just started itself, on loopback, which can run code + * as the user regardless. It is not a precedent for trusting third-party + * dashboards generally, which is why it is set here rather than defaulted. */ async runDeepSeekWeb() { document.getElementById('runModeMenu')?.classList.remove('active'); const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const port = DEEPSEEK_WEB_PORT; - const url = `http://127.0.0.1:${port}`; + const sessionName = `dsh-web-${caseName}`; const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting the DeepSeek web UI in ${caseName}...`); try { + // A server started by an earlier click may still be serving. Reusing it is + // what makes this entry idempotent: without the check, every click started + // a second `dsh web`, and the second one lost the port race. + const managed = [...(this.webviews?.values() || [])].find((w) => w.managed === 'deepseek-web'); + if (managed && (await this._probeUrlReachable(managed.url))) { + this._appendSessionLaunchStatus(ownsLaunchTerminal, `Already serving on ${managed.url} - opening it as a tab.`); + await this.openWebview(managed.id); + return; + } + + // Never hardcode the port. 3080 is `dsh web`'s own default, which makes it + // precisely the port a DeepSeek user is most likely to be running already; + // binding it unconditionally killed the launch with EADDRINUSE while the + // tab still opened onto nothing. + const portRes = await fetch('/api/deepseek/web-port'); + const portData = await portRes.json(); + if (!portData.success) throw new Error(portData.error || 'No free port for the DeepSeek web UI'); + const port = portData.data.port; + const url = `http://127.0.0.1:${port}`; + const res = await fetch('/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'shell', - sessionName: `dsh-web-${caseName}`, - }), + body: JSON.stringify({ caseName, mode: 'shell', sessionName }), }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start the shell session'); @@ -540,28 +571,83 @@ Object.assign(CodemanApp.prototype, { body: JSON.stringify({ input: `${cmd}\r` }), }); - // Reuse a saved tab for the same URL rather than stacking duplicates every - // time the server is restarted. - let webview = [...(this.webviews?.values() || [])].find((w) => w.url === url); - if (!webview) { + // Verify the server actually answers BEFORE persisting a tab for it. The + // tab used to open unconditionally, so a server that died on startup left + // a saved dashboard pointing at nothing and no hint as to why. + this._appendSessionLaunchStatus(ownsLaunchTerminal, `Waiting for ${url} to answer...`); + if (!(await this._waitForUrlReachable(url))) { + throw new Error(`The DeepSeek web UI never answered on ${url} - see the "${sessionName}" tab for what it printed.`); + } + + // One managed record, repointed rather than duplicated: the port is chosen + // per launch, so creating a fresh row each time would stack a dashboard + // per restart, each pointing at a port nothing serves any more. + let webview = managed; + if (webview) { + const patchRes = await fetch(`/api/webviews/${webview.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, trusted: true }), + }); + const patchData = await patchRes.json(); + if (!patchData.success) throw new Error(patchData.error || 'Failed to update the web tab'); + webview = patchData.data.webview || patchData.data; + } else { const wvRes = await fetch('/api/webviews', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'DeepSeek Harness', url, icon: '🐳' }), + body: JSON.stringify({ + name: 'DeepSeek Harness', + url, + icon: '\u{1F433}', + managed: 'deepseek-web', + trusted: true, + }), }); const wvData = await wvRes.json(); if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab'); webview = wvData.data.webview || wvData.data; - await this.loadWebviews?.(); } + await this.loadWebviews?.(); - this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} — opening it as a tab.`); + this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} - opening it as a tab.`); if (webview?.id) await this.openWebview(webview.id); } catch (err) { this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, + /** + * Server-side reachability check for a URL the browser is about to embed. + * + * Goes through the existing webview probe rather than `fetch(url)` from the + * page: a loopback dashboard is cross-origin to Codeman and would fail CORS + * long before it could report whether anything is listening. + */ + async _probeUrlReachable(url) { + try { + const res = await fetch('/api/webviews/probe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + const data = await res.json(); + return !!(data.success && data.data?.reachable); + } catch { + return false; + } + }, + + /** Poll `_probeUrlReachable` until the server answers or the budget runs out. */ + async _waitForUrlReachable(url, timeoutMs = 25000, intervalMs = 1000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await this._probeUrlReachable(url)) return true; + await new Promise((r) => setTimeout(r, intervalMs)); + } + return false; + }, + /** * Install a DeepSeek Harness terminal profile from the run menu. * diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js index 811367b01..97c87a44e 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.js @@ -302,7 +302,10 @@ Object.assign(CodemanApp.prototype, { renderWebviewMenuItems() { const container = document.getElementById('runModeWebviews'); if (!container) return; - const list = [...(this.webviews?.values() || [])]; + // Managed records are maintained by their own menu entry (the DeepSeek web + // UI shortcut), so listing them here showed one dashboard twice: the + // shortcut that starts it, and the row it wrote on the previous click. + const list = [...(this.webviews?.values() || [])].filter((w) => !w.managed); if (list.length === 0) { container.innerHTML = '
No URLs yet
'; return; diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 7f906f36b..aa8275c37 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -12,6 +12,7 @@ import fs from 'node:fs/promises'; import { totalmem, freemem, loadavg, cpus } from 'node:os'; import { execSync, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; +import { createServer } from 'node:net'; import { dataPath } from '../../config/instance.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; @@ -68,6 +69,35 @@ import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js'; */ const DEEPSEEK_DEFAULT_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui'; const DEEPSEEK_DEFAULT_PROFILE = 'dsh-tui'; + +/** + * Where `GET /api/deepseek/web-port` starts looking, and how far it walks. + * + * 3080 is `dsh web`'s own default, so it is the friendly first choice — but it + * is emphatically NOT a fixed port. DeepSeek's web UI is a thing users run + * themselves, so the default is exactly the port most likely to be taken + * already, and hardcoding it made the shortcut die with EADDRINUSE against the + * user's own server while the tab still opened onto nothing. + */ +const DEEPSEEK_WEB_PORT_BASE = 3080; +const DEEPSEEK_WEB_PORT_SPAN = 40; + +/** + * True when nothing holds `port` on the loopback interface. + * + * Binding is the only honest test: a connect probe cannot distinguish "free" + * from "listening but not answering yet", and this runs moments before `dsh web` + * binds the same port. The check is inherently racy, which is why the caller + * still verifies the server answered before it persists a tab for it. + */ +async function isLoopbackPortFree(port: number): Promise { + return new Promise((resolve) => { + const probe = createServer(); + probe.once('error', () => resolve(false)); + probe.once('listening', () => probe.close(() => resolve(true))); + probe.listen(port, '127.0.0.1'); + }); +} /** A plugin install compiles and links a dependency tree; npm-scale, not curl-scale. */ const DEEPSEEK_INSTALL_TIMEOUT_MS = 300_000; @@ -511,6 +541,21 @@ export function registerSystemRoutes( }; }); + // First free loopback port for a `dsh web` the UI is about to start. + // + // The browser cannot answer this: it can neither bind a port nor tell a closed + // one from a filtered one. Keeping the choice server-side also keeps it next + // to the process that will inherit it. + app.get('/api/deepseek/web-port', async () => { + for (let port = DEEPSEEK_WEB_PORT_BASE; port < DEEPSEEK_WEB_PORT_BASE + DEEPSEEK_WEB_PORT_SPAN; port++) { + if (await isLoopbackPortFree(port)) return { success: true, data: { port } }; + } + return createErrorResponse( + ApiErrorCode.INTERNAL_ERROR, + `No free port for the DeepSeek web UI in ${DEEPSEEK_WEB_PORT_BASE}-${DEEPSEEK_WEB_PORT_BASE + DEEPSEEK_WEB_PORT_SPAN - 1}` + ); + }); + // Bootstrap an interactive profile so the mode becomes usable. // // This exists because DeepSeek ships NO terminal front door: `dsh` on its own diff --git a/src/web/routes/webview-routes.ts b/src/web/routes/webview-routes.ts index 6f545607a..f8aed284f 100644 --- a/src/web/routes/webview-routes.ts +++ b/src/web/routes/webview-routes.ts @@ -132,6 +132,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort // dashboard on an HTTPS Codeman, which is the common case. embedMode: input.embedMode ?? 'proxy', trusted: input.trusted ?? false, + managed: input.managed, owner, createdAt: Date.now(), }; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index dd6f2acdb..25f6e7d70 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1679,6 +1679,12 @@ const WebviewBaseSchema = z.object({ * and call the API that spawns agents. */ trusted: z.boolean().optional(), + /** + * Marks a record Codeman maintains itself. Declared here because a plain + * `z.object` STRIPS undeclared keys, so an undeclared marker would be dropped + * on the way in and the dedup it drives would never fire. + */ + managed: z.enum(['deepseek-web']).optional(), }); /** POST /api/webviews */ From c30dfaf0e7114078b954ea101b8883c7d7f4601b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 25 Aug 2026 03:08:15 +0200 Subject: [PATCH 5/6] fix(deepseek): run the web UI server in the background, not in a shell tab Clicking "DeepSeek web UI..." opened two tabs: the web tab asked for, and a shell tab running the server next to it. The shell was deliberate - the server lived in an ordinary session so it was visible, scrollable, killable and died with its tab, and nothing new had to supervise a long-lived HTTP server. That reasoning was sound and the result was still wrong in use: opening a dashboard should open one tab, and after the first launch the terminal is pure noise. The server moves to a background child process owned by a new `src/deepseek-web-server.ts`, behind `POST /api/deepseek/web`. What the session gave away for free is now explicit, which is most of the module: - Exactly one server. A second click reuses the running one instead of racing it for a port; the session flow could not do this at all, because two clicks were simply two sessions. - Restarted when the requested authority changes. `--trusted-host` fences dsh's own /api against the browser authority, and a Codeman reachable at both loopback and a tailnet name has two. Reusing a server fenced for the other origin renders a page whose every call 403s, which reads as a broken dashboard rather than a misconfigured one, so a mismatch restarts instead. - Killed on shutdown. The child is detached so its whole plugin tree can be signalled at once, which also means it would outlive Codeman and hold its port against the next start - the exact EADDRINUSE this feature already got wrong once. - Boot output captured and returned. With no shell tab there is nowhere else for a stack trace to land, so a failed spawn reports its own tail. The endpoint is fenced at the same bar as the profile installer and for the same reason: booting a dsh profile executes the plugin code in it, so this is a privileged action even though it reads as "open a page". `authority` comes from the client (`location.host`) because only the browser knows which origin is in play, and it is regex-confined at the schema boundary - defence in depth behind the argv-array spawn, admitting host:port in the shapes a browser authority can take and nothing readable as a second argument. `GET /api/deepseek/web-port` is gone; port selection moved into the supervisor, which is the thing that knows whether a server is already running. The two client-side probe helpers went with it, since the server now owns the wait. Verified over the tailnet authority end to end: no session is created (session count unchanged, one tab), the server runs on 3081 beside the user's own dsh web on 3080, status reports the tailnet authority, and the proxied dashboard renders with zero 4xx. Full gate green (6148 passed, +6). --- docs/deepseek-integration-plan.md | 41 +++-- src/deepseek-web-server.ts | 252 ++++++++++++++++++++++++++++++ src/web/public/session-ui.js | 106 +++---------- src/web/routes/system-routes.ts | 77 +++++---- src/web/schemas.ts | 20 +++ src/web/server.ts | 7 + test/deepseek-web-server.test.ts | 73 +++++++++ 7 files changed, 440 insertions(+), 136 deletions(-) create mode 100644 src/deepseek-web-server.ts create mode 100644 test/deepseek-web-server.test.ts diff --git a/docs/deepseek-integration-plan.md b/docs/deepseek-integration-plan.md index aa4a7ad21..21a2e864f 100644 --- a/docs/deepseek-integration-plan.md +++ b/docs/deepseek-integration-plan.md @@ -49,14 +49,30 @@ in the six external CLIs before it. The browser UI is the only interactive surface DeepSeek ships itself, so it gets a **shortcut, not a run mode**: `Run ▸ DeepSeek web UI…` starts `dsh web --no-open --host 127.0.0.1 --port --trusted-host ` -in an ordinary shell session and opens the URL as an ordinary web tab. +as a background process and opens the URL as an ordinary web tab. + +The server was a **shell session** first, on the reasoning that Codeman already +supervises those (visible, scrollable, killable, dies with its tab) so nothing +new had to own a long-lived HTTP server. That version worked and was still +wrong in use: clicking "open the DeepSeek web UI" put a terminal tab on screen +next to the web tab actually asked for, every single time, and after the first +launch the terminal was pure noise. Opening a dashboard should open one tab. + +So `POST /api/deepseek/web` owns it instead (`src/deepseek-web-server.ts`), and +what the session gave away for free is now explicit: exactly one server, reused +rather than raced on a second click; restarted when the requested authority +changes; killed on Codeman shutdown (a detached child would otherwise hold its +port against the next start — the very EADDRINUSE this feature already got +wrong once); and boot output captured, since with no shell tab there is nowhere +else for a stack trace to land. It is fenced at the same bar as the profile +installer: booting a dsh profile executes the plugin code in it, so it requires +the privileged grant in multi-user mode. -Built entirely from parts that already exist: the server is a shell session -(visible, scrollable, killable, dies with its tab) and the UI is a web tab. -Nothing new supervises a long-lived HTTP server, because Codeman already does. `--trusted-host` is load-bearing — dsh fences its `/api` behind a browser-trust check on the request authority, and a Codeman web tab reaches it through -Codeman's own origin via the webview proxy, not directly. +Codeman's own origin via the webview proxy, not directly. The authority comes +from the CLIENT (`location.host`) because only the browser knows which of a +multi-homed Codeman's origins is actually in play. Three things about this shortcut are load-bearing and each came from it failing in exactly that way against a real install: @@ -88,10 +104,11 @@ listing both showed the same dashboard twice. Being managed is also what lets a relaunch repoint the existing row instead of stacking one dead dashboard per restart, since the port is now chosen per launch. -⚠️ The authority baked into `--trusted-host` is the one the launch was clicked -from. Codeman reachable at several authorities (loopback *and* a tailnet name) -therefore needs the server restarted from whichever one is in use; the reuse -path checks that the server is reachable, not that it trusts the current origin. +The authority baked into `--trusted-host` is the one the launch was clicked +from, and reuse is conditional on it: a running server fenced for a *different* +origin is stopped and restarted rather than reused, because reusing it renders a +page whose every API call 403s — which reads as a broken dashboard rather than a +misconfigured one. ## 4. Touch points (the checklist) @@ -144,9 +161,9 @@ behaviour covered by 31 new unit tests; and an isolated instance used to exercis - A Docker case with `mode: 'deepseek'` (needs a `--no-cache` agent-image rebuild — see the `--no-cache` rule in CLAUDE.md). - A remote-SSH deepseek case. -- The web-UI shortcut against a tunnel authority. Loopback is verified end to end - through the webview proxy (dashboard renders, its `/api` calls succeed); the - cross-authority case above is a known limitation rather than an open question. +- The web-UI shortcut against a tunnel authority. Loopback and a tailnet name are + both verified end to end through the webview proxy (dashboard renders, its + `/api` calls succeed, no shell session created). ## 6. Follow-ups diff --git a/src/deepseek-web-server.ts b/src/deepseek-web-server.ts new file mode 100644 index 000000000..b7ebb05b4 --- /dev/null +++ b/src/deepseek-web-server.ts @@ -0,0 +1,252 @@ +/** + * @fileoverview Supervises the one background `dsh web` process behind the Run + * menu's "DeepSeek web UI..." entry. + * + * The shortcut originally started the server inside an ordinary SHELL SESSION, + * on the reasoning that Codeman already knows how to supervise those: it was + * visible, scrollable, killable, and died with its tab, and nothing new had to + * own a long-lived HTTP server. That reasoning was sound and the result was + * still wrong in use — clicking "open the DeepSeek web UI" spawned a terminal + * tab the user never asked for, next to the web tab they did, and the terminal + * was noise every time after the first. + * + * So the server moves here instead: one child process, no session, no tab. + * What that buys back has to be paid for explicitly, which is what this module + * is: + * + * - **Exactly one.** A second click reuses the running server rather than + * racing it for a port. The old shell-session flow could not do this at all, + * because two clicks were simply two sessions. + * - **Restarted when the authority changes.** `--trusted-host` fences dsh's + * `/api` against the browser authority, and a Codeman reachable at both + * loopback and a tailnet name has two. Whoever asks last wins, because the + * asker is by definition the origin about to load the page. + * - **Killed on shutdown.** A detached child that outlived Codeman would hold + * its port against the next start, which is exactly the EADDRINUSE this + * feature already got wrong once. + * - **Failures reported, not swallowed.** The shell tab used to be where the + * stack trace landed. With no tab, the spawn's own output is captured and + * handed back to the caller instead. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; +import { join } from 'node:path'; +import { getErrorMessage } from './types.js'; + +/** + * Where the port search starts, and how far it walks. + * + * 3080 is `dsh web`'s own default, so it is the friendly first choice — and + * emphatically not a fixed port. DeepSeek's web UI is a thing users run + * themselves, which makes the default precisely the port most likely to be + * taken already; hardcoding it made this feature die with EADDRINUSE against + * the user's own server. + */ +const PORT_BASE = 3080; +const PORT_SPAN = 40; + +/** How long a freshly spawned server gets to answer before we call it failed. */ +const READY_TIMEOUT_MS = 30_000; +const READY_POLL_MS = 400; +/** Grace between SIGTERM and SIGKILL when stopping the tree. */ +const KILL_GRACE_MS = 3_000; +/** Bound on captured child output, so a chatty boot cannot grow without limit. */ +const OUTPUT_CAP = 16_384; + +export interface DeepSeekWebStatus { + running: boolean; + port: number | null; + url: string | null; + /** Browser authority this server was started to trust (`--trusted-host`). */ + authority: string | null; +} + +interface RunningServer { + child: ChildProcess; + port: number; + authority: string; + output: () => string; +} + +let current: RunningServer | null = null; + +/** + * True when nothing holds `port` on loopback. + * + * Binding is the only honest test: a connect probe cannot tell "free" from + * "listening but not answering yet", and this runs moments before `dsh web` + * binds the same port. It is inherently racy, which is why the caller still + * waits for the server to actually answer before reporting success. + */ +async function isLoopbackPortFree(port: number): Promise { + return new Promise((resolve) => { + const probe = createServer(); + probe.once('error', () => resolve(false)); + probe.once('listening', () => probe.close(() => resolve(true))); + probe.listen(port, '127.0.0.1'); + }); +} + +async function findFreePort(): Promise { + for (let port = PORT_BASE; port < PORT_BASE + PORT_SPAN; port++) { + if (await isLoopbackPortFree(port)) return port; + } + return null; +} + +/** Does the server answer HTTP yet? Any status counts: dsh may 4xx a bare GET. */ +async function answersHttp(port: number): Promise { + try { + await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2_000) }); + return true; + } catch { + return false; + } +} + +/** + * Signal the whole process group. + * + * `dsh web` boots a plugin tree and fans out, so signalling only the direct + * child leaves survivors holding the port. Same negative-pid escalation as + * `runGit()` in git-clone.ts and the profile installer. + */ +function killTree(child: ChildProcess, signal: NodeJS.Signals): void { + try { + if (child.pid) process.kill(-child.pid, signal); + } catch { + try { + child.kill(signal); + } catch { + /* already gone */ + } + } +} + +export function getDeepSeekWebStatus(): DeepSeekWebStatus { + if (!current) return { running: false, port: null, url: null, authority: null }; + return { + running: true, + port: current.port, + url: `http://127.0.0.1:${current.port}`, + authority: current.authority, + }; +} + +/** Stop the background server, if one is running. Safe to call when none is. */ +export async function stopDeepSeekWeb(): Promise { + const running = current; + current = null; + if (!running) return; + + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(hard); + resolve(); + }; + running.child.once('exit', finish); + killTree(running.child, 'SIGTERM'); + const hard = setTimeout(() => { + killTree(running.child, 'SIGKILL'); + finish(); + }, KILL_GRACE_MS); + }); +} + +/** + * Start (or reuse) the background `dsh web` for `authority`. + * + * @param dshDir directory holding the resolved `dsh` binary. + * @param authority browser authority to pass as `--trusted-host`. + */ +export async function startDeepSeekWeb( + dshDir: string, + authority: string +): Promise<{ ok: true; port: number; url: string; reused: boolean } | { ok: false; error: string }> { + // Reuse only when the running server is BOTH healthy and fenced for the + // authority now asking. A server trusting the other origin renders a page + // whose every API call 403s, which looks like a broken dashboard rather than + // a misconfigured one. + if (current) { + if (current.authority === authority && (await answersHttp(current.port))) { + return { ok: true, port: current.port, url: `http://127.0.0.1:${current.port}`, reused: true }; + } + await stopDeepSeekWeb(); + } + + const port = await findFreePort(); + if (port === null) { + return { ok: false, error: `No free port for the DeepSeek web UI in ${PORT_BASE}-${PORT_BASE + PORT_SPAN - 1}` }; + } + + let child: ChildProcess; + try { + child = spawn( + join(dshDir, 'dsh'), + ['web', '--no-open', '--host', '127.0.0.1', '--port', String(port), '--trusted-host', authority], + { + stdio: ['ignore', 'pipe', 'pipe'], + // Own process group so the whole plugin tree can be signalled at once. + detached: true, + env: process.env, + } + ); + } catch (err) { + return { ok: false, error: `Failed to start dsh web: ${getErrorMessage(err)}` }; + } + + // The pipes must be drained whether or not anyone reads them: a full pipe + // blocks the child. Storage is capped; draining is not. + let output = ''; + const capture = (chunk: Buffer) => { + if (output.length < OUTPUT_CAP) output += chunk.toString('utf-8'); + }; + child.stdout?.on('data', capture); + child.stderr?.on('data', capture); + + let exited = false; + child.once('exit', () => { + exited = true; + // Only clear if this is still the current server: a restart may have + // already replaced it, and clearing then would drop the live one. + if (current?.child === child) current = null; + }); + child.once('error', () => { + exited = true; + if (current?.child === child) current = null; + }); + + const running: RunningServer = { child, port, authority, output: () => output }; + current = running; + + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (exited) { + current = null; + const tail = output.trim().slice(-800); + return { ok: false, error: tail ? `dsh web exited during startup: ${tail}` : 'dsh web exited during startup' }; + } + if (await answersHttp(port)) { + return { ok: true, port, url: `http://127.0.0.1:${port}`, reused: false }; + } + await new Promise((r) => setTimeout(r, READY_POLL_MS)); + } + + await stopDeepSeekWeb(); + const tail = output.trim().slice(-800); + return { + ok: false, + error: tail + ? `dsh web did not answer on port ${port} within ${READY_TIMEOUT_MS / 1000}s: ${tail}` + : `dsh web did not answer on port ${port} within ${READY_TIMEOUT_MS / 1000}s`, + }; +} + +/** Test seam: forget any tracked child without signalling it. */ +export function resetDeepSeekWebForTest(): void { + current = null; +} diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 3e28a68a4..c718ffe7d 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -497,11 +497,11 @@ Object.assign(CodemanApp.prototype, { /** * Start the DeepSeek Harness browser UI and open it as a Codeman web tab. * - * Deliberately built from parts that already exist rather than a new process - * manager: the server runs in an ordinary SHELL session, so it is visible, - * scrollable, killable and dies with its tab like anything else, and the UI - * itself is an ordinary web tab. Nothing here needs to know how to supervise a - * long-lived HTTP server, because Codeman already does. + * The server is a background child process owned by + * `deepseek-web-server.ts`, NOT a shell session. It was a shell session first, + * on the reasoning that Codeman already supervises those, and that version + * worked - it just put a terminal tab on screen beside the web tab the user + * actually asked for, on every click. Opening a dashboard should open one tab. * * `--trusted-host` is the load-bearing flag: dsh fences its `/api` behind a * browser-trust check on the request authority, and a Codeman web tab reaches @@ -526,63 +526,32 @@ Object.assign(CodemanApp.prototype, { */ async runDeepSeekWeb() { document.getElementById('runModeMenu')?.classList.remove('active'); - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const sessionName = `dsh-web-${caseName}`; - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting the DeepSeek web UI in ${caseName}...`); + const ownsLaunchTerminal = this._beginSessionLaunchStatus('Starting the DeepSeek web UI...'); try { - // A server started by an earlier click may still be serving. Reusing it is - // what makes this entry idempotent: without the check, every click started - // a second `dsh web`, and the second one lost the port race. - const managed = [...(this.webviews?.values() || [])].find((w) => w.managed === 'deepseek-web'); - if (managed && (await this._probeUrlReachable(managed.url))) { - this._appendSessionLaunchStatus(ownsLaunchTerminal, `Already serving on ${managed.url} - opening it as a tab.`); - await this.openWebview(managed.id); - return; - } - - // Never hardcode the port. 3080 is `dsh web`'s own default, which makes it - // precisely the port a DeepSeek user is most likely to be running already; - // binding it unconditionally killed the launch with EADDRINUSE while the - // tab still opened onto nothing. - const portRes = await fetch('/api/deepseek/web-port'); - const portData = await portRes.json(); - if (!portData.success) throw new Error(portData.error || 'No free port for the DeepSeek web UI'); - const port = portData.data.port; - const url = `http://127.0.0.1:${port}`; - - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caseName, mode: 'shell', sessionName }), - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start the shell session'); - const sessionId = data.data.sessionId; - await this._ensureCreatedSessionVisible(sessionId, data.data.session); - - // The shell needs a moment to reach its prompt before it will accept a - // command; the same settle the other shell-driven flows use. - await new Promise((r) => setTimeout(r, 1200)); - const cmd = `dsh web --no-open --host 127.0.0.1 --port ${port} --trusted-host ${location.host}`; - await fetch(`/api/sessions/${sessionId}/input`, { + // One request, and the server owns everything behind it: picking a free + // port, spawning, waiting for the port to answer, and reusing an already + // running server instead of racing it. This used to start the server in a + // shell SESSION, which worked but put a terminal tab on screen next to the + // web tab actually asked for, every single time. + // + // `authority` is what dsh fences its own `/api` behind (`--trusted-host`), + // so it must be the origin this page is loaded from rather than anything + // the server could guess: a Codeman reachable at both loopback and a + // tailnet name has two, and only the browser knows which one is in play. + const startRes = await fetch('/api/deepseek/web', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: `${cmd}\r` }), + body: JSON.stringify({ authority: location.host }), }); - - // Verify the server actually answers BEFORE persisting a tab for it. The - // tab used to open unconditionally, so a server that died on startup left - // a saved dashboard pointing at nothing and no hint as to why. - this._appendSessionLaunchStatus(ownsLaunchTerminal, `Waiting for ${url} to answer...`); - if (!(await this._waitForUrlReachable(url))) { - throw new Error(`The DeepSeek web UI never answered on ${url} - see the "${sessionName}" tab for what it printed.`); - } + const startData = await startRes.json(); + if (!startData.success) throw new Error(startData.error || 'Failed to start the DeepSeek web UI'); + const url = startData.data.url; // One managed record, repointed rather than duplicated: the port is chosen // per launch, so creating a fresh row each time would stack a dashboard // per restart, each pointing at a port nothing serves any more. - let webview = managed; + let webview = [...(this.webviews?.values() || [])].find((w) => w.managed === 'deepseek-web'); if (webview) { const patchRes = await fetch(`/api/webviews/${webview.id}`, { method: 'PATCH', @@ -617,37 +586,6 @@ Object.assign(CodemanApp.prototype, { } }, - /** - * Server-side reachability check for a URL the browser is about to embed. - * - * Goes through the existing webview probe rather than `fetch(url)` from the - * page: a loopback dashboard is cross-origin to Codeman and would fail CORS - * long before it could report whether anything is listening. - */ - async _probeUrlReachable(url) { - try { - const res = await fetch('/api/webviews/probe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url }), - }); - const data = await res.json(); - return !!(data.success && data.data?.reachable); - } catch { - return false; - } - }, - - /** Poll `_probeUrlReachable` until the server answers or the budget runs out. */ - async _waitForUrlReachable(url, timeoutMs = 25000, intervalMs = 1000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await this._probeUrlReachable(url)) return true; - await new Promise((r) => setTimeout(r, intervalMs)); - } - return false; - }, - /** * Install a DeepSeek Harness terminal profile from the run menu. * diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index aa8275c37..5e0d0cbda 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -12,7 +12,6 @@ import fs from 'node:fs/promises'; import { totalmem, freemem, loadavg, cpus } from 'node:os'; import { execSync, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import { createServer } from 'node:net'; import { dataPath } from '../../config/instance.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; @@ -28,6 +27,7 @@ import { SubagentParentMapSchema, RevokeSessionSchema, DeepSeekInstallProfileSchema, + DeepSeekWebStartSchema, } from '../schemas.js'; import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; @@ -70,34 +70,6 @@ import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js'; const DEEPSEEK_DEFAULT_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui'; const DEEPSEEK_DEFAULT_PROFILE = 'dsh-tui'; -/** - * Where `GET /api/deepseek/web-port` starts looking, and how far it walks. - * - * 3080 is `dsh web`'s own default, so it is the friendly first choice — but it - * is emphatically NOT a fixed port. DeepSeek's web UI is a thing users run - * themselves, so the default is exactly the port most likely to be taken - * already, and hardcoding it made the shortcut die with EADDRINUSE against the - * user's own server while the tab still opened onto nothing. - */ -const DEEPSEEK_WEB_PORT_BASE = 3080; -const DEEPSEEK_WEB_PORT_SPAN = 40; - -/** - * True when nothing holds `port` on the loopback interface. - * - * Binding is the only honest test: a connect probe cannot distinguish "free" - * from "listening but not answering yet", and this runs moments before `dsh web` - * binds the same port. The check is inherently racy, which is why the caller - * still verifies the server answered before it persists a tab for it. - */ -async function isLoopbackPortFree(port: number): Promise { - return new Promise((resolve) => { - const probe = createServer(); - probe.once('error', () => resolve(false)); - probe.once('listening', () => probe.close(() => resolve(true))); - probe.listen(port, '127.0.0.1'); - }); -} /** A plugin install compiles and links a dependency tree; npm-scale, not curl-scale. */ const DEEPSEEK_INSTALL_TIMEOUT_MS = 300_000; @@ -541,19 +513,44 @@ export function registerSystemRoutes( }; }); - // First free loopback port for a `dsh web` the UI is about to start. + // Start (or reuse) the background `dsh web` behind the Run menu shortcut. // - // The browser cannot answer this: it can neither bind a port nor tell a closed - // one from a filtered one. Keeping the choice server-side also keeps it next - // to the process that will inherit it. - app.get('/api/deepseek/web-port', async () => { - for (let port = DEEPSEEK_WEB_PORT_BASE; port < DEEPSEEK_WEB_PORT_BASE + DEEPSEEK_WEB_PORT_SPAN; port++) { - if (await isLoopbackPortFree(port)) return { success: true, data: { port } }; + // This runs as a plain child process rather than a shell SESSION on purpose. + // The session version worked, but it put a terminal tab on screen next to the + // web tab the user actually asked for, every single time. Nothing about a + // long-lived HTTP server needs to be a tab. + // + // Fenced at the same bar as the profile installer, and for the same reason: + // booting a dsh profile executes the plugin code in it, so this is a + // privileged action even though it reads as "open a page". + app.post('/api/deepseek/web', async (req) => { + const { authority } = parseBody(DeepSeekWebStartSchema, req.body); + if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) { + return createErrorResponse( + ApiErrorCode.FORBIDDEN, + 'Starting the DeepSeek web UI requires the can-bypass-permissions grant' + ); } - return createErrorResponse( - ApiErrorCode.INTERNAL_ERROR, - `No free port for the DeepSeek web UI in ${DEEPSEEK_WEB_PORT_BASE}-${DEEPSEEK_WEB_PORT_BASE + DEEPSEEK_WEB_PORT_SPAN - 1}` - ); + + const { resolveDeepSeekDir, getDeepSeekNotFoundMessage } = await import('../../utils/deepseek-cli-resolver.js'); + const dir = resolveDeepSeekDir(); + if (!dir) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getDeepSeekNotFoundMessage()); + + const { startDeepSeekWeb } = await import('../../deepseek-web-server.js'); + const result = await startDeepSeekWeb(dir, authority); + if (!result.ok) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error); + return { success: true, data: { port: result.port, url: result.url, reused: result.reused } }; + }); + + app.get('/api/deepseek/web', async () => { + const { getDeepSeekWebStatus } = await import('../../deepseek-web-server.js'); + return { success: true, data: getDeepSeekWebStatus() }; + }); + + app.delete('/api/deepseek/web', async () => { + const { stopDeepSeekWeb } = await import('../../deepseek-web-server.js'); + await stopDeepSeekWeb(); + return { success: true, data: { stopped: true } }; }); // Bootstrap an interactive profile so the mode becomes usable. diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 25f6e7d70..826a5e4d9 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -407,6 +407,26 @@ export const DeepSeekInstallProfileSchema = z }) .strict(); +/** + * POST /api/deepseek/web: start the background `dsh web` for one browser authority. + * + * `authority` becomes `--trusted-host`, which is what dsh fences its own `/api` + * behind, so it must be the origin the browser will actually load the tab from + * (`location.host`). It reaches a spawn as one element of an argv ARRAY, never a + * shell string, so this regex is defence in depth rather than the only guard: it + * admits host:port in the shapes a browser authority can take (dotted names, + * IPv4, bracketed IPv6) and nothing that could be read as a second argument. + */ +export const DeepSeekWebStartSchema = z + .object({ + authority: z + .string() + .min(1) + .max(255) + .regex(/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)(?::\d{1,5})?$/), + }) + .strict(); + /** * The session that spawned the one being created — pure UI decoration, drawn as a * lineage line between the two tabs. Accepted here and, equivalently, as the diff --git a/src/web/server.ts b/src/web/server.ts index f24fee632..6227f60b5 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -95,6 +95,7 @@ import { sessionWaits } from './session-wait-registry.js'; import { intentStore } from '../intent-store.js'; import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { approvalInbox } from './approval-inbox.js'; +import { stopDeepSeekWeb } from '../deepseek-web-server.js'; import { wireRespawnListeners, setupTimedRespawn, @@ -3135,6 +3136,12 @@ export class WebServer extends EventEmitter { this._dockerBridgeServer = null; } + // The background `dsh web` is detached so its whole plugin tree can be + // signalled at once, which also means it would OUTLIVE Codeman and hold its + // port against the next start — the exact EADDRINUSE this feature already + // got wrong once. + void stopDeepSeekWeb(); + // Dispose all managed timers (intervals + resettable timeouts) this.cleanup.dispose(); diff --git a/test/deepseek-web-server.test.ts b/test/deepseek-web-server.test.ts new file mode 100644 index 000000000..ec2703b43 --- /dev/null +++ b/test/deepseek-web-server.test.ts @@ -0,0 +1,73 @@ +/** + * The background `dsh web` supervisor and the authority boundary in front of it. + * + * Two things here are worth pinning and neither is obvious from reading the + * module: + * + * 1. `authority` becomes an argv element of a spawned process (`--trusted-host + * `). The spawn is an argv ARRAY so a shell can never see it, but + * the schema is the layer that stops a value which is not a browser + * authority at all from reaching the command line, and a regex is easy to + * widen by accident. + * 2. The supervisor tracks at most ONE server. The status accessor is what every + * caller reads to decide whether to start another, so "no server" must report + * as absent rather than as a half-populated record. + */ +import { describe, expect, it, beforeEach } from 'vitest'; +import { DeepSeekWebStartSchema } from '../src/web/schemas.js'; +import { getDeepSeekWebStatus, resetDeepSeekWebForTest, stopDeepSeekWeb } from '../src/deepseek-web-server.js'; + +describe('DeepSeekWebStartSchema: the authority reaching --trusted-host', () => { + it('accepts the authority shapes a browser can actually report', () => { + for (const authority of [ + 'localhost:3000', + '127.0.0.1:5013', + 'tnode.tailf80371.ts.net:8444', + 'codeman.example.com', + '[::1]:3000', + 'host-with-dashes.local:80', + ]) { + expect(DeepSeekWebStartSchema.safeParse({ authority }).success, authority).toBe(true); + } + }); + + it('rejects values that are not an authority at all', () => { + for (const authority of [ + '', + 'http://localhost:3000', // a URL, not an authority + 'localhost:3000 --trusted-host evil', // an embedded second argument + '-oProxyCommand=evil', // leading dash, readable as a flag + 'localhost:3000/../path', + 'local host:3000', + 'user:pass@localhost:3000', + 'a'.repeat(256), + ]) { + expect(DeepSeekWebStartSchema.safeParse({ authority }).success, authority).toBe(false); + } + }); + + it('is strict, so an unexpected field cannot ride along', () => { + expect(DeepSeekWebStartSchema.safeParse({ authority: 'localhost:3000', port: 1 }).success).toBe(false); + }); + + it('requires the field rather than defaulting it', () => { + // A guessed default would silently fence dsh's /api against the wrong + // origin, which presents as a dashboard whose every call 403s. + expect(DeepSeekWebStartSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('DeepSeek web supervisor: status', () => { + beforeEach(() => { + resetDeepSeekWebForTest(); + }); + + it('reports absent as fully null, not a half-filled record', () => { + expect(getDeepSeekWebStatus()).toEqual({ running: false, port: null, url: null, authority: null }); + }); + + it('stopping when nothing runs resolves rather than throwing', async () => { + await expect(stopDeepSeekWeb()).resolves.toBeUndefined(); + expect(getDeepSeekWebStatus().running).toBe(false); + }); +}); From a628737d1fbec35d8a2207063e98d5002c13e941 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 25 Aug 2026 19:01:29 +0200 Subject: [PATCH 6/6] fix(deepseek): review-driven hardening across the harness integration Fifteen review findings on the dsh mode, the serious ones first: - Multi-user: DEEPSEEK_BASE_URL joins the owner-clamped env keys. _configureDeepSeek() forwards the SERVER's own DEEPSEEK_API_KEY into every dsh pane and applyEnvOverrides() lands after it, so a non-granted owner who could redirect the base URL would have the operator's key sent as a bearer credential to a host of their choosing. - Wait registry: until=stop/blocked is refused on docker and remote-SSH dsh sessions (new deepSeekBridgeUnreachable fact in sessionHookOptions). The HERDR triple is set via LOCAL tmux setenv, which crosses neither docker exec nor ssh, so such a session can never post a hook event and the wait burned its whole timeout on every turn. - Approvals: a dsh item is an ALERT, not an answerable card. The answer route refuses (the '1'/Esc keystrokes are Claude-dialog-shaped and the option parser cannot read a third-party TUI's frames, so an answer was a blind keystroke into a foreign composer), and the push notification carries no Approve/Deny actions for dsh sessions. - Status shim (v3): --seq is forwarded and the server drops stale retried reports inside a 60s window (the TUI retries with backoff, so a retried 'working' could land after 'blocked' and resolve an approval whose dialog was still on screen); 4xx responses exit 0 instead of retrying, so one misconfigured session cannot feed the auth rate-limit bucket until the hook endpoint 429s for the whole instance. - Web-UI server: concurrent starts are serialized through a lock (two racing POSTs used to pick the same port and orphan the winner), and the readiness poll / timeout paths only clear or stop the singleton while it is still theirs. First click actually opens the tab now (refreshWebviews, not the nonexistent loadWebviews). DELETE /api/deepseek/web requires the privileged grant in multi-user mode. - Cron: deepseek jobs run the same two-part launch gate as the HTTP create paths (impl moved into the resolver so all three share it) and no longer stamp a Claude default model on the session. - Parity sweeps: quick-start's docker branch rejects deepSeekConfig like the remote branch; the Ralph auto-enable list gained deepseek; HookEventType gained agent_working; the phone overview run menu filters managed webview records like the desktop menu. - install.sh: the dsh identity probe closes stdin (under curl|bash a child that reads stdin eats the rest of the script), bounds the exec with timeout where available, and is memoized to one scan per install. - Welcome screen: .welcome-btn-deepseek styled in the #4d6bfe brand identity (it rendered as an unstyled UA-grey button); stale markup comment about the web shortcut rewritten; clamp docs updated. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 4 +-- install.sh | 53 +++++++++++++++++------------ src/cron/cron-service.ts | 14 +++++++- src/deepseek-status-shim.ts | 18 ++++++++-- src/deepseek-web-server.ts | 43 +++++++++++++++++++---- src/types/api.ts | 6 +++- src/utils/deepseek-cli-resolver.ts | 36 ++++++++++++++++++++ src/web/public/index.html | 3 +- src/web/public/mobile-overview.js | 5 +++ src/web/public/session-ui.js | 6 +++- src/web/public/styles.css | 18 ++++++++++ src/web/routes/approval-routes.ts | 13 +++++++ src/web/routes/hook-event-routes.ts | 40 ++++++++++++++++++++-- src/web/routes/session-routes.ts | 48 +++++++++++--------------- src/web/routes/system-routes.ts | 11 +++++- src/web/session-wait-registry.ts | 32 ++++++++++++++--- test/deepseek-mode.test.ts | 33 +++++++++++++++++- 18 files changed, 311 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32aa8449b..30326a077 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,7 +205,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All seven **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE` and `DSH_HOME` for a non-granted owner (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`; user guide `docs/deepseek-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek) +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All seven **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureDeepSeek()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`; user guide `docs/deepseek-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek) **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6fbb5dbe8..cda54d9f3 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -20,13 +20,13 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All six modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. **DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. ⚠️ **The agent is a PROFILE, not the binary.** `dsh` is a launcher over `$DSH_HOME/profiles/` (an ordered stack of plugin-bundle patch layers), and DeepSeek ships only `web` (browser UI), `headless` (one-shot) and `base` (no app). The interactive terminal front door is ALWAYS third-party. So availability is TWO questions, not one, and `isDeepSeekRunnable()` (binary AND a pane-capable profile) is what the Run button gates on while `isDeepSeekAvailable()` (binary only) gates the "add a profile" affordance and the web-UI shortcut. Reporting only the binary would let Run spawn a pane that dies on arrival, which is this mode's single most confusing failure. `buildDeepSeekCommand()` emits `dsh --profile [--resume [id]]`; an absent profile resolves through `resolveDefaultDeepSeekProfile()`, which prefers a recognized TUI, then an UNRECOGNIZED profile (third-party by construction — a classifier that has not heard of a bundle must not hide it), and refuses `web`/`headless`, which cannot drive a pane. -⚠️ **The permission switch is an ENV VAR, not a flag.** The harness has no `--dangerously-skip-permissions` equivalent; its sandbox/approval rows read `DSH_PERMISSION_MODE` with three presets (`read-only` / `workspace-write` / `danger-full-access`; measured from `dsh --dump-default-config`). It is exported via `tmux setenv` in `_configureDeepSeek()`, never on the command line, and `test/deepseek-mode.test.ts` pins that nothing permission-shaped ever reaches the spawn line. This is the ONE place a Codeman env export is the right mechanism rather than the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks in-session `/effort`), the harness reads it with `??` as a boot-time DEFAULT, so it stays soft. Absent = `workspace-write`, which still asks, so the multi-user clamp is the only-if-sent branch (codex/antigravity/grok shape, not pi's materialize) — and it clamps down to `workspace-write`, NOT `read-only`, because the clamp removes privilege without breaking a session's ability to edit its own workspace. ⚠️ **Clamping the config is only HALF the gate here, and this is the only CLI where that is true.** Every sibling's bypass is a command-line flag, reachable only through the per-CLI config `clampExternalCliBypassForOwner()` already owns. DeepSeek's is an env var, `DSH_*` is an allowlisted `envOverrides` prefix (it must be — that is also how the harness's ordinary knobs are set), and `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so `envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'}` sent on the SAME request as a clamped config lands last and wins. `clampEnvOverridesForOwner()` (session-routes.ts, exported as `_clampEnvOverridesForOwner` for tests) DROPS `DSH_PERMISSION_MODE` and `DSH_HOME` for a non-granted owner rather than rewriting them, since dropping falls through to what `_configureDeepSeek()` exports, which is already the clamped value. `DSH_HOME` is on that list because it aims the launcher at a profile tree and a profile's plugin code executes at BOOT, before any approval row can apply — the wider of the two holes. No-op in single-user mode and for a granted owner, like every other clamp. +⚠️ **The permission switch is an ENV VAR, not a flag.** The harness has no `--dangerously-skip-permissions` equivalent; its sandbox/approval rows read `DSH_PERMISSION_MODE` with three presets (`read-only` / `workspace-write` / `danger-full-access`; measured from `dsh --dump-default-config`). It is exported via `tmux setenv` in `_configureDeepSeek()`, never on the command line, and `test/deepseek-mode.test.ts` pins that nothing permission-shaped ever reaches the spawn line. This is the ONE place a Codeman env export is the right mechanism rather than the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks in-session `/effort`), the harness reads it with `??` as a boot-time DEFAULT, so it stays soft. Absent = `workspace-write`, which still asks, so the multi-user clamp is the only-if-sent branch (codex/antigravity/grok shape, not pi's materialize) — and it clamps down to `workspace-write`, NOT `read-only`, because the clamp removes privilege without breaking a session's ability to edit its own workspace. ⚠️ **Clamping the config is only HALF the gate here, and this is the only CLI where that is true.** Every sibling's bypass is a command-line flag, reachable only through the per-CLI config `clampExternalCliBypassForOwner()` already owns. DeepSeek's is an env var, `DSH_*` is an allowlisted `envOverrides` prefix (it must be — that is also how the harness's ordinary knobs are set), and `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so `envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'}` sent on the SAME request as a clamped config lands last and wins. `clampEnvOverridesForOwner()` (session-routes.ts, exported as `_clampEnvOverridesForOwner` for tests) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureDeepSeek()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) rather than rewriting them, since dropping falls through to what `_configureDeepSeek()` exports, which is already the clamped value. `DSH_HOME` is on that list because it aims the launcher at a profile tree and a profile's plugin code executes at BOOT, before any approval row can apply — the wider of the two holes. No-op in single-user mode and for a granted owner, like every other clamp. ⚠️ **It is the only non-claude mode that passes `hooksAvailableForMode()`, and it earned that.** The community terminal front door reports its own lifecycle to a supervising process through a generic env-var-gated contract inherited from Herdr: with `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` set it shells out ` pane report-agent --state idle|working|blocked …` on every state change and treats exit 0 as delivered. `deepseek-status-shim.ts` GENERATES a small script into the data dir (like `self-update-runner.sh`, so npm installs and git clones behave alike) and points `HERDR_BIN_PATH` at it; it forwards to `POST /api/hook-event` as `idle→stop`, `blocked→permission_prompt`, `working→agent_working`. So a dsh session gets real respawn triggers, real `wait` stop/blocked signals and real Approvals Inbox items instead of output-stabilization guesswork. This is an interface implementation, not an impersonation — no real `herdr` binary is ever executed. A TUI that does not implement the contract simply never calls the shim and falls back to stabilization, so the feature is inert rather than harmful there. ⚠️ **For deepseek alone, `hooksAvailableForMode()` is a per-SESSION question**, which is why it takes a `HookCapabilityOptions` second argument and every call site passes `sessionHookOptions(session)`: `deepSeekConfig.statusReporting: false` skips the `HERDR_*` export, and that triple is the only reason a dsh session posts anything, so answering from the mode alone would accept `until=stop` on a session where nothing can ever send one — the infinite-wait-dressed-as-a-timeout the predicate exists to prevent. The option defaults permissive (`!== false`), so a call site that forgets it degrades to the old behaviour instead of 400ing a working session. ⚠️ Profile conformance is the LIMIT of what is knowable at request time: `resolveDefaultDeepSeekProfile()` deliberately treats an unrecognized profile as launchable, so a non-conforming TUI still answers true and still times out on an explicit `stop` — which is why the DEFAULT signal set keeps `idle`/`exit`. ⚠️ **The predicate is not a stand-in for "is this a claude session"**, though it read like one while `claude` was the only true answer: Read My Mind (`POST /api/sessions/:id/readmymind`) and intent capture (`captureIntentPrompt`) read Claude's own transcript and were silently widened to deepseek by this change, so both compare `mode === 'claude'` directly and a static check in `test/deepseek-mode.test.ts` keeps them there. diff --git a/install.sh b/install.sh index 34ae863dc..d2e58ed27 100755 --- a/install.sh +++ b/install.sh @@ -605,41 +605,52 @@ check_grok() { # `dsh` is the hardest name of the lot: Debian ships an unrelated `dsh` # (dancer's shell). The server-side resolver settles it by demanding the # harness's own help banner; detection here only feeds the "you have no AI CLI" -# hint, so the same cheap banner grep is enough and costs one exec. -check_dsh() { - local candidate +# hint, so the same banner grep is enough — but unlike every sibling probe it +# EXECUTES the candidate, so it must be bounded. /dev/null; then runner=(timeout 5); fi + "${runner[@]}" "$1" --help /dev/null | grep -qi "DeepSeek Harness" +} + +# Resolved ONCE and memoized: the probe executes a possibly-foreign binary, and +# the check/get/reminder call sites together used to re-run the whole scan many +# times per install. +DSH_RESOLVE_DONE="" +DSH_RESOLVED_PATH="" +resolve_dsh() { + [[ -n "$DSH_RESOLVE_DONE" ]] && return 0 + DSH_RESOLVE_DONE=1 + local candidate path if command -v dsh &>/dev/null; then candidate="$(command -v dsh)" - if "$candidate" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then + if dsh_banner_probe "$candidate"; then + DSH_RESOLVED_PATH="$candidate" return 0 fi fi for path in "${DSH_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]] && "$path" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then + if [[ -x "$path" ]] && dsh_banner_probe "$path"; then + DSH_RESOLVED_PATH="$path" return 0 fi done + return 0 +} - return 1 +check_dsh() { + resolve_dsh + [[ -n "$DSH_RESOLVED_PATH" ]] } get_dsh_path() { - local candidate - if command -v dsh &>/dev/null; then - candidate="$(command -v dsh)" - if "$candidate" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then - echo "$candidate" - return - fi - fi - - for path in "${DSH_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]] && "$path" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then - echo "$path" - return - fi - done + resolve_dsh + echo "$DSH_RESOLVED_PATH" } get_grok_path() { diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index 2c8a0cc4c..9e0faad10 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -395,11 +395,23 @@ export class CronService { let session: Session; try { const mode = job.agentType; + // Same two-part availability gate the HTTP create paths run: `dsh` is a + // profile LAUNCHER, so without this a job on a box with only the stock + // web/headless profiles spawns a bare `dsh` that boots a profile unable + // to drive a pane, and the prompt is typed into a logging server or a + // dead pane instead of failing the run with the actionable message. + if (mode === 'deepseek') { + const { resolveDeepSeekLaunchError } = await import('../utils/deepseek-cli-resolver.js'); + const launchError = resolveDeepSeekLaunchError(); + if (launchError) return this.failRun(job, run, launchError); + } const globalNice = await this.deps.getGlobalNiceConfig(); const modelConfig = await this.deps.getModelConfig(); const claudeModeConfig = await this.deps.getClaudeModeConfig(); const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner); - const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined; + // DeepSeek's model is a composition entry in the profile's config tree, + // not a session flag — mirror the HTTP routes' exclusion. + const model = mode !== 'shell' && mode !== 'deepseek' ? modelConfig?.defaultModel || undefined : undefined; // Section 6.3: materialize the safe default for a non-granted owner (see // clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own // spawn default is what would otherwise apply). diff --git a/src/deepseek-status-shim.ts b/src/deepseek-status-shim.ts index fa3b385e7..0a657e38a 100644 --- a/src/deepseek-status-shim.ts +++ b/src/deepseek-status-shim.ts @@ -54,7 +54,7 @@ import { dataPath } from './config/instance.js'; * by an older Codeman and rewrite only when needed (rather than rewriting on * every session create, or — worse — leaving a stale one in place forever). */ -const SHIM_VERSION = 2; +const SHIM_VERSION = 3; const SHIM_MARKER = `codeman-dsh-status-shim v${SHIM_VERSION}`; /** @@ -143,12 +143,19 @@ try { // Missing file: the loopback bypass still applies when no tunnel is running. } +// The contract's ordering token: the TUI retries failed deliveries with +// backoff, so a stale report can land AFTER a newer one. Forwarded so the +// server can drop out-of-order arrivals instead of, say, resolving an +// approval with a retried 'working' while the harness sits blocked. +const seq = Number(flag('--seq')) + const body = JSON.stringify({ event, sessionId, data: { source: 'dsh-status-shim', agent: flag('--agent') || 'dsh', + ...(Number.isFinite(seq) ? { seq } : {}), ...(flag('--message') ? { message: flag('--message') } : {}), }, }) @@ -179,7 +186,14 @@ const req = transport.request( }, (res) => { res.resume() - process.exit(res.statusCode && res.statusCode >= 200 && res.statusCode < 300 ? 0 : 1) + const status = res.statusCode ?? 0 + // 2xx: delivered. 4xx: PERMANENT — a 401 (missing/rotated secret) or 429 + // can never be fixed by retrying, and each retry feeds the auth-failure + // rate-limit bucket, so a single misconfigured dsh session could 429 the + // hook endpoint for the whole instance (killing every claude session's + // real hooks). Exit 0 so the TUI does not retry; only transport errors + // and 5xx stay retryable. + process.exit(status >= 200 && status < 500 ? 0 : 1) } ) req.on('timeout', () => { diff --git a/src/deepseek-web-server.ts b/src/deepseek-web-server.ts index b7ebb05b4..78dbf961b 100644 --- a/src/deepseek-web-server.ts +++ b/src/deepseek-web-server.ts @@ -157,16 +157,37 @@ export async function stopDeepSeekWeb(): Promise { }); } +type StartResult = { ok: true; port: number; url: string; reused: boolean } | { ok: false; error: string }; + +/** + * Serializes concurrent starts. Two POSTs racing (two devices, or a double + * click while the first boots) used to both see `current === null`, pick the + * SAME free port, and spawn twice: the loser died on EADDRINUSE while its exit + * handler nulled the singleton out from under the winner, leaving a live + * `dsh web` nothing tracked or killed — the exact orphan this module exists to + * prevent. The second caller now simply waits and reuses the first's server. + */ +let startLock: Promise = Promise.resolve(); + /** * Start (or reuse) the background `dsh web` for `authority`. * * @param dshDir directory holding the resolved `dsh` binary. * @param authority browser authority to pass as `--trusted-host`. */ -export async function startDeepSeekWeb( - dshDir: string, - authority: string -): Promise<{ ok: true; port: number; url: string; reused: boolean } | { ok: false; error: string }> { +export function startDeepSeekWeb(dshDir: string, authority: string): Promise { + const run = startLock.then( + () => startDeepSeekWebLocked(dshDir, authority), + () => startDeepSeekWebLocked(dshDir, authority) + ); + startLock = run.then( + () => undefined, + () => undefined + ); + return run; +} + +async function startDeepSeekWebLocked(dshDir: string, authority: string): Promise { // Reuse only when the running server is BOTH healthy and fenced for the // authority now asking. A server trusting the other origin renders a page // whose every API call 403s, which looks like a broken dashboard rather than @@ -226,7 +247,10 @@ export async function startDeepSeekWeb( const deadline = Date.now() + READY_TIMEOUT_MS; while (Date.now() < deadline) { if (exited) { - current = null; + // Guarded like the exit/error handlers: a concurrent stop (DELETE route, + // shutdown) may already have cleared or replaced the singleton, and an + // unconditional null here would drop a server this call does not own. + if (current === running) current = null; const tail = output.trim().slice(-800); return { ok: false, error: tail ? `dsh web exited during startup: ${tail}` : 'dsh web exited during startup' }; } @@ -236,7 +260,14 @@ export async function startDeepSeekWeb( await new Promise((r) => setTimeout(r, READY_POLL_MS)); } - await stopDeepSeekWeb(); + // Timeout: kill OUR child. Only route through stopDeepSeekWeb() while the + // singleton is still ours — signalling `current` unconditionally here could + // SIGTERM a healthy server a concurrent actor now owns. + if (current === running) { + await stopDeepSeekWeb(); + } else { + killTree(running.child, 'SIGKILL'); + } const tail = output.trim().slice(-800); return { ok: false, diff --git a/src/types/api.ts b/src/types/api.ts index 2bc574938..b63268af2 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -109,7 +109,11 @@ export type HookEventType = | 'elicitation_response' | 'stop' | 'teammate_idle' - | 'task_completed'; + | 'task_completed' + // No Claude Code hook behind this one: it is the DeepSeek status bridge's + // "a turn STARTED" report (see deepseek-status-shim.ts). Keep in step with + // HookEventSchema in web/schemas.ts. + | 'agent_working'; // ========== API Response Types ========== diff --git a/src/utils/deepseek-cli-resolver.ts b/src/utils/deepseek-cli-resolver.ts index 9868d01f8..c6e189502 100644 --- a/src/utils/deepseek-cli-resolver.ts +++ b/src/utils/deepseek-cli-resolver.ts @@ -363,3 +363,39 @@ export function getDeepSeekCliVersion(): string | null { export function profileExists(name: string): boolean { return existsSync(join(resolveDshHome(), 'profiles', name, 'package.json')); } + +/** + * Why a DeepSeek session cannot start, or null when it can. + * + * Availability for this mode is TWO questions, not one, because `dsh` is a + * profile launcher rather than an agent: the binary must resolve (and prove it + * is the harness and not Debian's dancer's shell), AND a profile that can occupy + * a pane must exist. Every create path — both HTTP routes AND cron fires — must + * ask this before constructing a Session, or the pane boots the box's default + * profile, which may be a logging web server or a one-shot that exits on + * arrival, and the prompt is typed into it. + */ +export function resolveDeepSeekLaunchError(requestedProfile?: string): string | null { + if (!isDeepSeekAvailable()) return getDeepSeekNotFoundMessage(); + + const profiles = listDeepSeekProfiles(); + if (requestedProfile) { + const match = profiles.find((p) => p.name === requestedProfile); + if (!match) { + return `DeepSeek Harness profile "${requestedProfile}" does not exist. Create it with: dsh plugin --profile ${requestedProfile} add `; + } + if (match.kind === 'web' || match.kind === 'headless') { + return `DeepSeek Harness profile "${requestedProfile}" is a ${match.kind} profile and cannot run in a terminal session. Pick an interactive profile, or open the web profile as a Codeman web tab.`; + } + return null; + } + + if (!resolveDefaultDeepSeekProfile(profiles)) { + return ( + 'No interactive DeepSeek Harness profile is installed. DeepSeek ships only the web and headless ' + + 'profiles, so the terminal agent comes from a plugin — install one with: ' + + 'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui' + ); + } + return null; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index c8e7df5c0..e9e3bc57a 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -657,7 +657,8 @@

Resume Conversation