mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
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>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* @fileoverview Tests for the DeepSeek Harness (`dsh`) resolver and profile inventory.
|
||||
*
|
||||
* `dsh` needs the strictest identity probe of any CLI Codeman resolves. pi and
|
||||
* grok are short names with npm squatters; `dsh` is worse — it is an EXISTING,
|
||||
* widely packaged Unix program (Debian's dancer's shell, `apt install dsh`),
|
||||
* which would sail through a version-token probe and then be handed a spawn
|
||||
* line. So the resolver demands the harness's own help banner first, and the
|
||||
* headline test below is the one that pins that rejection.
|
||||
*
|
||||
* The second half covers something no sibling resolver has: a profile
|
||||
* inventory. `dsh` is a launcher, so "is it installed" and "can it run a
|
||||
* session" are different questions, and the availability gate needs both.
|
||||
*/
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createDeepSeekResolverForTest,
|
||||
DEEPSEEK_VERSION_REGEX,
|
||||
DEEPSEEK_IDENTITY_REGEX,
|
||||
listDeepSeekProfiles,
|
||||
resolveDefaultDeepSeekProfile,
|
||||
isLaunchableProfile,
|
||||
resolveDshHome,
|
||||
} from '../src/utils/deepseek-cli-resolver.js';
|
||||
import {
|
||||
cliResolveRetryDelayMs,
|
||||
createProductionCliResolverHost,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createHost(
|
||||
options: {
|
||||
processPathResult?: string | null;
|
||||
loginShellResults?: Array<string | null>;
|
||||
existingPaths?: string[];
|
||||
} = {}
|
||||
): CliResolverHost {
|
||||
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||
const existingPaths = new Set(options.existingPaths ?? []);
|
||||
return {
|
||||
processPath: '/service/bin',
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
findOnProcessPath: () => options.processPathResult ?? null,
|
||||
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||
exists: (path) => existingPaths.has(path),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DeepSeek CLI resolver', () => {
|
||||
it('accepts a candidate the probe verifies and carries the version as metadata', () => {
|
||||
const binaryPath = '/service/bin/dsh';
|
||||
const probe = vi.fn(() => '0.1.1-rc.2');
|
||||
const resolver = createDeepSeekResolverForTest(
|
||||
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
|
||||
probe
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath,
|
||||
directory: '/service/bin',
|
||||
source: 'process-path',
|
||||
metadata: '0.1.1-rc.2',
|
||||
});
|
||||
expect(probe).toHaveBeenCalledWith(binaryPath);
|
||||
});
|
||||
|
||||
it('does not let a foreign `dsh` earlier on PATH mask the real one', () => {
|
||||
// The dancer's-shell case, at resolver level: a `dsh` that is a real program
|
||||
// and answers --version must still be refused, and must not stop the search.
|
||||
const impostor = '/usr/bin/dsh';
|
||||
const genuine = '/login-shell/bin/dsh';
|
||||
const probe = vi.fn((binPath: string) => (binPath === genuine ? '0.1.1-rc.2' : null));
|
||||
const resolver = createDeepSeekResolverForTest(
|
||||
createHost({
|
||||
processPathResult: impostor,
|
||||
loginShellResults: [genuine],
|
||||
existingPaths: [impostor, genuine],
|
||||
}),
|
||||
probe
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell' });
|
||||
});
|
||||
|
||||
it('negative-caches a miss and retries only after the backoff elapses', () => {
|
||||
const binaryPath = '/late/bin/dsh';
|
||||
let now = 0;
|
||||
const probe = vi.fn(() => '0.1.1-rc.2');
|
||||
const resolver = createDeepSeekResolverForTest(
|
||||
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||
probe,
|
||||
() => now
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()?.metadata).toBe('0.1.1-rc.2');
|
||||
});
|
||||
|
||||
it('extracts the version from the real output shape (a bare `0.1.1-rc.2`)', () => {
|
||||
// Shared with the dependency registry (doctor), so the accepted shape is
|
||||
// contract. The prerelease tail is part of the token on purpose: dropping it
|
||||
// would report a release candidate as a release.
|
||||
expect(DEEPSEEK_VERSION_REGEX.exec('0.1.1-rc.2')?.[1]).toBe('0.1.1-rc.2');
|
||||
expect(DEEPSEEK_VERSION_REGEX.exec('dsh 1.2.3')?.[1]).toBe('1.2.3');
|
||||
expect(DEEPSEEK_VERSION_REGEX.exec('not a version')).toBeNull();
|
||||
});
|
||||
|
||||
it('identifies the harness by its help banner and rejects a foreign dsh', () => {
|
||||
expect(DEEPSEEK_IDENTITY_REGEX.test('dsh: boot a DeepSeek Harness profile — an ordered stack')).toBe(true);
|
||||
// Debian's dancer's shell: a real program, a real version, not our agent.
|
||||
expect(DEEPSEEK_IDENTITY_REGEX.test('Usage: dsh [options] [command] ...\nDistributed shell')).toBe(false);
|
||||
});
|
||||
|
||||
it('never executes a dsh candidate under vitest (the ambient probe is VITEST-gated)', () => {
|
||||
// A REAL executable fixture that answers BOTH probes convincingly. If the
|
||||
// guard in probeDeepSeekVersion is ever removed, this script runs, the
|
||||
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
|
||||
// behavior rather than by source text. That matters more here than for any
|
||||
// sibling: `dsh` is a name real machines genuinely carry.
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-dsh-vitest-gate-'));
|
||||
temporaryDirectories.push(root);
|
||||
const binaryPath = join(root, 'dsh');
|
||||
writeFileSync(
|
||||
binaryPath,
|
||||
'#!/bin/sh\ncase "$1" in --help) echo "dsh: boot a DeepSeek Harness profile";; *) echo "9.9.9";; esac\n'
|
||||
);
|
||||
chmodSync(binaryPath, 0o755);
|
||||
const hostOptions = {
|
||||
processPath: root,
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'] as string[],
|
||||
runCommand: () => '',
|
||||
isExecutableFile: (path: string) => path === binaryPath,
|
||||
};
|
||||
|
||||
const gated = createDeepSeekResolverForTest(createProductionCliResolverHost(hostOptions));
|
||||
expect(gated.resolve()).toBeNull();
|
||||
|
||||
// Control: identical setup with an injected probe resolves, proving the null
|
||||
// above comes from the gate, not from the fixture or the host.
|
||||
const control = createDeepSeekResolverForTest(createProductionCliResolverHost(hostOptions), () => '9.9.9');
|
||||
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '9.9.9' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepSeek profile inventory', () => {
|
||||
let home: string;
|
||||
const ORIGINAL_DSH_HOME = process.env.DSH_HOME;
|
||||
|
||||
function writeProfile(name: string, bundles: string[]): void {
|
||||
const dir = join(home, 'profiles', name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'package.json'),
|
||||
JSON.stringify({ name: `dsh-profile-${name}`, dsh: { profile: { bundles } } })
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-dsh-home-'));
|
||||
temporaryDirectories.push(home);
|
||||
process.env.DSH_HOME = home;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_DSH_HOME === undefined) delete process.env.DSH_HOME;
|
||||
else process.env.DSH_HOME = ORIGINAL_DSH_HOME;
|
||||
});
|
||||
|
||||
it('honours DSH_HOME over the default ~/.dsh', () => {
|
||||
expect(resolveDshHome()).toBe(home);
|
||||
});
|
||||
|
||||
it('is empty (not an error) when dsh has never been run', () => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
expect(listDeepSeekProfiles()).toEqual([]);
|
||||
expect(resolveDefaultDeepSeekProfile()).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies the profiles DeepSeek ships as unable to drive a pane', () => {
|
||||
writeProfile('web', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']);
|
||||
writeProfile('headless', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']);
|
||||
|
||||
const profiles = listDeepSeekProfiles();
|
||||
expect(profiles.map((p) => `${p.name}:${p.kind}`).sort()).toEqual(['headless:headless', 'web:web']);
|
||||
expect(profiles.every((p) => !isLaunchableProfile(p))).toBe(true);
|
||||
// The whole point: a perfectly installed dsh with only the shipped profiles
|
||||
// still cannot start a Codeman session.
|
||||
expect(resolveDefaultDeepSeekProfile()).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers an interactive profile and ignores node_modules', () => {
|
||||
writeProfile('web', ['@deepseek-ai/dsh-web-app']);
|
||||
writeProfile('dsh-tui', ['@deepseek-ai/dsh-base', '@deepseek-harness-tui/dsh-tui']);
|
||||
mkdirSync(join(home, 'profiles', 'node_modules', 'something'), { recursive: true });
|
||||
|
||||
const names = listDeepSeekProfiles().map((p) => p.name);
|
||||
expect(names).not.toContain('node_modules');
|
||||
expect(resolveDefaultDeepSeekProfile()).toBe('dsh-tui');
|
||||
});
|
||||
|
||||
it('treats an unrecognized third-party profile as launchable', () => {
|
||||
// Anyone can publish an app bundle, so an unknown profile must not be hidden
|
||||
// from the picker just because this classifier has not heard of it.
|
||||
writeProfile('custom', ['@someone/dsh-my-own-surface']);
|
||||
const profile = listDeepSeekProfiles().find((p) => p.name === 'custom')!;
|
||||
expect(profile.kind).toBe('unknown');
|
||||
expect(isLaunchableProfile(profile)).toBe(true);
|
||||
expect(resolveDefaultDeepSeekProfile()).toBe('custom');
|
||||
});
|
||||
|
||||
it('survives a stray directory under profiles/', () => {
|
||||
mkdirSync(join(home, 'profiles', 'not-a-profile'), { recursive: true });
|
||||
writeProfile('dsh-tui', ['@deepseek-harness-tui/dsh-tui']);
|
||||
expect(listDeepSeekProfiles().map((p) => p.name)).toEqual(['dsh-tui']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -424,7 +424,17 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
||||
isCliAvailable: () => true,
|
||||
});
|
||||
const menu = app._buildMobileOverviewRunMenu();
|
||||
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']);
|
||||
expect(modeButtons(menu)).toEqual([
|
||||
'claude',
|
||||
'opencode',
|
||||
'codex',
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'pi',
|
||||
'grok',
|
||||
'deepseek',
|
||||
'shell',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gates every mode the picker actually offers', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
|
||||
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
||||
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
|
||||
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
|
||||
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
|
||||
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
||||
import { isGitAvailable } from '../src/git-clone.js';
|
||||
|
||||
@@ -55,6 +56,16 @@ vi.mock('../src/utils/grok-cli-resolver.js', () => ({
|
||||
resolveGrokDir: vi.fn(() => null),
|
||||
getGrokCliVersion: vi.fn(() => null),
|
||||
}));
|
||||
// DeepSeek is the one mode with a two-part availability answer (binary AND a
|
||||
// pane-capable profile), so both probes are mocked independently.
|
||||
vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({
|
||||
isDeepSeekAvailable: vi.fn(() => false),
|
||||
isDeepSeekRunnable: vi.fn(() => false),
|
||||
resolveDeepSeekDir: vi.fn(() => null),
|
||||
getDeepSeekCliVersion: vi.fn(() => null),
|
||||
listDeepSeekProfiles: vi.fn(() => []),
|
||||
resolveDefaultDeepSeekProfile: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
||||
isCloudflaredAvailable: vi.fn(() => false),
|
||||
resolveCloudflaredPath: vi.fn(() => null),
|
||||
@@ -160,6 +171,8 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
antigravity: false,
|
||||
pi: true,
|
||||
grok: false,
|
||||
deepseek: false,
|
||||
deepseekBinary: false,
|
||||
cloudflared: true,
|
||||
git: true,
|
||||
});
|
||||
@@ -176,6 +189,8 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
isAntigravityAvailable,
|
||||
isPiAvailable,
|
||||
isGrokAvailable,
|
||||
isDeepSeekAvailable,
|
||||
isDeepSeekRunnable,
|
||||
isCloudflaredAvailable,
|
||||
isGitAvailable,
|
||||
]) {
|
||||
|
||||
Reference in New Issue
Block a user