Files
openchamber/packages/web/server/lib/terminal/runtime.test.js
T
𝖎𝖚𝖑𝖎𝖎𝖆 759af5a77d fix(sessions): recover sessions whose directory disappeared (#3365)
* fix(sessions): keep a shared chat directory until its last session is deleted

Deleting a root chat session removed its managed scratch directory even
when forks, side threads, or subagents still lived in it; OpenCode then
failed every prompt in those sessions with FileSystem.realPath NotFound.
The directory is now removed only once no other known session resolves
to it. The deleted subtree does not count, because the server cascade-
deletes it, and an unloaded global cache keeps the directory instead of
guessing.

Closes #3312.

* fix(sessions): relocate a session whose worktree directory disappeared

A worktree removed outside OpenChamber, by the agent or by hand, left its
sessions pointed at a path that no longer exists: every terminal create
and restart failed with "Invalid working directory" and the tab stayed
stuck, while Git, Files, and prompts kept targeting the dead path.

The terminal server now names that one rejection (TERMINAL_CWD_MISSING)
instead of substituting a directory of its own. The shared UI reuses the
archived-restore fallback for live sessions: a server-confirmed missing
directory moves the session and its stranded subtree to the project's
primary directory through the control-plane move, clears the worktree
hint, re-selects the session, and tells the user where it went. It runs
from a terminal failure and on activation of any session whose directory
is neither a project root nor a managed chat directory; available,
unknown, and failed probes leave everything untouched.

Closes #3338.

* fix(scripts): make oc-dev load again after the changelog cleanup

The changelog cleanup referenced fs.existsSync in a module that imports
existsSync by name and never binds fs, so every oc-dev invocation failed
with "fs is not defined" before reaching its action.

* fix(sessions): probe directory availability on disk, not through OpenCode path resolution

OpenCode's /path never checks that a directory exists: it echoes the
requested path and resolves its project through Git discovery that
swallows errors, so a deleted worktree came back as a valid location and
every missing-directory fallback (draft recovery, archived restore,
session relocation) stayed inert on a real server. The probe now asks
OpenChamber's own /api/fs/list, which stats the path and reports
not-found and not-directory explicitly; anything else stays unknown.

* fix(sidebar): keep a worktree whose directory is gone visible as missing

git keeps a worktree registered after its directory is deleted outside
git and marks it prunable; the list parser ignored that line, so a
deleted worktree looked alive, and nothing in the app asked for a new
listing anyway. The server now reports prunable, the UI keeps such a
worktree in the topology with worktreeStatus missing and a warning icon
on its sidebar group, and relocating a session out of a confirmed-
missing directory raises an in-app topology signal the sidebar
rediscovers on. Dropping the worktree instead would hide every session
that lived there, and a hidden session can never be opened or relocated.
No idle polling is added.

* fix(sessions): never relocate a session to the filesystem root

OpenCode files a directory outside any Git repository under its global
project, whose worktree is the filesystem root. A managed chat whose
directory vanished would otherwise be moved to /. The relocation now
refuses a root destination, and the activation probe recognizes chat
directories through the home-based check as well, so it does not depend
on the chats root having been resolved yet.

* test(sessions): mirror the relocation action in the issue-2039 session-actions mock

session-ui-store now imports relocateSessionFromMissingDirectory, and the
mocked module in this test listed every other action but not that one, so
the file failed on import.
2026-09-05 21:26:21 +03:00

1120 lines
48 KiB
JavaScript

import { EventEmitter } from 'node:events';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { WebSocket } from 'ws';
import { createTerminalRuntime } from './runtime.js';
import { createTerminalWsControlFrame, readTerminalWsControlFrame } from './terminal-ws-protocol.js';
function createResponse() {
return {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(payload) {
this.body = payload;
return this;
},
};
}
async function openTerminalSocket(socketUrl) {
const socket = new WebSocket(socketUrl);
const messages = [];
socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
await new Promise((resolve, reject) => {
socket.once('open', resolve);
socket.once('error', reject);
});
const next = async (type, sessionId) => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
if (index >= 0) return messages.splice(index, 1)[0];
await new Promise((resolve) => setTimeout(resolve, 2));
}
throw new Error(`Timed out waiting for ${type}`);
};
await next('hello');
return { socket, next, messages };
}
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createHttpTestApp() {
const routes = { GET: [], POST: [], DELETE: [] };
const app = (req, res) => {
const methodRoutes = routes[req.method] ?? [];
const url = new URL(req.url, 'http://127.0.0.1');
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
const bodyText = Buffer.concat(chunks).toString('utf8');
const route = methodRoutes.find(({ pattern }) => pattern.test(url.pathname));
if (!route) {
res.statusCode = 404;
res.end('Not found');
return;
}
const match = route.pattern.exec(url.pathname);
const response = {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(payload) {
this.body = payload;
return this;
},
};
try {
await route.handler({
method: req.method,
url: req.url,
query: Object.fromEntries(url.searchParams.entries()),
params: route.params.reduce((acc, name, index) => ({ ...acc, [name]: match[index + 1] }), {}),
body: bodyText ? JSON.parse(bodyText) : {},
}, response);
} catch (error) {
response.status(500).json({ error: error?.message || 'Route failed' });
}
res.writeHead(response.statusCode, response.headers);
res.end(JSON.stringify(response.body));
});
};
const register = (method, route, handler) => {
const params = [];
const escaped = route.replace(/:([^/]+)/g, (_, name) => {
params.push(name);
return '([^/]+)';
});
routes[method].push({
pattern: new RegExp(`^${escaped}$`),
params,
handler,
});
};
app.get = (route, handler) => register('GET', route, handler);
app.post = (route, handler) => register('POST', route, handler);
app.delete = (route, handler) => register('DELETE', route, handler);
return app;
}
function createRuntime(server, overrides = {}) {
const app = overrides.app ?? {
post() {},
get() {},
delete() {},
};
return createTerminalRuntime({
app,
server,
express: { text: () => (_req, _res, next) => next?.() },
fs,
path,
uiAuthController: null,
buildAugmentedPath: () => process.env.PATH || '',
searchPathFor: () => null,
isExecutable: () => false,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {},
TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: 30_000,
TERMINAL_INPUT_WS_REBIND_WINDOW_MS: 1_000,
TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: 3,
...overrides,
});
}
describe('terminal runtime', () => {
const createHarness = (overrides = {}) => {
const routes = { get: new Map(), post: new Map(), delete: new Map() };
const processes = [];
const spawnDeferred = overrides.spawnDeferred ?? null;
const app = {
post(route, handler) { routes.post.set(route, handler); },
get(route, handler) { routes.get.set(route, handler); },
delete(route, handler) { routes.delete.set(route, handler); },
};
const loadPtyProvider = async () => ({
backend: 'fake-pty',
spawn: async (shell, args, options) => {
await spawnDeferred?.promise;
const dataHandlers = new Set();
const exitHandlers = new Set();
const process = {
pid: 123 + processes.length,
shell,
args,
options,
writes: [],
resizes: [],
killed: false,
kills: [],
write(data) { this.writes.push(data); },
resize(cols, rows) { this.resizes.push([cols, rows]); },
kill(signal) { this.killed = true; this.kills.push(signal ?? 'SIGTERM'); },
onData(handler) { dataHandlers.add(handler); return { dispose: () => dataHandlers.delete(handler) }; },
onExit(handler) { exitHandlers.add(handler); return { dispose: () => exitHandlers.delete(handler) }; },
emitData(data) { for (const handler of dataHandlers) handler(data); },
emitExit(exitCode = 0, signal = 0) { for (const handler of exitHandlers) handler({ exitCode, signal }); },
};
processes.push(process);
return process;
},
});
const server = new EventEmitter();
const runtime = createRuntime(server, {
app,
loadPtyProvider,
terminalTerminationGraceMs: 10,
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
searchPathFor: () => '/bin/sh',
isExecutable: () => true,
...overrides,
});
return { routes, processes, runtime };
};
it('replaces only exited action runs and keeps session capacity available across reruns', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
await create({ body: { sessionId: 'interactive', cwd: '/repo' } }, createResponse());
for (let index = 0; index < 25; index += 1) {
const id = `execution-${index}`;
const response = createResponse();
await create({ body: { sessionId: id, cwd: '/repo', mode: 'command', command: 'echo hello', purpose: { type: 'project-action', actionId: 'build', executionId: id } } }, response);
expect(response.statusCode).toBe(200);
const listed = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, listed);
expect(listed.body.sessions.map(session => session.sessionId)).toEqual(['interactive', id]);
expect(harness.processes[0].killed).toBe(false);
harness.processes.at(-1).emitExit(0);
}
} finally { await harness.runtime.shutdown(); }
});
it('retains completed output when the replacement command fails to start', async () => {
let available = true;
const harness = createHarness({ fs: { promises: { stat: async () => ({ isDirectory: () => available }) } } });
try {
const create = harness.routes.post.get('/api/terminal/create');
const options = { cwd: '/repo', mode: 'command', command: 'echo hello', purpose: { type: 'project-action', actionId: 'build', executionId: 'old' } };
await create({ body: { ...options, sessionId: 'old' } }, createResponse());
harness.processes[0].emitData('old output');
harness.processes[0].emitExit(0);
available = false;
const response = createResponse();
await create({ body: { ...options, sessionId: 'new', purpose: { ...options.purpose, executionId: 'new' } } }, response);
expect(response.statusCode).toBe(400);
const listed = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, listed);
expect(listed.body.sessions.map(session => session.sessionId)).toEqual(['old']);
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('rejects regular files as terminal working directories', async () => {
const postRoutes = new Map();
const app = {
post(route, ...handlers) {
postRoutes.set(route, handlers.at(-1));
},
get() {},
delete() {},
};
const server = new EventEmitter();
const runtime = createRuntime(server, {
app,
fs: {
promises: {
stat: async () => ({ isDirectory: () => false }),
},
},
uiAuthController: { enabled: false },
buildAugmentedPath: () => '',
TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: 1000,
TERMINAL_INPUT_WS_REBIND_WINDOW_MS: 1000,
});
try {
const createRoute = postRoutes.get('/api/terminal/create');
const res = createResponse();
await createRoute({ body: { cwd: '/tmp/not-a-directory' } }, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: 'Invalid working directory' });
} finally {
await runtime.shutdown();
}
});
it('names a missing working directory so the client can recover the session', async () => {
let cwdMissing = false;
const harness = createHarness({
fs: {
promises: {
stat: async () => {
if (cwdMissing) throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
return { isDirectory: () => true };
},
},
},
});
try {
const create = harness.routes.post.get('/api/terminal/create');
const created = createResponse();
await create({ body: { sessionId: 'worktree-terminal', cwd: '/repo/.worktrees/feature' } }, created);
expect(created.statusCode).toBe(200);
cwdMissing = true;
const recreated = createResponse();
await create({ body: { sessionId: 'worktree-terminal-2', cwd: '/repo/.worktrees/feature' } }, recreated);
expect(recreated.statusCode).toBe(400);
expect(recreated.body).toEqual({ error: 'Invalid working directory', code: 'TERMINAL_CWD_MISSING' });
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')(
{ params: { sessionId: 'worktree-terminal' }, body: { cwd: '/repo/.worktrees/feature' } },
restarted,
);
expect(restarted.statusCode).toBe(400);
expect(restarted.body).toEqual({ error: 'Invalid working directory', code: 'TERMINAL_CWD_MISSING' });
} finally { await harness.runtime.shutdown(); }
});
it('removes its websocket upgrade listener on shutdown', async () => {
const server = new EventEmitter();
const runtime = createRuntime(server);
expect(server.listenerCount('upgrade')).toBe(1);
await runtime.shutdown();
expect(server.listenerCount('upgrade')).toBe(0);
});
it('creates client-identified sessions and forwards bounded resize operations', async () => {
const harness = createHarness();
try {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo', cols: 120, rows: 40, themeMode: 'light', terminalBackground: '#faf8f0', terminalForeground: '#1b1b1b' } }, response);
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running', mode: 'interactive', purpose: { type: 'terminal' } });
expect(harness.processes[0].options.cwd).toBe('/repo');
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
expect(harness.processes[0].options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE');
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args.slice(0, 3)).toEqual(['-u', 'ARGV0', expect.any(String)]);
}
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']);
const appearance = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance);
expect(appearance.body).toEqual({ success: true });
expect(harness.processes[0].writes.at(-1)).toBe('\u001b[?997;1n');
const resize = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/resize')({ params: { sessionId: 'term-1' }, body: { cols: 200, rows: 60 } }, resize);
expect(resize.statusCode).toBe(200);
expect(harness.processes[0].resizes).toEqual([[200, 60]]);
const invalid = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/resize')({ params: { sessionId: 'term-1' }, body: { cols: 1001, rows: 60 } }, invalid);
expect(invalid.statusCode).toBe(400);
} finally { await harness.runtime.shutdown(); }
});
it('lists sessions scoped to a working directory and refreshes activity via touch', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-a', cwd: '/repo' } }, createResponse());
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-b', cwd: '/other' } }, createResponse());
const all = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: {} }, all);
expect(all.body.sessions.map((s) => s.sessionId).sort()).toEqual(['term-a', 'term-b']);
const scoped = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped);
expect(scoped.body.sessions).toEqual([
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number), mode: 'interactive', purpose: { type: 'terminal' } },
]);
const touch = createResponse();
harness.routes.post.get('/api/terminal/touch')({ body: { sessionIds: ['term-a', 'missing', 42] } }, touch);
expect(touch.body).toEqual({ touched: 1 });
const malformed = createResponse();
harness.routes.post.get('/api/terminal/touch')({ body: {} }, malformed);
expect(malformed.body).toEqual({ touched: 0 });
} finally { await harness.runtime.shutdown(); }
});
it('strips AppImage ARGV0 from PTY child environments', async () => {
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
const harness = createHarness();
try {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-argv0', cwd: '/repo', cols: 80, rows: 24 } }, response);
expect(response.statusCode).toBe(200);
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args[0]).toBe('-u');
expect(harness.processes[0].args[1]).toBe('ARGV0');
}
} finally {
if (previousArgv0 === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previousArgv0;
await harness.runtime.shutdown();
}
});
it('lists available shells and uses the selected shell for create and restart', async () => {
const executables = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']);
const harness = createHarness({
fs: {
promises: {
stat: async () => ({ isDirectory: () => true }),
readFile: async () => '/bin/zsh\n/bin/bash\n/bin/false\n',
},
},
searchPathFor: (name) => executables.has(`/bin/${name}`) ? `/bin/${name}` : null,
isExecutable: (candidate) => executables.has(candidate),
});
try {
const listed = createResponse();
await harness.routes.get.get('/api/terminal/shells')({}, listed);
expect(listed.body).toEqual(expect.arrayContaining([
{ id: 'auto', name: 'Auto', supportsLogin: true },
{ id: 'zsh', name: 'zsh', supportsLogin: true },
{ id: 'bash', name: 'bash', supportsLogin: true },
{ id: 'sh', name: 'sh', supportsLogin: false },
]));
const created = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created);
expect(created.statusCode).toBe(200);
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/zsh', '-l']);
} else {
expect(harness.processes[0].shell).toBe('/bin/zsh');
expect(harness.processes[0].args).toEqual(['-l']);
}
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted);
expect(restarted.statusCode).toBe(200);
if (process.platform === 'linux') {
expect(harness.processes[1].shell).toMatch(/\/env$/);
expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l']);
} else {
expect(harness.processes[1].shell).toBe('/bin/bash');
expect(harness.processes[1].args).toEqual(['-l']);
}
} finally { await harness.runtime.shutdown(); }
});
it('rejects invalid and unavailable explicit shells', async () => {
const harness = createHarness({
fs: {
promises: {
stat: async () => ({ isDirectory: () => true }),
readFile: async () => '/bin/sh\n',
},
},
searchPathFor: (name) => name === 'sh' ? '/bin/sh' : null,
isExecutable: (candidate) => candidate === '/bin/sh',
});
try {
for (const [shell, error] of [
['zsh -c whoami', 'Invalid terminal shell'],
['fish', 'Terminal shell "fish" is not available'],
]) {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { cwd: '/repo', shell } }, response);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error });
}
expect(harness.processes).toHaveLength(0);
} finally { await harness.runtime.shutdown(); }
});
it('rejects invalid and unsupported login modes', async () => {
const harness = createHarness({
fs: {
promises: {
stat: async () => ({ isDirectory: () => true }),
readFile: async () => '/bin/sh\n',
},
},
searchPathFor: (name) => name === 'sh' ? '/bin/sh' : null,
isExecutable: (candidate) => candidate === '/bin/sh',
});
try {
for (const [loginShell, error] of [
['true', 'Invalid terminal login mode'],
[true, 'Terminal shell "sh" does not support login mode'],
]) {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { cwd: '/repo', shell: 'sh', loginShell } }, response);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error });
}
expect(harness.processes).toHaveLength(0);
} finally { await harness.runtime.shutdown(); }
});
it('preserves the running process when a replacement shell is unavailable', async () => {
const harness = createHarness({
fs: {
promises: {
stat: async () => ({ isDirectory: () => true }),
readFile: async () => '/bin/sh\n',
},
},
searchPathFor: (name) => name === 'sh' ? '/bin/sh' : null,
isExecutable: (candidate) => candidate === '/bin/sh',
});
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo', shell: 'sh' } }, createResponse());
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-1' }, body: { shell: 'fish' } }, restarted);
expect(restarted.statusCode).toBe(400);
expect(restarted.body.error).toBe('Terminal shell "fish" is not available');
expect(harness.processes).toHaveLength(1);
expect(harness.processes[0].killed).toBe(false);
} finally { await harness.runtime.shutdown(); }
});
it('deduplicates concurrent creates and rejects cross-directory id reuse', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
const second = createResponse();
await Promise.all([
create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, first),
create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, second),
]);
expect(harness.processes).toHaveLength(1);
expect(first.body.sessionId).toBe('term-shared');
expect(second.body.sessionId).toBe('term-shared');
const conflicting = createResponse();
await create({ body: { sessionId: 'term-shared', cwd: '/other' } }, conflicting);
expect(conflicting.statusCode).toBe(400);
expect(conflicting.body.error).toBe('Terminal session belongs to a different working directory');
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('rejects concurrent creates with conflicting shell preferences', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
const conflicting = createResponse();
await Promise.all([
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'auto' } }, first),
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'zsh' } }, conflicting),
]);
expect(first.statusCode).toBe(200);
expect(conflicting.statusCode).toBe(400);
expect(conflicting.body.error).toBe('Terminal session is already being created with a different shell');
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('rejects concurrent creates with conflicting login modes', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
const conflicting = createResponse();
await Promise.all([
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'auto', loginShell: false } }, first),
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'auto', loginShell: true } }, conflicting),
]);
expect(first.statusCode).toBe(200);
expect(conflicting.statusCode).toBe(400);
expect(conflicting.body.error).toBe('Terminal session is already being created with a different login mode');
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('restarts atomically with the same identity and closes the previous process', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-1' }, body: { cwd: '/other', cols: 90, rows: 30 } }, restarted);
expect(restarted.body).toEqual({ sessionId: 'term-1', cols: 90, rows: 30, status: 'running' });
expect(harness.processes).toHaveLength(2);
expect(harness.processes[0].killed).toBe(true);
expect(harness.processes[1].options.cwd).toBe('/other');
} finally { await harness.runtime.shutdown(); }
});
it('serializes concurrent restarts without orphaning replacement processes', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const restart = harness.routes.post.get('/api/terminal/:sessionId/restart');
await create({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
const first = createResponse();
const second = createResponse();
await Promise.all([
restart({ params: { sessionId: 'term-1' }, body: { cwd: '/first' } }, first),
restart({ params: { sessionId: 'term-1' }, body: { cwd: '/second' } }, second),
]);
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
expect(harness.processes).toHaveLength(3);
expect(harness.processes[0].killed).toBe(true);
expect(harness.processes[1].killed).toBe(true);
expect(harness.processes[2].killed).toBe(false);
expect(harness.processes[2].options.cwd).toBe('/second');
} finally { await harness.runtime.shutdown(); }
});
it('retains exited sessions until explicit close', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
harness.processes[0].emitData('last output');
harness.processes[0].emitExit(7, 0);
const resize = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/resize')({ params: { sessionId: 'term-1' }, body: { cols: 80, rows: 24 } }, resize);
expect(resize.statusCode).toBe(200);
const closed = createResponse();
await harness.routes.delete.get('/api/terminal/:sessionId')({ params: { sessionId: 'term-1' } }, closed);
expect(closed.body).toEqual({ success: true });
} finally { await harness.runtime.shutdown(); }
});
it('escalates close to SIGKILL when a running process ignores SIGTERM', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
await harness.routes.delete.get('/api/terminal/:sessionId')({ params: { sessionId: 'term-1' } }, createResponse());
expect(harness.processes[0].kills).toEqual(['SIGTERM', 'SIGKILL']);
} finally { await harness.runtime.shutdown(); }
});
it('runs snapshot-first attach, scoped I/O, replay, reconnect, and close over a real websocket', async () => {
const app = createHttpTestApp();
const server = http.createServer(app);
const processes = [];
const loadPtyProvider = async () => ({
backend: 'fake-pty',
spawn: () => {
const data = new Set();
const exits = new Set();
const process = {
pid: 99123,
killed: false,
writes: [],
write(value) { this.writes.push(value); }, resize() {}, kill() { this.killed = true; },
onData(handler) { data.add(handler); return { dispose: () => data.delete(handler) }; },
onExit(handler) { exits.add(handler); return { dispose: () => exits.delete(handler) }; },
emitData(value) { for (const handler of data) handler(value); },
emitExit(exitCode) { for (const handler of exits) handler({ exitCode, signal: 0 }); },
};
processes.push(process);
return process;
},
});
const runtime = createRuntime(server, {
app, loadPtyProvider,
terminalTerminationGraceMs: 10,
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
searchPathFor: () => '/bin/sh', isExecutable: () => true,
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const base = `http://127.0.0.1:${address.port}`;
const socketUrl = `ws://127.0.0.1:${address.port}/api/terminal/ws`;
const sockets = [];
try {
const created = await fetch(`${base}/api/terminal/create`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: 'term-live', cwd: '/repo', cols: 80, rows: 24 }),
});
expect(created.status).toBe(200);
const secondCreated = await fetch(`${base}/api/terminal/create`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: 'term-second', cwd: '/other', cols: 80, rows: 24 }),
});
expect(secondCreated.status).toBe(200);
const first = await openTerminalSocket(socketUrl);
sockets.push(first.socket);
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running' });
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo ok\r' }));
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-second', d: 'pwd\r' }));
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo next\r' }));
await new Promise((resolve) => setTimeout(resolve, 5));
expect(processes[0].writes).toEqual(['echo ok\r', 'echo next\r']);
expect(processes[1].writes).toEqual(['pwd\r']);
processes[1].emitData('/other\r\n');
expect(await first.next('output', 'term-second')).toMatchObject({ s: 'term-second', q: 1, d: '/other\r\n' });
first.socket.send(createTerminalWsControlFrame({ t: 'detach', v: 3, s: 'term-second' }));
await new Promise((resolve) => setTimeout(resolve, 5));
processes[1].emitData('detached\r\n');
await new Promise((resolve) => setTimeout(resolve, 5));
expect(first.messages.some((message) => message?.t === 'output' && message.s === 'term-second')).toBe(false);
processes[0].emitData('ok\r\n');
expect(await first.next('output', 'term-live')).toMatchObject({ s: 'term-live', q: 1, d: 'ok\r\n' });
processes[0].emitData('\u001b[6n');
expect(await first.next('output', 'term-live')).toMatchObject({ s: 'term-live', q: 2, d: '\u001b[6n', r: '' });
const secondClosed = await fetch(`${base}/api/terminal/term-second`, { method: 'DELETE' });
expect(secondClosed.status).toBe(200);
first.socket.close();
const second = await openTerminalSocket(socketUrl);
sockets.push(second.socket);
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
processes[0].emitExit(7);
expect(await second.next('exit')).toMatchObject({ s: 'term-live', q: 3, exitCode: 7 });
const closed = await fetch(`${base}/api/terminal/term-live`, { method: 'DELETE' });
expect(closed.status).toBe(200);
expect(await second.next('error')).toMatchObject({ s: 'term-live', code: 'CLOSED', fatal: true });
await fetch(`${base}/api/terminal/create`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: 'term-kill', cwd: '/repo' }),
});
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-kill' }));
await second.next('snapshot');
const killed = await fetch(`${base}/api/terminal/force-kill`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ cwd: '/repo' }),
});
expect(await killed.json()).toEqual({ success: true, killedCount: 1, killedSessionIds: ['term-kill'] });
expect(await second.next('error')).toMatchObject({ s: 'term-kill', code: 'KILLED', fatal: true });
expect(processes[2].killed).toBe(true);
} finally {
for (const socket of sockets) socket.terminate();
await runtime.shutdown();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}, 15_000);
it('creates command-mode sessions and echoes the effective mode', async () => {
const harness = createHarness({
searchPathFor: (name) => name === 'bash' ? '/bin/bash' : '/bin/sh',
isExecutable: (candidate) => candidate === '/bin/bash' || candidate === '/bin/sh',
});
try {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf ready', shell: 'bash', loginShell: true } }, response);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ sessionId: 'term-command', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'terminal' } });
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
} else {
expect(harness.processes[0].args).toEqual(['-l', '-i', '-c', 'printf ready']);
}
} finally { await harness.runtime.shutdown(); }
});
it('rejects invalid terminal mode and command combinations', async () => {
const harness = createHarness();
try {
for (const [body, error] of [
[{ cwd: '/repo', mode: 'script' }, 'Invalid terminal mode'],
[{ cwd: '/repo', mode: 'command' }, 'Terminal command is required'],
[{ cwd: '/repo', mode: 'command', command: ' ' }, 'Terminal command is required'],
[{ cwd: '/repo', mode: 'interactive', command: 'echo nope' }, 'Interactive terminal create does not accept a command'],
[{ cwd: '/repo', mode: 'command', command: 'x'.repeat(65_537) }, 'Terminal command exceeds the input limit'],
]) {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body }, response);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error });
}
expect(harness.processes).toHaveLength(0);
} finally { await harness.runtime.shutdown(); }
});
it('rejects same-id running creates when mode or command do not match', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
await create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, createResponse());
const modeMismatch = createResponse();
await create({ body: { sessionId: 'term-shared', cwd: '/repo', mode: 'command', command: 'printf ready' } }, modeMismatch);
expect(modeMismatch.statusCode).toBe(400);
expect(modeMismatch.body).toEqual({ error: 'Terminal session is already running with a different mode' });
await create({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf ready' } }, createResponse());
const commandMismatch = createResponse();
await create({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf other' } }, commandMismatch);
expect(commandMismatch.statusCode).toBe(400);
expect(commandMismatch.body).toEqual({ error: 'Terminal session is already running with a different command' });
} finally { await harness.runtime.shutdown(); }
});
it('rejects pending creates when command mode does not match the in-flight request', async () => {
const spawnDeferred = deferred();
const harness = createHarness({ spawnDeferred });
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
const conflictingMode = createResponse();
const conflictingCommand = createResponse();
const firstPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo', mode: 'command', command: 'printf ready' } }, first);
await Promise.resolve();
const secondPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo' } }, conflictingMode);
const thirdPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo', mode: 'command', command: 'printf other' } }, conflictingCommand);
spawnDeferred.resolve();
await Promise.all([firstPromise, secondPromise, thirdPromise]);
expect(first.statusCode).toBe(200);
expect(conflictingMode.statusCode).toBe(400);
expect(conflictingMode.body).toEqual({ error: 'Terminal session is already being created with a different mode' });
expect(conflictingCommand.statusCode).toBe(400);
expect(conflictingCommand.body).toEqual({ error: 'Terminal session is already being created with a different command' });
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('validates purpose payloads and round-trips purpose through create, list, and snapshot without listing command text', async () => {
const app = createHttpTestApp();
const server = http.createServer(app);
const runtime = createRuntime(server, {
app,
loadPtyProvider: async () => ({
backend: 'fake-pty',
spawn: async () => ({
pid: 42,
write() {},
resize() {},
kill() {},
onData() { return { dispose() {} }; },
onExit() { return { dispose() {} }; },
}),
}),
terminalTerminationGraceMs: 10,
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
searchPathFor: () => '/bin/sh',
isExecutable: () => true,
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const base = `http://127.0.0.1:${port}`;
const socketUrl = `ws://127.0.0.1:${port}/api/terminal/ws`;
const sockets = [];
try {
for (const [body, error] of [
[{ cwd: '/repo', purpose: 'terminal' }, 'Invalid terminal purpose'],
[{ cwd: '/repo', purpose: { type: 'project-action' } }, 'Terminal project action id is required'],
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: 'build' } }, 'Terminal execution id is required'],
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: ' ', executionId: 'exec-1' } }, 'Terminal project action id is required'],
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: 'build', executionId: ' ' } }, 'Terminal execution id is required'],
]) {
const response = await fetch(`${base}/api/terminal/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error });
}
const created = await fetch(`${base}/api/terminal/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'printf ready',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
}),
});
expect(created.status).toBe(200);
expect(await created.json()).toEqual({
sessionId: 'action-tab',
cols: 80,
rows: 24,
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
const listed = await fetch(`${base}/api/terminal/sessions?cwd=%2Frepo`);
expect(listed.status).toBe(200);
expect(await listed.json()).toEqual({
sessions: [{
sessionId: 'action-tab',
cwd: '/repo',
status: 'running',
createdAt: expect.any(Number),
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
}],
});
const socket = await openTerminalSocket(socketUrl);
sockets.push(socket.socket);
socket.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'action-tab' }));
expect(await socket.next('snapshot', 'action-tab')).toMatchObject({
s: 'action-tab',
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
} finally {
for (const socket of sockets) socket.terminate();
await runtime.shutdown();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}, 15_000);
it('deduplicates running project actions by resolved cwd and action id across session ids and clients', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
await create({
body: {
sessionId: 'action-a',
cwd: '/repo/./nested/..',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
},
}, first);
expect(first.statusCode).toBe(200);
const adopted = createResponse();
await create({
body: {
sessionId: 'action-b',
cwd: '/repo',
mode: 'command',
command: 'npm run build --watch',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-2' },
},
}, adopted);
expect(adopted.statusCode).toBe(200);
expect(adopted.body).toEqual({
sessionId: 'action-a',
cols: 80,
rows: 24,
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('rejects purpose mismatches when the same session id is reused for a different action', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
await create({
body: {
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
},
}, createResponse());
const mismatch = createResponse();
await create({
body: {
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'npm run test',
purpose: { type: 'project-action', actionId: 'test', executionId: 'exec-2' },
},
}, mismatch);
expect(mismatch.statusCode).toBe(400);
expect(mismatch.body).toEqual({ error: 'Terminal session is already running with a different purpose' });
} finally { await harness.runtime.shutdown(); }
});
it('keeps an immediately exited command session attachable once listeners are registered', async () => {
const app = createHttpTestApp();
const server = http.createServer(app);
const runtime = createRuntime(server, {
app,
loadPtyProvider: async () => ({
backend: 'fake-pty',
spawn: async () => {
const dataHandlers = new Set();
const exitHandlers = new Set();
return {
pid: 404,
write() {},
resize() {},
kill() {},
onData(handler) { dataHandlers.add(handler); return { dispose: () => dataHandlers.delete(handler) }; },
onExit(handler) {
exitHandlers.add(handler);
queueMicrotask(() => {
for (const registered of exitHandlers) registered({ exitCode: 0, signal: 0 });
});
return { dispose: () => exitHandlers.delete(handler) };
},
};
},
}),
terminalTerminationGraceMs: 10,
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
searchPathFor: () => '/bin/sh',
isExecutable: () => true,
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const base = `http://127.0.0.1:${port}`;
const socketUrl = `ws://127.0.0.1:${port}/api/terminal/ws`;
const sockets = [];
try {
const created = await fetch(`${base}/api/terminal/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sessionId: 'fast-exit',
cwd: '/repo',
mode: 'command',
command: 'true',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-fast' },
}),
});
expect(created.status).toBe(200);
await new Promise((resolve) => setTimeout(resolve, 0));
const socket = await openTerminalSocket(socketUrl);
sockets.push(socket.socket);
socket.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'fast-exit' }));
expect(await socket.next('snapshot', 'fast-exit')).toMatchObject({
s: 'fast-exit',
status: 'exited',
exitCode: 0,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-fast' },
});
} finally {
for (const socket of sockets) socket.terminate();
await runtime.shutdown();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}, 15_000);
it('tombstones a pending create when delete arrives first and kills the eventual pty', async () => {
const spawnDeferred = deferred();
const harness = createHarness({ spawnDeferred });
try {
const create = harness.routes.post.get('/api/terminal/create');
const close = harness.routes.delete.get('/api/terminal/:sessionId');
const created = createResponse();
const closed = createResponse();
const createPromise = create({
body: {
sessionId: 'pending-action',
cwd: '/repo',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-pending' },
},
}, created);
await Promise.resolve();
const closePromise = close({ params: { sessionId: 'pending-action' } }, closed);
spawnDeferred.resolve();
await Promise.all([createPromise, closePromise]);
expect(closed.statusCode).toBe(200);
expect(closed.body).toEqual({ success: true });
expect(created.statusCode).toBe(400);
expect(created.body).toEqual({ error: 'Terminal session was closed during creation' });
const listed = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: {} }, listed);
expect(listed.body).toEqual({ sessions: [] });
expect(harness.processes).toHaveLength(1);
expect(harness.processes[0].killed).toBe(true);
} finally { await harness.runtime.shutdown(); }
});
it('rejects restart for command-mode sessions', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({
body: {
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
},
}, createResponse());
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'action-tab' }, body: {} }, restarted);
expect(restarted.statusCode).toBe(400);
expect(restarted.body).toEqual({ error: 'Command-mode terminal sessions cannot be restarted' });
expect(harness.processes).toHaveLength(1);
expect(harness.processes[0].killed).toBe(false);
} finally { await harness.runtime.shutdown(); }
});
});