Files
Codeman/test/deepseek-mode.test.ts
T
Codeman maintainer 4cda150493 feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode
Adds `mode: 'deepseek'` alongside claude/shell/opencode/codex/gemini/
antigravity/pi/grok, plus a shortcut that opens the harness's own browser UI
as a Codeman web tab.

DeepSeek is wired unlike its siblings in three ways, each of which is the
reason for a design decision rather than an accident:

1. The agent is a PROFILE, not the binary. `dsh` is a launcher over
   $DSH_HOME/profiles/<name>, and DeepSeek ships only `web`, `headless` and
   `base` -- the interactive terminal front door is always a third-party
   plugin. So availability is two questions: `isDeepSeekAvailable()` (binary)
   and `isDeepSeekRunnable()` (binary AND a pane-capable profile). The Run
   button gates on the latter, because reporting only the binary would spawn a
   pane that dies on arrival. When the binary is present but no profile is,
   the run menu offers to install one (POST /api/deepseek/install-profile).

2. The permission switch is an env var, not a flag. The harness has no
   command-line permission option; its sandbox/approval rows read
   DSH_PERMISSION_MODE (read-only / workspace-write / danger-full-access).
   Exported via `tmux setenv`, never on the spawn line. Absent = the harness's
   own workspace-write, which still asks, so the multi-user clamp is the
   only-if-sent branch and clamps to workspace-write, never read-only.

3. It is the only non-claude mode that passes hooksAvailableForMode(), and it
   earned that. The terminal front door reports idle/working/blocked to a
   supervising process over a generic env-gated contract; a generated shim
   (deepseek-status-shim.ts) makes Codeman that supervisor and forwards each
   report to /api/hook-event as stop / agent_working / permission_prompt. So a
   dsh session gets definitive respawn triggers, real wait-endpoint signals and
   real Approvals Inbox items instead of output-stabilization guesswork.
   `agent_working` is new (157th SSE constant) and joins
   APPROVAL_RESOLVING_EVENTS so a dialog answered in the terminal clears its
   alert at once.

The resolver needs the strictest identity probe of the family: `dsh` is not
merely a squattable npm name, Debian ships an unrelated `dsh` (dancer's shell),
so `dsh --help` must print the harness's own banner before a candidate is
handed a spawn line.

Model is deliberately not a session field -- it is a composition entry in the
profile's config tree. Env allowlist gains DSH_* and DEEPSEEK_* only; provider
keys named by a settings-file `apiKeyEnv` stay out, which is pi's
34-provider-key problem in a new shape.

Verified live against dsh 0.1.1-rc.2 and @deepseek-harness-tui/dsh-tui: the
status endpoint's two-part answer, the no-profile refusal, the profile
bootstrap, a real session whose pane runs `dsh --profile dsh-tui` with the
permission mode injected via setenv, and the full status bridge -- a
send-and-wait returned signal "stop" from a real turn, and blocked/working
created and cleared an Approvals Inbox item.

Docs: docs/deepseek-integration.md (guide), docs/deepseek-integration-plan.md
(decisions + honest gaps). Tests: test/deepseek-mode.test.ts,
test/deepseek-cli-resolver.test.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 03:37:56 +02:00

241 lines
9.4 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 } from '../src/web/session-wait-registry.js';
import { _clampExternalCliBypassForOwner } from '../src/web/routes/session-routes.js';
import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js';
vi.mock('../src/utils/deepseek-cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<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('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();
});
});