Files
platform/src/servers/sidecar-registry.test.ts
T
pastilhasandClaude Opus 5 fcdbf02649 the platform stops knowing what VNC is
Desktop is a plugin as of today, and this is the half that leaves. sidecar/vnc/, api/desktop/ and
apps/Desktop/ are gone from src/, along with the vocabulary that was the real problem.

sidecar/protocol.ts had a VNC section: vnc:start, vnc:ensure-password, vnc:stop, four reply variants
and two types. sidecar-registry.ts had startVnc, ensureVncPassword, stopVnc, isVncConnected. Four
functions and a dozen message types in the platform, describing a feature that is not the platform's —
the rule CLAUDE.md states most plainly, and its clearest breach.

What replaced them is generic. sendSidecarCommand(kind, cmd) and isSidecarConnected(kind) know that a
command has a type and an id and that a reply carries the same id. A plugin's sidecar speaks whatever
it likes through them and nothing here has to be edited when it adds a message.

The desktop PERMISSION leaves too, and that is the interesting part: it was kind: 'execution', a kind
a plugin permission cannot have and never will, because a permission nobody can delegate is a
contradiction. The plugin expresses the same refusal as ownerOnly: ['/'] — every path under it denied
to anyone but the owner. Verified: /api/desktop, /api/desktop/vnc-password and a path that does not
exist all resolve owner-only. registry.test.ts records the round trip rather than just dropping the
key from its list.

Also gone: the app store's `vnc` entry, which would have offered an older second way to install the
same thing against a process PM2 no longer has, and four commented-out mounts left from 2026-08-13.

The disconnect-scope test used startVnc as its stand-in for "another sidecar's long-running request".
It now uses the generic door, which is the better instrument — that file is about the blast radius of
a disconnect and never had anything to do with VNC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:42:19 +00:00

113 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// A disconnect must fail only the work that was in flight to the sidecar that actually went away.
//
// This exists because it did not. `unregisterSidecar` walked the whole `pending` map and rejected every
// entry, so `pm2 restart officer-music` would fail a running agent turn with `Sidecar "music" disconnected`
// — a message that points at the wrong process entirely and sends anyone debugging it in the wrong
// direction. Sidecars restart independently by design; that is the whole point of them being PM2 peers.
import { afterEach, describe, expect, test } from 'bun:test';
import { registerSidecar, unregisterSidecar, spawnClaude, sendSidecarCommand } from './sidecar-registry';
/** A socket that records what was written and never answers, so requests stay pending. */
function silentSocket() {
const sent: string[] = [];
return { sent, ws: { send: (data: string) => void sent.push(data) } as never };
}
const registered: string[] = [];
/** Register a fake sidecar and remember it, so a failed assertion can't leak it into the next test. */
function register(name: string, handles: string[]) {
const socket = silentSocket();
const id = registerSidecar(socket.ws, { type: 'register', name, handles });
registered.push(id);
return { ...socket, id };
}
afterEach(() => {
while (registered.length) unregisterSidecar(registered.pop()!);
});
/** Wait until a request has actually reached the socket — spawnClaude awaits waitForPermission first. */
async function awaitSend(socket: { sent: string[] }): Promise<void> {
for (let i = 0; i < 50 && socket.sent.length === 0; i++) await Bun.sleep(1);
expect(socket.sent).not.toHaveLength(0);
}
/** Resolves to 'pending' when the promise has not settled shortly after. */
async function settleState(promise: Promise<unknown>): Promise<'pending' | 'resolved' | { rejected: string }> {
const marker = Symbol('pending');
const outcome = await Promise.race([
promise.then(
() => 'resolved' as const,
(err: Error) => ({ rejected: err.message }),
),
Bun.sleep(25).then(() => marker),
]);
return outcome === marker ? 'pending' : (outcome as 'resolved' | { rejected: string });
}
const CLAUDE_PARAMS = {
userId: 1,
email: 'owner@example.com',
username: 'owner',
prompt: 'hello',
sessionKey: 'test-session',
};
// A stand-in for "some other sidecar's long-running request". This was `startVnc` until the desktop
// plugin took it on 2026-08-21; the generic door is the better instrument anyway, since what this file
// tests is the SCOPE of a disconnect and never had anything to do with VNC.
const otherRequest = (id: string) => sendSidecarCommand('vnc', { type: 'vnc:start', id });
describe('unregisterSidecar', () => {
test('an unrelated sidecar disconnecting leaves other sidecars’ requests in flight', async () => {
const agent = register('agent', ['claude']);
const vnc = register('vnc', ['vnc']);
const turn = spawnClaude(CLAUDE_PARAMS);
// Take ownership of the eventual rejection immediately, so it is never an unhandled one.
const turnState = settleState(turn);
await awaitSend(agent);
unregisterSidecar(vnc.id);
// The agent never disconnected, so its turn is still running.
expect(await turnState).toBe('pending');
// And when the agent itself goes, its own request fails — naming the right process.
unregisterSidecar(agent.id);
expect(await settleState(turn)).toEqual({ rejected: 'Sidecar "agent" disconnected' });
});
test('a sidecar’s own disconnect still rejects its own request', async () => {
const vnc = register('vnc', ['vnc']);
const start = otherRequest('test-start');
const state = settleState(start);
await awaitSend(vnc);
unregisterSidecar(vnc.id);
expect(await state).toEqual({ rejected: 'Sidecar "vnc" disconnected' });
});
test('re-registering under the same name only clears that name’s work', async () => {
// registerSidecar unregisters an existing same-named sidecar first — a reconnect after a restart.
// That path runs the same rejection loop, so it has the same blast radius if it is not scoped.
register('agent', ['claude']);
const vnc = register('vnc', ['vnc']);
const desktop = otherRequest('test-desktop');
const desktopState = settleState(desktop);
await awaitSend(vnc);
// The agent reconnects — a different process entirely.
register('agent', ['claude']);
expect(await desktopState).toBe('pending');
unregisterSidecar(vnc.id);
expect(await settleState(desktop)).toEqual({ rejected: 'Sidecar "vnc" disconnected' });
});
});