mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
**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 <id>`, `--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 <default|auto_edit|yolo|plan>` 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 <id>` 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 <id>` / `--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 <id>`, `--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 <default|auto_edit|yolo|plan>` 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 <id>` 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 <id>` / `--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.
|
**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/<name>` (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 <name> [--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 agent is a PROFILE, not the binary.** `dsh` is a launcher over `$DSH_HOME/profiles/<name>` (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 <name> [--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 `<bin> pane report-agent <paneId> --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.
|
⚠️ **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 `<bin> pane report-agent <paneId> --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.
|
||||||
|
|
||||||
|
|||||||
+32
-21
@@ -605,41 +605,52 @@ check_grok() {
|
|||||||
# `dsh` is the hardest name of the lot: Debian ships an unrelated `dsh`
|
# `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
|
# (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"
|
# 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.
|
# hint, so the same banner grep is enough — but unlike every sibling probe it
|
||||||
check_dsh() {
|
# EXECUTES the candidate, so it must be bounded. </dev/null is load-bearing
|
||||||
local candidate
|
# twice over: a foreign binary that blocks on stdin would hang the install, and
|
||||||
|
# under `curl | bash` a child that reads stdin EATS THE REST OF THIS SCRIPT.
|
||||||
|
# The timeout (where coreutils ships one; stock macOS has none) bounds a binary
|
||||||
|
# that ignores EOF, mirroring the server resolver's own EXEC_TIMEOUT_MS.
|
||||||
|
dsh_banner_probe() {
|
||||||
|
local runner=()
|
||||||
|
if command -v timeout &>/dev/null; then runner=(timeout 5); fi
|
||||||
|
"${runner[@]}" "$1" --help </dev/null 2>/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
|
if command -v dsh &>/dev/null; then
|
||||||
candidate="$(command -v dsh)"
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for path in "${DSH_SEARCH_PATHS[@]}"; do
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
return 1
|
check_dsh() {
|
||||||
|
resolve_dsh
|
||||||
|
[[ -n "$DSH_RESOLVED_PATH" ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
get_dsh_path() {
|
get_dsh_path() {
|
||||||
local candidate
|
resolve_dsh
|
||||||
if command -v dsh &>/dev/null; then
|
echo "$DSH_RESOLVED_PATH"
|
||||||
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() {
|
get_grok_path() {
|
||||||
|
|||||||
@@ -395,11 +395,23 @@ export class CronService {
|
|||||||
let session: Session;
|
let session: Session;
|
||||||
try {
|
try {
|
||||||
const mode = job.agentType;
|
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 globalNice = await this.deps.getGlobalNiceConfig();
|
||||||
const modelConfig = await this.deps.getModelConfig();
|
const modelConfig = await this.deps.getModelConfig();
|
||||||
const claudeModeConfig = await this.deps.getClaudeModeConfig();
|
const claudeModeConfig = await this.deps.getClaudeModeConfig();
|
||||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
|
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
|
// 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
|
// clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own
|
||||||
// spawn default is what would otherwise apply).
|
// spawn default is what would otherwise apply).
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import { dataPath } from './config/instance.js';
|
|||||||
* by an older Codeman and rewrite only when needed (rather than rewriting on
|
* 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).
|
* 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}`;
|
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.
|
// 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({
|
const body = JSON.stringify({
|
||||||
event,
|
event,
|
||||||
sessionId,
|
sessionId,
|
||||||
data: {
|
data: {
|
||||||
source: 'dsh-status-shim',
|
source: 'dsh-status-shim',
|
||||||
agent: flag('--agent') || 'dsh',
|
agent: flag('--agent') || 'dsh',
|
||||||
|
...(Number.isFinite(seq) ? { seq } : {}),
|
||||||
...(flag('--message') ? { message: flag('--message') } : {}),
|
...(flag('--message') ? { message: flag('--message') } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -179,7 +186,14 @@ const req = transport.request(
|
|||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
res.resume()
|
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', () => {
|
req.on('timeout', () => {
|
||||||
|
|||||||
@@ -157,16 +157,37 @@ export async function stopDeepSeekWeb(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<unknown> = Promise.resolve();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start (or reuse) the background `dsh web` for `authority`.
|
* Start (or reuse) the background `dsh web` for `authority`.
|
||||||
*
|
*
|
||||||
* @param dshDir directory holding the resolved `dsh` binary.
|
* @param dshDir directory holding the resolved `dsh` binary.
|
||||||
* @param authority browser authority to pass as `--trusted-host`.
|
* @param authority browser authority to pass as `--trusted-host`.
|
||||||
*/
|
*/
|
||||||
export async function startDeepSeekWeb(
|
export function startDeepSeekWeb(dshDir: string, authority: string): Promise<StartResult> {
|
||||||
dshDir: string,
|
const run = startLock.then(
|
||||||
authority: string
|
() => startDeepSeekWebLocked(dshDir, authority),
|
||||||
): Promise<{ ok: true; port: number; url: string; reused: boolean } | { ok: false; error: string }> {
|
() => startDeepSeekWebLocked(dshDir, authority)
|
||||||
|
);
|
||||||
|
startLock = run.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDeepSeekWebLocked(dshDir: string, authority: string): Promise<StartResult> {
|
||||||
// Reuse only when the running server is BOTH healthy and fenced for the
|
// 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
|
// 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
|
// 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;
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (exited) {
|
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);
|
const tail = output.trim().slice(-800);
|
||||||
return { ok: false, error: tail ? `dsh web exited during startup: ${tail}` : 'dsh web exited during startup' };
|
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 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);
|
const tail = output.trim().slice(-800);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
+5
-1
@@ -109,7 +109,11 @@ export type HookEventType =
|
|||||||
| 'elicitation_response'
|
| 'elicitation_response'
|
||||||
| 'stop'
|
| 'stop'
|
||||||
| 'teammate_idle'
|
| '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 ==========
|
// ========== API Response Types ==========
|
||||||
|
|
||||||
|
|||||||
@@ -363,3 +363,39 @@ export function getDeepSeekCliVersion(): string | null {
|
|||||||
export function profileExists(name: string): boolean {
|
export function profileExists(name: string): boolean {
|
||||||
return existsSync(join(resolveDshHome(), 'profiles', name, 'package.json'));
|
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 <package>`;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -657,7 +657,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<!-- The DeepSeek Harness browser UI is the vendor's OWN interactive
|
<!-- The DeepSeek Harness browser UI is the vendor's OWN interactive
|
||||||
surface (the terminal one is third-party), so it gets a shortcut:
|
surface (the terminal one is third-party), so it gets a shortcut:
|
||||||
this starts `dsh web` in a shell session and opens it as a tab.
|
POST /api/deepseek/web starts a background `dsh web` fenced to
|
||||||
|
this origin, and the URL opens as a managed web tab.
|
||||||
Shown only when dsh is installed. -->
|
Shown only when dsh is installed. -->
|
||||||
<button class="run-mode-option run-mode-option--web" id="runModeDeepSeekWeb" style="display: none;" onclick="app.runDeepSeekWeb()">
|
<button class="run-mode-option run-mode-option--web" id="runModeDeepSeekWeb" style="display: none;" onclick="app.runDeepSeekWeb()">
|
||||||
<span class="run-mode-dot deepseek"></span>DeepSeek web UI…
|
<span class="run-mode-dot deepseek"></span>DeepSeek web UI…
|
||||||
|
|||||||
@@ -581,6 +581,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
menu.appendChild(header);
|
menu.appendChild(header);
|
||||||
|
|
||||||
for (const webview of this.webviews ? this.webviews.values() : []) {
|
for (const webview of this.webviews ? this.webviews.values() : []) {
|
||||||
|
// Managed records are Codeman-owned shortcut state (the DeepSeek web UI
|
||||||
|
// writes one), not saved dashboards: same filter as the desktop run menu,
|
||||||
|
// or the phone picker lists a stale 127.0.0.1:<port> row that dies on the
|
||||||
|
// next server restart with no affordance here to restart it.
|
||||||
|
if (webview.managed) continue;
|
||||||
const option = document.createElement('button');
|
const option = document.createElement('button');
|
||||||
option.type = 'button';
|
option.type = 'button';
|
||||||
option.className = 'mobile-overview-run-option';
|
option.className = 'mobile-overview-run-option';
|
||||||
|
|||||||
@@ -577,7 +577,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab');
|
if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab');
|
||||||
webview = wvData.data.webview || wvData.data;
|
webview = wvData.data.webview || wvData.data;
|
||||||
}
|
}
|
||||||
await this.loadWebviews?.();
|
// refreshWebviews, not a hopeful optional-chain: openWebview() reads
|
||||||
|
// this.webviews and silently no-ops on an id it has not loaded, so
|
||||||
|
// skipping the refresh made the FIRST click create the record but open
|
||||||
|
// nothing (the SSE round-trip had not landed yet).
|
||||||
|
await this.refreshWebviews?.();
|
||||||
|
|
||||||
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);
|
if (webview?.id) await this.openWebview(webview.id);
|
||||||
|
|||||||
@@ -3892,6 +3892,24 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DeepSeek Harness: the #4d6bfe blue identity, matching
|
||||||
|
.btn-toolbar.btn-run.mode-deepseek and .run-mode-dot.deepseek so the welcome
|
||||||
|
action reads as the same backend. */
|
||||||
|
.welcome-btn-deepseek {
|
||||||
|
background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%);
|
||||||
|
border-color: rgba(124, 147, 255, 0.4);
|
||||||
|
color: #eef2ff;
|
||||||
|
box-shadow: 0 2px 8px rgba(77, 107, 254, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-btn-deepseek:hover {
|
||||||
|
background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%);
|
||||||
|
box-shadow: 0 4px 20px rgba(77, 107, 254, 0.3), 0 0 40px rgba(39, 64, 196, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(150, 170, 255, 0.5);
|
||||||
|
color: #f8faff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.welcome-btn-gemini {
|
.welcome-btn-gemini {
|
||||||
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
|
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
|
||||||
border-color: rgba(96, 165, 250, 0.4);
|
border-color: rgba(96, 165, 250, 0.4);
|
||||||
|
|||||||
@@ -102,6 +102,19 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
|
|||||||
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
|
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
|
||||||
}
|
}
|
||||||
|
// A dsh approval is an ALERT, not an answerable card: the dialog belongs to
|
||||||
|
// a third-party TUI whose keystroke contract Codeman has not measured, the
|
||||||
|
// Claude-shaped option parser never reads options off its frames, and
|
||||||
|
// verifyStillAnswerable() can therefore never be conclusive for it — so the
|
||||||
|
// '1'/Esc below would be a blind keystroke into a foreign composer. The item
|
||||||
|
// still raises the red alert and clears on the harness's own working/stop
|
||||||
|
// reports; answering happens in the terminal.
|
||||||
|
if (session.mode === 'deepseek') {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Re-capture the pane before aiming keystrokes at it: if the dialog was
|
// Re-capture the pane before aiming keystrokes at it: if the dialog was
|
||||||
// answered in the terminal moments ago, the digit would land in whatever
|
// answered in the terminal moments ago, the digit would land in whatever
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ const APPROVAL_KIND_BY_EVENT: Record<string, ApprovalKind> = {
|
|||||||
*/
|
*/
|
||||||
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response', 'agent_working']);
|
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response', 'agent_working']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last DeepSeek status-bridge sequence number seen per session.
|
||||||
|
*
|
||||||
|
* The Herdr contract the dsh TUI speaks stamps every report with `--seq <n>`
|
||||||
|
* and RETRIES failed deliveries with backoff — so a stale report can land
|
||||||
|
* AFTER a newer one, and applying it in arrival order resolves an approval
|
||||||
|
* with a retried `working` while the harness sits blocked, or releases a wait
|
||||||
|
* with a retried `idle` mid-turn. A report whose seq is not newer than the
|
||||||
|
* last accepted one is dropped, but only inside a short window: the TUI's
|
||||||
|
* retry backoff is seconds, so a LOWER seq arriving after the window is a
|
||||||
|
* restarted TUI's fresh numbering (same pane, new generation), not a stale
|
||||||
|
* retry, and must be accepted. Insertion-order eviction bounds the map.
|
||||||
|
*/
|
||||||
|
const dshSeqBySession = new Map<string, { seq: number; at: number }>();
|
||||||
|
const DSH_SEQ_STALE_WINDOW_MS = 60_000;
|
||||||
|
const DSH_SEQ_MAX_SESSIONS = 500;
|
||||||
|
|
||||||
export function registerHookEventRoutes(
|
export function registerHookEventRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||||
@@ -46,6 +63,22 @@ export function registerHookEventRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepSeek status-bridge ordering: drop a stale retried report (see
|
||||||
|
// dshSeqBySession above). Success rather than an error, so the shim exits 0
|
||||||
|
// and the TUI does not keep retrying a report that will stay stale.
|
||||||
|
if (data && data.source === 'dsh-status-shim' && typeof data.seq === 'number') {
|
||||||
|
const last = dshSeqBySession.get(sessionId);
|
||||||
|
const now = Date.now();
|
||||||
|
if (last && data.seq <= last.seq && now - last.at < DSH_SEQ_STALE_WINDOW_MS) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!dshSeqBySession.has(sessionId) && dshSeqBySession.size >= DSH_SEQ_MAX_SESSIONS) {
|
||||||
|
const oldest = dshSeqBySession.keys().next().value;
|
||||||
|
if (oldest !== undefined) dshSeqBySession.delete(oldest);
|
||||||
|
}
|
||||||
|
dshSeqBySession.set(sessionId, { seq: data.seq, at: now });
|
||||||
|
}
|
||||||
|
|
||||||
// Wake anything blocked on `GET /api/sessions/:id/wait`. Hooks are the only
|
// Wake anything blocked on `GET /api/sessions/:id/wait`. Hooks are the only
|
||||||
// DEFINITIVE signals Codeman gets (`idle` is inferred from output stabilization
|
// DEFINITIVE signals Codeman gets (`idle` is inferred from output stabilization
|
||||||
// and can flap mid-turn), so these two are what an orchestrating agent should
|
// and can flap mid-turn), so these two are what an orchestrating agent should
|
||||||
@@ -163,12 +196,15 @@ export function registerHookEventRoutes(
|
|||||||
// the browser loaded with. Debounced, so a hook burst costs one broadcast.
|
// the browser loaded with. Debounced, so a hook burst costs one broadcast.
|
||||||
ctx.broadcastSessionStateDebounced(sessionId);
|
ctx.broadcastSessionStateDebounced(sessionId);
|
||||||
|
|
||||||
// Send push notifications for hook events
|
// Send push notifications for hook events. Push Approve/Deny actions ride
|
||||||
|
// on approvalId, and the answer route refuses keystrokes for dsh dialogs
|
||||||
|
// (third-party TUI, unmeasured contract) — so a dsh push stays a plain
|
||||||
|
// notification instead of offering buttons whose answer would be refused.
|
||||||
ctx.sendPushNotifications(`hook:${event}`, {
|
ctx.sendPushNotifications(`hook:${event}`, {
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionName,
|
sessionName,
|
||||||
...safeData,
|
...safeData,
|
||||||
...(approvalId && { approvalId }),
|
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track in run summary
|
// Track in run summary
|
||||||
|
|||||||
@@ -394,11 +394,12 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Env-var keys a non-granted owner must not be able to set, because each one
|
* 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.
|
* hands back privilege the config clamp above just removed — or, for the last,
|
||||||
|
* redirects a credential the server injects.
|
||||||
*
|
*
|
||||||
* Both are DeepSeek's, and both are reachable because `DSH_*` is an allowlisted
|
* All are DeepSeek's, and all are reachable because `DSH_*` and `DEEPSEEK_*` are
|
||||||
* `envOverrides` prefix (schemas.ts) — which it has to be, since that is also how
|
* allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
|
||||||
* a user configures the harness's non-privileged knobs.
|
* 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
|
* - `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
|
* bypass is a command-line FLAG, reachable only through the per-CLI config the
|
||||||
@@ -407,8 +408,14 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
|||||||
* - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
|
* - `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
|
* 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.
|
* workspace can put a profile in it, so this is the wider of the two.
|
||||||
|
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureDeepSeek()`
|
||||||
|
* forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
|
||||||
|
* `applyEnvOverrides()` runs — so a non-granted owner who could set the base
|
||||||
|
* URL would have the operator's API key sent as a bearer credential to a host
|
||||||
|
* of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
|
||||||
|
* your OWN key removes privilege rather than granting it.)
|
||||||
*/
|
*/
|
||||||
const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME'] as const;
|
const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Env-var half of the multi-user bypass clamp.
|
* Env-var half of the multi-user bypass clamp.
|
||||||
@@ -456,30 +463,11 @@ export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
|
|||||||
* and exits, so both would present as "the tab immediately died".
|
* and exits, so both would present as "the tab immediately died".
|
||||||
*/
|
*/
|
||||||
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
|
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
|
||||||
const { isDeepSeekAvailable, getDeepSeekNotFoundMessage, listDeepSeekProfiles, resolveDefaultDeepSeekProfile } =
|
// Thin async wrapper: the implementation moved into the resolver module so
|
||||||
await import('../../utils/deepseek-cli-resolver.js');
|
// CRON fires can ask the same question before constructing a Session; the
|
||||||
if (!isDeepSeekAvailable()) return getDeepSeekNotFoundMessage();
|
// dynamic import keeps this file's startup free of the probe machinery.
|
||||||
|
const { resolveDeepSeekLaunchError: impl } = await import('../../utils/deepseek-cli-resolver.js');
|
||||||
const profiles = listDeepSeekProfiles();
|
return impl(requestedProfile);
|
||||||
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 <package>`;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -1299,6 +1287,7 @@ export function registerSessionRoutes(
|
|||||||
session.mode !== 'antigravity' &&
|
session.mode !== 'antigravity' &&
|
||||||
session.mode !== 'pi' &&
|
session.mode !== 'pi' &&
|
||||||
session.mode !== 'grok' &&
|
session.mode !== 'grok' &&
|
||||||
|
session.mode !== 'deepseek' &&
|
||||||
ctx.store.getConfig().ralphEnabled &&
|
ctx.store.getConfig().ralphEnabled &&
|
||||||
!session.ralphTracker.autoEnableDisabled
|
!session.ralphTracker.autoEnableDisabled
|
||||||
) {
|
) {
|
||||||
@@ -2921,6 +2910,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig ||
|
antigravityConfig ||
|
||||||
piConfig ||
|
piConfig ||
|
||||||
grokConfig ||
|
grokConfig ||
|
||||||
|
deepSeekConfig ||
|
||||||
openCodeConfig
|
openCodeConfig
|
||||||
) {
|
) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(
|
||||||
|
|||||||
@@ -547,7 +547,16 @@ export function registerSystemRoutes(
|
|||||||
return { success: true, data: getDeepSeekWebStatus() };
|
return { success: true, data: getDeepSeekWebStatus() };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/deepseek/web', async () => {
|
app.delete('/api/deepseek/web', async (req) => {
|
||||||
|
// Same bar as POST: the server is a single shared instance, so in
|
||||||
|
// multi-user mode stopping it out from under other users' tabs is a
|
||||||
|
// privileged act (single-user and granted owners are unaffected).
|
||||||
|
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.FORBIDDEN,
|
||||||
|
'Stopping the DeepSeek web UI requires the can-bypass-permissions grant'
|
||||||
|
);
|
||||||
|
}
|
||||||
const { stopDeepSeekWeb } = await import('../../deepseek-web-server.js');
|
const { stopDeepSeekWeb } = await import('../../deepseek-web-server.js');
|
||||||
await stopDeepSeekWeb();
|
await stopDeepSeekWeb();
|
||||||
return { success: true, data: { stopped: true } };
|
return { success: true, data: { stopped: true } };
|
||||||
|
|||||||
@@ -179,6 +179,16 @@ export interface HookCapabilityOptions {
|
|||||||
* session emit hook events at all.
|
* session emit hook events at all.
|
||||||
*/
|
*/
|
||||||
deepSeekStatusReporting?: boolean;
|
deepSeekStatusReporting?: boolean;
|
||||||
|
/**
|
||||||
|
* True when the pane's harness runs somewhere the status bridge cannot reach:
|
||||||
|
* a docker case (`docker exec` does not carry the local tmux env into the
|
||||||
|
* container, and the loopback-bound API is unreachable from it) or a
|
||||||
|
* remote-SSH case (the `HERDR_*` triple is set on the LOCAL ssh process, not
|
||||||
|
* the remote shell). Such a session never posts a hook event however the
|
||||||
|
* statusReporting flag is set, so `until=stop` on it would burn its whole
|
||||||
|
* timeout on every turn.
|
||||||
|
*/
|
||||||
|
deepSeekBridgeUnreachable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -221,7 +231,9 @@ export function hooksAvailableForMode(mode: SessionMode, options: HookCapability
|
|||||||
// deliver `stop` and `blocked` — unless the user turned the bridge off, in
|
// 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
|
// which case nothing on the box will ever post one. Every other mode is
|
||||||
// output-stabilization guesswork and must keep failing the ask.
|
// output-stabilization guesswork and must keep failing the ask.
|
||||||
if (mode === 'deepseek') return options.deepSeekStatusReporting !== false;
|
if (mode === 'deepseek') {
|
||||||
|
return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,8 +246,15 @@ export function hooksAvailableForMode(mode: SessionMode, options: HookCapability
|
|||||||
* call sites, so a future per-session fact is added in one place instead of
|
* call sites, so a future per-session fact is added in one place instead of
|
||||||
* being forgotten at three of them.
|
* being forgotten at three of them.
|
||||||
*/
|
*/
|
||||||
export function sessionHookOptions(session: { deepSeekStatusReporting?: boolean }): HookCapabilityOptions {
|
export function sessionHookOptions(session: {
|
||||||
return { deepSeekStatusReporting: session.deepSeekStatusReporting };
|
deepSeekStatusReporting?: boolean;
|
||||||
|
docker?: unknown;
|
||||||
|
remote?: unknown;
|
||||||
|
}): HookCapabilityOptions {
|
||||||
|
return {
|
||||||
|
deepSeekStatusReporting: session.deepSeekStatusReporting,
|
||||||
|
deepSeekBridgeUnreachable: Boolean(session.docker || session.remote),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
|
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
|
||||||
@@ -291,8 +310,11 @@ export function resolveWaitSignals(
|
|||||||
// caller looking for a bug that is really a setting they chose.
|
// caller looking for a bug that is really a setting they chose.
|
||||||
error:
|
error:
|
||||||
options.mode === 'deepseek'
|
options.mode === 'deepseek'
|
||||||
? `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` +
|
? options.deepSeekBridgeUnreachable
|
||||||
`(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.`
|
? `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: it runs in a container or on ` +
|
||||||
|
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
|
||||||
|
: `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.`,
|
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { buildSpawnCommand } from '../src/tmux-manager.js';
|
|||||||
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
|
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
|
||||||
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
|
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
|
||||||
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
|
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
|
||||||
import { hooksAvailableForMode, resolveWaitSignals } from '../src/web/session-wait-registry.js';
|
import { hooksAvailableForMode, resolveWaitSignals, sessionHookOptions } from '../src/web/session-wait-registry.js';
|
||||||
import { _clampExternalCliBypassForOwner, _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
|
import { _clampExternalCliBypassForOwner, _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
|
||||||
import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js';
|
import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -228,6 +228,24 @@ describe('DeepSeek status bridge', () => {
|
|||||||
expect(resolveWaitSignals(undefined, on).until).toContain('stop');
|
expect(resolveWaitSignals(undefined, on).until).toContain('stop');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses stop/blocked on a docker or remote dsh session, where the bridge cannot reach the harness', () => {
|
||||||
|
// `docker exec` does not carry the local tmux env into the container and the
|
||||||
|
// remote shell never sees the local `HERDR_*` setenv, so such a session can
|
||||||
|
// never post a hook event however statusReporting is set — accepting
|
||||||
|
// `until=stop` there burns the caller's whole timeout on every turn.
|
||||||
|
expect(hooksAvailableForMode('deepseek', { deepSeekBridgeUnreachable: true })).toBe(false);
|
||||||
|
const unreachable = { mode: 'deepseek' as const, deepSeekBridgeUnreachable: true };
|
||||||
|
const rejected = resolveWaitSignals('stop', unreachable);
|
||||||
|
expect(rejected.until).toEqual([]);
|
||||||
|
expect(rejected.error).toContain('container or on a remote host');
|
||||||
|
// The default set degrades instead of erroring, exactly like the disarmed case.
|
||||||
|
expect(resolveWaitSignals(undefined, unreachable)).toEqual({ until: ['idle', 'exit'], error: null });
|
||||||
|
// sessionHookOptions() is what lifts the fact off a live session.
|
||||||
|
expect(sessionHookOptions({ docker: { containerName: 'c' } }).deepSeekBridgeUnreachable).toBe(true);
|
||||||
|
expect(sessionHookOptions({ remote: { hostId: 'h' } }).deepSeekBridgeUnreachable).toBe(true);
|
||||||
|
expect(sessionHookOptions({}).deepSeekBridgeUnreachable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the hook predicate out of the two gates that mean "is this claude"', () => {
|
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
|
// Read My Mind and intent capture read Claude's own transcript, so they mean
|
||||||
// mode === 'claude'. They used to ask hooksAvailableForMode(), which was the
|
// mode === 'claude'. They used to ask hooksAvailableForMode(), which was the
|
||||||
@@ -323,6 +341,19 @@ describe('DeepSeek multi-user clamp: the env-var half', () => {
|
|||||||
expect(out).toEqual({});
|
expect(out).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips DEEPSEEK_BASE_URL, which would aim the server's own forwarded API key at a foreign host", async () => {
|
||||||
|
// _configureDeepSeek() exports the SERVER's DEEPSEEK_API_KEY into every dsh
|
||||||
|
// pane, and applyEnvOverrides() lands after it — so a non-granted owner who
|
||||||
|
// could set the base URL would have the operator's key sent as a bearer
|
||||||
|
// credential to an endpoint of their choosing. Their OWN key stays settable:
|
||||||
|
// that removes privilege rather than granting it.
|
||||||
|
const out = await _clampEnvOverridesForOwner('nobody', {
|
||||||
|
DEEPSEEK_BASE_URL: 'https://attacker.example/v1',
|
||||||
|
DEEPSEEK_API_KEY: 'sk-their-own',
|
||||||
|
});
|
||||||
|
expect(out).toEqual({ DEEPSEEK_API_KEY: 'sk-their-own' });
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves unrelated overrides alone, and returns the same object when there is nothing to strip', async () => {
|
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 input = { DEEPSEEK_API_KEY: 'sk-test', CODEX_HOME: '/tmp/cx' };
|
||||||
const out = await _clampEnvOverridesForOwner('nobody', input);
|
const out = await _clampEnvOverridesForOwner('nobody', input);
|
||||||
|
|||||||
Reference in New Issue
Block a user