Files
platform/src/servers/api/terminal/websocket.ts
T
pastilhasandClaude Opus 4.8 1824f53c89 let the pty sidecar decide what shell it runs
officer built the whole PtyInitConfig: it read the owner's SHELL (defaulting to
/bin/zsh), added `-i`, read their HOME, expanded `~` against it, and hardcoded
`host: true`. none of that is a proxy's business — the sidecar is the process that
calls pty.spawn, so it is the one that should know what to spawn and where.

the config now carries only what the bridge actually knows: sessionId, the folder
the panel was opened on, and the client's cols/rows. shell, args, home and cwd
resolution moved into the sidecar. home comes from HOME_DIR ?? HOME, mirroring
data-path.ts:getOwnerHomeDir — terminal was the one host-executing surface reading
process.env.HOME directly, which is identical here and divergent anywhere HOME_DIR
is set to something else.

deleted the bwrap sandbox branch rather than moving it. it was selected by
`config.host`, which officer hardcoded to true, so it never ran — and it expected
`shell` to contain a fully-built bwrap command that nothing on either side ever
built. it could not have worked. a terminal here is the owner's own shell on the
owner's own machine by design (platform/CLAUDE.md), so there is no jail to preserve.
its ensureUserFiles half duplicated api/users/provision.ts:seedShellConfigs, which
is the live seeder of those same templates and stays.

also deleted the 'cwd' handler that turned a message into `cd <path>\r` typed at
the shell. no frontend has ever sent that message — the browser composes its own cd
— so it was unreachable, and synthesizing keystrokes is not something a relay
should do.

the integration test pins SHELL and HOME_DIR now that the sidecar reads them, and
asserts the shell starts in the resolved `~` rather than officer having resolved it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:02:10 +00:00

139 lines
4.3 KiB
TypeScript

import type { ServerWebSocket } from 'bun';
import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry';
import type { PtyInitConfig } from '../../sidecar/protocol';
type WSData = {
userId: number;
email: string;
username: string;
sessionId?: string;
cwd?: string;
cols?: number;
rows?: number;
};
type BridgeSession = {
client: ServerWebSocket<WSData>;
sessionId: string;
unsubs: Array<() => void>;
};
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
let idCounter = 0;
function nextId(): string {
return `pty_${Date.now()}_${++idCounter}`;
}
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
try {
ws.send(JSON.stringify({ type: 'output', data }));
} catch {
// ws already closed
}
};
export const terminalWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
const { email, username } = ws.data;
console.log(`[terminal] open: email=${email} username=${username}`);
if (!isTerminalConnected()) {
sendOutput(ws, '\r\n[Terminal error] PTY sidecar is not connected\r\n');
return;
}
const sessionId = ws.data.sessionId ?? `host-${ws.data.userId}`;
// Everything this bridge knows: which session, which folder the panel was opened on, and how big the
// client's viewport is. The shell, its arguments and the home directory are the sidecar's — it is the
// process that spawns them, and officer has no business reading the owner's SHELL and HOME to guess.
const config: PtyInitConfig = { sessionId, cwd: ws.data.cwd, cols: ws.data.cols, rows: ws.data.rows };
// The sidecar emits one global stream, so each frame is filtered down to this session and relabelled.
const relay = (event: 'pty:output' | 'pty:replay' | 'pty:exit', clientType: string) =>
on(event, (msg) => {
if (msg.type !== event || msg.sessionId !== sessionId) return;
try {
ws.send(JSON.stringify({ type: clientType, data: 'data' in msg ? msg.data : undefined }));
} catch {
// ws already closed
}
});
const session: BridgeSession = {
client: ws,
sessionId,
unsubs: [relay('pty:output', 'output'), relay('pty:replay', 'replay'), relay('pty:exit', 'exit')],
};
sessions.set(ws, session);
// Send init command to PTY sidecar
try {
await sendPtyCommandAsync({ type: 'pty:init', id: nextId(), sessionId, config });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to initialize terminal';
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
for (const unsub of session.unsubs) unsub();
sessions.delete(ws);
}
},
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
const session = sessions.get(ws);
if (!session) return;
try {
const payload = typeof raw === 'string' ? raw : raw.toString();
const msg = JSON.parse(payload);
switch (msg.type) {
case 'input':
sendPtyCommand({ type: 'pty:input', id: nextId(), sessionId: session.sessionId, data: msg.data ?? '' });
break;
case 'resize':
if (msg.cols > 0 && msg.rows > 0) {
sendPtyCommand({
type: 'pty:resize',
id: nextId(),
sessionId: session.sessionId,
cols: msg.cols,
rows: msg.rows,
});
}
break;
// There was a 'cwd' case here that typed `cd <path>\r` into the user's shell. No frontend sends
// that message — the browser composes its own `cd` (Terminal.tsx / CommandTerminalWrapper.tsx) —
// so it was unreachable, and synthesizing keystrokes is not a thing a proxy should do.
}
} catch {
// ignore malformed messages
}
},
close(ws: ServerWebSocket<WSData>) {
const session = sessions.get(ws);
if (session) {
for (const unsub of session.unsubs) unsub();
// Don't kill PTY — it can be reattached
sessions.delete(ws);
}
},
drain() {},
};
export const broadcastPanelRefresh = (email: string) => {
const msg = JSON.stringify({ type: 'panel-refresh' });
for (const [ws] of sessions) {
if (ws.data.email === email) {
try {
ws.send(msg);
} catch {
/* ignore */
}
}
}
};