mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
commit
fec052f402
181 files changed
+14849
-872
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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\'; }'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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')));
|
||||
|
||||
@@ -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;/);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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\'; }'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\'; }'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 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[@]}"');
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user