From bb031ec84040b10554b775dc0c1498aa5e650175 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 23 Oct 2026 06:44:24 -0500 Subject: [PATCH] launchpad: row 301 (#1808): R6-N4, user-bus.mjs folded into bus.mjs beside humanCall, unchanged; channel-proof re-pin held for Mos's go; unrun --- agents/launchpad/work/queue-301/BUILD.md | 36 +++++++++++ agents/launchpad/work/queue-301/DELETED.txt | 1 + .../launchpad/work/queue-301/MANIFEST.sha256 | 4 ++ packages/webui/src/bus.mjs | 58 +++++++++++++++++ packages/webui/src/cli.mjs | 3 +- packages/webui/src/console-bus.mjs | 2 +- packages/webui/src/user-bus.mjs | 63 ------------------- packages/webui/tests/granted-user.test.mjs | 3 +- 8 files changed, 102 insertions(+), 68 deletions(-) create mode 100644 agents/launchpad/work/queue-301/BUILD.md create mode 100644 agents/launchpad/work/queue-301/DELETED.txt create mode 100644 agents/launchpad/work/queue-301/MANIFEST.sha256 delete mode 100644 packages/webui/src/user-bus.mjs diff --git a/agents/launchpad/work/queue-301/BUILD.md b/agents/launchpad/work/queue-301/BUILD.md new file mode 100644 index 000000000..a5825f3ef --- /dev/null +++ b/agents/launchpad/work/queue-301/BUILD.md @@ -0,0 +1,36 @@ +# Row 301 build notes (Launchpad): R6-N4, fold user-bus.mjs back into bus.mjs (#1808) + +Brief: docs/plans/2026-10-23_r6-followup-rows.md § Brief: R6-N4. Reviewer Duckworth, gate owner Sage. +Base e551cd846 (refactor after row 303). Branch launchpad/row301. + +## Change + +- packages/webui/src/bus.mjs: the user transport moves in beside humanCall, after `busReader`: + `USER_CLI`, `userCall`, `busBusiness` and `userCaller`, byte for byte from user-bus.mjs (checked + with diff), under a three-line header. One import is added, `fileURLToPath` from node:url. + `readHostState`, `spawn`, `randomBytes` and `BusReadError` were already in bus.mjs. humanCall and + busCaller are untouched. +- packages/webui/src/user-bus.mjs: deleted. +- packages/webui/src/cli.mjs: the two imports become one, from './bus.mjs'. +- packages/webui/src/console-bus.mjs: a comment names bus.mjs instead of user-bus.mjs. +- packages/webui/tests/granted-user.test.mjs:22-23: the two imports become one, from '../src/bus.mjs'. + Only the import path changes. The brief says "update its imports", but this file is an old test, + so Sage should confirm the line counts as an import update and not as an assertion change. + +No other reference to user-bus.mjs remains under packages/. tools/agent-watch's "user-bus" means +something unrelated. The 294 packet's mutants name the old file as history only. + +## Re-pin held + +channel-proof.test.mjs:28 pins packages/webui/src/bus.mjs at 15e8123f…3313. After this change the +file hashes to 39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c. A pin change is an +old-test change, and the brief allows it only in a re-pin Mos approves. So this commit leaves the pin +alone, and channel-proof.test is red on it until then. The re-pin is one value plus a comment line +in the row 196/254/236 style. It goes in a second commit once Mos says go. + +## Gate + +Duckworth approves the manifest. channel-proof.test, the webui suite and the 294 user-transport +tests (granted-user.test.mjs) run whole and green, on Sage's CLEAR, after the re-pin go. Not run yet. + +MANIFEST.sha256 lists the four changed paths. DELETED.txt names the one removed path. diff --git a/agents/launchpad/work/queue-301/DELETED.txt b/agents/launchpad/work/queue-301/DELETED.txt new file mode 100644 index 000000000..40ce91b11 --- /dev/null +++ b/agents/launchpad/work/queue-301/DELETED.txt @@ -0,0 +1 @@ +deleted packages/webui/src/user-bus.mjs diff --git a/agents/launchpad/work/queue-301/MANIFEST.sha256 b/agents/launchpad/work/queue-301/MANIFEST.sha256 new file mode 100644 index 000000000..610950e1f --- /dev/null +++ b/agents/launchpad/work/queue-301/MANIFEST.sha256 @@ -0,0 +1,4 @@ +39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c packages/webui/src/bus.mjs +0862b2b5bf6511d2c5deca1f318eee643edaee489c0ce8c83e57364f2f54a3d8 packages/webui/src/cli.mjs +653d2087c0796a35c3884b7243d1adf8a78c52950a4924049317063f2d3c0974 packages/webui/src/console-bus.mjs +34b6d052855369c8f7cf1faf8bab3c59ddf96ccd4c42731bd86a4e61e5e70640 packages/webui/tests/granted-user.test.mjs diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs index c46a6ef50..b49cfb996 100644 --- a/packages/webui/src/bus.mjs +++ b/packages/webui/src/bus.mjs @@ -10,6 +10,7 @@ import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; import { HUMAN_CLI } from '../../cli/src/transport.mjs'; import { readHostState } from '../../cli/src/host.mjs'; import { views } from '../../bus/src/views.mjs'; @@ -72,6 +73,63 @@ export function busCaller({ dataRoot, socket, business = null, env = process.env } export const busReader = options => views({ call: busCaller(options) }); +// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Same +// protocol as humanCall: the request on stdin, JSON on stdout, or a bus error code as the last +// `^[a-z-]{1,64}$` line of stderr. Row 301 (#1808) moved it here from user-bus.mjs. +export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url)); + +// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the +// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child +// can never pass as human-cli, and it only ever runs USER_CLI. +export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) { + const { MOSAIC_BUS_CLI_NONCE, ...rest } = env; + return (verb, args = {}) => new Promise((resolve, reject) => { + let child; + try { + child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] }); + } catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); } + const out = [], err = []; + let size = 0, settled = false; + const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } }; + const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs); + child.stdout.on('data', chunk => { + size += chunk.length; + if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); } + out.push(chunk); + }); + child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); }); + child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start'))); + child.on('close', status => { + if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish')); + if (status !== 0) { + const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response'; + return done(reject, new BusReadError(code)); + } + try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); } + catch { done(reject, new BusReadError('invalid-response')); } + }); + child.stdin.on('error', () => {}); // A child that exits early reports through 'close'. + child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`); + }); +} + +// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does. +export function busBusiness({ dataRoot, business = null }) { + return () => { + if (business) return business; + let state; + try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); } + if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given'); + return state.business; + }; +} + +// One granted user's caller, for that user's session only. +export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) { + const which = busBusiness({ dataRoot, business }); + return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args); +} + // A send's error, sorted. The broker writes in one transaction, so any code it // returns means nothing was written. A transport that lost the answer, or an // answer that can't be read, leaves the outcome open: the message may exist. diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index f38e334ae..8a6c19125 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -2,8 +2,7 @@ // MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP. import { createChat } from './chat.mjs'; import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs'; -import { busCaller } from './bus.mjs'; -import { busBusiness, userCaller } from './user-bus.mjs'; +import { busCaller, busBusiness, userCaller } from './bus.mjs'; import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs'; import { consoleAccess } from './access.mjs'; import { accessContextFor } from '../../cli/src/access.mjs'; diff --git a/packages/webui/src/console-bus.mjs b/packages/webui/src/console-bus.mjs index a446370c2..3d310c1fe 100644 --- a/packages/webui/src/console-bus.mjs +++ b/packages/webui/src/console-bus.mjs @@ -1,6 +1,6 @@ // FED R6 (#1800): the Console's bus-backed options for startServer, built over one transport // `call`. cli.mjs builds them once over the human transport. A granted user's session gets the same -// set over its own user transport (user-bus.mjs userCaller), so no route a user reaches holds a +// set over its own user transport (bus.mjs userCaller), so no route a user reaches holds a // human-transport object, and the broker decides every verb the user sends (design 3.1 D4). import { createChat } from './chat.mjs'; import { views } from '../../bus/src/views.mjs'; diff --git a/packages/webui/src/user-bus.mjs b/packages/webui/src/user-bus.mjs deleted file mode 100644 index 29c6302bf..000000000 --- a/packages/webui/src/user-bus.mjs +++ /dev/null @@ -1,63 +0,0 @@ -// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Kept apart -// from bus.mjs so the human transport's reviewed bytes stay as row 164 froze them -// (bus/tests/channel-proof.test.mjs). Same protocol as humanCall: the request on stdin, JSON on -// stdout, or a bus error code as the last `^[a-z-]{1,64}$` line of stderr. -import { spawn } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; -import { fileURLToPath } from 'node:url'; -import { readHostState } from '../../cli/src/host.mjs'; -import { BusReadError } from './bus.mjs'; - -export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url)); - -// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the -// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child -// can never pass as human-cli, and it only ever runs USER_CLI. -export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) { - const { MOSAIC_BUS_CLI_NONCE, ...rest } = env; - return (verb, args = {}) => new Promise((resolve, reject) => { - let child; - try { - child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] }); - } catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); } - const out = [], err = []; - let size = 0, settled = false; - const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } }; - const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs); - child.stdout.on('data', chunk => { - size += chunk.length; - if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); } - out.push(chunk); - }); - child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); }); - child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start'))); - child.on('close', status => { - if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish')); - if (status !== 0) { - const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response'; - return done(reject, new BusReadError(code)); - } - try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); } - catch { done(reject, new BusReadError('invalid-response')); } - }); - child.stdin.on('error', () => {}); // A child that exits early reports through 'close'. - child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`); - }); -} - -// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does. -export function busBusiness({ dataRoot, business = null }) { - return () => { - if (business) return business; - let state; - try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); } - if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given'); - return state.business; - }; -} - -// One granted user's caller, for that user's session only. -export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) { - const which = busBusiness({ dataRoot, business }); - return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args); -} diff --git a/packages/webui/tests/granted-user.test.mjs b/packages/webui/tests/granted-user.test.mjs index 7c1a902af..6921f08c2 100644 --- a/packages/webui/tests/granted-user.test.mjs +++ b/packages/webui/tests/granted-user.test.mjs @@ -19,8 +19,7 @@ import { startServer, userRoute } from '../src/serve.mjs'; import { createSso, SESSION_COOKIE } from '../src/sso.mjs'; import { consoleAccess } from '../src/access.mjs'; import { consoleBus, userOptionsFor } from '../src/console-bus.mjs'; -import { BusReadError } from '../src/bus.mjs'; -import { USER_CLI, userCall } from '../src/user-bus.mjs'; +import { BusReadError, USER_CLI, userCall } from '../src/bus.mjs'; import { close, fixture } from './fixture.mjs'; import { browser } from './browser.mjs'; import { fakeOidc } from './helpers/fake-oidc.mjs';