Files
platform/src/servers/sidecar-registry.test.ts
T
pastilhas 027b10bd6e step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
2026-08-15 16:31:11 +00:00

110 lines
4.2 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, startVnc } 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',
};
const VNC_PARAMS = { email: 'owner@example.com', username: 'owner' };
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 = startVnc(VNC_PARAMS);
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 = startVnc(VNC_PARAMS);
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' });
});
});