mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
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>
272 lines
12 KiB
TypeScript
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;
|
|
}
|