merge master (1.49.0 and later) into the MacBook test branch

One conflict, in install.sh's help text: both sides extended the same line of
environment variables. Kept both additions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 18:00:25 +01:00
commit fec052f402
181 files changed
+14849 -872

No files matched your search

+35
View File
@@ -3,6 +3,7 @@ import { expect, it, vi } from 'vitest';
import { formatPaneSnapshot } from '../src/tmux-manager.js';
import { Session } from '../src/session.js';
import { promptSha256 } from '../src/agent-staging.js';
import type { SessionAdopt } from '../src/types/session.js';
it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => {
let screen = '› \n GPT-6-Luna default · ~/case\n';
const mux = {
@@ -542,3 +543,37 @@ it('stages through a real session whose first post-write capture is the torn fra
await session.stop(false);
}
});
it('offers no guarded input on an adopted pane, however much it looks like a composer', () => {
// Stage and submit type a prompt on an agent's behalf, here into a pane a human is using.
const screen = '\u203a \n GPT-6-Luna default \u00b7 ~/case\n';
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(screen.trimEnd().split('\n'), { cols: 100, rows: 20, cursorX: 2, cursorY: 0 });
},
sendInput: async () => true,
};
const build = (adopt?: SessionAdopt) =>
new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
adopt,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
} as unknown as ConstructorParameters<typeof Session>[0]);
expect(build().guardedInputSnapshot()).not.toBeNull();
expect(
build({
location: 'local',
socketPath: '/tmp/tmux-1000/default',
targetSession: 'work',
viewSession: 'codeman-view-00000000',
}).guardedInputSnapshot()
).toBeNull();
});
+3 -1
View File
@@ -120,6 +120,8 @@ describe('Antigravity mode gates', () => {
expect(defaultDockerCommandForMode('antigravity')).toBe('exec agy');
// Routed through an interactive login shell so per-user PATH entries resolve —
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
expect(defaultRemoteCommandForMode('antigravity')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'agy\'');
expect(defaultRemoteCommandForMode('antigravity')).toBe(
'{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'agy\'; }'
);
});
});
+23
View File
@@ -237,6 +237,29 @@ describe('App Settings modal structure', () => {
);
});
it('never sells Allow network access as leaving the sandbox in charge', () => {
// Measured 2026-11-06 on codex 0.162.0: with the network open, a command the sandbox
// refused (`touch` outside the workspace) landed by asking Codeman's API for a shell
// session, and from inside a Codeman pane it did so with a password set, because a pane
// inherits CODEMAN_PASSWORD. So "the sandbox and approvals stay on" is false comfort.
const modal = settingsModal();
const start = modal.indexOf('data-search="codex network access');
expect(start).toBeGreaterThan(-1);
const row = modal.slice(start, modal.indexOf('id="appSettingsCodexNetworkAccess"', start));
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
const referenceRow = read('docs/wiki/Settings-Reference.md')
.split('\n')
.find((line) => line.startsWith('| Allow network access'));
expect(referenceRow).toBeDefined();
const safetyClaim = /sandbox and approvals? (prompts )?stay on|keeps? (Codex's |the )?sandbox and approval/i;
for (const text of [row, referenceRow!, read('docs/wiki/Codeman-Skill.md'), read('skills/codeman/SKILL.md')]) {
expect(text).not.toMatch(safetyClaim);
}
for (const text of [row, referenceRow!, read('docs/wiki/Security.md')]) {
expect(text).toContain("Codeman's API");
}
});
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
+39 -1
View File
@@ -102,7 +102,7 @@ describe('isMuxAltScreenOnlyStripMode', () => {
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
}
// The full strip and the mouse strip already own their modes; never double-gate.
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot'] as const) {
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'pi'] as const) {
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
}
});
@@ -372,6 +372,44 @@ describe('strip decision table: live stream = replay, for every stock CLI', () =
* on itself. Left in, xterm reports a plain drag to the TUI instead of selecting, so Auto Copy and
* copy-on-select did nothing (only Shift+drag selected). Same strip, same reason.
*/
describe('pi: alt-screen + mouse DECSETs stripped, 3J kept (#758)', () => {
// What fullscreen pi really emits on entering its TUI, read off a pane on 1.1.0, 1.4.0 and 1.5.0.
// Tracking comes on once, here, so a replay that drops it leaves the wheel dead unless the
// server remembers it. ?1004h (focus reporting) is not a mouse mode and stays.
const ENTER = '\x1b[?1049h\x1b[?7l\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h';
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('pi', true)).toBe(true);
expect(isMuxMouseStripMode('pi', false)).toBe(false);
});
it('drops the alt-screen and mouse enables, and keeps focus reporting and 3J', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
handleOutput(session, `${ENTER}\x1b[3JTUI`);
expect(session.terminalBuffer).toBe('\x1b[?7l\x1b[?1004h\x1b[3JTUI');
});
it('publishes cliMouseTracking so the wheel survives a replay', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
handleOutput(session, `${ENTER}TUI`);
expect(session.toState().cliMouseTracking).toBe(true);
handleOutput(session, '\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('never sets the flag for an inline pi, which emits no mouse enable', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
handleOutput(session, '\x1b[?2004hpi');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('leaves a direct-PTY pi pane untouched', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: false });
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
});
});
describe('copilot: alt-screen + mouse DECSETs stripped, 3J kept', () => {
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('copilot', true)).toBe(true);
+60
View File
@@ -0,0 +1,60 @@
/**
* @fileoverview A launch gate is measured against its OWN CLI's version.
*
* `capabilities.gates` used to be resolved with `getClaudeCliVersion()` for every entry,
* because claude was the only CLI with a gate. Codex's `noDaemonFlag` (0.162.0) would then
* have passed on any claude 2.x and handed an older codex `--no-daemon`, which makes codex
* exit on launch. The bridge now asks `resolveSessionCliVersion(entry.id)`; this pins that
* with both resolvers mocked to DIFFERENT versions.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const versions: Record<string, string | null> = {};
vi.mock('../src/utils/cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/cli-resolver.js')>();
return { ...actual, resolveSessionCliVersion: vi.fn((mode: string) => versions[mode] ?? null) };
});
vi.mock('../src/utils/claude-cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/claude-cli-resolver.js')>();
return { ...actual, getClaudeCliVersion: vi.fn(() => '2.1.316') };
});
const { getCli } = await import('../src/config/cli-registry/registry.js');
const { buildSpawnCommandFromRegistry, launchGatePasses } = await import('../src/session-cli-registry-bridge.js');
const SID = '00000000-0000-4000-8000-000000000000';
function codexCommand(): string | undefined {
return buildSpawnCommandFromRegistry(getCli('codex')!, { mode: 'codex', sessionId: SID });
}
describe('launch gates read the gated CLI version', () => {
beforeEach(() => {
for (const key of Object.keys(versions)) delete versions[key];
});
it("does not pass codex's gate on claude's version", () => {
versions.claude = '2.1.316';
versions.codex = '0.161.0';
expect(codexCommand()).toBe('codex');
expect(launchGatePasses('codex', 'noDaemonFlag')).toBe(false);
});
it("passes codex's gate on codex's own version", () => {
versions.codex = '0.162.0';
expect(codexCommand()).toBe('codex --no-daemon');
expect(launchGatePasses('codex', 'noDaemonFlag')).toBe(true);
});
it('fails closed when the codex version is unknown', () => {
versions.claude = '2.1.316';
expect(codexCommand()).toBe('codex');
});
it("still measures claude's gates on claude's version", () => {
versions.claude = '2.1.316';
versions.codex = '0.1.0';
expect(launchGatePasses('claude', 'nameFlag')).toBe(true);
});
});
+5 -3
View File
@@ -450,7 +450,7 @@ describe('cross-field integrity', () => {
// 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!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals', 'networkAccess']);
expect(codex!.launch.legacyConfigAliases?.bypassApprovals).toBe('dangerouslyBypassApprovals');
const wrong = baseEntry('codex');
@@ -633,20 +633,22 @@ describe('capability shapes', () => {
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
// forward without a version gate. Unmeasured entries stay 'never'.
// forward without a version gate, and so does pi. Unmeasured entries stay 'never'.
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
expect(mode('copilot')).toEqual({ mode: 'always' });
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
(e) => e.id as string
);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot']);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot', 'pi']);
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
// pi pages too when fullscreen, and stays out on purpose: in `tuiMode: regular` the same keys
// move the caret of a multi-line draft.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
});
+39
View File
@@ -191,6 +191,45 @@ describe('codex', () => {
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
);
});
it('emits the sandbox network-access config only when asked', () => {
expect(cx({ networkAccess: true })).toBe('codex --config sandbox_workspace_write.network_access=true');
expect(cx({ networkAccess: false })).toBe('codex');
});
it('never pairs network access with a sandbox mode (owner decision, 2026-11-06)', () => {
// A folder codex runs read-only must stay read-only: it keeps the network closed and asks
// per command. `--sandbox workspace-write` here would also grant write access to it.
for (const version of ['0.162.0', null]) {
const command = render({
mode: 'codex',
sessionId: SID,
codexConfig: { networkAccess: true },
claudeCliVersion: version,
});
expect(command).not.toContain('--sandbox');
expect(command).not.toContain('-s ');
}
});
// The `claudeCliVersion` option is the version every gate of THIS entry is measured
// against (the name predates a second gated CLI); undefined probes codex itself.
const cxv = (version: string | null, codexConfig?: SpawnBridgeOptions['codexConfig']) =>
render({ mode: 'codex', sessionId: SID, codexConfig, claudeCliVersion: version });
it('runs without the shared app-server daemon from 0.162.0, and fails closed below or unknown', () => {
expect(cxv('0.162.0')).toBe('codex --no-daemon');
expect(cxv('0.166.0')).toBe('codex --no-daemon');
expect(cxv('0.161.9')).toBe('codex');
expect(cxv(null)).toBe('codex');
});
it('keeps every flag ahead of the resume subcommand with the daemon flag on', () => {
expect(cxv('0.162.0', { dangerouslyBypassApprovals: true, networkAccess: true, resumeSessionId: 'roll_42' })).toBe(
'codex --no-daemon --dangerously-bypass-approvals-and-sandbox ' +
'--config sandbox_workspace_write.network_access=true resume roll_42'
);
});
});
describe('gemini', () => {
+188
View File
@@ -0,0 +1,188 @@
/**
* @fileoverview Frontend test for Clone Repo's custom folder (session-ui.js `cloneCase()` /
* `_cloneTargetPath()`): the chosen folder is the PARENT, so the clone request carries
* `<folder>/<case name>` exactly as Create New does, and without the checkbox no `path` is sent
* (the server then clones to `~/codeman-cases/<name>`). The "Will clone into" line has to name the
* folder the request will really use, so it follows every way the name or the folder can change:
* typing, the URL, the preflight's suggested name and the Browse picker. Runs against the real
* index.html markup.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
function bootClone() {
const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only' });
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => any };
const run = (src: string) => (win as unknown as { eval: (s: string) => void }).eval(src);
run('window.CodemanApp = function CodemanApp() {};');
run(CONSTANTS_JS);
run(SESSION_UI_JS);
const app = new win.CodemanApp();
app.showToast = () => {};
app._setCloneStatus = () => {};
app.closeCreateCaseModal = () => {};
app.loadQuickStartCases = async () => {};
app.saveLastUsedCase = async () => {};
const fetchMock = vi.fn(async () => ({ json: async () => ({ success: true, data: {} }) }));
(win as unknown as { fetch: unknown }).fetch = fetchMock;
const doc = win.document;
const set = (id: string, value: string) => ((doc.getElementById(id) as HTMLInputElement).value = value);
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost');
set('cloneCaseName', 'bifrost');
const preview = () => doc.getElementById('cloneCasePathPreview')?.textContent;
return { app, win, doc, set, preview, fetchMock };
}
async function requestBody(fetchMock: ReturnType<typeof vi.fn>) {
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, { body: string }];
return { url, body: JSON.parse(init.body) };
}
describe('Clone Repo custom folder', () => {
it('sends <folder>/<case name> as the path and previews it', async () => {
const { app, doc, set, fetchMock } = bootClone();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
set('cloneCasePath', '/mnt/user/Scripts/GitHub/opticon454/');
app.updateCloneCasePathPreview();
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe(
'Will clone into: /mnt/user/Scripts/GitHub/opticon454/bifrost'
);
await app.cloneCase();
const { url, body } = await requestBody(fetchMock);
expect(url).toBe('/api/cases/clone');
expect(body.path).toBe('/mnt/user/Scripts/GitHub/opticon454/bifrost');
expect(body.name).toBe('bifrost');
});
it('follows the case name when it is edited', () => {
const { app, doc, set } = bootClone();
set('cloneCasePath', '~/projects');
set('cloneCaseName', 'other');
app.onCloneNameEdited();
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: ~/projects/other');
});
it('sends no path without the checkbox, even when the folder field still holds one', async () => {
const { app, set, fetchMock } = bootClone();
set('cloneCasePath', '/mnt/user/Scripts/GitHub/opticon454');
await app.cloneCase();
const { body } = await requestBody(fetchMock);
expect(body.path).toBeUndefined();
});
it.each([
['/mnt/projects', '/mnt/projects/bifrost'],
['/mnt/projects///', '/mnt/projects/bifrost'],
[' /mnt/projects/ ', '/mnt/projects/bifrost'],
['/', '/bifrost'],
['~', '~/bifrost'],
['~/', '~/bifrost'],
])('joins the parent %j to the case name', (parent, target) => {
const { app, set } = bootClone();
set('cloneCasePath', parent);
expect(app._cloneTargetPath()).toBe(target);
});
it('follows the name the preflight settles on, which is the one the request will carry', async () => {
const { app, doc, set, preview, fetchMock } = bootClone();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
set('cloneCasePath', '/mnt/projects');
// The page's own guess for a URL copied out of a browser tab; the server reads the repo as `repo`.
set('cloneCaseName', 'repo-tab-readme');
app.updateCloneCasePathPreview();
expect(preview()).toBe('Will clone into: /mnt/projects/repo-tab-readme');
app._applyClonePreflight(
{
parse: { cloneable: true, suggestedName: 'repo', provider: 'GitHub', owner: 'owner', repo: 'repo' },
gitAvailable: true,
},
'https://github.com/owner/repo?tab=readme'
);
expect(preview()).toBe('Will clone into: /mnt/projects/repo');
await app.cloneCase();
const { body } = await requestBody(fetchMock);
expect(body).toMatchObject({ name: 'repo', path: '/mnt/projects/repo' });
});
it('sends the folder Browse picked as the parent, and titles the picker as the parent', async () => {
const { app, win, doc, preview, fetchMock } = bootClone();
const open = vi.fn();
(win as unknown as { PathPicker: { open: unknown } }).PathPicker = { open };
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
app.openCloneCasePathPicker();
const options = open.mock.calls[0]![0] as {
title: string;
directoriesOnly: boolean;
allowNewFolder: boolean;
onSelect(p: string): void;
};
expect(options.title).toBe('Choose the folder to clone inside');
expect(options.directoriesOnly).toBe(true);
// A parent that does not exist yet is refused, so the picker has to be able to make it.
expect(options.allowNewFolder).toBe(true);
options.onSelect('/mnt/projects');
expect((doc.getElementById('cloneCasePath') as HTMLInputElement).value).toBe('/mnt/projects');
expect(preview()).toBe('Will clone into: /mnt/projects/bifrost');
await app.cloneCase();
const { body } = await requestBody(fetchMock);
expect(body.path).toBe('/mnt/projects/bifrost');
});
it('asks for the parent folder before cloning when the checkbox is on and the field is empty', async () => {
const { app, doc, fetchMock } = bootClone();
app.showToast = vi.fn();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
await app.cloneCase();
expect(app.showToast).toHaveBeenCalledWith('Choose the folder to clone inside', 'error');
expect(fetchMock).not.toHaveBeenCalled();
});
it('builds no target and sends nothing without a case name', async () => {
const { app, doc, set, preview, fetchMock } = bootClone();
app.showToast = vi.fn();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
set('cloneCasePath', '/mnt/projects');
set('cloneCaseName', '');
app.updateCloneCasePathPreview();
expect(app._cloneTargetPath()).toBe('');
expect(preview()).toBe(
'Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.'
);
await app.cloneCase();
expect(app.showToast).toHaveBeenCalledWith('Please enter a case name', 'error');
expect(fetchMock).not.toHaveBeenCalled();
});
it('refreshes the preview when the preflight replaces the case name', () => {
const { app, doc, set } = bootClone();
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost?tab=readme-ov-file');
set('cloneCaseName', 'bifrost-tab-readme-ov-file');
set('cloneCasePath', '/mnt/projects');
app.updateCloneCasePathPreview();
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe(
'Will clone into: /mnt/projects/bifrost-tab-readme-ov-file'
);
app._applyClonePreflight({ gitAvailable: true, parse: { cloneable: true, suggestedName: 'bifrost' } }, '');
expect((doc.getElementById('cloneCaseName') as HTMLInputElement).value).toBe('bifrost');
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: /mnt/projects/bifrost');
});
it('previews the name taken from the URL as it is typed', () => {
const { app, doc, set, win } = bootClone();
app.isCliAvailable = () => true; // lives in settings-ui.js, which this test does not load
set('cloneCaseName', '');
set('cloneCasePath', '/mnt/projects');
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost');
app.onCloneUrlInput();
win.clearTimeout(app._clonePreflightTimer);
expect((doc.getElementById('cloneCaseName') as HTMLInputElement).value).toBe('bifrost');
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: /mnt/projects/bifrost');
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* @fileoverview The Codex "Allow network access" row (App Settings → Codex) in Chinese (zh-CN).
* That description is the only warning a user gets that the switch weakens Codex's sandbox, so
* it must read in Chinese, through the real translator over the real index.html in JSDOM, with
* no English left beyond product names and the `workspace-write` mode name, and each key must be
* in the dictionary exactly once (a repeated key in the object literal silently replaces the
* first).
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const ROW_INPUT = 'appSettingsCodexNetworkAccess';
type Api = { configure(o: Record<string, string>): void; start(): void };
/** What may stay Latin: the product names, the API acronym and codex's own sandbox mode name. */
const leftover = (text: string) =>
text.replace(/workspace-write|\b(Codex|Codeman|API)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document): { label: string; description: string } {
const row = doc.getElementById(ROW_INPUT)!.closest('.set-row')!;
return {
label: row.querySelector('.set-row-label')!.textContent!.trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
}
const source = new JSDOM(INDEX);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
afterAll(() => {
source.window.close();
dom.window.close();
});
describe('the Allow network access row in zh-CN', () => {
const english = rowStrings(source.window.document);
const chinese = rowStrings(dom.window.document);
it.each(['label', 'description'] as const)('translates the %s, with no English left', (key) => {
expect(english[key].length).toBeGreaterThan(0);
expect(chinese[key]).not.toBe(english[key]);
expect(leftover(chinese[key])).toEqual([]);
});
it('keeps the warning that the setting is close to Bypass', () => {
expect(chinese.description).toContain('绕过审批与沙箱');
});
it('adds each key to the dictionary once', () => {
for (const text of Object.values(english)) {
const quoted = text.includes("'") ? JSON.stringify(text) : `'${text}'`;
expect(I18N.split(`${quoted}:`).length - 1).toBe(1);
}
});
});
+79 -3
View File
@@ -13,10 +13,20 @@
*
* Port: N/A (no server; filesystem-only, under a temp CODEMAN data dir from test/setup.ts).
*/
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
readlinkSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getCli } from '../src/config/cli-registry/index.js';
import { applyCustomModelInjection, customModelConfigDir } from '../src/custom-model-injection-apply.js';
import type { CustomModelEndpoint } from '../src/custom-model-injection.js';
@@ -90,6 +100,72 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
}
});
it('claude: creates the missing projects folder under the temp HOME, never a real one', () => {
// test/setup.ts points HOME at a temp dir and drops CLAUDE_CONFIG_DIR. Without both, the
// apply below would create `projects` in the real `~/.claude` of whoever runs the suite.
expect(homedir().startsWith(tmpdir())).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBeUndefined();
const sessionId = 'sess-cfgdir-default';
sessionsToClean.push(sessionId);
const realProjects = join(homedir(), '.claude', 'projects');
rmSync(realProjects, { recursive: true, force: true });
const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId);
expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(realProjects);
expect(statSync(realProjects).isDirectory()).toBe(true);
});
it('claude: a relative CLAUDE_CONFIG_DIR of the server gets no link, nothing lands under its cwd', () => {
const sessionId = 'sess-cfgdir-relative';
sessionsToClean.push(sessionId);
vi.stubEnv('CLAUDE_CONFIG_DIR', 'relative-server-claude');
try {
const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId);
expect(existsSync(join(applied!.configDir!, 'projects'))).toBe(false);
expect(existsSync('relative-server-claude')).toBe(false);
} finally {
vi.unstubAllEnvs();
}
});
it("claude: links projects from the session's own CLAUDE_CONFIG_DIR, creating it for a new account", () => {
const sessionId = 'sess-cfgdir-own';
sessionsToClean.push(sessionId);
const ownDir = join(homedir(), 'client-account');
const applied = applyCustomModelInjection(
entryOrThrow('claude'),
endpoint,
'qwen3',
sessionId,
undefined,
undefined,
(envVar) => (envVar === 'CLAUDE_CONFIG_DIR' ? ownDir : undefined)
);
expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(join(ownDir, 'projects'));
// A dangling directory link is not inert: the CLI's mkdir through it fails ENOENT.
expect(statSync(join(ownDir, 'projects')).isDirectory()).toBe(true);
rmSync(ownDir, { recursive: true, force: true });
});
it('claude: links projects from the real config dir when the own value is relative or the isolated dir', () => {
for (const own of ['relative/claude', customModelConfigDir('sess-cfgdir-self')]) {
sessionsToClean.push('sess-cfgdir-self');
const applied = applyCustomModelInjection(
entryOrThrow('claude'),
endpoint,
'qwen3',
'sess-cfgdir-self',
undefined,
undefined,
() => own
);
expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(join(homedir(), '.claude', 'projects'));
rmSync(applied!.configDir!, { recursive: true, force: true });
}
});
it('claude: re-applying to the same session is idempotent (boot-recovery re-apply)', () => {
const sessionId = 'sess-cfgdir-3';
sessionsToClean.push(sessionId);
+121 -6
View File
@@ -7,18 +7,18 @@
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
* that clearing could never delete.
*
* Real-home resolution is NOT recovered from the session's env overrides any more:
* `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a
* session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on
* the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the
* recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment.
* Real-home resolution reads the record's `ownEnv` on this path, never the session's
* overrides: the first apply's links are still in the generated directory, and an entry
* that is missing links from the own home, or with none from the server's `DSH_HOME` or
* the recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment. The
* own home itself comes back on clear from `ownEnv`, the last block here.
*
* `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives
* everything from the endpoint store and the Session it is handed), so it is driven off
* the prototype with a minimal receiver: `restoreMuxSessions()` itself cannot be
* reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts.
*/
import { mkdirSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, readlinkSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -99,4 +99,119 @@ describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', ()
expect(session.getCustomModelForPersist()).toEqual(saved);
});
describe("a session's own value across a restart", () => {
const OWN_HOME = '/home/u/my-dsh';
const ORIGINAL_MULTIUSER = process.env.CODEMAN_MULTIUSER;
afterEach(() => {
if (ORIGINAL_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL_MULTIUSER;
});
/** What a previous run left in state.json for a deepseek session on its own home. */
function savedByPreviousRun(): CustomModelBookkeeping {
const previous = new Session({ workingDir: '/tmp', mode: 'deepseek', envOverrides: { DSH_HOME: OWN_HOME } });
configs.push(previous.id);
previous.setCustomModel(
{
endpointId: 'ep',
modelId: 'qwen3',
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
configDir: customModelConfigDir(previous.id),
homeEnvVar: 'DSH_HOME',
},
{ DSH_HOME: customModelConfigDir(previous.id), CODEMAN_CUSTOM_MODEL_API_KEY: 'k' }
);
return JSON.parse(JSON.stringify(previous.getCustomModelForPersist())) as CustomModelBookkeeping;
}
it('gets DSH_HOME back from state.json, which is the only copy left', async () => {
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
configs.push(session.id);
await restoreCustomModel(session, savedByPreviousRun());
expect(session.envOverrideValue('DSH_HOME')).toBe(customModelConfigDir(session.id));
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
});
it('never fills an entry the own home lacks from the server home', async () => {
// The first apply skipped `settings.yaml`, since the own home has none. Recovery finds
// that gap and, with no lookup, used to fill it with the server's settings.
const serverHome = join(homedir(), 'codeman-recovery-server-dsh');
const ownHome = join(homedir(), 'codeman-recovery-own-dsh');
mkdirSync(join(ownHome, 'profiles'), { recursive: true });
mkdirSync(serverHome, { recursive: true });
writeFileSync(join(serverHome, 'settings.yaml'), 'server: true\n');
vi.stubEnv('DSH_HOME', serverHome);
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
configs.push(session.id);
await restoreCustomModel(session, {
endpointId: 'ep',
modelId: 'qwen3',
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
homeEnvVar: 'DSH_HOME',
ownEnv: { DSH_HOME: ownHome },
});
const configDir = customModelConfigDir(session.id);
expect(readlinkSync(join(configDir, 'profiles'))).toBe(join(ownHome, 'profiles'));
expect(existsSync(join(configDir, 'settings.yaml'))).toBe(false);
rmSync(ownHome, { recursive: true, force: true });
rmSync(serverHome, { recursive: true, force: true });
});
it('keeps it when the endpoint is gone', async () => {
await writeCustomModelHosts(getDataDir(), []);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
await restoreCustomModel(session, savedByPreviousRun());
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
});
it('never reads what the previous run injected as the session own value', async () => {
// A record from before `ownEnv` existed. `__envOverrides` holds the isolated
// CLAUDE_CONFIG_DIR and the injected limit, which must go on clear, or the session
// stays pinned to a directory the clear just deleted.
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
envOverrides: { CLAUDE_CONFIG_DIR: '/isolated/old', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' },
});
configs.push(session.id);
await restoreCustomModel(session, {
endpointId: 'ep',
modelId: 'qwen3',
envKeys: ['CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
});
session.setCustomModel(undefined);
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBeUndefined();
expect(session.envOverrideValue('CLAUDE_CODE_MAX_CONTEXT_TOKENS')).toBeUndefined();
});
it('does not give a revoked owner their DSH_HOME back from disk', async () => {
// DSH_HOME is a privileged key: the reboot-restore clamp strips `__envOverrides`, and
// this record must not be a way around it.
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const saved = savedByPreviousRun();
process.env.CODEMAN_MULTIUSER = '1';
const session = new Session({ workingDir: '/tmp', mode: 'deepseek', owner: 'nobody' });
configs.push(session.id);
await restoreCustomModel(session, saved);
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
});
});
});
@@ -14,8 +14,14 @@ vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
});
import { getCli } from '../src/config/cli-registry/registry.js';
import { getDataDir } from '../src/config/instance.js';
import { writeCustomModelHosts } from '../src/custom-model-hosts.js';
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
import { Session } from '../src/session.js';
import { probePathKind } from '../src/utils/bounded-path-probe.js';
import { guardSessionConfigDirHome } from '../src/web/routes/session-routes.js';
import { WebServer } from '../src/web/server.js';
import type { CustomModelBookkeeping } from '../src/types/session.js';
const probe = vi.mocked(probePathKind);
const deepseek = () => getCli('deepseek')!;
@@ -52,6 +58,32 @@ describe('guardSessionConfigDirHome', () => {
expect(warn).not.toHaveBeenCalled();
});
it("guards claude's own config dir too: the apply makes its projects folder", async () => {
probe.mockResolvedValue('unknown');
vi.spyOn(console, 'warn').mockImplementation(() => {});
const lookup = await guardSessionConfigDirHome(getCli('claude')!, env({ CLAUDE_CONFIG_DIR: '/mnt/dead/claude' }));
expect(lookup('CLAUDE_CONFIG_DIR')).toBeUndefined();
expect(probe).toHaveBeenCalledWith('/mnt/dead/claude', { pastCap: true });
});
it('boot recovery probes the own home under the stall cap: it is a bulk pass, not one request', async () => {
probe.mockResolvedValue('unknown');
vi.spyOn(console, 'warn').mockImplementation(() => {});
await writeCustomModelHosts(getDataDir(), [{ id: 'ep', label: 'Ep', baseUrl: 'http://box:8080' }]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
const saved: CustomModelBookkeeping = { endpointId: 'ep', modelId: 'q', envKeys: [] };
const proto = WebServer.prototype as unknown as {
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping, ownEnv: object) => Promise<unknown>;
};
await proto._rebuildCustomModelEnv(session, saved, { DSH_HOME: '/mnt/dead/dsh' });
expect(probe).toHaveBeenCalledWith('/mnt/dead/dsh', {});
removeConfigDir(customModelConfigDir(session.id));
});
it('never probes a relative home: the apply refuses to join one', async () => {
const base = env({ DSH_HOME: 'relative/dsh' });
+9 -2
View File
@@ -218,8 +218,15 @@ describe('adopted container: the host is not required to have the CLI', () => {
expect(src.match(/if \(mode === '[a-z]+' && !cliDir\)/g)).toBeNull();
});
it('derives the flag from the docker metadata the session already carries', () => {
expect(src).toContain('const cliRunsInContainer = !!docker;');
it('derives the flag from the location metadata the session already carries', () => {
// Adoption joined the condition for the same reason docker is in it: an
// adopted session's CLI was started by a human in a process Codeman never
// spawned, so the host binary is irrelevant there too, and demanding it
// would reject adopting a claude that lives in a container, on an ssh host,
// or simply outside the server process's PATH (the systemd/launchd case).
// What the assertion still pins is that the flag comes from the session's
// OWN metadata rather than from anything ambient.
expect(src).toContain('const cliRunsInContainer = !!docker || !!adopt;');
});
});
+641
View File
@@ -0,0 +1,641 @@
/**
* @fileoverview The Docker Compose deployment's first-run path: what
* `docker/Start-Codeman.sh` does on a machine that has never run it.
*
* 1. Preflight: a missing `docker`, a missing or too-old Compose plugin and an
* unreachable daemon each stop the script with the fix named, before any
* question is asked or any file is written. The permission case points at
* the docker group, never at sudo (a root run cannot do the first-run setup).
* 2. Setup: with no `docker/.env`, the script writes one generated FROM
* `.env.example`, so every key the example sets is present. That is the
* exact check the in-app updater runs (`diffRequiredEnvKeys`), and a
* generated file missing a key would block the user's next update. The file
* is 0600, the generated password is alphanumeric (Compose's dotenv
* interpolates `$` and treats ` #` as a comment) and shown only on a
* terminal, and a data folder that would collide with a native install's
* `~/.codeman`, `$HOME` itself or the image build context is refused.
* 3. An existing `docker/.env` is never rewritten: the update path
* (Update-Codeman.sh hands off to this script) must stay byte-for-byte.
* 4. A `.env` still carrying the example password `changeme` is refused before
* anything is built or started.
*
* Runs the REAL script against a stub `docker` on PATH with stdin not a TTY,
* which is also the "no terminal attached, take the defaults" path. The tests
* of the questions themselves run on a real pty.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer, type Server } from 'node:net';
import {
readFileSync,
mkdtempSync,
rmSync,
writeFileSync,
mkdirSync,
statSync,
lstatSync,
existsSync,
symlinkSync,
readdirSync,
} from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { diffRequiredEnvKeys, parseEnvKeys } from '../src/web/self-update.js';
const ROOT = process.cwd();
const startScript = readFileSync(join(ROOT, 'docker/Start-Codeman.sh'), 'utf-8');
const updateScript = readFileSync(join(ROOT, 'docker/Update-Codeman.sh'), 'utf-8');
const example = readFileSync(join(ROOT, 'docker/.env.example'), 'utf-8');
const compose = readFileSync(join(ROOT, 'docker/docker-compose.yaml'), 'utf-8');
/** Absolute, so a run whose PATH deliberately lacks most tools can still start bash. */
const BASH = execFileSync('bash', ['-c', 'command -v bash'], { encoding: 'utf-8' }).trim();
/** The run on a real terminal needs util-linux `script`; BSD's takes different arguments. */
const HAS_PTY = process.platform === 'linux' && spawnSync('script', ['--version']).status === 0;
/** The values the first run fills in; every other line must be the example's. */
const REWRITTEN_KEYS = ['TZ', 'CODEMAN_APPDATA_PATH', 'CODEMAN_CASES_PATH', 'CODEMAN_PORT', 'CODEMAN_PASSWORD'];
/**
* A stub `docker` that logs every invocation and answers the calls the script
* makes. `compose ... config --environment` sources the env file and prints the
* keys the script reads, with the shell environment beating the file as it does
* in Compose. Bash reads a single-quoted value the way Compose's dotenv does
* except for a backslash before the closing quote: see `composeReads`.
*/
const STUB = [
'#!/usr/bin/env bash',
'echo "docker $*" >> "$CMDLOG"',
// Docker CLI 24 to 27 answer `info --format` with exit 0 and the error on
// stderr when the daemon is unreachable (measured on 27.5.1); `version --format`
// exits 1 on every release.
'if [[ "$1" == "info" ]]; then',
' if [[ -n "${STUB_DAEMON_ERR:-}" ]]; then echo "$STUB_DAEMON_ERR" >&2; exit 0; fi',
' echo 27.0.0; exit 0',
'fi',
'if [[ "$1" == "version" ]]; then',
' if [[ -n "${STUB_DAEMON_ERR:-}" ]]; then echo "$STUB_DAEMON_ERR" >&2; exit 1; fi',
' echo 27.0.0; exit 0',
'fi',
'if [[ "$1" == "compose" ]]; then',
' if [[ -n "${STUB_NO_COMPOSE:-}" ]]; then echo "docker: unknown command: docker compose" >&2; exit 1; fi',
' if [[ "$2" == "version" ]]; then',
' if [[ "${3:-}" == "--short" ]]; then echo "${STUB_COMPOSE_VERSION:-2.30.0}"; else echo "Docker Compose version v${STUB_COMPOSE_VERSION:-2.30.0}"; fi',
' exit 0',
' fi',
' prev=""; envfile=""',
' for a in "$@"; do',
' if [[ "$prev" == "--env-file" ]]; then envfile="$a"; fi',
' prev="$a"',
' done',
' if [[ " $* " == *" config "* && " $* " == *" --environment "* ]]; then',
' keys="CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH DOCKER_SOCKET CODEMAN_PORT CODEMAN_USERNAME CODEMAN_PASSWORD"',
' for k in $keys; do if [[ -n "${!k+x}" ]]; then printf -v "shell_$k" %s "${!k}"; fi; done',
' set -a; source "$envfile"; set +a',
' DOCKER_SOCKET="${STUB_DOCKER_SOCKET:-$DOCKER_SOCKET}"',
' for k in $keys; do',
' shell="shell_$k"',
' printf "%s=%s\\n" "$k" "${!shell-${!k}}"',
' done',
' exit 0',
' fi',
' if [[ " $* " == *" config "* && " $* " == *" --format json "* ]]; then printf \'{\\n "name": "codeman"\\n}\\n\'; exit 0; fi',
' if [[ " $* " == *" up "* && -n "${STUB_UP_FAIL:-}" ]]; then echo "network error pulling a layer" >&2; exit 1; fi',
' if [[ " $* " == *" ps -q codeman "* ]]; then echo cid123; exit 0; fi',
' if [[ " $* " == *" port codeman "* ]]; then echo "0.0.0.0:${@: -1}"; exit 0; fi',
' if [[ " $* " == *" logs "* ]]; then echo "FAKE-LOG: server crashed"; exit 0; fi',
' exit 0',
'fi',
'if [[ "$1" == "inspect" ]]; then echo "${STUB_STATE:-running|healthy|0}"; exit 0; fi',
'if [[ "$1" == "exec" ]]; then exit 1; fi',
'exit 0',
].join('\n');
interface Run {
status: number;
stdout: string;
stderr: string;
log: string[];
env: string | null;
envMode: number | null;
home: string;
dir: string;
/** What was in HOME when the run ended; the sandbox is gone by the time a test reads this. */
homeEntries: string[];
/** What `docker/.env` is when the run ended, without following a symlink. */
envKind: 'file' | 'directory' | 'symlink' | null;
/** What a `docker/.env` that is a directory holds. */
envDirEntries: string[];
}
/**
* Lays out `<dir>/repo/docker/{Start-Codeman.sh,.env.example,docker-compose.yaml}`
* plus a stub `docker`, runs the script with a temp HOME and stdin closed (not a
* TTY), and returns what happened. `existingEnv` seeds `docker/.env` first.
*/
function runStart(
args: string[],
opts: {
env?: Record<string, string>;
existingEnv?: string;
/** Makes `docker/.env` something other than a regular file. */
envShape?: 'directory' | 'dangling-symlink' | 'symlink-to-file';
noDocker?: boolean;
/** A real Unix socket to use as DOCKER_SOCKET (the start path checks `-S`). */
socket?: string;
/** Run Update-Codeman.sh (which hands off to Start-Codeman.sh) instead. */
update?: boolean;
/** CODEMAN_APPDATA_PATH preset, built from the sandbox's own paths. */
appdata?: (p: { home: string; repo: string }) => string;
/**
* Keystrokes for a run on a REAL terminal (a pty from util-linux `script`,
* so Linux only), which is the only way to reach the questions: the script
* reads stdin only when `-t 0` holds.
*/
terminal?: string;
} = {}
): Run {
const dir = mkdtempSync(join(tmpdir(), 'codeman-start-setup-'));
try {
const home = join(dir, 'home');
const dockerDir = join(dir, 'repo', 'docker');
mkdirSync(home);
mkdirSync(dockerDir, { recursive: true });
writeFileSync(join(dockerDir, 'Start-Codeman.sh'), startScript);
writeFileSync(join(dockerDir, 'Update-Codeman.sh'), updateScript);
writeFileSync(join(dockerDir, '.env.example'), example);
writeFileSync(join(dockerDir, 'docker-compose.yaml'), compose);
// Hashed by the start path for the updater's fingerprint baseline.
writeFileSync(join(dockerDir, 'server.Dockerfile'), readFileSync(join(ROOT, 'docker/server.Dockerfile')));
if (opts.existingEnv !== undefined) writeFileSync(join(dockerDir, '.env'), opts.existingEnv);
if (opts.envShape === 'directory') mkdirSync(join(dockerDir, '.env'));
if (opts.envShape === 'dangling-symlink') symlinkSync(join(dir, 'no-such-file'), join(dockerDir, '.env'));
if (opts.envShape === 'symlink-to-file') {
writeFileSync(join(dir, 'real.env'), 'CODEMAN_PASSWORD=mine-and-only-mine\n');
symlinkSync(join(dir, 'real.env'), join(dockerDir, '.env'));
}
const binDir = join(dir, 'bin');
mkdirSync(binDir);
let path: string;
if (opts.noDocker) {
// Only what the script runs before its `command -v docker` check, so the
// host's own docker (if any) cannot be found.
symlinkSync(
execFileSync('bash', ['-c', 'command -v dirname'], { encoding: 'utf-8' }).trim(),
join(binDir, 'dirname')
);
path = binDir;
} else {
const stubPath = join(binDir, 'docker');
writeFileSync(stubPath, STUB);
execFileSync('bash', ['-c', `chmod +x '${stubPath}'`]);
path = `${binDir}:${process.env.PATH}`;
}
const logPath = join(dir, 'cmdlog.txt');
writeFileSync(logPath, '');
const env: Record<string, string> = { ...process.env, HOME: home, PATH: path, CMDLOG: logPath } as Record<
string,
string
>;
// A developer shell exporting any of these would change the defaults under test.
for (const k of ['CODEMAN_APPDATA_PATH', 'CODEMAN_PORT', 'CODEMAN_PASSWORD', 'CODEMAN_NONINTERACTIVE'])
delete env[k];
Object.assign(env, opts.env ?? {});
if (opts.appdata) env.CODEMAN_APPDATA_PATH = opts.appdata({ home, repo: join(dir, 'repo') });
if (opts.socket) env.STUB_DOCKER_SOCKET = opts.socket;
const entry = join(dockerDir, opts.update ? 'Update-Codeman.sh' : 'Start-Codeman.sh');
const res =
opts.terminal === undefined
? spawnSync(BASH, [entry, ...args], { env, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] })
: spawnSync('script', ['-qefc', [BASH, entry, ...args].join(' '), '/dev/null'], {
env,
encoding: 'utf-8',
input: opts.terminal,
});
const envPath = join(dockerDir, '.env');
const envEntry = lstatSync(envPath, { throwIfNoEntry: false });
const envKind = !envEntry
? null
: envEntry.isSymbolicLink()
? 'symlink'
: envEntry.isDirectory()
? 'directory'
: 'file';
const hasEnv = envKind === 'file' || (envKind === 'symlink' && existsSync(envPath));
return {
status: res.status ?? 1,
// A terminal turns every line end into CRLF.
stdout: res.stdout.replace(/\r/g, ''),
stderr: res.stderr,
log: readFileSync(logPath, 'utf-8')
.split('\n')
.filter((l) => l.trim()),
env: hasEnv ? readFileSync(envPath, 'utf-8') : null,
envMode: hasEnv ? statSync(envPath).mode & 0o777 : null,
home,
dir,
homeEntries: readdirSync(home),
envKind,
envDirEntries: envKind === 'directory' ? readdirSync(envPath) : [],
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** The single (unquoted or single-quoted) value of KEY in a dotenv text. */
function envValue(text: string, key: string): string | undefined {
const line = text.split('\n').find((l) => l.startsWith(`${key}=`));
if (line === undefined) return undefined;
const raw = line.slice(key.length + 1);
return raw.startsWith("'") && raw.endsWith("'") ? raw.slice(1, -1) : raw;
}
/**
* What Compose reads from a single-quoted dotenv value, or null when it refuses
* the file with `unterminated quoted value`. A backslash there escapes the next
* character, so `'longpass\\'` never closes, while bash (and so the stub) ends
* the same value at that quote. Measured on Compose 2.33.0 and 5.5.0.
*/
function composeReads(quoted: string): string | null {
let escaped = false;
for (let i = 1; i < quoted.length; i++) {
if (quoted[i] === "'" && !escaped) return i === quoted.length - 1 ? quoted.slice(1, i) : null;
escaped = quoted[i] === '\\' && !escaped;
}
return null;
}
describe('Start-Codeman.sh first run (no docker/.env yet)', () => {
it('parses under bash -n', () => {
execFileSync('bash', ['-n', join(ROOT, 'docker/Start-Codeman.sh')]);
});
it('writes docker/.env from .env.example with every key the updater requires, mode 0600', () => {
const r = runStart(['--setup-only']);
expect(r.status, r.stderr).toBe(0);
expect(r.env).not.toBeNull();
const env = r.env as string;
expect(r.envMode).toBe(0o600);
// The in-app updater's own check: no key the example sets may be missing.
expect(diffRequiredEnvKeys(example, env)).toEqual([]);
expect(parseEnvKeys(env)).toEqual(parseEnvKeys(example));
// --setup-only stops before Compose is asked anything about the stack.
expect(r.log.some((l) => / (up|build|config)( |$)/.test(l))).toBe(false);
});
it('changes only the five first-run values and keeps every other line of the example', () => {
const r = runStart(['--setup-only']);
const generated = (r.env as string).split('\n');
const exampleLines = example.split('\n');
// Header comments first, then the example line for line.
const offset = generated.length - exampleLines.length;
expect(offset).toBeGreaterThan(0);
for (let i = 0; i < exampleLines.length; i++) {
const want = exampleLines[i];
const got = generated[i + offset];
const key = want.match(/^([A-Z_][A-Z0-9_]*)=/)?.[1];
if (key && REWRITTEN_KEYS.includes(key)) {
expect(got.startsWith(`${key}=`)).toBe(true);
} else {
expect(got).toBe(want);
}
}
});
it('defaults: a data folder of its own under HOME, cases inside it, a strong alphanumeric password', () => {
const r = runStart(['--setup-only']);
const env = r.env as string;
const appdata = envValue(env, 'CODEMAN_APPDATA_PATH');
expect(appdata).toBe(join(r.home, 'codeman-docker'));
expect(envValue(env, 'CODEMAN_CASES_PATH')).toBe(join(r.home, 'codeman-docker', 'codeman-cases'));
expect(appdata).not.toContain('.codeman');
const password = envValue(env, 'CODEMAN_PASSWORD') as string;
expect(password).toMatch(/^[A-Za-z0-9]{24}$/);
expect(password).not.toBe('changeme');
expect(envValue(env, 'CODEMAN_PORT')).toMatch(/^\d+$/);
expect(envValue(env, 'TZ')).toMatch(/^[A-Za-z0-9_+/-]+$/);
// No terminal here, and a captured log must not hold the password.
expect(r.stdout).not.toContain(password);
expect(r.stdout).toMatch(/generated; read it from docker\/\.env/);
expect(r.stdout).toMatch(/No questions asked/);
});
it('takes presets from the environment and quotes values Compose would otherwise interpolate', () => {
const r = runStart(['--setup-only'], {
env: { CODEMAN_PASSWORD: 'pa$$ #word', CODEMAN_PORT: '4567', CODEMAN_APPDATA_PATH: '/srv/My Data/codeman/' },
});
expect(r.status, r.stderr).toBe(0);
const lines = (r.env as string).split('\n');
expect(lines).toContain("CODEMAN_PASSWORD='pa$$ #word'");
expect(lines).toContain('CODEMAN_PORT=4567');
// Trailing slash dropped, the space kept by quoting.
expect(lines).toContain("CODEMAN_APPDATA_PATH='/srv/My Data/codeman'");
expect(lines).toContain("CODEMAN_CASES_PATH='/srv/My Data/codeman/codeman-cases'");
// A password the user chose is never echoed.
expect(r.stdout).not.toContain('pa$$ #word');
});
it.each([
['HOME itself', ({ home }: { home: string }) => home, /folder of its own/],
['HOME typed as ~', () => '~', /folder of its own/],
['a native install state dir', ({ home }: { home: string }) => join(home, '.codeman'), /installed directly/],
['inside a native install state dir', () => '~/.codeman/docker', /installed directly/],
['a relative path', () => 'codeman-data', /absolute path/],
['the checkout, which is the image build context', ({ repo }: { repo: string }) => repo, /copied into the image/],
['a folder inside the checkout', ({ repo }: { repo: string }) => join(repo, 'data'), /copied into the image/],
// join() would resolve these, which is the whole point: the refusals above compare strings.
['HOME spelled through ..', ({ home }: { home: string }) => `${home}/../home`, /without \. or \.\./],
['the checkout spelled through ..', ({ home }: { home: string }) => `${home}/../repo/data`, /without \. or \.\./],
['a path ending in ..', ({ home }: { home: string }) => `${home}/data/..`, /without \. or \.\./],
['a native install spelled through .', ({ home }: { home: string }) => `${home}/./.codeman`, /without \. or \.\./],
[
'a native install behind a doubled slash',
({ home }: { home: string }) => `${home}//.codeman`,
/installed directly/,
],
['the checkout behind a doubled slash', ({ repo }: { repo: string }) => `${repo}//data`, /copied into the image/],
])('refuses %s as the data folder and writes nothing', (_name, appdata, reason) => {
const r = runStart(['--setup-only'], { appdata });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(reason);
expect(r.env).toBeNull();
});
it.each([
['a single quote', "it's-a-password"],
['the example placeholder', 'changeme'],
['fewer than 8 characters', 'short'],
['a trailing backslash, which would escape the closing quote', 'longpass\\'],
])('refuses a preset password with %s', (_name, password) => {
const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } });
expect(r.status).toBe(1);
expect(r.env).toBeNull();
});
it('refuses a data folder ending in a backslash, even behind a trailing slash', () => {
for (const path of ['/srv/data\\', '/srv/data\\/']) {
const r = runStart(['--setup-only'], { env: { CODEMAN_APPDATA_PATH: path } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/cannot end in a backslash/);
expect(r.env).toBeNull();
}
});
it.each(['mid\\dle-pass', 'pa$$ #word', 'back`tick"quote', 'tab\there'])(
'writes %j so Compose reads it back exactly',
(password) => {
const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } });
expect(r.status, r.stderr).toBe(0);
const line = (r.env as string).split('\n').find((l) => l.startsWith('CODEMAN_PASSWORD=')) as string;
expect(composeReads(line.slice('CODEMAN_PASSWORD='.length))).toBe(password);
}
);
it('refuses a dangling docker/.env symlink instead of replacing the link', () => {
const r = runStart(['--setup-only'], { envShape: 'dangling-symlink' });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/not a regular file/);
expect(r.envKind).toBe('symlink');
});
it('refuses a docker/.env directory instead of writing a password file into it', () => {
const r = runStart(['--setup-only'], { envShape: 'directory' });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/not a regular file/);
expect(r.envKind).toBe('directory');
expect(r.envDirEntries).toEqual([]);
});
it('treats a docker/.env symlink to a real file as an existing install', () => {
const r = runStart(['--setup-only'], { envShape: 'symlink-to-file' });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/already exists/);
expect(r.envKind).toBe('symlink');
});
it('keeps a folder name that merely starts with a dot or dots', () => {
const r = runStart(['--setup-only'], { appdata: ({ home }) => `${home}/..data/.state` });
expect(r.status, r.stderr).toBe(0);
expect(envValue(r.env as string, 'CODEMAN_APPDATA_PATH')).toBe(`${r.home}/..data/.state`);
});
it('never rewrites an existing docker/.env', () => {
const existing = '# hand-written\nCODEMAN_PASSWORD=mine-and-only-mine\nCODEMAN_APPDATA_PATH=/x\n';
const r = runStart(['--setup-only'], { existingEnv: existing });
expect(r.status, r.stderr).toBe(0);
expect(r.env).toBe(existing);
expect(r.stdout).toMatch(/already exists/);
});
});
describe('Start-Codeman.sh preflight', () => {
it('names the docker group (not sudo) when the account cannot reach the daemon', () => {
const r = runStart(['--setup-only'], {
env: {
STUB_DAEMON_ERR: 'permission denied while trying to connect to the docker API at unix:///var/run/docker.sock',
},
});
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/sudo usermod -aG docker /);
expect(r.stderr).toMatch(/log out and back in/);
expect(r.env).toBeNull();
});
it('says to start Docker when the daemon is not running, quoting what Docker said', () => {
const r = runStart(['--setup-only'], {
env: { STUB_DAEMON_ERR: 'failed to connect to the docker API at unix:///var/run/docker.sock' },
});
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/daemon is not reachable/);
expect(r.stderr).toContain('failed to connect to the docker API');
expect(r.env).toBeNull();
});
it('refuses a Compose older than 2.27.2, which has no `config --environment`', () => {
const r = runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: '2.27.0' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Compose 2\.27\.0 is too old; Codeman needs 2\.27\.2 or newer/);
expect(r.env).toBeNull();
});
it('accepts newer Compose majors (v5 here) and a v-prefixed version', () => {
expect(runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: '5.5.0' } }).status).toBe(0);
expect(runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: 'v2.27.2' } }).status).toBe(0);
});
it('explains a missing Compose plugin', () => {
const r = runStart(['--setup-only'], { env: { STUB_NO_COMPOSE: '1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Compose v2 plugin is missing/);
});
it('explains a missing docker command', () => {
const r = runStart(['--setup-only'], { noDocker: true });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Docker is not installed/);
});
});
describe('Start-Codeman.sh on an existing install', () => {
it('refuses to start while CODEMAN_PASSWORD is still `changeme`, before building anything', () => {
const existing = example; // a straight copy of the example, never edited
const r = runStart([], { existingEnv: existing });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/still the example value "changeme"/);
expect(r.log.some((l) => / (up|build|down)( |$)/.test(l))).toBe(false);
expect(r.env).toBe(existing);
});
it('Update-Codeman.sh refuses `changeme` BEFORE its build and `down`, so the stack is never left stopped', () => {
// An existing appdata dir, so the check this is about is the one reached.
const existing = example.replace(/^CODEMAN_APPDATA_PATH=.*$/m, `CODEMAN_APPDATA_PATH=${tmpdir()}`);
const r = runStart([], { existingEnv: existing, update: true });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/still the example value "changeme"/);
expect(r.stderr).toMatch(/Nothing was stopped/);
expect(r.log.some((l) => / (build|down|up)( |$)/.test(l))).toBe(false);
});
it('rejects an unrecognised argument and prints usage for --help', () => {
expect(runStart(['--bogus']).status).toBe(1);
const help = runStart(['--help']);
expect(help.status).toBe(0);
expect(help.stdout).toMatch(/--setup-only/);
});
});
/**
* The whole start path against the stub: no `docker/.env`, so setup, then
* `up`, the readiness wait and the summary. Needs a real Unix socket for the
* `DOCKER_SOCKET` check, which Windows cannot provide reliably (see the note in
* docker-entrypoint.test.ts), so it runs on Linux and macOS only.
*/
describe.skipIf(process.platform === 'win32')('Start-Codeman.sh first run, start to summary', () => {
let sockDir = '';
let sockPath = '';
let server: Server | null = null;
beforeAll(async () => {
sockDir = mkdtempSync(join(tmpdir(), 'codeman-start-sock-'));
sockPath = join(sockDir, 'docker.sock');
server = createServer();
await new Promise<void>((resolve) => server!.listen(sockPath, resolve));
});
afterAll(async () => {
await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
rmSync(sockDir, { recursive: true, force: true });
});
it('ends on the URL, where the password is and the log/stop commands once the container is healthy', () => {
const r = runStart([], { socket: sockPath, env: { CODEMAN_PORT: '4321' } });
expect(r.status, r.stderr).toBe(0);
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
expect(r.stdout).toMatch(/Waiting for Codeman to answer\.\.\. ready\./);
expect(r.stdout).toContain('http://localhost:4321');
// Stdout is captured here, so docker/.env is the only place the password appears.
expect(r.stdout).not.toContain(password);
expect(r.stdout).toMatch(/Sign in +admin, with the CODEMAN_PASSWORD from .*docker\/\.env/);
expect(r.stdout).toMatch(/Logs +cd .* && docker compose logs -f codeman/);
expect(r.stdout).toMatch(/Stop +cd .* && docker compose down/);
// Rerunning alone rebuilds the same checkout: nothing here fetches.
expect(r.stdout).toMatch(/Update +App Settings > Updates in the dashboard, or git pull and rerun this script/);
const up = r.log.findIndex((l) => / up --build -d/.test(l));
expect(up).toBeGreaterThan(-1);
expect(r.log.findIndex((l) => l.startsWith('docker inspect'))).toBeGreaterThan(up);
});
it('runs on the port docker/.env holds, not on the preset the shell still exports', () => {
const r = runStart([], { socket: sockPath, env: { CODEMAN_PORT: '04444' } });
expect(r.status, r.stderr).toBe(0);
expect(envValue(r.env as string, 'CODEMAN_PORT')).toBe('4444');
expect(r.stdout).toContain('http://localhost:4444');
expect(r.stdout).not.toContain('04444');
});
it.skipIf(!HAS_PTY)('a typed answer beats a preset for the stack itself, not just the file', () => {
const r = runStart([], {
socket: sockPath,
appdata: ({ home }) => join(home, 'preset-data'),
env: { CODEMAN_PORT: '4444' },
terminal: '~/typed-data\n5555\n\n',
});
expect(r.status, r.stdout).toBe(0);
const env = r.env as string;
expect(envValue(env, 'CODEMAN_APPDATA_PATH')).toBe(join(r.home, 'typed-data'));
expect(envValue(env, 'CODEMAN_PORT')).toBe('5555');
expect(r.stdout).toContain('http://localhost:5555');
expect(r.homeEntries).toContain('typed-data');
expect(r.homeEntries).not.toContain('preset-data');
});
it.skipIf(!HAS_PTY)('shows a generated password twice at a terminal', () => {
const r = runStart([], { socket: sockPath, terminal: '\n\n\n' });
expect(r.status, r.stdout).toBe(0);
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
// Again at the end, since the build output has scrolled the first one away.
expect(r.stdout.split(password).length - 1).toBe(2);
});
it.skipIf(!HAS_PTY)('promises no second showing after --setup-only', () => {
const r = runStart(['--setup-only'], { terminal: '\n\n\n' });
expect(r.status, r.stdout).toBe(0);
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
expect(r.stdout.split(password).length - 1).toBe(1);
expect(r.stdout).toMatch(/it stays in docker\/\.env/);
expect(r.stdout).not.toMatch(/shown again/);
});
it('reports a container that keeps restarting, with its last log lines, and exits 1', () => {
const r = runStart([], { socket: sockPath, env: { STUB_STATE: 'restarting||1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/did not come up \(container restarting\)/);
expect(r.stderr).toContain('FAKE-LOG: server crashed');
expect(r.stdout).not.toMatch(/http:\/\/localhost/);
});
it('names the next step when `docker compose up` fails', () => {
const r = runStart([], { socket: sockPath, env: { STUB_UP_FAIL: '1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/`docker compose up --build` failed/);
expect(r.stderr).toMatch(/rerunning this script resumes/);
});
it('--no-wait prints the summary without waiting on the container', () => {
const r = runStart(['--no-wait'], { socket: sockPath, env: { STUB_STATE: 'running|starting|0' } });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/--no-wait given/);
expect(r.log.some((l) => l.startsWith('docker inspect'))).toBe(false);
});
});
describe('version_older_than', () => {
const cases: Array<[string, string, boolean]> = [
['2.27.0', '2.27.2', true],
['2.27.1', '2.27.2', true],
['2.27.2', '2.27.2', false],
['2.28.0', '2.27.2', false],
['2.9.0', '2.27.2', true],
['5.5.0', '2.27.2', false],
['1.29.2', '2.27.2', true],
['', '2.27.2', false],
['dev', '2.27.2', false],
['2.27.2-desktop.1', '2.27.2', false],
];
it.each(cases)('%s older than %s: %s', (have, need, older) => {
const script = [
'set -euo pipefail',
`eval "$(sed -n '/^version_older_than() {/,/^}/p' "$1")"`,
'if version_older_than "$2" "$3"; then echo yes; else echo no; fi',
].join('\n');
const out = execFileSync('bash', ['-c', script, '_', join(ROOT, 'docker/Start-Codeman.sh'), have, need], {
encoding: 'utf-8',
}).trim();
expect(out).toBe(older ? 'yes' : 'no');
});
});
+2 -2
View File
@@ -51,8 +51,8 @@ function setup(budget?: number, base = root) {
let current = at('convA');
const sync = new EditHistorySync({
transcriptPath: async () => (existsSync(current) ? current : null),
readSources: readClaudeEditSources,
probe: async (paths) => paths.map(() => null),
readSources: (_session, path, requests, budget, opts) => readClaudeEditSources(path, requests, budget, opts),
probe: async (_session, paths) => paths.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir: join(base, 'store'), debounceMs: 10 });
opened.push(store);
+2 -2
View File
@@ -79,7 +79,7 @@ describe('edit history image probe on an unresponsive mount', () => {
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: async (_path, requests, budget): Promise<EditSources> => {
readSources: async (_session, _path, requests, budget): Promise<EditSources> => {
const req = requests.find((r) => r.relPath === 'main');
const start = Math.min(req?.offset ?? 0, main.length);
const len = Math.min(budget, main.length - start);
@@ -89,7 +89,7 @@ describe('edit history image probe on an unresponsive mount', () => {
metas: {},
};
},
probe: (paths) => probeLocalPaths(paths),
probe: (_session, paths) => probeLocalPaths(paths),
loadStore: (id) => EditHistoryStore.load(id, { dir: stores, debounceMs: 0 }),
deleteStore: (id) => EditHistoryStore.delete(id, { dir: stores }),
sweepStores: async () => 0,
+84 -9
View File
@@ -22,6 +22,7 @@ import {
REMOVED,
type EditHistorySession,
type EditHistorySyncDeps,
remoteTranscriptIds,
unsupportedReason,
} from '../src/web/edit-history-sync.js';
@@ -129,13 +130,21 @@ function setup(over: Partial<EditHistorySyncDeps> = {}) {
const lookups: string[] = [];
/** Session ids in the order their stores were loaded. */
const loads: string[] = [];
/** The session each read (and, as `probe:<id>`, each probe) was made for. */
const readers: string[] = [];
const deps: EditHistorySyncDeps = {
transcriptPath: async (session) => {
lookups.push(session.id);
return host.path;
},
readSources: (path, req, budget, opts) => host.read(path, req, budget, opts),
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
readSources: (session, path, req, budget, opts) => {
readers.push(session.id);
return host.read(path, req, budget, opts);
},
probe: async (session, paths) => {
readers.push(`probe:${session.id}`);
return paths.map((p) => probes.get(p) ?? null);
},
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
@@ -148,7 +157,7 @@ function setup(over: Partial<EditHistorySyncDeps> = {}) {
opened.push(store);
return store;
};
return { host, probes, lookups, loads, deps, sync: new EditHistorySync(deps) };
return { host, probes, lookups, loads, readers, deps, sync: new EditHistorySync(deps) };
}
const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
@@ -160,14 +169,25 @@ const remote: EditHistorySession = {
};
describe('unsupportedReason', () => {
it('accepts a local claude session, rejects remote, docker and non-claude CLIs', () => {
it('accepts a local or remote claude session, rejects docker and non-claude CLIs', () => {
expect(unsupportedReason(local)).toBeUndefined();
expect(unsupportedReason(remote)).toBe('remote');
expect(unsupportedReason(remote)).toBeUndefined();
expect(unsupportedReason({ ...remote, docker: { image: 'x' } })).toBe('docker');
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
expect(unsupportedReason({ ...local, adopt: { location: 'local' } })).toBe('adopted');
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
});
});
describe('remoteTranscriptIds', () => {
it('includes the stored conversation besides the launch id: no hook tells Codeman of a remote /clear', () => {
expect(remoteTranscriptIds(remote, 'conv2')).toEqual(['conv2', 'conv1', 's2']);
expect(remoteTranscriptIds(remote, 'conv1')).toEqual(['conv1', 's2']);
expect(remoteTranscriptIds(remote, null)).toEqual(['conv1', 's2']);
expect(remoteTranscriptIds({ ...remote, claudeSessionId: null }, null)).toEqual(['s2']);
});
});
describe('EditHistorySync', () => {
it('reads new edits forward from the stored offset', async () => {
const { host, sync } = setup();
@@ -230,11 +250,11 @@ describe('EditHistorySync', () => {
});
it('a removal during a read stops it: no further parsing, no probe of printed paths', async () => {
const probe = vi.fn(async (paths: readonly string[]) => paths.map(() => null));
const probe = vi.fn(async (_session: unknown, paths: readonly string[]) => paths.map(() => null));
let removal: Promise<void> | undefined;
const ctx = setup({
probe,
readSources: async (path, req, budget, opts) => {
readSources: async (_session, path, req, budget, opts) => {
const res = await ctx.host.read(path, req, budget, opts);
removal = ctx.sync.remove('s1'); // the session is deleted while this read is out
return res;
@@ -285,16 +305,61 @@ describe('EditHistorySync', () => {
expect((await sync.getStore('s1')).list()).toHaveLength(1);
});
it('marks remote, docker and non-claude sessions unsupported without looking for a transcript', async () => {
it('marks docker and non-claude sessions unsupported without looking for a transcript', async () => {
const { host, lookups, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync({ ...local, mode: 'shell' })).toEqual({ state: 'unsupported', reason: 'mode' });
expect(await sync.sync(remote)).toEqual({ state: 'unsupported', reason: 'remote' });
expect(await sync.sync({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
expect(lookups).toEqual([]);
expect(host.calls).toEqual([]);
});
it('reads a remote session through the same deps, each call with that session (ssh is the server’s part)', async () => {
const { host, lookups, readers, probes, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0 + 1000, T0 + 3000, 'render out.png'));
probes.set('/w/out.png', { realPath: '/w/out.png', kind: 'file', size: 9, mtimeMs: T0 + 2000 });
expect(await sync.sync(remote)).toEqual({ state: 'ok' });
expect(lookups).toEqual(['s2']);
expect(readers).toEqual(['s2', 'probe:s2']);
expect((await sync.getStore('s2')).list().map((e) => e.path)).toEqual(['/w/out.png', '/w/a.ts']);
});
it('hands the transcript lookup the conversation the stored history holds', async () => {
const stored: Array<string | null> = [];
const { host, sync } = setup({
transcriptPath: async (_session, storedTranscriptId) => {
stored.push(storedTranscriptId);
return host.path;
},
});
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(remote);
host.transcriptId = 'conv2';
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
await sync.sync(remote);
await sync.sync(remote);
expect(stored).toEqual([null, 'conv1', 'conv2']);
});
it('an unreachable remote host is unavailable with the stored entries kept, and the next sync reads on', async () => {
const { host, sync, deps } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(remote);
const transcriptPath = deps.transcriptPath;
deps.transcriptPath = async () => {
throw new Error('remote host huff unreachable: ssh: connect to host huff port 22: Connection timed out');
};
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
expect(await sync.sync(remote)).toEqual({
state: 'unavailable',
reason: 'remote host huff unreachable: ssh: connect to host huff port 22: Connection timed out',
});
expect((await sync.getStore('s2')).list()).toHaveLength(1);
deps.transcriptPath = transcriptPath;
expect(await sync.sync(remote)).toEqual({ state: 'ok' });
expect((await sync.getStore('s2')).list()).toHaveLength(2);
});
it('runs one sync per session at a time and folds occasions into one more run', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
@@ -670,6 +735,16 @@ describe('reads only on request (wiring guard)', () => {
expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/);
});
it('a remote session looks its transcript up with the stored conversation; a local one keeps the hook path', () => {
const server = read('src/web/server.ts');
const start = server.indexOf('new EditHistorySync({');
const lookup = server.slice(start, server.indexOf('readSources:', start));
expect(lookup).toMatch(
/session\.remote\s*\?\s*remoteLocateClaudeTranscript\(\s*session\.remote,\s*remoteTranscriptIds\(session, storedTranscriptId\)/
);
expect(lookup).toMatch(/resolveClaudeTranscript\(\s*this\.transcriptWatchers\.get\(session\.id\)\?\.getPath\(\)/);
});
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
const root = join(__dirname, '..');
const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' })
+48 -3
View File
@@ -379,6 +379,33 @@ describe('Edit history panel in a real browser', () => {
state.set(sessionA, s);
});
it('a remote session lists its images without a thumbnail (no ssh read per row); a click still previews', async () => {
const thumbs = () => page.locator('#editHistoryBody img.edit-history-thumb').count();
expect(await thumbs()).toBe(1);
await page.evaluate((sid) => {
const w = window as any;
w.app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1', username: 'j', remotePath: '/' };
w.__previewed = [];
w.__openFilePreview = w.app.openFilePreview;
w.app.openFilePreview = (p: string, s: string) => w.__previewed.push([p, s]);
}, sessionA);
try {
await reload();
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).waitFor();
expect(await thumbs()).toBe(0);
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).click();
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([[join(root, 'shot.png'), sessionA]]);
} finally {
await page.evaluate((sid) => {
const w = window as any;
delete w.app.sessions.get(sid).remote;
w.app.openFilePreview = w.__openFilePreview;
}, sessionA);
await reload();
}
await page.locator('#editHistoryBody img.edit-history-thumb').waitFor();
});
it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => {
const s = state.get(sessionA)!;
const long =
@@ -445,14 +472,32 @@ describe('Edit history panel in a real browser', () => {
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
expect(note).toContain('No transcript found for this session yet');
expect(note).toContain('CLAUDE_CONFIG_DIR');
state.set(sessionA, s);
await reload();
// A remote host sends no hooks: no promise that the next prompt finds it.
await page.evaluate((sid) => {
(window as any).app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1' };
}, sessionA);
try {
await reload();
const remoteNote = page.locator('#editHistoryBody .edit-history-note--warn', {
hasText: 'No transcript found on the remote host yet',
});
await remoteNote.waitFor();
const text = (await remoteNote.textContent()) ?? '';
expect(text).not.toContain('next prompt');
expect(text).toContain('.bash_profile');
} finally {
await page.evaluate((sid) => delete (window as any).app.sessions.get(sid).remote, sessionA);
state.set(sessionA, s);
await reload();
}
});
it('follows the active session, and says when a session is not a Claude session', async () => {
await select(sessionB);
await page.waitForFunction(() =>
/only available for local Claude sessions/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
/only available for Claude sessions outside Docker/.test(
document.getElementById('editHistoryBody')!.textContent ?? ''
)
);
expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`);
expect(await rows().count()).toBe(0);
+168
View File
@@ -0,0 +1,168 @@
/**
* @fileoverview The Files panel's row for a folder the scan stopped short of.
*
* `GET .../files` gives a directory its node before reading it, so a folder still queued when the
* budget ends, or one that did not answer, has `children: []` or a partial list
* (test/routes/file-search-mode.test.ts, test/routes/file-walk-bounds.test.ts). Without
* `incomplete` that reads as an empty folder. What can go wrong here is the client: the flag lost
* on the way in, the row drawn for a collapsed or finished folder, or the click wiring treating it
* as a file.
*
* panels-ui.js loads via `vm` as in file-browser-worktree.test.ts, over a real DOM, since the row
* has to survive `innerHTML` and the `.file-tree-item` selector. Translation goes through the real
* i18n.js. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const dom = new JSDOM('<div id="fileBrowserTree"></div><div id="fileBrowserPanel" class="visible"></div>');
afterAll(() => dom.window.close());
const document = dom.window.document;
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
function loadPanel(expanded: string[] = []) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
CodemanBase: { url: (p: string) => p },
});
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app.activeSessionId = 'sess-1';
app.fileBrowserExpandedDirs = new Set(expanded);
app.getFileIcon = () => '';
app.formatFileSize = () => '';
app.openFilePreview = vi.fn();
return app;
}
const bulk = {
name: 'a-bulk',
path: 'a-bulk',
type: 'directory',
incomplete: true,
children: [{ name: 'one.txt', path: 'a-bulk/one.txt', type: 'file', size: 1, extension: 'txt' }],
};
const later = {
name: 'z-later',
path: 'z-later',
type: 'directory',
children: [{ name: 'needle.ts', path: 'z-later/needle.ts', type: 'file', size: 1, extension: 'ts' }],
};
const envelope = (tree: unknown[]) => ({
success: true,
data: { root: '/repo', tree, totalFiles: 2, totalDirectories: 2, truncated: true },
});
const render = (app: Record<string, any>, tree: unknown[]) => {
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(envelope(tree));
app.renderFileBrowserTree('sess-1');
return [...treeEl().querySelectorAll('.file-tree-incomplete')] as HTMLElement[];
};
describe('Files panel, a folder the scan stopped short of', () => {
it('keeps the flag through the envelope check, which ignores what it does not know', () => {
const app = loadPanel();
const data = app._validateFileBrowserTreeEnvelope(envelope([bulk, later]));
expect(data.tree[0].incomplete).toBe(true);
// An old tab on a new server, and a new tab on an old server: neither is refused.
expect(app._validateFileBrowserTreeEnvelope(envelope([later]))).not.toBeNull();
});
it('draws one row under an expanded incomplete folder, after what was read', () => {
const rows = render(loadPanel(['a-bulk']), [bulk, later]);
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toBe('Not fully scanned');
expect(rows[0].dataset.depth).toBe('1');
const items = [...treeEl().children].map((el) => (el as HTMLElement).dataset.path ?? 'ROW');
expect(items).toEqual(['a-bulk', 'a-bulk/one.txt', 'ROW', 'z-later']);
});
it('draws it for a folder nothing was read from, which would otherwise look empty', () => {
const unread = { name: 'b-unread', path: 'b-unread', type: 'directory', incomplete: true, children: [] };
const rows = render(loadPanel(['b-unread']), [unread]);
expect(rows).toHaveLength(1);
expect(treeEl().querySelectorAll('.file-tree-item')).toHaveLength(1);
});
it('indents the row one level under a nested folder', () => {
const nested = { name: 'outer', path: 'outer', type: 'directory', children: [bulk] };
const rows = render(loadPanel(['outer', 'a-bulk']), [nested]);
expect(rows[0].dataset.depth).toBe('2');
});
it('draws nothing while the folder is collapsed', () => {
expect(render(loadPanel(), [bulk, later])).toHaveLength(0);
});
it('draws nothing under an expanded folder that was finished', () => {
expect(render(loadPanel(['z-later']), [bulk, later])).toHaveLength(0);
});
it('accepts only a real true, so a stray truthy value draws nothing', () => {
const odd = { ...bulk, incomplete: 'false' };
expect(render(loadPanel(['a-bulk']), [odd])).toHaveLength(0);
});
it('is inert: a click neither opens nor toggles anything', () => {
const app = loadPanel(['a-bulk']);
const toggle = vi.spyOn(app, 'toggleFileBrowserFolder');
const [row] = render(app, [bulk, later]);
row.click();
expect(row.matches('.file-tree-item')).toBe(false);
expect(row.dataset.path).toBeUndefined();
expect(app.openFilePreview).not.toHaveBeenCalled();
expect(toggle).not.toHaveBeenCalled();
// The file beside it still opens, so the click wiring did run.
(treeEl().querySelector('[data-path="a-bulk/one.txt"]') as HTMLElement).click();
expect(app.openFilePreview).toHaveBeenCalledWith('a-bulk/one.txt', 'sess-1');
});
it('is indented by the same steps as the rows around it', () => {
for (let depth = 1; depth <= 9; depth++) {
expect(STYLES).toContain(
`.file-tree-item[data-depth="${depth}"], .file-tree-incomplete[data-depth="${depth}"] {`
);
}
});
it('reads in Chinese, through the string the row really shows', () => {
const [row] = render(loadPanel(['a-bulk']), [bulk]);
const i18n = new JSDOM('<html></html>', { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, i18n.getInternalVMContext(), { filename: 'i18n.js' });
const api = (
i18n.window as unknown as {
CodemanI18n: { t(s: string): string; configure(o: Record<string, string>): void; start(): void };
}
).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const translated = api.t(row.textContent as string);
expect(translated).not.toBe(row.textContent);
expect(translated).not.toMatch(/[A-Za-z]/);
i18n.window.close();
});
});
+16 -16
View File
@@ -387,7 +387,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(1);
expect(pending).toHaveLength(1);
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs');
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live');
});
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
@@ -399,7 +399,7 @@ describe('File Viewer server search', () => {
app.activeSessionId = 'session/B';
app.filterFileBrowser('after-session');
await vi.advanceTimersByTimeAsync(250);
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session');
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live');
pending[0].reply.resolve(response(successfulData()));
await vi.advanceTimersByTimeAsync(0);
@@ -450,7 +450,7 @@ describe('File Viewer server search', () => {
expect(pending).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live',
]);
});
@@ -659,7 +659,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(1);
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true);
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live`)).toBe(true);
});
it('rejects 257 trimmed characters without a request', async () => {
@@ -786,7 +786,7 @@ describe('File Viewer server search', () => {
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live');
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
await toggle;
@@ -794,7 +794,7 @@ describe('File Viewer server search', () => {
expect(app._fileBrowserState.view).toBe('search-pending');
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
await vi.advanceTimersByTimeAsync(250);
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live');
pending[2].reply.resolve(
response(
successfulData({
@@ -877,8 +877,8 @@ describe('File Viewer server search', () => {
const firstToggle = app.toggleFileBrowserHidden();
const secondToggle = app.toggleFileBrowserHidden();
expect(pending.slice(1).map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=true',
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
]);
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
@@ -891,7 +891,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(4);
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live');
expect(app.fileBrowserShowHidden).toBe(false);
});
@@ -1674,7 +1674,7 @@ describe('File Viewer server search', () => {
expect(immediateState.activeSessionId).toBe('session/B');
expect(immediateState.ownerSessionId).toBe('session/B');
expect(immediateState.resizeCalls).toBe(1);
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
expect(filePending).toHaveLength(1);
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
@@ -1755,7 +1755,7 @@ describe('File Viewer server search', () => {
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(1);
@@ -1797,8 +1797,8 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live',
]);
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
});
@@ -1815,9 +1815,9 @@ describe('File Viewer server search', () => {
expect(secondA).not.toBe(firstA);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
]);
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
+231
View File
@@ -0,0 +1,231 @@
/**
* @fileoverview Files panel following the agent into a git worktree (#765).
*
* The server does the walking, so what can go wrong here is the client: asking for the wrong root,
* showing a strip that does not describe the tree on screen, or reloading forever. The last one is
* why the follow compares with what the listing reported rather than with the previous update.
*
* Loaded via `vm` with a stubbed context, like file-browser-hidden.test.ts.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const WORKTREE = { name: 'probe', path: '/repo/.claude/worktrees/probe' };
function element() {
const classes = new Set<string>();
return {
innerHTML: '',
textContent: '',
title: '',
hidden: true,
value: '',
classList: {
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
contains: (name: string) => classes.has(name),
},
setAttribute: () => {},
querySelectorAll: () => [],
};
}
function loadPanel() {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
fetch: undefined as unknown,
CodemanBase: { url: (p: string) => p },
});
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const ids = [
'fileBrowserPanel',
'fileBrowserTree',
'fileBrowserStatus',
'fileBrowserHiddenBtn',
'fileBrowserSearch',
'fileBrowserRoot',
'fileBrowserRootLabel',
'fileBrowserRootName',
'fileBrowserRootSwitch',
];
const elements = Object.fromEntries(ids.map((id) => [id, element()]));
elements.fileBrowserPanel.classList.toggle('visible', true);
const app = new CodemanApp();
app.$ = (id: string) => elements[id] ?? null;
app.activeSessionId = 'sess-1';
app.fileBrowserData = null;
app.fileBrowserExpandedDirs = new Set<string>();
app.fileBrowserFilter = '';
app.fileBrowserShowHidden = false;
app.getFileIcon = () => '';
app.formatFileSize = () => '';
// A stand-in for the route: the session's worktree, listed when the request asks for it.
const server = { worktree: null as typeof WORKTREE | null, requests: [] as string[] };
context.fetch = async (url: string) => {
server.requests.push(url);
const live = url.includes('root=live') && server.worktree;
const data = {
root: live ? server.worktree!.path : '/repo',
...(server.worktree && { worktree: server.worktree }),
tree: [],
totalFiles: 0,
totalDirectories: 0,
truncated: false,
...(url.includes('q=') && { mode: 'search', matches: [], matchCount: 0 }),
};
return { ok: true, json: async () => ({ success: true, data }) };
};
return { app, elements, server };
}
const strip = (elements: Record<string, ReturnType<typeof element>>) => ({
hidden: elements.fileBrowserRoot.hidden,
label: elements.fileBrowserRootLabel.textContent,
name: elements.fileBrowserRootName.textContent,
button: elements.fileBrowserRootSwitch.textContent,
title: elements.fileBrowserRoot.title,
});
describe('Files panel in a git worktree', () => {
it('asks for the live root by default', async () => {
const { app, server } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(server.requests[0]).toContain('root=live');
});
it('shows no strip for a session in no worktree', async () => {
const { app, elements } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(elements.fileBrowserRoot.hidden).toBe(true);
});
it('says which worktree is listed, and offers the session folder', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
expect(strip(elements)).toEqual({
hidden: false,
label: 'Worktree',
name: 'probe',
button: 'Session folder',
title: WORKTREE.path,
});
});
it('switches to the session folder and back', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
expect(server.requests[1]).not.toContain('root=live');
expect(strip(elements)).toEqual({
hidden: false,
label: 'Session folder',
name: '',
button: 'Back to worktree',
title: '/repo',
});
await app.toggleFileBrowserRoot();
expect(server.requests[2]).toContain('root=live');
expect(elements.fileBrowserRootName.textContent).toBe('probe');
});
it('keeps the query in the box across the switch, and searches the new root', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
app._fileBrowserState.filter = 'main';
elements.fileBrowserSearch.value = 'main';
await app.toggleFileBrowserRoot();
await vi.waitFor(() => expect(server.requests.some((url) => url.includes('q=main'))).toBe(true));
const search = server.requests.find((url) => url.includes('q=main'))!;
expect(search).not.toContain('root=live');
});
it('follows the agent into a worktree while the panel is open', async () => {
const { app, elements, server } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(server.requests).toHaveLength(1);
server.worktree = WORKTREE;
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
await vi.waitFor(() => expect(server.requests).toHaveLength(2));
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(false));
expect(elements.fileBrowserRootName.textContent).toBe('probe');
});
it('follows the agent back out, and a pin on the old worktree does not outlive it', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
server.worktree = null;
app._followFileBrowserWorktree({ id: 'sess-1' });
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(true));
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
});
it('settles: updates that agree with the listing reload nothing', async () => {
const { app, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
expect(server.requests).toHaveLength(1);
});
it('leaves another session and a closed panel alone', async () => {
const { app, elements, server } = loadPanel();
await app.loadFileBrowser('sess-1');
app._followFileBrowserWorktree({ id: 'other', liveWorktree: WORKTREE });
elements.fileBrowserPanel.classList.toggle('visible', false);
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
expect(server.requests).toHaveLength(1);
});
it('drops the pin and the strip on a session switch', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
app._activateFileBrowserSession('sess-2');
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
expect(elements.fileBrowserRoot.hidden).toBe(true);
});
it('gives the switch button a name before the first listing fills it in', () => {
expect(indexHtml).toMatch(/id="fileBrowserRootSwitch"[^>]*>Session folder<\/button>/);
});
it('is wired up in the page', () => {
expect(appJs).toContain('this._followFileBrowserWorktree?.(session)');
expect(indexHtml).toContain('id="fileBrowserRoot" hidden');
expect(indexHtml).toContain('onclick="app.toggleFileBrowserRoot()"');
// The strip sets `display`, so `hidden` needs its own rule or it never hides.
expect(stylesCss).toMatch(/\.file-browser-root\[hidden\]\s*\{\s*display: none;/);
});
});
+14
View File
@@ -151,6 +151,7 @@ function loadApp(prefs: Record<string, string> = {}) {
app.filePreviewContent = '';
app._renderMarkdown = vi.fn(() => MARKDOWN_HTML);
app._linkifyFilePaths = vi.fn();
app._applyBlockDirection = vi.fn();
app._bindResponseViewerInteractions = vi.fn();
const byId = (id: string) => document.getElementById(id) as HTMLButtonElement;
@@ -177,6 +178,11 @@ describe('file viewer rendered markdown', () => {
expect(app._renderMarkdown).toHaveBeenCalledWith(MD_CONTENT, { breaks: false });
// Identity, not deep equality: DOM nodes are compared by reference here.
expect(app._linkifyFilePaths.mock.calls[0][0]).toBe(doc);
// After the linkifier: a dir="auto" block re-resolves on every insertion under it (#715).
expect(app._applyBlockDirection.mock.calls[0][0]).toBe(doc);
expect(app._applyBlockDirection.mock.invocationCallOrder[0]).toBeGreaterThan(
app._linkifyFilePaths.mock.invocationCallOrder[0]
);
expect(app._bindResponseViewerInteractions.mock.calls[0][0]).toBe(body);
// The source stays what Copy copies.
expect(app.filePreviewContent).toBe(MD_CONTENT);
@@ -463,6 +469,14 @@ describe('file viewer markdown line breaks', () => {
expect(chatPara.querySelectorAll('br')).toHaveLength(1);
expect(chatPara.textContent).toBe(WRAPPED.replace('\n', ''));
});
it('lets a document read right to left through the shipped viewer (#715)', async () => {
const app = loadShippingApp('سلام، این یک تست است.\n');
await app.openFilePreview('docs/README.md', 's1');
expect(document.querySelector('#filePreviewBody .file-preview-md p')?.getAttribute('dir')).toBe('auto');
});
});
describe('FILE_PREVIEW_EXTENSIONS', () => {
+260
View File
@@ -0,0 +1,260 @@
/**
* @fileoverview The adoption guards that are not reachable over HTTP: the
* boot-time workspace-hook sweep, the workspace-trust auto-accept, the new-shell
* clear and the direct-PTY fallback.
*
* Each acts on a session's WORKSPACE or its PANE, each runs unattended, and each
* previously keyed off `session.remote`, the mode or nothing at all, none of which
* sees an adopted session, whose connection facts live on `adopt.remote` /
* `adopt.docker`. So a tab wrapping someone's claude read as a plain local
* claude: Codeman wrote `.claude/settings.local.json` into their repo on every
* restart, and answered a trust dialog that was theirs to answer.
*
* Each one is paired with a session Codeman DID start: without that control,
* "nothing was written" is equally explained by a disabled setting or a loop
* that never ran.
*
* Port: none (no server is listened on).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const applyWorkspaceHooks = vi.fn(async () => {});
vi.mock('../src/hooks-config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/hooks-config.js')>();
return { ...actual, applyWorkspaceHooks: (...args: unknown[]) => applyWorkspaceHooks(...(args as [])) };
});
// The direct-PTY fallback must never run the real `claude` or a real login shell from a
// test, and the spawn is the one thing that proves whether it happened at all.
const ptySpawn = vi.fn();
vi.mock('node-pty', async (importOriginal) => {
const real = await importOriginal<typeof import('node-pty')>();
return {
...real,
spawn: (file: string, args: string[] | string, options: import('node-pty').IPtyForkOptions) => {
ptySpawn(file, args);
return real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options);
},
};
});
const { WebServer } = await import('../src/web/server.js');
const { Session } = await import('../src/session.js');
type AnySession = Record<string, unknown>;
/** The minimum a session needs to reach the sweep's `workspaces` set. */
function fakeSession(over: AnySession): AnySession {
return { mode: 'claude', workingDir: '/srv/app', isAdopted: false, ...over };
}
describe('the boot workspace-hook sweep', () => {
let server: InstanceType<typeof WebServer>;
beforeEach(() => {
applyWorkspaceHooks.mockClear();
server = new WebServer(0);
(server as unknown as { getWorkspaceHooksEnabled: () => Promise<boolean> }).getWorkspaceHooksEnabled = async () =>
true;
});
afterEach(async () => {
await server.stop().catch(() => {});
});
const sweep = async (sessions: AnySession[]) => {
const map = new Map<string, AnySession>();
sessions.forEach((s, i) => map.set(`s${i}`, s));
(server as unknown as { sessions: Map<string, AnySession> }).sessions = map;
await (
server as unknown as { ensureHooksForRecoveredWorkspaces: () => Promise<void> }
).ensureHooksForRecoveredWorkspaces();
return applyWorkspaceHooks.mock.calls.map((c) => c[0]);
};
it('installs hooks into a workspace Codeman owns', async () => {
expect(await sweep([fakeSession({ workingDir: '/srv/ours' })])).toContain('/srv/ours');
});
it('skips an adopted session even though its mode is claude and `remote` is unset', async () => {
// This is the exact shape the bug had: the ssh/docker facts hang off
// `adopt`, so the pre-existing `session.remote` check sees nothing.
const adopted = fakeSession({
workingDir: '/home/them/repo',
isAdopted: true,
adopt: { location: 'remote', remote: { host: 'box' } },
});
expect(await sweep([adopted])).not.toContain('/home/them/repo');
});
it('still skips a plain remote session, the older rule is untouched', async () => {
expect(await sweep([fakeSession({ workingDir: '/srv/far', remote: { host: 'box' } })])).not.toContain('/srv/far');
});
});
describe('the workspace-trust auto-accept', () => {
const makeSession = (adopt?: Record<string, unknown>) =>
new Session({
id: adopt ? 'adopt-trust' : 'own-trust',
name: 't',
workingDir: '/tmp',
mode: 'claude',
...(adopt ? { adopt } : {}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const scan = (s: InstanceType<typeof Session>) => {
const priv = s as unknown as {
_interactiveStartedAt: number;
_trustDialogAccepted: boolean;
_maybeAcceptTrustDialog: () => void;
_trustDialogTimer: unknown;
};
priv._interactiveStartedAt = Date.now(); // inside the 90s startup window
priv._trustDialogAccepted = false;
priv._maybeAcceptTrustDialog();
return priv;
};
it('arms the scan for a session Codeman started', async () => {
const s = makeSession();
const priv = scan(s);
expect(priv._trustDialogAccepted).toBe(false);
await s.stop(true).catch(() => {});
});
it('never answers a dialog in an adopted pane', async () => {
// That pane holds SOMEONE ELSE'S claude. And boot recovery re-opens the 90s
// window on every restart, so this is not a one-shot at adoption time.
const s = makeSession({
location: 'local',
socketPath: '/tmp/tmux-0/default',
targetSession: 'work',
viewSession: '',
});
const priv = scan(s);
expect(priv._trustDialogAccepted).toBe(true); // short-circuited, never scans again
expect(priv._trustDialogTimer).toBeFalsy();
await s.stop(true).catch(() => {});
});
});
describe('the new-shell clear', () => {
/** What a shell session writes to its pane once the mux attach is up. */
const bytesWrittenAfterStart = async (adopt?: Record<string, unknown>) => {
const s = new Session({
id: adopt ? 'adopt-shell' : 'own-shell',
name: 't',
workingDir: '/tmp',
mode: 'shell',
useMux: true,
mux: {},
...(adopt ? { adopt } : {}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const write = vi.fn();
const disposable = () => ({ dispose() {} });
const priv = s as unknown as Record<string, unknown>;
priv._setupOrAttachMuxSession = async () => {
priv.ptyProcess = { write, onData: disposable, onExit: disposable, kill() {}, pid: 1 };
return { isRestored: false, respawnedDeadPane: false };
};
await s.startShell();
await new Promise((resolve) => setTimeout(resolve, 200));
priv.ptyProcess = null;
await s.stop(true).catch(() => {});
return write.mock.calls.map((c) => c[0]);
};
it('clears a shell Codeman started', async () => {
expect(await bytesWrittenAfterStart()).toEqual(['clear\n']);
});
it('types nothing into an adopted pane', async () => {
// ⚠️ That pane is someone else's, and `shell` is also the answer for anything the
// classifier cannot name (vim, psql, a `read` prompt). `clear` plus a newline landed
// on a half-typed command or in whatever was reading stdin, on every adoption.
const adopt = { location: 'local', socketPath: '/tmp/tmux-0/default', targetSession: 'work', viewSession: '' };
expect(await bytesWrittenAfterStart(adopt)).toEqual([]);
});
});
describe('the direct-PTY fallback', () => {
const adopt = { location: 'remote', socketPath: '/tmp/tmux-0/default', targetSession: 'work', viewSession: '' };
/** A wrapper that cannot start: `createSession` threw (a loaded tmux server timing out, say). */
const session = (mode: 'claude' | 'shell', opts: { adopt?: typeof adopt; useMux?: boolean } = {}) => {
const s = new Session({
name: 'work',
workingDir: '/tmp',
mode,
useMux: opts.useMux ?? true,
mux: {},
...(opts.adopt ? { adopt: opts.adopt } : {}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const errors: string[] = [];
s.on('error', (message: string) => errors.push(message));
const priv = s as unknown as Record<string, unknown>;
priv._setupOrAttachMuxSession = async () => {
throw new Error('tmux new-session timed out');
};
return { s, priv, errors };
};
beforeEach(() => ptySpawn.mockClear());
it('still catches a session Codeman started, whose fallback is a real PTY', async () => {
// The control: without it, "nothing spawned" below is equally explained by a mock
// that never sees a spawn. A shell, because a claude fallback also probes the real binary.
const { s } = session('shell');
await s.startShell();
expect(ptySpawn).toHaveBeenCalledTimes(1);
await s.stop(true).catch(() => {});
});
it.each([
['claude', 'startInteractive'],
['shell', 'startShell'],
] as const)('never starts a local %s for an adopted session (%s)', async (mode, start) => {
// ⚠️ The tab still says it is someone else's session. A fallback here runs
// `claude --dangerously-skip-permissions` or a server-account shell in `/tmp` on THIS
// host, so whoever opened an ssh or container session ends up typing into this one.
const { s, errors } = session(mode, { adopt });
await expect(s[start]()).rejects.toThrow(/tmux new-session timed out/);
expect(ptySpawn).not.toHaveBeenCalled();
expect(s.pid).toBeNull();
expect(s.status).toBe('stopped');
// The browser's re-attach does not read the POST's answer, so this is what the tab shows.
expect(errors.join()).toMatch(/work/);
});
it('leaves the wrapper to be tried again', async () => {
const { s, priv } = session('shell', { adopt });
await expect(s.startShell()).rejects.toThrow();
const disposable = () => ({ dispose() {} });
priv._setupOrAttachMuxSession = async () => {
priv.ptyProcess = { write: vi.fn(), onData: disposable, onExit: disposable, kill() {}, pid: 1 };
return { isRestored: true, respawnedDeadPane: false };
};
await s.startShell();
expect(s.pid).toBe(1);
expect(ptySpawn).not.toHaveBeenCalled();
priv.ptyProcess = null;
await s.stop(true).catch(() => {});
});
it.each([
['claude', 'startInteractive'],
['shell', 'startShell'],
] as const)('never starts a local %s for an adopted session with no mux at all (%s)', async (mode, start) => {
const { s } = session(mode, { adopt, useMux: false });
await expect(s[start]()).rejects.toThrow(/tmux/);
expect(ptySpawn).not.toHaveBeenCalled();
});
it('refuses a one-shot prompt, a new local claude rather than the agent in that pane', async () => {
const { s } = session('claude', { adopt });
await expect(s.runPrompt('hello')).rejects.toThrow(/adopted/i);
expect(ptySpawn).not.toHaveBeenCalled();
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* @fileoverview The strings of the outside-sessions block in Chinese (zh-CN).
*
* Harvested from the real module rather than listed here, so a new string without a
* dictionary entry fails this test instead of showing up English on one surface. The
* block's row text carries data-i18n-skip (a session name and a command line are
* someone else's), so its location words only translate through the module's own
* foreignT() call, and the DOM translator would never reach them.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const MODULE = readFileSync(resolve(PUBLIC, 'foreign-sessions.js'), 'utf8');
type Api = { configure(o: Record<string, string>): void; t(source: string): string };
/** Everything the module sends through foreignT, plus the toggle's two literal labels. */
const FROM_MODULE = [...MODULE.matchAll(/foreignT\('([^']+)'\)/g)].map((m) => m[1]);
const LOCATION_WORDS = ['this machine', 'container', 'remote'];
const TOGGLE = ['⟳ all', '⟳ local'];
const ELSEWHERE = [
'Close Tab',
'Detaches only. The tmux session keeps running for whoever started it.',
'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.',
'Git status is not available for a tmux session opened outside Codeman.',
];
const ALL = [...FROM_MODULE, ...LOCATION_WORDS, ...TOGGLE, ...ELSEWHERE];
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.configure({ language: 'zh-CN' });
afterAll(() => {
dom.window.close();
});
describe('the outside-sessions block in zh-CN', () => {
it('harvests the module (the check is not vacuous)', () => {
expect(FROM_MODULE).toContain('Opened outside Codeman');
expect(FROM_MODULE).toContain('Go to tab');
expect(FROM_MODULE.length).toBeGreaterThanOrEqual(10);
});
it('wraps every location word, whose row the DOM translator skips', () => {
expect(MODULE).toContain('foreignT(FOREIGN_LOCATION_LABEL[row.location] || row.location)');
for (const word of LOCATION_WORDS) expect(MODULE).toContain(`: '${word}'`);
});
it.each(ALL)('translates %s', (source) => {
const chinese = api.t(source);
expect(chinese).not.toBe(source);
expect(chinese.replace(/\b(Codeman|tmux|Git)\b/g, '').match(/[A-Za-z]+/g) ?? []).toEqual([]);
});
it('adds each key to the dictionary once', () => {
// Prettier leaves a key that is one word unquoted.
for (const source of new Set(ALL)) {
const bare = /^\w+$/.test(source) ? I18N.split(`\n ${source}: `).length - 1 : 0;
expect(I18N.split(`'${source}':`).length - 1 + bare, source).toBe(1);
}
});
});
+330
View File
@@ -0,0 +1,330 @@
/**
* @fileoverview The home screen's foreign-session block: the wide scan is
* one-shot, and an adoption failure says what the server said.
*
* Containers and ssh hosts cost a `docker exec` or a full ssh handshake each,
* with a ~12s timeout. Riding them along on the 8s poll stacked connections on
* a slow host, so the wide scan now runs only when the user asks. That makes a
* second property load-bearing: the periodic poll's payload cannot contain
* those rows, so they are kept apart and merged: assert only "the request was
* right" and the real failure (the next tick wiping the container rows off the
* list) sails through.
*
* Port: none.
*/
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
const SRC = readFileSync(fileURLToPath(new URL('../src/web/public/foreign-sessions.js', import.meta.url)), 'utf8');
function stubEl(): Record<string, unknown> {
const el: Record<string, unknown> = {
children: [] as unknown[],
dataset: {},
disabled: false,
textContent: '',
title: '',
type: '',
className: '',
hidden: false,
classList: { add() {}, remove() {}, contains: () => false },
appendChild(c: unknown) {
(el.children as unknown[]).push(c);
return c;
},
setAttribute() {},
};
Object.defineProperty(el, 'innerHTML', { get: () => '', set: () => void (el.children = []) });
return el;
}
/** The module is a classic script that mixes into CodemanApp.prototype. */
function loadApp(window: Record<string, unknown> = {}, page: Record<string, unknown> = {}) {
const host = stubEl();
const ctx: Record<string, unknown> = {
console,
Date,
Number,
Array,
Object,
JSON,
Math,
String,
setInterval: () => 1,
clearInterval() {},
codemanT: null,
window,
document: Object.assign(page, {
createElement: () => stubEl(),
getElementById: (id: string) => (id === 'foreignSessions' ? host : null),
addEventListener() {},
removeEventListener() {},
...page,
}),
CodemanApp: function () {} as unknown,
};
vm.createContext(ctx);
vm.runInContext(SRC, ctx);
const Ctor = ctx.CodemanApp as { prototype: object };
return Object.create(Ctor.prototype) as Record<string, unknown> & {
toggleForeignScanRemote(): void;
rescanForeignWide(): void;
loadForeignSessions(o?: { wide?: boolean }): Promise<void>;
adoptForeignSession(id: string, el?: unknown): Promise<void>;
_allForeignRows(): Array<{ id: string }>;
renderAllForeignSessions(): void;
startForeignPolling(): void;
stopForeignPolling(): void;
_foreignPollTick(): void;
renderForeignSessions(el: unknown): void;
_foreignRow(row: object): { children: Array<{ children: Array<{ textContent: string }> }> };
};
}
/** A response as `_api` hands it over, the server's envelope included. */
function ok(data: unknown) {
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}
const LOCAL = { id: 'L1', location: 'local', sessionName: 'work', mode: 'claude' };
const DOCKER = { id: 'D1', location: 'docker', sessionName: 'inbox', mode: 'codex' };
describe('the wide scan is one-shot', () => {
function wired() {
const app = loadApp();
const calls: string[] = [];
app._api = vi.fn(async (path: string) => {
calls.push(path);
return ok(
path.includes('docker=1')
? {
sessions: [LOCAL, DOCKER],
notes: ['docker box: not running, no tmux, or engine unreachable'],
canScanWide: true,
}
: { sessions: [LOCAL], notes: ['local: 1 session(s) skipped'], canScanWide: true }
);
});
return { app, calls };
}
it('fetches containers and hosts once when the toggle goes on', async () => {
const { app, calls } = wired();
app.toggleForeignScanRemote();
// Wait on the STATE, not on the call count: the stub pushes its path before
// it resolves, so a call-count wait returns while the rows are still unset.
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
expect(calls[0]).toContain('docker=1');
expect(calls[0]).toContain('remote=1');
expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1', 'D1']);
});
it('keeps the periodic poll local-only, and the container row survives it', async () => {
// Both halves matter. The first is the cost fix; the second is what makes it
// usable: a local-only payload has no container rows in it.
const { app, calls } = wired();
app.toggleForeignScanRemote();
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
await app.loadForeignSessions(); // the interval's call shape
expect(calls[1]).toBe('/api/mux/foreign');
expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1', 'D1']);
});
it('keeps a container or host note across the local polls, and refreshes the local ones', async () => {
// Only a wide scan can re-read a container's note, so a local poll wiping it
// left "unreachable" gone while its stale rows stayed on the list.
const { app } = wired();
app.toggleForeignScanRemote();
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
await app.loadForeignSessions();
expect(app._foreignWideNotes).toEqual(['docker box: not running, no tmux, or engine unreachable']);
expect(app._foreignNotes).toEqual(['local: 1 session(s) skipped']);
});
it('discards the wide rows when the toggle goes off', async () => {
// Nothing re-confirms them, so leaving them up would show a container
// session that may be long gone.
const { app, calls } = wired();
app.toggleForeignScanRemote();
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
app.toggleForeignScanRemote();
expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1']);
});
it('Rescan re-runs the wide scan, and does nothing while the toggle is off', async () => {
const { app, calls } = wired();
app.rescanForeignWide();
expect(calls).toHaveLength(0);
app.toggleForeignScanRemote();
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
app.rescanForeignWide();
await vi.waitFor(() => expect(calls).toHaveLength(2));
expect(calls[1]).toContain('docker=1');
});
});
describe('a hidden tab does not sweep tmux sockets', () => {
// One scan forks once per socket and pane, and a tab left on the home screen in the
// background has nobody reading the list: 772 forks a tick was measured on a real box.
function harness(hidden: boolean) {
const listeners = new Set<() => void>();
const page: Record<string, unknown> = {
hidden,
addEventListener(type: string, listener: () => void) {
if (type === 'visibilitychange') listeners.add(listener);
},
removeEventListener(type: string, listener: () => void) {
if (type === 'visibilitychange') listeners.delete(listener);
},
};
const app = loadApp({}, page);
const calls: string[] = [];
app._api = vi.fn(async (path: string) => {
calls.push(path);
return ok({ sessions: [LOCAL], notes: [], canScanWide: false });
});
return { app, calls, page, listeners };
}
it('makes no first load for a home screen that opened in the background', () => {
const { app, calls } = harness(true);
app.startForeignPolling();
expect(calls).toHaveLength(0);
});
it('skips the tick while hidden and runs it again once visible', () => {
const { app, calls, page } = harness(false);
app.startForeignPolling();
expect(calls).toHaveLength(1);
page.hidden = true;
app._foreignPollTick();
expect(calls).toHaveLength(1);
page.hidden = false;
app._foreignPollTick();
expect(calls).toHaveLength(2);
});
it('catches up the moment the tab comes back, without waiting out the interval', () => {
const { app, calls, page, listeners } = harness(true);
app.startForeignPolling();
expect(listeners.size).toBe(1);
page.hidden = false;
for (const listener of listeners) listener();
expect(calls).toEqual(['/api/mux/foreign']);
});
it('does not scan on the visibility event that hides the tab', () => {
const { app, calls, page, listeners } = harness(false);
app.startForeignPolling();
expect(listeners.size).toBe(1);
page.hidden = true;
for (const listener of listeners) listener();
expect(calls).toHaveLength(1);
});
it('holds one listener over repeated home screens and none after leaving', () => {
// showWelcome/hideWelcome run this on every trip, unlike a poller created once.
const { app, listeners } = harness(false);
for (let trip = 0; trip < 3; trip++) {
app.startForeignPolling();
app.startForeignPolling();
expect(listeners.size).toBe(1);
app.stopForeignPolling();
expect(listeners.size).toBe(0);
}
});
});
describe('an adoption failure reports what the server said', () => {
function adoptHarness(status: number, body: unknown) {
const app = loadApp();
const toasts: string[] = [];
app.showToast = (msg: string) => void toasts.push(msg);
app._api = vi.fn(async () => ({ ok: status < 400, status, json: async () => body }));
app._onSessionCreated = vi.fn();
app.selectSession = vi.fn(async () => {});
return { app, toasts };
}
it('shows "could not reach it" verbatim instead of "that session is gone"', async () => {
// The server deliberately tells two different stories. `_apiJson` folds a
// non-2xx to null, which collapsed both into one message and reported a
// live remote session as deleted every time the link flickered.
const msg = 'Could not reach it just now (ssh box: timeout). It may still be running, try again.';
const { app, toasts } = adoptHarness(404, { success: false, error: msg, errorCode: 'NOT_FOUND' });
await app.adoptForeignSession('cand-1');
expect(toasts[0]).toBe(msg);
});
it('still shows the gone message when that is what the server said', async () => {
const msg = 'That tmux session is no longer there. Refresh the list and try again.';
const { app, toasts } = adoptHarness(404, { success: false, error: msg, errorCode: 'NOT_FOUND' });
await app.adoptForeignSession('cand-1');
expect(toasts[0]).toBe(msg);
});
it('falls back to its own wording when the server sent no readable body', async () => {
const app = loadApp();
const toasts: string[] = [];
app.showToast = (msg: string) => void toasts.push(msg);
app._api = vi.fn(async () => null); // network failure
await app.adoptForeignSession('cand-1');
expect(toasts[0]).toMatch(/Could not open that session/);
});
it('goes through the app’s normal create path on success', async () => {
const session = { id: 'new-1' };
const { app } = adoptHarness(200, { success: true, data: { session } });
await app.adoptForeignSession('cand-1');
expect(app._onSessionCreated).toHaveBeenCalledWith(session);
expect(app.selectSession).toHaveBeenCalledWith('new-1');
});
});
describe('a non-admin in multi-user mode', () => {
// The list is admin-only, so every poll answers 403. That used to read as a failed
// poll: a "Could not reach the server" block, and a request every eight seconds.
it('stops asking and shows no block at all', async () => {
const app = loadApp();
const calls: string[] = [];
app._api = vi.fn(async (path: string) => {
calls.push(path);
return { ok: false, status: 403, json: async () => ({ success: false, errorCode: 'FORBIDDEN' }) };
});
await app.loadForeignSessions();
const box = stubEl();
app.renderForeignSessions(box);
expect(box.hidden).toBe(true);
expect(box.children).toEqual([]);
await app.loadForeignSessions();
app.startForeignPolling();
expect(calls).toHaveLength(1);
});
it('still reads any other failure as a failed poll', async () => {
const app = loadApp();
app._api = vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ success: false }) }));
await app.loadForeignSessions();
expect(app._foreignError).toBe(true);
expect(app._foreignRefused).toBeFalsy();
});
});
describe('a row names its CLI in the registry\u2019s own word', () => {
it('has no list of CLI ids to fall behind: omp and copilot read like the rest', () => {
const app = loadApp({ __codemanCliCatalog: [{ id: 'copilot', label: 'GitHub Copilot' }] });
const sub = (mode: string) =>
app._foreignRow({ sessionName: 'x', mode, location: 'local' }).children[1].children[1].textContent;
expect(sub('copilot')).toBe('GitHub Copilot \u00b7 this machine');
// Absent from the catalog: the id itself, never a blank row.
expect(sub('newcli')).toBe('newcli \u00b7 this machine');
});
});
File diff suppressed because it is too large. Load diff
@@ -36,6 +36,8 @@ const SCANNED_FILES = [
// The command palette's new-session agent picker reads its agents from the
// injected catalog; panels-ui.js carries no id branch, so keep it that way.
'panels-ui.js',
// A row's mode comes from the same catalog, never from a list of ids here.
'foreign-sessions.js',
];
/**
+2
View File
@@ -263,6 +263,8 @@ describe('who runs', () => {
const h = harness([
VIEW('ssh', { remote: { host: 'x' } }),
VIEW('docker', { docker: { container: 'c' } }),
// A wrapper around a foreign tmux session sits on the neutral `/tmp`, no repository of its own.
VIEW('adopted', { adopt: { location: 'local' } }),
VIEW('local'),
]);
await h.scheduler.tick(0);
+3 -1
View File
@@ -154,6 +154,8 @@ describe('Grok mode gates', () => {
expect(defaultDockerCommandForMode('grok')).toBe('exec grok');
// Routed through an interactive login shell so ~/.grok/bin resolves,
// the same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
expect(defaultRemoteCommandForMode('grok')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'grok\'');
expect(defaultRemoteCommandForMode('grok')).toBe(
'{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'grok\'; }'
);
});
});
+3 -1
View File
@@ -229,7 +229,9 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
expect(app).toContain(
'const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));'
);
expect(app).toContain('!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;');
expect(app).toContain(
'!session?.remote && !session?.docker && !session?.adopt && this._paneHistoryLines?.get(sessionId) === 0;'
);
// The shell's bounded load is tmux's rendered history, never the byte recording.
expect(app).toContain('`/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`');
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
+164
View File
@@ -1162,6 +1162,18 @@ describe('install.sh no-admin route', () => {
expect(existsSync(`${app}.new`)).toBe(false);
expect(existsSync(`${app}.old`)).toBe(false);
// A trailing slash on the setting must not put the staging folder inside the install.
const slashed = drive(script, {
CODEMAN_INSTALL_DIR: `${app}/`,
CODEMAN_TARBALL_URL: `file://${first}`,
HOME: scratch,
});
expect(slashed.status, slashed.stderr).toBe(0);
expect(slashed.stdout).toContain(`staging=[${app}.new] cwd=${app}.new`);
expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('1.0.0');
expect(existsSync(`${app}.new`)).toBe(false);
expect(existsSync(`${app}.old`)).toBe(false);
// A folder that is neither a checkout nor one of ours is never written into.
const foreign = join(scratch, 'foreign');
mkdirSync(foreign);
@@ -1301,4 +1313,156 @@ describe('install.sh no-admin route', () => {
expect(rm).toBeGreaterThan(-1);
expect(body.slice(body.lastIndexOf('if [[', rm), rm)).toContain('== "$RUNTIME_DIR/"*');
});
it('says the rights were not confirmed, and names the way back to sudo, when it chose this route itself', () => {
// A sudoers entry cannot be read without a probe that reports to the administrator, so an
// account listed by name, with a password, lands on this route too. It must not be told it
// may not use sudo, and every place that ends or sums up the route must name the override.
const stops = `
detect_arch() { echo x64; }
download() { echo "DOWNLOAD $1"; }; download_to_stdout() { echo "DOWNLOAD $1"; }
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 0; }
missing_build_tools() { printf 'make'; }; is_musl_libc() { return 1; }
ts_cmd() { return 1; }; check_tailscale() { return 1; }
CLI_IDS=(); TS_STATE=""; MISSING_PKGS=""; EXISTING_FOUND=""; EXISTING_VERSION=""
section() { echo "== $1"; }
NONINTERACTIVE=1
section blocker; check_build_tools() { return 1; }; (ask_dependencies_no_admin linux) 2>&1 || true
check_build_tools() { return 0; }
section declined; prompt_yes_no() { return 1; }; (ask_dependencies_no_admin linux) 2>&1 || true
section ensure_sudo; (ensure_sudo) 2>&1 || true
section operator; (ensure_tailscale_operator) 2>&1 || true
section summary; (print_preflight_summary linux x64 ubuntu) 2>&1
NONINTERACTIVE=0
section tailscale; (offer_install_tailscale) 2>&1 || true
`;
const sections = (stdout: string) =>
Object.fromEntries(
stdout
.split(/^== /m)
.slice(1)
.map((part) => [part.slice(0, part.indexOf('\n')), part])
);
const detected = drive(`
[[ $EUID -ne 0 ]] || { echo "skipped=root"; exit 0; }
uname() { echo Linux; }
command() { if [[ "$1" == "-v" && "$2" == "sudo" ]]; then return 0; else builtin command "$@"; fi; }
id() { echo users; }
sudo() { return 1; }
NO_ADMIN=""; resolve_admin_mode
echo "route=$NO_ADMIN reason=$NO_ADMIN_REASON"
${stops}
`);
expect(detected.status, detected.stderr).toBe(0);
if (detected.stdout.includes('skipped=root')) return;
expect(detected.stdout).toMatch(/route=1 reason=.*could not confirm/);
expect(detected.stdout).not.toContain('may not use sudo');
const parts = sections(detected.stdout);
for (const name of ['blocker', 'declined', 'ensure_sudo', 'operator', 'summary', 'tailscale']) {
expect(parts[name], `${name}: ${detected.stdout}`).toContain('CODEMAN_NO_ADMIN=0');
}
// Asked for, or sudo really tried: there is no way back to name.
for (const reason of ['--no-admin', 'sudo is not installed', 'sudo was refused']) {
const other = drive(`
uname() { echo Linux; }
NO_ADMIN=1; NO_ADMIN_REASON="${reason}"
${stops}
`);
expect(other.status, other.stderr).toBe(0);
expect(other.stdout, reason).not.toContain('CODEMAN_NO_ADMIN=0');
}
});
it('proves a downloaded tmux and cloudflared in the home folder, not in the temp folder', () => {
// /tmp is commonly mounted noexec on the shared hosts this route is for. The stand-in
// below refuses to run from TMPDIR the way such a mount does (126, not a shell error).
const home = mkdtempSync(join(tmpdir(), 'codeman-noexec-'));
try {
const noexec = join(home, 'noexec-tmp');
const fake = join(home, 'fake');
mkdirSync(noexec);
mkdirSync(fake);
for (const tool of ['tmux', 'cloudflared']) {
writeFileSync(join(fake, tool), `#!/bin/sh\ncase "$0" in "${noexec}"/*) exit 126 ;; esac\necho ok\n`, {
mode: 0o755,
});
}
const tarball = join(home, 'tmux.tar.gz');
const tar = spawnSync('tar', ['-czf', tarball, '-C', fake, 'tmux']);
expect(tar.status, String(tar.stderr)).toBe(0);
const result = drive(
`
run_step() { shift; "$@"; }
ensure_on_path() { :; }
tmux_build_sha256() { sha256_file "$DRIVE_FAKE/../tmux.tar.gz"; }
download() { case "$1" in *tmux*) cp "$DRIVE_FAKE/../tmux.tar.gz" "$2" ;; *) cp "$DRIVE_FAKE/cloudflared" "$2" ;; esac; }
(install_tmux_userspace linux x64) && echo "tmux=installed"
(install_cloudflared_userspace linux x64) && echo "cloudflared=installed"
ls -A "$RUNTIME_DIR" | tr '\n' ' '; echo
ls -A "$HOME/.local" | tr '\n' ' '; echo
ls -A "$HOME/.local/bin" | tr '\n' ' '; echo
`,
{ HOME: home, TMPDIR: noexec, DRIVE_FAKE: fake }
);
expect(result.stderr).not.toContain('does not run');
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('tmux=installed');
expect(result.stdout).toContain('cloudflared=installed');
expect(existsSync(join(home, '.codeman/runtime/bin/tmux'))).toBe(true);
expect(existsSync(join(home, '.local/bin/cloudflared'))).toBe(true);
// Nothing left beside them: the listings hold the final names and nothing else.
const [runtime, local, bin] = result.stdout.trim().split('\n').slice(-3);
expect(runtime.trim()).toBe('bin');
expect(local.trim()).toBe('bin');
expect(bin.trim().split(' ').sort()).toEqual(['cloudflared', 'tmux']);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it('survives an environment with no USER', () => {
// docker run -u gives a process no USER, and set -u turns a message that prints it into a
// crash. The no-admin route reaches several such lines, and such a container is its first home.
const result = drive(
`
uname() { echo Linux; }
NO_ADMIN=1; NO_ADMIN_REASON=test; NONINTERACTIVE=0
ts_cmd() { return 1; }; check_tailscale() { return 1; }
(ensure_tailscale_operator) 2>&1 || true
(offer_install_tailscale) 2>&1 || true
BIND_HOST=0.0.0.0; BIND_PASSWORD=""
(print_security_notice) 2>&1
`,
{ USER: undefined }
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout + result.stderr).not.toContain('unbound variable');
expect(result.stdout.match(/--operator=\S/g)?.length).toBe(2);
});
it('drops a trailing slash from CODEMAN_INSTALL_DIR', () => {
// "$INSTALL_DIR.new" must sit beside the install, and app/.new sits inside it.
const dir = (value: string) => drive('echo "$INSTALL_DIR"', { CODEMAN_INSTALL_DIR: value }).stdout.trim();
expect(dir('/x/app/')).toBe('/x/app');
expect(dir('/x/app//')).toBe('/x/app');
expect(dir('/x/app')).toBe('/x/app');
expect(dir('/')).toBe('/');
});
it('warns that removing the data directory takes a kept runtime with it', () => {
// The runtime lives inside ~/.codeman, so the yes to the data directory removes what the
// user chose to keep one prompt earlier. Read from the source, never driven: uninstall()
// calls `systemctl --user`, which HOME does not redirect, so a drive stops and disables
// the real codeman-web of whoever runs this test.
const body = fn('uninstall', '\nusage() {');
const prompt = body.indexOf('Remove data directory ($data_dir)?');
expect(prompt).toBeGreaterThan(-1);
const note = body.lastIndexOf('[[ -d "$RUNTIME_DIR" ]]', prompt);
expect(note, 'the kept runtime is not looked at before that prompt').toBeGreaterThan(
body.indexOf('Kept $RUNTIME_DIR')
);
expect(body.slice(note, prompt)).toMatch(/data_note=" .*\$RUNTIME_DIR/);
expect(body.slice(prompt)).toMatch(/^Remove data directory \(\$data_dir\)\?\$data_note"/);
});
});
+198
View File
@@ -0,0 +1,198 @@
/**
* @fileoverview Every writer of the linked-cases registry is serialized, atomic, and refuses to
* overwrite a file that does not parse (`mutateLinkedCases()`, src/web/linked-cases-writer.ts). Real
* filesystem under test/setup.ts's temp HOME. Port: N/A (app.inject()).
*
* The CI flake this fixes was "a name linked while the clone ran is a 409" in
* test/routes/case-clone-custom-folder.test.ts: two clones claiming one name both re-read the
* registry before either wrote it, so both answered success and the later write replaced the
* earlier entry. Writes to the registry (and its temp files) are slowed down here, so two writers
* that both read before either writes lose an entry every time instead of once in a while.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
readlinkSync,
realpathSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { createRouteTestHarness } from './routes/_route-test-utils.js';
import { registerCaseRoutes } from '../src/web/routes/case-routes.js';
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../src/web/linked-cases-writer.js';
import { isSandboxedHome, safeRmHomeTree } from './mocks/index.js';
const LINKED = () => linkedCasesPath();
const linked = (): Record<string, string> => JSON.parse(readFileSync(LINKED(), 'utf8'));
/** Temp files the writer left beside the registry (there must never be any). */
const leftovers = () =>
readdirSync(dirname(LINKED())).filter((n) => n.startsWith(`${basename(LINKED())}.`) && n.endsWith('.tmp'));
/** The bytes of a registry torn by two overlapping truncate-then-write calls. */
const TORN = '{\n "kept": "/srv/kept"\n}' + 'ept-2"\n}';
/** How long every write to the registry (or a temp file beside it) is held before it starts. */
const SLOW_WRITE_MS = 60;
const realWriteFile = fs.writeFile.bind(fs);
let workDir = '';
beforeEach(() => {
if (!isSandboxedHome()) {
throw new Error('linked-cases-writer.test.ts rewrites the linked-cases registry: run it via npm test');
}
safeRmHomeTree(LINKED());
mkdirSync(dirname(LINKED()), { recursive: true });
workDir = realpathSync(mkdtempSync(join(homedir(), 'linked-cases-writer-')));
vi.spyOn(fs, 'writeFile').mockImplementation((async (file: unknown, data: unknown, options?: unknown) => {
if (typeof file === 'string' && file.startsWith(LINKED())) {
await new Promise((resolve) => setTimeout(resolve, SLOW_WRITE_MS));
}
return realWriteFile(file as string, data as string, options as undefined);
}) as typeof fs.writeFile);
});
afterEach(() => {
vi.restoreAllMocks();
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (isSandboxedHome()) safeRmHomeTree(LINKED());
});
describe('mutateLinkedCases()', () => {
it('runs concurrent mutations one at a time, so none loses another one’s entry', async () => {
writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' }));
await Promise.all(
Array.from({ length: 8 }, (_, i) => mutateLinkedCases((current) => ({ ...current, [`c${i}`]: `/srv/c${i}` })))
);
expect(Object.keys(linked()).sort()).toEqual(['c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'kept']);
expect(leftovers()).toEqual([]);
});
it('a check that throws inside the mutation rejects that caller only and writes nothing', async () => {
const claim = (name: string, path: string) =>
mutateLinkedCases((current) => {
if (current[name]) throw Object.assign(new Error(`${name} is taken`), { conflict: true });
return { ...current, [name]: path };
});
const results = await Promise.allSettled([
claim('twin', '/srv/a'),
claim('twin', '/srv/b'),
claim('solo', '/srv/s'),
]);
expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'fulfilled']);
expect((results[1] as PromiseRejectedResult).reason.message).toBe('twin is taken');
expect(linked()).toEqual({ twin: '/srv/a', solo: '/srv/s' });
});
it('starts from an empty registry when the file is missing or empty, and writes nothing for undefined', async () => {
const { before, after } = await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
expect(before).toEqual({});
expect(after).toEqual({ a: '/srv/a' });
writeFileSync(LINKED(), ' \n');
await mutateLinkedCases((current) => ({ ...current, b: '/srv/b' }));
expect(linked()).toEqual({ b: '/srv/b' });
const untouched = readFileSync(LINKED(), 'utf8');
expect((await mutateLinkedCases(() => undefined)).after).toBeUndefined();
expect(readFileSync(LINKED(), 'utf8')).toBe(untouched);
});
it('refuses a registry that is not a JSON object and leaves it byte for byte', async () => {
for (const content of [TORN, '[]', 'null', '"text"']) {
writeFileSync(LINKED(), content);
const fn = vi.fn((current: Record<string, string>) => ({ ...current, a: '/srv/a' }));
await expect(mutateLinkedCases(fn)).rejects.toBeInstanceOf(LinkedCasesWriteRefusedError);
expect(fn).not.toHaveBeenCalled();
expect(readFileSync(LINKED(), 'utf8')).toBe(content);
}
// The chain keeps going after a refusal.
writeFileSync(LINKED(), '{}');
await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
expect(linked()).toEqual({ a: '/srv/a' });
});
it('leaves the previous registry and no temp file when the write fails', async () => {
writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' }));
const realRename = fs.rename.bind(fs);
vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === LINKED()) throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' });
return realRename(from, to);
});
await expect(mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }))).rejects.toThrow('no space left');
expect(linked()).toEqual({ kept: '/srv/kept' });
expect(leftovers()).toEqual([]);
});
it('writes through a symlinked registry onto its target and keeps the file’s permissions', async () => {
const real = join(workDir, 'linked-cases.json');
writeFileSync(real, JSON.stringify({ kept: '/srv/kept' }), { mode: 0o600 });
safeRmHomeTree(LINKED());
symlinkSync(real, LINKED());
await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
expect(lstatSync(LINKED()).isSymbolicLink()).toBe(true);
expect(readlinkSync(LINKED())).toBe(real);
expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual({ kept: '/srv/kept', a: '/srv/a' });
expect(statSync(real).mode & 0o777).toBe(0o600);
expect(readdirSync(workDir).filter((n) => n.endsWith('.tmp'))).toEqual([]);
});
});
describe('the case routes share the writer', () => {
const folder = (name: string) => {
const dir = join(workDir, name);
mkdirSync(dir);
return dir;
};
it('two links of different names at once both survive', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const [a, b] = await Promise.all([
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'one', path: folder('one') } }),
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'two', path: folder('two') } }),
]);
expect([a.json().success, b.json().success]).toEqual([true, true]);
expect(linked()).toEqual({ one: join(workDir, 'one'), two: join(workDir, 'two') });
});
it('two links of one name at once: exactly one wins and the other is told the name is taken', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const [a, b] = await Promise.all([
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-a') } }),
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-b') } }),
]);
expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1);
const loser = a.json().success ? b : a;
expect(loser.json().errorCode).toBe('ALREADY_EXISTS');
expect(Object.keys(linked())).toEqual(['twin']);
});
it('an unlink at the same time as a link keeps the new link', async () => {
writeFileSync(LINKED(), JSON.stringify({ old: folder('old') }));
const { app } = await createRouteTestHarness(registerCaseRoutes);
const [unlink, link] = await Promise.all([
app.inject({ method: 'DELETE', url: '/api/cases/old' }),
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'fresh', path: folder('fresh') } }),
]);
expect([unlink.json().success, link.json().success]).toEqual([true, true]);
expect(linked()).toEqual({ fresh: join(workDir, 'fresh') });
expect(existsSync(join(workDir, 'old'))).toBe(true);
});
it('a link refuses a registry that does not parse instead of replacing it', async () => {
writeFileSync(LINKED(), TORN);
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'n', path: folder('n') } });
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(res.json().error).toContain('is not a valid JSON object');
expect(readFileSync(LINKED(), 'utf8')).toBe(TORN);
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* @fileoverview `findLiveWorktree()` (#765): which linked worktree a session's pane is in.
*
* The answer moves the Files panel's listing, so what matters is what it must NOT answer: the
* session folder itself, a folder outside it, a plain subfolder (a `cd` in a shell must not re-root
* the panel), a repository or submodule nested in the folder, and a `.git` that is not a regular
* file. Runs against a real filesystem, and one case against a worktree real git made.
*/
import { execFileSync } from 'node:child_process';
import { constants, mkdirSync, mkdtempSync, promises as fsPromises, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { findLiveWorktree, isLinkedWorktreeGitFile } from '../src/live-worktree.js';
describe('isLinkedWorktreeGitFile', () => {
it('accepts what git writes for a linked worktree, absolute or relative', () => {
expect(isLinkedWorktreeGitFile('gitdir: /home/me/repo/.git/worktrees/probe\n')).toBe(true);
expect(isLinkedWorktreeGitFile('gitdir: ../../../.git/worktrees/probe\n')).toBe(true);
});
it('refuses a submodule, a bare path and text that is not a gitdir line', () => {
expect(isLinkedWorktreeGitFile('gitdir: ../.git/modules/lib\n')).toBe(false);
expect(isLinkedWorktreeGitFile('gitdir: worktrees\n')).toBe(false);
expect(isLinkedWorktreeGitFile('/home/me/repo/.git/worktrees/probe\n')).toBe(false);
expect(isLinkedWorktreeGitFile('')).toBe(false);
});
});
describe('findLiveWorktree', () => {
let base: string;
let repo: string;
let worktree: string;
beforeEach(() => {
base = mkdtempSync(join(tmpdir(), 'live-worktree-'));
repo = join(base, 'repo');
worktree = join(repo, '.claude', 'worktrees', 'probe');
mkdirSync(join(repo, '.git'), { recursive: true });
mkdirSync(join(worktree, 'src', 'deep'), { recursive: true });
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
it('finds the worktree from its own root and from below it', async () => {
const expected = { name: 'probe', path: worktree };
expect(await findLiveWorktree(repo, worktree)).toEqual(expected);
expect(await findLiveWorktree(repo, join(worktree, 'src', 'deep'))).toEqual(expected);
});
it('answers the same for a workingDir spelled with a trailing slash', async () => {
expect(await findLiveWorktree(`${repo}/`, worktree)).toEqual({ name: 'probe', path: worktree });
});
it('does not treat the session folder as a worktree of itself', async () => {
writeFileSync(join(worktree, 'marker'), '');
expect(await findLiveWorktree(worktree, worktree)).toBeUndefined();
expect(await findLiveWorktree(worktree, join(worktree, 'src'))).toBeUndefined();
});
it('stays on the session folder for a plain subfolder, so a shell cd moves nothing', async () => {
mkdirSync(join(repo, 'src'));
expect(await findLiveWorktree(repo, repo)).toBeUndefined();
expect(await findLiveWorktree(repo, join(repo, 'src'))).toBeUndefined();
});
it('never answers outside the session folder', async () => {
const outside = join(base, 'repo-feature');
mkdirSync(outside);
writeFileSync(join(outside, '.git'), `gitdir: ${repo}/.git/worktrees/feature\n`);
// A sibling sharing the folder's name as a prefix is the case a bare startsWith gets wrong.
expect(await findLiveWorktree(repo, outside)).toBeUndefined();
expect(await findLiveWorktree(repo, '/')).toBeUndefined();
});
it('stops at a submodule and at a repository of its own', async () => {
const submodule = join(repo, 'lib');
mkdirSync(submodule);
writeFileSync(join(submodule, '.git'), 'gitdir: ../.git/modules/lib\n');
expect(await findLiveWorktree(repo, submodule)).toBeUndefined();
const nested = join(worktree, 'vendored');
mkdirSync(join(nested, '.git'), { recursive: true });
expect(await findLiveWorktree(repo, nested)).toBeUndefined();
});
it('refuses a .git that is not a regular file', async () => {
const linked = join(repo, 'linked');
mkdirSync(linked);
symlinkSync(join(worktree, '.git'), join(linked, '.git'));
expect(await findLiveWorktree(repo, linked)).toBeUndefined();
// A FIFO would block open() for good: the test would time out rather than fail.
const piped = join(repo, 'piped');
mkdirSync(piped);
execFileSync('mkfifo', [join(piped, '.git')]);
expect(await findLiveWorktree(repo, piped)).toBeUndefined();
});
it('refuses a FIFO swapped in after the probe, without waiting for a writer', async () => {
const swapped = join(repo, 'swapped');
mkdirSync(swapped);
execFileSync('mkfifo', [join(swapped, 'fifo')]);
const flagsSeen: unknown[] = [];
const realOpen = fsPromises.open.bind(fsPromises);
const spy = vi.spyOn(fsPromises, 'open').mockImplementationOnce((_path, flags) => {
flagsSeen.push(flags);
// The test's own open never blocks, so a change that loses the flag fails on the assert below.
return realOpen(join(swapped, 'fifo'), constants.O_RDONLY | constants.O_NONBLOCK);
});
try {
// The probe and the open disagree here, which is what a swap between the two looks like.
writeFileSync(join(swapped, '.git'), 'gitdir: /home/me/repo/.git/worktrees/swapped\n');
expect(await findLiveWorktree(repo, swapped)).toBeUndefined();
} finally {
spy.mockRestore();
}
expect(Number(flagsSeen[0]) & constants.O_NONBLOCK).toBeTruthy();
expect(Number(flagsSeen[0]) & constants.O_NOFOLLOW).toBeTruthy();
});
it('answers nothing without an absolute reading', async () => {
expect(await findLiveWorktree(repo, undefined)).toBeUndefined();
expect(await findLiveWorktree(repo, '')).toBeUndefined();
expect(await findLiveWorktree(repo, '.claude/worktrees/probe')).toBeUndefined();
});
it('recognises a worktree real git made', async () => {
const real = join(base, 'real');
const git = (...args: string[]) =>
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], {
cwd: real,
stdio: 'ignore',
});
mkdirSync(real);
git('init', '-q');
git('commit', '-q', '--allow-empty', '-m', 'init');
git('worktree', 'add', '-q', '.claude/worktrees/made', '-b', 'worktree-made');
expect(await findLiveWorktree(real, join(real, '.claude', 'worktrees', 'made'))).toEqual({
name: 'made',
path: join(real, '.claude', 'worktrees', 'made'),
});
});
});
+1 -3
View File
@@ -22,11 +22,9 @@ import { defaultRemoteCommandForMode, remoteLoginShellCommand } from '../src/rem
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`,
shell: '{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@"; }',
claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'),
opencode: remoteLoginShellCommand('opencode'),
codex: remoteLoginShellCommand('codex'),
+4
View File
@@ -157,6 +157,10 @@ export function createMockRouteContext(options?: {
createSession: vi.fn(),
killSession: vi.fn(),
listSessions: vi.fn(() => []),
// A DIFFERENT method from listSessions, and the adoption routes call this
// one. Missing it is not a type error: the handler throws and the route
// answers 500, which reads as a broken endpoint rather than a broken mock.
getSessions: vi.fn(() => []),
getStats: vi.fn(() => ({})),
updateSessionName: vi.fn(() => true),
getSession: vi.fn(() => null),
+24
View File
@@ -11,6 +11,7 @@ import type {
SessionStatus,
SessionRemote,
} from '../../src/types.js';
import type { SessionAdopt } from '../../src/types/session.js';
import type { GitFetchStatus } from '../../src/git-fetch.js';
/**
@@ -26,6 +27,15 @@ export class MockSession extends EventEmitter {
* over ssh instead of with local `fs` (#415).
*/
remote?: SessionRemote;
/**
* Mirrors `Session.isAdopted`: true when this session wraps a process SOMEONE
* ELSE started (a hand-opened tmux session Codeman attached to). A plain field
* rather than a getter over `adopt`, because the routes only ever read the
* boolean and the tests only ever need to set it.
*/
isAdopted: boolean = false;
/** Mirrors `Session.adopt`, the record the one-wrapper-per-target check keys on. */
adopt?: SessionAdopt;
/** Mirrors Session.attachmentHistory (the attachment panel's source of truth). */
attachmentHistory: SessionAttachmentHistoryItem[] = [];
/** Mirrors Session.getAttachmentHistoryForPersist(). */
@@ -402,6 +412,18 @@ export class MockSession extends EventEmitter {
}
/** Auto-resume on usage limit (token pause control) */
autoClearEnabled: boolean = false;
autoClearThreshold: number = 0;
setAutoClear = vi.fn((enabled: boolean, threshold?: number) => {
this.autoClearEnabled = enabled;
if (typeof threshold === 'number') this.autoClearThreshold = threshold;
});
autoCompactEnabled: boolean = false;
autoCompactThreshold: number = 0;
setAutoCompact = vi.fn((enabled: boolean, threshold?: number) => {
this.autoCompactEnabled = enabled;
if (typeof threshold === 'number') this.autoCompactThreshold = threshold;
});
autoResumeEnabled: boolean = false;
autoResumeAt: number | null = null;
isLimitPaused: boolean = false;
@@ -464,6 +486,8 @@ export class MockSession extends EventEmitter {
/** Mirrors Session.envOverrideValue; no override map here, so the custom-model
* real-home lookup defaults to "session says nothing" (overridable per test). */
envOverrideValue = vi.fn((_key: string): string | undefined => undefined);
/** Mirrors Session.ownEnvValue, same default. */
ownEnvValue = vi.fn((_key: string): string | undefined => undefined);
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
+3 -1
View File
@@ -130,7 +130,9 @@ describe('OMP mode gates', () => {
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\'');
expect(defaultRemoteCommandForMode('omp')).toBe(
'{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'omp\'; }'
);
});
});
+73 -2
View File
@@ -29,11 +29,11 @@
*
* Port: ephemeral
*/
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Session, rebasePath } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { SessionDocker, SessionRemote } from '../src/types.js';
@@ -278,6 +278,77 @@ describe('a pane read reaching the session record', () => {
expect(session.toState().workingDir).toBe('/work/proj');
});
describe('liveWorktree (#765)', () => {
let base: string;
let repo: string;
let worktree: string;
beforeEach(() => {
base = mkdtempSync(join(tmpdir(), 'pane-live-worktree-'));
repo = join(base, 'repo');
worktree = join(repo, '.claude', 'worktrees', 'probe');
mkdirSync(worktree, { recursive: true });
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
const inRepo = () => {
const { mux, sessions, web } = build();
const session = localMuxSession({ workingDir: repo });
sessions.set(session.id, session);
const broadcast = vi.fn();
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
broadcast;
mux.getPaneExit = () => undefined;
return { mux, session, web, broadcast };
};
it('publishes the worktree the pane moved into, and clears it when the pane leaves', async () => {
const { mux, session, web, broadcast } = inRepo();
let dir = worktree;
mux.getPaneCurrentPath = () => dir;
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
// What the browser receives, and what the file routes read: never the confinement root.
const state = (
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
).getSessionStateWithRespawn(session);
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
expect(session.toState().workingDir).toBe(repo);
const broadcasts = broadcast.mock.calls.length;
dir = repo;
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
expect(broadcast.mock.calls.length).toBeGreaterThan(broadcasts);
});
it('does nothing for a pane that has not moved', async () => {
const { mux, session } = inRepo();
mux.getPaneCurrentPath = () => repo;
const lookup = vi.spyOn(session, 'setLiveWorktree');
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
mux.emit('paneExitsUpdated');
await new Promise((resolve) => setTimeout(resolve, 20));
expect(lookup).toHaveBeenCalledTimes(1);
});
it('drops an answer for a directory the pane has already left', async () => {
const { web, session } = inRepo();
session.setPaneCwd(worktree);
const refresh = (
web as unknown as { refreshLiveWorktree: (s: Session) => Promise<void> }
).refreshLiveWorktree.call(web, session);
session.setPaneCwd(repo);
await refresh;
expect(session.liveWorktree).toBeUndefined();
});
});
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
const { mux, sessions } = build();
const session = localMuxSession();
+10
View File
@@ -30,6 +30,16 @@ describe('sweepPasteImagesOnce', () => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it('lists nothing for an adopted session, whose folder is the /tmp others may work in', async () => {
// The delete path removes what this lists, recursively: closing an adopted tab
// must not take the uploads of a real session sitting in that same folder.
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
dirs.push(workingDir);
mkdirSync(join(workingDir, UPLOADS_DIR));
expect(await uploadDirs({ workingDir })).toHaveLength(1);
expect(await uploadDirs({ workingDir, adopt: { location: 'local' } })).toEqual([]);
});
it('ages paste-* files out of both the current and the legacy upload dir', async () => {
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
dirs.push(workingDir);
+30 -8
View File
@@ -14,6 +14,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const sessionUiJs = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
@@ -166,14 +167,35 @@ describe('PathPicker New folder', () => {
expect(doc.querySelector('.path-picker-overlay')).not.toBeNull();
});
it('is on for Link Existing and off for the other session-ui pickers', () => {
const block = (name: string) => {
const start = sessionUiJs.indexOf(`${name}() {`);
return sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400);
};
expect(block('openLinkCasePathPicker')).toContain('allowNewFolder: true');
for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker', 'openNewCasePathPicker']) {
expect(block(other), other).not.toContain('allowNewFolder');
// A picker's whole options object: a fixed window stops short of the container picker's long one.
const pickerOptions = (name: string) => {
const start = sessionUiJs.indexOf(`${name}() {`);
expect(start, name).toBeGreaterThan(-1);
const open = sessionUiJs.indexOf('PathPicker.open', start);
expect(open, name).toBeGreaterThan(start);
const end = sessionUiJs.indexOf('\n });', open);
expect(end, name).toBeGreaterThan(open);
return sessionUiJs.slice(open, end);
};
it("is on for Create New's custom folder picker too", () => {
expect(pickerOptions('openNewCasePathPicker')).toContain('allowNewFolder: true');
});
it("is on for Clone Repo's parent folder picker: that parent must already exist", () => {
expect(pickerOptions('openCloneCasePathPicker')).toContain('allowNewFolder: true');
});
it('is on for Link Existing and off for the Docker pickers', () => {
expect(pickerOptions('openLinkCasePathPicker')).toContain('allowNewFolder: true');
for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker']) {
expect(pickerOptions(other), other).not.toContain('allowNewFolder');
}
});
it('lets an error wrap: the status line has no other way to show the rest of a long refusal', () => {
const start = stylesCss.indexOf('.path-picker-status.error {');
expect(start).toBeGreaterThan(-1);
expect(stylesCss.slice(start, stylesCss.indexOf('}', start))).toContain('white-space: normal');
});
});
+3 -1
View File
@@ -186,7 +186,9 @@ describe('Pi mode gates', () => {
expect(defaultDockerCommandForMode('pi')).toBe('exec pi');
// Routed through an interactive login shell so npm's global bin resolves —
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
expect(defaultRemoteCommandForMode('pi')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'pi\'');
expect(defaultRemoteCommandForMode('pi')).toBe(
'{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'pi\'; }'
);
});
it('probes the CLI version on a remote host (REMOTE_CLI_BIN carries pi)', () => {
+10
View File
@@ -151,6 +151,16 @@ describe('which dead sessions may be rebuilt', () => {
expect(planRebootRestore(['d'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
});
it('refuses an adopted session: Codeman never started the process it wrapped', () => {
const persisted = {
a: persistedSession({
id: 'a',
adopt: { location: 'local', socketPath: '/tmp/tmux-1000/default', targetSession: 'work', viewSession: 'v' },
} as Partial<SessionState> & { id: string }),
};
expect(planRebootRestore(['a'], persisted, () => true).skipped[0].reason).toBe('adopted');
});
it('skips a CLI whose history the claude transcript reader does not understand', () => {
const persisted = { c: persistedSession({ id: 'c', mode: 'codex' }) };
expect(planRebootRestore(['c'], persisted, () => true).skipped[0].reason).toBe('unsupported-mode');
+250
View File
@@ -0,0 +1,250 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
// @ts-expect-error plain .mjs helper shared with scripts/generate-release-notes.mjs
import { buildReleaseNotes, previousVersion } from '../scripts/lib/release-notes.mjs';
// @ts-expect-error plain .mjs helper shared with scripts/generate-whats-new.mjs
import { versionSection, extractHighlights } from '../scripts/lib/whats-new.mjs';
const root = resolve(import.meta.dirname, '..');
const realChangelog = readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8');
/** A release the way changesets writes it: one hashed bullet, the rest nested under it. */
const CHANGELOG = `# aicodeman
## 2.1.0
### Minor Changes
- 12024cc: ### Thanks
- @someone for the tile grid (#10).
- @other for reporting the wheel bug (#11).
**The tile grid (#10).** Six sessions side by side. Behaviour change: \`Ctrl+G\` opens it now.
It remembers your layout.
**Install without admin rights.** Nothing lands outside your home folder.
**Fixes.**
- **The wheel works (#11).** It survives a reload.
- **A second fix.** Text.
**Fixes applied while landing.** One review fix (#10).
**Docs and tests.** A new guide.
## 2.0.0
### Patch Changes
- abcdef1: One line.
- A bullet with no hash.
## 1.9.0
- **The oldest entry.** Text.
`;
const notes = (version: string, changelog = CHANGELOG): string => buildReleaseNotes(changelog, version);
describe('release notes', () => {
it('drops what only changesets needs: the commit prefix, the type heading and the nesting', () => {
const body = notes('2.1.0');
expect(body).not.toMatch(/12024cc/);
expect(body).not.toMatch(/Minor Changes/);
expect(body.split('\n').filter((line) => line.startsWith(' '))).toEqual([]);
});
it('lays the page out in order, with Thanks first', () => {
const headings = notes('2.1.0')
.split('\n')
.filter((line) => line.startsWith('#'));
expect(headings).toEqual([
'## Thanks',
"## What's new",
'### The tile grid (#10)',
'### Install without admin rights',
'## Fixes',
'## Also in this release',
]);
});
it('keeps a paragraph with the feature it follows', () => {
const body = notes('2.1.0');
expect(body.indexOf('It remembers your layout.')).toBeLessThan(body.indexOf('### Install without admin rights'));
expect(body.indexOf('It remembers your layout.')).toBeGreaterThan(body.indexOf('### The tile grid'));
});
it('keeps the fixes as a list and the two short sections as run-ins', () => {
const body = notes('2.1.0');
expect(body).toContain('## Fixes\n\n- **The wheel works (#11).** It survives a reload.\n- **A second fix.** Text.');
expect(body).toContain('**Fixes applied while landing.** One review fix (#10).');
expect(body).toContain('**Docs and tests.** A new guide.');
});
it('makes a behaviour change stand out, without doubling bold that is already there', () => {
expect(notes('2.1.0')).toContain('**Behaviour change:** `Ctrl+G` opens it now.');
const bold = '## 1.0.0\n\n- **Fixes.**\n - **Clone.** Text. Behaviour changes: a. **Breaking:** b.\n';
const body = notes('1.0.0', bold);
expect(body).toContain('**Behaviour changes:** a. **Breaking:** b.');
expect(body).not.toContain('****');
});
it('links the comparison with the previous release, and only when there is one', () => {
expect(previousVersion(CHANGELOG, '2.1.0')).toBe('2.0.0');
expect(notes('2.1.0')).toContain('compare/codeman@2.0.0...codeman@2.1.0');
expect(previousVersion(CHANGELOG, '1.9.0')).toBeNull();
expect(notes('1.9.0')).not.toContain('Full Changelog');
});
it('keeps one-line changesets as a list, hashed or not', () => {
expect(notes('2.0.0')).toContain("## What's new\n\n- One line.\n\n- A bullet with no hash.");
});
it('merges several changesets into one page instead of repeating sections', () => {
const changelog = [
'## 3.0.0',
'### Minor Changes',
'- aaaaaaa: ### Thanks\n - @a for one.\n\n **Feature A.** Text.\n\n **Fixes.**\n - **Fix A.** Text.',
'### Patch Changes',
'- bbbbbbb: ### Thanks\n - @b for two.\n\n **Feature B.** Text.\n\n **Fixes.**\n - **Fix B.** Text.',
].join('\n\n');
const body = notes('3.0.0', changelog);
expect(body.match(/^## Thanks$/gm)).toHaveLength(1);
expect(body.match(/^## Fixes$/gm)).toHaveLength(1);
expect(body).toContain('### Feature A');
expect(body).toContain('### Feature B');
expect(body).toContain('- @b for two.');
expect(body).toContain('- **Fix B.** Text.');
});
it('does not take a sentence that opens in bold for a headline', () => {
const changelog = '## 1.0.0\n\n- **Allow network access** is a new setting, off by default.\n';
const body = notes('1.0.0', changelog);
expect(body).toContain('**Allow network access** is a new setting, off by default.');
expect(body).not.toContain('###');
});
it('keeps text after the Thanks list out of Thanks, and a lead-in line in it', () => {
const changelog = [
'## 1.0.0',
'- ### Thanks',
' 1.0.0 follows 0.9.0, so the thanks belong here too:',
'',
' - @a for a fix.',
'',
' ![a demo](https://example.com/demo.png)',
'',
' **A feature.** Text.',
].join('\n');
const body = notes('1.0.0', changelog);
expect(body).toMatch(/## Thanks\n\n1\.0\.0 follows 0\.9\.0.*:\n\n- @a for a fix\.\n\n## What's new\n\n!\[a demo\]/);
});
it('keeps credit written as a paragraph under Thanks, where it opened (1.21.0 does this)', () => {
const changelog =
'## 1.0.0\n\n- ### Thanks\n\n Seven rounds of beta testing by **@someone**.\n\n **A feature.** Text.\n';
expect(notes('1.0.0', changelog)).toMatch(
/^## Thanks\n\nSeven rounds of beta testing by \*\*@someone\*\*\.\n\n## What's new/
);
});
it('leaves a fenced code block alone', () => {
const changelog = '## 1.0.0\n\n- **A feature.** Text.\n\n ```md\n ### Not a heading\n\n **Fixes.**\n ```\n';
const body = notes('1.0.0', changelog);
expect(body).toContain('```md\n### Not a heading\n\n**Fixes.**\n```');
expect(body).not.toContain('## Fixes');
});
it('returns nothing for a version the changelog does not have', () => {
expect(notes('9.9.9')).toBe('');
expect(notes('1.0.0', '## 1.0.0\n\n### Patch Changes\n')).toBe('');
});
});
describe('release notes over every release in CHANGELOG.md', () => {
const versions: string[] = [...realChangelog.matchAll(/^## (\d+\.\d+\.\d+\S*)\s*$/gm)].map((m) => m[1]);
/** The words of a text, ignoring only what the page legitimately changes around them. */
const words = (text: string): string[] =>
text
.replace(/^### (?:Major|Minor|Patch) Changes\s*$/gm, '')
.replace(/^- [0-9a-f]{7,40}: /gm, '')
.replace(/[*#]/g, '')
.split(/\s+/)
.map((word) => word.replace(/[.:]+$/, ''))
.filter((word) => word && word !== '-');
it('reads the whole changelog', () => {
expect(versions.length).toBeGreaterThan(200);
});
it('never loses a word', () => {
const lost: string[] = [];
for (const version of versions) {
const available = new Map<string, number>();
for (const word of words(buildReleaseNotes(realChangelog, version))) {
available.set(word, (available.get(word) ?? 0) + 1);
}
for (const word of words(versionSection(realChangelog, version))) {
const left = available.get(word) ?? 0;
if (left === 0) lost.push(`${version}: ${word}`);
available.set(word, left - 1);
}
}
expect(lost).toEqual([]);
});
it('leaves no changesets scaffolding on any page, and opens with Thanks wherever there is one', () => {
for (const version of versions) {
const body: string = buildReleaseNotes(realChangelog, version);
expect(body, version).not.toMatch(/^- [0-9a-f]{7,40}: /m);
expect(body, version).not.toMatch(/^#+ (?:Major|Minor|Patch) Changes/m);
if (/^ *### Thanks|: ### Thanks/m.test(versionSection(realChangelog, version))) {
expect(body.startsWith('## Thanks'), version).toBe(true);
}
}
});
it('gives a page every headline the in-app toast announces for the same release', () => {
const body: string = buildReleaseNotes(realChangelog, '1.49.0');
const highlights: string[] = extractHighlights(versionSection(realChangelog, '1.49.0'));
expect(highlights.length).toBeGreaterThan(0);
for (const highlight of highlights) expect(body).toContain(highlight);
});
});
describe('generate-release-notes.mjs', () => {
const script = resolve(root, 'scripts/generate-release-notes.mjs');
it('prints the page for a version', () => {
const out = execFileSync(process.execPath, [script, '1.49.0'], { encoding: 'utf8' });
expect(out).toBe(buildReleaseNotes(realChangelog, '1.49.0'));
});
it('prints nothing and fails for an unknown version, so the workflow keeps the changesets text', () => {
const run = spawnSync(process.execPath, [script, '0.0.1-nope'], { encoding: 'utf8' });
expect(run.status).toBe(1);
expect(run.stdout).toBe('');
});
});
describe('Release workflow', () => {
const workflow = readFileSync(resolve(root, '.github/workflows/release.yml'), 'utf8');
it('titles the release after the product and keeps the tag the updater parses', () => {
expect(workflow).toContain('-f name="Codeman ${VERSION}"');
expect(workflow).toContain('-f tag_name="$NEW_TAG"');
});
it('cannot lose the retag to a failed format: the script is guarded and the PATCH is not', () => {
const guard = workflow.indexOf('if node scripts/generate-release-notes.mjs "$VERSION"');
const patch = workflow.indexOf('gh api -X PATCH');
expect(guard).toBeGreaterThan(-1);
expect(guard).toBeLessThan(patch);
expect(workflow.slice(guard, patch)).toContain('fi\n');
expect(workflow.slice(patch)).toContain('"${BODY_ARGS[@]}"');
});
});
+821
View File
@@ -0,0 +1,821 @@
/**
* @fileoverview Tests for the remote edit-history reads in `src/remote-files.ts`: the transcript
* lookup and the transcript read a remote claude session's edit history uses.
*
* Both scripts are executed by a real `/bin/sh` against a temp `$HOME`: the script IS the
* remote command, so `sh -c <script>` reproduces what sshd runs on the other end. The read is
* checked byte for byte against the local reader (`readClaudeEditSources`) on seeded random
* transcripts, so the edit history sync sees the same thing from a remote host as from a
* local one.
*
* Port: N/A (no HTTP server).
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { exec, execFileSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import {
RemoteFileAccessError,
buildRemoteClaudeEditSourcesCommand,
buildRemoteEditHistoryCommand,
buildRemoteClaudeTranscriptLocateCommand,
isRemoteClaudeTranscriptPath,
parseRemoteClaudeEditSources,
parseRemoteClaudeTranscriptLocate,
planRemoteEditSourceRounds,
readRemoteEditSourceRounds,
remoteEditSourcesTimeoutMs,
remoteLocateClaudeTranscript,
remoteReadClaudeEditSources,
} from '../src/remote-files.js';
import { readClaudeEditSources, type EditSourceRequest } from '../src/edit-history-local-source.js';
import { EditHistoryStore } from '../src/edit-history-store.js';
import { EditHistorySync, type EditHistorySession } from '../src/web/edit-history-sync.js';
import type { SessionRemote } from '../src/types/session.js';
let home: string;
beforeAll(() => {
// A throwaway HOME: the scripts only ever see this directory as $HOME.
home = mkdtempSync(join(tmpdir(), 'codeman-remote-edit-'));
});
afterAll(() => rmSync(home, { recursive: true, force: true }));
/** Run a built command (`sh -c '…'` or `exit 3`) the way sshd would; status and raw stdout. */
function runSh(command: string, env: Record<string, string> = {}): { code: number; stdout: Buffer } {
expect(home.startsWith(tmpdir())).toBe(true);
try {
return {
code: 0,
stdout: execFileSync('sh', ['-c', command], { env: { ...process.env, HOME: home, ...env }, cwd: home }),
};
} catch (err) {
const e = err as { status: number; stdout: Buffer };
return { code: e.status, stdout: e.stdout };
}
}
/** Runs a read command the way a {@link readRemoteEditSourceRounds} runner does, here on `sh`. */
async function runRead(command: string): Promise<Buffer | null> {
const { code, stdout } = runSh(command);
return code === 0 ? stdout : null;
}
/** One edit-source record in the script's wire format. */
function editRecord(relPath: string, size: number, start: number, body: Buffer | string): Buffer {
const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body);
return Buffer.concat([Buffer.from(`${relPath}\0${size}\0${start}\0${bytes.length}\0`), bytes]);
}
const REMOTE: SessionRemote = { hostId: 'h', label: 'h', host: '192.0.2.1', username: 'j', remotePath: '/' };
describe('isRemoteClaudeTranscriptPath', () => {
it('takes an absolute <id>.jsonl path and nothing else', () => {
expect(isRemoteClaudeTranscriptPath('/home/u/.claude/projects/-w/conv-1.jsonl')).toBe(true);
expect(isRemoteClaudeTranscriptPath('rel/conv-1.jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p/conv-1.json')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p/bad id.jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p/$(x).jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p\n/conv-1.jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/.jsonl')).toBe(false);
});
});
describe('the transcript lookup on a real shell', () => {
const projects = () => join(home, '.claude', 'projects');
beforeAll(() => {
mkdirSync(join(projects(), 'old'), { recursive: true });
mkdirSync(join(projects(), 'new'), { recursive: true });
writeFileSync(join(projects(), 'old', 'conv-1.jsonl'), 'x\n');
writeFileSync(join(projects(), 'new', 'conv-1.jsonl'), 'y\n');
writeFileSync(join(projects(), 'old', 'solo.jsonl'), 'z\n');
utimesSync(join(projects(), 'old', 'conv-1.jsonl'), 1_000, 1_000);
utimesSync(join(projects(), 'new', 'conv-1.jsonl'), 2_000, 2_000);
});
const locate = (ids: string[], pane?: Parameters<typeof buildRemoteClaudeTranscriptLocateCommand>[1], env = {}) => {
const { code, stdout } = runSh(buildRemoteClaudeTranscriptLocateCommand(ids, pane), env);
return code === 0 ? parseRemoteClaudeTranscriptLocate(stdout) : code;
};
it('runs under an explicit sh and never calls ls', () => {
const command = buildRemoteClaudeTranscriptLocateCommand(['conv-1']);
expect(command).toMatch(/^sh -c '/);
expect(command).not.toMatch(/(^|[\s;|(])ls\s/m);
});
it('takes the most recently written transcript of all ids, not the first id that has one', () => {
// conv-1 lies in old/ (1000 s) and new/ (2000 s); solo is older than both, then newest.
const solo = join(projects(), 'old', 'solo.jsonl');
try {
utimesSync(solo, 1_500, 1_500);
expect(locate(['conv-1', 'solo'])).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(locate(['solo', 'conv-1'])).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(locate(['missing', 'solo'])).toBe(solo);
// A respawn resumed the launch conversation after a /clear: the one written last wins.
utimesSync(solo, 3_000, 3_000);
expect(locate(['conv-1', 'solo'])).toBe(solo);
} finally {
// Older than conv-1, so the live tests below cannot pass by mtime alone.
utimesSync(solo, 1_500, 1_500);
}
});
it('exits 3 when no id has a transcript, and drops ids that are not plain tokens', () => {
expect(locate(['nope'])).toBe(3);
expect(buildRemoteClaudeTranscriptLocateCommand(['$(touch pwned)', "a'b", '../../x'])).toBe('exit 3');
const command = buildRemoteClaudeTranscriptLocateCommand(['$(touch pwned)', 'conv-1']);
expect(command).toContain('conv-1');
expect(command).not.toContain('pwned');
});
it('looks in an absolute CLAUDE_CONFIG_DIR instead of ~/.claude, as claude does; a relative one is ignored', () => {
const cfg = join(home, 'alt-config');
mkdirSync(join(cfg, 'projects', 'w'), { recursive: true });
writeFileSync(join(cfg, 'projects', 'w', 'alt-1.jsonl'), 'a\n');
expect(locate(['alt-1', 'conv-1'], undefined, { CLAUDE_CONFIG_DIR: cfg })).toBe(
join(cfg, 'projects', 'w', 'alt-1.jsonl')
);
expect(locate(['conv-1'], undefined, { CLAUDE_CONFIG_DIR: cfg })).toBe(3);
expect(locate(['alt-1', 'conv-1'], undefined, { CLAUDE_CONFIG_DIR: 'alt-config' })).toBe(
join(projects(), 'new', 'conv-1.jsonl')
);
});
it('parses only an absolute transcript path after the first NUL (a login banner comes before it)', () => {
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('motd\n\0/p/conv-1.jsonl\0'))).toBe('/p/conv-1.jsonl');
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('no nul'))).toBeNull();
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('\0/p/conv-1.jsonl'))).toBeNull();
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('\0p/conv-1.jsonl\0'))).toBeNull();
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('\0/p/notes.txt\0'))).toBeNull();
});
describe('after a /clear: the conversation the pane’s claude writes now', () => {
// Fake tmux (pane pid 100) and ps (100 → 200 wrapper → 300 claude); claude's own
// ~/.claude/sessions/<pid>.json names the current conversation. tmux sits in the temp
// $HOME/.local/bin, which withRemoteTmuxPath() puts ahead of the system dirs, so a real
// tmux on this machine is never asked.
let bin: string;
const pane = { socket: 'codeman-remote', sessionName: 'codeman-ssh-conv1abc' };
beforeAll(() => {
bin = mkdtempSync(join(tmpdir(), 'codeman-fake-tmux-'));
const localBin = join(home, '.local', 'bin');
mkdirSync(localBin, { recursive: true });
// Only the exact `=name:` target reaches the pane. Real tmux (3.4, measured) reads `=name`
// without the colon as a window target too, so it also answers for a window of that name
// or a session the name is a prefix of: the second pattern is that second case.
writeFileSync(
join(localBin, 'tmux'),
[
'#!/bin/sh',
'for a; do',
' case "$a" in =codeman-ssh-conv1abc:|=codeman-ssh-conv1) echo 100; exit 0 ;; esac',
'done',
'echo "no such session" >&2',
'exit 1',
'',
].join('\n')
);
writeFileSync(join(bin, 'ps'), '#!/bin/sh\nprintf " 1 0\\n100 1\\n200 100\\n300 200\\n"\n');
chmodSync(join(localBin, 'tmux'), 0o755);
chmodSync(join(bin, 'ps'), 0o755);
mkdirSync(join(home, '.claude', 'sessions'), { recursive: true });
});
afterAll(() => rmSync(bin, { recursive: true, force: true }));
const locateIn = (p: typeof pane | undefined) => locate(['conv-1'], p, { PATH: `${bin}:${process.env.PATH}` });
const session = (body: string) => writeFileSync(join(home, '.claude', 'sessions', '300.json'), body);
it('finds the live conversation of a grandchild claude before the stored ids, even an older one', () => {
// solo (1500 s) is older than conv-1 (2000 s): a /resume into an older conversation
// that has not written yet still wins.
session('{"pid":300,"sessionId":"solo","cwd":"/w","kind":"interactive"}');
expect(locateIn(pane)).toBe(join(projects(), 'old', 'solo.jsonl'));
});
it('finds the pane with tmux only under $HOME/.local/bin, on a PATH that does not list it', () => {
// A host where tmux is a per-user install: sshd's non-interactive PATH has no such dir.
const PATH = `${bin}:/usr/bin:/bin`;
expect(PATH).not.toContain('.local/bin');
expect(existsSync(join(bin, 'tmux'))).toBe(false);
session('{"pid":300,"sessionId":"solo"}');
expect(locate(['conv-1'], pane, { PATH })).toBe(join(projects(), 'old', 'solo.jsonl'));
});
it('falls back to the stored ids without a pane, for an unknown session, or a live id with no transcript', () => {
session('{"pid":300,"sessionId":"solo"}');
expect(locateIn(undefined)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(locateIn({ ...pane, sessionName: 'codeman-ssh-other' })).toBe(join(projects(), 'new', 'conv-1.jsonl'));
session('{"pid":300,"sessionId":"gone"}');
expect(locateIn(pane)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
});
it('never takes the pane of another tmux session whose name starts with this one', () => {
// codeman-ssh-conv1 is gone, codeman-ssh-conv1abc is alive: its conversation is not ours.
session('{"pid":300,"sessionId":"solo"}');
expect(locateIn({ ...pane, sessionName: 'codeman-ssh-conv1' })).toBe(join(projects(), 'new', 'conv-1.jsonl'));
});
it('reads the live id from the sessions dir under CLAUDE_CONFIG_DIR', () => {
const cfg = join(home, 'alt-config');
mkdirSync(join(cfg, 'sessions'), { recursive: true });
mkdirSync(join(cfg, 'projects', 'w'), { recursive: true });
writeFileSync(join(cfg, 'projects', 'w', 'alt-1.jsonl'), 'a\n');
writeFileSync(join(cfg, 'projects', 'w', 'conv-1.jsonl'), 'c\n');
writeFileSync(join(cfg, 'sessions', '300.json'), '{"pid":300,"sessionId":"alt-1"}');
session('{"pid":300,"sessionId":"solo"}'); // ~/.claude's file is not claude's here
expect(locate(['conv-1'], pane, { PATH: `${bin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: cfg })).toBe(
join(cfg, 'projects', 'w', 'alt-1.jsonl')
);
});
it('takes only an id-shaped value from the sessions file, and only plain socket/session names', () => {
session('{"pid":300,"sessionId":"x$(touch pwned)"}');
expect(locateIn(pane)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(existsSync(join(home, 'pwned'))).toBe(false);
expect(buildRemoteClaudeTranscriptLocateCommand(['conv-1'], { socket: "a';x", sessionName: 's' })).not.toContain(
'list-panes'
);
});
});
});
describe('buildRemoteClaudeEditSourcesCommand', () => {
it('runs under an explicit sh and never calls ls', () => {
const command = buildRemoteClaudeEditSourcesCommand('/p/conv-1.jsonl', [], 1024);
expect(command).toMatch(/^sh -c '/);
expect(command).not.toMatch(/(^|[\s;|(])ls\s/m);
});
it('exits 3 for a path that is not an absolute <id>.jsonl, without putting it in a script', () => {
for (const path of ['rel/conv-1.jsonl', "/p/a';touch x;'.jsonl", '/p/conv-1.txt', '/p\n/conv-1.jsonl']) {
expect(buildRemoteClaudeEditSourcesCommand(path, [], 10)).toBe('exit 3');
}
});
it('ignores invalid relPaths, offsets and lengths; the first request per relPath wins', () => {
const command = buildRemoteClaudeEditSourcesCommand(
'/p/conv-1.jsonl',
[
{ relPath: '../../etc/passwd', offset: 0 },
{ relPath: "subagents/agent-a';touch x;'.jsonl", offset: 0 },
{ relPath: 'subagents/agent-neg.jsonl', offset: -1 },
{ relPath: 'subagents/agent-frac.jsonl', offset: 1.5 },
{ relPath: 'subagents/agent-nan.jsonl', offset: Number.NaN },
{ relPath: 'subagents/agent-badlen.jsonl', offset: 0, len: -5 },
{ relPath: 'subagents/agent-ok.jsonl', offset: 7, len: 3 },
{ relPath: 'subagents/agent-ok.jsonl', offset: 99 },
],
100
);
expect(command).not.toMatch(/passwd|touch|agent-neg|agent-frac|agent-nan|agent-badlen/);
expect(command).toContain('agent-ok.jsonl');
expect(command).toContain('r 7 3');
expect(command).not.toContain('r 99');
});
});
describe('planRemoteEditSourceRounds', () => {
const agents = (...ids: string[]): EditSourceRequest[] =>
ids.map((id) => ({ relPath: `subagents/agent-${id}.jsonl`, offset: 1 }));
it('keeps a read that fits one script in one round trip, with no name range', () => {
const requests = [{ relPath: 'main', offset: 5 }, ...agents('b', 'a')];
expect(planRemoteEditSourceRounds(requests, 2)).toEqual([{ requests, main: true, after: '', upTo: '' }]);
expect(planRemoteEditSourceRounds([], 2)).toEqual([{ requests: [], main: true, after: '', upTo: '' }]);
});
it('cuts more requests by name: ranges that join up, main in the first, every request once', () => {
const main = { relPath: 'main', offset: 5 };
const rounds = planRemoteEditSourceRounds([...agents('e', 'c'), main, ...agents('a', 'd', 'b')], 2);
expect(rounds.map((r) => [r.main, r.after, r.upTo, r.requests.map((q) => q.relPath)])).toEqual([
[true, '', 'agent-b.jsonl', ['main', 'subagents/agent-a.jsonl', 'subagents/agent-b.jsonl']],
[false, 'agent-b.jsonl', 'agent-d.jsonl', ['subagents/agent-c.jsonl', 'subagents/agent-d.jsonl']],
[false, 'agent-d.jsonl', '', ['subagents/agent-e.jsonl']],
]);
});
it('counts only valid requests, the first per relPath', () => {
const rounds = planRemoteEditSourceRounds(
[
...agents('a'),
{ relPath: 'subagents/agent-a.jsonl', offset: 9 },
{ relPath: '../../etc/passwd', offset: 0 },
{ relPath: 'subagents/agent-neg.jsonl', offset: -1 },
],
1
);
expect(rounds).toEqual([{ requests: agents('a'), main: true, after: '', upTo: '' }]);
});
it('keeps every ssh line under the 128 KiB argv limit, longest names and offsets included', () => {
// 255 bytes is the longest file name a filesystem allows; a request without `len` is the
// longer one, because its `''` costs 16 bytes per quote once escaped and encoded.
const requests: EditSourceRequest[] = Array.from({ length: 5000 }, (_, i) => ({
relPath: `subagents/agent-${String(i).padStart(6, '0')}${'x'.repeat(237)}.jsonl`,
offset: Number.MAX_SAFE_INTEGER,
}));
const mainPath = `/${'p/'.repeat(2000)}conv-1.jsonl`;
const line = (reqs: EditSourceRequest[], round?: { main: boolean; after: string; upTo: string }) =>
Buffer.byteLength(
buildRemoteEditHistoryCommand(REMOTE, buildRemoteClaudeEditSourcesCommand(mainPath, reqs, 1000, { round }))
);
const ARGV_LIMIT = 128 * 1024;
expect(line(requests)).toBeGreaterThan(ARGV_LIMIT);
const rounds = planRemoteEditSourceRounds(requests);
expect(rounds.length).toBeGreaterThan(1);
for (const round of rounds) expect(line(round.requests, round)).toBeLessThan(ARGV_LIMIT);
});
});
describe('parseRemoteClaudeEditSources', () => {
it('splits several records byte-exactly and keeps a UTF-8 sequence cut at a chunk edge intact as bytes', () => {
const euro = Buffer.from('a€', 'utf8'); // 61 e2 82 ac
const stdout = Buffer.concat([
Buffer.from('banner from .zshrc\n\0conv-1\0'),
editRecord('main', 10, 6, euro.subarray(0, 3)), // ends mid-€
editRecord('subagents/agent-x1.jsonl', 4, 0, '{}\n\0'),
editRecord('subagents/agent-x1.meta.json', 23, 0, '{"agentType":"Explore"}'),
]);
const parsed = parseRemoteClaudeEditSources(stdout);
expect(parsed?.transcriptId).toBe('conv-1');
expect(parsed?.files).toHaveLength(2);
expect(parsed?.files[0]).toMatchObject({ relPath: 'main', size: 10, startOffset: 6 });
expect(parsed?.files[0].chunk.equals(euro.subarray(0, 3))).toBe(true);
expect(parsed?.files[1].chunk.equals(Buffer.from('{}\n\0'))).toBe(true);
expect(parsed?.metas).toEqual({ x1: '{"agentType":"Explore"}' });
});
it('accepts a zero-length record (budget used up) and a header with no records', () => {
const parsed = parseRemoteClaudeEditSources(
Buffer.concat([Buffer.from('\0conv-1\0'), editRecord('main', 500, 500, '')])
);
expect(parsed?.files).toEqual([{ relPath: 'main', size: 500, startOffset: 500, chunk: Buffer.alloc(0) }]);
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0'))).toEqual({
transcriptId: 'conv-1',
files: [],
metas: {},
});
});
it('is null when the protocol is broken', () => {
expect(parseRemoteClaudeEditSources(Buffer.from('no nul at all'))).toBeNull();
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1'))).toBeNull();
expect(parseRemoteClaudeEditSources(Buffer.from('\0bad id\0'))).toBeNull();
// Body shorter than declared.
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0main\x0010\x000\x005\x00abc'))).toBeNull();
// Truncated header.
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0main\x0010\x00'))).toBeNull();
// Non-numeric size, unknown relPath, slice outside the file.
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0main\0x\x000\x000\0'))).toBeNull();
expect(
parseRemoteClaudeEditSources(Buffer.concat([Buffer.from('\0conv-1\0'), editRecord('../x', 1, 0, 'a')]))
).toBeNull();
expect(
parseRemoteClaudeEditSources(Buffer.concat([Buffer.from('\0conv-1\0'), editRecord('main', 2, 1, 'ab')]))
).toBeNull();
});
});
describe('remoteEditSourcesTimeoutMs', () => {
it('gives a read of megabytes its transfer time at 256 KiB/s on top of the flat 30 s', () => {
expect(remoteEditSourcesTimeoutMs(0)).toBe(30_000);
expect(remoteEditSourcesTimeoutMs(-5)).toBe(30_000);
expect(remoteEditSourcesTimeoutMs(256 * 1024)).toBe(31_000);
// a forward pass: 8 MiB budget + 4 MiB metas + 1 MiB header slack
expect(remoteEditSourcesTimeoutMs(13 * 1024 * 1024)).toBe(82_000);
});
});
describe('the transcript read on a real shell', () => {
const project = () => join(home, '.claude', 'projects', 'read');
const main = () => join(project(), 'conv-r.jsonl');
const read = (requests: EditSourceRequest[], budget: number, onlyRequested = false) => {
const { code, stdout } = runSh(buildRemoteClaudeEditSourcesCommand(main(), requests, budget, { onlyRequested }));
return code === 0 ? parseRemoteClaudeEditSources(stdout) : code;
};
beforeAll(() => {
const sub = join(project(), 'conv-r', 'subagents');
mkdirSync(sub, { recursive: true });
writeFileSync(main(), 'main-0123456789\n');
writeFileSync(join(sub, 'agent-a1.jsonl'), 'AAAAAAAAAA');
writeFileSync(join(sub, 'agent-a1.meta.json'), '{"agentType":"Explore"}');
writeFileSync(join(sub, 'agent-b2.jsonl'), 'BBBBB');
writeFileSync(join(sub, 'agent-bad name.jsonl'), 'never read');
});
it('exits 3 when the transcript is gone', () => {
const { code } = runSh(buildRemoteClaudeEditSourcesCommand(join(project(), 'gone.jsonl'), [], 100));
expect(code).toBe(3);
});
it('continues from the offsets, reads a new subagent’s end with its meta, and an older block to its end', () => {
const parsed = read(
[
{ relPath: 'main', offset: 5 },
{ relPath: 'subagents/agent-b2.jsonl', offset: 1, len: 3 },
],
1000
);
expect(parsed).not.toBe(3);
if (typeof parsed !== 'object' || !parsed) return;
expect(parsed.files.map((f) => [f.relPath, f.size, f.startOffset, f.chunk.toString()])).toEqual([
['main', 16, 5, '0123456789\n'],
['subagents/agent-a1.jsonl', 10, 0, 'AAAAAAAAAA'],
['subagents/agent-b2.jsonl', 5, 1, 'BBB'],
]);
expect(parsed.metas).toEqual({ a1: '{"agentType":"Explore"}' });
});
it('with onlyRequested lists only the requested files and sends no meta', () => {
const parsed = read([{ relPath: 'subagents/agent-a1.jsonl', offset: 2, len: 4 }], 1000, true);
if (typeof parsed !== 'object' || !parsed) throw new Error('no read');
expect(parsed.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
['subagents/agent-a1.jsonl', 2, 'AAAA'],
]);
expect(parsed.metas).toEqual({});
});
it('treats a hostile request as data: it never reaches the shell', () => {
const marker = join(home, 'pwned-read');
const parsed = read([{ relPath: `subagents/agent-x.jsonl'; touch ${marker}; '`, offset: 0 }], 1000);
expect(existsSync(marker)).toBe(false);
if (typeof parsed !== 'object' || !parsed) throw new Error('no read');
expect(parsed.files.map((f) => f.relPath)).toEqual([
'main',
'subagents/agent-a1.jsonl',
'subagents/agent-b2.jsonl',
]);
});
it('reads exactly what the local reader reads (seeded random transcripts, requests and budgets)', async () => {
const failures: string[] = [];
for (let seed = 1; seed <= 60; seed++) {
let state = seed;
const rnd = () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
const ri = (a: number, b: number) => a + Math.floor(rnd() * (b - a + 1));
const dir = join(home, '.claude', 'projects', `fuzz-${seed}`);
const path = join(dir, `conv-${seed}.jsonl`);
const sub = join(dir, `conv-${seed}`, 'subagents');
mkdirSync(sub, { recursive: true });
const text = (n: number) => Array.from({ length: n }, () => ['a', 'ä', '€', '\n', '{', 'z'][ri(0, 5)]).join('');
writeFileSync(path, text(ri(0, 300)));
const names: string[] = [];
for (let i = ri(0, 5); i > 0; i--) {
const name = `agent-${['A', 'b', '0', '_', '-'][ri(0, 4)]}${ri(0, 99)}`;
names.push(name);
writeFileSync(join(sub, `${name}.jsonl`), text(ri(0, 200)));
if (rnd() < 0.6)
writeFileSync(join(sub, `${name}.meta.json`), JSON.stringify({ description: text(ri(0, 40)) }));
}
const relPaths = ['main', ...names.map((n) => `subagents/${n}.jsonl`), 'subagents/agent-missing.jsonl'];
const requests: EditSourceRequest[] = [];
for (const relPath of relPaths) {
if (rnd() < 0.5) continue;
const r: EditSourceRequest = { relPath, offset: ri(0, 350) };
if (rnd() < 0.4) r.len = ri(0, 250);
requests.push(r);
}
const budget = ri(0, 700);
const onlyRequested = rnd() < 0.3;
const local = await readClaudeEditSources(path, requests, budget, { onlyRequested });
const { code, stdout } = runSh(buildRemoteClaudeEditSourcesCommand(path, requests, budget, { onlyRequested }));
const remote = code === 0 ? parseRemoteClaudeEditSources(stdout) : null;
// The same read cut into several round trips must come out the same.
const split = await readRemoteEditSourceRounds(path, requests, budget, { onlyRequested }, runRead, ri(1, 3));
const shape = (r: typeof local) =>
r && {
transcriptId: r.transcriptId,
metas: r.metas,
files: r.files.map((f) => [f.relPath, f.size, f.startOffset, f.chunk.toString('hex')]),
};
try {
expect(shape(remote)).toEqual(shape(local));
expect(shape(split)).toEqual(shape(local));
} catch {
failures.push(`seed ${seed}: ${JSON.stringify({ requests, budget, onlyRequested })}`);
}
}
expect(failures).toEqual([]);
});
});
describe('a read too large for one ssh line', () => {
// `ssh` is a fake that runs the remote command on `sh`, so the whole ssh line really goes
// through the local `sh -c` argv: what must not hit E2BIG.
let bin: string;
beforeAll(() => {
bin = mkdtempSync(join(tmpdir(), 'codeman-fake-ssh-read-'));
writeFileSync(join(bin, 'ssh'), '#!/bin/sh\nfor arg; do :; done\nexec sh -c "$arg"\n');
chmodSync(join(bin, 'ssh'), 0o755);
});
afterAll(() => rmSync(bin, { recursive: true, force: true }));
/** A runner that goes through the fake ssh, counting its round trips. */
function overSsh(): { run: (command: string, maxBytes: number) => Promise<Buffer | null>; trips: () => number } {
let trips = 0;
return {
trips: () => trips,
run: async (command, maxBytes) => {
trips++;
try {
return execFileSync('sh', ['-c', buildRemoteEditHistoryCommand(REMOTE, command)], {
env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
cwd: home,
maxBuffer: maxBytes,
});
} catch (err) {
if ((err as { status?: number }).status === 3) return null;
throw err;
}
},
};
}
const shape = (r: Awaited<ReturnType<typeof readClaudeEditSources>>) =>
r && {
transcriptId: r.transcriptId,
metas: r.metas,
files: r.files.map((f) => [f.relPath, f.size, f.startOffset, f.chunk.toString('hex')]),
};
describe('as a read', () => {
const project = () => join(home, '.claude', 'projects', 'many');
const main = () => join(project(), 'conv-many.jsonl');
const sub = () => join(project(), 'conv-many', 'subagents');
const id = (i: number) => `a${String(i).padStart(4, '0')}`;
/** A cursor for each of 1500 subagents; only every 100th still has a file. */
const requests: EditSourceRequest[] = [
{ relPath: 'main', offset: 2 },
...Array.from({ length: 1500 }, (_, i) => ({ relPath: `subagents/agent-${id(i)}.jsonl`, offset: 1 })),
];
beforeAll(() => {
mkdirSync(sub(), { recursive: true });
writeFileSync(main(), 'main transcript\n');
for (let i = 0; i < 1500; i += 100) writeFileSync(join(sub(), `agent-${id(i)}.jsonl`), `subagent ${i}\n`);
// New files in the first, a middle and the last name range: each read once, with its meta.
for (const name of ['agent-0first', 'agent-a0750new', 'agent-zlast']) {
writeFileSync(join(sub(), `${name}.jsonl`), `${name}\n`);
writeFileSync(join(sub(), `${name}.meta.json`), `{"description":"${name}"}`);
}
});
it.each([0, 40, 8 * 1024 * 1024])('reads what the local reader reads, budget %i', async (budget) => {
const ssh = overSsh();
const read = await readRemoteEditSourceRounds(main(), requests, budget, {}, ssh.run);
expect(shape(read)).toEqual(shape(await readClaudeEditSources(main(), requests, budget)));
expect(ssh.trips()).toBe(5);
});
it('is null when the transcript goes away during the read', async () => {
let trips = 0;
const read = readRemoteEditSourceRounds(main(), requests, 100, {}, async (command) => {
return ++trips === 2 ? null : runRead(command);
});
await expect(read).resolves.toBeNull();
expect(trips).toBe(2);
});
it('fails as a whole on broken output from a later round trip', async () => {
let trips = 0;
const read = readRemoteEditSourceRounds(main(), requests, 100, {}, async (command) => {
return ++trips === 2 ? Buffer.from('\0other-conversation\0') : runRead(command);
});
await expect(read).rejects.toBeInstanceOf(RemoteFileAccessError);
expect(trips).toBe(2);
});
it('gives a later round trip the meta budget the earlier ones left', async () => {
const commands: string[] = [];
await readRemoteEditSourceRounds(
main(),
requests,
1000,
{},
async (command) => {
commands.push(command);
return runRead(command);
},
900
);
const budgets = commands.map((c) => Number(/mbudget=(\d+)/.exec(c)?.[1]));
expect(budgets).toHaveLength(2);
// `agent-0first` sorts before every request, so its meta went with the first round trip.
expect(budgets[0] - budgets[1]).toBe(
Buffer.byteLength('{"description":"agent-0first"}') + Buffer.byteLength('{"description":"agent-a0750new"}')
);
});
});
describe('through the sync', () => {
const project = () => join(home, '.claude', 'projects', 'long-history');
const sub = () => join(project(), 'conv-old', 'subagents');
const edit = (id: string, path: string) =>
JSON.stringify({
type: 'user',
timestamp: '2026-10-15T20:00:00.000Z',
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
},
}) + '\n';
it('keeps a conversation with 1200 subagents available, and a /clear gets past them', async () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-edit-store-'));
try {
mkdirSync(sub(), { recursive: true });
const oldMain = join(project(), 'conv-old.jsonl');
const newMain = join(project(), 'conv-new.jsonl');
writeFileSync(oldMain, '{}\n');
// Ids as long as claude's own: with these, 1200 requests do not fit one ssh line.
const name = (i: number) => `agent-a${String(i).padStart(16, '0')}`;
for (let i = 0; i < 1200; i++) writeFileSync(join(sub(), `${name(i)}.jsonl`), '{}\n');
const ssh = overSsh();
let path = oldMain;
const sync = new EditHistorySync({
transcriptPath: async () => path,
readSources: (_session, mainPath, requests, budget, opts) =>
readRemoteEditSourceRounds(mainPath, requests, budget, opts ?? {}, ssh.run),
probe: async () => [],
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
deleteStore: async () => {},
sweepStores: async () => 0,
});
const session: EditHistorySession = {
id: 's1',
mode: 'claude',
remote: REMOTE,
claudeSessionId: 'conv-old',
};
// The cursors of a history that was read before, one per file at its end.
const store = await sync.getStore('s1');
const seed = (source: string) =>
store.apply({ transcriptId: 'conv-old', source, startOffset: 0, chunk: Buffer.from('{}\n'), size: 3 });
seed('main');
for (let i = 0; i < 1200; i++) seed(`subagents/${name(i)}.jsonl`);
expect(store.knownSources()).toHaveLength(1201);
// Past the argv limit: one script would not even start.
const all = store.knownSources().map((relPath) => ({ relPath, offset: 3 }));
const line = buildRemoteEditHistoryCommand(REMOTE, buildRemoteClaudeEditSourcesCommand(oldMain, all, 1000));
expect(() => execFileSync('sh', ['-c', line])).toThrow(/E2BIG/);
// A known file that grew in the last name range, a new one in the middle.
writeFileSync(join(sub(), `${name(1199)}.jsonl`), `{}\n${edit('late', '/w/late.ts')}`);
const fresh = `${name(600)}new`;
writeFileSync(join(sub(), `${fresh}.jsonl`), edit('fresh', '/w/fresh.ts'));
writeFileSync(join(sub(), `${fresh}.meta.json`), '{"description":"new one","agentType":"Explore"}');
expect(await sync.sync(session)).toEqual({ state: 'ok' });
expect(ssh.trips()).toBe(4);
expect(
store
.list()
.map((e) => e.path)
.sort()
).toEqual(['/w/fresh.ts', '/w/late.ts']);
expect(store.getAgents()[fresh.slice('agent-'.length)]).toMatchObject({ description: 'new one' });
expect(store.knownSources()).toHaveLength(1202); // no cursor lost
// /clear: the stored conversation is read to its end first, then the new one.
writeFileSync(newMain, edit('after', '/w/after.ts'));
path = newMain;
expect(await sync.sync(session)).toEqual({ state: 'ok' });
expect(store.getTranscriptId()).toBe('conv-new');
expect(
store
.list()
.map((e) => e.path)
.sort()
).toEqual(['/w/after.ts', '/w/fresh.ts', '/w/late.ts']);
await store.remove();
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});
});
describe('a timeout ends ssh itself', () => {
// Node's timeout signals the local `sh -c`. On dash that shell stays the parent of a plain
// command, so only `exec` makes the signal reach ssh. The fake ssh records its pid and then
// waits; the test sends the timeout's SIGTERM itself, once that pid is there, so a slow
// machine cannot kill the fake before it wrote it.
it('kills the ssh process, not only the shell around it', async () => {
const bin = mkdtempSync(join(tmpdir(), 'codeman-fake-ssh-'));
const pidFile = join(bin, 'pid');
try {
writeFileSync(
join(bin, 'ssh'),
`#!/bin/sh\necho $$ > '${pidFile}.tmp'\nmv '${pidFile}.tmp' '${pidFile}'\nexec sleep 30\n`
);
chmodSync(join(bin, 'ssh'), 0o755);
const command = buildRemoteEditHistoryCommand(REMOTE, buildRemoteClaudeTranscriptLocateCommand(['conv-1']));
expect(command).toMatch(/^exec ssh /);
const child = exec(command, { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
await expect.poll(() => existsSync(pidFile), { timeout: 10_000 }).toBe(true);
const pid = Number(readFileSync(pidFile, 'utf8'));
child.kill();
const alive = (): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
try {
await expect.poll(alive, { timeout: 3000 }).toBe(false);
} finally {
if (alive()) process.kill(pid, 'SIGKILL');
}
} finally {
rmSync(bin, { recursive: true, force: true });
}
});
});
describe('under vitest', () => {
it('never opens a connection: the lookup and the read reject with a clear error', async () => {
await expect(remoteLocateClaudeTranscript(REMOTE, ['a'])).rejects.toBeInstanceOf(RemoteFileAccessError);
await expect(remoteReadClaudeEditSources(REMOTE, '/p/a.jsonl', [], 10)).rejects.toBeInstanceOf(
RemoteFileAccessError
);
});
});
describe('never wakes a host', () => {
// Invariant: only real user input or an explicit wake request may wake a host. The edit
// history reads a remote transcript whenever the panel is open, so nothing it can reach may
// hold the wake registry: a sleeping host must stay asleep and the read ends `unavailable`.
// `test/remote-wake.test.ts` pins who imports remote-wake; this pins that the edit-history
// read path reaches neither it nor the route module that owns the registry, transitively.
const SRC = fileURLToPath(new URL('../src', import.meta.url));
const ROOTS = [
'edit-history-local-source.ts',
'edit-history-parse.ts',
'edit-history-store.ts',
'remote-files.ts',
join('web', 'edit-history-sync.ts'),
join('web', 'routes', 'edit-history-routes.ts'),
];
function resolveImport(from: string, spec: string): string | null {
if (!spec.startsWith('.')) return null;
const base = join(dirname(from), spec).replace(/\.js$/, '');
for (const candidate of [`${base}.ts`, join(base, 'index.ts')]) {
try {
readFileSync(candidate);
return candidate;
} catch {
/* next candidate */
}
}
throw new Error(`unresolved import ${spec} in ${relative(SRC, from)}`);
}
it('the read path never imports remote-wake or session-routes, even transitively', () => {
const seen = new Set<string>();
const queue = ROOTS.map((r) => join(SRC, r));
while (queue.length) {
const file = queue.pop()!;
if (seen.has(file)) continue;
seen.add(file);
const text = readFileSync(file, 'utf-8');
// Static imports/re-exports start a line; a dynamic import() counts outside comment lines.
const specs = [
...text.matchAll(/^(?:import|export)\s[^;]*?\sfrom\s+['"]([^'"]+)['"]/gms),
...text.matchAll(/^(?!\s*(?:\*|\/\/)).*\bimport\(\s*['"]([^'"]+)['"]/gm),
].map((m) => m[1]);
for (const spec of specs) {
const next = resolveImport(file, spec);
if (next) queue.push(next);
}
}
const reached = [...seen].map((f) => relative(SRC, f));
expect(reached).toContain(join('web', 'edit-history-sync.ts'));
// `config/remote-wake-limits.ts` (the MAC-count constant of the host schema) may be reached; it wakes nothing.
expect(reached).not.toContain('remote-wake.ts');
expect(reached).not.toContain(join('web', 'routes', 'session-routes.ts'));
});
});
+32 -3
View File
@@ -23,6 +23,7 @@ import {
RemoteFileAccessError,
buildRemoteFileCommand,
buildRemoteProbeCommand,
remoteLoginShellLine,
buildRemoteReadCommand,
parseRemoteProbeRecord,
parseRemoteProbeOutput,
@@ -55,6 +56,34 @@ function remoteFixture(overrides: Partial<SessionRemote> = {}): SessionRemote {
};
}
describe('remoteLoginShellLine', () => {
const run = (command: string) => execFileSync('sh', ['-c', command]).toString();
it('runs the script in sh, byte for byte', () => {
const script = [
`printf '%s\\n' "it's 100% done!"`,
`printf 'back\\\\slash\\n'`,
`echo "zürich $((6 * 7))"`,
`for x in a b; do echo "$x"; done`,
].join('\n');
expect(run(remoteLoginShellLine(script))).toBe(run(script));
expect(run(remoteLoginShellLine(script))).toContain("it's 100% done!\nback\\slash\nzürich 42\na\nb\n");
});
it('gives the login shell nothing it could read differently', () => {
// sshd runs this in the user's login shell, and fish and csh do not parse single quotes
// the way sh does. Neither is on CI, so this pins what makes their answer the same as
// sh's: one fixed wrapper around a word with no quote, newline, `!` or backslash pair.
const script = buildRemoteProbeCommand(["/srv/it's\n#!/bin/\\'100%.txt"]);
const match = /^sh -c 'eval "\$\(printf "\$1"\)"' sh '([\s\S]*)'$/.exec(remoteLoginShellLine(script));
expect(match).not.toBeNull();
expect(match![1]).toMatch(/^(?:[\x20-\x7e]|\\[0-7]{3})*$/);
expect(match![1].replace(/\\[0-7]{3}/g, '')).not.toMatch(/['\\%!]/);
});
});
describe('buildRemoteFileCommand', () => {
it('builds the ssh line from the shared connection args and one shellescaped command', () => {
const argv = shellArgv(buildRemoteFileCommand(remoteFixture(), 'cat /etc/hostname'));
@@ -65,7 +94,7 @@ describe('buildRemoteFileCommand', () => {
expect(argv).toContain('ConnectTimeout=10');
expect(argv).toContain('j@192.0.2.10');
// The remote command is ONE argument, whatever it contains.
expect(argv[argv.length - 1]).toBe('cat /etc/hostname');
expect(argv[argv.length - 1]).toBe(remoteLoginShellLine('cat /etc/hostname'));
expect(argv[argv.length - 2]).toBe('j@192.0.2.10');
});
@@ -95,7 +124,7 @@ describe('buildRemoteFileCommand', () => {
const command = "cat '/tmp/it''s here' ; rm -rf ~ #";
const argv = shellArgv(buildRemoteFileCommand(remoteFixture(), command));
expect(argv[argv.length - 1]).toBe(command);
expect(argv[argv.length - 1]).toBe(remoteLoginShellLine(command));
expect(argv).not.toContain('rm');
expect(argv).not.toContain('-rf');
});
@@ -115,7 +144,7 @@ describe('buildRemoteProbeCommand', () => {
const script = buildRemoteProbeCommand([nasty]);
expect(script).toContain(`probe 0 '/srv/case/it'\\''s $(touch /tmp/pwned).txt'`);
expect(shellArgv(buildRemoteFileCommand(remoteFixture(), script)).at(-1)).toBe(script);
expect(shellArgv(buildRemoteFileCommand(remoteFixture(), script)).at(-1)).toBe(remoteLoginShellLine(script));
});
});
+27 -4
View File
@@ -2,6 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { listClis } from '../src/config/cli-registry/registry.js';
import {
defaultRemoteCommandForMode,
readRemoteCases,
@@ -13,7 +14,7 @@ import {
writeRemoteCases,
writeRemoteHosts,
} from '../src/remote-hosts.js';
import { RemoteHostSchema } from '../src/web/schemas.js';
import { DockerHostSchema, RemoteHostSchema } from '../src/web/schemas.js';
describe('remote-hosts domain', () => {
let dir: string | null = null;
@@ -58,14 +59,18 @@ describe('remote-hosts domain', () => {
});
it('returns safe mode defaults and remote display values', () => {
expect(defaultRemoteCommandForMode('shell')).toBe('exec "${SHELL:-/bin/sh}" -i -l');
expect(defaultRemoteCommandForMode('shell')).toBe(
'{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@"; }'
);
// Routed through an interactive login shell so per-user PATH entries (e.g.
// ~/.local/bin, ~/.opencode/bin) resolve — a bare `exec codex` sees only
// sshd's minimal default PATH and fails with "command not found".
expect(defaultRemoteCommandForMode('codex')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'codex\'');
expect(defaultRemoteCommandForMode('codex')).toBe(
'{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'codex\'; }'
);
// Mirrors the local claude default so the remote agent runs non-interactively.
expect(defaultRemoteCommandForMode('claude')).toBe(
'exec "${SHELL:-/bin/sh}" -i -l -c \'claude --dangerously-skip-permissions\''
'{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'claude --dangerously-skip-permissions\'; }'
);
expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local');
expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe(
@@ -126,6 +131,24 @@ describe('remote-hosts domain', () => {
});
});
describe('per-mode command overrides', () => {
const modes = ['shell', ...listClis().map((cli) => cli.id)];
const host = { id: 'h', label: 'H', host: '10.0.0.1', username: 'u' };
// `.strict()` refuses an unknown key, so a mode missing here cannot take an override, and
// `commands.*` is the documented way out for a login shell the stock launch cannot run.
it.each(modes)('accepts a %s override on a remote host and a docker host', (mode) => {
const commands = { [mode]: 'exec my-agent' };
const docker = { id: 'h', label: 'H', image: 'codeman/agent:base', commands };
expect(RemoteHostSchema.safeParse({ ...host, commands }).success).toBe(true);
expect(DockerHostSchema.safeParse(docker).success).toBe(true);
});
it('still refuses a key that is no mode', () => {
expect(RemoteHostSchema.safeParse({ ...host, commands: { nope: 'exec my-agent' } }).success).toBe(false);
});
});
describe('rehydrateRemoteHostFields', () => {
const persisted = {
hostId: 'hufflepuff',
+154
View File
@@ -0,0 +1,154 @@
/**
* @fileoverview Which shell parses a remote pane command (#760).
*
* tmux runs a ONE-argument command through the remote default-shell, the user's login shell, and
* bypasses it for two or more arguments. Codeman's own text carries `"${SHELL:-/bin/sh}"`, a parse
* error in fish that kills the pane with status 127, so it must reach `/bin/sh -c` as separate
* arguments. A per-host `commands.*` override is the user's text for their own shell: `source` and
* `[[` work in bash and zsh and die under a dash `/bin/sh`, so it must stay one argument.
*
* CI has no fish, so this reads the argv tmux would receive instead: the real builder's string goes
* through a real sh with `ssh` and `tmux` defined as functions that report their arguments.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { listClis } from '../src/config/cli-registry/registry.js';
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
import type { SessionMode, SessionRemote } from '../src/types.js';
const SESSION_ID = 'abcd1234-0000-0000-0000-000000000000';
const remote: SessionRemote = {
hostId: 'gpu',
label: 'GPU',
host: '10.0.0.42',
username: 'ubuntu',
remotePath: '/home/ubuntu/it is work',
};
/** The one argument ssh would send, as the login shell on the far end receives it. */
function sshArgument(launch: string): string {
return execFileSync('/bin/sh', ['-c', `ssh() { for a; do l="$a"; done; printf '%s' "$l"; }; ${launch}`], {
encoding: 'utf8',
});
}
/** What follows `new-session … -c <dir>`, up to the end of that tmux command. */
function paneArgv(launch: string): string[] {
const argv = execFileSync('/bin/sh', ['-c', `tmux() { printf '%s\\0' "$@"; }; ${sshArgument(launch)}`], {
encoding: 'utf8',
})
.split('\0')
.slice(0, -1);
const head = argv.slice(0, argv.indexOf(';'));
return head.slice(head.indexOf('-c', head.indexOf('new-session')) + 2);
}
const launches: Array<[string, Parameters<typeof buildRemoteLaunchCommand>[0]]> = [
...listClis().map((cli): [string, Parameters<typeof buildRemoteLaunchCommand>[0]] => [
cli.id,
{ mode: cli.id as SessionMode, remote, sessionId: SESSION_ID },
]),
['shell', { mode: 'shell', remote, sessionId: SESSION_ID }],
['claude resume', { mode: 'claude', remote, sessionId: SESSION_ID, resumeSessionId: SESSION_ID }],
['omp resume', { mode: 'omp', remote, sessionId: SESSION_ID, resumeSessionId: 'abc123' }],
];
describe('the shell that parses a remote pane command', () => {
it.each(launches)('%s: Codeman text goes to /bin/sh, never the default-shell', (_name, options) => {
const argv = paneArgv(buildRemoteLaunchCommand(options));
expect(argv.slice(0, 2)).toEqual(['/bin/sh', '-c']);
expect(argv).toHaveLength(3);
expect(argv[2]).toContain(`cd '/home/ubuntu/it is work' && `);
});
it('leaves an override as one argument, parsed by the remote default-shell', () => {
const override = 'source ~/env.sh && [[ -x ~/.local/bin/codx ]] && exec codx';
const argv = paneArgv(
buildRemoteLaunchCommand({
mode: 'codex',
remote: { ...remote, commands: { codex: override } },
sessionId: SESSION_ID,
})
);
expect(argv).toEqual([`cd '/home/ubuntu/it is work' && ${override}`]);
});
it('keeps the rest of the tmux chain behind the longer pane command', () => {
const sent = sshArgument(buildRemoteLaunchCommand({ mode: 'shell', remote, sessionId: SESSION_ID }));
expect(sent).toContain(String.raw` \; set -t codeman-ssh-abcd1234 status off \; `);
expect(sent).toMatch(/set -t codeman-ssh-abcd1234 remain-on-exit failed$/);
});
});
describe('the login flags a remote launch picks on the far end', () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'remote-login-flags-'));
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
/** A shell that reports what it was started with, under the name the launch reads. */
function fakeShell(name: string): string {
const path = join(root, 'bin', name);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `#!/bin/sh\nfor a; do printf '%s\\0' "$a"; done > "${root}/argv"\n`, { mode: 0o755 });
return path;
}
/** Runs the pane argv tmux would receive, in a real sh, with `$SHELL` as given. */
function runPane(mode: SessionMode, env: NodeJS.ProcessEnv, remotePath = root): void {
const [sh, flag, text] = paneArgv(
buildRemoteLaunchCommand({ mode, remote: { ...remote, remotePath }, sessionId: SESSION_ID })
);
execFileSync(sh, [flag, text], { env, stdio: 'ignore' });
}
function shellArgv(): string[] {
return readFileSync(join(root, 'argv'), 'utf8').split('\0').slice(0, -1);
}
it.each(['bash', 'zsh', 'dash', 'sh', 'ksh', 'fish'])('%s starts as an interactive login shell', (name) => {
runPane('codex', { ...process.env, SHELL: fakeShell(name) });
expect(shellArgv()).toEqual(['-i', '-l', '-c', 'codex']);
runPane('shell', { ...process.env, SHELL: fakeShell(name) });
expect(shellArgv()).toEqual(['-i', '-l']);
});
// tcsh exits on `-i -l -c`, `-l -c` and `-i -l`: every stock launch was a dead pane on such a host.
it.each(['tcsh', 'csh'])('%s starts with neither flag', (name) => {
runPane('codex', { ...process.env, SHELL: fakeShell(name) });
expect(shellArgv()).toEqual(['-c', 'codex']);
runPane('shell', { ...process.env, SHELL: fakeShell(name) });
expect(shellArgv()).toEqual([]);
});
it('goes by the name of the shell, not by a folder on its path', () => {
runPane('codex', { ...process.env, SHELL: fakeShell('tcsh/bash') });
expect(shellArgv()).toEqual(['-i', '-l', '-c', 'codex']);
});
it.each(launches)('%s: a failed cd starts no shell', (_name, options) => {
const [sh, flag, text] = paneArgv(
buildRemoteLaunchCommand({ ...options, remote: { ...remote, remotePath: join(root, 'missing') } })
);
expect(() =>
execFileSync(sh, [flag, text], { env: { ...process.env, SHELL: fakeShell('tcsh') }, stdio: 'ignore' })
).toThrow();
expect(existsSync(join(root, 'argv'))).toBe(false);
});
it('starts /bin/sh when SHELL is unset', () => {
const { SHELL: _unset, ...env } = process.env;
const bin = dirname(fakeShell('codex'));
runPane('codex', { ...env, PATH: `${bin}:${env.PATH ?? ''}`, HOME: root });
expect(shellArgv()).toEqual([]);
});
});
+3 -2
View File
@@ -157,9 +157,10 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`;
const path = sh('/home/ubuntu/work');
const paneCommand = `cd ${path} && exec "\${SHELL:-/bin/sh}" -i -l`;
const flags = 'set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac';
const paneCommand = `cd ${path} && { ${flags}; exec "\${SHELL:-/bin/sh}" "$@"; }`;
const tmuxInvocation = [
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} /bin/sh -c ${sh(paneCommand)}`,
`set -t ${remoteName} status off`,
`set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`,
+223
View File
@@ -0,0 +1,223 @@
/**
* @fileoverview Right-to-left text in rendered markdown (`_applyBlockDirection`, issue #715).
*
* The Response Viewer and the File Viewer's markdown view rendered every block left to
* right, so a Persian, Arabic or Hebrew answer was left-aligned, its punctuation landed on
* the wrong end and its list markers sat on the left. `_applyBlockDirection` now gives each
* OUTERMOST prose block `dir="auto"` (the browser picks the direction from the block's own
* first letter), and `_renderMarkdown` pins code to `dir="ltr"`, which also keeps a code
* span from deciding a sentence's direction, since `dir="auto"` skips descendants that
* carry a `dir`.
*
* The pass runs LAST, on the DOM, after the linkifier: an element that already carries
* `dir="auto"` re-resolves its direction on every insertion under it, which made a
* code-only or path-only document quadratic. Two tests below pin that order.
*
* jsdom does not resolve `dir="auto"`, so this pins the attributes the browser acts on and
* the order, never the cost; what Chromium makes of them was measured when the change was
* made.
*
* Drives the SHIPPING artifacts (vendored `marked`, vendored DOMPurify + `sanitize-html.js`,
* `constants.js` and `app.js`) in a `vm` with a jsdom document, the technique from
* response-viewer-external-links.test.ts. No port / server needed.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const { document, NodeFilter } = dom.window;
function loadShippingSanitizer(): (html: string) => string {
const dpModule: { exports: unknown } = { exports: {} };
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', publicFile('vendor/dompurify.min.js'))(dpModule, dpModule.exports);
const DOMPurify = (dpModule.exports as (win: unknown) => unknown)(jsdomWindow);
const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = {
exports: {},
};
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', publicFile('sanitize-html.js'))(sanModule, sanModule.exports);
const create = sanModule.exports.createMarkdownSanitizer;
if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported');
return create(DOMPurify);
}
function loadShippingMarked(): { parse: (src: string, opts?: unknown) => string } {
const module: { exports: unknown } = { exports: {} };
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', publicFile('vendor/marked.min.js'))(module, module.exports);
return module.exports as { parse: (src: string, opts?: unknown) => string };
}
type RenderApp = {
_renderMarkdown(text: string, opts?: { breaks?: boolean }): string;
_linkifyFilePaths(root: unknown): void;
_applyBlockDirection(root: unknown): void;
_buildResponseViewerMessage(text: string, role: string, agentLabel: string): HTMLElement;
};
function loadCodemanAppClass(): { prototype: RenderApp } {
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
fetch: vi.fn(),
document,
NodeFilter,
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
// _sanitizeHtml fails closed without the sanitizer, which would make every
// assertion below vacuous.
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), sanitizeMarkdownHtml: loadShippingSanitizer() },
marked: loadShippingMarked(),
MobileDetection: {},
});
vm.runInContext(
`${publicFile('constants.js')}\n${publicFile('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
context
);
return (context as { __CodemanApp: { prototype: RenderApp } }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
const app = Object.create(CodemanApp.prototype) as RenderApp;
/** Render markdown the way both viewers do (pipeline, linkifier, direction pass) and return the element. */
function render(markdown: string, opts?: { breaks?: boolean }): HTMLElement {
const root = document.createElement('div');
root.className = 'rv-text';
root.innerHTML = app._renderMarkdown(markdown, opts);
app._linkifyFilePaths(root);
app._applyBlockDirection(root);
return root as unknown as HTMLElement;
}
const dirOf = (el: Element | null) => el?.getAttribute('dir') ?? null;
describe('response viewer right-to-left text (#715)', () => {
it('lets every top-level prose block pick its own direction', () => {
const root = render(
[
'# عنوان',
'',
'سلام، این یک تست است.',
'',
'An English paragraph.',
'',
'- مورد اول',
'- مورد دوم',
'',
'1. one',
'',
'> نقل قول',
].join('\n')
);
expect(dirOf(root.querySelector('h1'))).toBe('auto');
const paragraphs = Array.from(root.querySelectorAll(':scope > p'));
expect(paragraphs).toHaveLength(2);
for (const p of paragraphs) expect(dirOf(p)).toBe('auto');
expect(dirOf(root.querySelector('ul'))).toBe('auto');
expect(dirOf(root.querySelector('ol'))).toBe('auto');
expect(dirOf(root.querySelector('blockquote'))).toBe('auto');
});
it('leaves nested blocks to inherit, so a list keeps its markers and indent on one side', () => {
// A `dir="auto"` per item would resolve each item on its own while the list's
// padding stays on the list's side, which hung RTL markers outside the box.
const root = render(['- مورد اول', ' - تو در تو', '', '> پاراگراف اول', '>', '> پاراگراف دوم'].join('\n'));
for (const li of Array.from(root.querySelectorAll('li'))) expect(li.hasAttribute('dir')).toBe(false);
expect(root.querySelector('ul ul')?.hasAttribute('dir')).toBe(false);
for (const p of Array.from(root.querySelectorAll('blockquote p'))) expect(p.hasAttribute('dir')).toBe(false);
});
it('gives a table its direction on the scroll wrapper, so an RTL table scrolls from its first column', () => {
const root = render('| ستون | مقدار |\n| --- | --- |\n| الف | ۱ |');
const wrap = root.querySelector('.rv-table-wrap');
expect(wrap, 'tables are still wrapped').not.toBeNull();
expect(dirOf(wrap)).toBe('auto');
expect(root.querySelector('table')?.hasAttribute('dir')).toBe(false);
for (const cell of Array.from(root.querySelectorAll('th, td'))) expect(cell.hasAttribute('dir')).toBe(false);
});
it('pins code blocks and inline code left to right', () => {
const root = render('`npm install` را اجرا کن.\n\n```\nconst a = 1;\n```');
expect(dirOf(root.querySelector('p > code'))).toBe('ltr');
expect(dirOf(root.querySelector('pre'))).toBe('ltr');
// The code-block toolbar shares the template walk; it must survive the new pass.
expect(root.querySelector('.rv-code-wrap .rv-copy-btn')).not.toBeNull();
expect(root.querySelector('.rv-code-wrap')?.hasAttribute('dir')).toBe(false);
});
it('keeps a dir the agent wrote, and what it covers', () => {
// The sanitizer allows `dir`; an explicit choice must not be replaced by a guess.
const root = render('<div dir="rtl"><p>inherits</p></div>\n\n<p dir="ltr">explicit</p>');
expect(dirOf(root.querySelector('div[dir]'))).toBe('rtl');
expect(root.querySelector('div[dir] > p')?.hasAttribute('dir')).toBe(false);
expect(dirOf(Array.from(root.querySelectorAll('p')).find((p) => p.textContent === 'explicit') ?? null)).toBe('ltr');
});
it('does the same for the File Viewer markdown view (breaks: false)', () => {
const root = render('پاراگراف اول\nادامه همان پاراگراف', { breaks: false });
expect(dirOf(root.querySelector('p'))).toBe('auto');
});
it('pins a linkified file path left to right, so it does not decide the sentence direction', () => {
const root = render('فایل /home/user/project/config.json را باز کن.');
const link = root.querySelector('a.rv-path');
expect(link?.textContent).toBe('/home/user/project/config.json');
expect(dirOf(link)).toBe('ltr');
});
it('does not pin a markdown link label, which is prose in the sentence language', () => {
const root = render('[گزارش حادثه](/tmp/report.json)');
const link = root.querySelector('a.rv-path-link');
expect(link, 'a path-target link takes the rv-path shape').not.toBeNull();
expect(link?.hasAttribute('dir')).toBe(false);
});
it('never puts dir="auto" in the rendered string, where it would make the parse and the linkifier quadratic', () => {
// An element carrying dir="auto" re-resolves its direction on every insertion
// under it. A document whose letters all sit in `dir` children walks the whole
// block each time, which froze the tab for seconds on an all-code list.
const html = app._renderMarkdown('- `f1`\n- `f2`\n\nسلام\n\n| ستون |\n| --- |\n| الف |');
expect(html).not.toContain('dir="auto"');
expect(html).toContain('<code dir="ltr">f1</code>');
});
it('gives a Response Viewer message its direction only after the linkifier finished', () => {
const probe = Object.create(app) as RenderApp;
let autoDuringLinkify = -1;
probe._linkifyFilePaths = (root) => {
autoDuringLinkify = (root as Element).querySelectorAll('[dir="auto"]').length;
app._linkifyFilePaths(root);
};
const message = probe._buildResponseViewerMessage('/home/user/project/config.json را ببین.', 'assistant', 'Claude');
expect(autoDuringLinkify).toBe(0);
const para = message.querySelector('.rv-text p');
expect(dirOf(para)).toBe('auto');
expect(dirOf(para?.querySelector('a.rv-path') ?? null)).toBe('ltr');
});
});
+15 -1
View File
@@ -20,7 +20,7 @@ import {
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
@@ -178,6 +178,20 @@ describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder',
expect(Object.keys(linked())).toEqual(['twin']);
});
it('a registry that is not a JSON object is a 409 that keeps the folder, names it and leaves the file alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'refused');
mkdirSync(dirname(LINKED()), { recursive: true });
writeFileSync(LINKED(), '[]');
const res = await clone(app, { name: 'refused', repository: origin, path: target });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain(target);
expect(res.json().error).toContain('Use Link Existing');
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(readFileSync(LINKED(), 'utf8')).toBe('[]');
});
it('a direct clone whose git-slot wait times out leaves what was written into the folder meanwhile', async () => {
const target = join(workDir, 'queued');
mkdirSync(target);
+24 -7
View File
@@ -8,7 +8,7 @@
* to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through
* safeRmHomeTree, so a run can only ever delete what it created.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
existsSync,
mkdirSync,
@@ -19,6 +19,7 @@ import {
symlinkSync,
writeFileSync,
} from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
@@ -40,7 +41,7 @@ beforeEach(() => {
if (!sandboxed()) {
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
}
// Recursive: the rollback tests turn the registry into a directory.
// Recursive, so a test that left anything at the registry's path cannot leak into the next one.
safeRmHomeTree(LINKED());
// Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private).
workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-')));
@@ -134,20 +135,36 @@ describe('POST /api/cases with a custom path', () => {
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
// The registry is replaced by renaming a temp file over it: fail that rename.
const realRename = fs.rename;
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === LINKED()) throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
return realRename(from, to);
});
let res;
try {
res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
} finally {
spy.mockRestore();
}
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'doomed'))).toBe(false);
expect(linked()).toEqual({});
});
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
it('undoes only the scaffold inside an empty folder the user picked when the registry is refused', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'picked'));
mkdirSync(LINKED(), { recursive: true });
// A registry that does not parse is never written over (that would unlink every other case).
const torn = '{\n "kept": "/somewhere"\n}tail"\n}';
writeFileSync(LINKED(), torn);
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
expect(res.statusCode).toBe(500);
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('is not a valid JSON object');
expect(existsSync(join(work(), 'picked'))).toBe(true);
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
expect(readFileSync(LINKED(), 'utf8')).toBe(torn);
});
it('a request without a path still creates under the cases directory, as before', async () => {
+111 -17
View File
@@ -6,7 +6,6 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -26,6 +25,7 @@ import { SETTINGS_PATH, resolveCasesDir } from '../../src/web/route-helpers.js';
import { caseSessionSettled, claudeProjectDirName } from '../../src/case-rename.js';
import { Session } from '../../src/session.js';
import { safeRmHomeTree } from '../mocks/index.js';
import { mutateLinkedCases } from '../../src/web/linked-cases-writer.js';
import { mutateSettingsFile } from '../../src/web/settings-writer.js';
const LINKED = () => dataPath('linked-cases.json');
@@ -36,6 +36,32 @@ let claudeCfg = '';
const made: string[] = [];
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
/**
* The registry is replaced by renaming a temp file over it, which a read-only file does not stop, so a
* failed registry write is injected at that rename. `before` runs first (another writer landing then).
*/
const failRegistryWrite = (before?: () => Promise<unknown>, alsoFail?: (from: string) => boolean) => {
const realRename = fs.rename;
return vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === LINKED()) {
if (before) await before();
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
}
if (alsoFail?.(String(from))) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
return realRename(from, to);
});
};
/**
* Runs `during` right after `folder` moved: past every check the route makes before it writes the
* registry, and before that write starts. Every other rename goes through untouched.
*/
const afterFolderMove = (folder: string, during: () => Promise<unknown>) => {
const realRename = fs.rename;
return vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
await realRename(from, to);
if (from === folder) await during();
});
};
const settings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
const rename = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], name: string, payload: object) =>
app.inject({ method: 'POST', url: `/api/cases/${name}/rename`, payload });
@@ -278,8 +304,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
// the registry reads fine but cannot be written: the rename must undo the folder move
chmodSync(LINKED(), 0o444);
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
const spy = failRegistryWrite();
let res;
try {
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
} finally {
spy.mockRestore();
}
expect(res.statusCode).toBe(500);
expect(existsSync(real)).toBe(true);
expect(linked()).toEqual({ proj: real });
@@ -294,14 +325,9 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
chmodSync(LINKED(), 0o444);
// Another writer (a settings save) lands after the rename wrote settings.json and before the
// registry write fails.
const realWriteFile = fs.writeFile;
const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => {
if (file === LINKED()) await mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true }));
return realWriteFile(file, data, options);
});
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
@@ -311,6 +337,74 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
});
describe('when the registry changes after the first check', () => {
let real = '';
type App = Awaited<ReturnType<typeof createRouteTestHarness>>['app'];
const renameRacing = async (app: App, during: () => Promise<unknown>) => {
const spy = afterFolderMove(real, during);
try {
return await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
} finally {
spy.mockRestore();
}
};
const expectFolderAndSettingsBack = () => {
expect(existsSync(real)).toBe(true);
expect(existsSync(join(work, 'renamed'))).toBe(false);
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
};
beforeEach(() => {
real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
});
it('a name linked meanwhile is 409 EXISTS: the link stands and the rename is undone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const other = join(work, 'other');
mkdirSync(other);
const res = await renameRacing(app, () =>
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'renamed', path: other } })
);
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ errorCode: 'ALREADY_EXISTS', renameCode: 'EXISTS', stoppedSessions: 0 });
expect(linked()).toEqual({ proj: real, renamed: other });
expectFolderAndSettingsBack();
});
it('an old entry unlinked meanwhile is 409 BUSY: the rename does not bring it back', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await renameRacing(app, () => app.inject({ method: 'DELETE', url: '/api/cases/proj' }));
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY', stoppedSessions: 0 });
expect(linked()).toEqual({});
expectFolderAndSettingsBack();
});
it('an old entry pointed at another folder meanwhile is 409 BUSY: that entry is kept', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const other = join(work, 'other');
const res = await renameRacing(app, () => mutateLinkedCases((current) => ({ ...current, proj: other })));
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' });
expect(linked()).toEqual({ proj: other });
expectFolderAndSettingsBack();
});
it('a registry that stopped parsing is refused 409 CONFLICT and left byte for byte', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const torn = `{ "proj": "${real}" }tail"`;
const res = await renameRacing(app, async () => writeFileSync(LINKED(), torn));
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', stoppedSessions: 0 });
expect(res.json().error).toContain('is not a valid JSON object');
expect(res.json()).not.toHaveProperty('renameCode');
expect(readFileSync(LINKED(), 'utf8')).toBe(torn);
expectFolderAndSettingsBack();
});
});
it('refuses to rename while settings.json is not a JSON object, leaving the folder and the file alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
@@ -351,13 +445,8 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
chmodSync(LINKED(), 0o444);
const moved = join(work, 'renamed');
const realRename = fs.rename;
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (from === moved) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
return realRename(from, to);
});
const spy = failRegistryWrite(undefined, (from) => from === moved);
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
@@ -373,9 +462,14 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
chmodSync(LINKED(), 0o444);
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
const spy = failRegistryWrite();
let res;
try {
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
} finally {
spy.mockRestore();
}
expect(res.statusCode).toBe(500);
expect(res.json().stoppedSessions).toBe(1);
expect(ctx.cleanupSession).toHaveBeenCalledWith('s1', true, 'case_rename');
+7
View File
@@ -54,6 +54,12 @@ vi.mock('node:fs/promises', () => ({
throw err;
}),
writeFile: vi.fn(async () => {}),
// What the linked-cases writer needs to replace the registry atomically (temp file + rename).
realpath: vi.fn(async (p: string) => p),
mkdir: vi.fn(async () => undefined),
chmod: vi.fn(async () => {}),
rename: vi.fn(async () => {}),
rm: vi.fn(async () => {}),
},
}));
@@ -704,6 +710,7 @@ describe('case-routes', () => {
});
// linked-cases.json doesn't exist yet
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const res = await harness.app.inject({
method: 'POST',
+12 -7
View File
@@ -62,7 +62,7 @@ async function setup(
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: read,
probe: async (p) => p.map(() => null),
probe: async (_session, p) => p.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir, debounceMs: 0 });
opened.push(store);
@@ -183,7 +183,6 @@ describe('GET /api/sessions/:id/edits', () => {
});
it.each([
['remote', { remote: { host: 'huff' } }],
['docker', { docker: { image: 'x' } }],
['mode', { mode: 'shell' }],
])('answers unsupported (%s) without reading or loading a store', async (reason, patch) => {
@@ -242,7 +241,12 @@ describe('POST /api/sessions/:id/edits/older', () => {
const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join('');
const full = Buffer.from(lines);
const read = vi.fn(
async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[], budget: number) => {
async (
_session: unknown,
_path: string,
requests: readonly { relPath: string; offset: number; len?: number }[],
budget: number
) => {
const req = requests.find((r) => r.relPath === 'main');
const start = req ? req.offset : full.length - budget;
const len = req?.len ?? full.length - start;
@@ -260,12 +264,13 @@ describe('POST /api/sessions/:id/edits/older', () => {
expect(older.entries.length).toBeGreaterThan(first.entries.length);
});
it('answers unsupported for a remote session without reading', async () => {
it('reads a remote session, handing the reader that session', async () => {
const { app, read } = await setup();
session.remote = { host: 'huff' };
const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' });
expect(read).not.toHaveBeenCalled();
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'ok', entries: [{ path: '/w/src/a.ts' }] });
expect(read).toHaveBeenCalledTimes(1);
expect((read.mock.calls[0] as unknown[])[0]).toMatchObject({ id: session.id, remote: { host: 'huff' } });
});
});
+21 -2
View File
@@ -99,7 +99,7 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
undefined,
{ alwaysApprove: true, model: 'grok-4.5' }
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, model: 'gpt-5' });
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, networkAccess: false, model: 'gpt-5' });
expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: false, model: 'gemini-3-pro' });
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
});
@@ -134,7 +134,26 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
undefined,
undefined
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, reasoningEffort: 'xhigh' });
expect(out.codexConfig).toEqual({
dangerouslyBypassApprovals: false,
networkAccess: false,
reasoningEffort: 'xhigh',
});
});
it("forces a non-granted owner's codex network access off, as when the launch default filled it", async () => {
// applyLaunchDefaults runs BEFORE this clamp, so App Settings → Allow network access
// arrives here as an ordinary `networkAccess: true` in a sent config. It widens the
// sandbox to every host, so a non-granted owner must not get it.
const out = await _clampExternalCliBypassForOwner(
'peon',
{ networkAccess: true },
undefined,
undefined,
undefined,
undefined
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, networkAccess: false });
});
it('leaves codex/antigravity/grok absent when nothing was sent (they already spawn safe)', async () => {
@@ -0,0 +1,204 @@
/**
* @fileoverview `GET /api/sessions/:id/files?root=live` (#765).
*
* The listing starts at the worktree the pane is in, and every `path` stays relative to
* `workingDir`: that is what keeps the preview, raw and edit routes working unchanged, inside the
* same confinement. Real filesystem on purpose, since the walk is the thing under test.
*/
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
const PREFIX = '.claude/worktrees/probe';
describe('Files panel listing of a live worktree', () => {
let harness: RouteTestHarness;
let repo: string;
let worktree: string;
/** Removed after each test: a folder of the server user's outside the session folder. */
let outside: string | undefined;
const list = async (query = '') => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/files?depth=5${query}`,
});
expect(res.statusCode).toBe(200);
return res.json().data;
};
const names = (nodes: Array<{ name: string }>) => nodes.map((node) => node.name);
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
repo = mkdtempSync(join(homedir(), 'live-worktree-route-'));
worktree = join(repo, PREFIX);
mkdirSync(join(repo, 'src'), { recursive: true });
mkdirSync(join(worktree, 'src'), { recursive: true });
writeFileSync(join(repo, 'src', 'main-only.ts'), 'main');
writeFileSync(join(worktree, 'src', 'worktree-only.ts'), 'worktree');
Object.assign(harness.ctx._session, {
workingDir: repo,
liveWorktree: { name: 'probe', path: worktree },
});
});
afterEach(async () => {
await harness.app.close();
rmSync(repo, { recursive: true, force: true });
if (outside) rmSync(outside, { recursive: true, force: true });
outside = undefined;
});
/** A folder outside the session folder, holding a visible and a hidden file and a subfolder. */
const makeOutsideFolder = () => {
outside = mkdtempSync(join(homedir(), 'live-worktree-outside-'));
mkdirSync(join(outside, 'inner'));
writeFileSync(join(outside, '.hidden-outside'), 'secret');
writeFileSync(join(outside, 'inner', 'outside-file.txt'), 'secret');
return outside;
};
const expectSessionFolderListing = (data: {
root: string;
worktree: unknown;
tree: Array<{ name: string; children?: Array<{ path: string }> }>;
}) => {
expect(data.root).toBe(repo);
expect(JSON.stringify(data.tree)).not.toContain('outside');
expect(data.tree.find((node) => node.name === 'src')?.children?.[0].path).toBe('src/main-only.ts');
// Kept so the panel's comparison with the session's state settles instead of reloading forever.
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
};
it('lists the worktree with paths relative to the session folder', async () => {
const data = await list('&root=live');
expect(data.root).toBe(worktree);
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
expect(names(data.tree)).toEqual(['src']);
expect(data.tree[0].path).toBe(`${PREFIX}/src`);
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
});
it('does not need hidden files on, though the worktree lives under a dot folder', async () => {
const data = await list('&root=live&showHidden=false');
expect(data.totalFiles).toBe(1);
});
it('keeps the session folder without the parameter, and still reports the worktree', async () => {
const data = await list();
expect(data.root).toBe(repo);
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
expect(names(data.tree)).toEqual(['src']);
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
});
it('answers exactly as before for a session in no worktree', async () => {
Object.assign(harness.ctx._session, { liveWorktree: undefined });
const data = await list('&root=live');
expect(data).toEqual(await list());
expect(data.root).toBe(repo);
expect('worktree' in data).toBe(false);
});
it('falls back to the session folder when the worktree has gone, keeping the report', async () => {
rmSync(worktree, { recursive: true });
const data = await list('&root=live');
expect(data.root).toBe(repo);
// Kept so the panel's comparison with the session's state settles instead of reloading forever.
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
});
it('walks the session folder when a symlink replaced the worktree with a folder outside it', async () => {
rmSync(worktree, { recursive: true });
symlinkSync(makeOutsideFolder(), worktree);
expectSessionFolderListing(await list('&root=live&showHidden=true'));
});
it('does not find anything outside the session folder through such a symlink when searching', async () => {
rmSync(worktree, { recursive: true });
symlinkSync(makeOutsideFolder(), worktree);
const data = await list(`&root=live&showHidden=true&q=${encodeURIComponent('outside')}`);
expect(data.root).toBe(repo);
expect(data.matches).toEqual([]);
});
it('walks the session folder when a parent of the worktree is the symlink', async () => {
const parent = join(repo, '.claude/worktrees');
rmSync(parent, { recursive: true });
mkdirSync(join(makeOutsideFolder(), 'probe'));
writeFileSync(join(outside as string, 'probe', 'outside-file.txt'), 'secret');
symlinkSync(outside as string, parent);
expectSessionFolderListing(await list('&root=live&showHidden=true'));
});
it('walks the session folder when the worktree path is a symlink back to that folder', async () => {
rmSync(worktree, { recursive: true });
symlinkSync(repo, worktree);
expectSessionFolderListing(await list('&root=live'));
});
it('still lists a worktree of a workspace reached through a symlink, in the workspace spelling', async () => {
const link = `${repo}-link`;
symlinkSync(repo, link);
try {
const spelled = join(link, PREFIX);
Object.assign(harness.ctx._session, {
workingDir: link,
liveWorktree: { name: 'probe', path: spelled },
});
const data = await list('&root=live');
expect(data.root).toBe(spelled);
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
} finally {
rmSync(link);
}
});
it('searches the worktree: the query reads paths in the listing, the result in the session folder', async () => {
const data = await list(`&root=live&q=${encodeURIComponent('src/*.ts')}`);
expect(data.root).toBe(worktree);
expect(data.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/src/worktree-only.ts`]);
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
});
it('walks a large worktree in fair rounds, and the paths stay relative to the session folder', async () => {
mkdirSync(join(worktree, 'a-bulk'));
mkdirSync(join(worktree, 'z-later'));
for (let index = 0; index < 5100; index++) {
writeFileSync(join(worktree, 'a-bulk', `item-${index}.txt`), '');
}
writeFileSync(join(worktree, 'z-later', 'needle.ts'), 'x');
// The main checkout must play no part: this is the folder a walk seeded wrongly would list.
writeFileSync(join(repo, 'src', 'needle.ts'), 'main');
const data = await list('&root=live');
expect(data.root).toBe(worktree);
expect(data.truncated).toBe(true);
expect(names(data.tree)).toEqual(['a-bulk', 'src', 'z-later']);
const [bulk, , later] = data.tree;
expect(bulk).toMatchObject({ path: `${PREFIX}/a-bulk`, incomplete: true });
expect(bulk.children[0].path.startsWith(`${PREFIX}/a-bulk/`)).toBe(true);
expect(later).not.toHaveProperty('incomplete');
expect(later.children[0].path).toBe(`${PREFIX}/z-later/needle.ts`);
// A `/` in the query reads the listing's paths, and only the answer is in workingDir's terms.
const found = await list(`&root=live&q=${encodeURIComponent('z-later/*.ts')}`);
expect(found.root).toBe(worktree);
expect(found.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/z-later/needle.ts`]);
expect(found.truncated).toBe(true);
const anywhere = await list('&root=live&q=needle');
expect(anywhere.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/z-later/needle.ts`]);
});
it('serves a listed path through the unchanged preview route', async () => {
const path = `${PREFIX}/src/worktree-only.ts`;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=${encodeURIComponent(path)}`,
});
expect(res.json().success).toBe(true);
expect(res.json().data.content).toBe('worktree');
});
});
+227
View File
@@ -91,4 +91,231 @@ describe('files endpoint search mode', () => {
expect(body.data.matches).toHaveLength(1);
expect(body.data.matches[0]).toMatchObject({ name: 'nested', type: 'directory' });
});
it('shares the scan budget across sibling folders in both tree and search modes', async () => {
const bulk = join(root, 'a-bulk');
const middle = join(root, 'm-middle');
const later = join(root, 'z-later');
mkdirSync(bulk);
mkdirSync(middle);
mkdirSync(later);
for (let index = 0; index < 5100; index++) {
writeFileSync(join(bulk, `item-${index}.txt`), '');
}
writeFileSync(join(middle, 'middle.ts'), 'x\n');
writeFileSync(join(later, 'needle.ts'), 'x\n');
try {
const tree = await get('');
const treePaths = tree.data.tree.map((entry: { path: string }) => entry.path);
expect(treePaths).toContain('a-bulk');
expect(treePaths).toContain('m-middle');
expect(treePaths).toContain('src');
expect(treePaths).toContain('z-later');
expect(tree.data.truncated).toBe(true);
expect(tree.data.totalFiles + tree.data.totalDirectories).toBeLessThanOrEqual(5000);
const node = (path: string) => tree.data.tree.find((entry: { path: string }) => entry.path === path);
expect(node('a-bulk').incomplete).toBe(true);
expect(node('a-bulk').children.length).toBeGreaterThan(0);
// Finished folders do not carry the key at all, not even as false.
expect(node('z-later')).not.toHaveProperty('incomplete');
expect(node('z-later').children.map((entry: { path: string }) => entry.path)).toEqual([
join('z-later', 'needle.ts'),
]);
expect(node('m-middle')).not.toHaveProperty('incomplete');
const search = await get('?q=needle');
expect(search.data.matches.map((match: { path: string }) => match.path)).toContain(join('z-later', 'needle.ts'));
expect(search.data.truncated).toBe(true);
} finally {
rmSync(bulk, { recursive: true, force: true });
rmSync(middle, { recursive: true, force: true });
rmSync(later, { recursive: true, force: true });
}
});
it('adds nothing to a listing that fits the budget', async () => {
const { data } = await get('');
expect(data.truncated).toBe(false);
expect(JSON.stringify(data)).not.toContain('incomplete');
const dir = data.tree.find((entry: { name: string }) => entry.name === 'src');
expect(Object.keys(dir)).toEqual(['name', 'path', 'type', 'children']);
const file = data.tree.find((entry: { name: string }) => entry.name === 'README.md');
expect(Object.keys(file)).toEqual(['name', 'path', 'type', 'size', 'extension']);
});
it('flags folders the budget ran out on part way through a round, read or not', async () => {
// 3,000 folders use 3,000 of the 5,000 in the first round. The 2,000 left then buy one file
// each, so the round ends with 1,000 folders never read: the last of those is the one a budget
// check after the shift would drop from the queue, and with it the flag.
const wide = mkdtempSync(join(tmpdir(), 'codeman-file-wide-'));
for (let index = 0; index < 3000; index++) {
const dir = join(wide, `dir-${String(index).padStart(4, '0')}`);
mkdirSync(dir);
writeFileSync(join(dir, 'one.txt'), '');
writeFileSync(join(dir, 'two.txt'), '');
}
harness.ctx._session.workingDir = wide;
try {
const { data } = await get('');
expect(data.truncated).toBe(true);
expect(data.tree).toHaveLength(3000);
expect(data.tree.filter((node: { incomplete?: boolean }) => node.incomplete === true)).toHaveLength(3000);
const sizes = data.tree.map((node: { children: unknown[] }) => node.children.length);
expect(sizes.filter((size: number) => size === 1)).toHaveLength(2000);
expect(sizes.filter((size: number) => size === 0)).toHaveLength(1000);
} finally {
rmSync(wide, { recursive: true, force: true });
}
});
it('copes with the root itself being unfinished, and its subfolders still get read', async () => {
// The root has no node to flag. Its files stop the walk, but only after the folder it found.
const wide = mkdtempSync(join(tmpdir(), 'codeman-file-root-'));
mkdirSync(join(wide, 'first'));
writeFileSync(join(wide, 'first', 'inside.txt'), '');
for (let index = 0; index < 5100; index++) {
writeFileSync(join(wide, `item-${index}.txt`), '');
}
harness.ctx._session.workingDir = wide;
try {
const { data } = await get('');
expect(data.truncated).toBe(true);
expect(data.totalFiles + data.totalDirectories).toBe(5000);
expect(data.tree[0]).toMatchObject({ name: 'first', children: [{ name: 'inside.txt' }] });
expect(data.tree[0]).not.toHaveProperty('incomplete');
const search = await get('?q=inside');
expect(search.data.matches.map((match: { path: string }) => match.path)).toEqual([join('first', 'inside.txt')]);
expect(search.data.truncated).toBe(true);
} finally {
rmSync(wide, { recursive: true, force: true });
}
});
it('does not let a large folder alone in its round starve the subfolders it holds', async () => {
// One level below the walk root, with a second level of subfolder to reach.
const wide = mkdtempSync(join(tmpdir(), 'codeman-file-sole-'));
mkdirSync(join(wide, 'repo', 'src', 'lib'), { recursive: true });
writeFileSync(join(wide, 'repo', 'src', 'lib', 'needle.ts'), '');
for (let index = 0; index < 5100; index++) {
writeFileSync(join(wide, 'repo', `item-${index}.txt`), '');
}
harness.ctx._session.workingDir = wide;
try {
const { data } = await get('?q=needle');
expect(data.matches.map((match: { path: string }) => match.path)).toEqual([
join('repo', 'src', 'lib', 'needle.ts'),
]);
expect(data.truncated).toBe(true);
} finally {
rmSync(wide, { recursive: true, force: true });
}
});
it('does not let large sibling folders starve the subfolders they hold', async () => {
// Two in a round, so "alone in its round" is not what makes this go wrong: each folder's
// equal share used to be spent on its own files, leaving nothing for what it had just found.
const wide = mkdtempSync(join(tmpdir(), 'codeman-file-pair-'));
for (const name of ['a-big', 'b-big']) {
mkdirSync(join(wide, name, 'src'), { recursive: true });
writeFileSync(join(wide, name, 'src', 'needle.ts'), '');
for (let index = 0; index < 5100; index++) {
writeFileSync(join(wide, name, `item-${index}.txt`), '');
}
}
harness.ctx._session.workingDir = wide;
try {
const { data } = await get('?q=needle');
expect(data.matches.map((match: { path: string }) => match.path).sort()).toEqual([
join('a-big', 'src', 'needle.ts'),
join('b-big', 'src', 'needle.ts'),
]);
} finally {
rmSync(wide, { recursive: true, force: true });
}
});
it('calls a workspace of exactly 5,000 nodes finished', async () => {
const exact = mkdtempSync(join(tmpdir(), 'codeman-file-exact-'));
mkdirSync(join(exact, 'empty'));
for (let index = 0; index < 4999; index++) {
writeFileSync(join(exact, `item-${index}.txt`), '');
}
harness.ctx._session.workingDir = exact;
try {
const { data } = await get('');
expect(data.totalFiles + data.totalDirectories).toBe(5000);
expect(data.truncated).toBe(false);
expect(JSON.stringify(data)).not.toContain('incomplete');
} finally {
rmSync(exact, { recursive: true, force: true });
}
});
it('does not flag a folder whose only unread entries are ones the listing leaves out', async () => {
// 3,000 folders leave 2,000 of the budget, which buys each of the first 2,000 its `src`. All
// that is left unread in them is `vendor` and `.hidden`, which would never be listed anyway.
const wide = mkdtempSync(join(tmpdir(), 'codeman-file-excluded-'));
for (let index = 0; index < 3000; index++) {
const dir = join(wide, `dir-${String(index).padStart(4, '0')}`);
mkdirSync(join(dir, 'src'), { recursive: true });
mkdirSync(join(dir, 'vendor'));
writeFileSync(join(dir, '.hidden'), '');
}
harness.ctx._session.workingDir = wide;
try {
const { data } = await get('');
expect(data.truncated).toBe(true);
expect(data.tree[0].children.map((node: { name: string }) => node.name)).toEqual(['src']);
expect(data.tree[0]).not.toHaveProperty('incomplete');
expect(data.tree[2999]).toMatchObject({ incomplete: true, children: [] });
} finally {
rmSync(wide, { recursive: true, force: true });
}
});
it('returns search results shallow-first', async () => {
const deep = join(root, 'a-deep', 'b', 'c');
mkdirSync(deep, { recursive: true });
mkdirSync(join(root, 'z-shallow'));
writeFileSync(join(deep, 'order-probe.ts'), 'x\n');
writeFileSync(join(root, 'z-shallow', 'order-probe.ts'), 'x\n');
try {
const { data } = await get('?q=order-probe');
expect(data.matches.map((match: { path: string }) => match.path)).toEqual([
join('z-shallow', 'order-probe.ts'),
join('a-deep', 'b', 'c', 'order-probe.ts'),
]);
} finally {
rmSync(join(root, 'a-deep'), { recursive: true, force: true });
rmSync(join(root, 'z-shallow'), { recursive: true, force: true });
}
});
it('stops at the match limit and says so', async () => {
const many = mkdtempSync(join(tmpdir(), 'codeman-file-many-'));
for (let index = 0; index < 1100; index++) {
writeFileSync(join(many, `hit-${index}.txt`), '');
}
harness.ctx._session.workingDir = many;
try {
const { data } = await get('?q=hit-');
expect(data.matches).toHaveLength(1000);
expect(data.matchCount).toBe(1000);
expect(data.truncated).toBe(true);
} finally {
rmSync(many, { recursive: true, force: true });
}
});
});
+178
View File
@@ -0,0 +1,178 @@
/**
* GET /api/sessions/:id/files: what one listing costs the server.
*
* test/routes/file-search-mode.test.ts pins what the walk returns, on real folders. This file pins
* what it reads, which is where a real tree cannot go: 100 folders of 50,000 files is minutes of
* setup, and a dead network mount cannot be made in a test. So `fs.readdir` and `fs.stat` are
* stubbed here, on the one object the route and the bounded probe both call through.
*
* Port: none (app.inject).
*/
import { Dirent, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
const UV_DIRENT_FILE = 1;
const UV_DIRENT_DIR = 2;
const dirent = (name: string, type: number, parent: string): Dirent =>
new (Dirent as unknown as new (name: string, type: number, path: string) => Dirent)(name, type, parent);
describe('files endpoint read bounds', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
});
afterEach(() => {
vi.restoreAllMocks();
});
const get = async (query: string) => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/files${query}`,
});
expect(res.statusCode).toBe(200);
return JSON.parse(res.body).data;
};
describe('over a workspace far bigger than the budget', () => {
const FOLDERS = 100;
const FILES_EACH = 50_000;
const WORKSPACE = '/virtual/ws';
let entriesServed = 0;
beforeEach(() => {
entriesServed = 0;
const folders = Array.from({ length: FOLDERS }, (_, index) =>
dirent(`class-${String(index).padStart(5, '0')}`, UV_DIRENT_DIR, WORKSPACE)
);
const files = Array.from({ length: FILES_EACH }, (_, index) =>
dirent(`image-${String(index).padStart(8, '0')}.jpg`, UV_DIRENT_FILE, 'x')
);
vi.spyOn(fs, 'readdir').mockImplementation((async (path: string) => {
const entries = path === WORKSPACE ? folders : files;
entriesServed += entries.length;
return [...entries];
}) as never);
vi.spyOn(fs, 'stat').mockImplementation((async (path: string) => ({
size: 1,
isDirectory: () => !String(path).endsWith('.jpg'),
})) as never);
harness.ctx._session.workingDir = WORKSPACE;
});
it('stops reading once it has read 250,000 entries, not at the end of the workspace', async () => {
const data = await get('');
// Past the ceiling by at most the folder that crossed it.
expect(entriesServed).toBeLessThanOrEqual(250_000 + FILES_EACH);
// What was read is still used, up to the budget, and nothing unread passes as empty.
expect(data.totalFiles + data.totalDirectories).toBe(5000);
expect(data.truncated).toBe(true);
expect(data.tree).toHaveLength(FOLDERS);
for (const folder of data.tree) expect(folder.incomplete).toBe(true);
expect(data.tree.filter((folder: { children: unknown[] }) => folder.children.length === 0)).toHaveLength(
FOLDERS - 5
);
});
it('stops a search the same way', async () => {
const data = await get('?q=image-00000000');
expect(entriesServed).toBeLessThanOrEqual(250_000 + FILES_EACH);
expect(data.matches.length).toBeGreaterThan(0);
expect(data.truncated).toBe(true);
});
});
describe('with a dead mount below the workspace', () => {
let workspace: string;
let deadFolder: string;
let deadReads: number;
let releaseStat: (() => void) | undefined;
beforeEach(() => {
workspace = mkdtempSync(join(tmpdir(), 'codeman-file-dead-'));
deadFolder = join(workspace, 'z-nas');
mkdirSync(deadFolder);
mkdirSync(join(workspace, 'a-fine'));
writeFileSync(join(workspace, 'a-fine', 'needle.ts'), '');
harness.ctx._session.workingDir = workspace;
deadReads = 0;
const never = new Promise<never>(() => {});
const realReaddir = fs.readdir.bind(fs);
const realStat = fs.stat.bind(fs);
vi.spyOn(fs, 'readdir').mockImplementation(((path: string, options: never) => {
if (path !== deadFolder) return realReaddir(path, options);
deadReads++;
return never;
}) as never);
vi.spyOn(fs, 'stat').mockImplementation(((path: string) => {
if (path !== deadFolder) return realStat(path);
// Settled in afterEach: the probe calls a path stalled for as long as its stat is pending.
return new Promise((_resolve, reject) => {
releaseStat = () => reject(Object.assign(new Error('gone'), { code: 'EIO' }));
});
}) as never);
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(async () => {
releaseStat?.();
releaseStat = undefined;
await new Promise((settled) => setTimeout(settled, 0));
rmSync(workspace, { recursive: true, force: true });
});
const deadline = <T>(work: Promise<T>): Promise<T> =>
Promise.race([
work,
new Promise<T>((_resolve, reject) => {
setTimeout(() => reject(new Error('the listing never answered')), 5000).unref();
}),
]);
it('answers without it, and costs it one stuck stat however often the panel asks', async () => {
const first = await deadline(get(''));
// Nothing was read from it, so `children: []` alone would pass it off as an empty folder (#516:
// unknown is never absent).
const dead = first.tree.find((node: { name: string }) => node.name === 'z-nas');
expect(dead).toEqual({ name: 'z-nas', path: 'z-nas', type: 'directory', children: [], incomplete: true });
expect(first.truncated).toBe(true);
expect(first.tree[0].children.map((node: { name: string }) => node.name)).toEqual(['needle.ts']);
expect(deadReads).toBe(0);
const statCalls = () => vi.mocked(fs.stat).mock.calls.filter(([path]) => path === deadFolder).length;
const stats = statCalls();
const search = await deadline(get('?q=needle'));
expect(search.matches.map((match: { path: string }) => match.path)).toEqual([join('a-fine', 'needle.ts')]);
// A hit could be behind it, which is what the search's own `truncated` is for.
expect(search.truncated).toBe(true);
expect(statCalls()).toBe(stats);
expect(deadReads).toBe(0);
});
it('leaves a folder alone once the filesystem says it is gone: that is not an unfinished scan', async () => {
vi.mocked(fs.stat).mockImplementation((async (path: string) => {
if (path === deadFolder) throw Object.assign(new Error('gone'), { code: 'ENOENT' });
return { size: 1, isDirectory: () => true };
}) as never);
const data = await deadline(get(''));
const gone = data.tree.find((node: { name: string }) => node.name === 'z-nas');
expect(gone).toEqual({ name: 'z-nas', path: 'z-nas', type: 'directory', children: [] });
expect(data.truncated).toBe(false);
expect(deadReads).toBe(0);
});
});
});
+3 -1
View File
@@ -51,7 +51,9 @@ describe('POST /api/filesystem/mkdir', () => {
try {
const res = await mkdir({ path: casesDir, name: 'myproj' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Add Case > New Case');
expect(res.json().error).toContain('Pick a folder outside it');
// Every picker offering New folder sees this, and Clone Repo opens here by default.
expect(res.json().error).toContain('Add Case > Create New or Clone Repo (no custom folder)');
expect(existsSync(join(casesDir, 'myproj'))).toBe(false);
// A folder inside a case is fine.
mkdirSync(join(casesDir, 'real-case'));
+550
View File
@@ -0,0 +1,550 @@
/**
* @fileoverview The HTTP surface of foreign-tmux adoption, and the gates that
* keep Codeman from driving a session it did not start.
*
* An adopted session wraps a process SOMEONE ELSE launched. Its `mode` is
* whatever the probe saw (very often `claude`), so every refusal here has to
* be its own `isAdopted` check and not a side effect of the external-CLI rule.
* The two questions are different ("is this CLI one we drive?" vs "did we start
* this process?"), and folding them into one means the day the first loosens,
* Codeman silently starts sending `/clear` into someone's live conversation.
*
* Every refusal is paired with a session Codeman DID start, or a broken
* endpoint would satisfy the refusal assertion just as well.
*
* Port: none (app.inject()).
*/
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// The adopt route RE-RESOLVES the candidate rather than trusting the body, and
// real discovery is disabled under VITEST, so the success path is unreachable
// without standing in for it. Everything downstream of the lookup is real.
const discoverForeignSessions = vi.fn();
const invalidateForeignCache = vi.fn();
vi.mock('../../src/foreign-tmux-discovery.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/foreign-tmux-discovery.js')>();
return {
...actual,
discoverForeignSessions: (...a: unknown[]) => discoverForeignSessions(...(a as [])),
invalidateForeignCache: () => invalidateForeignCache(),
readAllDockerCases: async () => [],
readAllRemoteHosts: async () => [],
};
});
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerRespawnRoutes } from '../../src/web/routes/respawn-routes.js';
import { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js';
import { registerMuxRoutes } from '../../src/web/routes/mux-routes.js';
import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
import { adoptTargetIdentity, foreignTargetKey } from '../../src/foreign-tmux.js';
import type { SessionAdopt } from '../../src/types/session.js';
const SID = 'adopted-1';
// The stand-in defaults to "found nothing", which is what real discovery gives
// under VITEST, so mocking the module does not change the tests that predate it.
beforeEach(() => {
discoverForeignSessions.mockReset();
discoverForeignSessions.mockResolvedValue({ sessions: [], notes: [] });
invalidateForeignCache.mockClear();
});
describe('adopted sessions refuse every auto-op that drives the pane', () => {
for (const route of ['auto-clear', 'auto-compact', 'auto-resume']) {
it(`refuses POST /api/sessions/:id/${route}`, async () => {
const h = await createRouteTestHarness(registerSessionRoutes, { sessionId: SID });
(h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true;
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${SID}/${route}`,
payload: { enabled: true },
});
const body = res.json();
expect(body.success).toBe(false);
expect(body.error).toMatch(/adopted/i);
await h.app.close();
});
it(`still allows ${route} on a session Codeman started`, async () => {
const h = await createRouteTestHarness(registerSessionRoutes, { sessionId: SID });
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${SID}/${route}`,
payload: { enabled: true },
});
expect(res.json().success).toBe(true);
await h.app.close();
});
}
it('refuses a one-shot prompt, which would start a new local claude in /tmp', async () => {
const h = await createRouteTestHarness(registerSessionRoutes, { sessionId: SID });
(h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true;
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${SID}/run`,
payload: { prompt: 'hello' },
});
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/adopted/i);
expect(h.ctx._session.runPrompt).not.toHaveBeenCalled();
await h.app.close();
});
it('still runs a one-shot prompt on a session Codeman started', async () => {
const h = await createRouteTestHarness(registerSessionRoutes, { sessionId: SID });
await h.app.inject({ method: 'POST', url: `/api/sessions/${SID}/run`, payload: { prompt: 'hello' } });
expect(h.ctx._session.runPrompt).toHaveBeenCalledWith('hello');
await h.app.close();
});
it('refuses respawn, for a harder reason: it kills and restarts the pane', async () => {
const h = await createRouteTestHarness(registerRespawnRoutes, { sessionId: SID });
(h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true;
const res = await h.app.inject({ method: 'POST', url: `/api/sessions/${SID}/respawn/start`, payload: {} });
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/adopted/i);
await h.app.close();
});
it('refuses Ralph config', async () => {
const h = await createRouteTestHarness(registerRalphRoutes, { sessionId: SID });
(h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true;
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${SID}/ralph-config`,
payload: { maxTodos: 10 },
});
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/adopted/i);
await h.app.close();
});
// ⚠️ Neither of these is a mock-only concern: an adopted session's `workingDir` is the
// neutral wrapper folder `/tmp`, so a live one wrote `/tmp/@ralph_prompt.md` on the host
// and answered success, a predictable name in a folder anyone can write to.
describe.each([
['fix-plan/write', {}],
['fix-plan/read', {}],
['ralph-prompt/write', { content: 'prompt' }],
])('POST %s', (route, payload) => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'adopted-ralph-'));
writeFileSync(join(dir, '@fix_plan.md'), '- [ ] task\n');
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
const post = async (isAdopted: boolean) => {
const h = await createRouteTestHarness(registerRalphRoutes, { sessionId: SID });
const ralphTracker = { todos: [], generateFixPlanMarkdown: () => '', importFixPlanMarkdown: () => 0 };
Object.assign(h.ctx._session as object, { isAdopted, workingDir: dir, ralphTracker });
const res = await h.app.inject({ method: 'POST', url: `/api/sessions/${SID}/${route}`, payload });
await h.app.close();
return res.json();
};
it('refuses an adopted session and touches nothing', async () => {
const before = readdirSync(dir);
const body = await post(true);
expect(body.success).toBe(false);
expect(body.error).toMatch(/adopted/i);
expect(readdirSync(dir)).toEqual(before);
});
it('still answers for a session Codeman started', async () => {
expect((await post(false)).success).toBe(true);
});
});
});
describe('GET /api/mux/foreign', () => {
let prev: string | undefined;
beforeEach(() => {
prev = process.env.CODEMAN_MULTIUSER;
});
afterEach(() => {
if (prev === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prev;
});
it('is admin-only in multi-user mode: it exposes every user’s processes and cwds', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerMuxRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'GET', url: '/api/mux/foreign' });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
await app.close();
});
it('answers an admin', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerMuxRoutes, {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/mux/foreign' })).statusCode).toBe(200);
await app.close();
});
it('answers in single-user mode, where the admin gate is a no-op', async () => {
delete process.env.CODEMAN_MULTIUSER;
const { app } = await createRouteTestHarness(registerMuxRoutes);
const res = await app.inject({ method: 'GET', url: '/api/mux/foreign' });
expect(res.statusCode).toBe(200);
// Real discovery is disabled under VITEST, so this pins the shape, not the
// content. The body is bare here: the uniform envelope is applied by the
// server's own hook, which a single-route harness does not install.
expect(res.json()).toHaveProperty('sessions');
expect(res.json()).toHaveProperty('canScanWide');
await app.close();
});
});
describe('POST /api/sessions/adopt', () => {
let prev: string | undefined;
beforeEach(() => {
prev = process.env.CODEMAN_MULTIUSER;
});
afterEach(() => {
if (prev === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prev;
});
it('is admin-only in multi-user mode: adopting a shell is arbitrary host execution', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerSessionRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'x' } });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
await app.close();
});
it('404s when the re-resolve no longer sees the target', async () => {
// The browser hands back only an opaque id; the socket path and session name
// are re-derived server-side, so a vanished target is simply absent from the
// fresh list.
const { app } = await createRouteTestHarness(registerSessionRoutes);
const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'nope' } });
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('NOT_FOUND');
await app.close();
});
it('rejects a body that tries to supply a socket path or session name', async () => {
// The schema takes the opaque id and nothing else. That is why a path can
// never reach the launch chain from the browser.
const { app } = await createRouteTestHarness(registerSessionRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/sessions/adopt',
payload: { id: 'x', socketPath: '/tmp/tmux-0/default', targetSession: 'work' },
});
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('INVALID_INPUT');
await app.close();
});
});
/** The adoption record of a local wrapper, as the route writes it. */
function localAdopt(socketPath: string, targetSession: string): SessionAdopt {
return { location: 'local', socketPath, targetSession, viewSession: 'codeman-view-00000000' };
}
describe('a successful adoption', () => {
/** A candidate whose cwd is BOTH unsafe for SAFE_PATH_PATTERN and not on this host. */
const CANDIDATE = {
id: 'cand-1',
location: 'local' as const,
socketPath: '/tmp/tmux-0/default',
sessionName: 'work',
mode: 'claude' as const,
workingDir: '/home/them/Program Files (x86)/c++',
command: 'claude',
panePid: 4242,
paneCurrentCommand: 'node',
createdAt: 1,
windows: 1,
attached: false,
};
beforeEach(() => {
discoverForeignSessions.mockResolvedValue({ sessions: [CANDIDATE], notes: [] });
});
it('gives the wrapper a neutral LOCAL workingDir, never the foreign pane\u2019s cwd', async () => {
// That cwd carries `+`, spaces and parentheses, so using it would make
// createSession throw and the tab would never start; and when the candidate
// is a container or ssh pane it is not a path on this host at all, which is
// what sent the boot hook sweep into someone else's repo.
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
expect(res.statusCode).toBe(200);
expect(ctx.addSession).toHaveBeenCalledTimes(1);
const created = ctx.addSession.mock.calls[0][0] as unknown as {
workingDir: string;
adopt?: { paneCurrentPath?: string; targetSession?: string; viewSession?: string };
};
expect(created.workingDir).toBe('/tmp');
expect(created.workingDir).not.toBe(CANDIDATE.workingDir);
// The foreign cwd survives for display only.
expect(created.adopt?.paneCurrentPath).toBe(CANDIDATE.workingDir);
await app.close();
});
it('reuses the existing wrapper rather than making a second one for the same target', async () => {
// Keyed on the target and not on our opaque candidate id: a wrapper restored
// after a server restart does not carry that id.
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
ctx.sessions.set('wrapper-1', ctx._session);
ctx._session.adopt = localAdopt(CANDIDATE.socketPath, CANDIDATE.sessionName);
const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
expect(res.statusCode).toBe(200);
expect(res.json().alreadyAdopted).toBe(true);
expect(ctx.addSession).not.toHaveBeenCalled();
await app.close();
});
it('sees a wrapper whose tab has not started yet, which has no mux record to find', async () => {
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
const first = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
expect(first.json().adopted).toBe(true);
expect(ctx.mux.getSessions()).toEqual([]);
const second = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
expect(second.json().alreadyAdopted).toBe(true);
expect(ctx.addSession).toHaveBeenCalledTimes(1);
await app.close();
});
it('refuses a second adopt of a target whose wrapper is still being created', async () => {
// Two devices on one row: both pass the live-session check, because the first
// is parked on the layout lock and not yet in the map.
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
const add = ctx.addSession.getMockImplementation()!;
let release!: () => void;
const held = new Promise<void>((resolve) => (release = resolve));
ctx.addSession.mockImplementationOnce(async (s: never) => {
await held;
return add(s);
});
const first = app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
await vi.waitFor(() => expect(ctx.addSession).toHaveBeenCalledTimes(1));
const second = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
expect(second.json().success).toBe(false);
expect(second.json().errorCode).toBe('SESSION_BUSY');
release();
expect((await first).json().adopted).toBe(true);
expect(ctx.addSession).toHaveBeenCalledTimes(1);
await app.close();
});
it('says "could not reach it" rather than "gone" when discovery left a note', async () => {
// On a flaky link the session is alive and sitting right there; reporting
// it as deleted is the wrong answer, not just an unhelpful one.
discoverForeignSessions.mockResolvedValue({ sessions: [], notes: ['ssh box: connection timed out'] });
const { app } = await createRouteTestHarness(registerSessionRoutes);
const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/Could not reach it/);
expect(res.json().error).toContain('connection timed out');
await app.close();
});
it('carries the adopt metadata that a restart needs to rebuild the command', async () => {
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
const created = ctx.addSession.mock.calls[0][0] as unknown as {
adopt?: { socketPath?: string; targetSession?: string; viewSession?: string };
isAdopted?: boolean;
};
expect(created.isAdopted).toBe(true);
expect(created.adopt?.socketPath).toBe(CANDIDATE.socketPath);
expect(created.adopt?.targetSession).toBe(CANDIDATE.sessionName);
// Derived from the Codeman session id, so it is provably ours to kill.
expect(created.adopt?.viewSession).toMatch(/^codeman-view-/);
await app.close();
});
});
describe('same-named sessions in different locations', () => {
// Containers commonly run tmux as root with default names, so `0` in two
// containers is the same (socket, name) pair, and the same goes for this machine
// and an ssh host with the same uid.
const SHARED = {
socketPath: '/tmp/tmux-0/default',
sessionName: '0',
mode: 'shell' as const,
workingDir: '/workspace',
command: 'bash',
createdAt: 1,
windows: 1,
attached: false,
};
const IN_A = { ...SHARED, id: 'in-a', location: 'docker' as const, hostId: 'h1', containerName: 'box-a' };
const IN_B = { ...SHARED, id: 'in-b', location: 'docker' as const, hostId: 'h1', containerName: 'box-b' };
const OTHER_ENGINE = { ...SHARED, id: 'in-c', location: 'docker' as const, hostId: 'h2', containerName: 'box-a' };
const ON_THIS_HOST = { ...SHARED, id: 'here', location: 'local' as const };
beforeEach(() => {
discoverForeignSessions.mockResolvedValue({ sessions: [IN_A, IN_B, OTHER_ENGINE, ON_THIS_HOST], notes: [] });
});
it('gives each location its own key, and a wrapper the key of its candidate', () => {
const keys = [IN_A, IN_B, OTHER_ENGINE, ON_THIS_HOST].map(foreignTargetKey);
expect(new Set(keys).size).toBe(4);
expect(
foreignTargetKey(
adoptTargetIdentity({
...localAdopt(SHARED.socketPath, SHARED.sessionName),
location: 'docker',
docker: { hostId: 'h1', label: 'one', engine: 'docker', containerName: 'box-b' },
})
)
).toBe(foreignTargetKey(IN_B));
});
it('adopting one never returns the tab of another', async () => {
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
const a = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'in-a', docker: true } });
const b = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'in-b', docker: true } });
const local = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'here' } });
for (const res of [a, b, local]) expect(res.json().adopted).toBe(true);
expect(ctx.addSession).toHaveBeenCalledTimes(3);
const ids = [a, b, local].map((res) => res.json().session.id);
expect(new Set(ids).size).toBe(3);
await app.close();
});
it('marks only the row that is really open as open', async () => {
const { app, ctx } = await createRouteTestHarness(registerMuxRoutes);
ctx.sessions.set('wrapper-b', {
id: 'wrapper-b',
adopt: {
...localAdopt(SHARED.socketPath, SHARED.sessionName),
location: 'docker',
docker: { hostId: 'h1', label: 'one', engine: 'docker', containerName: 'box-b' },
},
} as never);
const res = await app.inject({ method: 'GET', url: '/api/mux/foreign' });
const adoptedBy = Object.fromEntries(
(res.json().sessions as { id: string; adoptedBy?: string }[]).map((s) => [s.id, s.adoptedBy])
);
expect(adoptedBy).toEqual({ 'in-a': undefined, 'in-b': 'wrapper-b', 'in-c': undefined, here: undefined });
await app.close();
});
});
describe('an adopted session has no workspace of ours', () => {
// Its `workingDir` is the neutral `/tmp`, which local-looking code would take for
// the session's folder: uploads landed there and the tab's close deleted them, and
// a Git panel or fetch ran git on whatever sat there. Each refusal is paired with a
// plain session, or a broken route would pass it too.
const adopted = (s: { isAdopted: boolean; adopt?: SessionAdopt }) => {
s.isAdopted = true;
s.adopt = localAdopt('/tmp/tmux-1000/default', 'work');
};
it('refuses a prompt upload before touching disk', async () => {
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
adopted(ctx._session);
const res = await app.inject({
method: 'POST',
url: `/api/sessions/${ctx._sessionId}/paste-image`,
headers: { 'x-codeman-csrf': '1' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/adopted/);
await app.close();
});
it('refuses the image watcher, and leaves it alone being switched off', async () => {
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
adopted(ctx._session);
const post = (enabled: boolean) =>
app.inject({ method: 'POST', url: `/api/sessions/${ctx._sessionId}/image-watcher`, payload: { enabled } });
expect((await post(true)).json().success).toBe(false);
expect((await post(false)).json().success).toBe(true);
await app.close();
});
it('refuses the per-session git fetch settings', async () => {
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
adopted(ctx._session);
const res = await app.inject({
method: 'POST',
url: `/api/sessions/${ctx._sessionId}/git-fetch-settings`,
payload: { enabled: true },
});
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/adopted/);
await app.close();
});
it('refuses a custom model, which restarts the CLI in the pane', async () => {
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
adopted(ctx._session);
const res = await app.inject({
method: 'POST',
url: `/api/sessions/${ctx._sessionId}/custom-model`,
payload: { clear: true },
});
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/adopted/);
await app.close();
});
it('answers unsupported on the git routes, running no git', async () => {
const git = vi.fn(async () => ({ stdout: '', stderr: '' }));
const { app, ctx } = await createRouteTestHarness((a, c) =>
registerGitStatusRoutes(a, c, git as never, async () => [])
);
adopted(ctx._session);
const status = await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/git-status` });
expect(status.json()).toMatchObject({ success: true, data: { state: 'unsupported', reason: 'adopted' } });
const fetch = await app.inject({ method: 'POST', url: `/api/sessions/${ctx._sessionId}/git-fetch` });
expect(fetch.json()).toMatchObject({ success: true, data: { state: 'unsupported', reason: 'adopted' } });
const diff = await app.inject({
method: 'GET',
url: `/api/sessions/${ctx._sessionId}/git-diff?repo=x&path=y&kind=z`,
});
expect(diff.statusCode).toBe(400);
expect(git).not.toHaveBeenCalled();
await app.close();
});
describe('last-response', () => {
let project: string;
beforeEach(() => {
project = join(process.env.HOME!, '.claude', 'projects', '-tmp');
mkdirSync(project, { recursive: true });
});
afterEach(() => {
rmSync(project, { recursive: true, force: true });
});
it('never pairs a transcript by the wrapper folder or the id we launched nothing with', async () => {
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
(ctx._session as unknown as { mode: string }).mode = 'claude';
writeFileSync(
join(project, `${ctx._sessionId}.jsonl`),
JSON.stringify({
type: 'assistant',
timestamp: '2026-07-21T00:00:01Z',
message: { content: [{ type: 'text', text: 'an answer from another conversation' }] },
})
);
const read = async () =>
(await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/last-response` })).json();
expect((await read()).text).toBe('an answer from another conversation');
adopted(ctx._session);
expect((await read()).text).toBe('');
await app.close();
});
});
});
@@ -92,6 +92,35 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
safeRmHomeTree(CASES_DIR);
});
it("learns the caller's own value under an injected key, so a clear can put it back", async () => {
const own = join(getDataDir(), 'client-claude');
const res = await quickStart({
caseName: 'cm-own-home',
mode: 'claude',
envOverrides: { CLAUDE_CONFIG_DIR: own },
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
const { sessionId } = res.json();
const session = ctx.sessions.get(sessionId) as unknown as Session;
const isolatedDir = customModelConfigDir(sessionId);
// It launches on the isolated directory, with the caller's value set aside rather
// than recorded as the injected one.
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBe(isolatedDir);
expect(session.getCustomModelForPersist()?.ownEnv).toEqual({ CLAUDE_CONFIG_DIR: own });
const clear = await app.inject({
method: 'POST',
url: `/api/sessions/${sessionId}/custom-model`,
payload: { clear: true },
});
expect(clear.statusCode).toBe(200);
expect(session.customModel).toBeUndefined();
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBe(own);
expect(existsSync(isolatedDir)).toBe(false);
});
it('launches a claude session already pointed at the endpoint — no restart at all', async () => {
const res = await quickStart({
caseName: 'cm-claude',
@@ -175,6 +204,34 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(restartSpy).not.toHaveBeenCalled();
});
it("leaves the caller's own model in the stored config, which is what a later clear falls back to", async () => {
const res = await quickStart({
caseName: 'cm-pi-own-model',
mode: 'pi',
piConfig: { model: 'anthropic/claude-x' },
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
const { sessionId } = res.json();
const session = ctx.sessions.get(sessionId) as unknown as Session;
expect(session.getCustomModelForPersist()?.launchModel).toBe('custom/qwen3');
expect(session.toState().piConfig?.model).toBe('anthropic/claude-x');
});
it('stores no model at all for a caller that chose none', async () => {
const res = await quickStart({
caseName: 'cm-codex-no-model',
mode: 'codex',
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
const session = ctx.sessions.get(res.json().sessionId) as unknown as Session;
expect(session.getCustomModelForPersist()?.launchModel).toBe('qwen3');
expect(session.toState().codexConfig?.model).toBeUndefined();
});
it('404s for an unknown endpoint id', async () => {
const res = await quickStart({
caseName: 'cm-ghost',
+22 -1
View File
@@ -9,7 +9,7 @@ import { createRouteTestHarness } from './_route-test-utils.js';
import { createMockSession } from '../mocks/index.js';
import { getDataDir } from '../../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
import { existsSync, readFileSync, readlinkSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { webviewFetch } from '../../src/web/webview-egress.js';
@@ -97,6 +97,27 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(trustFile.customApiKeyResponses.approved).toEqual(['k']);
});
it("links a switch from the session's own home, never the injected one the map now holds", async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'deepseek';
const isolatedDir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
const ownHome = join(getDataDir(), 'own-dsh-home');
session.envOverrideValue.mockImplementation(() => isolatedDir);
session.ownEnvValue.mockImplementation((key: string) => (key === 'DSH_HOME' ? ownHome : undefined));
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
expect(readlinkSync(join(isolatedDir, 'sessions'))).toBe(join(ownHome, 'sessions'));
rmSync(isolatedDir, { recursive: true, force: true });
rmSync(ownHome, { recursive: true, force: true });
});
it('clears back to the native default', async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
@@ -89,6 +89,32 @@ describe('Codex launch defaults', () => {
}
});
it('fills Allow network access into every local codex launch, and an explicit value wins', async () => {
await writeFile(SETTINGS_PATH, JSON.stringify({ codexNetworkAccess: true }));
expect(await resolveCodexDefaults(undefined)).toEqual({ networkAccess: true });
expect(buildCodexCommand(await resolveCodexDefaults(undefined))).toBe(
'codex --config sandbox_workspace_write.network_access=true'
);
// The browser sends a codexConfig without the field, so the default still applies.
expect(await resolveCodexDefaults({ dangerouslyBypassApprovals: false })).toEqual({
dangerouslyBypassApprovals: false,
networkAccess: true,
});
expect(await resolveCodexDefaults({ networkAccess: false })).toEqual({ networkAccess: false });
// Off (and a malformed value) fills nothing, so the launch stays byte-identical.
for (const value of [false, 'yes']) {
await writeFile(SETTINGS_PATH, JSON.stringify({ codexNetworkAccess: value }));
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
}
});
for (const url of ['/api/sessions', '/api/quick-start']) {
it(`records Allow network access on a codex session created at ${url}`, async () => {
await writeFile(SETTINGS_PATH, JSON.stringify({ codexNetworkAccess: true }));
expect(await createdConfig(url)).toMatchObject({ networkAccess: true });
});
}
it('keeps defaults out of custom endpoint launches', async () => {
const config = { model: 'local-model', animations: false };
expect(await resolveCodexDefaults(config, true)).toBe(config);
@@ -100,6 +126,7 @@ describe('Codex launch defaults', () => {
expect(getCli('codex')!.capabilities.launchDefaults).toEqual({
model: 'codexModel',
reasoningEffort: 'codexReasoningEffort',
networkAccess: 'codexNetworkAccess',
});
for (const entry of STOCK_CLIS) {
if ((entry.id as string) === 'codex') continue;
+28
View File
@@ -184,6 +184,34 @@ describe('closing the active session', () => {
});
});
describe('closing an adopted tab', () => {
it('re-reads the outside-sessions list once the wrapper is gone', async () => {
// The home screen loads that list as soon as the tab goes, which is BEFORE the DELETE
// lands, so the row kept saying "Go to tab" with no tab behind it until the next poll.
const app = makeApp(A, [A]);
app.sessions.set(A, { id: A, adopt: { targetSession: 'work' } });
const order: string[] = [];
app._apiDelete = vi.fn(async () => {
order.push('delete');
return { ok: true, status: 200 };
});
app.loadForeignSessions = vi.fn(async () => void order.push('reload'));
await app.closeSession(A);
expect(order).toEqual(['delete', 'reload']);
});
it('leaves the list alone for a session of our own', async () => {
const app = makeApp(A, [A]);
app.loadForeignSessions = vi.fn();
await app.closeSession(A);
expect(app.loadForeignSessions).not.toHaveBeenCalled();
});
});
describe('closing is optimistic', () => {
it('drops the tab and selects the next one BEFORE the server answers', async () => {
const app = makeApp(A);
+253 -2
View File
@@ -1,7 +1,7 @@
/**
* @fileoverview Custom Model Endpoint Profiles, the Session half of applying and
* clearing a selection against a live pane (`Session.setCustomModel()` +
* `Session.restartCli()`), pinned against the three ways the first cut broke a
* `Session.restartCli()`), pinned against the four ways the first cut broke a
* working session:
*
* 1. Clearing did not clear. The injected vars reach the CLI via `tmux setenv`,
@@ -16,12 +16,15 @@
* gated on that transcript existing, so these tests write one.
* 3. pi/omp/grok wrote their config file and then launched without the `--model`
* that selects it, so the file was ignored.
* 4. A value the session set itself under an injected key was overwritten, then deleted
* with the injected one on clear: an own `DSH_HOME` fell back to the server's tree and
* an own `CLAUDE_CONFIG_DIR` silently moved the session to the default Claude account.
*
* Drives a real `Session` against the in-memory tmux layer vitest substitutes,
* spying on `respawnPane` to read the options the relaunch would get.
* Port: N/A.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -29,6 +32,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { buildCustomModelInjection } from '../src/custom-model-injection.js';
import {
applyCustomModelInjection,
customModelConfigDir,
removeConfigDir,
} from '../src/custom-model-injection-apply.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { MuxSession, SessionMode } from '../src/types.js';
@@ -187,6 +195,53 @@ describe('restartCli() must not kill a working pane', () => {
});
describe('launchModel reaches the CLI through its own launch param', () => {
/** A session the route just built: no pane yet, so its launch is `createSession`. */
function freshSession(mode: SessionMode, extra: Record<string, unknown> = {}) {
mkdirSync(workingDir, { recursive: true });
const mux = new TmuxManager();
const create = vi.spyOn(mux, 'createSession');
vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true);
const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242);
const session = new Session({ workingDir, mode, mux, useMux: true, ...extra });
sessions.push(session);
return { session, create, respawn };
}
it('the first launch carries it too, and a clear relaunches on the model the session was created with', async () => {
const { session, create, respawn } = freshSession('pi', { piConfig: { model: 'anthropic/claude-x' } });
session.setCustomModel(
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['HOME'], configDir: '/tmp/cfg', launchModel: 'custom/qwen3' },
{ HOME: '/tmp/cfg' }
);
await session.startInteractive();
expect(create.mock.calls[0][0].piConfig?.model).toBe('custom/qwen3');
expect(session.toState().piConfig?.model).toBe('anthropic/claude-x');
session.setCustomModel(undefined);
expect(await session.restartCli()).toBe(true);
expect(respawn.mock.calls[0][0].piConfig?.model).toBe('anthropic/claude-x');
});
it('a clear on a session created with no model relaunches with none', async () => {
const { session, create, respawn } = freshSession('codex');
session.setCustomModel(
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['CODEX_HOME'], configDir: '/tmp/cfg', launchModel: 'qwen3' },
{ CODEX_HOME: '/tmp/cfg' }
);
await session.startInteractive();
expect(create.mock.calls[0][0].codexConfig?.model).toBe('qwen3');
session.setCustomModel(undefined);
expect(await session.restartCli()).toBe(true);
expect(respawn.mock.calls[0][0].codexConfig?.model).toBeUndefined();
});
it('leaves the launch untouched without a selection', async () => {
const { session, create } = freshSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
await session.startInteractive();
expect(create.mock.calls[0][0].piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
});
it('pi: the selection forces piConfig.model on the respawn options, leaving the stored config alone', async () => {
const { session, respawn } = liveSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
session.setCustomModel(
@@ -253,3 +308,199 @@ describe('Codex custom endpoint model overrides', () => {
expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'native-model', reasoningEffort: 'high' });
});
});
describe('a value the session set itself comes back with the selection gone', () => {
const DSH_SELECTION = {
endpointId: 'ep',
modelId: 'q',
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
configDir: '/isolated/dsh',
homeEnvVar: 'DSH_HOME',
};
const DSH_ENV = { DSH_HOME: '/isolated/dsh', CODEMAN_CUSTOM_MODEL_API_KEY: 'k' };
it('puts an own DSH_HOME back on clear and sets it rather than unsetting it', async () => {
const { session, respawn } = liveSession('deepseek', { envOverrides: { DSH_HOME: '/home/u/my-dsh' } });
session.setCustomModel(DSH_SELECTION, DSH_ENV);
expect(session.envOverrideValue('DSH_HOME')).toBe('/isolated/dsh');
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBe('/home/u/my-dsh');
expect(session.deepSeekHomeOverride).toBe('/home/u/my-dsh');
await session.restartCli();
const options = respawn.mock.calls[0][0];
expect(options.envOverrides).toEqual({ DSH_HOME: '/home/u/my-dsh' });
expect(options.unsetEnvKeys).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']);
});
it('carries it across a switch between endpoints', () => {
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: '/home/u/my-dsh' } });
session.setCustomModel(DSH_SELECTION, DSH_ENV);
session.setCustomModel({ ...DSH_SELECTION, endpointId: 'other' }, DSH_ENV);
expect(session.envOverrideValue('DSH_HOME')).toBe('/isolated/dsh');
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBe('/home/u/my-dsh');
});
it('keeps a claude session on its own account, the #255 override', async () => {
const { session, respawn } = liveSession('claude', {
envOverrides: { CLAUDE_CONFIG_DIR: '/home/u/.claude-client', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '100000' },
});
session.setCustomModel(
{
endpointId: 'ep',
modelId: 'q',
envKeys: [...CLAUDE_KEYS, 'CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
configDir: '/isolated/claude',
homeEnvVar: 'CLAUDE_CONFIG_DIR',
},
{ ...claudeEnv, CLAUDE_CONFIG_DIR: '/isolated/claude', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' }
);
session.setCustomModel(undefined);
await session.restartCli();
const options = respawn.mock.calls[0][0];
expect(options.envOverrides).toEqual({
CLAUDE_CONFIG_DIR: '/home/u/.claude-client',
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '100000',
});
expect(options.unsetEnvKeys).toEqual(CLAUDE_KEYS);
});
it('still unsets a key whose own value was empty, since tmux never held that value', () => {
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: '' } });
session.setCustomModel(DSH_SELECTION, DSH_ENV);
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
});
it('writes the own home to state.json and no other own value', () => {
const { session } = liveSession('gemini', {
envOverrides: { GEMINI_API_KEY: 'own-secret', DSH_HOME: '/home/u/my-dsh' },
});
session.setCustomModel(
{
endpointId: 'ep',
modelId: 'q',
envKeys: ['GEMINI_API_KEY', 'DSH_HOME'],
homeEnvVar: 'DSH_HOME',
},
{ GEMINI_API_KEY: 'endpoint-key', DSH_HOME: '/isolated/dsh' }
);
const persisted = session.getCustomModelForPersist();
expect(persisted?.ownEnv).toEqual({ DSH_HOME: '/home/u/my-dsh' });
expect(JSON.stringify(persisted)).not.toContain('own-secret');
// The secret is still put back: it just never leaves memory.
session.setCustomModel(undefined);
expect(session.envOverrideValue('GEMINI_API_KEY')).toBe('own-secret');
});
it('keeps a home variable holding anything but an absolute path off disk', () => {
// The registry types `configDirVar`/`dirEnvVar` as plain env names, so a clis.json
// override can name a credential as the home variable. Only a path may reach disk.
const { session } = liveSession('gemini', { envOverrides: { GEMINI_API_KEY: 'own-secret' } });
session.setCustomModel(
{ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'], homeEnvVar: 'GEMINI_API_KEY' },
{ GEMINI_API_KEY: 'endpoint-key' }
);
expect(JSON.stringify(session.getCustomModelForPersist())).not.toContain('own-secret');
session.setCustomModel(undefined);
expect(session.envOverrideValue('GEMINI_API_KEY')).toBe('own-secret');
});
it('reads the own value while a selection holds the key, never the injected one', () => {
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: ' /home/u/my-dsh ' } });
expect(session.ownEnvValue('DSH_HOME')).toBe('/home/u/my-dsh');
session.setCustomModel(DSH_SELECTION, DSH_ENV);
expect(session.ownEnvValue('DSH_HOME')).toBe('/home/u/my-dsh');
// An injected key the session never set has no own value, whatever the map holds now.
expect(session.envOverrideValue('CODEMAN_CUSTOM_MODEL_API_KEY')).toBe('k');
expect(session.ownEnvValue('CODEMAN_CUSTOM_MODEL_API_KEY')).toBeUndefined();
});
it('persists nothing extra for a recipe with no home variable', () => {
const { session } = liveSession('gemini', { envOverrides: { GEMINI_API_KEY: 'own-secret' } });
session.setCustomModel(
{ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'] },
{ GEMINI_API_KEY: 'endpoint-key' }
);
expect(session.getCustomModelForPersist()).toEqual({ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'] });
});
});
describe('a claude session on its own config dir keeps its conversation', () => {
const ownDir = join(homedir(), 'custom-model-restart-own-claude');
const endpoint = { id: 'ep', label: 'Ep', baseUrl: 'http://box:8080', apiKey: 'k' };
afterEach(() => rmSync(ownDir, { recursive: true, force: true }));
function writeTranscript(configDir: string, conversationId: string): string {
const dir = join(configDir, 'projects', '-custom-model-restart');
mkdirSync(dir, { recursive: true });
const file = join(dir, `${conversationId}.jsonl`);
writeFileSync(file, '{"type":"user"}\n');
return file;
}
/** The route's apply, with the real isolated directory and its `projects` link. */
function applyEndpoint(session: Session) {
const applied = applyCustomModelInjection(
getCli('claude')!,
endpoint,
'q',
session.id,
undefined,
workingDir,
(envVar) => session.ownEnvValue(envVar)
)!;
session.setCustomModel(
{
endpointId: 'ep',
modelId: 'q',
envKeys: applied.envKeys,
configDir: applied.configDir,
homeEnvVar: applied.homeEnvVar,
},
applied.envOverrides
);
return applied;
}
it('resumes it on apply, from under the own dir', async () => {
const { session, respawn } = liveSession('claude', { envOverrides: { CLAUDE_CONFIG_DIR: ownDir } });
writeTranscript(ownDir, session.id);
const applied = applyEndpoint(session);
await session.restartCli();
const options = respawn.mock.calls[0][0];
expect(options.envOverrides?.CLAUDE_CONFIG_DIR).toBe(applied.configDir);
expect(options.resumeSessionId).toBe(session.id);
removeConfigDir(applied.configDir);
});
it('resumes what the endpoint run wrote on clear, on the own account', async () => {
// The server's tree exists, as it does on any machine that has run claude: this is
// where the `projects` link used to send the transcript, out of the own dir's sight.
mkdirSync(join(homedir(), '.claude', 'projects'), { recursive: true });
const { session, respawn } = liveSession('claude', { envOverrides: { CLAUDE_CONFIG_DIR: ownDir } });
const applied = applyEndpoint(session);
writeTranscript(applied.configDir!, session.id);
const { previousConfigDir } = session.setCustomModel(undefined);
removeConfigDir(previousConfigDir);
await session.restartCli();
expect(existsSync(customModelConfigDir(session.id))).toBe(false);
expect(existsSync(join(ownDir, 'projects', '-custom-model-restart', `${session.id}.jsonl`))).toBe(true);
const options = respawn.mock.calls[0][0];
expect(options.envOverrides?.CLAUDE_CONFIG_DIR).toBe(ownDir);
expect(options.resumeSessionId).toBe(session.id);
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* @fileoverview The env clamp's key list, read from the registry. A key stays clamped while
* its CLI is disabled: a session can outlive the switch (the reboot-restore route and the
* custom-model recovery both clamp for one that already exists), and neither the clear
* branch of the custom-model route nor `restartCli()` checks `enabled`.
*
* Port: N/A (the on-disk `clis.json` uses the per-file temp HOME from test/setup.ts).
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { reloadCliRegistry, registryFilePath } from '../src/config/cli-registry/registry.js';
import { clampEnvOverridesForOwner } from '../src/session-env-clamp.js';
describe('clampEnvOverridesForOwner: a disabled CLI', () => {
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
mkdirSync(dirname(registryFilePath()), { recursive: true });
writeFileSync(registryFilePath(), JSON.stringify({ clis: { deepseek: { enabled: false } } }), { mode: 0o600 });
reloadCliRegistry();
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
});
it('still strips its privileged keys for a non-granted owner', async () => {
expect(await clampEnvOverridesForOwner('nobody', { DSH_HOME: '/own' })).toEqual({});
});
});
+10 -4
View File
@@ -94,9 +94,11 @@ describe('Session Options modal structure', () => {
expect(modal.match(/<button[^>]*data-tab="git"[^>]*>/)?.[0]).not.toContain('data-claude-only');
const open = methodBody('openSessionOptions(sessionId)');
expect(open).toContain('.set-rail-item[data-tab="git"]');
// Remote and Docker sessions answer 'unsupported' on both git routes and the scheduler never fetches them,
// so the entry goes the same way the Claude-only ones do.
expect(open).toMatch(/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \? 'none' : ''/);
// Remote, Docker and adopted sessions answer 'unsupported' on both git routes and the scheduler never fetches
// them, so the entry goes the same way the Claude-only ones do.
expect(open).toMatch(
/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \|\| session\.adopt \? 'none' : ''/
);
});
it('makes the Git rows inert while a session follows the app, the way the keep-warm rows go inert', () => {
@@ -150,6 +152,10 @@ describe('Session Options modal structure', () => {
const open = methodBody('openSessionOptions(sessionId)');
expect(open).toContain("getElementById('modalSessionPath')");
expect(open).toContain('session.workingDir');
expect(methodBody('async copySessionPath()')).toContain('_copyText(session.workingDir)');
// An adopted session's workingDir is the neutral wrapper folder, so the row shows where the pane was.
expect(open).toContain('session.adopt?.paneCurrentPath || session.workingDir');
const copy = methodBody('async copySessionPath()');
expect(copy).toContain('session?.adopt?.paneCurrentPath || session?.workingDir');
expect(copy).toContain('_copyText(path)');
});
});
+11
View File
@@ -170,6 +170,17 @@ describe('resolveWaitSignals', () => {
expect(resolveWaitSignals('blocked', { mode: 'gemini' as SessionMode }).error).toBeTruthy();
});
it('names adoption, not the mode, when an adopted session refuses `stop`', () => {
// An adopted session can be `mode: claude` with no hooks installed, and "no Claude
// Code hooks for claude sessions" sent the caller looking for a bug in the wrong place.
const refusal = resolveWaitSignals('stop', { ...claude, adopted: true });
expect(refusal.until).toEqual([]);
expect(refusal.error).toContain('adopted session');
expect(refusal.error).toContain('idle or exit');
expect(refusal.error).not.toContain('claude sessions');
});
it('accepts `blocked` on codex, whose dialogs are read off the screen, but still not `stop`', () => {
// capabilities.workDetect.blockedLine gives codex a source for `blocked`; nothing on a
// screen says a turn ended, so `stop` keeps its 400.
+29
View File
@@ -898,6 +898,35 @@ describe('GitHub Copilot wheel forwarding', () => {
});
});
describe('pi wheel forwarding (fullscreen by default since 1.0.0)', () => {
// pi enables mouse tracking once at start. Codeman's replay (tab switch, reload) drops that
// mode in xterm (`mouseTrackingMode` none, baseY 0), so the wheel hit an empty local buffer
// and went dead until a redraw. Like Copilot, the server strips the DECSETs and publishes
// `cliMouseTracking`; the gate forwards from that flag, which survives the replay.
it('forwards the wheel while pi tracks the mouse', () => {
const { app } = hollowApp({ mode: 'pi', cliMouseTracking: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
});
it('keeps Shift+wheel local', () => {
expect(hollowApp({ mode: 'pi', cliMouseTracking: true }).app._shouldForwardWheelToApp({ shiftKey: true })).toBe(
false
);
});
it('sends nothing to an inline pi: page keys there move the caret of a multi-line draft', () => {
// `tuiMode: regular`, and every pi below 1.0.0, emits no mouse enable, so the flag stays false
// over a buffer that is hollow on a fresh pane. pi binds PageUp to its editor in that mode:
// measured on real pi, the wheel moved the caret and the next X landed on the first line.
const { app, sent } = hollowApp({ mode: 'pi', cliMouseTracking: false });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
});
});
describe('the paging gates asked for another pane (a TerminalTile)', () => {
it('exports the paging math, and the primary pane runs on it', () => {
const { app, sent, windowRef } = hollowApp();
+252 -2
View File
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
TmuxManager,
buildAdoptSessionCommand,
buildCodexCommand,
buildRemoteKillCommand,
buildRemoteLaunchCommand,
@@ -17,17 +18,33 @@ import {
parsePaneRows,
derivePaneExits,
hasObservablePaneSession,
endedAdoptedAttaches,
type PaneRow,
resolveActivePaneTarget,
} from '../src/tmux-manager.js';
import { execSync, exec } from 'node:child_process';
import type { MuxSession } from '../src/mux-interface.js';
import type { SessionAdopt } from '../src/types/session.js';
// ============================================================================
// Unit Tests (mocked)
// ============================================================================
// Mock child_process
/**
* Runs a captured `… respawn-pane … bash -c <arg>` line through a real local sh (with a
* foreign HOME/PATH) and returns the string bash would get, i.e. what ssh sends on.
*/
async function bashArgAfterLocalShell(respawnCmd: string): Promise<string> {
const real = await vi.importActual<typeof import('node:child_process')>('node:child_process');
const at = respawnCmd.indexOf(' bash -c ');
expect(at).toBeGreaterThan(0);
return real.execFileSync('/bin/sh', ['-c', `printf '%s' ${respawnCmd.slice(at + ' bash -c '.length)}`], {
env: { HOME: '/local-home-xyz', PATH: '/local-path-xyz:/usr/bin:/bin' },
encoding: 'utf8',
});
}
vi.mock('node:child_process', async () => {
const actual = await vi.importActual('node:child_process');
return {
@@ -189,7 +206,7 @@ describe('TmuxManager (unit)', () => {
sessionId: 'abc123def456',
});
expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l');
expect(command).toContain('exec "${SHELL:-/bin/sh}" "$@"; }');
// `failed`, not `on`: `on` also keeps the pane after a CLEAN exit, so typing
// `exit` in a remote shell strands a dead pane that the next launch's `-A`
// reattaches to instead of starting a shell.
@@ -212,7 +229,7 @@ describe('TmuxManager (unit)', () => {
// interactive nor login, so a bare `exec claude` fails with "command not found".
// The inner quoting is escaped twice over (once per shellescape() layer), so
// assert on the unescaped substrings rather than the literal quoted form.
expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l -c');
expect(command).toContain('exec "${SHELL:-/bin/sh}" "$@" -c');
expect(command).toContain('claude --dangerously-skip-permissions');
});
@@ -865,6 +882,171 @@ describe('TmuxManager (unit)', () => {
}
});
it('hands a remote launch to ssh with $HOME and $PATH unexpanded', async () => {
// respawn-pane runs `bash -c <arg>` through the local sh. In double quotes that sh
// expanded withRemoteTmuxPath's `$HOME/…:$PATH` to the Codeman host's values, so a
// remote user with another home found no tmux and the pane exited 127.
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.createSession({
sessionId: 'abc12345-1234-5678-90ab-cdef12345679',
workingDir: '/w',
mode: 'shell',
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
});
const launch = [...mockedExecSync.mock.calls, ...vi.mocked(exec).mock.calls]
.map(([cmd]) => cmd)
.find((cmd) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
const sent = await bashArgAfterLocalShell(launch as string);
expect(sent).toContain('ssh ');
expect(sent).toContain('export PATH="$HOME/');
expect(sent).toContain(':$PATH"; tmux ');
expect(sent).not.toContain('local-home-xyz');
expect(sent).not.toContain('local-path-xyz');
} finally {
nonTestManager.destroy();
}
});
it('respawns a remote pane with $HOME unexpanded as well', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
nonTestManager.registerSession({
sessionId: 'respawnremote2',
muxName: 'codeman-abcd8888',
pid: 1000,
createdAt: Date.now(),
workingDir: '/w',
mode: 'shell',
attached: false,
});
try {
await nonTestManager.respawnPane({
sessionId: 'respawnremote2',
workingDir: '/w',
mode: 'shell',
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
});
const { exec: currentExec } = await import('node:child_process');
const respawnCall = vi
.mocked(currentExec)
.mock.calls.find(([cmd]) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
const sent = await bashArgAfterLocalShell(respawnCall?.[0] as string);
expect(sent).toContain('export PATH="$HOME/');
expect(sent).not.toContain('local-home-xyz');
} finally {
nonTestManager.destroy();
}
});
it('hands a docker override to bash with $HOME unexpanded', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.createSession({
sessionId: 'abc12345-1234-5678-90ab-cdef12345680',
workingDir: '/w',
mode: 'shell',
docker: {
hostId: 'h',
label: 'L',
engine: 'docker',
image: 'codeman/agent:base',
containerName: 'codeman-case-x',
hostWorkspacePath: '/w',
containerWorkdir: '/w',
commands: { shell: 'echo CONTAINER_HOME=$HOME; exec sleep 30' },
} as never,
});
const launch = [...mockedExecSync.mock.calls, ...vi.mocked(exec).mock.calls]
.map(([cmd]) => cmd)
.find((cmd) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
const sent = await bashArgAfterLocalShell(launch as string);
expect(sent).toContain('docker');
expect(sent).toContain('CONTAINER_HOME=$HOME');
expect(sent).not.toContain('local-home-xyz');
} finally {
nonTestManager.destroy();
}
});
describe('an adopted launch reaches bash exactly as built', () => {
// A single quote, a real newline and `$HOME` in one value. Discovery refuses such a
// socket path, so this measures the quoting alone: `$HOME/…:$PATH` in the ssh line and
// the `'\''` of every nested quote have to come through the same way.
const foreign = {
socketPath: "/tmp/it's\n$HOME/sock",
targetSession: 'work',
viewSession: 'codeman-view-abc12345',
};
const adoptions: [string, SessionAdopt][] = [
['local', { ...foreign, location: 'local' }],
[
'docker',
{
...foreign,
location: 'docker',
docker: { hostId: 'local', label: 'Local', engine: 'docker', containerName: 'their-box' },
},
],
[
'ssh',
{
...foreign,
location: 'remote',
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
},
],
];
const respawnLine = () =>
[...mockedExecSync.mock.calls, ...vi.mocked(exec).mock.calls]
.map(([cmd]) => cmd)
.find((cmd) => typeof cmd === 'string' && cmd.includes(' respawn-pane ')) as string;
it.each(adoptions)('at create: %s', async (_location, adopt) => {
const sessionId = 'abc12345-1234-5678-90ab-cdef12345681';
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.createSession({ sessionId, workingDir: '/tmp', mode: 'shell', adopt });
// Equal to the built line: no `cd`, nothing expanded here, nothing re-escaped.
expect(await bashArgAfterLocalShell(respawnLine())).toBe(buildAdoptSessionCommand(adopt, sessionId));
} finally {
nonTestManager.destroy();
}
});
it.each(adoptions)('at respawn: %s', async (_location, adopt) => {
const sessionId = 'respawnadopted';
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
nonTestManager.registerSession({
sessionId,
muxName: 'codeman-abcd7777',
pid: 1000,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'shell',
attached: false,
adopt,
});
try {
// No `adopt` here: a respawn takes it from the tracked session, so a caller
// that forgets the field still re-attaches rather than launching an agent.
await nonTestManager.respawnPane({ sessionId, workingDir: '/tmp', mode: 'shell' });
expect(await bashArgAfterLocalShell(respawnLine())).toBe(buildAdoptSessionCommand(adopt, sessionId));
} finally {
nonTestManager.destroy();
}
});
});
it('changes the global history default on tmux versions that cannot resize panes', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
@@ -1540,3 +1722,71 @@ describe('hasObservablePaneSession', () => {
// the rule is enforced; that pairing is pinned in session-pane-exit.test.ts,
// where a real Session can answer for itself.
});
describe('an adopted wrapper whose pane died', () => {
const adopted = new Set(['codeman-aaaa1111']);
class WatchedManager extends TmuxManager {
rows = '';
detached: string[] = [];
protected override async readAttachRows(): Promise<string> {
return this.rows;
}
protected override async detachClients(muxName: string): Promise<void> {
this.detached.push(muxName);
}
adopt(sessionId: string, adopt?: MuxSession['adopt']): void {
const session = {
sessionId,
muxName: `codeman-${sessionId}`,
pid: 1,
createdAt: 0,
workingDir: '/tmp',
mode: 'shell',
attached: false,
adopt,
};
(this as unknown as { sessions: Map<string, MuxSession> }).sessions.set(sessionId, session as MuxSession);
}
}
const adoption = {
location: 'local',
socketPath: '/tmp/s',
targetSession: 'work',
viewSession: 'codeman-view-aaaa1111',
} as MuxSession['adopt'];
it('reads as ended only with a dead pane and a client still on it', () => {
expect(endedAdoptedAttaches('1 1 codeman-aaaa1111\n', adopted)).toEqual(['codeman-aaaa1111']);
expect(endedAdoptedAttaches('1 2 codeman-aaaa1111', adopted)).toEqual(['codeman-aaaa1111']);
// Nothing to detach: a second try would only print `no current client` every tick.
expect(endedAdoptedAttaches('1 0 codeman-aaaa1111', adopted)).toEqual([]);
expect(endedAdoptedAttaches('0 1 codeman-aaaa1111', adopted)).toEqual([]);
expect(endedAdoptedAttaches('', adopted)).toEqual([]);
});
it("leaves every other session's dead pane alone, since that one is an agent's exit", () => {
expect(endedAdoptedAttaches('1 1 codeman-bbbb2222', adopted)).toEqual([]);
});
it('detaches the client from a dead wrapper on the tick', async () => {
const manager = new WatchedManager();
manager.adopt('aaaa1111', adoption);
manager.adopt('bbbb2222');
manager.rows = '1 1 codeman-aaaa1111\n1 1 codeman-bbbb2222\n';
await manager.detachEndedAdoptedAttaches();
expect(manager.detached).toEqual(['codeman-aaaa1111']);
});
it('does not read tmux while nothing is adopted', async () => {
const manager = new WatchedManager();
manager.adopt('bbbb2222');
const read = vi.spyOn(manager as unknown as { readAttachRows: () => Promise<string> }, 'readAttachRows');
await manager.detachEndedAdoptedAttaches();
expect(read).not.toHaveBeenCalled();
});
});