Compare commits

...
5 changed files with 300 additions and 0 deletions

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
# Row 300 build notes (Launchpad): R6-N1, an HTTP-level test of the user-session option swap (#1807)
Brief: docs/plans/2026-10-23_r6-followup-rows.md § Brief: R6-N1. Reviewer Duckworth, gate owner Sage.
Base c13c337e1 (refactor, after row 294 landed at d3059c2c3). Branch launchpad/row300.
## Change
Tests only. One new file, packages/webui/tests/user-option-swap.test.mjs. No product change and no
existing assertion or fixture touched.
- **Gate stubbed open.** The file registers an in-thread load hook (`registerHooks`, as
thread-claims.test and library-cli.test do through `--import`) keyed on the one URL
`serve.mjs?gate=open`. On that URL alone it prepends `return true;` to `userRoute`'s body, then
imports `startServer` from it. Every other import of serve.mjs, and every relative import inside
the copy, is the real module. If the gate's signature line is not in serve.mjs exactly once, the
hook throws. A renamed gate then fails loudly, instead of quietly testing the closed gate. The
test also asserts that the copy's `userRoute` answers true for `POST /api/bus/message`.
- **Setup.** This is granted-user.test.mjs's setup, cut to what the test needs: the fake OP, the
in-process broker for `demo`, createSso with the access hook, and `userOptionsFor` over alice's
transport. Every owner option is a stub that records its own name. The owner transport records
`['transport', verb, args]`. One list, `owner`, holds both kinds of owner call.
- **Positive control.** The owner signs in and posts `/api/bus/message`. Exactly
`['option', 'send']` must be recorded, so the route reaches `send` and the count works.
- **The check.** Alice signs in and walks G4's owner routes, copied, plus two chat reads. No answer
may say `owner-only`, so the gate really is open. After each route, every owner record must be
`['transport', 'plugins', {}]`, the read mos-1394 allowed. The message route must reach the user
transport (`message.send`), and every transport opened is alice's.
Why a new file: granted-user.test.mjs's setup imports startServer statically. Passing the gate-open
copy into it would change that fixture, and a fixture change goes to Mos. The route list and setup
are copied, with a comment naming their source.
## Mutant
M12: serve.mjs:689 drops `send` from the user-session destructure, so a user session keeps the
owner's `send`. Killed when "R6-N1" reports not ok. Expected failure: on `POST /api/bus/message`,
the owner record holds `['option', 'send']`. run1/mutants.py applies it (anchor exactly once), runs
the new file alone, restores with git checkout and checks the tree is clean.
## Gate
Duckworth approves the manifest. On Sage's CLEAR: run1/gate.sh does npm ci (root and webui), the new
file whole, the webui suite whole, then M12. Logs go to /mnt/storage/scratch/launchpad-r300/run1,
outside the tree. Not run yet.
MANIFEST.sha256 lists the one new path.
## Run 1 (sage-3408 CLEAR, 12:05Z)
npm ci root and webui were green. The new file was red on its first run (mos-1272, placed by me):
`TypeError: Cannot read properties of undefined (reading 'set-cookie')` in signIn. When I cut down
granted-user.test.mjs's `call`, I dropped `headers: res.headers` from what it resolves, so the
sign-in found no cookies. The fix puts `headers` back, the same as granted-user.test.mjs:40. No
assertion changed. The new file then passed alone (1/0). The fix is a new commit, and the manifest
is re-hashed. The gate resumes at new-file on the new head.
Run 1 resumed (TAG -r2) on db5122920, manifest db8be52ce895603e687985acfae29e53e3e8782ae945c3cded6cca36141bfa3b:
- new file whole: 1 pass, 0 fail.
- webui suite whole: 375 pass, 0 fail, 0 skipped, 0 cancelled, 58.2 s (limit 600).
- M12 killed. "R6-N1" reports not ok at :151: `POST /api/bus/message: ["option","send"]`, where
`["transport","plugins",{}]` is expected. The restore left the tree clean.
Duckworth N1 (duckworth-0263, non-blocking): the setup passes no `upstream`, so the swap's
`upstream = null` has no recorder here. The test proves the swap for the options it stubs. It
proves nothing about `upstream`.
@@ -0,0 +1 @@
03b5d60f68ea2bddd779e1df19519855a5f01cc28ec9ddb26ee7c45fcfe75cf4 packages/webui/tests/user-option-swap.test.mjs
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Row 300 gate run 1 (brief R6-N1): npm ci (root and packages/webui), the new test file whole, the
# webui suite whole, then mutant M12. Stops at the first red. FROM=<step> resumes at a step.
set -u -o pipefail
WT=/mnt/storage/scratch/launchpad-r300/wt
OUT=$WT/agents/launchpad/work/queue-300/run1
LOG=/mnt/storage/scratch/launchpad-r300/run1
FROM=${FROM:-npm-root}; TAG=${TAG:-}
mkdir -p $LOG
export TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/gXXXXXX)
echo "start $(date -u +%H:%M:%SZ) FROM=$FROM TAG=$TAG TMPDIR $TMPDIR head $(git -C $WT rev-parse HEAD)" >> $LOG/summary.txt
[ -z "$(git -C $WT status --porcelain)" ] || { echo "STOP worktree not clean" | tee -a $LOG/summary.txt; exit 9; }
on=0
step() { # name limit dir cmd...
local name=$1 limit=$2 dir=$3; shift 3
[ $on = 1 ] || [ $name = $FROM ] || return 0; on=1
local t0=$(date +%s%N)
( cd $dir && timeout -k 10 $limit "$@" ) > $LOG/$name$TAG.log 2>&1
local rc=$?
local ms=$(( ($(date +%s%N) - t0) / 1000000 )); local dt=$((ms/1000)).$(printf %03d $((ms%1000)))
echo "$name$TAG rc=$rc seconds=$dt limit=$limit $(date -u +%H:%M:%SZ)" | tee -a $LOG/summary.txt
[ $rc -eq 0 ] || { echo "STOP at $name$TAG" | tee -a $LOG/summary.txt; exit $rc; }
}
step npm-root 900 $WT npm ci --ignore-scripts
step npm-webui 900 $WT/packages/webui npm ci --ignore-scripts
step new-file 300 $WT/packages/webui node --test tests/user-option-swap.test.mjs
step webui 600 $WT/packages/webui node --test tests/
[ -z "$(git -C $WT status --porcelain)" ] || { echo "STOP worktree dirty after the suite" | tee -a $LOG/summary.txt; git -C $WT status --porcelain >> $LOG/summary.txt; exit 9; }
echo "SUITES GREEN $(date -u +%H:%M:%SZ)" | tee -a $LOG/summary.txt
python3 -B $OUT/mutants.py
rc=$?; echo "MUTANTS rc=$rc $(date -u +%H:%M:%SZ)" | tee -a $LOG/summary.txt; exit $rc
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
# Row 300 mutant M12 (BUILD.md "Mutant"): serve.mjs keeps the owner's send for a user session. The
# edit must match exactly once; the new file runs alone under timeout -k 10 300 with the TAP
# reporter; git checkout restores and the tree must be clean. Killed = "R6-N1" reports "not ok".
import json, os, re, subprocess, sys, time
WT = '/mnt/storage/scratch/launchpad-r300/wt'
LOG = '/mnt/storage/scratch/launchpad-r300/run1'
SWAP = "({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));"
MUTANTS = [
('M12', 'packages/webui/src/serve.mjs', SWAP,
"({ bus, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));",
('packages/webui', 'tests/user-option-swap.test.mjs'), 'R6-N1'),
]
def git(*a):
return subprocess.run(['git', '-C', WT, *a], capture_output=True, text=True, check=True).stdout
def main():
if git('status', '--porcelain'): raise SystemExit('worktree not clean at start')
summary, bad = [], 0
for mid, path, old, new, (pkg, test), killer in MUTANTS:
p = os.path.join(WT, path)
try:
s = open(p).read()
if s.count(old) != 1: raise SystemExit(f'{mid}: anchor count {s.count(old)} in {path}')
open(p, 'w').write(s.replace(old, new))
diff = git('diff')
t0 = time.time()
r = subprocess.run(['timeout', '-k', '10', '300', 'node', '--test', '--test-reporter=tap', test],
cwd=os.path.join(WT, pkg), capture_output=True, text=True)
dt = round(time.time() - t0, 1)
finally:
git('checkout', '--', path)
if git('status', '--porcelain'): raise SystemExit('worktree not clean after restore')
open(f'{LOG}/{mid}.diff', 'w').write(diff)
open(f'{LOG}/{mid}.tap', 'w').write(r.stdout + '\n# stderr\n' + r.stderr)
res = {m.group(2): m.group(1) == 'ok' for m in re.finditer(r'^(ok|not ok) \d+ - (.*)$', r.stdout, re.M)}
hit = [n for n in res if n.startswith(killer)]
killed = r.returncode not in (0, 124, 137) and bool(hit) and all(not res[n] for n in hit)
bad += not killed
print(f'{mid} rc={r.returncode} seconds={dt} killed={killed} red={[n for n, ok in res.items() if not ok]}', flush=True)
summary.append({'mutant': mid, 'rc': r.returncode, 'seconds': dt, 'killed': killed, 'killer': killer})
json.dump(summary, open(f'{LOG}/mutants.json', 'w'), indent=1)
sys.exit(1 if bad else 0)
main()
@@ -0,0 +1,155 @@
// R6-N1 (#1807, row 294 N1): the user-session option swap in serve.mjs, driven through HTTP.
// granted-user.test.mjs G4 stops every owner route at the userRoute gate, so it never reaches the
// swap behind it. Here serve.mjs loads with that gate stubbed open (a load hook keyed on one query
// string; the product file is unchanged), and every owner route a user session might try must reach
// no owner option. The owner transport may carry only the plugins read mos-1394 allowed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { registerHooks } from 'node:module';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
import { consoleAccess } from '../src/access.mjs';
import { userOptionsFor } from '../src/console-bus.mjs';
import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.mjs';
import { fakeOidc } from './helpers/fake-oidc.mjs';
const SERVE = new URL('../src/serve.mjs', import.meta.url).href, OPEN = `${SERVE}?gate=open`;
const GATE = 'export function userRoute(path, method) {';
registerHooks({
load(url, context, next) {
const r = next(url, context);
if (url !== OPEN) return r;
const source = String(r.source);
// A renamed gate fails here rather than leaving the test on the closed gate.
if (source.split(GATE).length !== 2) throw new Error('serve.mjs: the userRoute gate is not there exactly once');
return { ...r, source: source.replace(GATE, `${GATE} return true;`) };
},
});
const { startServer, userRoute } = await import(OPEN);
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
function call(port, { method = 'GET', path = '/', headers = {}, body } = {}) {
return new Promise((resolve, reject) => {
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method, path, headers: { host: HOST, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text }));
});
req.on('error', reject);
req.end(body);
});
}
const setCookies = r => [r.headers['set-cookie'] ?? []].flat();
const writeHeaders = cookie => ({ cookie, origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
// As granted-user.test.mjs setup: the fake OP, a broker for `demo`, createSso with the access hook,
// counting owner stubs, and consoleBus over alice's transport for her session. Only startServer
// differs: it is the gate-open copy.
async function setup(t) {
const op = await fakeOidc();
t.after(() => op.close());
const iss = op.issuer('a');
const root = mkdtempSync(join(tmpdir(), 'webui-option-swap-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const doc = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
const load = () => doc;
const broker = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8')).messaging } },
},
},
access: load,
});
const owner = [], user = [], opened = [];
const userTransport = name => async (verb, args = {}) => {
user.push(verb);
try { return broker.request(broker.bindUser({ business: 'demo', user: name }), { verb, args }); }
catch (e) { throw new BusReadError(e.code ?? 'invalid-response'); }
};
const ownerCall = async (verb, args) => { owner.push(['transport', verb, args]); return { plugins: { channel: ['webui'] } }; };
const userOptions = userOptionsFor({ owner: ownerCall, send: true, transport: name => { opened.push(name); return userTransport(name); } });
const lines = [];
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) });
t.after(() => sso.close());
// Every owner option is a stub that records its own name.
const stub = name => async () => { owner.push(['option', name]); return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; };
const options = {
bus: Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'names', 'instances', 'routes', 'thread.claims'].map(v => [v, stub(`bus.${v}`)])),
send: stub('send'), settingsWrite: stub('settingsWrite'), projectFiles: stub('projectFiles'), projectFile: stub('projectFile'), projectSave: stub('projectSave'),
nameWrite: stub('nameWrite'), assistantStop: stub('assistantStop'), instancesWrite: stub('instancesWrite'), routesWrite: stub('routesWrite'), agentWrite: stub('agentWrite'),
processing: { read: stub('processing.read'), halt: stub('processing.halt'), resume: stub('processing.resume') },
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(`reads.${v}`)])),
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(`chat.${v}`)])),
};
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log: l => lines.push(l), ...options, userOptions });
t.after(() => close(web));
return { op, iss, owner, user, opened, lines, port: web.address().port };
}
async function signIn(env, sub) {
const start = await call(env.port, { path: '/auth/login' });
assert.equal(start.status, 303);
const cookie = setCookies(start)[0].split(';')[0];
const location = start.headers.location;
const g = env.op.grant(env.iss, location, { sub });
const q = new URLSearchParams({ code: g.code, state: new URL(location).searchParams.get('state') });
const r = await call(env.port, { path: `/auth/callback?${q}`, headers: { cookie } });
const session = setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0];
assert.ok(session, `${sub}: no session cookie (${r.status})`);
return session;
}
// granted-user.test.mjs G4's owner routes, plus the chat routes a user session reaches anyway.
const ROUTES = [
['GET', '/api/bus/inbox'], ['GET', '/api/bus/tasks'], ['GET', '/api/bus/names'], ['GET', '/api/bus/thread.claims'],
['POST', '/api/bus/message', { to: 'coder', body: 'hi' }],
['GET', '/api/settings'], ['POST', '/api/settings', { revision: 'x' }],
['POST', '/api/name', { role: 'assistant', name: 'x' }], ['POST', '/api/assistant/stop', {}],
['POST', '/api/instances', { instance: 'x' }], ['POST', '/api/routes', { routes: [] }],
['POST', '/api/agent/launch', { instance: 'x' }], ['GET', '/api/agent/request?id=x'],
['GET', '/api/processing'], ['POST', '/api/processing/halt', {}],
['GET', '/api/project-files?project=p'], ['GET', '/api/project-file?project=p&path=a'], ['POST', '/api/project-file', { project: 'p' }],
['GET', '/api/board'], ['GET', '/api/conversations'], ['POST', '/api/seen', {}], ['POST', '/api/reply', {}],
['GET', '/api/queue'], ['GET', '/api/queue/1'], ['GET', '/api/business'], ['GET', '/api/bus-status'], ['GET', '/api/task-fields'],
['GET', '/api/chat/projects'], ['GET', '/api/chat/instances'],
['GET', '/app.js'], ['GET', '/bus.js'], ['GET', '/agents.js'], ['GET', '/editor.js'], ['GET', '/shared/app.css'], ['GET', '/index.html'],
];
const go = (env, session, [method, path, value]) => call(env.port, { method, path, headers: value === undefined ? { cookie: session } : writeHeaders(session), body: value === undefined ? undefined : JSON.stringify(value) });
test('R6-N1 with the userRoute gate open, a user session reaches no owner option over HTTP', async t => {
// The stub took: the gate-open copy lets an owner route through.
assert.equal(userRoute('/api/bus/message', 'POST'), true);
const env = await setup(t);
// Positive control: the owner's session reaches the owner's send on this route.
const ownerSession = await signIn(env, 'owner-sub');
const sent = await go(env, ownerSession, ['POST', '/api/bus/message', { to: 'coder', body: 'hi' }]);
assert.deepEqual(env.owner, [['option', 'send']], `the owner's send did not run: ${sent.status} ${sent.body}`);
env.owner.length = 0;
const session = await signIn(env, 'alice-sub');
for (const route of ROUTES) {
const r = await go(env, session, route);
assert.doesNotMatch(r.body, /owner-only/, `${route[0]} ${route[1]}: the gate is still closed`);
// Only the plugins read with {}, as granted-user.test.mjs G9 pins for the chat.
for (const entry of env.owner) assert.deepEqual(entry, ['transport', 'plugins', {}], `${route[0]} ${route[1]}: ${JSON.stringify(entry)}`);
}
assert.ok(env.user.includes('message.send'), 'the message route never reached the user transport');
assert.ok(env.opened.length > 0 && env.opened.every(name => name === 'alice'));
});