// #1873 CB3 live acceptance: a bound copy's launch gate against a scratch gitea/gitea:1.27.1, a // throwaway container on 127.0.0.1 over TLS with a scratch CA, removed after. The boot proves the // slot live, a launch carries its authority to the bound session, one checked call reads the bound // repository, and a reissue denies the next call before any request. The test never pulls an image; // without Docker or the image it skips. Every token here is a scratch token this container made; // none is printed, and nothing here reaches git.mosaicstack.dev or any real service. import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash, randomBytes } from 'node:crypto'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { request } from 'node:https'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { Broker, BusError } from '../src/broker.mjs'; import { Store } from '../src/store.mjs'; import { Credentials } from '../src/credentials.mjs'; import { copyGrant } from '../src/copy-grant.mjs'; import { copyLaunchGate } from '../src/copy-launch.mjs'; import { send } from '../src/gitea-grant.mjs'; import { hireDecisions } from '../../organization/src/hire-decisions.mjs'; import { organizationWriter } from '../../organization/src/organization.mjs'; import { checkRootAgents } from '../../organization/src/agent-levels.mjs'; import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs'; import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs'; import { copyBotLogin } from '../../organization/src/copy-bindings.mjs'; import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs'; import { scratchCa } from '../../seat/tests/fake-gitea.mjs'; const IMAGE = 'gitea/gitea:1.27.1'; const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' }; const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10); const sha = (s) => createHash('sha256').update(s).digest('hex'); const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 }); const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`; const freePort = () => new Promise((resolve, reject) => { const s = createServer(); s.once('error', reject); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); }); // One HTTPS call to the scratch container as its administrator, trusting only the scratch CA. function call(url, ca, method, path, { auth = null, body = null } = {}) { return new Promise((resolve) => { const data = body ? JSON.stringify(body) : null; const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) }; const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => { let text = ''; res.on('data', (c) => (text += c)); res.on('end', () => { let json = null; try { json = text ? JSON.parse(text) : null; } catch {} resolve({ status: res.statusCode, json }); }); }); req.on('timeout', () => req.destroy()); req.on('error', () => resolve({ status: 0, json: null })); req.end(data ?? undefined); }); } test('CB3-D: on scratch 1.27.1 a bound copy boots proved, launches with its authority, reads only its repository, refuses a redirect, and a reissue denies the next call unsent', { skip, timeout: 300000 }, async (t) => { // The child's complete TAP output is captured before any of it reaches this runner, so the parent // can search it for the minted values without putting one in an assertion. if (!process.env.CB3_COPY_CAPTURE) { const capture = mkdtempSync(join(tmpdir(), 'cb3cap-')); t.after(() => rmSync(capture, { recursive: true, force: true })); const file = join(capture, 'minted'); const childEnv = { ...process.env, CB3_COPY_CAPTURE: file }; delete childEnv.NODE_TEST_CONTEXT; const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^CB3-D:', fileURLToPath(import.meta.url)], { encoding: 'utf8', timeout: 280000, env: childEnv, }); const values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : []; const output = (child.stdout ?? '') + (child.stderr ?? ''); assert.ok(values.every((value) => !output.includes(value) && !output.includes(sha(value))), 'complete TAP stdout/stderr contain no minted token'); t.diagnostic(output); t.diagnostic(`minted ${values.length}, hits 0 (complete TAP stdout/stderr)`); assert.equal(child.status, 0, 'Docker child passed'); assert.equal(values.length, 3, 'all three scratch tokens were minted: the acceptance actually ran'); return; } const dir = mkdtempSync(join(tmpdir(), 'cb3d-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const tlsDir = join(dir, 'tls'), tls = scratchCa(tlsDir); // The container's git user reads the scratch key; it is a throwaway key for this run only. chmodSync(tlsDir, 0o755); chmodSync(join(tlsDir, 'server.key'), 0o644); const ca = readFileSync(tls.ca, 'utf8'), port = await freePort(); const url = `https://127.0.0.1:${port}`, name = 'cb3-' + randomBytes(6).toString('hex'); t.after(() => docker('rm', '-f', name)); const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`, '-e', 'GITEA__security__INSTALL_LOCK=true', '-e', 'GITEA__database__DB_TYPE=sqlite3', '-e', 'GITEA__service__DISABLE_REGISTRATION=true', '-e', 'GITEA__server__PROTOCOL=https', '-e', 'GITEA__server__CERT_FILE=/certs/server.pem', '-e', 'GITEA__server__KEY_FILE=/certs/server.key', '-e', `GITEA__server__ROOT_URL=${url}/`, IMAGE); assert.equal(started.status, 0, 'scratch container starts'); const end = Date.now() + 120000; while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) { assert.ok(Date.now() < end, 'scratch Gitea answers in 120 seconds'); await sleep(500); } const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args); const minted = []; const createUser = (user, admin = false) => { const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`, '--password', randomBytes(16).toString('base64url'), '--must-change-password=false', ...(admin ? ['--admin'] : [])); assert.equal(r.status, 0, `create scratch ${user}`); }; const mint = (user, scopes) => { const r = gitea('admin', 'user', 'generate-access-token', '--username', user, '--token-name', 'cb3-' + randomBytes(4).toString('hex'), '--scopes', scopes.join(','), '--raw'); assert.equal(r.status, 0, `mint scratch ${user}`); const value = r.stdout.trim(); assert.ok(/^[0-9a-f]{40}$/.test(value), 'minted token shape'); minted.push(value); writeFileSync(process.env.CB3_COPY_CAPTURE, JSON.stringify(minted), { mode: 0o600 }); return value; }; createUser('scratch-admin', true); createUser('owner'); const admin = 'token ' + mint('scratch-admin', ['write:admin', 'write:repository', 'write:user']); for (const repo of ['stack', 'other']) { const made = await call(url, ca, 'POST', '/api/v1/admin/users/owner/repos', { auth: admin, body: { name: repo, auto_init: true, private: true } }); assert.equal(made.status, 201, `create owner/${repo}`); } // The organization: one root agent "rev" (research, Gitea alone) bound read-only to project stack. const root = mkdtempSync(join(tmpdir(), 'cb3o-')); t.after(() => rmSync(root, { recursive: true, force: true })); const rolesDir = rolesCopy(root); const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } }; doc.projects.stack.remote = `${url}/owner/stack`; writeJson(join(root, 'businesses/demo.json'), doc); const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: root, dataRoot, instance: 'test' }; await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo'); const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'research' } } }, { rolesDir }) }, rolesDir }); const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot); t.after(() => store.close()); const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => (snapshot.org === id ? snapshot : null) }); const storeRoot = join(root, 'secrets', 'agents'); mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700); const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' }); // Every request the gate sends, by op only. const sent = []; const transport = (req) => { sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); }; const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca, timeoutMs: 10000 }); let promoted = snapshot; const business = () => { const d = loadWithCopies('demo', { dir: root, rolesDir, snapshot: promoted }); return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions?.[e.definition], ...e }])) }; }; const issue = copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca, timeoutMs: 10000 }); const b = new Broker({ store, businesses: { demo: business() }, copyLaunch: gate, hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', business()); return r; } }, credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a), }); const consoleCap = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT }); const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const request_ = (cap, verb, args) => Promise.resolve().then(() => b.request(cap, { verb, args })); await request_(consoleCap, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy', binding: { services: READ } }); const rec = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8')).copyBindings.rev; const login = copyBotLogin(rec().orgIncarnation, rec().copyId, 'gitea'); createUser(login); const collaborate = (repo) => call(url, ca, 'PUT', `/api/v1/repos/owner/${repo}/collaborators/${login}`, { auth: admin, body: { permission: 'read' } }); assert.equal((await collaborate('stack')).status, 204, 'bot is a read collaborator on owner/stack'); const issueToken = (token) => request_(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token }); assert.equal((await issueToken(mint(login, ['read:repository', 'read:user']))).state, 'bound'); // Bound after this gate was made: restart-required until a boot proves it. await assert.rejects(gate.prepare('demo', 'rev'), { code: 'restart-required' }); assert.deepEqual(sent, []); await gate.boot('demo'); assert.deepEqual(gate.bound(), { rev: ['gitea'] }); assert.ok(sent.includes('GET /api/v1/token'), 'the boot proved the slot live at the service'); promoted = promoteCopies(snapshot, gate.bound()); b.reloadBusiness('demo', business()); // A launch: prepare proves the slot again, bindLaunch carries the authority to the session. const project = (await request_(human, 'project.create', { name: 'stack work', repo: 'stack' })).id; const thread = (await request_(human, 'thread.create', { project, name: 'one', instance: 'rev' })).id; const proofs = sent.length; const revision = writer.read('demo').revision; const { authority } = await gate.prepare('demo', 'rev', revision); assert.ok(sent.slice(proofs).includes('GET /api/v1/token')); const cap = b.bindLaunch({ business: 'demo', role: 'rev', run: 'rev-run', harness: 'pi', thread, copyAuthority: authority, revision }); const use = (req) => Promise.resolve().then(() => gate.call(b.copySession(cap), req)); const own = await use({ method: 'GET', path: '/api/v1/repos/owner/stack' }); assert.equal(own.status, 200); assert.equal(own.json.full_name, 'owner/stack'); // A repository outside the binding, and a write on a read binding: refused before any request. const before = sent.length; await assert.rejects(use({ method: 'GET', path: '/api/v1/repos/owner/other' }), { code: 'copy-request-refused' }); await assert.rejects(use({ method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } }), { code: 'copy-request-refused' }); assert.equal(sent.length, before); // A reissue (a new generation) while the session is live: the next call refuses, none is sent. await issueToken(mint(login, ['read:repository', 'read:user'])); assert.equal(rec().services.gitea.grantGeneration, 2); const afterIssue = sent.length; await assert.rejects(use({ method: 'GET', path: '/api/v1/repos/owner/stack' }), { code: 'restart-required' }); assert.equal(sent.length, afterIssue, 'no request after the reissue'); // Gitea answers a renamed repository's old path with a redirect. A fresh boot and launch on the // second token; the bound path now redirects, and the gate refuses it without following. const gate2 = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca, timeoutMs: 10000 }); await gate2.boot('demo'); assert.deepEqual(gate2.bound(), { rev: ['gitea'] }); const { authority: authority2 } = await gate2.prepare('demo', 'rev'); // The broker's session for the old launch is gone with its authority; this one is gate2's own. assert.throws(() => b.copySession(cap), { code: 'restart-required' }); const session = { business: 'demo', role: 'rev', copyAuthority: authority2 }; assert.equal((await gate2.call(session, { method: 'GET', path: '/api/v1/repos/owner/stack' })).status, 200); assert.equal((await call(url, ca, 'PATCH', '/api/v1/repos/owner/stack', { auth: admin, body: { name: 'moved' } })).status, 200, 'rename owner/stack'); const viaOld = await call(url, ca, 'GET', '/api/v1/repos/owner/stack', { auth: admin }); assert.equal(viaOld.status, 301, 'Gitea 1.27.1 redirects a renamed repository\'s old path'); const n = sent.length; await assert.rejects(gate2.call(session, { method: 'GET', path: '/api/v1/repos/owner/stack' }), { code: 'copy-redirect-refused' }); assert.equal(sent.length, n + 1, 'one request, never followed'); t.diagnostic(`minted ${minted.length}`); });