import { describe, expect, test } from 'bun:test'; import { rmSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { runAsArgv } from '@@/os-user'; 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 uid that actually ran, established by what the kernel wrote rather than by what any process said. // // The first version read `/proc//status`, and `child.pid` is **sudo** — whose real uid is // legitimately the service user's until it execs down through `setpriv` to the member. So it asserted against // the wrapper and failed on a working privilege drop. // // Having the final process create a file and reading its owner keeps the property that mattered — nothing // self-reports — while observing the process that matters. A process cannot forge the uid that owns a file it // created. describe.if(live)('spawnClaudeAsMember against a real account', () => { test('runs as the member, and the kernel says so', async () => { const spawn = spawnClaudeAsMember({ osUser: osUser!, home: home! }); // Their own binary is the only command the hook permits, so identity is proven by a file the CLI's own // process leaves behind rather than by running `id`. const child = spawn({ command: claudeBinIn(home!), args: ['--version'], cwd: home!, env: {}, signal: new AbortController().signal, }) as unknown as { stdout: NodeJS.ReadableStream; on: (e: string, cb: (c: number | null) => void) => void }; let out = ''; child.stdout.on('data', (chunk: Buffer) => { out += chunk.toString(); }); const code = await new Promise((resolve) => child.on('exit', resolve)); expect(code).toBe(0); expect(out).toMatch(/\d+\.\d+\.\d+/); }); test('the privilege drop lands on the member, proven by file ownership', async () => { // A second spawn whose only job is to leave evidence. `sh` is refused by the binary check, so this goes // through `runAsArgv` directly — the same argv the hook builds, minus the SDK's shape. const probe = join(home!, `.spawn-probe-${Date.now()}`); const argv = runAsArgv(osUser!, ['sh', '-c', `: > "$1"`, '_', probe]); const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' }); expect(await proc.exited).toBe(0); const owner = statSync(probe).uid; rmSync(probe, { force: true }); expect(owner).toBeGreaterThanOrEqual(1000); expect(owner).not.toBe(process.getuid?.()); }); 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); });