bus: tester identity for PM tests (row 110, #1604)
A tester binds with a process proof from the bus's own tester-cli.mjs when the business var bus.tester is true. It may send [mos test] messages to a role, receive replies on the tester address and use the read verbs; every other verb refuses. Messages store from_role tester and write no human.input event. turnRequest() marks them as never Jason's instruction. human.mjs is unchanged. Candidate manifest bdaf7f476abd0b2b5095ab2f7f43e38540e6ba48ee14a02a4f4995a1469b79df (Darkwing round 2, packet9f1563ec1), BASEbadfa52b9. Approved by Filbert (comment 28360) and Sage (comment 28361). Sage gate rerun passed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+42
-11
@@ -50,6 +50,10 @@ const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after',
|
||||
export const TASK_PAGE = 50;
|
||||
export const TASK_PAGE_MAX = 200;
|
||||
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
|
||||
// Row 110 (#1604): what a tester session may do. Send to a role, receive and read the replies
|
||||
// sent to `tester`, and the human's read views. Every other verb refuses tester-refused.
|
||||
const TESTER_VERBS = ['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies'];
|
||||
const TESTER_PREFIX = '[mos test]';
|
||||
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
|
||||
const POLL_KINDS = new Set([
|
||||
'task.changed.external',
|
||||
@@ -133,7 +137,7 @@ export class Broker {
|
||||
if (!id(name) || b.id !== name || !id(b.human) || !object(b.roles) || !object(b.arbiters))
|
||||
fail('invalid-business');
|
||||
for (const role of Object.keys(b.roles)) {
|
||||
if (!id(role) || role === 'human') fail('invalid-business');
|
||||
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
|
||||
const a = b.roles[role].authority;
|
||||
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
|
||||
fail('invalid-authority');
|
||||
@@ -164,6 +168,7 @@ export class Broker {
|
||||
);
|
||||
}
|
||||
#agent(s) {
|
||||
if (s.tester) fail('tester-refused');
|
||||
if (s.reader) fail('read-only');
|
||||
if (s.human) fail('agent-required');
|
||||
const h = this.#holder(s.business, s.role);
|
||||
@@ -254,6 +259,14 @@ export class Broker {
|
||||
fail('human-required');
|
||||
return this.#cap({ ...record, role: null, run: null, human: b.human });
|
||||
}
|
||||
// Row 110 (#1604): the trusted tester transport invokes this only after checking its CLI process
|
||||
// and that no managed run is among its ancestors. The business turns testing on with the business
|
||||
// var `bus.tester`, exactly true; anything else refuses tester-off.
|
||||
bindTester(record) {
|
||||
keys(record, ['business'], ['business']);
|
||||
if (this.#business(record.business).vars?.['bus.tester'] !== true) fail('tester-off');
|
||||
return this.#cap({ business: record.business, tester: true, role: 'tester', run: null, human: false });
|
||||
}
|
||||
bindReader({ business }) {
|
||||
this.#business(business);
|
||||
return this.#cap({ business, reader: true, role: null, run: null, human: false });
|
||||
@@ -656,7 +669,10 @@ export class Broker {
|
||||
return this.#store.transaction(() => {
|
||||
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb))
|
||||
fail('read-only');
|
||||
const result = this.#dispatch(s, request.verb, args);
|
||||
if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused');
|
||||
// A tester reads the human's views, as a reader does, and its message verbs are its own.
|
||||
const view = s.tester && !request.verb.startsWith('message.') ? { ...s, reader: true } : s;
|
||||
const result = this.#dispatch(view, request.verb, args);
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
});
|
||||
@@ -877,14 +893,29 @@ export class Broker {
|
||||
}
|
||||
case 'message.send': {
|
||||
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
|
||||
if (!s.human) {
|
||||
if (s.tester) {
|
||||
// A tester writes to a role only, says so in its first words, and cites no decision.
|
||||
// It consumes no authority and writes no human.input.
|
||||
keys(a, ['to', 'body', 'class', 'in_reply_to'], ['to', 'body']);
|
||||
if (!Object.hasOwn(b.roles, a.to)) fail('unknown-role');
|
||||
if (typeof a.body !== 'string' || !a.body.startsWith(TESTER_PREFIX)) fail('tester-prefix');
|
||||
} else if (!s.human) {
|
||||
const withinRole = this.#classification(s, 'message.send') === 'within-role';
|
||||
this.#consumeAuthority(s, 'message.send', {
|
||||
decision: withinRole ? null : (a.decision ?? null),
|
||||
target: a.to,
|
||||
});
|
||||
} else this.#human(s);
|
||||
if (a.to !== 'human' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role');
|
||||
// A role answers a tester only in reply to a tester's message.
|
||||
if (
|
||||
a.to === 'tester' &&
|
||||
!(id(a.in_reply_to) && this.#store.get("SELECT 1 FROM messages WHERE id=? AND business=? AND from_role='tester'", a.in_reply_to, s.business))
|
||||
)
|
||||
fail('tester-reply-required');
|
||||
} else {
|
||||
this.#human(s);
|
||||
if (a.to === 'tester') fail('unknown-role');
|
||||
}
|
||||
if (a.to !== 'human' && a.to !== 'tester' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role');
|
||||
string(a.body, 32768);
|
||||
if (
|
||||
a.class !== undefined &&
|
||||
@@ -915,7 +946,7 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
s.human ? 'human' : s.role,
|
||||
s.human ? 'human-cli' : s.run,
|
||||
s.human ? 'human-cli' : s.tester ? 'tester-cli' : s.run,
|
||||
a.to,
|
||||
a.class ?? 'INFO',
|
||||
a.in_reply_to ?? null,
|
||||
@@ -933,7 +964,7 @@ export class Broker {
|
||||
case 'message.receive': {
|
||||
keys(a, []);
|
||||
if (s.human) this.#human(s);
|
||||
else this.#agent(s);
|
||||
else if (!s.tester) this.#agent(s);
|
||||
const role = s.human ? 'human' : s.role;
|
||||
const rows = this.#store.all(
|
||||
"SELECT * FROM messages m WHERE business=? AND to_role=? AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read')) ORDER BY seq LIMIT 100",
|
||||
@@ -944,8 +975,8 @@ export class Broker {
|
||||
this.#delivery(
|
||||
m.id,
|
||||
'delivered',
|
||||
s.human ? null : this.#holder(s.business, s.role),
|
||||
s.human ? 'cli' : 'broker',
|
||||
s.human || s.tester ? null : this.#holder(s.business, s.role),
|
||||
s.human || s.tester ? 'cli' : 'broker',
|
||||
);
|
||||
return rows.map((m) => ({
|
||||
...m,
|
||||
@@ -960,7 +991,7 @@ export class Broker {
|
||||
case 'message.read': {
|
||||
keys(a, ['id'], ['id']);
|
||||
if (s.human) this.#human(s);
|
||||
else this.#agent(s);
|
||||
else if (!s.tester) this.#agent(s);
|
||||
const m = this.#store.get('SELECT * FROM messages WHERE business=? AND id=?', s.business, a.id);
|
||||
if (!m || m.to_role !== (s.human ? 'human' : s.role)) fail('message-not-found');
|
||||
const delivery = this.#store.get(
|
||||
@@ -969,7 +1000,7 @@ export class Broker {
|
||||
);
|
||||
if (!delivery || delivery.holder_run !== (s.run ?? null)) fail('not-recipient');
|
||||
if (!this.#store.get("SELECT seq FROM deliveries WHERE message=? AND op='read'", m.id))
|
||||
this.#delivery(m.id, 'read', s.human ? null : this.#holder(s.business, s.role));
|
||||
this.#delivery(m.id, 'read', s.human || s.tester ? null : this.#holder(s.business, s.role));
|
||||
return { read: true };
|
||||
}
|
||||
// Row 93 (#1587): the coder asks a reviewer role to review the task's latest pushed commit.
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { connect } from 'node:net';
|
||||
import { BusError } from './broker.mjs';
|
||||
export class Client {
|
||||
constructor({ path, cap, human, timeout = 5000 }) {
|
||||
constructor({ path, cap, human, tester, timeout = 5000 }) {
|
||||
this.path = path;
|
||||
this.cap = cap;
|
||||
this.human = human;
|
||||
this.tester = tester;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
call(verb, args = {}) {
|
||||
const request =
|
||||
JSON.stringify({ ...(this.human ? { human: this.human } : { cap: this.cap }), verb, args }) + '\n';
|
||||
JSON.stringify({
|
||||
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : { cap: this.cap }),
|
||||
verb,
|
||||
args,
|
||||
}) + '\n';
|
||||
if (Buffer.byteLength(request) > 65536) return Promise.reject(new BusError('request-too-large'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(this.path);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Broker, BusError } from './broker.mjs';
|
||||
import { Credentials } from './credentials.mjs';
|
||||
import { serve } from './server.mjs';
|
||||
import { verifyHuman } from './human.mjs';
|
||||
import { verifyTester } from './tester.mjs';
|
||||
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
|
||||
// Neither a business-file writer nor a socket-accessible configuration endpoint.
|
||||
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
|
||||
@@ -72,6 +73,15 @@ export async function startBroker({
|
||||
if (!human) throw new BusError('unknown-business');
|
||||
return broker.bindHuman({ business, human, 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 });
|
||||
},
|
||||
});
|
||||
// S6 launcher operations. The host decides capacity and the instance list; the broker
|
||||
// authorizes role.launch and keeps the evidence, refusals included.
|
||||
|
||||
@@ -4,7 +4,8 @@ import { BusError, TASK_VERBS } from './broker.mjs';
|
||||
const LIMIT = 65536;
|
||||
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
|
||||
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
|
||||
export async function serve({ broker, path, authenticateHuman = null, timeout = 5000, tasks = null }) {
|
||||
// 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 }) {
|
||||
try {
|
||||
lstatSync(path);
|
||||
throw new BusError('socket-exists');
|
||||
@@ -44,9 +45,9 @@ export async function serve({ broker, path, authenticateHuman = null, timeout =
|
||||
!r ||
|
||||
Array.isArray(r) ||
|
||||
typeof r !== 'object' ||
|
||||
Object.keys(r).some((k) => !['cap', 'human', 'verb', 'args'].includes(k)) ||
|
||||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'verb', 'args'].includes(k)) ||
|
||||
typeof r.verb !== 'string' ||
|
||||
Boolean(r.cap) === Boolean(r.human)
|
||||
[r.cap, r.human, r.tester].filter(Boolean).length !== 1
|
||||
)
|
||||
throw new BusError('invalid-envelope');
|
||||
let cap = r.cap;
|
||||
@@ -54,6 +55,10 @@ export async function serve({ broker, path, authenticateHuman = null, timeout =
|
||||
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);
|
||||
}
|
||||
if (typeof cap !== 'string') throw new BusError('unauthenticated');
|
||||
if (tasks && TASK_VERBS.includes(r.verb)) {
|
||||
const own = transient;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Row 110 (#1604): the tester's transport shim, human-cli.mjs with its own nonce variable and a
|
||||
// tester proof. Not a second product CLI. Input and proof never carry service tokens.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Client } from './client.mjs';
|
||||
import { readProcess } from './human.mjs';
|
||||
const file = fileURLToPath(import.meta.url);
|
||||
if (!process.env.MOSAIC_BUS_TESTER_NONCE) {
|
||||
const child = spawnSync(process.execPath, [file, ...process.argv.slice(2)], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, MOSAIC_BUS_TESTER_NONCE: randomBytes(32).toString('hex') },
|
||||
});
|
||||
process.exit(child.status ?? 2);
|
||||
}
|
||||
try {
|
||||
const request = JSON.parse(readFileSync(0, 'utf8'));
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Object.keys(request).some((k) => !['business', 'verb', 'args'].includes(k))
|
||||
)
|
||||
throw Error();
|
||||
const me = readProcess(process.pid);
|
||||
const client = new Client({
|
||||
path: process.argv[2],
|
||||
tester: {
|
||||
business: request.business,
|
||||
pid: me.pid,
|
||||
startTime: me.startTime,
|
||||
nonce: process.env.MOSAIC_BUS_TESTER_NONCE,
|
||||
},
|
||||
});
|
||||
const result = await client.call(request.verb, request.args ?? {});
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
} catch (e) {
|
||||
process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { readProcess } from './human.mjs';
|
||||
// Row 110 (#1604): the tester's proof. Same shape as the human's (pid, startTime, nonce, business),
|
||||
// from its own CLI and its own nonce variable, so neither CLI's nonce proves the other. The walk
|
||||
// refuses only a managed run: a launch record or a managed run's marker. An agent's markers and
|
||||
// binaries are allowed, since a tester is meant to run from inside Mos or a T3 seat.
|
||||
// Like verifyHuman, these are cooperative same-UID process checks, not a hostile-process wall.
|
||||
const RUN_MARKERS = ['MOSAIC_BUS_CAP', 'MOSAIC_RUN_ID', 'MOSAIC_AGENT_RUN'];
|
||||
const NONCE = 'MOSAIC_BUS_TESTER_NONCE=';
|
||||
const refuse = () => {
|
||||
throw new BusError('tester-required');
|
||||
};
|
||||
// readProcess keeps only the human's nonce and the markers, so the tester's nonce is read here.
|
||||
export function testerNonce(pid, { readFile = readFileSync } = {}) {
|
||||
try {
|
||||
const entry = readFile(`/proc/${pid}/environ`, 'utf8')
|
||||
.split('\0')
|
||||
.find((e) => e.startsWith(NONCE));
|
||||
return entry === undefined ? null : entry.slice(NONCE.length);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function verifyTester(
|
||||
proof,
|
||||
{ cliPath, launches = [], readProcess: read = readProcess, readNonce = testerNonce },
|
||||
) {
|
||||
if (
|
||||
!proof ||
|
||||
typeof proof !== 'object' ||
|
||||
Object.keys(proof).some((k) => !['pid', 'startTime', 'nonce', 'business'].includes(k)) ||
|
||||
!Number.isSafeInteger(proof.pid) ||
|
||||
proof.pid < 2 ||
|
||||
typeof proof.business !== 'string' ||
|
||||
typeof proof.nonce !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/.test(proof.nonce)
|
||||
)
|
||||
refuse();
|
||||
try {
|
||||
const nonce = readNonce(proof.pid);
|
||||
const p = read(proof.pid);
|
||||
if (
|
||||
p.uid !== process.getuid() ||
|
||||
p.startTime !== proof.startTime ||
|
||||
nonce !== proof.nonce ||
|
||||
p.argv[1] !== resolve(cliPath)
|
||||
)
|
||||
refuse();
|
||||
const seen = new Set();
|
||||
let current = p;
|
||||
for (let depth = 0; depth < 128; depth++) {
|
||||
if (seen.has(current.pid)) refuse();
|
||||
seen.add(current.pid);
|
||||
if (
|
||||
(current.env !== null && RUN_MARKERS.some((k) => current.env[k])) ||
|
||||
launches.some((r) => r.pid === current.pid && r.startTime === current.startTime)
|
||||
)
|
||||
refuse();
|
||||
if (current.ppid === 1) {
|
||||
const again = read(proof.pid);
|
||||
if (again.startTime !== proof.startTime || again.ppid !== p.ppid) refuse();
|
||||
return { business: proof.business };
|
||||
}
|
||||
if (current.ppid < 2) refuse();
|
||||
current = read(current.ppid);
|
||||
}
|
||||
} catch {
|
||||
refuse();
|
||||
}
|
||||
refuse();
|
||||
}
|
||||
Reference in New Issue
Block a user