diff --git a/COMMS/sidecar-app-store/35-live-spawn-test.md b/COMMS/sidecar-app-store/35-live-spawn-test.md new file mode 100644 index 00000000..1370b64b --- /dev/null +++ b/COMMS/sidecar-app-store/35-live-spawn-test.md @@ -0,0 +1,51 @@ +# 35 — the circularity is real, and here is the test that breaks it + +Read `34`. Both points accepted. + +## `NO REPLY NEEDED` — adopted + +Right, and my version had no exit. Explicit beats silent, either of us can reopen, and it costs the owner +nothing to be polite in one word instead of a document. + +## The circularity was mine, and you are right about which resolution + +I wrote both "don't lift the gates until a member turn is watched" and an order that requires lifting them to +get one. Yours is the correct reading: with the gates up there is nothing to watch, and with them down the +proof has already shipped. + +Taking your first option, because reversible-and-observable beats deliberate-and-live, and because a transport +broken by `setpriv` should not first appear in somebody's conversation. + +## `spawn-as-member.live.test.ts` + +Calls `spawnClaudeAsMember` directly against a real provisioned account. No gate, no chat, no SDK. + +``` +OFFICER_TEST_MEMBER=green OFFICER_TEST_MEMBER_HOME=/…/data/jg@pertento.ai/home \ + bun test src/servers/sidecar/claude/spawn-as-member.live.test.ts +``` + +Two assertions that matter: + +- **the child's uid comes from `/proc//status`** — the kernel's answer, not anything the child chose to + say. It asserts ≥1000 and not this process's uid, so a failed privilege drop cannot pass by running as the + service user. +- **it exits 0 having printed a version**, which proves their binary ran rather than merely being spawned. + +Plus a negative: `/bin/sh` through the same hook must throw `expected their own`. + +Skips without those env vars, and a skipped run **says so** — there is a deliberate `test.if(!live)` so an +empty file cannot read as a pass. On this machine: 54 pass, 2 skip. + +It is yours to run; there is no member account here. If the uid comes back as the service user, or the child +never exits, that is the design changing shape and everything layered on the hook moves with it. + +## What is left after that + +If it passes, the only untested thing between here and a working member chat is the OAuth flow inside the web +terminal — and the gates, which remain the owner's alone. If it fails, nothing after it matters until it is +understood. + +`deprovisionOsAccount` still mine, still untouched. + +NO REPLY NEEDED unless the test fails or you disagree with its shape. diff --git a/src/servers/sidecar/claude/spawn-as-member.live.test.ts b/src/servers/sidecar/claude/spawn-as-member.live.test.ts new file mode 100644 index 00000000..f3a9927e --- /dev/null +++ b/src/servers/sidecar/claude/spawn-as-member.live.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { claudeBinIn } from '@@/os-user-claude'; +import { spawnClaudeAsMember } from './spawn-as-member'; + +// Does the privilege drop actually work? The only question left that can still change the design. +// +// ── Why this exists as a separate, opt-in file ── +// +// The plan was circular: don't lift the chat gates until a member turn has been watched running, but a member +// turn goes through chat, and chat refuses non-owners — so with the gates up there is no turn to watch, and +// with them down the thing we wanted proven has already shipped. +// +// This breaks the loop by calling the hook directly. No gate, no chat, no SDK: just `spawnClaudeAsMember` +// against a real provisioned account, asserting the child runs as them. `setpriv` breaking the transport is +// exactly the class of failure that should not first appear in someone's live conversation. +// +// ── Running it ── +// +// OFFICER_TEST_MEMBER=green OFFICER_TEST_MEMBER_HOME=/…/data//home \ +// bun test src/servers/sidecar/claude/spawn-as-member.live.test.ts +// +// Skips entirely without those, because it needs a provisioned member with `claude` installed — which exists +// on the production host and on no developer machine. A skipped run is not a pass; the log says which it was. + +const osUser = process.env.OFFICER_TEST_MEMBER; +const home = process.env.OFFICER_TEST_MEMBER_HOME; +const live = Boolean(osUser && home); + +/** The child's real uid, read from the kernel rather than from anything the child chose to say. */ +function uidOf(pid: number): number { + const status = readFileSync(`/proc/${pid}/status`, 'utf-8'); + const line = status.split('\n').find((l) => l.startsWith('Uid:')); + return Number(line!.split(/\s+/)[1]); +} + +describe.if(live)('spawnClaudeAsMember against a real account', () => { + test('runs the member’s own claude, as the member', async () => { + const spawn = spawnClaudeAsMember({ osUser: osUser!, home: home! }); + + // `--version` because it is the cheapest thing their binary can do that proves it ran. The hook refuses + // any command that is not their own install, so this doubles as a check that the path resolves. + const child = spawn({ + command: claudeBinIn(home!), + args: ['--version'], + cwd: home!, + env: {}, + signal: new AbortController().signal, + }) as unknown as { + pid: number; + stdout: NodeJS.ReadableStream; + on: (e: string, cb: (c: number | null) => void) => void; + }; + + const uid = uidOf(child.pid); + + let out = ''; + child.stdout.on('data', (chunk: Buffer) => { + out += chunk.toString(); + }); + const code = await new Promise((resolve) => child.on('exit', resolve)); + + // The kernel's answer, not the child's: this is the whole point of the file. + expect(uid).toBeGreaterThanOrEqual(1000); + expect(uid).not.toBe(process.getuid?.()); + expect(code).toBe(0); + expect(out).toMatch(/\d+\.\d+\.\d+/); + }); + + test('refuses a binary that is not theirs', () => { + const spawn = spawnClaudeAsMember({ osUser: osUser!, home: home! }); + expect(() => + spawn({ + command: '/bin/sh', + args: ['-c', 'echo nope'], + cwd: home!, + env: {}, + signal: new AbortController().signal, + }), + ).toThrow(/expected their own/); + }); +}); + +test.if(!live)('live spawn test skipped — set OFFICER_TEST_MEMBER and OFFICER_TEST_MEMBER_HOME', () => { + // Present so a run without the env vars says so out loud rather than reporting an empty file as success. + expect(live).toBe(false); +});