Merge #510 feat(adopt): adopt tmux sessions a human started, in all three locations

The attach line now takes the rule #770 gave remote and docker launches: bashCArg() single-quotes it at both respawn-pane sites, so it reaches bash verbatim. quoteAdoptLaunch is retired, since its double quotes kept the same $ and backtick safety but lost a real newline. #510's no-cd condition stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 05:36:20 +01:00
commit c65706a3e4
64 files changed
+5321 -103

No files matched your search

+35
View File
@@ -3,6 +3,7 @@ import { expect, it, vi } from 'vitest';
import { formatPaneSnapshot } from '../src/tmux-manager.js';
import { Session } from '../src/session.js';
import { promptSha256 } from '../src/agent-staging.js';
import type { SessionAdopt } from '../src/types/session.js';
it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => {
let screen = '› \n GPT-6-Luna default · ~/case\n';
const mux = {
@@ -542,3 +543,37 @@ it('stages through a real session whose first post-write capture is the torn fra
await session.stop(false);
}
});
it('offers no guarded input on an adopted pane, however much it looks like a composer', () => {
// Stage and submit type a prompt on an agent's behalf, here into a pane a human is using.
const screen = '\u203a \n GPT-6-Luna default \u00b7 ~/case\n';
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(screen.trimEnd().split('\n'), { cols: 100, rows: 20, cursorX: 2, cursorY: 0 });
},
sendInput: async () => true,
};
const build = (adopt?: SessionAdopt) =>
new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
adopt,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
} as unknown as ConstructorParameters<typeof Session>[0]);
expect(build().guardedInputSnapshot()).not.toBeNull();
expect(
build({
location: 'local',
socketPath: '/tmp/tmux-1000/default',
targetSession: 'work',
viewSession: 'codeman-view-00000000',
}).guardedInputSnapshot()
).toBeNull();
});
+9 -2
View File
@@ -218,8 +218,15 @@ describe('adopted container: the host is not required to have the CLI', () => {
expect(src.match(/if \(mode === '[a-z]+' && !cliDir\)/g)).toBeNull();
});
it('derives the flag from the docker metadata the session already carries', () => {
expect(src).toContain('const cliRunsInContainer = !!docker;');
it('derives the flag from the location metadata the session already carries', () => {
// Adoption joined the condition for the same reason docker is in it: an
// adopted session's CLI was started by a human in a process Codeman never
// spawned, so the host binary is irrelevant there too — and demanding it
// would reject adopting a claude that lives in a container, on an ssh host,
// or simply outside the server process's PATH (the systemd/launchd case).
// What the assertion still pins is that the flag comes from the session's
// OWN metadata rather than from anything ambient.
expect(src).toContain('const cliRunsInContainer = !!docker || !!adopt;');
});
});
+1
View File
@@ -174,6 +174,7 @@ describe('unsupportedReason', () => {
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');
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* @fileoverview The adoption guards that are not reachable over HTTP: the
* boot-time workspace-hook sweep, the workspace-trust auto-accept and the
* new-shell clear.
*
* 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 [])) };
});
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([]);
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* @fileoverview The strings of the outside-sessions block in Chinese (zh-CN).
*
* Harvested from the real module rather than listed here, so a new string without a
* dictionary entry fails this test instead of showing up English on one surface. The
* block's row text carries data-i18n-skip (a session name and a command line are
* someone else's), so its location words only translate through the module's own
* foreignT() call, and the DOM translator would never reach them.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const MODULE = readFileSync(resolve(PUBLIC, 'foreign-sessions.js'), 'utf8');
type Api = { configure(o: Record<string, string>): void; t(source: string): string };
/** Everything the module sends through foreignT, plus the toggle's two literal labels. */
const FROM_MODULE = [...MODULE.matchAll(/foreignT\('([^']+)'\)/g)].map((m) => m[1]);
const LOCATION_WORDS = ['this machine', 'container', 'remote'];
const TOGGLE = ['⟳ all', '⟳ local'];
const ELSEWHERE = [
'Close Tab',
'Detaches only. The tmux session keeps running for whoever started it.',
'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.',
'Git status is not available for a tmux session opened outside Codeman.',
];
const ALL = [...FROM_MODULE, ...LOCATION_WORDS, ...TOGGLE, ...ELSEWHERE];
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.configure({ language: 'zh-CN' });
afterAll(() => {
dom.window.close();
});
describe('the outside-sessions block in zh-CN', () => {
it('harvests the module (the check is not vacuous)', () => {
expect(FROM_MODULE).toContain('Opened outside Codeman');
expect(FROM_MODULE).toContain('Go to tab');
expect(FROM_MODULE.length).toBeGreaterThanOrEqual(10);
});
it('wraps every location word, whose row the DOM translator skips', () => {
expect(MODULE).toContain('foreignT(FOREIGN_LOCATION_LABEL[row.location] || row.location)');
for (const word of LOCATION_WORDS) expect(MODULE).toContain(`: '${word}'`);
});
it.each(ALL)('translates %s', (source) => {
const chinese = api.t(source);
expect(chinese).not.toBe(source);
expect(chinese.replace(/\b(Codeman|tmux|Git)\b/g, '').match(/[A-Za-z]+/g) ?? []).toEqual([]);
});
it('adds each key to the dictionary once', () => {
// Prettier leaves a key that is one word unquoted.
for (const source of new Set(ALL)) {
const bare = /^\w+$/.test(source) ? I18N.split(`\n ${source}: `).length - 1 : 0;
expect(I18N.split(`'${source}':`).length - 1 + bare, source).toBe(1);
}
});
});
+248
View File
@@ -0,0 +1,248 @@
/**
* @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> = {}) {
const host = stubEl();
const ctx: Record<string, unknown> = {
console,
Date,
Number,
Array,
Object,
JSON,
Math,
String,
setInterval: () => 1,
clearInterval() {},
codemanT: null,
window,
document: {
createElement: () => stubEl(),
getElementById: (id: string) => (id === 'foreignSessions' ? host : null),
addEventListener() {},
},
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;
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('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');
});
});
+915
View File
@@ -0,0 +1,915 @@
/**
* Foreign tmux adoption — the pure core.
*
* These pin the properties that were established by MEASUREMENT against a real
* tmux (3.3a) while the feature was built, and that a plausible-looking refactor
* would quietly undo. Each one has a comment naming what actually went wrong.
*/
import { execSync, spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createServer, type Server } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import {
buildForeignProbeScript,
parseForeignProbeOutput,
classifyForeignPaneMode,
isCodemanOwnedPane,
foreignSessionId,
foreignViewSessionName,
isAdoptableSessionName,
isAdoptableSocketPath,
buildForeignAttachCommand,
buildForeignDockerAttachCommand,
buildForeignRemoteAttachCommand,
buildForeignDockerViewKillCommand,
buildForeignTmuxInvocation,
foreignCliSignatures,
} from '../src/foreign-tmux.js';
import {
candidatesFrom,
foreignDockerProbeArgv,
foreignRemoteProbeCommand,
runForeignProbe,
} from '../src/foreign-tmux-discovery.js';
import { bashCArg, buildAdoptSessionCommand } from '../src/tmux-manager.js';
import type { RemoteHost, SessionAdopt } from '../src/types/session.js';
import { PROC_WALK_MAX_NODES } from '../src/proc-tree.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { hooksAvailableForMode, sessionHookOptions } from '../src/web/session-wait-registry.js';
// A probe transcript in exactly the shape a real run produces. The pane rows use
// the LITERAL backslash-t that tmux's `-F` emits (verified on next-3.7 and 3.3a),
// while the socket line is space-separated because `sh`'s builtin `echo` expands
// a backslash-t to a real TAB — two different meanings for one escape, two lines
// apart, which is why the socket marker carries no separator at all.
const PROBE = [
'CMFS /tmp/tmux-0/default',
'CMFP\\t/tmp/tmux-0/default\\t631\\t0\\t1\\t1788092494\\t1\\t%0\\tclaude\\twork\\t/srv/app',
'CMFP\\t/tmp/tmux-0/default\\t900\\t0\\t1\\t1788092500\\t0\\t%1\\tbash\\tscratch\\t/home/me',
'CMFP\\t/tmp/tmux-0/default\\t950\\t0\\t2\\t1788092600\\t0\\t%2\\tnode\\tcodex-work\\t/srv/app',
'CMFQ',
' 631 630 -bash',
' 4056 631 claude --dangerously-skip-permissions',
' 4104 4056 /usr/local/bin/ortg --repo /ortg mcp',
' 900 630 -bash',
' 950 630 node /opt/homebrew/bin/codex',
].join('\n');
describe('buildForeignProbeScript', () => {
it('contains no single quote — it is wrapped in one to cross ssh and docker exec', () => {
// The script is embedded as `ssh host '<script>'` and `docker exec c sh -lc
// '<script>'`. One single quote inside ends the wrapper early and the rest is
// re-tokenized by the remote shell.
expect(buildForeignProbeScript()).not.toContain("'");
});
it('does not disable globbing — the socket sweep IS a glob', () => {
// A `set -f` here turned the whole loop into one literal non-match, and the
// probe reported zero sessions on a machine that had three.
expect(buildForeignProbeScript()).not.toMatch(/\bset -f\b/);
});
it('keeps the shell variables unexpanded for the INNER shell', () => {
// Running this through anything that adds an outer shell (execSync spawns
// `sh -c`) expands `$TMUX_TMPDIR`/`$U` too early. Callers must use an argv
// array locally; the script's job is only to still contain them.
const s = buildForeignProbeScript();
expect(s).toContain('${TMUX_TMPDIR:-/tmp}');
expect(s).toContain('$TD/tmux-$U/*');
});
it('asks tmux for UTF-8, so a POSIX locale cannot rewrite a name into underscores', () => {
// Measured in a POSIX-locale container: without `-u` a session called `会话` was
// listed as `____`, the guard then looked for `____`, and the tab opened dead.
expect(buildForeignProbeScript()).toContain('tmux -u -S "$S" list-panes');
});
it('pings each server with stdout at /dev/null before it gets the real one', () => {
// A client hands dups of its stdout to the server, so a stopped server holds our
// pipe open after the client dies and no reader of that pipe can ever finish.
const s = buildForeignProbeScript();
expect(s).toContain('tmux -S "$S" has-session >/dev/null 2>&1 &');
expect(s.indexOf('has-session')).toBeLessThan(s.indexOf('list-panes'));
});
});
describe('parseForeignProbeOutput', () => {
it('parses pane rows, the socket list and the process snapshot', () => {
const out = parseForeignProbeOutput(PROBE);
expect(out.sockets).toEqual(['/tmp/tmux-0/default']);
expect(out.panes).toHaveLength(3);
expect(out.panes[0]).toMatchObject({
socketPath: '/tmp/tmux-0/default',
sessionName: 'work',
panePid: 631,
paneCurrentCommand: 'claude',
paneCurrentPath: '/srv/app',
sessionAttached: true,
windows: 1,
});
expect(out.argvByPid.get(4056)).toBe('claude --dangerously-skip-permissions');
expect(out.byParent.get(631)).toEqual([4056]);
});
it('never throws on garbage and simply skips unusable rows', () => {
const out = parseForeignProbeOutput('nonsense\nCMFP\\ttoo\\tfew\nCMFQ\nnot a proc row\n');
expect(out.panes).toHaveLength(0);
expect(out.argvByPid.size).toBe(0);
});
it('tolerates a real TAB, in case a tmux build expands the escape', () => {
const withTabs = PROBE.split('\n')
.map((l) => (l.startsWith('CMFP') ? l.replace(/\\t/g, '\t') : l))
.join('\n');
expect(parseForeignProbeOutput(withTabs).panes).toHaveLength(3);
});
it('keeps the pane path even when the SESSION NAME contains a separator', () => {
// The two free-form fields sit last for exactly this: the path is taken from
// the END and the name is whatever lies between the fixed prefix and it, so a
// separator inside a user-chosen name cannot shift the path out of place.
const row = 'CMFP\\t/s\\t1\\t0\\t1\\t100\\t0\\t%0\\tbash\\tmy\\tname\\t/srv/x';
const out = parseForeignProbeOutput(`${row}\nCMFQ\n`);
expect(out.panes[0].paneCurrentPath).toBe('/srv/x');
expect(out.panes[0].sessionName).toBe('my\tname');
});
it('lists every socket it swept, however many there are', () => {
// There used to be a cap here that only trimmed this list while the loop went on
// to every socket anyway. A real cap in glob order would be worse: `codeman*`
// sorts before `default`, so a box with many instances would never reach it.
const lines = Array.from({ length: 40 }, (_, i) => `CMFS /tmp/tmux-0/s${i}`);
expect(parseForeignProbeOutput(lines.join('\n')).sockets).toHaveLength(40);
});
it('records a server that did not answer', () => {
const out = parseForeignProbeOutput(`CMFS /tmp/tmux-0/hung\nCMFH /tmp/tmux-0/hung\nCMFQ\n`);
expect(out.hungSockets).toEqual(['/tmp/tmux-0/hung']);
});
});
describe('classifyForeignPaneMode', () => {
const probe = parseForeignProbeOutput(PROBE);
it('finds claude through the process tree, not the pane command', () => {
// `#{pane_current_command}` is `node` for BOTH claude and codex, and the
// pane's own pid is the SHELL. Only the descendant walk can tell them apart.
const pane = probe.panes.find((p) => p.sessionName === 'work')!;
expect(classifyForeignPaneMode(pane, probe).mode).toBe('claude');
});
it('finds codex the same way', () => {
const pane = probe.panes.find((p) => p.sessionName === 'codex-work')!;
expect(classifyForeignPaneMode(pane, probe).mode).toBe('codex');
});
it('calls a plain shell a shell — the case this feature exists for', () => {
const pane = probe.panes.find((p) => p.sessionName === 'scratch')!;
const c = classifyForeignPaneMode(pane, probe);
expect(c.mode).toBe('shell');
expect(c.command).toBe('-bash');
});
it("falls back to tmux's own answer when there is no process snapshot", () => {
// A host whose `ps` refused both forms still gets a usable classification:
// `pane_current_command` is the FOREGROUND process, so it names the agent
// even though the pane pid names the shell.
const empty = { byParent: new Map(), argvByPid: new Map() };
expect(classifyForeignPaneMode({ panePid: 1, paneCurrentCommand: 'claude' }, empty).mode).toBe('claude');
expect(classifyForeignPaneMode({ panePid: 1, paneCurrentCommand: 'zsh' }, empty).mode).toBe('shell');
});
});
describe('isCodemanOwnedPane', () => {
const pane = (socketPath: string, sessionName: string) => ({ socketPath, sessionName });
it("excludes this instance's own socket", () => {
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/codeman-beta', 'x'), 'codeman-beta')).toBe(true);
});
it('excludes ANOTHER Codeman instance too', () => {
// Otherwise a beta would offer to adopt prod's sessions, attaching a second
// PTY to a live pane prod is already driving.
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/codeman', 'x'), 'codeman-beta')).toBe(true);
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/codeman-remote', 'x'), 'codeman-beta')).toBe(true);
});
it('excludes Codeman-minted session names on a foreign socket', () => {
// Our own grouped view sessions live on the foreign server; without this a
// second scan would offer to adopt our own view.
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/default', 'codeman-view-abc12345'), 'codeman')).toBe(true);
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/default', 'claudeman-abc'), 'codeman')).toBe(true);
});
it('admits an ordinary hand-made session', () => {
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/default', 'work'), 'codeman')).toBe(false);
});
});
describe('adoptability gate (security)', () => {
it('refuses a name carrying command substitution', () => {
// The attach line reaches `bash -c` single-quoted now (`bashCArg()`), but it used
// to end at `bash -c ${JSON.stringify(cmd)}`, where the OUTER shell expanded
// `$(...)` and backticks inside its double quotes before bash ever saw the inner
// single quotes. Measured: a launchCmd of `: 'x$(touch A)`touch B`'` created BOTH
// files. A foreign session name is chosen by someone else and more shells parse it
// after that one, so it must never reach that string whatever the quoting of the day.
expect(isAdoptableSessionName('work;$(touch /tmp/PWNED)')).toBe(false);
expect(isAdoptableSessionName('work`touch /tmp/PWNED`')).toBe(false);
expect(isAdoptableSessionName('work$HOME')).toBe(false);
expect(isAdoptableSessionName('a\\b')).toBe(false);
expect(isAdoptableSessionName('a"b')).toBe(false);
expect(isAdoptableSessionName("a'b")).toBe(false);
expect(isAdoptableSessionName('a\nb')).toBe(false);
expect(isAdoptableSessionName('')).toBe(false);
});
it('still admits the names people actually use', () => {
// A refusal here is a session the user cannot open at all, so the allowlist
// has to cover real life: spaces, CJK, punctuation.
for (const n of ['work', 'my-project', 'feat/login', 'weird name', 'zh-会话', 'v1.2_build', 'a+b@c']) {
expect(isAdoptableSessionName(n)).toBe(true);
}
});
it('refuses a name tmux reads as target syntax rather than as a name', () => {
// `has-session -t '=<name>'` finds these by name, but `new-session -t <name>` does
// not. Measured on tmux 3.4: `@1` and `%1` joined the session holding that window or
// pane, so input typed in the tab reached someone else's pane, and `=eq`, `=`, `@zz`,
// `%zz` and `~` made a new group with a fresh shell on the foreign server.
for (const n of ['@1', '%1', '@zz', '%zz', '=eq', '=', '~']) expect(isAdoptableSessionName(n)).toBe(false);
});
it('keeps the names that only look like target syntax', () => {
// Each of these joined its own windows on tmux 3.4. `! + - ^` are window tokens, and
// a `-t` of the session type never reads them as such.
for (const n of ['!', '+', '-', '^', '^x', '+x', '-dash', '!bang', '~foo', 'a~b', 'a@b', 'x=y', 'x%y', '40']) {
expect(isAdoptableSessionName(n)).toBe(true);
}
});
it('holds the socket path to the same rule, plus absoluteness', () => {
// A socket is `tmux -L <name>` under a user-controlled directory, so its
// path is attacker-influenceable in exactly the same way.
expect(isAdoptableSocketPath('/tmp/tmux-0/default')).toBe(true);
expect(isAdoptableSocketPath('/tmp/tmux-0/$(id)')).toBe(false);
expect(isAdoptableSocketPath('relative/path')).toBe(false);
expect(isAdoptableSocketPath('/tmp/../etc/x')).toBe(false);
});
});
describe('identity', () => {
it('is stable across scans and distinct per target', () => {
const a = foreignSessionId('local', 'local', '/tmp/tmux-0/default', 'work');
expect(foreignSessionId('local', 'local', '/tmp/tmux-0/default', 'work')).toBe(a);
expect(foreignSessionId('local', 'local', '/tmp/tmux-0/default', 'other')).not.toBe(a);
expect(foreignSessionId('remote', 'h1', '/tmp/tmux-0/default', 'work')).not.toBe(a);
});
it('names the view session after the Codeman session', () => {
expect(foreignViewSessionName('a10674f9-abce-4551')).toBe('codeman-view-a10674f9');
});
});
describe('attach command builders', () => {
const target = { socketPath: '/tmp/tmux-0/default', targetSession: 'work', viewSession: 'codeman-view-abc12345' };
it('creates the view session ATTACHED, never with -d', () => {
// tmux's `server_check_unattached()` runs every server loop, so a DETACHED
// session carrying `destroy-unattached on` is destroyed almost immediately.
const cmd = buildForeignTmuxInvocation(target);
expect(cmd).toContain('new-session -t');
expect(cmd).not.toMatch(/new-session\s+-d/);
});
it('checks the target exists BEFORE new-session, because new-session invents one', () => {
// ⚠️ `new-session -t` does not fail on a missing target. Measured on tmux 3.3a
// with only `work-prod` present: `new-session -t work` succeeded and joined our
// view into `work-prod`'s group — someone else's session. On a socket with no
// server it starts one. The wrapper pane re-runs this on every respawn, and boot
// recovery respawns every dead pane, so without this guard the owner exiting
// their session leaves Codeman creating shells on their machine.
const cmd = buildForeignTmuxInvocation(target);
const guard = cmd.indexOf('has-session');
const create = cmd.indexOf('new-session');
expect(guard).toBeGreaterThan(-1);
expect(guard).toBeLessThan(create);
expect(cmd).toMatch(/has-session -t '=work'/);
// Fails closed with a message rather than falling through to new-session.
expect(cmd).toMatch(/has-session[^;]*\|\|\s*\{\s*echo[^;]*;\s*exit 1;\s*\}/);
});
it('uses the exact-match = only where a session is looked UP, never on new-session -t', () => {
// Measured: tmux reads the `=` on `new-session -t` as part of the name and
// creates a group literally called `=work`, so the guard has to be separate.
// Without `=`, `has-session -t work` matches `work-prod` (verified on 3.3a).
const cmd = buildForeignTmuxInvocation(target);
expect(cmd).toContain("new-session -t 'work'");
expect(cmd).not.toContain("new-session -t '=work'");
// The two commands that LOOK a session up both take `=`.
expect(cmd).toContain("has-session -t '=work'");
expect(cmd).toContain("kill-session -t '=codeman-view-abc12345'");
});
it('keeps the guard on all three locations, not just the local one', () => {
// The guard lives in the one shared invocation builder, so docker and remote
// inherit it. A future split into per-location strings must keep that true:
// the ssh and container paths are exactly where a stray session costs most.
expect(buildForeignDockerAttachCommand({ ...target, dockerBase: 'docker', containerName: 'box' })).toContain(
'has-session'
);
expect(
buildForeignRemoteAttachCommand({
...target,
sshArgs: ['ssh', '-o BatchMode=yes'],
sshTarget: 'u@h',
})
).toContain('has-session');
});
it('attaches as a UTF-8 client whatever the locale says', () => {
// Measured in a POSIX-locale container: without `-u` the tab read `_ hello qqq ____`
// for `❯ hello ─── 你好`, with `client_utf8=0`.
expect(buildForeignTmuxInvocation(target)).toContain("exec tmux -u -S '/tmp/tmux-0/default' new-session -t");
});
it('says tmux is missing instead of calling a live session gone', () => {
// `has-session ... 2>/dev/null ||` fires for any failure, exit 127 included, so an
// ssh host where tmux is off the PATH used to print that the session was gone.
const emptyBin = mkdtempSync(join(tmpdir(), 'foreign-no-tmux-'));
try {
const run = spawnSync('/bin/sh', ['-c', buildForeignTmuxInvocation(target)], {
encoding: 'utf-8',
env: { PATH: emptyBin },
});
expect(run.status).toBe(127);
expect(run.stdout).toMatch(/tmux was not found/);
expect(run.stdout).not.toMatch(/is gone/);
} finally {
rmSync(emptyBin, { recursive: true, force: true });
}
});
it('sets destroy-unattached so the view dies with our pane', () => {
expect(buildForeignTmuxInvocation(target)).toContain('destroy-unattached on');
});
it('never sets window-size on the shared window', () => {
// Measured: `window-size largest` is the only setting that protects an
// actively-used session's size, but it is a WINDOW option on a SHARED window
// and SURVIVES our detach — it would permanently rewrite the owner's config.
expect(buildForeignTmuxInvocation(target)).not.toContain('window-size');
});
it('leaves NO degraded fallback — it either groups or fails with a message', () => {
// A read-only `attach-session -r` used to hang off `||`, nominally for a tmux
// too old to group. But `||` fires on ANY new-session failure, and the one that
// happens is a leftover view with the same name: measured, new-session answers
// `duplicate session: …` and the fallback then handed the user a read-only
// client where typed input is dropped while the API reported success.
const cmd = buildForeignTmuxInvocation(target);
expect(cmd).not.toContain('attach-session');
expect(cmd).not.toMatch(/new-session[^;]*\|\|/);
});
it('clears OUR OWN stale view before creating it, and only ours', () => {
// The view name is derived from Codeman's own session id, so a session with
// that name can only ever be ours. `=` keeps the match exact, and killing a
// session in a group removes only that session (measured: the owner's `work`
// survived its grouped view being killed).
const cmd = buildForeignTmuxInvocation(target);
expect(cmd).toContain("kill-session -t '=codeman-view-abc12345'");
expect(cmd).not.toContain("kill-session -t '=work'");
const kill = cmd.indexOf('kill-session');
expect(kill).toBeGreaterThan(cmd.indexOf('has-session'));
expect(kill).toBeLessThan(cmd.indexOf('new-session'));
// A missing stale view is the normal case, never a reason to abort.
expect(cmd).toMatch(/kill-session[^;]*2>\/dev\/null \|\| true/);
});
it('stays on ONE line', () => {
expect(buildForeignAttachCommand(target)).not.toContain('\n');
});
it('uses no command substitution, the rule of every launch chain here', () => {
// Same rule the docker launch chain states, and the same trap that emptied
// `$TMUX_TMPDIR` during development.
expect(buildForeignAttachCommand(target)).not.toContain('$(');
});
it('shell-quotes every caller-supplied value', () => {
const nasty = buildForeignAttachCommand({
socketPath: '/tmp/a b',
targetSession: 'ev;il`x`',
viewSession: 'codeman-view-1',
});
expect(nasty).toContain("'/tmp/a b'");
// The metacharacters survive only INSIDE quotes, never as shell syntax. Checked
// by stripping every single-quoted span and looking at what is left, rather than
// by what character precedes the name: the "session is gone" message quotes the
// name too, with a space in front of it, and a lookbehind assertion reads that
// as an escape when it is not one.
const outsideQuotes = nasty
.split("'")
.filter((_, i) => i % 2 === 0)
.join(' ');
expect(outsideQuotes).not.toContain('ev;il');
expect(outsideQuotes).not.toContain('`');
});
describe('docker', () => {
const cmd = buildForeignDockerAttachCommand({
...target,
dockerBase: 'docker --context ci',
containerName: 'my-box',
});
it('looks, then execs — never create, never start', () => {
// An adopted container belongs to the user. Mirrors the adopted branch of
// buildDockerLaunchCommand, which fails closed rather than mutating it.
expect(cmd).toContain('inspect');
expect(cmd).toContain('exec -it');
expect(cmd).not.toMatch(/\bdocker[^;]*\bstart\b/);
expect(cmd).not.toMatch(/\bdocker[^;]*\bcreate\b/);
expect(cmd).not.toMatch(/\bdocker[^;]*\brun\b/);
});
it('refuses a stopped container with a message instead of starting it', () => {
expect(cmd).toContain('State.Running');
expect(cmd).toMatch(/not running/);
});
it('carries the engine flags it was given', () => {
expect(cmd).toContain('docker --context ci');
});
it('gives the client the same terminal an owned container gets', () => {
// `docker exec -it` defaults to TERM=xterm, which held the tab to 8 colours.
expect(cmd).toContain("exec -it -e TERM=xterm-256color -e COLORTERM=truecolor 'my-box' sh -lc");
});
});
describe('remote', () => {
const cmd = buildForeignRemoteAttachCommand({
...target,
sshArgs: ['ssh', '-o BatchMode=yes', '-o ConnectTimeout=10', '-p 2222'],
sshTarget: 'me@host',
});
it('requests a PTY right after BatchMode, like buildRemoteAttachCommand', () => {
// Interactive tmux needs a TTY; the position matches the existing remote
// attach builder so the two connect on identical terms.
expect(cmd.startsWith('ssh -o BatchMode=yes -t ')).toBe(true);
});
it('keeps every connection option it was handed', () => {
expect(cmd).toContain('-p 2222');
expect(cmd).toContain('me@host');
});
it('single-quotes the whole remote invocation', () => {
expect(cmd).toMatch(/me@host '.*tmux -S .*'$/);
});
});
});
describe('the docker and ssh transports', () => {
const host: RemoteHost = {
id: 'h1',
label: 'Build box',
host: 'build.example',
username: 'me',
} as RemoteHost;
const dockerTarget = {
hostId: 'd1',
label: 'ci',
containerName: 'box',
base: { engine: 'docker' as const, context: 'my ctx', daemonHost: 'tcp://10.0.0.1:2375' },
};
it('hands execFile a docker argv with no shell quoting in it', () => {
// `docker --context "'my ctx'"` is what the shell-quoted builder gave execFile:
// "context not found", so every container behind a context read as unreachable.
const argv = foreignDockerProbeArgv(dockerTarget);
expect(argv.slice(0, 5)).toEqual(['docker', '--context', 'my ctx', '-H', 'tcp://10.0.0.1:2375']);
expect(argv.slice(5, 9)).toEqual(['exec', 'box', 'sh', '-lc']);
for (const token of argv) expect(token).not.toContain("'");
});
it('runs the ssh probe in sh with the remote tmux PATH, so a timeout reaches ssh', () => {
const cmd = foreignRemoteProbeCommand(host);
expect(cmd.startsWith('exec ssh ')).toBe(true);
expect(cmd).toContain('me@build.example');
expect(cmd).toContain('/opt/homebrew/bin');
expect(cmd).toContain('exec sh -c');
expect(cmd).toContain('list-panes');
});
it('puts the same PATH and sh on the ssh attach', () => {
const cmd = buildForeignRemoteAttachCommand({
socketPath: '/tmp/tmux-1000/default',
targetSession: 'work',
viewSession: 'codeman-view-abc12345',
sshArgs: ['ssh', '-o BatchMode=yes'],
sshTarget: 'me@host',
});
expect(cmd).toContain('/opt/homebrew/bin');
expect(cmd).toContain('exec sh -c');
});
it('kills the in-container view by exact name', () => {
// Without `=` tmux prefix-matches, and the view is grouped with the owner's work.
expect(
buildForeignDockerViewKillCommand({
dockerBase: 'docker',
containerName: 'box',
socketPath: '/tmp/tmux-0/default',
viewSession: 'codeman-view-abc12345',
})
).toContain("kill-session -t '=codeman-view-abc12345'");
});
describe('buildAdoptSessionCommand', () => {
const adopt: SessionAdopt = {
location: 'local',
socketPath: '/tmp/tmux-1000/default',
targetSession: 'work',
viewSession: '',
};
it('attaches in place for a local target, and names the view after our session', () => {
const cmd = buildAdoptSessionCommand(adopt, 'abc12345-rest');
expect(cmd).toContain("has-session -t '=work'");
expect(cmd).toContain("-s 'codeman-view-abc12345'");
expect(cmd).not.toMatch(/\b(ssh|docker)\b/);
});
it('goes through the saved engine for a container, shell-quoted for the launch string', () => {
const cmd = buildAdoptSessionCommand(
{
...adopt,
location: 'docker',
docker: { hostId: 'd1', label: 'ci', engine: 'podman', containerName: 'box', context: 'my ctx' },
},
'abc12345'
);
expect(cmd).toContain("podman --context 'my ctx' exec -it ");
expect(cmd).toContain("'box' sh -lc");
});
it('goes through the saved connection for an ssh host', () => {
const cmd = buildAdoptSessionCommand(
{
...adopt,
location: 'remote',
remote: { hostId: 'h1', label: 'b', host: 'h.example', username: 'me', port: 2222 },
},
'abc12345'
);
expect(cmd.startsWith('ssh -o BatchMode=yes -t ')).toBe(true);
expect(cmd).toContain('me@h.example');
expect(cmd).toContain('2222');
});
});
describe('the ssh attach survives every shell on its way', () => {
// bash -c '<line>' at respawn-pane, the single quotes around the remote command,
// the remote login shell, then sh -c. Stand-ins for ssh and tmux at the two ends
// show what a real tmux would receive, which no substring check can.
let home: string;
let bin: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'foreign-ssh-'));
// $HOME/bin, because the remote PATH line puts it ahead of a real tmux.
bin = join(home, 'bin');
mkdirSync(bin);
// The real thing hands its LAST argument to the remote login shell.
writeFileSync(join(bin, 'ssh'), '#!/bin/sh\nfor last; do :; done\nexec sh -c "$last"\n', { mode: 0o755 });
writeFileSync(join(bin, 'tmux'), '#!/bin/sh\nfor a; do printf "<%s>" "$a"; done\nprintf "\\n"\n', {
mode: 0o755,
});
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
it('delivers a spaced name as one argument to each tmux command', () => {
const launch = buildForeignRemoteAttachCommand({
socketPath: '/tmp/tmux-1000/my sock',
targetSession: 'my work',
viewSession: 'codeman-view-abc12345',
sshArgs: ['ssh', '-o BatchMode=yes'],
sshTarget: 'me@host',
});
const out = execSync(`bash -c ${bashCArg(launch, true)}`, {
encoding: 'utf-8',
env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
});
expect(out).toContain('<-S></tmp/tmux-1000/my sock><has-session><-t><=my work>');
expect(out).toContain('<new-session><-t><my work><-s><codeman-view-abc12345><;><set-option>');
});
it('leaves the PATH line for the far end to expand', () => {
// In double quotes on execSync's `sh -c`, single quotes protect nothing, so `$HOME`
// and `$PATH` used to become THIS host's values and a tmux in the remote
// `~/.local/bin` was found by discovery and missed here.
writeFileSync(join(bin, 'ssh'), '#!/bin/sh\nfor last; do :; done\nprintf "%s\\n" "$last"\n', { mode: 0o755 });
const launch = buildForeignRemoteAttachCommand({
socketPath: '/tmp/tmux-1000/default',
targetSession: 'work',
viewSession: 'codeman-view-abc12345',
sshArgs: ['ssh', '-o BatchMode=yes'],
sshTarget: 'me@host',
});
const out = execSync(`bash -c ${bashCArg(launch, true)}`, {
encoding: 'utf-8',
env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
});
expect(out).toContain('$HOME/.local/bin');
expect(out).toContain(':$PATH"');
expect(out).not.toContain(home);
});
});
describe('the attach line as the argument of bash -c', () => {
const echoed = (text: string) => execSync(`printf '%s' ${bashCArg(text, true)}`, { encoding: 'utf-8' });
it('hands the text through one sh parse untouched', () => {
const text = 'echo $HOME `id` "q" \\ and \\$x';
expect(echoed(text)).toBe(text);
});
it('keeps a single quote and a real newline', () => {
// Double quotes with JSON escaping left a newline as the two characters `\n`.
const text = "it's\n'\\''$PATH";
expect(echoed(text)).toBe(text);
});
it('is what both launch sites use', () => {
// Create and respawn build the same string. Fixing only one would leave a wrapper
// whose first attach works and whose re-attach after a restart does not. What each
// one hands bash is measured in tmux-manager.test.ts.
const src = readFileSync(join(import.meta.dirname, '../src/tmux-manager.ts'), 'utf-8');
expect(src.match(/bashCArg\(launchCmd, !!\(remote \|\| docker \|\| adopt\)\)/g)).toHaveLength(2);
expect(src.match(/bash -c \$\{JSON\.stringify\(launchCmd\)\}/g)).toBeNull();
});
});
});
describe('a tmux server that stops answering', () => {
// A real server SIGSTOPped on a scratch socket cost 12 s, every row on the machine and
// a stuck client per scan. The stand-in tmux below hangs the same way for one socket.
// /tmp rather than tmpdir(): a unix socket path has to fit in about 100 bytes.
let root: string;
let bin: string;
let servers: Server[];
const listen = (path: string) =>
new Promise<Server>((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(path, () => resolve(server));
});
beforeEach(async () => {
root = mkdtempSync('/tmp/cmfp-');
bin = join(root, 'bin');
const sockets = join(root, `tmux-${process.getuid?.() ?? 0}`);
mkdirSync(bin);
mkdirSync(sockets);
servers = await Promise.all(['a', 'hung', 'z'].map((name) => listen(join(sockets, name))));
writeFileSync(
join(bin, 'tmux'),
[
'#!/bin/sh',
`echo "$*" >> ${root}/calls`,
'case "$*" in *"/hung "*) exec sleep 30 ;; esac',
'case "$*" in *has-session*) exit 0 ;; esac',
'for a; do case "$a" in */tmux-*) S=$a ;; esac; done',
`printf 'CMFP\\t%s\\t7\\t0\\t1\\t100\\t0\\t%%%%0\\tbash\\twork\\t/srv\\n' "$S"`,
].join('\n'),
{ mode: 0o755 }
);
});
afterEach(async () => {
await Promise.all(servers.map((s) => new Promise((resolve) => s.close(resolve))));
rmSync(root, { recursive: true, force: true });
});
const probe = (script: string) =>
runForeignProbe(['env', `TMUX_TMPDIR=${root}`, `PATH=${bin}:${process.env.PATH}`, 'sh', '-c', script]);
it('costs only its own rows, and the scan still finishes', async () => {
const startedAt = Date.now();
const run = await probe(buildForeignProbeScript());
expect(Date.now() - startedAt).toBeLessThan(6000);
expect(run.ok).toBe(true);
const out = parseForeignProbeOutput(run.stdout);
expect(out.panes.map((p) => p.socketPath.split('/').pop())).toEqual(['a', 'z']);
expect(out.hungSockets.map((s) => s.split('/').pop())).toEqual(['hung']);
// The script ran on past the hung socket to the marker that ends the sweep.
expect(run.stdout).toContain('CMFQ');
// ⚠️ The point of the ping: a server that missed it never receives our stdout.
const calls = readFileSync(join(root, 'calls'), 'utf-8');
expect(calls).toMatch(/\/hung has-session/);
expect(calls).not.toMatch(/\/hung list-panes/);
});
it('never lists, let alone starts, a server on a stale socket', async () => {
writeFileSync(
join(bin, 'tmux'),
`#!/bin/sh\necho "$*" >> ${root}/calls\ncase "$*" in *has-session*) exit 1 ;; esac\n`,
{ mode: 0o755 }
);
await probe(buildForeignProbeScript());
const calls = readFileSync(join(root, 'calls'), 'utf-8');
expect(calls).toMatch(/has-session/);
expect(calls).not.toMatch(/list-panes|new-session|start-server/);
});
it('says so in a note, and keeps the rows it did read', () => {
const stdout = [
'CMFS /tmp/tmux-0/default',
'CMFP\\t/tmp/tmux-0/default\\t900\\t0\\t1\\t1788092500\\t0\\t%1\\tbash\\tscratch\\t/home/me',
'CMFH /tmp/tmux-0/stopped',
'CMFH /tmp/tmux-0/codeman-other',
'CMFQ',
].join('\n');
const notes: string[] = [];
const rows = candidatesFrom(stdout, 'local', 'local', {}, 'local', notes);
expect(rows.map((r) => r.sessionName)).toEqual(['scratch']);
// Another Codeman's socket is not a session this list would ever show.
expect(notes).toEqual(['local: 1 tmux server(s) did not answer, sessions on them are not listed']);
// The adopt route reads a note with `skipped` in it as a refusal rather than as a
// location it could not reach, and this one is the second kind.
expect(notes[0]).not.toMatch(/skipped/);
});
});
describe.skipIf(spawnSync('tmux', ['-V']).status !== 0)('a real stopped tmux server', () => {
it('costs a scan the ping and nothing more, though it holds stdout past its client', async () => {
// The stand-in above cannot show this. Only a real server takes a dup of the client's
// stdout, and that is what kept a scan waiting after the client was dead.
const root = mkdtempSync('/tmp/cmfr-');
const sockets = join(root, `tmux-${process.getuid?.() ?? 0}`);
mkdirSync(sockets);
const stopped = join(sockets, 'stopped');
const env = { ...process.env, TMUX: '' };
const tmux = (...args: string[]) => spawnSync('tmux', ['-S', stopped, ...args], { encoding: 'utf-8', env });
let serverPid = 0;
try {
expect(tmux('-f', '/dev/null', 'new-session', '-d', '-s', 'held', 'sleep 60').status).toBe(0);
serverPid = Number(tmux('display-message', '-p', '#{pid}').stdout);
expect(serverPid).toBeGreaterThan(0);
process.kill(serverPid, 'SIGSTOP');
const startedAt = Date.now();
const run = await runForeignProbe(['env', `TMUX_TMPDIR=${root}`, 'sh', '-c', buildForeignProbeScript()]);
expect(Date.now() - startedAt).toBeLessThan(6000);
expect(run.ok).toBe(true);
const out = parseForeignProbeOutput(run.stdout);
expect(out.hungSockets).toEqual([stopped]);
expect(out.panes).toEqual([]);
} finally {
if (serverPid > 0) process.kill(serverPid, 'SIGCONT');
tmux('kill-server');
rmSync(root, { recursive: true, force: true });
}
}, 20000);
});
describe('runForeignProbe', () => {
const alive = (pid: number) =>
spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf-8' }).stdout.trim();
it('reaches the whole process group at the deadline and keeps what was read', async () => {
// A timeout that signals only `sh` left its tmux child stuck on the hung server, one
// more per scan, for as long as the home screen stayed open.
const run = await runForeignProbe(['sh', '-c', 'echo first; sleep 60 & echo $!; wait'], { timeoutMs: 400 });
expect(run.ok).toBe(false);
const [first, pid] = run.stdout.trim().split('\n');
expect(first).toBe('first');
await vi.waitFor(() => expect(alive(Number(pid))).toMatch(/^(Z.*)?$/), { timeout: 2000 });
});
it('answers at the deadline even when something outside the group holds the pipe', async () => {
// What a stopped tmux server does. `setsid` is the stand-in for it: out of the group,
// so the kill misses it, and it keeps the write end of stdout open.
if (spawnSync('sh', ['-c', 'command -v setsid']).status !== 0) return;
const startedAt = Date.now();
const run = await runForeignProbe(['sh', '-c', 'echo first; setsid sleep 3 & exit 0'], { timeoutMs: 300 });
expect(Date.now() - startedAt).toBeLessThan(2000);
expect(run).toEqual({ stdout: 'first\n', ok: false });
});
it('stops a probe that floods instead of holding it in memory', async () => {
const run = await runForeignProbe(['sh', '-c', 'while :; do echo aaaaaaaa; done'], {
maxBytes: 4096,
timeoutMs: 5000,
});
expect(run.ok).toBe(false);
expect(run.stdout.length).toBeLessThanOrEqual(4096);
});
it('keeps the output of a command that failed', async () => {
expect(await runForeignProbe(['sh', '-c', 'echo partial; exit 3'])).toEqual({ stdout: 'partial\n', ok: false });
});
it('answers rather than throws when the command cannot start', async () => {
expect(await runForeignProbe(['/nonexistent/probe'])).toEqual({ stdout: '', ok: false });
});
});
describe('foreignCliSignatures', () => {
it('covers every stock CLI that declares a binary — including the one the hand-written table missed', () => {
// The table this replaced listed eight CLI ids by hand and had already gone
// stale: `omp` shipped without a signature, so a hand-started omp pane was
// classified `shell`. Deriving from `discovery.binaries` makes that class of
// drift impossible rather than merely fixed once.
const covered = new Set(foreignCliSignatures().map((sig) => sig.mode));
const expected = STOCK_CLIS.filter((e) => e.discovery.binaries.length > 0).map((e) => e.id as string);
expect(expected).toContain('omp');
for (const id of expected) expect(covered).toContain(id);
});
it('leaves shell out — it declares no binary and is the FALLBACK answer', () => {
expect(foreignCliSignatures().some((sig) => sig.mode === 'shell')).toBe(false);
});
it('orders longest binary name first, so a two-letter name cannot win early', () => {
// `pi` matches `(^|/)pi(\s|$)`, which is the loosest rule in the table. If it
// were tried before `opencode`'s, nothing would break on these two names, but
// the ordering is the only thing standing between a future one-letter binary
// and every pane on the machine.
const names = foreignCliSignatures().map((sig) => sig.mode);
expect(names.indexOf('pi')).toBeGreaterThan(names.indexOf('opencode'));
});
it('classifies an omp pane as omp rather than shell', () => {
const empty = { byParent: new Map(), argvByPid: new Map() };
expect(classifyForeignPaneMode({ panePid: 1, paneCurrentCommand: 'omp' }, empty).mode).toBe('omp');
});
it('still finds claude under its own dot-directory node launcher', () => {
// The derived "node launcher" shape has to reproduce what claude's hand-written
// `[/\\]\.?claude[/\\][^\s]*cli\.js` rule covered, or every claude installed
// the normal way (a `node ~/.claude/local/.../cli.js` argv) silently reads as shell.
const argv = 'node /home/u/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js';
const probe = { byParent: new Map([[7, [8]]]), argvByPid: new Map([[8, argv]]) };
expect(classifyForeignPaneMode({ panePid: 7, paneCurrentCommand: 'node' }, probe).mode).toBe('claude');
});
it('does not let a binary name match inside a longer command', () => {
const empty = { byParent: new Map(), argvByPid: new Map() };
expect(classifyForeignPaneMode({ panePid: 1, paneCurrentCommand: '/usr/bin/pip install x' }, empty).mode).toBe(
'shell'
);
});
});
describe('classifyForeignPaneMode truncation reporting', () => {
it('reports a truncated descendant walk instead of silently answering shell', () => {
// A capped walk returns "nothing matched", which is byte-identical to the
// answer for a real shell. Without the warning there is nothing anywhere to
// tell a wide process tree apart from a plain bash pane.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const byParent = new Map<number, number[]>();
byParent.set(
1,
Array.from({ length: PROC_WALK_MAX_NODES + 10 }, (_, i) => i + 100)
);
const result = classifyForeignPaneMode(
{ panePid: 1, paneCurrentCommand: 'bash' },
{ byParent, argvByPid: new Map() }
);
expect(result.mode).toBe('shell');
expect(warn).toHaveBeenCalled();
expect(String(warn.mock.calls[0]?.[0])).toMatch(/nodes cap|-nodes/);
} finally {
warn.mockRestore();
}
});
});
describe('hooksAvailableForMode for an adopted session', () => {
it('refuses claude when the process was adopted', () => {
// Adoption asks who STARTED the process. Codeman never installed its hooks
// block in that workspace, so answering by mode would dress an infinite wait
// up as a timeout.
expect(hooksAvailableForMode('claude')).toBe(true);
expect(hooksAvailableForMode('claude', { adopted: true })).toBe(false);
});
it('derives `adopted` from the session, so every call site inherits it', () => {
expect(sessionHookOptions({ adopt: { targetSession: 'work' } }).adopted).toBe(true);
expect(sessionHookOptions({}).adopted).toBe(false);
});
});
@@ -36,6 +36,8 @@ const SCANNED_FILES = [
// The command palette's new-session agent picker reads its agents from the
// injected catalog; panels-ui.js carries no id branch, so keep it that way.
'panels-ui.js',
// A row's mode comes from the same catalog, never from a list of ids here.
'foreign-sessions.js',
];
/**
+2
View File
@@ -263,6 +263,8 @@ describe('who runs', () => {
const h = harness([
VIEW('ssh', { remote: { host: 'x' } }),
VIEW('docker', { docker: { container: 'c' } }),
// A wrapper around a foreign tmux session sits on the neutral `/tmp`, no repository of its own.
VIEW('adopted', { adopt: { location: 'local' } }),
VIEW('local'),
]);
await h.scheduler.tick(0);
+4
View File
@@ -157,6 +157,10 @@ export function createMockRouteContext(options?: {
createSession: vi.fn(),
killSession: vi.fn(),
listSessions: vi.fn(() => []),
// A DIFFERENT method from listSessions, and the adoption routes call this
// one. Missing it is not a type error — the handler throws and the route
// answers 500, which reads as a broken endpoint rather than a broken mock.
getSessions: vi.fn(() => []),
getStats: vi.fn(() => ({})),
updateSessionName: vi.fn(() => true),
getSession: vi.fn(() => null),
+22
View File
@@ -11,6 +11,7 @@ import type {
SessionStatus,
SessionRemote,
} from '../../src/types.js';
import type { SessionAdopt } from '../../src/types/session.js';
import type { GitFetchStatus } from '../../src/git-fetch.js';
/**
@@ -26,6 +27,15 @@ export class MockSession extends EventEmitter {
* over ssh instead of with local `fs` (#415).
*/
remote?: SessionRemote;
/**
* Mirrors `Session.isAdopted` — true when this session wraps a process SOMEONE
* ELSE started (a hand-opened tmux session Codeman attached to). A plain field
* rather than a getter over `adopt`, because the routes only ever read the
* boolean and the tests only ever need to set it.
*/
isAdopted: boolean = false;
/** Mirrors `Session.adopt`, the record the one-wrapper-per-target check keys on. */
adopt?: SessionAdopt;
/** Mirrors Session.attachmentHistory (the attachment panel's source of truth). */
attachmentHistory: SessionAttachmentHistoryItem[] = [];
/** Mirrors Session.getAttachmentHistoryForPersist(). */
@@ -402,6 +412,18 @@ export class MockSession extends EventEmitter {
}
/** Auto-resume on usage limit (token pause control) */
autoClearEnabled: boolean = false;
autoClearThreshold: number = 0;
setAutoClear = vi.fn((enabled: boolean, threshold?: number) => {
this.autoClearEnabled = enabled;
if (typeof threshold === 'number') this.autoClearThreshold = threshold;
});
autoCompactEnabled: boolean = false;
autoCompactThreshold: number = 0;
setAutoCompact = vi.fn((enabled: boolean, threshold?: number) => {
this.autoCompactEnabled = enabled;
if (typeof threshold === 'number') this.autoCompactThreshold = threshold;
});
autoResumeEnabled: boolean = false;
autoResumeAt: number | null = null;
isLimitPaused: boolean = false;
+10
View File
@@ -30,6 +30,16 @@ describe('sweepPasteImagesOnce', () => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it('lists nothing for an adopted session, whose folder is the /tmp others may work in', async () => {
// The delete path removes what this lists, recursively: closing an adopted tab
// must not take the uploads of a real session sitting in that same folder.
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
dirs.push(workingDir);
mkdirSync(join(workingDir, UPLOADS_DIR));
expect(await uploadDirs({ workingDir })).toHaveLength(1);
expect(await uploadDirs({ workingDir, adopt: { location: 'local' } })).toEqual([]);
});
it('ages paste-* files out of both the current and the legacy upload dir', async () => {
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
dirs.push(workingDir);
+10
View File
@@ -151,6 +151,16 @@ describe('which dead sessions may be rebuilt', () => {
expect(planRebootRestore(['d'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
});
it('refuses an adopted session: Codeman never started the process it wrapped', () => {
const persisted = {
a: persistedSession({
id: 'a',
adopt: { location: 'local', socketPath: '/tmp/tmux-1000/default', targetSession: 'work', viewSession: 'v' },
} as Partial<SessionState> & { id: string }),
};
expect(planRebootRestore(['a'], persisted, () => true).skipped[0].reason).toBe('adopted');
});
it('skips a CLI whose history the claude transcript reader does not understand', () => {
const persisted = { c: persistedSession({ id: 'c', mode: 'codex' }) };
expect(planRebootRestore(['c'], persisted, () => true).skipped[0].reason).toBe('unsupported-mode');
+529
View File
@@ -0,0 +1,529 @@
/**
* @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 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();
});
});
});
+28
View File
@@ -184,6 +184,34 @@ describe('closing the active session', () => {
});
});
describe('closing an adopted tab', () => {
it('re-reads the outside-sessions list once the wrapper is gone', async () => {
// The home screen loads that list as soon as the tab goes, which is BEFORE the DELETE
// lands, so the row kept saying "Go to tab" with no tab behind it until the next poll.
const app = makeApp(A, [A]);
app.sessions.set(A, { id: A, adopt: { targetSession: 'work' } });
const order: string[] = [];
app._apiDelete = vi.fn(async () => {
order.push('delete');
return { ok: true, status: 200 };
});
app.loadForeignSessions = vi.fn(async () => void order.push('reload'));
await app.closeSession(A);
expect(order).toEqual(['delete', 'reload']);
});
it('leaves the list alone for a session of our own', async () => {
const app = makeApp(A, [A]);
app.loadForeignSessions = vi.fn();
await app.closeSession(A);
expect(app.loadForeignSessions).not.toHaveBeenCalled();
});
});
describe('closing is optimistic', () => {
it('drops the tab and selects the next one BEFORE the server answers', async () => {
const app = makeApp(A);
+10 -4
View File
@@ -94,9 +94,11 @@ describe('Session Options modal structure', () => {
expect(modal.match(/<button[^>]*data-tab="git"[^>]*>/)?.[0]).not.toContain('data-claude-only');
const open = methodBody('openSessionOptions(sessionId)');
expect(open).toContain('.set-rail-item[data-tab="git"]');
// Remote and Docker sessions answer 'unsupported' on both git routes and the scheduler never fetches them,
// so the entry goes the same way the Claude-only ones do.
expect(open).toMatch(/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \? 'none' : ''/);
// Remote, Docker and adopted sessions answer 'unsupported' on both git routes and the scheduler never fetches
// them, so the entry goes the same way the Claude-only ones do.
expect(open).toMatch(
/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \|\| session\.adopt \? 'none' : ''/
);
});
it('makes the Git rows inert while a session follows the app, the way the keep-warm rows go inert', () => {
@@ -150,6 +152,10 @@ describe('Session Options modal structure', () => {
const open = methodBody('openSessionOptions(sessionId)');
expect(open).toContain("getElementById('modalSessionPath')");
expect(open).toContain('session.workingDir');
expect(methodBody('async copySessionPath()')).toContain('_copyText(session.workingDir)');
// An adopted session's workingDir is the neutral wrapper folder, so the row shows where the pane was.
expect(open).toContain('session.adopt?.paneCurrentPath || session.workingDir');
const copy = methodBody('async copySessionPath()');
expect(copy).toContain('session?.adopt?.paneCurrentPath || session?.workingDir');
expect(copy).toContain('_copyText(path)');
});
});
+75
View File
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
TmuxManager,
buildAdoptSessionCommand,
buildCodexCommand,
buildRemoteKillCommand,
buildRemoteLaunchCommand,
@@ -22,6 +23,7 @@ import {
} 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)
@@ -971,6 +973,79 @@ describe('TmuxManager (unit)', () => {
}
});
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();