mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
commit
c65706a3e4
64 files changed
+5321
-103
No files matched your search
@@ -3,6 +3,7 @@ import { expect, it, vi } from 'vitest';
|
||||
import { formatPaneSnapshot } from '../src/tmux-manager.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import { promptSha256 } from '../src/agent-staging.js';
|
||||
import type { SessionAdopt } from '../src/types/session.js';
|
||||
it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => {
|
||||
let screen = '› \n GPT-6-Luna default · ~/case\n';
|
||||
const mux = {
|
||||
@@ -542,3 +543,37 @@ it('stages through a real session whose first post-write capture is the torn fra
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('offers no guarded input on an adopted pane, however much it looks like a composer', () => {
|
||||
// Stage and submit type a prompt on an agent's behalf, here into a pane a human is using.
|
||||
const screen = '\u203a \n GPT-6-Luna default \u00b7 ~/case\n';
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
return formatPaneSnapshot(screen.trimEnd().split('\n'), { cols: 100, rows: 20, cursorX: 2, cursorY: 0 });
|
||||
},
|
||||
sendInput: async () => true,
|
||||
};
|
||||
const build = (adopt?: SessionAdopt) =>
|
||||
new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux,
|
||||
adopt,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
expect(build().guardedInputSnapshot()).not.toBeNull();
|
||||
expect(
|
||||
build({
|
||||
location: 'local',
|
||||
socketPath: '/tmp/tmux-1000/default',
|
||||
targetSession: 'work',
|
||||
viewSession: 'codeman-view-00000000',
|
||||
}).guardedInputSnapshot()
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @fileoverview The strings of the outside-sessions block in Chinese (zh-CN).
|
||||
*
|
||||
* Harvested from the real module rather than listed here, so a new string without a
|
||||
* dictionary entry fails this test instead of showing up English on one surface. The
|
||||
* block's row text carries data-i18n-skip (a session name and a command line are
|
||||
* someone else's), so its location words only translate through the module's own
|
||||
* foreignT() call, and the DOM translator would never reach them.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
|
||||
const MODULE = readFileSync(resolve(PUBLIC, 'foreign-sessions.js'), 'utf8');
|
||||
|
||||
type Api = { configure(o: Record<string, string>): void; t(source: string): string };
|
||||
|
||||
/** Everything the module sends through foreignT, plus the toggle's two literal labels. */
|
||||
const FROM_MODULE = [...MODULE.matchAll(/foreignT\('([^']+)'\)/g)].map((m) => m[1]);
|
||||
const LOCATION_WORDS = ['this machine', 'container', 'remote'];
|
||||
const TOGGLE = ['⟳ all', '⟳ local'];
|
||||
const ELSEWHERE = [
|
||||
'Close Tab',
|
||||
'Detaches only. The tmux session keeps running for whoever started it.',
|
||||
'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.',
|
||||
'Git status is not available for a tmux session opened outside Codeman.',
|
||||
];
|
||||
const ALL = [...FROM_MODULE, ...LOCATION_WORDS, ...TOGGLE, ...ELSEWHERE];
|
||||
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
api.configure({ language: 'zh-CN' });
|
||||
|
||||
afterAll(() => {
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
describe('the outside-sessions block in zh-CN', () => {
|
||||
it('harvests the module (the check is not vacuous)', () => {
|
||||
expect(FROM_MODULE).toContain('Opened outside Codeman');
|
||||
expect(FROM_MODULE).toContain('Go to tab');
|
||||
expect(FROM_MODULE.length).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('wraps every location word, whose row the DOM translator skips', () => {
|
||||
expect(MODULE).toContain('foreignT(FOREIGN_LOCATION_LABEL[row.location] || row.location)');
|
||||
for (const word of LOCATION_WORDS) expect(MODULE).toContain(`: '${word}'`);
|
||||
});
|
||||
|
||||
it.each(ALL)('translates %s', (source) => {
|
||||
const chinese = api.t(source);
|
||||
expect(chinese).not.toBe(source);
|
||||
expect(chinese.replace(/\b(Codeman|tmux|Git)\b/g, '').match(/[A-Za-z]+/g) ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
// Prettier leaves a key that is one word unquoted.
|
||||
for (const source of new Set(ALL)) {
|
||||
const bare = /^\w+$/.test(source) ? I18N.split(`\n ${source}: `).length - 1 : 0;
|
||||
expect(I18N.split(`'${source}':`).length - 1 + bare, source).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -157,6 +157,10 @@ export function createMockRouteContext(options?: {
|
||||
createSession: vi.fn(),
|
||||
killSession: vi.fn(),
|
||||
listSessions: vi.fn(() => []),
|
||||
// A DIFFERENT method from listSessions, and the adoption routes call this
|
||||
// one. Missing it is not a type error — the handler throws and the route
|
||||
// answers 500, which reads as a broken endpoint rather than a broken mock.
|
||||
getSessions: vi.fn(() => []),
|
||||
getStats: vi.fn(() => ({})),
|
||||
updateSessionName: vi.fn(() => true),
|
||||
getSession: vi.fn(() => null),
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SessionStatus,
|
||||
SessionRemote,
|
||||
} from '../../src/types.js';
|
||||
import type { SessionAdopt } from '../../src/types/session.js';
|
||||
import type { GitFetchStatus } from '../../src/git-fetch.js';
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,15 @@ export class MockSession extends EventEmitter {
|
||||
* over ssh instead of with local `fs` (#415).
|
||||
*/
|
||||
remote?: SessionRemote;
|
||||
/**
|
||||
* Mirrors `Session.isAdopted` — true when this session wraps a process SOMEONE
|
||||
* ELSE started (a hand-opened tmux session Codeman attached to). A plain field
|
||||
* rather than a getter over `adopt`, because the routes only ever read the
|
||||
* boolean and the tests only ever need to set it.
|
||||
*/
|
||||
isAdopted: boolean = false;
|
||||
/** Mirrors `Session.adopt`, the record the one-wrapper-per-target check keys on. */
|
||||
adopt?: SessionAdopt;
|
||||
/** Mirrors Session.attachmentHistory (the attachment panel's source of truth). */
|
||||
attachmentHistory: SessionAttachmentHistoryItem[] = [];
|
||||
/** Mirrors Session.getAttachmentHistoryForPersist(). */
|
||||
@@ -402,6 +412,18 @@ export class MockSession extends EventEmitter {
|
||||
}
|
||||
|
||||
/** Auto-resume on usage limit (token pause control) */
|
||||
autoClearEnabled: boolean = false;
|
||||
autoClearThreshold: number = 0;
|
||||
setAutoClear = vi.fn((enabled: boolean, threshold?: number) => {
|
||||
this.autoClearEnabled = enabled;
|
||||
if (typeof threshold === 'number') this.autoClearThreshold = threshold;
|
||||
});
|
||||
autoCompactEnabled: boolean = false;
|
||||
autoCompactThreshold: number = 0;
|
||||
setAutoCompact = vi.fn((enabled: boolean, threshold?: number) => {
|
||||
this.autoCompactEnabled = enabled;
|
||||
if (typeof threshold === 'number') this.autoCompactThreshold = threshold;
|
||||
});
|
||||
autoResumeEnabled: boolean = false;
|
||||
autoResumeAt: number | null = null;
|
||||
isLimitPaused: boolean = false;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -151,6 +151,16 @@ describe('which dead sessions may be rebuilt', () => {
|
||||
expect(planRebootRestore(['d'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
|
||||
});
|
||||
|
||||
it('refuses an adopted session: Codeman never started the process it wrapped', () => {
|
||||
const persisted = {
|
||||
a: persistedSession({
|
||||
id: 'a',
|
||||
adopt: { location: 'local', socketPath: '/tmp/tmux-1000/default', targetSession: 'work', viewSession: 'v' },
|
||||
} as Partial<SessionState> & { id: string }),
|
||||
};
|
||||
expect(planRebootRestore(['a'], persisted, () => true).skipped[0].reason).toBe('adopted');
|
||||
});
|
||||
|
||||
it('skips a CLI whose history the claude transcript reader does not understand', () => {
|
||||
const persisted = { c: persistedSession({ id: 'c', mode: 'codex' }) };
|
||||
expect(planRebootRestore(['c'], persisted, () => true).skipped[0].reason).toBe('unsupported-mode');
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user