move the pty sidecar under src/servers/sidecar

it was the only sidecar living outside src/servers/sidecar/ — it sat in
api/terminal/ next to the bridge that talks to it, which is the one place a reader
looking for "the sidecars" would not check. now src/servers/sidecar/pty/index.mjs,
matching every peer, with a note on the pm2 entry about why this one is node and
.mjs (node-pty is a native addon) rather than bun and typescript like the rest.

the templates/ directory went to api/users/, next to provision.ts:seedShellConfigs,
which is now its only consumer — the sidecar's duplicate seeder went with the
sandbox branch in the previous commit. api/terminal/ is left holding exactly one
thing: the websocket bridge.

no behaviour change. the pm2 entry's script path changed, so `pm2 restart
officer-pty` is not enough — pm2 remembers the old path until the entry is deleted
and started again. commands are in SIDECAR_WORK_LOG.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 05:03:03 +00:00
co-authored by Claude Opus 4.8
parent 1824f53c89
commit ab261cf52d
8 changed files with 6 additions and 3 deletions
+134
View File
@@ -0,0 +1,134 @@
import { describe, test, expect, afterAll } from 'bun:test';
import type { ServerWebSocket, Subprocess } from 'bun';
// Integration test for the one thing about the pty sidecar that cannot be reasoned about from the code
// alone: what happens to a live shell when officer goes away and comes back. It stands up a fake
// registration socket, runs the real sidecar against it, then kills the socket and rebinds the same port
// the way `pm2 restart officer` does.
//
// Nothing here touches the running officer — the sidecar dials API_URL, which is overridden per spawn.
const SIDECAR = 'src/servers/sidecar/pty/index.mjs';
type Frame = Record<string, any>;
// One fake officer. `stop()` drops the socket; a new instance on the same port is the restart.
function fakeOfficer(port?: number) {
let socket: ServerWebSocket<unknown> | null = null;
const frames: Frame[] = [];
const waiters: Array<{ match: (f: Frame) => boolean; resolve: (f: Frame) => void }> = [];
const server = Bun.serve({
port: port ?? 0,
fetch(req, srv) {
if (new URL(req.url).pathname === '/api/sidecar/register' && srv.upgrade(req)) return undefined;
return new Response('no', { status: 404 });
},
websocket: {
open(ws) {
socket = ws;
},
message(_ws, raw) {
const frame = JSON.parse(String(raw)) as Frame;
frames.push(frame);
for (let i = waiters.length - 1; i >= 0; i--) {
if (waiters[i]!.match(frame)) waiters.splice(i, 1)[0]!.resolve(frame);
}
},
close() {
socket = null;
},
},
});
return {
port: server.port,
send: (msg: Frame) => socket?.send(JSON.stringify(msg)),
/** Resolve on the first frame matching `match`, including ones already received. */
await: (match: (f: Frame) => boolean, timeoutMs = 15_000) =>
new Promise<Frame>((resolve, reject) => {
const seen = frames.find(match);
if (seen) return resolve(seen);
const timer = setTimeout(() => reject(new Error(`timed out waiting for a frame`)), timeoutMs);
waiters.push({
match,
resolve: (f) => {
clearTimeout(timer);
resolve(f);
},
});
}),
/** Output frames for one session, concatenated — the terminal's visible text. */
outputFor: (sessionId: string) =>
frames
.filter((f) => f.type === 'pty:output' && f.sessionId === sessionId)
.map((f) => f.data as string)
.join(''),
stop: () => server.stop(true),
};
}
const isOutput = (sessionId: string, needle: string) => (f: Frame) =>
f.type === 'pty:output' && f.sessionId === sessionId && String(f.data).includes(needle);
let child: Subprocess | null = null;
afterAll(() => child?.kill());
describe('pty sidecar', () => {
test('a shell keeps streaming output after officer restarts under it', async () => {
const sessionId = 'test-restart';
let officer = fakeOfficer();
const port = officer.port;
child = Bun.spawn(['node', SIDECAR], {
env: {
...process.env,
API_URL: `ws://127.0.0.1:${port}`,
// The sidecar now chooses the shell and the home itself, so the test pins both rather than
// spawning the owner's interactive zsh (which would read their rc files and their history).
SHELL: '/bin/sh',
HOME_DIR: '/tmp',
ENV: '/dev/null',
},
stdout: 'ignore',
stderr: 'ignore',
});
await officer.await((f) => f.type === 'register' && f.capabilities?.includes('terminal'));
// `~` is resolved by the sidecar against its own HOME_DIR, not by officer.
officer.send({ type: 'pty:init', id: 'i1', sessionId, config: { sessionId, cwd: '~', cols: 80, rows: 24 } });
await officer.await((f) => f.type === 'pty:ready' && f.sessionId === sessionId);
officer.send({ type: 'pty:input', id: 'in0', sessionId, data: 'pwd\n' });
await officer.await(isOutput(sessionId, '/tmp'));
officer.send({ type: 'pty:input', id: 'in1', sessionId, data: 'echo before-restart\n' });
await officer.await(isOutput(sessionId, 'before-restart'));
// ── the restart ──
officer.stop();
officer = fakeOfficer(port);
await officer.await((f) => f.type === 'register');
// The shell is the same process; only officer changed. Before the sendJson fix this input was
// accepted and executed, but its output went to the socket captured at init time and vanished.
officer.send({ type: 'pty:input', id: 'in2', sessionId, data: 'echo after-restart\n' });
await officer.await(isOutput(sessionId, 'after-restart'));
// Re-attaching replays the scrollback, marked as history so the client can rebuild rather than
// append — and it contains what happened on both sides of the restart.
officer.send({ type: 'pty:init', id: 'i2', sessionId, config: { sessionId, cols: 80, rows: 24 } });
const replay = await officer.await((f) => f.type === 'pty:replay' && f.sessionId === sessionId);
expect(String(replay.data)).toContain('before-restart');
expect(String(replay.data)).toContain('after-restart');
// Re-attach must not spawn a second shell, and must not replay as ordinary output.
await officer.await((f) => f.type === 'pty:ready' && f.id === 'i2');
expect(officer.outputFor(sessionId)).not.toContain('before-restart');
officer.send({ type: 'pty:close', id: 'c1', sessionId });
await officer.await((f) => f.type === 'pty:exit' && f.sessionId === sessionId);
officer.stop();
}, 30_000);
});