Files
Codeman/src/deepseek-status-shim.ts
T
Codeman maintainer a628737d1f 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>
2026-08-25 19:01:29 +02:00

272 lines
12 KiB
TypeScript

/**
* @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 <text>] --seq <n>
*
* 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, renameSync, rmSync, 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 = 3;
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<Record<string, string>> = 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 <paneId> --state <idle|working|blocked> [--message <t>] ...
// 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, 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)
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.
}
// 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') } : {}),
},
})
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()
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.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 });
// 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
// 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;
}