row 196: human boundary on peer credentials, interpreter receipt and the Console tester socket (#1694)
Candidate527b9a1e6(gander/row196-r6-prd19, basef48725cfc), the 39 manifest paths of CANDIDATE.sha256 537d30aef70b6cd1 atec1e7fad8. 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:
1 parent
6661732925
commit
7247d80c7a
39 files changed
+1133
-183
No files matched your search
+46
-29
@@ -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
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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');
|
||||
})));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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});}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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')});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 +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));
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user