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>
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
// 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' });
|
||
});
|
||
});
|