TERMINAL is confined now, and the shell is genuinely theirs. The pty sidecar spawns it through sudo setpriv as their own account, in their own home, with the platform's environment cleared. Verified end to end against the sidecar's own socket: id -u 1001, not 1000 file the shell wrote owned by ptyprobe ps -o user=,args= ptyprobe /bin/zsh -i env | grep -c POSTGRES 0 osUser and home are resolved in upgradeWs from the authenticated account, and whatever the browser sent under those names is DELETED first. The bridge forwards the query string to the sidecar untouched and the sidecar starts a shell from what it finds there, so trusting the client for either would let a member ask for the owner's uid in a query parameter. node-pty does support uid/gid, unlike Bun.spawn, and they are deliberately unused: they set the ids without applying the account's groups or resetting the environment, so the shell would keep the owner's groups and everything Bun loaded from .env. Also closes the pty identity blindness in TODO.md. Sessions record whose they are, list and kill scope to the caller, and re-attaching to a session belonging to another account is refused — otherwise a member resumes someone else's shell by guessing an id that travels in a query string. Measured: member killing the owner's session -> ok:false, owner killing it -> ok:true. CHAT is confined so the owner can grant it and the route resolves, and both execution doors refuse a non-owner: the router wholesale, and the socket in server.tsx. The agent has not moved — the SDK spawns claude itself with nowhere to put a uid, and every transcript path resolves through the owner's home, so a member would read the owner's session list and run an agent as the owner. Reads are refused too, because listClaudePwds returns the names of the owner's projects. A deliberate, temporary gap at the owner's request: permission and route now, function when a turn can be spawned under runAs with the member's own HOME. Both guards say so, and the registry test names them so a future edit cannot move one without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 lines
3.8 KiB
JavaScript
99 lines
3.8 KiB
JavaScript
import http from 'node:http';
|
|
import { WebSocketServer } from 'ws';
|
|
import * as store from './sessions.mjs';
|
|
|
|
// The sidecar's own listener — the thing that makes this a real sidecar rather than a set of commands the
|
|
// platform drives. It binds an ephemeral loopback port and announces it; the platform authenticates the
|
|
// browser and relays bytes here without reading them.
|
|
//
|
|
// The socket protocol is the one the browser already speaks, unchanged: {input,resize} in,
|
|
// {output,replay,exit} out. That is deliberate — the frontend did not have to move for the
|
|
// transport to.
|
|
|
|
const wsSend = (socket) => ({
|
|
send: (msg) => {
|
|
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg));
|
|
},
|
|
});
|
|
|
|
const json = (res, status, body) => {
|
|
const payload = JSON.stringify(body);
|
|
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
|
|
res.end(payload);
|
|
};
|
|
|
|
export function startServer() {
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
|
|
// Officer-owned routes, reached through the platform's authenticated proxy.
|
|
//
|
|
// `osUser` scopes both to one account's sessions. The platform sends it for a member and omits it for the
|
|
// owner; absent means unscoped. Until this existed these two listed and killed EVERY shell on the box for
|
|
// anyone who could reach them, which was safe only because the terminal was owner-only.
|
|
const scope = url.searchParams.has('osUser') ? (url.searchParams.get('osUser') || null) : undefined;
|
|
|
|
if (url.pathname === '/_officer/sessions' && req.method === 'GET') {
|
|
return json(res, 200, { sessions: store.list(scope) });
|
|
}
|
|
|
|
const killMatch = url.pathname.match(/^\/_officer\/sessions\/([^/]+)$/);
|
|
if (killMatch && req.method === 'DELETE') {
|
|
const killed = store.kill(decodeURIComponent(killMatch[1]), scope);
|
|
return json(res, killed ? 200 : 404, { ok: killed });
|
|
}
|
|
|
|
json(res, 404, { error: 'not found' });
|
|
});
|
|
|
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
|
|
wss.on('connection', (socket, req) => {
|
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
const sessionId = url.searchParams.get('sessionId');
|
|
if (!sessionId) {
|
|
socket.close(1008, 'sessionId required');
|
|
return;
|
|
}
|
|
|
|
const client = wsSend(socket);
|
|
const session = store.attach(sessionId, client, {
|
|
cwd: url.searchParams.get('cwd') ?? undefined,
|
|
cols: Number(url.searchParams.get('cols')) || 0,
|
|
rows: Number(url.searchParams.get('rows')) || 0,
|
|
// Injected by the platform's bridge from the authenticated account, after stripping whatever the
|
|
// browser sent. Absent for the owner, whose shell runs as this process.
|
|
osUser: url.searchParams.get('osUser') || undefined,
|
|
home: url.searchParams.get('home') || undefined,
|
|
});
|
|
if (!session) {
|
|
socket.close(1011, 'failed to start terminal');
|
|
return;
|
|
}
|
|
|
|
socket.on('message', (raw) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
} catch {
|
|
return; // malformed frame
|
|
}
|
|
if (msg.type === 'input') store.write(sessionId, msg.data);
|
|
else if (msg.type === 'resize') store.resize(sessionId, msg.cols, msg.rows);
|
|
});
|
|
|
|
// Detach, never kill: the shell outlives the viewer, which is what makes re-attach work at all.
|
|
socket.on('close', () => store.detach(sessionId, client));
|
|
socket.on('error', () => store.detach(sessionId, client));
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
// Port 0 — the OS picks, and the platform learns it from the registration socket. Loopback only.
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const { port } = server.address();
|
|
console.log(`[pty-sidecar] listening on http://127.0.0.1:${port}`);
|
|
resolve(port);
|
|
});
|
|
});
|
|
}
|