row 196: human boundary on peer credentials, interpreter receipt and the Console tester socket (#1694)

Candidate 527b9a1e6 (gander/row196-r6-prd19, base f48725cfc), the 39 manifest paths of CANDIDATE.sha256 537d30aef70b6cd1 at ec1e7fad8. Duckworth approved round 1 outside B1/B2 (rev 2973, comment 30145) and round 2 merge-scoped (rev 2986, comment 30156). Gate gander-0066 on grant sage-2145: 2334 pass, 0 fail, 1 skip; mutants 38/38 killed. BUILD-LOG lines and Duckworth's C1 DEFERRED entry follow as a doc-only append.
This commit is contained in:
jason.woltje committed 2026-10-21 15:55:39 -05:00
1 parent 6661732925
commit 7247d80c7a
39 files changed
+1133 -183

No files matched your search

+46 -29
View File
@@ -465,7 +465,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-socket | --tester-port N (legacy copies)]
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
@@ -562,7 +562,8 @@ refuses `capture-failed` before it stops anything. Adopt uses the same rule.
The Console's row 178 proof pair gets into the pointer only through `activate`
(row 187). `--proof-header NAME --proof-file PATH` puts the pair in the
pointer's Console flags and the unit after the preview flags and before `--send`
and `--tester-port`, where `webui-service.sh` renders it, replacing any pair
and the tester flag (`--tester-socket`, or `--tester-port` for legacy copies),
where `webui-service.sh` renders it, replacing any pair
already there. `--no-proof` drops it. Either may name the live copy, which a
plain activate refuses with `already-active`, so the proof goes on or off
without changing copies; `--no-proof` with no pair in the pointer still refuses
@@ -692,6 +693,12 @@ backup's checks it prints `BACKUP_STORE <activation id> schema <version>`.
it never picks another port. `--timeout` (default 60 seconds) bounds the
gate and each stop.
A copy marked for the private tester receives `--tester-socket` during rehearsal;
a legacy copy receives no tester flag. The socket is under the rehearsal's
scratch dataRoot, at `data/console/tester.sock`. That path is longer than the live
one. A long dataRoot can pass activation's pathname preflight and still make the
rehearsal Console refuse its socket path; the rehearsal then fails closed.
Before it makes anything, it checks the pointer, the copy (present,
reachable from refactor, a readable schema, a bus host with `--no-launch`),
the port and the backup's `SHA256SUMS`. Then it makes
@@ -1264,36 +1271,46 @@ not claim an assistant answer; an uncertain receipt is never retried. Read the
current list/history before trying again. Activation still needs Mos's separate
window; changing these files does not authorize a live Console action.
### Console tester listener (`serve --tester-port N`, row 176)
### Console tester listener (`serve --tester-socket`, row 196)
`node packages/webui/src/cli.mjs serve --tester-port N` adds a second listener
bound only to `127.0.0.1:N`. N must be an integer in 1024..65535, different
from `--port` and never 7332 (the tunnel's forwarded port). It requires the
bus configuration. It calls only the tester shim; the main listener keeps its
existing human transport and rejects every `x-mosaic-tester` request.
The tester listener rejects the main preview auth header and only accepts its
own loopback Host/port. Its Settings banner identifies the tester. Its instance,
route and name editors use the tester's three writes; settings, document saves,
assistant stop, role messages and chat writes stay refused. Writes require the
exact `http://127.0.0.1:N` Origin, same-origin Fetch Metadata, JSON and the existing
bounded strict bodies. `tester-off` and `tester-required` answer HTTP 403.
`node packages/webui/src/cli.mjs serve --tester-socket` adds private HTTP at
`<dataRoot>/console/tester.sock` (0600) under console/ (0700). The path must fit
107 bytes. Unsafe ownership, modes, symlinks and pre-existing entries refuse.
The main Console remains on its configured TCP port. New copies refuse --tester-port.
`scripts/webui-service.sh render|install --tester-port N` carries the flag into
ExecStart, and status names its URL. Bad tester ports exit 4. Install remains a
unit write for the authorized operator, with no start/restart verb.
The tester's fixed Host is mosaic-tester.invalid and its Origin is
http://mosaic-tester.invalid. No DNS is used. POST also needs same-origin Fetch
Metadata and bounded JSON. The endpoint uses only TESTER_CLI. It admits the
existing instance, route, name and tester-chat writes, never owner settings,
document saves, assistant stop or role messages. bus.tester stays Jason's switch.
Authorization, login/session cookies and configured proxy headers refuse.
`stack-release.sh activate ID BUSINESS --tester-port N` replaces or adds this
Console flag in both the installed unit and the pointer. It checks the target
copy's own Console before stopping anything; an explicit unsupported request
refuses `tester-port-target`. A port outside 1024 to 65535, 7332 or the
Console's `--port` is a usage error (exit 4). Under `--plan` both are listed
with the plan's other refusals instead, the bad port as `tester-port-invalid`,
and the plan exits 3. Without an explicit flag, activate and rollback
keep it between supporting copies, or drop it when the target predates support.
The plan prints the final Console flags and any dropped `--tester-port N`;
the steps log records a drop too. `pm-on` keeps the pointer's Console flags.
These operations retain their existing Mos/Jason window authority. Nothing in
this row forwards the tester port through the tunnel or changes live units.
```sh
node packages/cli/src/tester-http.mjs GET /api/bus/agents
printf '%s' '{"name":"fixture"}' | node packages/cli/src/tester-http.mjs POST /api/chat/projects
```
The helper loads the selected instance config and reports HTTP status/text/body
as JSON, exiting 2 for refusal. It never retries writes. Direct browser access
is removed with Mos's acceptance (mos-1015). A future authenticated browser relay
behind SSO is a separate row. No TCP compatibility bridge is provided.
`webui-service.sh render|install --tester-socket` checks target support and carries
the flag into ExecStart. `stack-release.sh activate ID BUSINESS --tester-socket`
explicitly replaces an inherited --tester-port pair in both unit and pointer.
Without explicit conversion a new target refuses tester-transport-migration before
stopping anything. Unsupported targets refuse tester-socket-target. Rollback drops
unsupported socket enablement, visibly in plan/steps, without enabling TCP. Legacy
flags remain supported by the release tool for legacy target copies only. Failed
activation recovery can restore the previous TCP unit and its old limits.
Activation of a new copy preflights the pinned root-owned system Python ELF,
records its resolved path/version/identity/hash and checks the bus's fresh startup
receipt. Restarts revalidate and append actual interpreter identity to
`<dataRoot>/bus/peercred-starts.jsonl`, without comparing an old activation hash.
After an OS interpreter update, the next authorized re-pin uses a different reviewed
copy and refreshes activation evidence. Do not bypass already-active or use a PATH shim.
All live operations keep their existing window authority.
Console proxy proof (row 178, #1676): `serve`, `webui-service.sh render` and
`install` accept `--proof-header NAME --proof-file PATH` together, under preview
+35 -2
View File
@@ -9,12 +9,14 @@ returned to a client or written to the database.
Slice 1 shares an OS user. Files, capabilities, process ancestry checks and
hooks are **not a wall against a hostile same-UID process**. In particular,
Linux `/proc` checks here are not SO_PEERCRED authentication. The trusted host
The ancestry walk remains cooperative. Human/tester/user proof envelopes are now
bound to the connecting PID and uid using Linux SO_PEERCRED before dispatch. The trusted host
and well-behaved clients must follow this protocol. Containers remain later work.
## Start and stop
Requires Linux and Node 24 or 26, local disk, no dependencies. The data root must
Requires Linux, Node 24 or 26, local disk and system Python >=3.10. The bus pins
the root-owned ELF resolved from /usr/bin/python3, never a PATH shim or home binary. The data root must
already exist and belong to the user. A broker creates `bus/` mode 0700 and the
DB/lock/socket mode 0600. It refuses symlinks or unsafe existing permissions.
@@ -902,3 +904,34 @@ on the session and written on each usage record; a pi run's is null. The
account stays out of `session.launched`'s body. `mayLaunch` and `mustStop`
pass it to the limit checks in the subject. A session can edit its own transcript: this is a best reading, not proof.
No enforcement or resets are added. Settings reads through the same bus view.
## Human boundary (row 196)
A bounded Python stdlib helper receives only the accepted Unix socket fd and
returns pid, uid and gid. All three are validated as safe integers. Only pid and
uid select a principal. It runs for proof envelopes only, never cap traffic, with
a limit of 16 helpers, a one-second timeout and 256-byte result. Proof PID must
equal kernel peer PID. Human/user proofs retain the full agent ancestry walk.
Tester proofs deliberately admit agent seats and still refuse managed runs.
The trusted verifyPrincipal interface returns {kind,business,id} for one request.
User provenance requires the same-tree Console as direct parent and a distinct
user nonce. R3 supplies user mapping and checks the live grant on every request.
No role is accepted from a proof or cached. Caps are not returned to proof clients.
Every restart repeats interpreter safety checks and appends a 0600 startup receipt
to bus/peercred-starts.jsonl before listening. The receipt names the actual current
interpreter and copy. A safe OS replacement is accepted without comparing an old
activation hash. Unsafe interpreter or failed receipt append refuses startup.
Original activation evidence is never rewritten. The next authorized re-pin to a
different reviewed copy refreshes activation evidence. The worker Containerfile
does not run the bus and needs no Python change. Any future bus image must provide
this dependency or use a separately reviewed compiled helper.
Hostile processes on the instance uid remain outside this guarantee. In particular,
a detached agent can erase visible ancestry, and a same-uid process can tamper with
files, pipes or process memory where OS policy permits. Separate instance uids
narrow cross-instance reach, not reach within one instance.
The Console provenance option trusts verified code and process metadata, not an independent
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust.
+17
View File
@@ -0,0 +1,17 @@
import { connect } from 'node:net';
process.send({pid:process.pid});
process.once('message', ({path,claimed}) => {
const s = connect(path);
s.on('error', () => process.exit(2));
let text = '';
s.on('connect', () => s.write(JSON.stringify({human:{pid:claimed ?? process.pid,startTime:'test',nonce:'a'.repeat(64),business:'demo'},verb:'agents'})+'\n'));
s.on('data', data => {
text += data;
if (!text.includes('\n')) return;
const reply = JSON.parse(text);
s.write('after-helper\n');
s.end();
process.send({reply});
process.disconnect();
});
});
+3 -3
View File
@@ -31,7 +31,7 @@ export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } =
for (const entry of environment.split('\0')) {
const i = entry.indexOf('=');
const key = entry.slice(0, i);
if (key === 'MOSAIC_BUS_CLI_NONCE' || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
if (['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE'].includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
}
return {
pid,
@@ -49,7 +49,7 @@ export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } =
}
// Cooperative same-UID process checks, not peer credentials or a hostile-process wall.
// A nonce in the CLI's launch environment binds the submitted PID to a live CLI.
export function verifyHuman(proof, { cliPath, launches = [], readProcess: read = readProcess }) {
export function verifyHuman(proof, { cliPath, launches = [], readProcess: read = readProcess, nonceKey = 'MOSAIC_BUS_CLI_NONCE' }) {
if (
!proof ||
typeof proof !== 'object' ||
@@ -66,7 +66,7 @@ export function verifyHuman(proof, { cliPath, launches = [], readProcess: read =
if (
p.uid !== process.getuid() ||
p.startTime !== proof.startTime ||
p.env?.MOSAIC_BUS_CLI_NONCE !== proof.nonce ||
p.env?.[nonceKey] !== proof.nonce ||
p.argv[1] !== resolve(cliPath)
)
refuse();
+67
View File
@@ -0,0 +1,67 @@
import { realpathSync, statSync, readFileSync, openSync, fstatSync, writeSync, fsyncSync, closeSync, constants } from 'node:fs';
import { dirname, join, sep } from 'node:path';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
const systemPython = '/usr/bin/python3';
const defaultIO = { realpath: realpathSync, stat: statSync, readFile: readFileSync, exec: execFileSync };
const below = (path, parent) => path === parent || path.startsWith(parent + sep);
// Trusted dependency injection is for offline tests, never a CLI/env interpreter override.
export function inspectInterpreter({ io = defaultIO } = {}) {
try {
const path = io.realpath(systemPython);
const homes = ['/home', '/root', ...String(io.readFile('/etc/passwd', 'utf8')).split('\n')
.map(line => line.split(':')[5]).filter(p => p?.startsWith('/') && p !== '/')];
if (!path.startsWith('/') || homes.some(home => below(path, home))) throw Error();
// Check both the system entry's parent and every resolved component. /usr/bin/python3
// may be a system symlink; its containing directory and resolved target must be safe.
for (const start of [dirname(systemPython), path]) {
for (let at = start;; at = dirname(at)) {
const s = io.stat(at);
if (s.uid !== 0 || (s.mode & 0o022) || (at === path ? !s.isFile() : !s.isDirectory())) throw Error();
if (at === '/') break;
}
}
const data = io.readFile(path);
if (!Buffer.isBuffer(data) || !data.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70]))) throw Error();
const version = JSON.parse(String(io.exec(path, ['-I', '-S', '-c', 'import json,sys,socket;assert hasattr(socket,"SO_PEERCRED");print(json.dumps(list(sys.version_info[:3])))'],
{ env: {}, timeout: 1000, maxBuffer: 256, encoding: 'utf8' })));
if (!Array.isArray(version) || version.length !== 3 || !version.every(Number.isSafeInteger) ||
version[0] < 3 || (version[0] === 3 && version[1] < 10)) throw Error();
const s = io.stat(path);
return Object.freeze({ path, version: Object.freeze(version), dev: s.dev, ino: s.ino,
sha256: createHash('sha256').update(data).digest('hex') });
} catch { throw Error('interpreter-unsafe'); }
}
export function verifyInterpreterReceipt(directory, expected, since) {
let fd;
try {
fd = openSync(join(directory, 'peercred-starts.jsonl'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const s = fstatSync(fd);
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o077) || s.nlink !== 1) throw Error();
const text = readFileSync(fd, 'utf8');
if (!text.endsWith('\n')) throw Error();
const actual = JSON.parse(text.trimEnd().split('\n').at(-1));
if (actual.copy !== new URL('../../../', import.meta.url).pathname || !(Date.parse(actual.at) >= Number(since)) ||
Date.parse(actual.at) > Date.now() + 1000 || ['path', 'sha256', 'dev', 'ino', 'version'].some(k => JSON.stringify(actual[k]) !== JSON.stringify(expected[k]))) throw Error();
return true;
} catch { throw Error('interpreter-receipt-mismatch'); }
finally { if (fd !== undefined) closeSync(fd); }
}
export function recordInterpreterStart(directory, record) {
let fd;
try {
const dir = statSync(directory);
if (!dir.isDirectory() || dir.uid !== process.getuid() || (dir.mode & 0o077)) throw Error();
fd = openSync(join(directory, 'peercred-starts.jsonl'),
constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
const s = fstatSync(fd);
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o077) || s.nlink !== 1) throw Error();
const line = Buffer.from(JSON.stringify({ at: new Date().toISOString(), copy: new URL('../../../', import.meta.url).pathname, ...record }) + '\n');
if (writeSync(fd, line) !== line.length) throw Error();
fsyncSync(fd);
} catch { throw Error('interpreter-receipt-unsafe'); }
finally { if (fd !== undefined) closeSync(fd); }
}
+58
View File
@@ -0,0 +1,58 @@
import { spawn as nodeSpawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { lstatSync } from 'node:fs';
import { BusError } from './broker.mjs';
const helper = fileURLToPath(new URL('./peercred.py', import.meta.url));
const refused = () => new BusError('peercred-required');
export function createPeerReader({ interpreter, spawn = nodeSpawn, timeout = 1000, stat = lstatSync } = {}) {
try { const s = stat(helper); if (!s.isFile() || s.isSymbolicLink()) throw Error(); }
catch { throw refused(); }
const children = new Set();
let closed = false;
return {
read(socket) {
if (closed || children.size >= 16) return Promise.reject(refused());
return new Promise((resolve, reject) => {
let child, timer, output = '', settled = false, invalid = false;
const finish = (error, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
error ? reject(refused()) : resolve(value);
};
try {
// Keep this connection's Socket in this closure. Never borrow another pending fd.
child = spawn(interpreter.path, ['-I', '-S', helper],
{ env: {}, stdio: ['ignore', 'pipe', 'pipe', socket] });
children.add(child);
timer = setTimeout(() => { child.kill('SIGKILL'); finish(true); }, timeout);
child.stdout.on('data', data => {
output += data.toString('utf8');
if (Buffer.byteLength(output) > 256) { invalid = true; child.kill('SIGKILL'); finish(true); }
});
child.stderr.on('data', () => { invalid = true; });
child.on('error', () => { children.delete(child); finish(true); });
child.on('close', (code, signal) => {
children.delete(child);
if (settled) return;
try {
if (closed || invalid || code !== 0 || signal) throw Error();
const p = JSON.parse(output);
if (!p || Array.isArray(p) || Object.keys(p).sort().join(',') !== 'gid,pid,uid' ||
!['pid', 'uid', 'gid'].every(k => Number.isSafeInteger(p[k])) || p.pid < 2 || p.uid < 0 || p.gid < 0) throw Error();
finish(false, Object.freeze(p));
} catch { finish(true); }
});
} catch { finish(true); }
});
},
async close() {
closed = true;
await Promise.all([...children].map(child => new Promise(resolve => {
child.once('close', resolve);
child.kill('SIGKILL');
})));
},
};
}
+14
View File
@@ -0,0 +1,14 @@
"""Read only kernel credentials from an inherited accepted AF_UNIX stream."""
import json
import socket
import struct
import sys
try:
with socket.socket(fileno=3) as peer:
if peer.family != socket.AF_UNIX or peer.type != socket.SOCK_STREAM:
raise ValueError()
pid, uid, gid = struct.unpack("3i", peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
print(json.dumps({"pid": pid, "uid": uid, "gid": gid}, separators=(",", ":")))
except Exception:
sys.exit(2)
+44
View File
@@ -0,0 +1,44 @@
import { resolve } from 'node:path';
import { BusError } from './broker.mjs';
import { verifyHuman, readProcess as defaultRead } from './human.mjs';
import { verifyTester, testerNonce } from './tester.mjs';
// Trusted transport API. Identity is held only by this connection's transient session.
// User mapping and live grant authorization belong to R3, not to this verifier.
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce }) {
const kinds = ['human', 'tester', 'user'].filter(k => request?.[k]);
if (kinds.length !== 1 || request.cap) throw new BusError('invalid-envelope');
const kind = kinds[0], proof = request[kind];
const error = kind === 'tester' ? 'tester-required' : 'human-required';
const refuse = () => { throw new BusError(error); };
if (!peer || peer.uid !== process.getuid() || peer.pid !== proof.pid) refuse();
if (kind === 'tester') {
const { business } = verifyTester(proof, { cliPath: paths.tester, launches, readProcess, readNonce: readTesterNonce });
return { kind: 'tester', business, id: 'tester' };
}
let checked = proof, parent;
if (kind === 'user') {
if (!proof || typeof proof.user !== 'string' || !proof.user ||
Object.keys(proof).some(k => !['pid', 'startTime', 'nonce', 'business', 'user'].includes(k))) refuse();
const { user, ...rest } = proof;
checked = rest;
try {
const child = readProcess(proof.pid);
parent = readProcess(child.ppid);
if (parent.uid !== peer.uid || parent.argv[1] !== resolve(paths.console) || parent.argv[2] !== 'serve') refuse();
} catch { refuse(); }
}
const { business } = verifyHuman(checked, { cliPath: paths[kind], launches, readProcess,
nonceKey: kind === 'user' ? 'MOSAIC_BUS_USER_NONCE' : 'MOSAIC_BUS_CLI_NONCE' });
if (kind === 'user') {
try {
const child = readProcess(proof.pid), again = readProcess(parent.pid);
if (child.ppid !== parent.pid || again.startTime !== parent.startTime || again.ppid !== parent.ppid ||
again.uid !== parent.uid || again.argv[1] !== parent.argv[1] || again.argv[2] !== 'serve') refuse();
} catch { refuse(); }
return { kind: 'user', business, id: proof.user };
}
const id = businesses[business]?.human;
if (!id) throw new BusError('unknown-business');
return { kind: 'owner', business, id };
}
+19 -18
View File
@@ -18,10 +18,11 @@ import { credentialVerbs } from './credential-verbs.mjs';
import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { verifyHuman } from './human.mjs';
import { verifyPrincipal } from './principal.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
import { createUsage } from './usage.mjs';
import { createLimits } from './limits.mjs';
import { verifyTester } from './tester.mjs';
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
@@ -230,6 +231,15 @@ export async function startBroker({
if (runRoot) sweepRunDir(runRoot);
// Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call.
broker.reconcileGrants();
const interpreter = inspectInterpreter();
recordInterpreterStart(store.directory, interpreter);
const peerReader = createPeerReader({ interpreter });
const principalPaths = {
human: fileURLToPath(new URL('./human-cli.mjs', import.meta.url)),
tester: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)),
user: fileURLToPath(new URL('./user-cli.mjs', import.meta.url)),
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
};
const path = join(store.directory, 'broker.sock');
server = await serve({
web: (business) => web.get(business) ?? null,
@@ -237,23 +247,14 @@ export async function startBroker({
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
broker,
path,
authenticateHuman: (proof) => {
const { business } = verifyHuman(proof, {
cliPath: fileURLToPath(new URL('./human-cli.mjs', import.meta.url)),
launches: launchRecords,
});
const human = businesses[business]?.human;
if (!human) throw new BusError('unknown-business');
return broker.bindHuman({ business, human, via: 'cli', outsideAgent: true });
peerReader,
authenticateHuman: (proof, peer) => {
const p = verifyPrincipal({ human: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
return broker.bindHuman({ business: p.business, human: p.id, via: 'cli', outsideAgent: true });
},
// Row 110 (#1604): a tester proves its CLI process and that no managed run is above it.
// The broker then binds only if the business sets `bus.tester` to true.
authenticateTester: (proof) => {
const { business } = verifyTester(proof, {
cliPath: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)),
launches: launchRecords,
});
return broker.bindTester({ business });
authenticateTester: (proof, peer) => {
const p = verifyPrincipal({ tester: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
return broker.bindTester({ business: p.business });
},
});
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
+34 -19
View File
@@ -1,6 +1,8 @@
import { createServer } from 'node:net';
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
import { inspectInterpreter } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
const LIMIT = 65536;
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
@@ -12,31 +14,37 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
// `host` (row 210) is the trusted host.run executor (host-run.mjs), or null: host.run then refuses
// host-not-configured.
// A request carries exactly one of cap, human (row S4) and tester (row 110) proof.
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, timeout = 5000, tasks = null, web = null, host = null }) {
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
try {
lstatSync(path);
throw new BusError('socket-exists');
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
const peers = peerReader ?? createPeerReader({ interpreter: inspectInterpreter() });
const sockets = new Set();
const server = createServer({ allowHalfOpen: true }, (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
let boundCap;
const release = () => { if (boundCap) { broker.disconnect(boundCap); boundCap = null; } };
socket.on('close', () => { sockets.delete(socket); release(); });
socket.on('error', () => {});
let input = Buffer.alloc(0),
done = false;
done = false, processing = false;
const finish = (result) => {
if (done) return;
done = true;
release();
socket.resume();
socket.end(JSON.stringify(result) + '\n');
};
socket.setTimeout(timeout, () => {
finish({ ok: false, error: 'request-timeout' });
socket.destroySoon();
});
socket.on('data', (chunk) => {
socket.on('data', async (chunk) => {
if (done) return;
if (processing) return finish({ ok: false, error: 'invalid-envelope' });
const total = input.length + chunk.length;
if (total > LIMIT && !(total <= PROJECT_SAVE_REQUEST_MAX &&
SAVE_VERB_AT_START.test(Buffer.concat([input, chunk]).subarray(0, 1024).toString('latin1')))) {
@@ -46,6 +54,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
input = Buffer.concat([input, chunk]);
const end = input.indexOf(10);
if (end < 0) return;
processing = true;
let transient;
try {
if (end !== input.length - 1) throw new BusError('invalid-envelope');
@@ -55,19 +64,24 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
!r ||
Array.isArray(r) ||
typeof r !== 'object' ||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'verb', 'args'].includes(k)) ||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'verb', 'args'].includes(k)) ||
typeof r.verb !== 'string' ||
[r.cap, r.human, r.tester].filter(Boolean).length !== 1
[r.cap, r.human, r.tester, r.user].filter(Boolean).length !== 1
)
throw new BusError('invalid-envelope');
let cap = r.cap;
if (r.human) {
if (!authenticateHuman) throw new BusError('human-required');
transient = cap = authenticateHuman(r.human);
}
if (r.tester) {
if (!authenticateTester) throw new BusError('tester-required');
transient = cap = authenticateTester(r.tester);
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : null;
if (kind) {
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : authenticateUser;
if (!authenticate) throw new BusError(kind === 'tester' ? 'tester-required' : 'human-required');
socket.pause();
let peer;
try { peer = await peers.read(socket); }
catch { throw new BusError('peercred-required'); }
if (done || socket.destroyed) return;
transient = cap = authenticate(r[kind], peer);
boundCap = transient;
socket.resume();
}
if (typeof cap !== 'string') throw new BusError('unauthenticated');
if (tasks && TASK_VERBS.includes(r.verb)) {
@@ -82,7 +96,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }),
)
.finally(() => {
if (own) broker.disconnect(own);
if (own) release();
});
return;
}
@@ -100,7 +114,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }),
)
.finally(() => {
if (own) broker.disconnect(own);
if (own) release();
});
return;
}
@@ -116,7 +130,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }),
)
.finally(() => {
if (own) broker.disconnect(own);
if (own) release();
});
return;
}
@@ -134,7 +148,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
result => { if (!socket.destroyed) finish({ ok: true, result }); },
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
).finally(() => { if (own) broker.disconnect(own); });
).finally(() => { if (own) release(); });
return;
}
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
@@ -142,11 +156,11 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
} catch (e) {
finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' });
} finally {
if (transient) broker.disconnect(transient);
if (transient) release();
}
});
socket.on('end', () => {
if (!done) finish({ ok: false, error: 'incomplete-request' });
if (!done && !processing) finish({ ok: false, error: 'incomplete-request' });
});
});
await new Promise((resolve, reject) => {
@@ -160,6 +174,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
const owned = lstatSync(path);
return {
async close() {
await peers.close();
for (const s of sockets) s.destroy();
await new Promise((resolve) => server.close(resolve));
try {
+6 -3
View File
@@ -1,4 +1,7 @@
// Row 164 explicitly freezes proof bytes and its literal entry-path rule.
// Row 164 freezes reviewed proof bytes and the literal entry-path rule.
// Row 196 intentionally re-pins human.mjs for the distinct user nonce and shared human
// walk, and webui bus.mjs for initial-spawn nonce plus peer refusal status. Entry paths
// and the ban on channel plug-ins importing a proof caller are retained.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
@@ -8,7 +11,7 @@ import { provedRuntime } from "../../cli/tests/channel-fixture.mjs";
import { Client } from "../src/client.mjs";
const baselines = {
"packages/bus/src/human.mjs": "b995fc5cff3af8ade4c990d0e3bebf9d83829e00f034ebc9c3dcfd05f252b573",
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
"packages/bus/src/human-cli.mjs": "269c3a66008c0d57a54b14b73cf988d5f61f5e815455486296716e98ce5e8073",
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
@@ -16,7 +19,7 @@ const baselines = {
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
// transport change. Row 155 (#1649) added eight for the assistant routes.
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
"packages/webui/src/bus.mjs": "76a1b57858c4aa41071746713a196299264851c18322a6f9869d553f0362d421",
"packages/webui/src/bus.mjs": "934ab2f9b6bde72aafe7814758550c283eb25a4f3a23b284021b13718d67178f",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { inspectInterpreter, recordInterpreterStart } from '../src/interpreter.mjs';
function fixture(change = {}) {
const binary = Buffer.from('\x7fELFbody');
return { realpath: () => '/usr/bin/python3.14',
stat: path => ({ uid: 0, mode: 0o100755, dev: 1, ino: 10, isFile: () => path.endsWith('3.14'), isDirectory: () => !path.endsWith('3.14') }),
readFile: path => path === '/etc/passwd' ? 'root:x:0:0::/root:/bin/sh\nuser:x:1000:1000::/home/user:/bin/sh\n' : binary,
exec: () => '[3,14,0]', ...change };
}
test('system Python is a resolved root-owned ELF, never a shim or home interpreter', () => {
const record = inspectInterpreter({ io: fixture() });
assert.equal(record.path, '/usr/bin/python3.14');
assert.deepEqual(record.version, [3,14,0]);
assert.match(record.sha256, /^[a-f0-9]{64}$/);
const bad = [
{ realpath: () => '/home/user/python3' },
{ realpath: () => '/root/python3' },
{ realpath: () => '/srv/user/python3', readFile: p => p === '/etc/passwd' ? 'u:x:1000:1000::/srv/user:/bin/sh' : Buffer.from('\x7fELF') },
{ readFile: p => p === '/etc/passwd' ? '' : Buffer.from('#!/bin/sh\n') },
{ stat: () => ({ uid: 1000, mode: 0o100755, isFile: () => true, isDirectory: () => true }) },
{ stat: () => ({ uid: 0, mode: 0o100775, isFile: () => true, isDirectory: () => true }) },
{ stat: () => { throw Error('missing'); } },
{ exec: () => '[3,9,9]' },
{ exec: () => 'unexpected' },
];
for (const f of bad) assert.throws(() => inspectInterpreter({ io: fixture(f) }), /interpreter-unsafe/);
});
test('real system interpreter is available and safely inspected', () => {
const actual = inspectInterpreter();
assert.ok(actual.path.startsWith('/usr/'));
assert.ok(actual.version[0] >= 3);
});
test('restart receipt appends actual replacement identity and refuses unsafe files', t => {
const root = mkdtempSync(join(tmpdir(), 'g196-int-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const first = inspectInterpreter({ io: fixture() });
recordInterpreterStart(root, first);
const file = join(root, 'peercred-starts.jsonl');
const old = readFileSync(file, 'utf8');
const second = inspectInterpreter({ io: fixture({ readFile: p => p === '/etc/passwd' ? '' : Buffer.from('\x7fELFnew') }) });
recordInterpreterStart(root, second);
const now = readFileSync(file, 'utf8');
assert.ok(now.startsWith(old));
assert.notEqual(JSON.parse(old).sha256, JSON.parse(now.trim().split('\n')[1]).sha256);
chmodSync(file, 0o644);
assert.throws(() => recordInterpreterStart(root, second), /interpreter-receipt-unsafe/);
rmSync(file);
writeFileSync(join(root,'target'), 'untouched');
symlinkSync(join(root,'target'),file);
assert.throws(() => recordInterpreterStart(root, second), /interpreter-receipt-unsafe/);
assert.equal(readFileSync(join(root,'target'),'utf8'),'untouched');
});
test('activation gate compares fresh startup receipt to the current interpreter identity',async t=>{
const { verifyInterpreterReceipt }=await import('../src/interpreter.mjs');
const root=mkdtempSync(join(tmpdir(),'g196-int-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
const first=inspectInterpreter({io:fixture()}),since=Date.now()-1;
recordInterpreterStart(root,first);
assert.equal(verifyInterpreterReceipt(root,first,since),true);
assert.throws(()=>verifyInterpreterReceipt(root,{...first,sha256:'b'.repeat(64)},since),/interpreter-receipt-mismatch/);
assert.throws(()=>verifyInterpreterReceipt(root,first,Date.now()+1000),/interpreter-receipt-mismatch/);
});
test('non-regular startup receipt refuses promptly without waiting on a FIFO peer',async t=>{
const {execFileSync,spawnSync}=await import('node:child_process');
const root=mkdtempSync(join(tmpdir(),'g196-fifo-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
execFileSync('mkfifo',[join(root,'peercred-starts.jsonl')]);
const url=new URL('../src/interpreter.mjs',import.meta.url).href;
const child=spawnSync(process.execPath,['--input-type=module','-e',`import {recordInterpreterStart} from ${JSON.stringify(url)};try{recordInterpreterStart(${JSON.stringify(root)},{});process.exit(2)}catch(e){process.exit(e.message==='interpreter-receipt-unsafe'?0:3)}`],{timeout:1000});
assert.equal(child.status,0,'unsafe FIFO must refuse without blocking startup');
});
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { mkdtempSync,rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { BusError } from '../src/broker.mjs';
const request=(path,body)=>new Promise((resolve,reject)=>{const s=connect(path);let out='';s.on('error',reject);s.on('data',b=>out+=b);s.on('end',()=>resolve(JSON.parse(out)));s.end(JSON.stringify(body)+'\n');});
test('proof receives actual peer, cap traffic never invokes helper and transient cap disconnects',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-s-')),path=join(root,'s');let peers=0,auth=0;const disconnected=[];
const broker={request:()=>({ok:'value'}),disconnect:c=>disconnected.push(c)};
const reader={read:async socket=>{peers++;return {pid:process.pid,uid:process.getuid(),gid:process.getgid()};},close(){}};
const server=await serve({path,broker,peerReader:reader,authenticateHuman:(proof,peer)=>{auth++;assert.equal(peer.pid,process.pid);return 'transient';}});
t.after(async()=>{await server.close();rmSync(root,{recursive:true,force:true});});
assert.equal((await request(path,{human:{pid:process.pid},verb:'agents'})).ok,true);
assert.equal(peers,1);assert.equal(auth,1);assert.ok(disconnected.includes('transient'));
assert.equal((await request(path,{cap:'existing',verb:'agents'})).ok,true);
assert.equal(peers,1);
});
test('proof helper error and disconnect during verification never dispatch or bind',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-s-'));let auth=0,calls=0;
const broker={request:()=>calls++,disconnect(){}};
const server=await serve({path:join(root,'s'),broker,peerReader:{read:async()=>{throw Error('helper');},close(){}},authenticateHuman:()=>{auth++;return 'x';}});
t.after(async()=>{await server.close();rmSync(root,{recursive:true,force:true});});
assert.equal((await request(join(root,'s'),{human:{pid:process.pid},verb:'agents'})).error,'peercred-required');
assert.equal(auth,0);assert.equal(calls,0);
});
test('Client receives explicit proof refusal codes without dispatch, not outcome-unknown',async t=>{
for(const failure of ['human-required','peercred-required']) {
const root=mkdtempSync(join(tmpdir(),'g196-refuse-')),path=join(root,'s');let calls=0;
const server=await serve({path,broker:{request(){calls++;},disconnect(){}},
...(failure==='peercred-required'?{}:{authenticateHuman(){throw new BusError('human-required');}}),
peerReader:failure==='peercred-required'?{read:async()=>{throw Error('helper');},close(){}}:undefined,
...(failure==='peercred-required'?{authenticateHuman(){throw Error('must not authenticate');}}:{})});
try {
const client=new Client({path,human:{pid:process.pid,business:'fixture',startTime:'0',nonce:'a'.repeat(64)}});
await assert.rejects(client.call('agents'),e=>e.code===failure);
assert.equal(calls,0);
}finally{await server.close();rmSync(root,{recursive:true,force:true});}
}
});
test('asynchronous proof calls release their transient session on success, failure and timeout',async t=>{
const {TASK_VERBS,BusError}=await import('../src/broker.mjs');
for(const mode of ['success','failure','timeout']) {
const root=mkdtempSync(join(tmpdir(),'g196-async-')),path=join(root,'s'),disconnected=[];
const broker={disconnect:cap=>disconnected.push(cap),requestTask:()=>mode==='success'?Promise.resolve('done'):mode==='failure'?Promise.reject(new BusError('fixture-failed')):new Promise(()=>{})};
const server=await serve({path,broker,tasks:{handle(){},timeout:25},peerReader:{read:async()=>({pid:process.pid,uid:process.getuid(),gid:process.getgid()}),close(){}},authenticateHuman:()=>mode});
try {
const response=await request(path,{human:{pid:process.pid},verb:TASK_VERBS[0]});
assert.equal(response.ok,mode==='success');
if(mode==='timeout')assert.equal(response.error,'request-timeout');
assert.deepEqual(disconnected,[mode]);
}finally{await server.close();rmSync(root,{recursive:true,force:true});}
}
});
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:net';
import { fork } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { createPeerReader } from '../src/peercred.mjs';
import { inspectInterpreter } from '../src/interpreter.mjs';
const interpreter = inspectInterpreter();
test('T2 eight proof callers and a racing copier bind the exact accepted socket, which survives helper exit', {timeout:10000}, async t => {
const root = mkdtempSync(join(tmpdir(),'g196-p-'));
const path = join(root,'s');
const reader = createPeerReader({interpreter});
const sockets = [], pending = [], after = [];
let resolveReady;
const ready = new Promise(r => resolveReady = r);
const server = createServer({allowHalfOpen:true}, socket => {
sockets.push(socket);
let text = '';
socket.on('data', chunk => {
text += chunk;
if (!text.includes('\n')) return;
if (!socket.proof) {
socket.proof = JSON.parse(text.slice(0,text.indexOf('\n')));
text = text.slice(text.indexOf('\n')+1);
pending.push(socket);
if (pending.length === 9) resolveReady();
}
if (text.includes('after-helper\n')) after.push(socket);
});
socket.on('error',()=>{});
});
const children = [];
t.after(async () => { reader.close(); for(const c of children)c.kill(); for(const s of sockets)s.destroy(); await new Promise(r=>server.close(r)); rmSync(root,{recursive:true,force:true}); });
await new Promise(r=>server.listen(path,r));
const pids=[];
for(let i=0;i<9;i++) {
const c=fork(new URL('../fixtures/peer-client.mjs',import.meta.url),[],{stdio:['ignore','ignore','inherit','ipc']});
children.push(c); pids.push((await once(c,'message'))[0].pid);
}
const replies=children.map(c=>once(c,'message'));
children.forEach((c,i)=>c.send({path,...(i===8?{claimed:pids[0]}:{})}));
await ready;
const peers = await Promise.all(pending.map(socket => reader.read(socket)));
pending.forEach(s=>s.resume()); // spawn stdio pauses inherited streams.
assert.equal(new Set(peers.map(p=>p.pid)).size,9);
pending.forEach((socket,i)=> {
const peer=peers[i];
assert.equal(peer.uid,process.getuid());
const accepted=peer.pid===socket.proof.human.pid;
assert.equal(accepted, peer.pid!==pids[8]);
socket.write(JSON.stringify({accepted,pid:peer.pid})+'\n');
});
const results=await Promise.all(replies);
assert.equal(results.filter(([r])=>r.reply.accepted).length,8);
assert.equal(results[8][0].reply.accepted,false);
await Promise.all(sockets.map(s=>s.readableEnded?Promise.resolve():once(s,'end')));
assert.equal(after.length,9, 'all sockets read after the helper closed its duplicate');
});
function fakeSpawn(output, status=0) {
return () => {
const child=new EventEmitter(); child.stdout=new PassThrough(); child.stderr=new PassThrough();
child.kill=()=>{queueMicrotask(()=>child.emit('close',null,'SIGKILL'));return true;};
queueMicrotask(()=>{child.stdout.end(output);child.emit('close',status,null);});
return child;
};
}
test('helper output and failures fail closed including unsafe gid', async () => {
for(const [out,code] of [['{}',0],['{"pid":2,"uid":0,"gid":9007199254740992}',0],['{"pid":2,"uid":0,"gid":0}\nextra',0],['x'.repeat(257),0],['{"pid":2,"uid":0,"gid":0}',1]]) {
const reader=createPeerReader({interpreter,spawn:fakeSpawn(out,code)});
await assert.rejects(reader.read({}),/peercred-required/);reader.close();
}
const reader=createPeerReader({interpreter,spawn:()=>{throw Error('spawn');}});
await assert.rejects(reader.read({}),/peercred-required/);reader.close();
});
test('helper timeout, capacity and shutdown refuse without leaving children', async () => {
const reader=createPeerReader({interpreter,timeout:25,spawn:()=>{
const c=new EventEmitter();c.stdout=new PassThrough();c.stderr=new PassThrough();
c.kill=()=>{queueMicrotask(()=>c.emit('close',null,'SIGKILL'));return true;};return c;
}});
const jobs=Array.from({length:16},()=>assert.rejects(reader.read({}),/peercred-required/));
await assert.rejects(reader.read({}),/peercred-required/);
await Promise.all(jobs);reader.close();
await assert.rejects(reader.read({}),/peercred-required/);
});
test('missing or non-regular peer helper refuses at construction before opening the bus',()=>{
assert.throws(()=>createPeerReader({interpreter,stat:()=>{throw Error('missing');}}),/peercred-required/);
assert.throws(()=>createPeerReader({interpreter,stat:()=>({isFile:()=>false,isSymbolicLink:()=>true})}),/peercred-required/);
});
test('shutdown waits for killed helper close before returning',async()=>{
let reaped=0;
const reader=createPeerReader({interpreter,spawn:()=>{
const c=new EventEmitter();c.stdout=new PassThrough();c.stderr=new PassThrough();
c.kill=()=>{setTimeout(()=>{reaped++;c.emit('close',null,'SIGKILL');},5);return true;};return c;
}});
const pending=assert.rejects(reader.read({}),/peercred-required/);
await reader.close();
assert.equal(reaped,1,'shutdown must await the helper close event');
await pending;
});
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { verifyPrincipal } from '../src/principal.mjs';
const nonce='a'.repeat(64), uid=process.getuid();
const paths={human:'/tree/human-cli.mjs',tester:'/tree/tester-cli.mjs',user:'/tree/user-cli.mjs',console:'/tree/console.mjs'};
function context(kind='human') {
const variable=kind==='human'?'MOSAIC_BUS_CLI_NONCE':kind==='tester'?'MOSAIC_BUS_TESTER_NONCE':'MOSAIC_BUS_USER_NONCE';
const table=new Map([[50,{pid:50,ppid:40,startTime:'100',uid,argv:['node',paths[kind]],env:{[variable]:nonce}}],
[40,{pid:40,ppid:1,startTime:'90',uid,argv:['node',paths.console,'serve'],env:{}}]]);
return {peer:{pid:50,uid,gid:uid},paths,businesses:{demo:{human:'owner'}},launches:[],
readProcess:pid=>{if(!table.has(pid))throw Error('gone');return {...table.get(pid)};},
readTesterNonce:()=>table.get(50).env.MOSAIC_BUS_TESTER_NONCE,table};
}
const proof={pid:50,startTime:'100',nonce,business:'demo'};
test('principal binds kernel peer PID and uid, maps owner, and never takes identity or role from body',()=>{
const c=context();
assert.deepEqual(verifyPrincipal({human:proof},c),{kind:'owner',business:'demo',id:'owner'});
for(const peer of [{pid:51,uid,gid:uid},{pid:50,uid:uid+1,gid:uid}])assert.throws(()=>verifyPrincipal({human:proof},{...c,peer}),/human-required/);
assert.throws(()=>verifyPrincipal({human:{...proof,human:'other'}},c),/human-required/);
assert.throws(()=>verifyPrincipal({human:proof,tester:proof},c),/invalid-envelope/);
});
test('user requires its own nonce and full walk plus immediate same-tree Console parent',()=>{
const request={user:{...proof,user:'owner'}};
assert.deepEqual(verifyPrincipal(request,context('user')),{kind:'user',business:'demo',id:'owner'});
for(const change of [{argv:['bash']},{argv:['node','/other/console.mjs','serve']},{argv:['node',paths.console,'other']},{env:{CODEX_THREAD_ID:'agent'}}]) {
const c=context('user');Object.assign(c.table.get(40),change);
assert.throws(()=>verifyPrincipal(request,c),/human-required/);
}
const wrong=context('user');wrong.table.get(50).env={MOSAIC_BUS_CLI_NONCE:nonce};
assert.throws(()=>verifyPrincipal(request,wrong),/human-required/);
const launched=context('user');launched.launches=[{pid:40,startTime:'90'}];
assert.throws(()=>verifyPrincipal(request,launched),/human-required/);
const changed=context('user'), read=changed.readProcess;let n=0;
changed.readProcess=pid=>{const p=read(pid);if(pid===40&&n++>0)p.startTime='91';return p;};
assert.throws(()=>verifyPrincipal(request,changed),/human-required/);
});
test('tester deliberately admits agent parents but rejects managed runs and another nonce',()=>{
const c=context('tester');c.table.get(40).env={CODEX_THREAD_ID:'seat'};
assert.deepEqual(verifyPrincipal({tester:proof},c),{kind:'tester',business:'demo',id:'tester'});
c.table.get(40).env={MOSAIC_RUN_ID:'managed'};
assert.throws(()=>verifyPrincipal({tester:proof},c),/tester-required/);
});
test('human leaf identity is reread after walking the parents',()=>{
const c=context(),read=c.readProcess;let n=0;
c.readProcess=pid=>{const p=read(pid);if(pid===50&&n++>0)p.startTime='101';return p;};
assert.throws(()=>verifyPrincipal({human:proof},c),/human-required/);
});
+9
View File
@@ -1069,3 +1069,12 @@ provider/model, provider and account groups, with input/output/cacheWrite/
cacheRead and unknown turns beside every total. JSON retains the full view.
Account is unassigned today. Unknown turns can have used tokens: known sums
are a best reading of session-editable transcripts, not proof or a limit check.
### Private tester HTTP (row 196)
`node packages/cli/src/tester-http.mjs GET /api/config` selects the instance's
console/tester.sock from its dataRoot. POST reads one bounded JSON body from stdin.
The helper supplies the fixed logical origin, reports status/text/body as JSON,
and never retries an uncertain write. It cannot navigate a browser or upgrade the
tester to an owner. `mosaic ... --tester` continues using broker.sock directly.
See docs/TOOLS.md for explicit activation conversion and rollback behavior.
+39
View File
@@ -0,0 +1,39 @@
import { request } from 'node:http';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { testerSocketPath, TESTER_ORIGIN } from '../../webui/src/tester-socket.mjs';
export function testerRequest({ dataRoot, path = '/healthz', method = 'GET', body, headers = {}, timeout = 30000 }) {
if (typeof path !== 'string' || !/^\/(?!\/)/.test(path) || /[\r\n\0]/.test(path)) return Promise.reject(Error('tester-http-path'));
const data = body === undefined ? undefined : JSON.stringify(body);
if (data && Buffer.byteLength(data) > 16384) return Promise.reject(Error('tester-http-size'));
return new Promise((resolve, reject) => {
const req = request({ socketPath: testerSocketPath(dataRoot), path, method,
headers: { host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin',
...(data ? { 'content-type': 'application/json' } : {}), ...headers } }, res => {
const chunks = []; let size = 0;
res.on('data', b => { size += b.length; if (size > 8 * 1024 * 1024) req.destroy(Error('tester-http-size')); else chunks.push(b); });
res.on('error', reject);
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
try { resolve({ status: res.statusCode, text, body: res.headers['content-type']?.includes('json') ? JSON.parse(text) : null }); }
catch { reject(Error('tester-http-response')); }
});
});
req.setTimeout(timeout, () => req.destroy(Error('tester-http-timeout')));
req.on('error', reject);
req.end(data);
});
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
try {
const { loadSystem } = await import('./config.mjs');
const [method = 'GET', path = '/healthz'] = process.argv.slice(2);
if (!['GET', 'HEAD', 'POST'].includes(method) || process.argv.length > 4) throw Error('tester-http-usage');
const body = method === 'POST' ? JSON.parse(readFileSync(0, 'utf8')) : undefined;
const result = await testerRequest({ dataRoot: loadSystem().dataRoot, path, method, body });
process.stdout.write(JSON.stringify(result) + '\n');
if (result.status >= 400) process.exitCode = 2;
} catch { process.stderr.write('tester-http-refused\n'); process.exitCode = 2; }
}
+2 -2
View File
@@ -357,9 +357,9 @@ export function biz(e,plugins){
writeFileSync(f,JSON.stringify(b,null,2)+'\n',{mode:0o600});chmodSync(f,0o600);
}
export async function mk(t){
export async function mk(t,dataName='data'){
const root=realpathSync(mkdtempSync(join(tmpdir(),'sr-')));
const e={root,data:join(root,'data'),bin:join(root,'bin'),sd:join(root,'sd'),units:join(root,'xcfg','systemd','user'),cfg:join(root,'cfg','config.json'),claude:join(root,'claude-bin','claude')};
const e={root,data:join(root,dataName),bin:join(root,'bin'),sd:join(root,'sd'),units:join(root,'xcfg','systemd','user'),cfg:join(root,'cfg','config.json'),claude:join(root,'claude-bin','claude')};
// The default instance's names; named() sets NAME's (row 190).
Object.assign(e,{flags:[],bus:BUS,tmpl:'[email protected]',con:CON,wrapper:'mosaic'});
Object.assign(e,{state:join(e.data,'state'),pointer:join(e.data,'state','stack-active.json'),log:join(e.data,'state','stack-activation-log.jsonl'),store:join(e.data,'bus','bus.sqlite'),lock:join(e.data,'state','stack-release.lock')});
+118
View File
@@ -1744,6 +1744,7 @@ function rConsole(){
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot})+'\n');
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
@@ -2273,3 +2274,120 @@ test('T56 row 190: named instances side by side on one unit directory, $BIN and
const r=await run(a,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
});
});
test('row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket',async t=>{
const e=await mk(t),base=await seedPreview(e,{copy:A}),proof=await proofFile(e);
let r=await run(e,'activate',B,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
const file=join(COPIES,C1,'packages/webui/src/cli.mjs'),original=readFileSync(file,'utf8');
chmodSync(file,0o644);writeFileSync(file,'// MOSAIC_TESTER_SOCKET_V1\n// --tester-socket\n// --proof-header\n');chmodSync(file,0o444);
t.after(()=>{chmodSync(file,0o644);writeFileSync(file,original);chmodSync(file,0o444);});
const module=join(COPIES,C1,'packages/bus/src/interpreter.mjs');
chmodSync(dirname(module),0o755);
writeFileSync(module,'export const inspectInterpreter=()=>({path:"/usr/bin/python3",sha256:"fixture"});export const verifyInterpreterReceipt=()=>true;\n');chmodSync(module,0o444);chmodSync(dirname(module),0o555);
t.after(()=>{chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);});
const n=calls(e).length;
r=await run(e,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-transport-migration/);assert.deepEqual(startsStops(e,n),[]);
r=await run(e,'activate',C1,'acme','--tester-socket','--plan');assert.equal(r.code,0,r.out+r.err);assert.match(r.out,/--tester-socket/);assert.match(r.out,/drop.*--tester-port 7333/);
r=await run(e,'activate',C1,'acme','--tester-socket',...pair(proof));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...base,...pair(proof),'--tester-socket']);assert.match(consoleText(e),/--tester-socket/);assert.doesNotMatch(consoleText(e),/--tester-port/);
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.doesNotMatch(consoleText(e),/--tester-(socket|port)/);assert.match(r.out,/drop.*--tester-socket/);
await noLeak(e);
mkdirSync(join(e.data,'console'),{mode:0o755});
const unsafeBefore=calls(e).length;
r=await run(e,'activate',C1,'acme','--tester-socket');
assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(e,unsafeBefore),[]);
const long=await mk(t,'data-'+ 'a'.repeat(100));await seed(long,{copy:B});
const before=calls(long).length;
r=await run(long,'activate',C1,'acme','--tester-socket');
assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(long,before),[]);
});
// Row196 merge guard: without --send, SSO insertion must still precede the socket flag.
test('row196 SSO socket conversion pins pointer and unit argv order without send',async t=>{
const e=await mk(t);await seedPreview(e,{copy:B});await ssoConfig(e);
const file=join(COPIES,C1,'packages/webui/src/cli.mjs'),original=readFileSync(file,'utf8');
chmodSync(file,0o644);writeFileSync(file,original+'\n// MOSAIC_TESTER_SOCKET_V1\n// --tester-socket\n');chmodSync(file,0o444);
t.after(()=>{chmodSync(file,0o644);writeFileSync(file,original);chmodSync(file,0o444);});
const module=join(COPIES,C1,'packages/bus/src/interpreter.mjs');
chmodSync(dirname(module),0o755);
writeFileSync(module,'export const inspectInterpreter=()=>({path:"/usr/bin/python3",sha256:"fixture"});export const verifyInterpreterReceipt=()=>true;\n');chmodSync(module,0o444);chmodSync(dirname(module),0o555);
t.after(()=>{chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);});
const r=await prun(e,'activate',C1,'acme','--sso','--tester-socket');assert.equal(r.code,0,r.out+r.err);
const want=['--port',e.port,'--business','acme','--sso','--tester-socket'];
assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
assert.doesNotMatch(conExec(e),/ --send(?: |$)|--tester-port|--public-host|--proof-header/);
const status=await prun(e,'status','acme');assert.equal(status.code,0,status.out+status.err);assert.match(status.out,/unit flags: match the pointer/);
await noLeak(e);
});
// T10 activation: exercise the shell caller as well as interpreter.mjs itself.
test('row196 interpreter activation checks receipt, identity, plan and startup log',async t=>{
const cli=join(COPIES,C1,'packages/webui/src/cli.mjs'),old=readFileSync(cli,'utf8');
const module=join(COPIES,C1,'packages/bus/src/interpreter.mjs');
const identity={path:'/usr/bin/python3',sha256:'fixture-root-owned-ELF'};
const source=`import fs from 'node:fs';import {join} from 'node:path';
const root=process.env.HOME,sd=process.env.SD_STATE;
const fault=n=>fs.existsSync(join(sd,'faults',n));
export function inspectInterpreter(){
if(fault('interpreter-unsafe'))throw Error('unsafe interpreter');
const seen=join(root,'interpreter-inspected'),changed=fault('interpreter-drift')&&fs.existsSync(seen);
fs.writeFileSync(seen,'inspected');
return {...${JSON.stringify(identity)},...(changed?{sha256:'replacement-ELF'}:{})};
}
export function verifyInterpreterReceipt(directory,expected,since){
fs.appendFileSync(join(root,'interpreter-verified.jsonl'),JSON.stringify({directory,expected,since})+'\\n');
if(fault('interpreter-receipt-refuse'))throw Error('receipt does not match started bus');
return true;
}
`;
const putModule=()=>{chmodSync(dirname(module),0o755);writeFileSync(module,source);chmodSync(module,0o444);chmodSync(dirname(module),0o555);};
chmodSync(cli,0o644);writeFileSync(cli,old+'\n// MOSAIC_TESTER_SOCKET_V1\n');chmodSync(cli,0o444);putModule();
t.after(()=>{chmodSync(cli,0o644);writeFileSync(cli,old);chmodSync(cli,0o444);chmodSync(dirname(module),0o755);rmSync(module,{force:true});chmodSync(dirname(module),0o555);});
await t.test('plan and started log preserve the same interpreter JSON',async t=>{
const e=await mk(t);await seed(e);
const plan=await run(e,'activate',C1,'acme','--tester-socket','--plan');assert.equal(plan.code,0,plan.out+plan.err);
const line=/^interpreter: (.+)$/m.exec(plan.out);assert.ok(line,plan.out);assert.deepEqual(JSON.parse(line[1]),identity);
const r=await run(e,'activate',C1,'acme','--tester-socket');assert.equal(r.code,0,r.out+r.err);
const started=logLines(e).find(x=>x.op==='activate'&&x.result==='started');assert.ok(started);
assert.equal(started.interpreter,line[1]);assert.deepEqual(JSON.parse(started.interpreter),identity);
const verified=lines(join(e.env.HOME,'interpreter-verified.jsonl')).map(JSON.parse);
assert.ok(verified.length>0);assert.equal(verified[0].directory,join(e.data,'bus'));assert.deepEqual(verified[0].expected,identity);assert.ok(Number(verified[0].since)>0);
});
for(const scenario of ['interpreter-receipt-refuse','interpreter-drift'])await t.test(scenario+' fails at the gate and restores the pointer',async t=>{
const e=await mk(t);await seed(e);const before=readFileSync(e.pointer,'utf8'),n=calls(e).length,logs=logLines(e).length;fault(e,scenario);
const r=await run(e,'activate',C1,'acme','--tester-socket','--timeout','1');
assert.equal(r.code,1,r.out+r.err);assert.match(r.out+r.err,/interpreter-receipt/);assert.equal(readFileSync(e.pointer,'utf8'),before);
assert.ok(startsStops(e,n).some(x=>x.startsWith('start ')),startsStops(e,n).join('\n'));
const added=logLines(e).slice(logs);assert.ok(added.some(x=>x.result==='gate-failed'&&x.code==='interpreter-receipt'&&x.rollback==='ok'),JSON.stringify(added));
assert.equal(added.some(x=>x.result==='ok'),false,JSON.stringify(added));
});
for(const scenario of ['missing-verifier','interpreter-unsafe'])await t.test(scenario+' refuses before any start or stop',async t=>{
const e=await mk(t);await seed(e);const before=readFileSync(e.pointer,'utf8'),n=calls(e).length;
if(scenario==='missing-verifier'){chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);}else fault(e,scenario);
try{
for(const flags of [['--plan'],[]]){
const r=await run(e,'activate',C1,'acme','--tester-socket',...flags);
assert.equal(r.code,3,r.out+r.err);assert.match(r.out+r.err,/interpreter-unsafe/);
assert.equal(readFileSync(e.pointer,'utf8'),before);assert.deepEqual(startsStops(e,n),[]);
}
}finally{if(scenario==='missing-verifier')putModule();}
});
});
test('row196 rehearsal selects socket only for marked copies and uses scratch dataRoot',async t=>{
const file=join(RCOPIES,B,'packages/webui/src/cli.mjs'),old=readFileSync(file,'utf8');
const put=text=>{chmodSync(file,0o644);writeFileSync(file,text);chmodSync(file,0o444);};
t.after(()=>put(old));
for(const marked of [true,false])await t.test(marked?'socket copy':'legacy copy',async t=>{
put(old+(marked?'\n// MOSAIC_TESTER_SOCKET_V1\n':''));
const e=await rmk(t),before=untouched(e),port=await freePort();
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');assert.equal(r.code,0,r.out+r.err);
const contexts=lines(rh(e,'console-context.jsonl')).map(JSON.parse);assert.equal(contexts.length,1);
const context=contexts[0];assert.deepEqual(context.argv,['serve','--port',port,'--business','acme',...(marked?['--tester-socket']:[])]);
assert.equal(context.argv.includes('--tester-socket'),marked);assert.equal(context.argv.includes('--tester-port'),false);
assert.equal(context.dataRoot,join(r.dir,'data'));assert.notEqual(context.dataRoot,e.data);
assert.equal(JSON.parse(readFileSync(join(r.dir,'config/config.json'),'utf8')).dataRoot,context.dataRoot);
assert.equal(untouched(e),before);
});
});
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtempSync,rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { bindTesterSocket, testerSocketPath, TESTER_ORIGIN } from '../../webui/src/tester-socket.mjs';
import { testerRequest } from '../src/tester-http.mjs';
test('tester HTTP helper uses instance socket, logical origin and JSON without retrying',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-h-'));let hits=0;
const server=await bindTesterSocket(createServer((q,r)=>{hits++;assert.equal(q.headers.host,new URL(TESTER_ORIGIN).host);assert.equal(q.headers.origin,TESTER_ORIGIN);r.setHeader('content-type','application/json');r.end('{"ok":true}');}),testerSocketPath(root));
t.after(async()=>{await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
assert.equal((await testerRequest({dataRoot:root,path:'/fixture',method:'POST',body:{x:1}})).body.ok,true);
assert.equal(hits,1);
for(const path of ['//remote','bad','/a\n'])await assert.rejects(testerRequest({dataRoot:root,path}),/tester-http-path/);
await assert.rejects(testerRequest({dataRoot:root,body:{x:'x'.repeat(16385)}}),/tester-http-size/);
});
test('tester HTTP timeout closes the socket and does not retry',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-h-'));let hits=0;
const server=await bindTesterSocket(createServer(()=>{hits++;}),testerSocketPath(root));
t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
await assert.rejects(testerRequest({dataRoot:root,timeout:20}),/tester-http-timeout/);
assert.equal(hits,1);
});
+31 -26
View File
@@ -991,35 +991,40 @@ thread-scoped; sign-out clears private chat state. Bodies are inert text.
### Tester listener
`serve --tester-port 7333` starts a second listener on `127.0.0.1` only and
requires the bus config. The main listener keeps the human transport and rejects
`x-mosaic-tester` for every Host. The second listener rejects the main preview
auth header and uses `TESTER_CLI` for bus reads, the instance, route and name
writes, and chat. Its banner says tester; it offers only those editors and the
chat composer. It never enables settings saves, document saves, assistant stop or
messages to roles.
Both credential and committed-project reads keep their existing secret/path checks.
`serve --tester-socket` serves tester HTTP at `<dataRoot>/console/tester.sock`.
It creates or checks console/ as 0700 and the socket as 0600, owned by the instance
uid. It refuses symlinks, unsafe modes, existing entries and overlong Unix paths.
It binds a unique temporary name in that parent and renames onto the final path.
Cleanup removes the final path only when its dev/ino still matches. Hostile
same-uid replacement races remain outside the boundary.
Its chat (row 177, #1675) is the tester's own. The chat calls the bus through
`TESTER_CLI`, so it lists, opens and sends only in threads owned by `tester`, and
a human's thread answers 403 `thread-not-owned`. The main listener's chat stays
the human's and never lists tester threads. A tester send starts an assistant
turn, so a loopback caller on this port can start assistant turns, which row 176's
listener could not. The business var `bus.tester` stays the switch: while it is
off, chat answers 403 `tester-off` like every other tester call.
There is no tester TCP listener. `--tester-port` is retired for this copy.
Mos accepted loss of direct browser navigation (mos-1015). Agents use the socket:
The tester port must be 1024..65535, different from `--port`, and never the
forwarded port 7332. Writes need the listener's own loopback Origin and
same-origin Fetch Metadata, plus the existing bounded strict JSON bodies.
With the tester disabled by the business file, reads refuse HTTP 403 and the
page says the tester is off. The listener's address and identity are printed at start.
```sh
node packages/cli/src/tester-http.mjs GET /api/config
curl --unix-socket "$INSTANCE_DATA/console/tester.sock" http://mosaic-tester.invalid/healthz
```
The service script accepts the same `--tester-port` flag. The authorized window
uses `stack-release.sh activate ID BUSINESS --tester-port 7333` so both the unit
and pointer carry it. A target that lacks support refuses an explicit flag before
stopping anything. Re-pins/rollbacks preserve it for supporting targets and drop
it, visibly in the plan and steps log, for older ones. See docs/TOOLS.md. No live
listener, unit or tunnel changes are part of a build or test.
The helper selects dataRoot from the instance config, uses fixed Host and Origin
`http://mosaic-tester.invalid`, and adds same-origin Fetch Metadata and JSON for
POST bodies read from stdin. It never retries an uncertain write. Ordinary
browsers cannot navigate this socket. A later authenticated browser relay behind
SSO requires its own row.
The tester calls TESTER_CLI for reads, instance/route/name writes and its own chat.
It rejects sign-in cookies, Authorization, proxy auth/proof headers and owner-only
writes. Its bus calls still require `bus.tester` exactly true. A tester cannot
become an owner or user. The main listener rejects x-mosaic-tester for every Host.
Credential and committed-project read restrictions remain in force.
In an authorized window, `stack-release.sh activate ID BUSINESS --tester-socket`
explicitly converts inherited tester TCP flags in both unit and pointer. Without
that flag a new target refuses inherited TCP before stopping any unit. Rollback
to an older target drops tester enablement and reports it, without reopening TCP.
The reviewed window tool must also be used for rollback. Failed activation may
restore the old saved TCP unit, which restores the old security state and must
be reported as such. Builds and tests never change live units, listeners or tunnels.
### Proxy proof (row 178)
+3 -2
View File
@@ -9,6 +9,7 @@
// an agent run gets `human-required` from the broker.
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { views } from '../../bus/src/views.mjs';
@@ -28,7 +29,7 @@ export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI
return (verb, args = {}) => new Promise((resolve, reject) => {
let child;
try {
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
child = spawn(process.execPath, [cli, socket], { env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, 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;
@@ -83,7 +84,7 @@ export function sendOutcome(err) {
// HTTP status by bus error code. 403: the bus refused this caller; 503: no
// bus to ask, or no answer; 502: an answer that can't be used.
const STATUS = {
'human-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'human-required': 403, 'peercred-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
'revision-conflict':409,'organization-busy':409,'organization-uncertain':503,
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
+10 -10
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env node
// 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 { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
@@ -12,26 +14,25 @@ import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerPort = null;
let sendOn = false, ssoOn = false, testerSocket = false;
while (args.length) {
const flag = args.shift();
// Row 76 (#1570): the one flag without a value.
if (flag === '--send') { sendOn = true; continue; }
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
if (flag === '--sso') { ssoOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
} else if (flag === '--tester-port') {
if (!/^\d{1,5}$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1024 || Number(value) > 65535) throw new Error('--tester-port must be a whole number in 1024..65535');
testerPort = Number(value);
} else if (flag === '--board') options.board = value;
// Row 77 (#1571): the canonical checkout the queue read runs from, when
// serve runs from a pinned copy; everything else loads from the copy.
@@ -53,7 +54,6 @@ try {
else if (flag === '--proof-file') proofFlags.proofFile = value;
else throw new Error(`unknown option: ${flag}`);
}
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
options.preview = previewOptions(preview);
const proof = proofOptions(proofFlags, options.preview);
@@ -84,7 +84,7 @@ try {
const why = redactor({ configDir: configDir() })(err.message);
if (ssoOn) throw new Error(`--sso needs the system config (${why})`);
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
if (testerPort !== null) throw new Error(`--tester-port needs the bus configured (${why})`);
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
@@ -101,11 +101,11 @@ try {
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerPort !== null) {
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try {
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
tester: true, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
@@ -121,7 +121,7 @@ try {
const health = sso?.health() ?? {};
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
+10 -6
View File
@@ -1,4 +1,5 @@
import { createServer } from 'node:http';
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
import { timingSafeEqual } from 'node:crypto';
import { resolve } from 'node:path';
@@ -471,7 +472,7 @@ async function projectFileRead(res, read, path, search) {
// `testerNoSignIn` makes the tester listener refuse anything that looks like a sign-in (--sso). `log`
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerNoSignIn = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerNoSignIn = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
@@ -480,6 +481,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
const upstream = boardURL(board);
// An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
@@ -497,9 +500,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
// Under --sso the tester listener reads no cookie and takes no sign-in of any kind (design section 6).
if (tester && testerNoSignIn && (Object.hasOwn(req.headers, 'authorization') || SIGN_IN_COOKIE.test(req.headers.cookie ?? '') || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))))
return json(res, 403, { error: 'tester listener takes no sign-in' });
if (tester && (Object.hasOwn(req.headers, 'authorization') || SIGN_IN_COOKIE.test(req.headers.cookie ?? '') || Object.keys(req.headers).some(h => h.startsWith('x-authentik-')))) return json(res, 403, { error: 'tester listener takes no sign-in' });
let path, search;
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
// never legitimate, so it is refused before any check reads the Host.
@@ -521,7 +522,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// otherwise land on the loopback path unchecked. The operator's browser never sends it.
if (preview !== null && !named && req.headersDistinct[preview.header]) return json(res, 403, { error: 'proxy Host refused', message: 'the proxy must forward Host unchanged' });
try {
if (!named) {
if (tester) {
if (req.headers.host !== new URL(TESTER_ORIGIN).host) return json(res, 403, { error: 'non-local Host refused' });
} else if (!named) {
const authority = new URL(`http://${req.headers.host}`);
if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' });
}
@@ -549,7 +552,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const values = req.headersDistinct[preview.header];
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : signer;
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
if(path==='/api/settings'&&req.method==='POST'){
@@ -648,6 +651,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
res.end(req.method === 'HEAD' ? undefined : content);
} catch { json(res, 500, { error: 'WebUI asset unavailable' }); }
});
if (tester) return bindTesterSocket(server, testerSocket);
return new Promise((resolvePromise, reject) => {
server.once('error', reject);
server.listen(port, host, () => { server.off('error', reject); resolvePromise(server); });
+46
View File
@@ -0,0 +1,46 @@
import { lstatSync, mkdirSync, chmodSync, unlinkSync, renameSync } from 'node:fs';
import { isAbsolute, join, dirname } from 'node:path';
import { randomBytes } from 'node:crypto';
export const TESTER_ORIGIN = 'http://mosaic-tester.invalid';
export function testerSocketPath(dataRoot) {
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot)) throw Error('tester-socket-path');
const path = join(dataRoot, 'console', 'tester.sock');
if (Buffer.byteLength(path) > 107) throw Error('tester-socket-path');
return path;
}
export async function bindTesterSocket(server, path) {
if (typeof path !== 'string' || !isAbsolute(path) || Buffer.byteLength(path) > 107) throw Error('tester-socket-path');
const parent = dirname(path);
try { mkdirSync(parent, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw Error('tester-socket-unsafe'); }
const dir = lstatSync(parent);
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077)) throw Error('tester-socket-unsafe');
try { lstatSync(path); throw Error('tester-socket-exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
// Same length as tester.sock, so the final-path capacity check covers the temporary name.
const temporary = join(parent, '.' + randomBytes(4).toString('hex') + '.s');
try { lstatSync(temporary); throw Error('tester-socket-exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(temporary, () => { server.off('error', reject); resolve(); });
});
try {
chmodSync(temporary, 0o600);
const owned = lstatSync(temporary);
if (!owned.isSocket() || owned.uid !== process.getuid() || (owned.mode & 0o777) !== 0o600) throw Error('tester-socket-unsafe');
// Recheck before publishing. Same-uid swaps between lstat and rename/unlink remain
// outside the boundary, as Sage's option-1 ruling and the build receipt state.
try { lstatSync(path); throw Error('tester-socket-exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
renameSync(temporary, path);
// libuv remembers only the now-absent temporary name. Publish the actual client path.
server.address = () => server.listening ? path : null;
server.once('close', () => {
try { const s = lstatSync(path); if (s.dev === owned.dev && s.ino === owned.ino) unlinkSync(path); }
catch (e) { if (e.code !== 'ENOENT') server.emit('error', e); }
});
return server;
} catch (error) { await new Promise(r => server.close(r)); throw error; }
}
export async function startTesterSocket({ dataRoot, ...options }) {
const { startServer } = await import('./serve.mjs');
return startServer({ ...options, tester: true, testerSocket: testerSocketPath(dataRoot) });
}
+3 -1
View File
@@ -14,13 +14,14 @@ export async function browser({ machine = true } = {}) {
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`,
], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
let id = 0, buffer = '';
const pending = new Map();
const pending = new Map(), events = new Map();
child.stdio[4].on('data', chunk => {
buffer += chunk.toString();
let end;
while ((end = buffer.indexOf('\0')) !== -1) {
const message = JSON.parse(buffer.slice(0, end));
buffer = buffer.slice(end + 1);
if (message.method) for (const fn of events.get(message.method) ?? []) fn(message.params, message.sessionId);
if (pending.has(message.id)) {
const { resolve, reject, timeout } = pending.get(message.id);
clearTimeout(timeout);
@@ -50,6 +51,7 @@ export async function browser({ machine = true } = {}) {
};
return {
call, evaluate,
on(method, fn) { if (!events.has(method)) events.set(method, new Set()); events.get(method).add(fn); return () => events.get(method).delete(fn); },
async viewport(width, height = 1000) {
await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
},
+1 -1
View File
@@ -22,7 +22,7 @@ test('C2-04 CLI: both listener callback pairs dispatch library verbs with their
return next(url,context);
}});`);
const result = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname,
'serve', '--business', 'acme', '--port', '0', '--tester-port', '7444'],
'serve', '--business', 'acme', '--port', '0', '--tester-socket'],
{ env: { ...f.env, NODE_OPTIONS: `--import=${hook}` }, encoding: 'utf8', timeout: 10000 });
assert.equal(result.status, 0, result.stdout + result.stderr);
const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+4 -3
View File
@@ -1,5 +1,6 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
import { testerRequest } from '../../cli/src/tester-http.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
@@ -64,8 +65,8 @@ test('serve argv carries only fixture proof path and header; startup explicitly
test('CLI applies proof on the main listener and rejects that header on its tester listener', async t => {
const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
writeFileSync(file, VALUE, { mode: 0o600 });
const testerPort = await leasePort({after:laneAfter}, {closed:true});
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-port', String(testerPort), '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
await new Promise(r=>f.web.close(r));
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-socket', '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
@@ -82,5 +83,5 @@ test('CLI applies proof on the main listener and rejects that header on its test
assert.equal((await get(port, { host: 'console.example', 'x-user': 'jason' })).status, 401);
const good = await get(port, { host: 'console.example', 'x-user': 'jason', 'x-console-proof': VALUE });
assert.equal(good.status, 200); assert.ok(!good.text.includes(VALUE));
assert.equal((await get(testerPort, { host: `127.0.0.1:${testerPort}`, 'x-console-proof': VALUE })).status, 403);
assert.equal((await testerRequest({dataRoot:f.f.dataRoot,path:'/api/config',headers:{'x-console-proof':VALUE}})).status, 403);
});
+6 -2
View File
@@ -7,6 +7,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as serve from '../src/serve.mjs';
import { close } from './fixture.mjs';
import { startTesterSocket } from '../src/tester-socket.mjs';
import { testerRequest } from '../../cli/src/tester-http.mjs';
const HOST = 'console.example', HEADER = 'x-console-proof', VALUE = 'fixture_proxy_proof_0123456789abcdef_0123456789';
const preview = { host: HOST, header: 'x-user', user: 'jason' };
function ask(port, { host = HOST, headers = {}, path = '/api/bus/tasks', method = 'GET', body } = {}) {
@@ -66,9 +68,11 @@ test('proxy proof header is refused on other Hosts and on the tester listener',
assert.equal((await ask(f.port, { host, headers: { 'x-user': undefined, [HEADER]: VALUE } })).status, 403);
}
assert.equal((await ask(f.port, { host: `127.0.0.1:${f.port}`, headers: { 'x-user': undefined, [HEADER]: '' } })).status, 403);
const server = await serve.startServer({ port: 0, tester: true, testerProofHeader: HEADER, board: 'http://127.0.0.1:9' });
const testerRoot = mkdtempSync(join(tmpdir(),'tester-proof-'));
t.after(()=>rmSync(testerRoot,{recursive:true,force:true}));
const server = await startTesterSocket({ dataRoot: testerRoot, testerProofHeader: HEADER, board: 'http://127.0.0.1:9' });
t.after(() => close(server));
const result = await ask(server.address().port, { host: `127.0.0.1:${server.address().port}`, headers: { 'x-user': undefined, [HEADER]: VALUE }, path: '/api/config' });
const result = await testerRequest({dataRoot:testerRoot,headers:{[HEADER]:VALUE},path:'/api/config'});
assert.equal(result.status, 403); assert.deepEqual(f.calls, []);
});
test('proof options reject unsafe or colliding header names without disclosing file contents', t => {
+10
View File
@@ -803,3 +803,13 @@ test('proxy proof status retains send and tester reporting with both proof flags
assert.match(status.stdout, /send: on \(--send; human messages to roles\)/);
assert.match(status.stdout, /tester: http:\/\/127\.0\.0\.1:1024/);
});
test('row196 socket flag renders only for supporting targets and refuses mixed transports',async t=>{
const dir=tmp(t);
const good=await run(dir,['render','--tester-socket']);
assert.equal(good.status,0,good.stderr);assert.match(execStart(good.stdout),/ --tester-socket$/);
const old=await run(dir,['render','--copy',COPY_ID,'--tester-socket']);
assert.equal(old.status,1);assert.match(old.stderr,/tester.socket.*support/i);
const both=await run(dir,['render','--tester-socket','--tester-port','7333']);
assert.equal(both.status,4);
});
+9 -5
View File
@@ -12,6 +12,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { startTesterSocket } from '../src/tester-socket.mjs';
import { testerRequest } from '../../cli/src/tester-http.mjs';
import { createSso, readSlot, SESSION_COOKIE, LOGIN_COOKIE } from '../src/sso.mjs';
import { close } from './fixture.mjs';
import { b64url, ecKey, fakeOidc, jwt, psKey, rsaKey } from './helpers/fake-oidc.mjs';
@@ -385,12 +387,14 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, and
test('12: under --sso the tester listener refuses each kind of sign-in, has no /auth, and startServer refuses tester with sso', async t => {
const env = await setup(t);
const tester = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: env.bus, tester: true, testerNoSignIn: true, log: () => {} });
const dataRoot = mkdtempSync(join(tmpdir(),'g196-sso-'));
t.after(()=>rmSync(dataRoot,{recursive:true,force:true}));
const tester = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: env.bus, testerNoSignIn: true, log: () => {} });
t.after(() => close(tester));
const port = tester.address().port, host = `127.0.0.1:${port}`;
const callTester = options => testerRequest({dataRoot,...options});
const { session, g, begun } = await signIn(env);
assert.ok(session);
const plain = await call(port, { path: '/api/config', host });
const plain = await callTester({ path: '/api/config' });
assert.equal(plain.status, 200, 'control: a request with no sign-in is served');
for (const headers of [
{ cookie: session },
@@ -400,11 +404,11 @@ test('12: under --sso the tester listener refuses each kind of sign-in, has no /
{ 'x-authentik-jwt': g.idToken },
{ 'X-Authentik-Username': 'jason.woltje' },
]) {
const r = await call(port, { path: '/api/config', host, headers });
const r = await callTester({ path: '/api/config', headers });
assert.equal(r.status, 403, JSON.stringify(Object.keys(headers)));
assert.deepEqual(r.body, { error: 'tester listener takes no sign-in' });
}
assert.equal((await call(port, { path: '/auth/login', host })).status, 404);
assert.equal((await callTester({ path: '/auth/login' })).status, 404);
await assert.rejects(startServer({ port: 0, tester: true, sso: env.sso }), /tester listener cannot use sso/);
});
@@ -0,0 +1,26 @@
// UI QA only: CDP fulfills this browser's synthetic-origin requests from a scratch Unix
// listener. No TCP listener, proxy, installed endpoint or reusable relay is created.
import { request } from 'node:http';
import { browser } from './browser.mjs';
import { TESTER_ORIGIN } from '../src/tester-socket.mjs';
export async function testerBrowser(server) {
const b = await browser();
b.on('Fetch.requestPaused', ({requestId,request:incoming}) => {
const url = new URL(incoming.url);
const q = request({socketPath:server.address(),path:url.pathname+url.search,method:incoming.method,
headers:{...incoming.headers,host:new URL(TESTER_ORIGIN).host,origin:TESTER_ORIGIN,'sec-fetch-site':'same-origin'}}, res => {
const parts=[];
res.on('data',part=>parts.push(part));
res.on('end',()=>{
const responseHeaders=Object.entries(res.headers).filter(([name])=>!['connection','keep-alive','transfer-encoding'].includes(name))
.flatMap(([name,value])=>(Array.isArray(value)?value:[value]).map(value=>({name,value:String(value)})));
b.call('Fetch.fulfillRequest',{requestId,responseCode:res.statusCode,responseHeaders,body:Buffer.concat(parts).toString('base64')}).catch(()=>{});
});
});
q.on('error',()=>b.call('Fetch.failRequest',{requestId,errorReason:'Failed'}).catch(()=>{}));
q.end(incoming.postData);
});
await b.call('Fetch.enable',{patterns:[{urlPattern:TESTER_ORIGIN+'/*',requestStage:'Request'}]});
return b;
}
+5 -5
View File
@@ -1,14 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { browser } from './browser.mjs';
import { testerBrowser } from './tester-browser-fixture.mjs';
import { testerFixture, testerChat } from './tester-fixture.mjs';
test('tester Console banner exposes only instance route and name editors and saves a route as tester', { timeout: 60000 }, async t => {
const s = await testerFixture(t), b = await browser();
const s = await testerFixture(t), b = await testerBrowser(s.web);
try {
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<150;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
await b.navigate(`http://127.0.0.1:${s.web.address().port}/#/settings`);
await b.navigate(`http://mosaic-tester.invalid/#/settings`);
await wait('!!document.querySelector("#tester-banner") && document.querySelector("#routes-form button[type=submit]")?.disabled === false');
assert.match(await b.evaluate('document.querySelector("#tester-banner")?.textContent ?? ""'), /tester/i);
assert.equal(await b.evaluate('document.querySelector("#routes-form button[type=submit]").disabled'), false);
@@ -25,13 +25,13 @@ test('tester Console banner exposes only instance route and name editors and sav
});
test('row 177: the tester Console chat composer is enabled and a send shows in its own thread', { timeout: 60000 }, async t => {
const s = await testerChat(t), b = await browser();
const s = await testerChat(t), b = await testerBrowser(s.web);
try {
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<150;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
const tester = s.broker.bindTester({ business: 'demo' });
const project = s.broker.request(tester, { verb: 'project.create', args: { name: 'probe work' } }).id;
const thread = s.broker.request(tester, { verb: 'thread.create', args: { project, name: 'probe' } }).id;
await b.navigate(`http://127.0.0.1:${s.web.address().port}/#/chat/threads/${thread}`);
await b.navigate(`http://mosaic-tester.invalid/#/chat/threads/${thread}`);
await wait('!!document.querySelector("#tester-banner") && document.querySelector("#chat-body")?.disabled === false');
await b.evaluate(`(()=>{const e=document.querySelector('#chat-body');e.value='from the tester console';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#chat-send-form').requestSubmit();return true;})()`);
await wait('document.querySelector("#chat-receipt")?.textContent.includes("Accepted")');
+11 -9
View File
@@ -11,9 +11,10 @@ import { tmp, fixture } from '../../cli/tests/helpers.mjs';
import { TESTER_CLI, HUMAN_CLI } from '../../cli/src/transport.mjs';
import { testerFixture } from './tester-fixture.mjs';
import { request } from 'node:http';
import { testerSocketPath, TESTER_ORIGIN } from '../src/tester-socket.mjs';
const CLI = new URL('../src/cli.mjs', import.meta.url).pathname;
const get = (port, path) => new Promise((resolve, reject) => {
const q = request({ host: '127.0.0.1', port, path, headers: { host: `127.0.0.1:${port}` } }, r => {
const q = request({ socketPath: port, path, headers: { host: new URL(TESTER_ORIGIN).host } }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
}); q.on('error', reject); q.end();
});
@@ -23,24 +24,25 @@ test('tester-port validates range tunnel main port and requires configured bus b
const root = tmp(t, 'tester-cli-invalid-'), f = fixture(root);
for (const flags of [['--tester-port', '7332'], ['--port', '7444', '--tester-port', '7444'], ['--tester-port', '7444', '--port', '7444'], ...['1023', '65536', '1.5', '-1', 'nope'].map(p => ['--tester-port', p])]) {
const r = spawnSync(process.execPath, [CLI, 'serve', ...flags], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 });
assert.equal(r.status, 2); assert.match(r.stderr, /tester.port.*(1024|7332|differ|whole)/i);
assert.equal(r.status, 2); assert.match(r.stderr, /tester.port.*retired/i);
}
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--tester-port', '7333'], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 });
assert.equal(r.status, 2); assert.match(r.stderr, /tester-port needs the bus configured/);
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket'], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 });
assert.equal(r.status, 2); assert.match(r.stderr, /tester-socket needs the bus configured/);
});
test('CLI tester listener binds loopback and spawns TESTER_CLI for every Console bus read', { timeout: 30000 }, async t => {
const f = await testerFixture(t), port = await freePort(), log = join(f.root, 'spawn.jsonl');
test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 30000 }, async t => {
const f = await testerFixture(t), port = testerSocketPath(f.f.dataRoot), log = join(f.root, 'spawn.jsonl');
await new Promise(r=>f.web.close(r));
const hook = join(f.root, 'spawn-hook.mjs');
writeFileSync(log, '');
writeFileSync(hook, `import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs';const original=cp.spawn;cp.spawn=function(cmd,args,options){if(Array.isArray(args)&&args[0]?.endsWith('-cli.mjs'))appendFileSync(${JSON.stringify(log)},JSON.stringify({cli:args[0]})+'\\n');return original.call(this,cmd,args,options);};syncBuiltinESMExports();`);
const child = spawn(process.execPath, [CLI, 'serve', '--board', 'http://127.0.0.1:9', '--port', '0', '--tester-port', String(port), '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [CLI, 'serve', '--board', 'http://127.0.0.1:9', '--port', '0', '--tester-socket', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
let output = '', error = '';
child.stdout.on('data', c => output += c); child.stderr.on('data', c => error += c);
t.after(async () => { if (child.exitCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('startup did not name tester: ' + output + error)), 5000);
const check = () => { if (output.includes(`Tester Console: http://127.0.0.1:${port}/`)) { clearTimeout(timer); resolve(); } };
const check = () => { if (output.includes(`Tester Console: unix:${port}`)) { clearTimeout(timer); resolve(); } };
child.stdout.on('data', check); child.once('exit', code => { clearTimeout(timer); reject(Error('CLI exit ' + code + ': ' + output + error)); }); check();
});
assert.match(output, /binds (the )?tester/);
@@ -59,7 +61,7 @@ test('CLI tester listener binds loopback and spawns TESTER_CLI for every Console
const { revision, plugins } = await f.call('plugins');
await f.call('plugins.set', { revision, plugins: { ...plugins, channel: ['cli', 'webui'] } });
const send = (path, value) => new Promise((resolve, reject) => {
const q = request({ host: '127.0.0.1', port, path, method: 'POST', headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, r => {
const q = request({ socketPath: port, path, method: 'POST', headers: { host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
}); q.on('error', reject); q.end(JSON.stringify(value));
});
+3 -3
View File
@@ -13,7 +13,7 @@ import { Broker, BusError } from '../../bus/src/broker.mjs';
import { Store } from '../../bus/src/store.mjs';
import { BusReadError } from '../src/bus.mjs';
import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { startTesterSocket } from '../src/tester-socket.mjs';
import { createChat } from '../src/chat.mjs';
import { close } from './fixture.mjs';
@@ -53,7 +53,7 @@ export async function testerFixture(t, { enabled = true } = {}) {
};
const bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
const lines = [];
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', tester: true, testerAuthHeader: 'x-user', bus,
const web = await startTesterSocket({ dataRoot: f.dataRoot, board: 'http://127.0.0.1:9', tester: true, testerAuthHeader: 'x-user', bus,
reads: { settings: async () => ({ business: 'acme', release: 'fixture', notifier: {}, launch: await call('limits') }) },
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
@@ -90,7 +90,7 @@ export async function testerChat(t, { enabled = true } = {}) {
} catch (err) { if (err instanceof BusError) throw new BusReadError(err.code); throw err; }
} });
const lines = [];
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', tester: true, testerAuthHeader: 'x-user', chat, log: line => lines.push(line) });
const web = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9', tester: true, testerAuthHeader: 'x-user', chat, log: line => lines.push(line) });
t.after(() => close(web));
return { store, broker, human, calls, web, lines };
}
@@ -2,14 +2,16 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { request, createServer } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
import { tmp } from '../../cli/tests/helpers.mjs';
import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.mjs';
import { testerFixture, testerChat, TOKEN, VARIABLE, OUTSIDE } from './tester-fixture.mjs';
export function http(server, path, { method = 'GET', headers = {}, data } = {}) {
const port = server.address().port;
const address = server.address(), unix = typeof address === 'string', port = address.port;
return new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, path, method, headers: { host: `127.0.0.1:${port}`, ...headers } }, res => {
const req = request({ ...(unix ? {socketPath:address} : {host:'127.0.0.1',port}), path, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : `127.0.0.1:${port}`, ...headers } }, res => {
let text = ''; res.on('data', c => text += c);
res.on('end', () => resolve({ status: res.statusCode, text, body: res.headers['content-type']?.includes('json') ? JSON.parse(text) : null }));
});
@@ -17,7 +19,7 @@ export function http(server, path, { method = 'GET', headers = {}, data } = {})
});
}
export const post = (web, path, data, headers = {}) => http(web, path, { method: 'POST', data, headers: {
origin: `http://127.0.0.1:${web.address().port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers,
origin: typeof web.address() === 'string' ? TESTER_ORIGIN : `http://127.0.0.1:${web.address().port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers,
} });
test('main listener refuses x-mosaic-tester before any board or bus read for every Host', async t => {
@@ -33,13 +35,13 @@ test('main listener refuses x-mosaic-tester before any board or bus read for eve
}
});
test('tester listener rejects preview binding and requires its loopback Host and no proxy auth header', async t => {
test('tester listener rejects preview binding and requires its fixed logical Host and no proxy auth header', async t => {
const wrong = await startServer({ port: 0, tester: true, preview: { host: 'console.example', header: 'x-user', user: 'jason' } }).catch(e => e);
if (!(wrong instanceof Error)) { await close(wrong); assert.fail('tester accepted preview binding'); }
assert.match(wrong.message, /tester.*preview/i);
await assert.rejects(startServer({ port: 0, host: '0.0.0.0', tester: true }), /non-loopback/);
const s = await testerFixture(t);
assert.equal(s.web.address().address, '127.0.0.1');
assert.equal(typeof s.web.address(), 'string');
for (const headers of [{ host: 'elsewhere.example' }, { host: `127.0.0.1:${s.web.address().port + 1}` }, { 'x-user': 'jason' }]) {
const r = await http(s.web, '/api/bus/names', { headers });
assert.equal(r.status, 403); assert.deepEqual(s.calls, []);
@@ -89,7 +91,7 @@ test('tester-off and tester-required reads return HTTP 403', async t => {
const s = await testerFixture(t, { enabled: false });
const r = await http(s.web, '/api/bus/names');
assert.equal(r.status, 403); assert.equal(r.body.error, 'tester-off');
const required = await startServer({ port: 0, tester: true, board: 'http://127.0.0.1:9', bus: { names: async () => { throw new BusReadError('tester-required'); } } });
const required = await startTesterSocket({ dataRoot: tmp(t,'tester-required-'), board: 'http://127.0.0.1:9', bus: { names: async () => { throw new BusReadError('tester-required'); } } });
t.after(() => close(required));
const missing = await http(required, '/api/bus/names');
assert.equal(missing.status, 403); assert.equal(missing.body.error, 'tester-required');
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync,lstatSync,readdirSync,chmodSync,symlinkSync,mkdirSync,rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { startTesterSocket, testerSocketPath } from '../src/tester-socket.mjs';
import { testerRequest } from '../../cli/src/tester-http.mjs';
async function refused(options, pattern) {
const result=await startTesterSocket(options).catch(e=>e);
if(!(result instanceof Error)) await new Promise(r=>result.close(r));
assert.ok(result instanceof Error,'unsafe tester bind must refuse');assert.match(result.message,pattern);
}
test('private tester HTTP serves on 0600 socket in 0700 parent and fixed origin',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-w-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
const web=await startTesterSocket({dataRoot:root,board:'http://127.0.0.1:9'});t.after(()=>new Promise(r=>web.close(r)));
assert.equal(web.address(),testerSocketPath(root));
assert.equal(lstatSync(join(root,'console')).mode&0o777,0o700);
assert.equal(lstatSync(web.address()).mode&0o777,0o600);
assert.equal((await testerRequest({dataRoot:root,path:'/healthz'})).status,200);
for(const headers of [{host:'elsewhere.invalid'},{authorization:'Bearer fixture'},{cookie:'__Host-mosaic_session=fixture'},{origin:'http://elsewhere.invalid'}])
assert.equal((await testerRequest({dataRoot:root,path:'/api/config',headers})).status,403);
assert.equal((await testerRequest({dataRoot:root,path:'/auth/login'})).status,404);
assert.equal((await testerRequest({dataRoot:root,path:'/api/settings',method:'POST',body:{}})).status,403);
await refused({dataRoot:root},/tester-socket-exists/);
});
test('private tester refuses symlink, unsafe parent and long path without repairs',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-w-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
mkdirSync(join(root,'console'),{mode:0o755});
await refused({dataRoot:root},/tester-socket-unsafe/);
chmodSync(join(root,'console'),0o700);rmSync(join(root,'console'),{recursive:true});
symlinkSync(root,join(root,'console'));
await refused({dataRoot:root},/tester-socket-unsafe/);
await refused({dataRoot:'/tmp/'+ 'a'.repeat(108)},/tester-socket-path/);
});
test('tester cleanup must leave a replacement inode untouched',async t=>{
const { createServer }=await import('node:http');
const { writeFileSync,existsSync }=await import('node:fs');
const { bindTesterSocket }=await import('../src/tester-socket.mjs');
const root=mkdtempSync(join(tmpdir(),'g196-clean-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
const path=testerSocketPath(root);
const server=await bindTesterSocket(createServer(),path);
assert.deepEqual(readdirSync(join(root,'console')),['tester.sock'],'temporary name is absent after bind');
rmSync(path);writeFileSync(path,'replacement',{mode:0o600});
await new Promise(r=>server.close(r));
assert.equal(existsSync(path),true,'closing the listener must not unlink a replacement inode');
assert.deepEqual(readdirSync(join(root,'console')),['tester.sock'],'temporary name is absent after close');
});
+54 -22
View File
@@ -37,14 +37,14 @@ source scripts/instance.sh
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT="" TESTER_SOCKET=0 EXPECTED_INTERPRETER=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 SSO=0 R_BACKUP="" R_PORT=""
UNITS=user TO_FLAG="" EXPECT_FROM="" SINCE="" ROLLBACK_OLD="" CAUSE=""
usage() {
cat >&2 <<'USAGE'
usage: stack-release.sh adopt BUSINESS
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-socket | --tester-port N (legacy copies)]
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
stack-release.sh pm-on BUSINESS [--timeout S]
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
@@ -122,6 +122,7 @@ parse_opts() {
--plan) [ "$OP" = activate ] || [ "$OP" = rollback ] || usage; PLAN=1; shift ;;
--pm) [ "$OP" = activate ] || usage; WANT_PM=1; shift ;;
--pm-after-harness) [ "$OP" = activate ] || usage; AFTER_HARNESS=1; shift ;;
--tester-socket) [ "$OP" = activate ] || usage; TESTER_SOCKET=1; shift ;;
--tester-port) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; TESTER_PORT="$2"; shift 2 ;;
--proof-header) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_HEADER="$2"; shift 2 ;;
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
@@ -149,11 +150,12 @@ parse_opts() {
[ "$OP" != confirm ] && [ "$OP" != revert ] && [ "$OP" != gate ] && [ -z "$TO_FLAG" ] && [ -z "$EXPECT_FROM" ] || usage
else
[ "$OP" != rollback ] || [ -n "$TO_FLAG" ] || usage
[ "$WANT_PM" = 0 ] && [ -z "$TESTER_PORT" ] && [ -z "$PROOF_HEADER" ] && [ -z "$PROOF_FILE" ] && [ "$NO_PROOF" = 0 ] && [ "$NO_SEND" = 0 ] && [ "$SSO" = 0 ] || usage
[ "$TESTER_SOCKET" = 0 ] && [ "$WANT_PM" = 0 ] && [ -z "$TESTER_PORT" ] && [ -z "$PROOF_HEADER" ] && [ -z "$PROOF_FILE" ] && [ "$NO_PROOF" = 0 ] && [ "$NO_SEND" = 0 ] && [ "$SSO" = 0 ] || usage
fi
[ "$OP" != gate ] || [ -n "$SINCE" ] || usage
[ -z "$EXPECT_FROM" ] || [ "$PLAN" = 1 ] || usage
[ "${ROLLBACK_OLD:+1}" = "${CAUSE:+1}" ] || usage
[ "$TESTER_SOCKET" = 0 ] || [ -z "$TESTER_PORT" ] || usage
[ -z "$EVENTS" ] || [ -n "$RESTORE" ] || usage
[ "$OP" != rehearse ] || { [ -n "$R_BACKUP" ] && [ -n "$R_PORT" ]; } || usage
# A bad tester port is a usage error; --plan lists it as tester-port-invalid instead (row 180).
@@ -567,6 +569,10 @@ gate_once() {
[ "$out" -ge "$FLOOR" ] || { GATE_WHY="the bus started at @$out, before @$FLOOR"; return 1; }
START_MS=$((out * 1000))
fi
if [ -f "$copy/packages/bus/src/interpreter.mjs" ]; then
GATE=interpreter-receipt
node -e 'import(require("url").pathToFileURL(process.argv[1]).href).then(m=>{const expected=m.inspectInterpreter();if(process.argv[4]&&JSON.stringify(expected)!==process.argv[4])process.exit(1);m.verifyInterpreterReceipt(process.argv[2],expected,process.argv[3]);}).catch(()=>process.exit(1));' -- "$copy/packages/bus/src/interpreter.mjs" "$DATA/bus" "$START_MS" "$EXPECTED_INTERPRETER" || return 1
fi
GATE=bus-status
main="$(sc show "$BUS_UNIT" -p MainPID | sed -n 's/^MainPID=//p')" || return 1
out="$("$copy/scripts/mosaic" bus status --json 2>/dev/null)" || { GATE_WHY="bus status failed"; return 1; }
@@ -783,22 +789,39 @@ flags_drift() {
# unit still refuses drift and a failed attempt can restore exactly the captured stack.
# Read the target's own Console, because old copies cannot start with the new flag.
console_for_target() {
local supported=0 main=7332 inherited="" i
local supported=0 socket_supported=0 main=7332 inherited="" inherited_socket=0 i
local cli="$COPIES/$TO/packages/webui/src/cli.mjs"
local -a without_tester=()
sso_base
grep -q -- '--tester-port' "$COPIES/$TO/packages/webui/src/cli.mjs" && supported=1
if grep -q 'MOSAIC_TESTER_SOCKET_V1' "$cli"; then socket_supported=1
elif grep -q -- '--tester-port' "$cli"; then supported=1; fi
for ((i = 0; i < ${#BASE_CONSOLE[@]}; i++)); do
if [ "${BASE_CONSOLE[i]}" = --tester-port ]; then
i=$((i + 1)); inherited="${BASE_CONSOLE[i]:-}"
else
without_tester+=("${BASE_CONSOLE[i]}")
if [ "${BASE_CONSOLE[i]}" = --port ]; then main="${BASE_CONSOLE[i+1]:-7332}"; fi
fi
case "${BASE_CONSOLE[i]}" in
--tester-port) i=$((i + 1)); inherited="${BASE_CONSOLE[i]:-}" ;;
--tester-socket) inherited_socket=1 ;;
*) without_tester+=("${BASE_CONSOLE[i]}")
if [ "${BASE_CONSOLE[i]}" = --port ]; then main="${BASE_CONSOLE[i+1]:-7332}"; fi ;;
esac
done
RUN_CONSOLE=("${BASE_CONSOLE[@]}")
DROP_TESTER=""
if [ -n "$TESTER_PORT" ]; then
# --plan lists both with its other refusals (row 180); outside it they end the run.
RUN_CONSOLE=("${BASE_CONSOLE[@]}") DROP_TESTER="" EXPECTED_INTERPRETER=""
if [ "$socket_supported" = 1 ]; then
[ -z "$TESTER_PORT" ] || check_refuse tester-port-target "$TO retires tester TCP; use --tester-socket"
if [ -n "$inherited" ] && [ "$TESTER_SOCKET" = 0 ]; then
check_refuse tester-transport-migration "inherited tester TCP requires explicit --tester-socket conversion"
fi
if [ "$TESTER_SOCKET" = 1 ] || [ "$inherited_socket" = 1 ]; then
RUN_CONSOLE=("${without_tester[@]}" --tester-socket)
[ -z "$inherited" ] || DROP_TESTER="--tester-port $inherited (replaced by private Unix socket)"
node -e 'const fs=require("fs"),p=require("path"),root=process.argv[1],parent=p.join(root,"console");if(!p.isAbsolute(root)||Buffer.byteLength(p.join(parent,"tester.sock"))>107)process.exit(1);try{const s=fs.lstatSync(parent);if(!s.isDirectory()||s.isSymbolicLink()||s.uid!==process.getuid()||(s.mode&0o077))process.exit(1);}catch(e){if(e.code!=="ENOENT")process.exit(1);}' -- "$DATA" || check_refuse tester-socket-path "private tester path is too long or its parent is unsafe"
fi
local module="$COPIES/$TO/packages/bus/src/interpreter.mjs"
[ -f "$module" ] || check_refuse interpreter-unsafe "$TO lacks its interpreter verifier"
if [ -f "$module" ]; then
EXPECTED_INTERPRETER="$(node -e 'import(require("url").pathToFileURL(process.argv[1]).href).then(m=>console.log(JSON.stringify(m.inspectInterpreter())),()=>process.exit(1)).catch(()=>process.exit(1))' -- "$module")" || check_refuse interpreter-unsafe "system Python preflight refused"
fi
elif [ "$TESTER_SOCKET" = 1 ]; then
check_refuse tester-socket-target "$TO does not support --tester-socket"
elif [ -n "$TESTER_PORT" ]; then
if ! valid_tester_port "$TESTER_PORT" "$main"; then
[ "$PLAN" = 1 ] || usage
local other=""
@@ -808,9 +831,10 @@ console_for_target() {
fi
[ "$supported" = 1 ] || check_refuse tester-port-target "$TO's Console does not support --tester-port"
RUN_CONSOLE=("${without_tester[@]}" --tester-port "$TESTER_PORT")
elif [ "$inherited_socket" = 1 ]; then
RUN_CONSOLE=("${without_tester[@]}") DROP_TESTER="--tester-socket (tester disabled; no TCP fallback)"
elif [ -n "$inherited" ] && [ "$supported" = 0 ]; then
RUN_CONSOLE=("${without_tester[@]}")
DROP_TESTER="--tester-port $inherited"
RUN_CONSOLE=("${without_tester[@]}") DROP_TESTER="--tester-port $inherited"
fi
if [ "$SSO" = 1 ]; then sso_for_target; else proof_for_target; fi
send_for_target
@@ -859,6 +883,7 @@ sso_base() {
case "${SERVE_FLAGS[i]}" in
--send) ;;
--tester-port) i=$((i + 1)) ;;
--tester-socket) ;;
*) BASE_CONSOLE+=("${SERVE_FLAGS[i]}") ;;
esac
done
@@ -866,6 +891,7 @@ sso_base() {
case "${P_CONSOLE[i]}" in
--send) BASE_CONSOLE+=(--send) ;;
--tester-port) i=$((i + 1)); BASE_CONSOLE+=(--tester-port "${P_CONSOLE[i]:-}") ;;
--tester-socket) BASE_CONSOLE+=(--tester-socket) ;;
esac
done
SSO_PLAN="sso: dropped; $TO's Console does not support --sso, so the rollback takes the Console flags from $set/$f, with --send and --tester-port as the pointer has them"
@@ -890,7 +916,7 @@ sso_for_target() {
done
RUN_CONSOLE=()
for ((i = 0; i < ${#kept[@]}; i++)); do
case "${kept[i]}" in --send | --tester-port) break ;; esac
case "${kept[i]}" in --send | --tester-port | --tester-socket) break ;; esac
RUN_CONSOLE+=("${kept[i]}")
done
RUN_CONSOLE+=(--sso "${kept[@]:i}")
@@ -1044,10 +1070,10 @@ proof_for_target() {
fi
if [ -n "$PROOF_HEADER" ]; then
# webui-service.sh renders the pair after the preview flags and before --send and
# --tester-port, and flags_drift compares the pointer with that argv as it stands.
# tester flags, and flags_drift compares the pointer with that argv as it stands.
header="$PROOF_HEADER" file="$PROOF_FILE" RUN_CONSOLE=()
for ((i = 0; i < ${#without_proof[@]}; i++)); do
case "${without_proof[i]}" in --send | --tester-port) break ;; esac
case "${without_proof[i]}" in --send | --tester-port | --tester-socket) break ;; esac
RUN_CONSOLE+=("${without_proof[i]}")
done
RUN_CONSOLE+=(--proof-header "$header" --proof-file "$file" "${without_proof[@]:i}")
@@ -1297,6 +1323,7 @@ plan() {
echo "Console: scripts/webui-service.sh install $flags--copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
fi
echo "CLI: scripts/stack-copy.sh wrapper $TO $flags--copies $COPIES --bin $BIN"
[ -z "$EXPECTED_INTERPRETER" ] || echo "interpreter: $EXPECTED_INTERPRETER"
local r
for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done
[ "${#PLAN_REFUSE[@]}" = 0 ] || exit 3
@@ -1304,7 +1331,10 @@ plan() {
# Steps 4.1 to 4.5 and the gate for target $TO. $1 is the store step: migrate or restore.
run_steps() {
log_line result started
if [ -n "$EXPECTED_INTERPRETER" ]; then
log_line result started interpreter "$EXPECTED_INTERPRETER"
say "interpreter: $EXPECTED_INTERPRETER"
else log_line result started; fi
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
@@ -2120,7 +2150,9 @@ do_rehearse() {
# itself (env execs scripts/mosaic, which execs node), for the bus-status pid check and the stop.
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" --no-launch > "$R/host.log" 2>&1 &
R_HOST_PID=$!
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" > "$R/console.log" 2>&1 &
local -a tester_rehearsal=()
if grep -q 'MOSAIC_TESTER_SOCKET_V1' "$COPIES/$TO/packages/webui/src/cli.mjs"; then tester_rehearsal=(--tester-socket); fi
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" "${tester_rehearsal[@]}" > "$R/console.log" 2>&1 &
R_CONSOLE_PID=$!
if r_gate; then r_no_session; fi
r_end
+19 -6
View File
@@ -40,8 +40,9 @@
# row 85 supplies the shared copy and bus unit flags; Mos owns switching.
#
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
# Console), --tester-port N (1024..65535, neither --port nor the tunnel's
# 7332; binds the tester on loopback), --business ID, --board URL, and the preview set --public-host
# Console), --tester-socket (private Unix tester), or legacy --tester-port N
# (1024..65535, neither --port nor the tunnel's 7332; loopback on old copies),
# --business ID, --board URL, and the preview set --public-host
# NAME --auth-header HEADER --allow-user ID, all three or none, and --send
# (row 83), taken only with the preview set: the unit's Console then sends
# human messages to roles and needs the system config at start. systemd
@@ -92,8 +93,8 @@ instance_select
UNIT_NAME="$CONSOLE_UNIT"
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-socket | --tester-port N (legacy copies)] [--proof-header NAME --proof-file PATH]
--tester-port: legacy copies only; 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
die() { echo "webui-service: $*" >&2; exit 1; }
@@ -110,6 +111,7 @@ PORT=7332
PORT_SET=0
TESTER_PORT=""
TESTER_SET=0
TESTER_SOCKET=0
BUSINESS=""
BOARD=""
PUBLIC_HOST=""
@@ -131,6 +133,7 @@ parse_flags() {
--allow-worktree) ALLOW_WORKTREE=1; shift ;;
--no-reload) RELOAD=0; DIR_FLAGS=1; shift ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; PORT_SET=1; SERVE_SET=1; shift 2 ;;
--tester-socket) TESTER_SOCKET=1; SERVE_SET=1; shift ;;
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
@@ -146,6 +149,7 @@ parse_flags() {
done
# Validate here so a bad tester value exits 4 before render's existing
# operation-failure wrappers, and before install reads or changes units.
[ "$TESTER_SOCKET" = 0 ] || [ "$TESTER_SET" = 0 ] || usage
[ "$TESTER_SET" = 0 ] || tester_port_ok
}
@@ -260,6 +264,7 @@ serve_flags() {
[ "$SSO" = 0 ] || flags="$flags --sso"
[ "$SEND" = 0 ] || flags="$flags --send"
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
[ "$TESTER_SOCKET" = 0 ] || flags="$flags --tester-socket"
echo "$flags"
}
@@ -300,6 +305,12 @@ render() {
case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac
local root
root="$(unit_root)" || exit 1
if [ "$TESTER_SOCKET" = 1 ]; then
grep -q 'MOSAIC_TESTER_SOCKET_V1' "$root/packages/webui/src/cli.mjs" || die "tester socket target lacks support"
fi
if [ "$TESTER_SET" = 1 ] && [ -n "$COPY_ID" ] && grep -q 'MOSAIC_TESTER_SOCKET_V1' "$root/packages/webui/src/cli.mjs"; then
die "tester port retired for this target; use --tester-socket"
fi
local node_dir path="/usr/local/bin:/usr/bin:/bin"
node_dir="$(dirname "$node")"
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
@@ -448,7 +459,7 @@ status_unit() {
# render puts --send after the allowed user and optional proof pair, or
# after --sso, before the tester port. The allowed user
# starts with a letter or digit (N5), so it is never the value --send.
if grep -Eq '^ExecStart=.*( --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)?| --sso) --send( --tester-port [0-9]+)?$' "$target"; then
if grep -Eq '^ExecStart=.*( --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)?| --sso) --send( --tester-port [0-9]+| --tester-socket)?$' "$target"; then
echo "send: on (--send; human messages to roles)"
else
echo "send: off (read-only)"
@@ -458,7 +469,9 @@ status_unit() {
fi
local tester_port
tester_port="$(sed -n 's/^ExecStart=.* --tester-port \([0-9][0-9]*\)$/\1/p' "$target")"
if [ -n "$tester_port" ]; then
if grep -q -- " --tester-socket" "$target"; then
echo "tester: private Unix socket (console/tester.sock under instance dataRoot)"
elif [ -n "$tester_port" ]; then
echo "tester: http://127.0.0.1:$tester_port (binds the tester)"
else
echo "tester: off"