mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
The three smaller review nits, plus the first real test coverage for the status shim (it had none: it is emitted as a STRING, so tsc never sees it). 1. The shim was written with a plain writeFileSync. The TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a reader catching 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. Now temp + rename (atomic within the directory), with the temp chmod'ed before the rename since writeFileSync's mode only applies on create, and removed if the write throws. SHIM_VERSION bumped to 2, because SHIM_SOURCE changed and an existing v1 shim would otherwise keep matching the embedded marker and never be refreshed. 2. The pane-id comment claimed the ambient env "cannot be spoofed by an argument the agent itself could influence". The agent runs IN that pane and can invoke the shim with CODEMAN_SESSION_ID unset and any argv it likes. It buys nothing it did not already have (the hook-secret file is readable from the same pane, so it can POST /api/hook-event directly), but the comment read like a security boundary. Rewritten to say what the preference actually buys: correct attribution when a TUI mangles or re-uses the pane argument. Accidents, not adversaries. 3. classifyProfile() folded the directory name into the same haystack as the bundles, but only the TUI arm could match a bare name, so a stock profile whose package.json has no dsh.profile.bundles (hand-edited, older layout, mid-install) classified as `unknown` -> launchable -> eligible as the DEFAULT pick, which is exactly the pane-dies-on-arrival failure the two-part availability gate exists to prevent. The stock names are now a LAST-resort fallback consulted after the bundle patterns, so real bundle evidence still wins over a name the user chose. The loose `tui` arm gained word boundaries: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. New test/deepseek-status-shim.test.ts runs the generated script the way the harness does -- real node process, real argv, real env, real listener -- and covers the exit-code contract that makes the retry behaviour safe: mapped states post and exit 0, an unknown verb or unmapped state exits 0 WITHOUT posting (a non-zero there would be four HTTP requests per state change forever), a rejecting server or an unreachable one exits non-zero so the caller retries, the hook secret is read at execution time, and `node --check` parses the file (a template-literal typo in SHIM_SOURCE is invisible to tsc). Trap worth recording, hit while writing it: the tests must spawn the shim ASYNCHRONOUSLY. The listener lives in the test process, so spawnSync blocks the event loop that has to accept the connection, the shim waits out its own 1500ms socket timeout and exits 1, and it reads exactly like a broken shim (measured: Socket._onTimeout in its --trace-exit output, server logging nothing). Verified: full gate green (6142 passed, +10), typecheck/lint/format clean.
278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
/**
|
|
* @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('does not treat a bundle-less stock profile as launchable', () => {
|
|
// readProfile() yields an empty bundle list for any package.json without a
|
|
// `dsh.profile.bundles` array (hand-edited, older layout, mid-install), and
|
|
// with no bundles to read the shipped web/headless profiles used to look
|
|
// exactly like an unrecognized third-party one — inheriting its
|
|
// launchable-by-default treatment and producing the pane-dies-on-arrival
|
|
// failure the two-part availability gate exists to prevent.
|
|
const bare = (name: string) => {
|
|
const dir = join(home, 'profiles', name);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: `dsh-profile-${name}` }));
|
|
};
|
|
bare('web');
|
|
bare('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);
|
|
expect(resolveDefaultDeepSeekProfile()).toBeNull();
|
|
});
|
|
|
|
it('lets bundle evidence beat the name fallback', () => {
|
|
// The name check is a LAST resort, so a profile the user happened to call
|
|
// `web` that really composes a terminal app is still interactive. Otherwise
|
|
// a directory name would override what the profile actually contains.
|
|
writeProfile('web', ['@deepseek-ai/dsh-base', '@someone/dsh-tui']);
|
|
const profile = listDeepSeekProfiles().find((p) => p.name === 'web')!;
|
|
expect(profile.kind).toBe('interactive');
|
|
expect(resolveDefaultDeepSeekProfile()).toBe('web');
|
|
});
|
|
|
|
it('does not read `tui` out of the middle of an unrelated word', () => {
|
|
// The loose arm is a TOKEN match: `@someone/tui-app` is a TUI, `intuition`
|
|
// is a word. Being wrong is cheap (unknown is launchable too) but it decides
|
|
// which profile boots by DEFAULT, and "its name contains t-u-i" is not a
|
|
// rule anyone could predict.
|
|
writeProfile('intuition', ['@someone/gratuitous-surface']);
|
|
expect(listDeepSeekProfiles().find((p) => p.name === 'intuition')!.kind).toBe('unknown');
|
|
|
|
writeProfile('mine', ['@someone/tui-app']);
|
|
expect(listDeepSeekProfiles().find((p) => p.name === 'mine')!.kind).toBe('interactive');
|
|
// Preferred over the merely-unknown one, which is the whole point of ranking.
|
|
expect(resolveDefaultDeepSeekProfile()).toBe('mine');
|
|
});
|
|
|
|
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']);
|
|
});
|
|
});
|