Files
Codeman/test/deepseek-mode.test.ts
T
DevvynandClaude Opus 5 4830e662f9 refactor(cli-registry): make CLI backends data instead of per-mode branching
Every run mode is now a `CliEntry` in `src/config/cli-registry/` — discovery
(search dirs, version + identity probes), the launch argv template, env
handling, the `capabilities` flags that replace per-CLI branching, and the
`overlays` that back the remote/docker pane commands. Code that used to ask
"which CLI is this?" reads the entry instead.

Behaviour is unchanged. `test/cli-registry-spawn-golden.test.ts` pins every
spawn command as a literal string, captured from the hand-written builders
before they were deleted, and `test/location-overlay-commands.test.ts` does the
same for all 20 remote and in-container pane commands.

Config can never contain shell text: an entry declares typed argv tokens,
literals are validated against a safe-word pattern at LOAD time (a bad literal
rejects the whole entry — a silently dropped `--no-approve` is not cosmetic),
and values resolve through patterns NAMED in code, so a user `clis.json` cannot
widen its own validation. `~/.codeman/clis.json` overrides any entry, read-only
in this release.

OMP is included as a registry entry rather than a tenth hand-written builder,
so `buildOmpCommand()`, the omp availability pre-flight, the omp arm of
`buildPathExport()` and the omp entries in the truecolor/NO_COLOR, alt-screen
and doctor ladders all drop out.

Guard rails:

- `test/cli-registry-no-id-branching.test.ts` fails the build if per-CLI-id
  branching reappears outside `stock.ts`, in any of its four shapes (`===`,
  `!==`, `switch`/`case`, `includes`) — an `===`-only version would miss the
  negated forms, which is how 36 of them survived an earlier pass. Every
  allowlisted branch carries its reason.
- `external`, `hooks` and `altScreen` stay three INDEPENDENT capabilities;
  deriving one from another shipped the `until=stop`-hangs-on-shell bug.
- `param` is two namespaces. `launch.params` keys, `configSetenv.fromParam` and
  `privilegedParams[].param` all name a LAUNCH param; the legacy `<Mode>Config`
  wire field is separate, bridged only by `legacyConfigAliases`. Getting
  `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass
  clamp's only handle on a CLI's privilege switch, and a wrong name clamps
  nothing with no error and no failing test — so `schema.ts` rejects an entry
  naming a param it never declared.
- Registry data resolves AT CALL TIME (`sessionModeSchema()`,
  `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs`
  thunks). A module-level const freezes at first import, so a CLI enabled while
  the server ran moved the run menu but not that surface.
- Six fields are annotated DECLARED-FOR-LATER and read by nothing
  (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/
  `keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed
  rather than measured. A test pins the list so it cannot quietly grow.

Three user-visible changes, all deliberate and named:

- `probeDockerCliVersion()` derives the in-container binary from the registry
  rather than assuming it equals the mode name (`antigravity` runs `agy`).
- The remote CLI version probe now covers grok and deepseek, which the
  hardcoded map it replaces omitted while its own comment said the rule was
  "every mode except shell".
- `codeman doctor`'s CLI rows are generated from the entries, so Claude's
  install hint is the install command rather than a docs URL, five CLIs gain
  hints they never had, and the row order follows the catalog.

Also hardened along the way: `sessionModeSchema()` is bounded at 24 chars
(matching the `cliId` pattern) before its failure message quotes the value
back, and `deepMerge` skips `__proto__`/`constructor`/`prototype` when reading
the hand-editable `clis.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
2026-09-02 08:26:45 +08:00

408 lines
19 KiB
TypeScript

/**
* DeepSeek Harness (`dsh`) run mode.
*
* The interesting assertions here are the ones that differ from every sibling
* CLI, because dsh is shaped differently in two ways:
*
* 1. the agent is a PROFILE, not the binary, so the spawn line carries
* `--profile <name>` and a profile name has to be treated as a path segment;
* 2. the permission switch is an ENV VAR (`DSH_PERMISSION_MODE`), not a flag,
* so the thing to pin is that nothing permission-shaped ever reaches the
* command line.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { CreateSessionSchema, QuickStartSchema, HookEventSchema } from '../src/web/schemas.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
import { hooksAvailableForMode, resolveWaitSignals, sessionHookOptions } from '../src/web/session-wait-registry.js';
import { _clampExternalCliBypassForOwner, _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
vi.mock('../src/utils/deepseek-cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/deepseek-cli-resolver.js')>();
return { ...actual, resolveDefaultDeepSeekProfile: vi.fn(() => 'dsh-tui') };
});
describe('DeepSeek mode schemas', () => {
it('accepts DeepSeek session creation config', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { profile: 'dsh-tui', permissionMode: 'danger-full-access' },
});
expect(parsed.mode).toBe('deepseek');
expect(parsed.deepSeekConfig).toEqual({ profile: 'dsh-tui', permissionMode: 'danger-full-access' });
});
it('accepts DeepSeek quick-start config', () => {
const parsed = QuickStartSchema.parse({
caseName: 'dsh-case',
mode: 'deepseek',
deepSeekConfig: { resumeSessionId: 'sess_01H9', statusReporting: false },
});
expect(parsed.mode).toBe('deepseek');
expect(parsed.deepSeekConfig?.resumeSessionId).toBe('sess_01H9');
expect(parsed.deepSeekConfig?.statusReporting).toBe(false);
});
it('rejects a profile name that is not a single path segment', () => {
// A profile is BOTH interpolated into a `bash -c "…"` line and joined into a
// filesystem path under $DSH_HOME/profiles, so separators and traversal have
// to die at the schema boundary.
for (const profile of ['../../etc/passwd', 'a/b', './x', '-rf', 'has space', 'semi;colon']) {
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'deepseek', deepSeekConfig: { profile } })
).toThrow();
}
});
it('rejects an unknown permission preset', () => {
// The three presets are the harness's own; anything else would be exported
// verbatim as DSH_PERMISSION_MODE and silently fall back to its default.
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { permissionMode: 'yolo' },
})
).toThrow();
});
it('rejects unsafe resumeSessionId values', () => {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { resumeSessionId: '../../etc/passwd' },
})
).toThrow();
});
it('allows DSH_* and DEEPSEEK_* env overrides but not a foreign provider key', () => {
const ok = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
envOverrides: { DSH_HOME: '/tmp/dsh', DEEPSEEK_API_KEY: 'sk-test' },
});
expect(ok.envOverrides).toEqual({ DSH_HOME: '/tmp/dsh', DEEPSEEK_API_KEY: 'sk-test' });
// A dsh settings.yaml can name ANY env var as a provider credential
// (apiKeyEnv), which is pi's 34-provider-key problem in a new shape. The
// allowlist is global, so admitting them would widen every mode at once.
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
envOverrides: { QWEN5090_API_KEY: 'sk-test' },
})
).toThrow();
});
});
describe('DeepSeek spawn command', () => {
it('boots the requested profile', () => {
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'dsh-tui' },
});
expect(cmd).toBe('dsh --profile dsh-tui');
});
it('falls back to the resolved default profile when none was requested', () => {
const cmd = buildSpawnCommand({ mode: 'deepseek', sessionId: 's1' });
expect(cmd).toBe('dsh --profile dsh-tui');
});
it('never puts anything permission-shaped on the command line', () => {
// The harness has NO permission flag: the switch is the DSH_PERMISSION_MODE
// env export, applied via `tmux setenv`. If this ever starts failing, someone
// has invented a flag that does not exist.
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'dsh-tui', permissionMode: 'danger-full-access' },
});
expect(cmd).toBe('dsh --profile dsh-tui');
expect(cmd).not.toMatch(/danger|approve|permission|yolo|dangerously/i);
});
it('prefers an explicit resume id over the most-recent form', () => {
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'p', resumeSession: true, resumeSessionId: 'sess_42' },
});
expect(cmd).toBe('dsh --profile p --resume sess_42');
});
it('resumes the most recent session when only the flag is set', () => {
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'p', resumeSession: true },
});
expect(cmd).toBe('dsh --profile p --resume');
});
it('drops an unsafe profile rather than interpolating it', () => {
// Defense in depth behind the schema: builders must not trust their callers,
// because this string is interpolated into a `bash -c "…"` argument.
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'evil; rm -rf /' },
});
expect(cmd).not.toContain('rm -rf');
expect(cmd).toBe('dsh --profile dsh-tui');
});
});
describe('DeepSeek mode wiring', () => {
it('is an external CLI mode', () => {
expect(isExternalCliMode('deepseek')).toBe(true);
});
it('is NOT an alt-screen strip mode', () => {
// The strip is for Ink-style repaint TUIs (claude/codex/gemini). A dsh
// terminal profile is a third-party fullscreen TUI, i.e. the opencode case.
expect(isAltScreenStripMode('deepseek')).toBe(false);
});
it('has default remote and docker commands', () => {
expect(defaultRemoteCommandForMode('deepseek')).toContain('dsh');
expect(defaultDockerCommandForMode('deepseek')).toBe('exec dsh');
});
});
describe('DeepSeek status bridge', () => {
it('is the only non-claude mode allowed to deliver hook signals', () => {
// Earned, not granted: the harness terminal front door REPORTS its state to
// a supervisor, so `stop` and `blocked` for a dsh session are definitive
// rather than inferred. Every other external CLI must keep failing this.
expect(hooksAvailableForMode('deepseek')).toBe(true);
expect(hooksAvailableForMode('claude')).toBe(true);
for (const mode of ['shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok'] as const) {
expect(hooksAvailableForMode(mode)).toBe(false);
}
});
it('is a per-SESSION answer for deepseek: a disarmed status bridge emits nothing', () => {
// `statusReporting: false` is what stops _configureDeepSeek() exporting the
// HERDR_* triple, and the triple is the ONLY reason a dsh session posts hook
// events. Answering from the mode alone would accept `until=stop` on a
// session where nothing can ever send one, which is the exact
// infinite-wait-dressed-as-a-timeout this predicate exists to prevent.
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: false })).toBe(false);
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: true })).toBe(true);
// Not sent = ON, so an ordinary session is unaffected.
expect(hooksAvailableForMode('deepseek', {})).toBe(true);
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: undefined })).toBe(true);
// The flag is meaningless for every other mode and must not move them.
expect(hooksAvailableForMode('claude', { deepSeekStatusReporting: false })).toBe(true);
expect(hooksAvailableForMode('codex', { deepSeekStatusReporting: true })).toBe(false);
});
it('refuses an explicit stop/blocked on a dsh session whose bridge is off, and says why', () => {
const off = { mode: 'deepseek' as const, deepSeekStatusReporting: false };
const on = { mode: 'deepseek' as const };
expect(resolveWaitSignals('stop', on)).toEqual({ until: ['stop'], error: null });
const rejected = resolveWaitSignals('stop', off);
expect(rejected.until).toEqual([]);
// The generic "no Claude Code hooks" wording would send the caller hunting a
// bug that is really a setting they chose, so this arm names the setting.
expect(rejected.error).toContain('statusReporting');
expect(rejected.error).not.toContain('no Claude Code hooks');
// An OMITTED `until` must never 400: the hook-only signals are dropped from
// the default set instead, leaving the two that still work.
expect(resolveWaitSignals(undefined, off)).toEqual({ until: ['idle', 'exit'], error: null });
expect(resolveWaitSignals(undefined, on).until).toContain('stop');
});
it('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"', () => {
// Read My Mind and intent capture read Claude's own transcript, so they mean
// mode === 'claude'. They used to ask hooksAvailableForMode(), which was the
// same question until `deepseek` earned a yes and silently widened both to a
// mode with no transcript to read. Static, because the alternative is
// standing up a predictor and a transcript watcher to observe one `if`.
const rmm = readFileSync(join(process.cwd(), 'src/web/routes/readmymind-routes.ts'), 'utf-8');
expect(rmm).toContain("session.mode !== 'claude'");
// Comment lines dropped first: the comment above that `if` names the
// predicate in order to explain why it is NOT the one being called there.
const uncommented = (src: string) =>
src
.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
expect(uncommented(rmm)).not.toMatch(/hooksAvailableForMode\(/);
const server = readFileSync(join(process.cwd(), 'src/web/server.ts'), 'utf-8');
expect(server).toContain("if (!session || session.mode !== 'claude') return;");
});
it('keeps the transcript reader off docker and remote-SSH sessions', () => {
// A docker case's harness writes its transcript inside the CONTAINER's
// ~/.dsh and a remote-SSH case's lives on the remote host, so the local
// reader would scan a $DSH_HOME that can never hold the file and return
// "nothing said yet" forever — starving an agent that polls the worker.
// Those sessions must keep the pane segmenter. Static, because standing up
// a docker/remote session in the unit harness is exactly what the tmux
// test-mode mocks exist to avoid.
//
// The mode check itself is now a capability read (`transcript === 'deepseek-zstd'`) —
// which reader understands this CLI's on-disk history is exactly the kind of fact the
// CLI registry owns. What this test guards is unchanged and is the part that matters:
// the two LOCATION exclusions beside it.
const routes = readFileSync(join(process.cwd(), 'src/web/routes/session-routes.ts'), 'utf-8');
expect(routes).toMatch(/capabilities\.transcript === 'deepseek-zstd' && !session\.docker && !session\.remote/);
});
it('maps the harness lifecycle states onto real hook events', () => {
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.idle).toBe('stop');
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.blocked).toBe('permission_prompt');
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.working).toBe('agent_working');
// Every mapped event must be one the hook endpoint actually accepts, or the
// bridge would post reports the schema silently rejects.
for (const event of Object.values(DEEPSEEK_STATE_TO_HOOK_EVENT)) {
expect(() => HookEventSchema.parse({ event, sessionId: 's1' })).not.toThrow();
}
});
});
describe('DeepSeek multi-user clamp', () => {
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('clamps a sent danger-full-access down to workspace-write, not read-only', () => {
// The clamp removes PRIVILEGE; it must not also break the session's ability
// to edit its own workspace, which read-only would.
return _clampExternalCliBypassForOwner('nobody', undefined, undefined, undefined, undefined, undefined, {
permissionMode: 'danger-full-access',
}).then((out) => {
expect(out.deepSeekConfig?.permissionMode).toBe('workspace-write');
});
});
it('leaves an ABSENT config absent (the only-if-sent branch)', async () => {
// Omitting DSH_PERMISSION_MODE leaves the harness on its own workspace-write
// preset, which still asks — so there is nothing to materialize, unlike pi.
const out = await _clampExternalCliBypassForOwner(
'nobody',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
expect(out.deepSeekConfig).toBeUndefined();
});
});
describe('DeepSeek multi-user clamp: the env-var half', () => {
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('strips DSH_PERMISSION_MODE, which would otherwise undo the config clamp on the same request', async () => {
// applyEnvOverrides() runs AFTER _configureDeepSeek() in tmux-manager, so an
// override sent alongside the config lands last and WINS. Clamping the config
// alone is therefore half a gate: this is the other half.
const out = await _clampEnvOverridesForOwner('nobody', {
DSH_PERMISSION_MODE: 'danger-full-access',
DSH_TELEMETRY_MODE: 'off',
});
expect(out).toEqual({ DSH_TELEMETRY_MODE: 'off' });
});
it('strips DSH_HOME, which points the launcher at a profile tree that executes at boot', async () => {
const out = await _clampEnvOverridesForOwner('nobody', { DSH_HOME: '/home/attacker/evil-dsh' });
expect(out).toEqual({});
});
it("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 () => {
const input = { DEEPSEEK_API_KEY: 'sk-test', CODEX_HOME: '/tmp/cx' };
const out = await _clampEnvOverridesForOwner('nobody', input);
expect(out).toBe(input);
expect(await _clampEnvOverridesForOwner('nobody', undefined)).toBeUndefined();
});
it('is a no-op in single-user mode', async () => {
delete process.env.CODEMAN_MULTIUSER;
const input = { DSH_PERMISSION_MODE: 'danger-full-access', DSH_HOME: '/opt/dsh' };
// canUsernameRunPrivilegedCommands() returns true when !isMultiUserMode(), so
// the single-user behaviour has to be byte-identical to before this clamp.
expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input);
});
});
describe('DeepSeek profile install is bounded for real', () => {
it('runs in its own process group and escalates the kill to the whole tree', () => {
// `dsh plugin add` fans out into package-manager resolver/build children, and
// spawn's own `timeout` signals only the direct child: survivors hold the
// inherited stdio pipes open, `close` never fires, and the held-open request
// leaks forever. Same failure and same fix as runGit() in git-clone.ts.
// Static, because reproducing it needs a real package manager that hangs.
const src = readFileSync(join(process.cwd(), 'src/web/routes/system-routes.ts'), 'utf-8');
const handler = src.slice(src.indexOf("app.post('/api/deepseek/install-profile'"));
const body = handler.slice(0, handler.indexOf('app.post(', 1) + 1 || handler.length);
expect(body).toContain('detached: true');
expect(body).toContain('process.kill(-child.pid, signal)');
expect(body).toContain("killTree('SIGTERM')");
expect(body).toContain("killTree('SIGKILL')");
// The built-in option is the thing that did NOT work here; it must not come back.
expect(body).not.toContain('timeout: DEEPSEEK_INSTALL_TIMEOUT_MS');
});
});