plugins, cli, bus, business, webui: harness, model and thinking from the instance vars, a fallback route, and a harness plug-in kind (row 65, #1558)
Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
This commit is contained in:
@@ -77,6 +77,7 @@ session's workspace and its own process group. Same contract, plus:
|
||||
| `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. |
|
||||
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. |
|
||||
| `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. |
|
||||
| `MOSAIC_THINKING` | The route's thinking level (row 65), which the harness module's `levels()` already checked. `pi` passes `--thinking` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) and omits it when unset, as for `run-task.sh` workers. `claude` passes `--effort` (`low`, `medium`, `high`, `xhigh`, `max`) and requires it; unset exits 2. |
|
||||
| `MOSAIC_CLAUDE_BIN` | `claude` only: absolute path of the CLI to run, from the host's environment (the bus unit pins it). Unset or empty runs `claude` from `PATH`; a relative path or a non-executable file exits 2. |
|
||||
|
||||
The layers each adapter relies on, and what they don't cover, are in
|
||||
|
||||
@@ -21,6 +21,7 @@ need MOSAIC_REQUEST
|
||||
need MOSAIC_WORKSPACE
|
||||
need MOSAIC_SESSION_DIR
|
||||
need MOSAIC_MODEL
|
||||
need MOSAIC_THINKING
|
||||
need MOSAIC_CLAUDE_SETTINGS
|
||||
need MOSAIC_CLAUDE_MCP_CONFIG
|
||||
for f in "$MOSAIC_SYSTEM_PROMPT_FILE" "$MOSAIC_CLAUDE_SETTINGS" "$MOSAIC_CLAUDE_MCP_CONFIG"; do
|
||||
@@ -56,6 +57,8 @@ fi
|
||||
# -p one-shot: print the answer and exit
|
||||
# --output-format text stdout is the answer only
|
||||
# --system-prompt the generated prompt replaces the default
|
||||
# --effort the instance's thinking var (row 65; 2.1.302 takes
|
||||
# low, medium, high, xhigh, max)
|
||||
# --restricted no user/project/local settings files; --settings
|
||||
# (the gate hook) still applies; no code-running
|
||||
# tool unless --tools names it; file tools confined
|
||||
@@ -77,6 +80,7 @@ PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
|
||||
--output-format text \
|
||||
--system-prompt "$PROMPT_CONTENT" \
|
||||
--model "$MOSAIC_MODEL" \
|
||||
--effort "$MOSAIC_THINKING" \
|
||||
--restricted \
|
||||
--tools "${MOSAIC_TOOLS:-}" \
|
||||
--allowedTools "$ALLOWED" \
|
||||
|
||||
@@ -73,6 +73,12 @@ if [ -n "${MOSAIC_EXTENSIONS:-}" ]; then
|
||||
IFS=$OLDIFS
|
||||
fi
|
||||
|
||||
# Thinking (row 65): a managed session's level, from its instance's thinking
|
||||
# var (pi 0.85.1: off, minimal, low, medium, high, xhigh, max). Workers from
|
||||
# run-task.sh set none and keep pi's default.
|
||||
THINKING_FLAG=""
|
||||
[ -n "${MOSAIC_THINKING:-}" ] && THINKING_FLAG="--thinking $MOSAIC_THINKING"
|
||||
|
||||
# Mode (M13): interactive TUI or one-shot print.
|
||||
PRINT_MODE="-p"
|
||||
REQUEST_ARG=""
|
||||
@@ -90,6 +96,7 @@ fi
|
||||
# EXT_FLAGS the explicitly provided extensions only (-e)
|
||||
# SESSION_FLAGS ephemeral | persistent | forked (per env)
|
||||
# TOOLS_FLAG per capabilities
|
||||
# THINKING_FLAG --thinking <level> for a managed session, else nothing
|
||||
# --offline no startup network operations (update checks/telemetry)
|
||||
# --no-approve ignore project-local .pi/ files (settings, SYSTEM.md)
|
||||
# whatever trust is saved for the workspace
|
||||
@@ -107,6 +114,7 @@ set -- \
|
||||
$SESSION_FLAGS \
|
||||
--provider "$PI_PROVIDER" \
|
||||
--model "$PI_MODEL" \
|
||||
$THINKING_FLAG \
|
||||
--system-prompt "$PROMPT_CONTENT"
|
||||
# One-shot mode appends -p and the request (both safely quoted);
|
||||
# interactive mode appends nothing - clean TUI.
|
||||
|
||||
@@ -63,6 +63,16 @@ underlying Broker's test-level binding API to bypass runtime process checks.
|
||||
A rebind of a run that already has `session.ended` refuses with `run-ended`,
|
||||
also after a restart, and a refused bind leaves the run unbound.
|
||||
|
||||
Row 65 (#1558): the record's `harness` is a plug-in module name (the loader's
|
||||
pattern), else `invalid-harness`; the broker names no harness itself. A new
|
||||
run binds only when `harnessOn(business, name)` says the organization file's
|
||||
`plugins.harness` switches it on now, else `invalid-harness`. A run the store
|
||||
already has binds again even after its harness is switched off, so it can be
|
||||
ended. A broker built without `harnessOn` (the unit tests') lets every
|
||||
module name through. The record may carry `transcript`, an absolute path
|
||||
where the harness keeps the run's session files, for the usage reader; it
|
||||
is held in memory only.
|
||||
|
||||
A request that carries a safe-integer `id` gets the same `id` on its reply,
|
||||
refusals included, so the host can match each reply to its request
|
||||
(`packages/cli/README.md`). A request without one gets a reply without one.
|
||||
@@ -585,6 +595,17 @@ carries `restart: true` after an add or a definition change: the broker keeps th
|
||||
loaded at start, so the change counts only after a restart that needs Mos's go or Jason's
|
||||
hand. Tests: `packages/bus/tests/instances-verb.test.mjs`.
|
||||
|
||||
`routes` and `routes.set` (row 65, #1558, mos-0840) are human-only the same way, and outside
|
||||
`ORGANIZATION_KEYS`, so the chief of staff's `change_organization` can't send a `routes` key.
|
||||
The chief reaches a route only through the assistant's whole vars (Q169), and that write runs
|
||||
the same route checks. `routes` takes no args and answers the routing table;
|
||||
`routes.set` takes exactly `{revision,routes}`. The business package's `launch-settings.mjs`
|
||||
writes them with the human as actor (`packages/business/README.md`, "Routes"). `harness-off`,
|
||||
`harness-refused` and `thinking-unsupported` pass through as they are, from `routes.set`,
|
||||
`instances.set` and `organization.set` alike. The runtime builds the write checks' `levels`
|
||||
from `harnessLevels()`, which loads each harness module in the tree once at start, switched on
|
||||
or not, and reads its `levels()`. Tests: `packages/bus/tests/routes-verb.test.mjs`.
|
||||
|
||||
A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs <socket>` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`; every other verb refuses `tester-refused`. It sends only to a role, with a body that starts `[mos test]` (`tester-prefix`) and no `corrects` or `decision`. Its message is stored `from_role 'tester'`, `from_run 'tester-cli'`, with no request and no event, so no `human.input` backs it and `task_create` can't cite it. A role answers at the `tester` address, and only with `in_reply_to` naming a tester's message (`tester-reply-required`); the human can't send there. Those replies never reach the human's `message.receive` or `messages`.
|
||||
|
||||
## Shared reads and adapter boundary
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID, randomBytes } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
|
||||
@@ -189,11 +191,12 @@ export class Broker {
|
||||
#project;
|
||||
#usage;
|
||||
#limits;
|
||||
#harnessOn;
|
||||
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
|
||||
#stops = new Map();
|
||||
#clock = 0;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
@@ -203,6 +206,11 @@ export class Broker {
|
||||
this.#project = project;
|
||||
this.#usage = usage;
|
||||
this.#limits = limits;
|
||||
// Row 65: harnessOn(business, name) is true when the organization file's plugins.harness switches
|
||||
// the harness on now; the bus runtime reads it live. Without it (the unit tests' brokers) a
|
||||
// module-shaped name passes and the launcher's own harness-off check is the only one.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
this.#harnessOn = harnessOn ?? (() => true);
|
||||
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
|
||||
this.#requirements = loadRequirements();
|
||||
for (const table of [
|
||||
@@ -412,13 +420,18 @@ export class Broker {
|
||||
bindLaunch(record) {
|
||||
keys(
|
||||
record,
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model', 'account'],
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model', 'account', 'transcript'],
|
||||
['business', 'role', 'run', 'harness'],
|
||||
);
|
||||
const b = this.#business(identifier(record.business));
|
||||
if (!Object.hasOwn(b.roles, record.role)) fail('unknown-role');
|
||||
identifier(record.run);
|
||||
if (!['pi', 'claude-code'].includes(record.harness)) fail('invalid-harness');
|
||||
// Row 65 (#1558): a harness is a plug-in module name; whether it is switched on is checked below,
|
||||
// for a new run only.
|
||||
if (typeof record.harness !== 'string' || !HARNESS_NAME.test(record.harness)) fail('invalid-harness');
|
||||
// Where the run's harness keeps its session files, for the usage reader; kept in memory only.
|
||||
if (record.transcript !== undefined && (!text(record.transcript) || !isAbsolute(record.transcript) || record.transcript.split('/').includes('..')))
|
||||
fail('invalid-request');
|
||||
if (record.address !== undefined) string(record.address, 1024);
|
||||
if (record.provider !== undefined) string(record.provider, 1024);
|
||||
if (record.model !== undefined) string(record.model, 1024);
|
||||
@@ -448,6 +461,9 @@ export class Broker {
|
||||
startTime: record.startTime ?? null,
|
||||
...(record.thread === undefined ? {} : { thread: record.thread }),
|
||||
};
|
||||
// A run an earlier broker bound is bound again unchanged (the host's recover()), even after its
|
||||
// harness was switched off, so it can be ended.
|
||||
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
|
||||
if (old) {
|
||||
if (old.actor_role !== record.role || old.body !== JSON.stringify(body)) fail('launch-record-mismatch');
|
||||
// An ended run stays ended across broker restarts.
|
||||
@@ -1205,18 +1221,23 @@ export class Broker {
|
||||
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (PROJECT_FILE_VERBS.includes(request.verb))
|
||||
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (["limits", "limits.set", "organization.set", "plugins", "plugins.set", "instances", "instances.set"].includes(request.verb)) {
|
||||
if (["limits", "limits.set", "organization.set", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
// logs the same receipt; only the actor differs: the role definition and instance.
|
||||
const role = request.verb === "organization.set";
|
||||
if (!role && !s.human) fail("human-required");
|
||||
if (!this.#organization) fail("not-configured");
|
||||
if (["limits", "plugins", "instances"].includes(request.verb)) keys(args, []);
|
||||
if (["limits", "plugins", "instances", "routes"].includes(request.verb)) keys(args, []);
|
||||
else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]);
|
||||
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so
|
||||
// organization.set and the chief of staff's tool never reach it (sage-0918).
|
||||
else if (request.verb === "instances.set") keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
|
||||
// Row 65 (mos-0840): routes.set, the Console's routing table, is human-only and outside
|
||||
// ORGANIZATION_KEYS too. Through organization.set the chief of staff changes only the model
|
||||
// key of an instance, except the assistant, whose whole vars it may set (Q169); that write
|
||||
// runs the same route checks.
|
||||
else if (request.verb === "routes.set") keys(args, ["revision", "routes"], ["revision", "routes"]);
|
||||
else keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
let actor = s.human;
|
||||
// This branch runs outside the store transaction below, so the grant is checked before the writer
|
||||
@@ -1233,6 +1254,10 @@ export class Broker {
|
||||
if (e.code === "revision-conflict") fail("revision-conflict");
|
||||
if (e.code?.startsWith("organization-")) fail(e.code);
|
||||
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
|
||||
// Row 65: the route checks every write of an instance's route vars runs.
|
||||
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
|
||||
// The routing table names a model the table lacks; the older verbs keep invalid-request.
|
||||
if (request.verb === "routes.set" && e.code === "unknown-model") fail(e.code);
|
||||
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
|
||||
fail("invalid-request");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from './store.mjs';
|
||||
@@ -39,6 +40,30 @@ export async function webServices(businesses, { log = (e) => process.stderr.writ
|
||||
}
|
||||
return services;
|
||||
}
|
||||
// Row 65 (REQ-PLUG-2): the thinking levels each harness module in this tree applies, for the route
|
||||
// checks a write runs, which are synchronous. A module that doesn't load, or answers anything but a
|
||||
// list of names, answers null, which refuses the write. The tree is pinned, so a module changes only
|
||||
// with a re-pin, and a re-pin restarts the bus.
|
||||
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`) } = {}) {
|
||||
const levels = new Map();
|
||||
let names = [];
|
||||
try {
|
||||
names = readdirSync(fileURLToPath(new URL('../../plugins/harness/', import.meta.url))).filter((name) => hasModule('harness', name));
|
||||
} catch (e) {
|
||||
log({ event: 'harness-levels-unread', message: e.message });
|
||||
}
|
||||
for (const name of names) {
|
||||
const loader = createLoader({ log });
|
||||
if ((await loader.load({ harness: [name] })).refused.length) continue;
|
||||
try {
|
||||
const list = loader.modules('harness')[0].levels();
|
||||
if (Array.isArray(list) && list.every((level) => typeof level === 'string')) levels.set(name, Object.freeze([...list]));
|
||||
} catch (e) {
|
||||
log({ event: 'harness-levels-failed', name, message: e.message });
|
||||
}
|
||||
}
|
||||
return (name) => levels.get(name) ?? null;
|
||||
}
|
||||
export async function startBroker({
|
||||
dataRoot,
|
||||
businesses,
|
||||
@@ -66,9 +91,10 @@ export async function startBroker({
|
||||
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
||||
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
|
||||
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
||||
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot)}):null;
|
||||
const levels=organization?await harnessLevels():()=>null;
|
||||
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels}):null;
|
||||
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
|
||||
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system}):null;
|
||||
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels}):null;
|
||||
const usage = await createUsage({ store, dataRoot, businesses,
|
||||
log: entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`) });
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
|
||||
@@ -98,12 +124,25 @@ export async function startBroker({
|
||||
if(verb==='plugins')return plugins.read(id);
|
||||
if(verb==='instances')return instances.read(id);
|
||||
if(verb==='instances.set')return instances.set(id,args,actor);
|
||||
// Row 65 (mos-0840): the Console's routing table.
|
||||
if(verb==='routes')return settings.routes(id);
|
||||
if(verb==='routes.set')return settings.setRoutes(id,args,actor);
|
||||
const result=plugins.set(id,args,actor);
|
||||
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
|
||||
return result;
|
||||
}:null,
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
credentialStatus: () => credentials.status(),
|
||||
// Row 65: a new launch binds only on a harness its business switches on, read from the file
|
||||
// as it stands now; an unreadable file switches none on.
|
||||
harnessOn: (id, name) => {
|
||||
try {
|
||||
const b = settings ? settings.current(id) : businesses[id];
|
||||
return Array.isArray(b?.plugins?.harness) && b.plugins.harness.includes(name);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
// Process identities must be supplied for every managed run when using the human transport.
|
||||
const launchRecords = [];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// REQ-LIMIT-4: the host calls sources only through the registry loader.
|
||||
import { join } from 'node:path';
|
||||
import { createLoader } from '../../plugins/src/loader.mjs';
|
||||
const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
|
||||
const zero = () => ({ input: 0, output: 0, cacheWrite: 0, cacheRead: 0 });
|
||||
@@ -52,7 +51,7 @@ export function turnRecords(store, business) {
|
||||
};
|
||||
});
|
||||
}
|
||||
export async function createUsage({ store, dataRoot, businesses, log }) {
|
||||
export async function createUsage({ store, businesses, log }) {
|
||||
const loaders = new Map();
|
||||
for (const [id, b] of Object.entries(businesses)) {
|
||||
const loader = createLoader({ log });
|
||||
@@ -72,13 +71,13 @@ export async function createUsage({ store, dataRoot, businesses, log }) {
|
||||
// Preserve a failed/disabled baseline even if configuration changes between calls/restarts.
|
||||
for (const base of bases) if (!sources.some(s => s.name === base.source)) sources.push({ name: base.source });
|
||||
const rows = [];
|
||||
const runDir = join(dataRoot, 'launches', business, run);
|
||||
const root = session.thread
|
||||
? join(dataRoot, 'threads', business, session.thread, harness === 'pi' ? 'session' : 'claude-projects')
|
||||
: harness === 'pi' ? join(runDir, 'session') : join(runDir, 'home/.claude/projects');
|
||||
// Row 65 (#1558): where the run's harness keeps its session files is the module's transcript(),
|
||||
// which the launcher binds into the run record. A run bound without one has no reading.
|
||||
const root = session.transcript ?? null;
|
||||
for (const { module, name } of sources) {
|
||||
let sample = unknown('no-source');
|
||||
if (module) {
|
||||
if (module && root === null) sample = unknown('no-transcript');
|
||||
else if (module) {
|
||||
try { sample = snapshot(await module.readTurn({ harness, root })); }
|
||||
catch { sample = unknown('source-failed'); }
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const demo = () => ({
|
||||
roles,
|
||||
definitions: {},
|
||||
projects: { stack: { root: '/nonexistent' } },
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
vars: { 'bus.tester': true },
|
||||
},
|
||||
});
|
||||
@@ -288,7 +290,8 @@ test('REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-runtime-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, businessDoc(root, 'demo'));
|
||||
// Row 65: the chief's launch binds only on a harness the file switches on.
|
||||
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({
|
||||
dataRoot: join(root, 'data'),
|
||||
@@ -357,6 +360,7 @@ test('Q169: the chief of staff\'s change of the assistant is validated, written
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.roles.assistant = { definition: 'assistant', vars: { model: 'claude-sonnet-5-5' } };
|
||||
doc.plugins = { harness: ['pi'] };
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({
|
||||
@@ -405,6 +409,55 @@ test('Q169: the chief of staff\'s change of the assistant is validated, written
|
||||
assert.equal(readFileSync(file, 'utf8'), bytes);
|
||||
});
|
||||
|
||||
// Row 65 (mos-0840, Q169): the chief may change the assistant's route through organization.set's
|
||||
// assistants key, and that write runs the routing table's checks. The passing case keeps the
|
||||
// exception real: a change that quietly refused the chief would fail here, not only in the refusals.
|
||||
test('row 65, Q169: the chief\'s change of the assistant\'s route runs the routing checks, and a valid one saves as the chief', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-route-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.roles.assistant = { definition: 'assistant', vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
doc.plugins = { harness: ['pi', 'claude-code'] };
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({
|
||||
dataRoot: join(root, 'data'),
|
||||
businesses: demo(),
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
t.after(async () => {
|
||||
await bus.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const { broker } = bus;
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
broker.request(chief, { verb: 'role.claim' });
|
||||
const set = (args) => broker.request(chief, { verb: 'organization.set', args });
|
||||
const document = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||
const last = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
|
||||
const { revision } = broker.request(human, { verb: 'limits' });
|
||||
const vars = (more) => ({ revision, assistants: { assistant: { vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high', ...more } } } });
|
||||
|
||||
const bytes = readFileSync(file, 'utf8');
|
||||
for (const [args, code] of [
|
||||
[vars({ harness: 'codex' }), /harness-off/],
|
||||
[vars({ harness: 'claude-code', thinking: 'off' }), /thinking-unsupported/],
|
||||
[vars({ 'fallback.harness': 'codex', 'fallback.model': 'glm-5.3-flash', 'fallback.thinking': 'low' }), /harness-off/],
|
||||
[vars({ 'fallback.harness': 'pi' }), /invalid-request/],
|
||||
])
|
||||
assert.throws(() => set(args), code, JSON.stringify(args));
|
||||
assert.equal(readFileSync(file, 'utf8'), bytes);
|
||||
|
||||
const route = { harness: 'claude-code', model: 'claude-opus-5-5', thinking: 'high', 'fallback.harness': 'pi', 'fallback.model': 'glm-5.3-flash', 'fallback.thinking': 'off' };
|
||||
set({ revision, assistants: { assistant: { vars: route } } });
|
||||
assert.deepEqual(document().roles.assistant.vars, route);
|
||||
assert.equal(last().actor, 'chief-of-staff.chief');
|
||||
for (const [path, value] of [['harness', 'claude-code'], ['fallback.harness', 'pi'], ['fallback.thinking', 'off']])
|
||||
assert.ok(last().changes.some((c) => c.path === `roles.assistant.vars.${path}` && c.new === value), path);
|
||||
});
|
||||
|
||||
// REQ-ROLE-2, REQ-COS-1: the 3c→3d step. The 3c database comes out of row 49's real 3b→3c step, run by a
|
||||
// copy of the engine pinned at 3c (its schema.sql is tests/fixtures/schema-3c.sql), not hand-built DDL.
|
||||
const here = (path) => new URL(path, import.meta.url);
|
||||
|
||||
@@ -41,6 +41,8 @@ function seed(root) {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: {
|
||||
|
||||
@@ -17,7 +17,7 @@ async function setup(t, limit = 0) {
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-quota-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { 'limit-check': ['daily-quota'] };
|
||||
doc.plugins = { 'limit-check': ['daily-quota'], harness: ['pi', 'claude-code'] };
|
||||
doc.launch.account = { name: 'jason-max', credential: 'CLAUDE_CODE_OAUTH_TOKEN' };
|
||||
doc.launch.limits = { ...doc.launch.limits,
|
||||
tokens: { daily: { 'jason-max': { limit, since: SINCE, reset: '05:00', timeZone: 'America/Chicago' } } } };
|
||||
|
||||
@@ -13,6 +13,8 @@ const businesses = {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
launch: { by: 'pm', instances: ['coder'], max: { opus: 2, sonnet: 2 } },
|
||||
roles: {
|
||||
|
||||
@@ -15,7 +15,7 @@ async function setup(t, plugins, limit = 0) {
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-limits-tokens-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { 'limit-check': plugins };
|
||||
doc.plugins = { 'limit-check': plugins, harness: ['pi'] };
|
||||
doc.launch.limits = { ...doc.launch.limits, tokens: { instances: { coder: { limit, since: SINCE } } } };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
|
||||
@@ -10,6 +10,8 @@ const businesses = {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
arbiters: { technical: 'cto', delivery: 'cto' },
|
||||
roles: { cto: { authority: { withinRole: ['message.send'], crossRole: [] } } },
|
||||
},
|
||||
|
||||
@@ -9,10 +9,14 @@ import { startTimeOf } from '../../seat/src/proc.mjs';
|
||||
import { initProject } from '../../project/src/index.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
|
||||
test('Broker keeps the existing sandbox client import closure: no new imports or lazy project import', () => {
|
||||
test('Broker keeps the sandbox client import closure: only the listed imports, no lazy project import', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||
"import { randomUUID, randomBytes } from 'node:crypto';",
|
||||
// Row 65 (#1558): bindLaunch checks a run's transcript path is absolute.
|
||||
"import { isAbsolute } from 'node:path';",
|
||||
// Row 65: the plug-in name pattern a harness takes. kinds.mjs is in RUNNER_FILES and imports nothing.
|
||||
"import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';",
|
||||
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
|
||||
// Row 137 (#1631): land-work's checks, in RUNNER_FILES beside the broker. They import nothing.
|
||||
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
|
||||
@@ -24,6 +28,7 @@ test('Broker keeps the existing sandbox client import closure: no new imports or
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
});
|
||||
|
||||
test('without a trusted project adapter all three async paths refuse project-not-configured', async t => {
|
||||
@@ -40,7 +45,8 @@ test('real startBroker injects the project adapter for socket reads and bound-au
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-runtime-'));
|
||||
let runtime;
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code') } },
|
||||
// Row 65: the bound run's harness is switched on.
|
||||
const business = { id: 'demo', human: 'jason', plugins: { harness: ['pi'] }, projects: { stack: { root: join(root, 'code') } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: {
|
||||
definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] },
|
||||
} } };
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Row 65 (#1558, REQ-PLUG-2, mos-0840): routes and routes.set, the Console's routing table, are
|
||||
// human-only broker actions outside ORGANIZATION_KEYS, so neither organization.set nor the chief of
|
||||
// staff's change_organization sends a routes key. The chief reaches a route only through the
|
||||
// assistant's vars (Q169), and that write runs the same checks (chief-of-staff.test). A new launch
|
||||
// binds only on a harness its business switches on; a run an earlier broker bound binds again unchanged.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
|
||||
|
||||
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const biz = () => ({
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' }, definitions: {},
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: chiefAuthority }, pm: { definition: 'pm', ...none } },
|
||||
projects: { stack: { root: '/nonexistent' } }, vars: { 'bus.tester': true },
|
||||
},
|
||||
});
|
||||
const ROUTE = { harness: 'pi', model: 'sonnet', thinking: 'low', fallback: null };
|
||||
const SET = { revision: 'a'.repeat(64), routes: { pm: ROUTE } };
|
||||
|
||||
function scratch(t, prefix) {
|
||||
const root = mkdtempSync(join(tmpdir(), prefix));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
test('row 65: routes and routes.set refuse every non-human session, the chief of staff included, and pass the verified actor', (t) => {
|
||||
const store = new Store(scratch(t, 'routes-verb-'));
|
||||
t.after(() => store.close());
|
||||
const calls = [];
|
||||
const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([id, verb, args, actor]), { ok: true }) });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const caps = { reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }) };
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
for (const [who, cap] of Object.entries(caps))
|
||||
for (const verb of ['routes', 'routes.set'])
|
||||
assert.throws(() => b.request(cap, { verb, args: verb === 'routes' ? {} : SET }), /human-required/, `${who} ${verb}`);
|
||||
// The chief of staff holds organization.change, but organization.set takes ORGANIZATION_KEYS
|
||||
// only: a routes key is refused before the writer, so change_organization can't send one.
|
||||
assert.equal(ORGANIZATION_KEYS.includes('routes'), false);
|
||||
for (const args of [SET, { revision: SET.revision, routes: SET.routes, instances: { pm: 'sonnet' } }])
|
||||
assert.throws(() => b.request(caps.chief, { verb: 'organization.set', args }), /invalid-request/);
|
||||
assert.equal(calls.length, 0);
|
||||
assert.throws(() => b.request(human, { verb: 'routes.set', args: { ...SET, actor: 'forged' } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'routes.set', args: { revision: SET.revision } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'routes.set', args: { routes: SET.routes } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'routes', args: { x: 1 } }), /invalid-request/);
|
||||
assert.equal(calls.length, 0);
|
||||
b.request(human, { verb: 'routes' });
|
||||
b.request(human, { verb: 'routes.set', args: SET });
|
||||
assert.deepEqual(calls, [['demo', 'routes', {}, 'jason'], ['demo', 'routes.set', SET, 'jason']]);
|
||||
});
|
||||
|
||||
test('row 65: a new launch binds only on a switched-on harness with a module name, and a run an earlier broker bound binds again after its harness is switched off', (t) => {
|
||||
const store = new Store(scratch(t, 'routes-harness-on-'));
|
||||
t.after(() => store.close());
|
||||
const asked = [];
|
||||
const first = new Broker({ store, businesses: biz(), harnessOn: (id, name) => (asked.push([id, name]), name === 'pi') });
|
||||
assert.throws(() => first.bindLaunch({ business: 'demo', role: 'pm', run: 'off-run', harness: 'claude-code' }), /invalid-harness/);
|
||||
for (const harness of ['', 'Pi', '../pi', 'pi/x', 7])
|
||||
assert.throws(() => first.bindLaunch({ business: 'demo', role: 'pm', run: 'bad-run', harness }), /invalid-harness/, String(harness));
|
||||
const cap = first.bindLaunch({ business: 'demo', role: 'pm', run: 'pi-run', harness: 'pi' });
|
||||
first.request(cap, { verb: 'role.claim' });
|
||||
// The name check runs before the file is asked; the switched-on check asks for the bind's business.
|
||||
assert.deepEqual(asked, [['demo', 'claude-code'], ['demo', 'pi']]);
|
||||
// A second broker over the same store, with nothing switched on: the old run binds unchanged, and
|
||||
// a new run on the same harness doesn't.
|
||||
const second = new Broker({ store, businesses: biz(), harnessOn: () => false });
|
||||
assert.equal(typeof second.bindLaunch({ business: 'demo', role: 'pm', run: 'pi-run', harness: 'pi' }), 'string');
|
||||
assert.throws(() => second.bindLaunch({ business: 'demo', role: 'pm', run: 'new-run', harness: 'pi' }), /invalid-harness/);
|
||||
// A broker given no harnessOn switches every module name on, as before row 65.
|
||||
const store2 = new Store(scratch(t, 'routes-harness-default-'));
|
||||
t.after(() => store2.close());
|
||||
new Broker({ store: store2, businesses: biz() }).bindLaunch({ business: 'demo', role: 'pm', run: 'any-run', harness: 'fixture' });
|
||||
assert.throws(() => new Broker({ store: store2, businesses: biz(), harnessOn: 'yes' }), /harnessOn must be a function/);
|
||||
});
|
||||
|
||||
test('row 65 runtime: harnessLevels answers each module in the tree, and null for a name with no module', async () => {
|
||||
const { harnessLevels } = await import('../src/runtime.mjs');
|
||||
const logged = [];
|
||||
const levels = await harnessLevels({ log: (e) => logged.push(e) });
|
||||
assert.deepEqual(levels('pi'), ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
||||
assert.deepEqual(levels('claude-code'), ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
for (const name of ['cursor', '', '../pi']) assert.equal(levels(name), null, name);
|
||||
assert.deepEqual(logged, []);
|
||||
});
|
||||
|
||||
test('row 65 runtime: routes and routes.set reach the organization file with the human as actor, a new bind reads plugins.harness, and each refusal keeps the bytes and logs nothing', async (t) => {
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
||||
const root = scratch(t, 'routes-runtime-');
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { ...doc.plugins, harness: ['pi', 'claude-code'] };
|
||||
for (const r of Object.values(doc.roles)) r.vars = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high', ...r.vars };
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
const view = bus.broker.request(human, { verb: 'routes' });
|
||||
assert.deepEqual(view.harnesses, ['pi', 'claude-code']);
|
||||
assert.deepEqual(view.levels.pi, ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
||||
assert.deepEqual(view.instances.coder, { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high', fallback: null });
|
||||
const route = { harness: 'pi', model: 'claude-opus-5-5', thinking: 'minimal', fallback: { harness: 'pi', model: 'glm-5.3-flash', thinking: 'off' } };
|
||||
const saved = bus.broker.request(human, { verb: 'routes.set', args: { revision: view.revision, routes: { coder: route } } });
|
||||
assert.deepEqual(saved.instances.coder, route);
|
||||
const vars = JSON.parse(readFileSync(file, 'utf8')).roles.coder.vars;
|
||||
assert.deepEqual([vars.model, vars.thinking, vars['fallback.harness'], vars['fallback.model'], vars['fallback.thinking'], vars['limits.network']], ['claude-opus-5-5', 'minimal', 'pi', 'glm-5.3-flash', 'off', 'none']);
|
||||
assert.equal(log().at(-1).actor, 'jason');
|
||||
// harnessOn reads the file as it stands: pi and claude-code are on, and cursor isn't.
|
||||
bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'pi-run', harness: 'pi' });
|
||||
assert.throws(() => bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'cursor-run', harness: 'cursor' }), /invalid-harness/);
|
||||
const before = readFileSync(file, 'utf8');
|
||||
const entries = log().length;
|
||||
const code = (routes, revision = saved.revision) => { try { bus.broker.request(human, { verb: 'routes.set', args: { revision, routes } }); } catch (e) { return e.code; } return null; };
|
||||
const coder = (more) => ({ coder: { ...route, ...more } });
|
||||
assert.equal(code(coder({ harness: 'cursor', thinking: 'low' })), 'harness-off');
|
||||
assert.equal(code(coder({ fallback: { harness: 'cursor', model: 'glm-5.3-flash', thinking: 'low' } })), 'harness-off');
|
||||
// claude-code has no minimal or off; a level no harness applies fails the file's own check.
|
||||
assert.equal(code(coder({ harness: 'claude-code' })), 'thinking-unsupported');
|
||||
assert.equal(code(coder({ fallback: { harness: 'claude-code', model: 'glm-5.3-flash', thinking: 'off' } })), 'thinking-unsupported');
|
||||
assert.equal(code(coder({ thinking: 'ultra' })), 'invalid-request');
|
||||
assert.equal(code(coder({ fallback: { harness: 'pi', model: 'glm-5.3-flash' } })), 'organization-partial-fallback');
|
||||
assert.equal(code(coder({ fallback: { harness: 'pi', model: 'glm-5.3-flash', thinking: null } })), 'organization-partial-fallback');
|
||||
// A model the table lacks comes back as unknown-model, so the Console can say so (dewey N1).
|
||||
assert.equal(code(coder({ model: 'ghost' })), 'unknown-model');
|
||||
assert.equal(code(coder({ fallback: { harness: 'pi', model: 'ghost', thinking: 'off' } })), 'unknown-model');
|
||||
assert.equal(code({ ghost: route }), 'organization-unknown-instance');
|
||||
assert.equal(code({ coder: route }), 'organization-no-change');
|
||||
assert.equal(code(coder({ thinking: 'low' }), view.revision), 'revision-conflict');
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
assert.equal(log().length, entries, 'a refused write logs nothing');
|
||||
});
|
||||
@@ -16,6 +16,8 @@ const businesses = {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
|
||||
@@ -20,7 +20,8 @@ const roles = {
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
};
|
||||
const business = (vars) => ({
|
||||
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles, ...(vars ? { vars } : {}) },
|
||||
// Row 65: the runtime binds only on a harness the business switches on.
|
||||
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles, plugins: { harness: ['pi'] }, ...(vars ? { vars } : {}) },
|
||||
});
|
||||
function setup(t, vars = { 'bus.tester': true }) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-tester-'));
|
||||
|
||||
@@ -11,10 +11,11 @@ test('REQ-PLUG-1 runtime reaches transcript only through loader, over the real b
|
||||
let runtime;
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const dir = join(root, 'launches/demo/run1/session'); mkdirSync(dir, { recursive: true });
|
||||
const business = { id: 'demo', human: 'jason', plugins: { 'usage-source': ['transcript'] },
|
||||
const business = { id: 'demo', human: 'jason', plugins: { 'usage-source': ['transcript'], harness: ['pi'] },
|
||||
roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
runtime = await startBroker({ dataRoot: root, businesses: { demo: business }, launches: [{
|
||||
business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'other-provider', model: 'other-model',
|
||||
// Row 65: the launch names where its harness keeps the session files.
|
||||
business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'other-provider', model: 'other-model', transcript: dir,
|
||||
pid: process.pid, startTime: startTimeOf(process.pid),
|
||||
}] });
|
||||
const client = new Client({ path: runtime.path, cap: runtime.launches[0].cap });
|
||||
|
||||
@@ -9,9 +9,10 @@ async function fixture(t, plugins = { 'usage-source': ['transcript'] }) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-usage-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const store = new Store(root); t.after(() => store.close());
|
||||
const logs = [], session = { business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'fixture', model: 'model' };
|
||||
const dir = join(root, 'launches/demo/run1/session'); mkdirSync(dir, { recursive: true });
|
||||
const usage = await createUsage({ store, dataRoot: root, businesses: { demo: { plugins } }, log: e => logs.push(e) });
|
||||
// Row 65: the launcher binds the harness module's transcript directory into the run record.
|
||||
const logs = [], session = { business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'fixture', model: 'model', transcript: dir };
|
||||
const usage = await createUsage({ store, businesses: { demo: { plugins } }, log: e => logs.push(e) });
|
||||
const put = (id, input = 2) => ({ type: 'message', id, message: { role: 'assistant', usage: { input, output: 3, cacheWrite: 4, cacheRead: 5 } } });
|
||||
const file = join(dir, 'session.jsonl');
|
||||
const save = records => writeFileSync(file, records.map(JSON.stringify).join('\n') + '\n');
|
||||
@@ -50,7 +51,7 @@ for (const kind of ['cut', 'disabled', 'missing-begin', 'no-new-usage', 'rewrite
|
||||
test('REQ-LIMIT-4 unfinished turn remains visibly unknown after accounting restarts', async t => {
|
||||
const f = await fixture(t);
|
||||
await f.usage.collect(f.session, 'begin', 'turn1');
|
||||
const again = await createUsage({ store: f.store, dataRoot: f.root, businesses: { demo: { plugins: {} } }, log: () => {} });
|
||||
const again = await createUsage({ store: f.store, businesses: { demo: { plugins: {} } }, log: () => {} });
|
||||
assert.equal(again.view('demo').total.unknownTurns, 1);
|
||||
});
|
||||
test('usage records are append-only', async t => {
|
||||
@@ -70,8 +71,8 @@ test('REQ-LIMIT-4 parse failure logs its safe code, never transcript content', a
|
||||
test('REQ-LIMIT-4 resumed run baselines history and exact totals do not overflow', async t => {
|
||||
const f = await fixture(t);
|
||||
for (const [n, run] of [['1', 'run1'], ['2', 'run2']]) {
|
||||
const session = { ...f.session, run };
|
||||
const dir = join(f.root, 'launches/demo', run, 'session'); mkdirSync(dir, { recursive: true });
|
||||
const session = { ...f.session, run, transcript: dir };
|
||||
const file = join(dir, 'session.jsonl');
|
||||
await f.usage.collect(session, 'begin', 'turn' + n);
|
||||
writeFileSync(file, JSON.stringify(f.put('new', n === '1' ? Number.MAX_SAFE_INTEGER : 2)) + '\n');
|
||||
@@ -95,7 +96,7 @@ test('REQ-LIMIT-4 a resumed thread bills only new assistant messages to its next
|
||||
const root = join(f.root, 'threads/demo/thread1/session'); mkdirSync(root, { recursive: true });
|
||||
const file = join(root, 'session.jsonl');
|
||||
writeFileSync(file, JSON.stringify(f.put('historical', 900)) + '\n');
|
||||
const session = { ...f.session, run: 'next-run', thread: 'thread1' };
|
||||
const session = { ...f.session, run: 'next-run', thread: 'thread1', transcript: root };
|
||||
await f.usage.collect(session, 'begin', 'turn2');
|
||||
writeFileSync(file, [f.put('historical', 900), f.put('new', 6)].map(JSON.stringify).join('\n') + '\n');
|
||||
await f.usage.collect(session, 'end', 'turn2');
|
||||
@@ -118,7 +119,7 @@ test('REQ-LIMIT-4 every enabled source is called; failure is unknown and a parti
|
||||
const bus = join(plugins.root, 'packages/bus/src'); mkdirSync(bus, { recursive: true });
|
||||
copyFileSync(new URL('../src/usage.mjs', import.meta.url), join(bus, 'usage.mjs'));
|
||||
const { createUsage: create } = await import(pathToFileURL(join(bus, 'usage.mjs')));
|
||||
const usage = await create({ store: f.store, dataRoot: f.root,
|
||||
const usage = await create({ store: f.store,
|
||||
businesses: { demo: { plugins: { 'usage-source': ['meter', 'broken', 'invalid'] } } }, log: e => f.logs.push(e) });
|
||||
await usage.collect(f.session, 'begin', 'turn1');
|
||||
await usage.collect(f.session, 'end', 'turn1');
|
||||
@@ -138,3 +139,17 @@ test('REQ-LIMIT-4 a turn whose reported sum exceeds safe precision is unknown, n
|
||||
assert.equal(f.store.get("SELECT code FROM usage_records WHERE phase='end'").code, 'usage-overflow');
|
||||
assert.equal(f.usage.view('demo').total.unknownTurns, 1);
|
||||
});
|
||||
|
||||
test('row 65 a run bound without a transcript directory records no-transcript', async t => {
|
||||
const f = await fixture(t);
|
||||
f.save([f.put('old', 99)]);
|
||||
const { transcript, ...session } = f.session;
|
||||
await f.usage.collect(session, 'begin', 'turn1');
|
||||
f.save([f.put('old', 99), f.put('new')]);
|
||||
await f.usage.collect(session, 'end', 'turn1');
|
||||
assert.deepEqual(f.store.all('SELECT phase, status, code FROM usage_records ORDER BY phase').map(r => [r.phase, r.status, r.code]),
|
||||
// The end row's delta has no baseline to subtract from.
|
||||
[['begin', 'unknown', 'no-transcript'], ['end', 'unknown', 'baseline-unknown']]);
|
||||
assert.equal(f.usage.view('demo').total.unknownTurns, 1);
|
||||
assert.ok(transcript);
|
||||
});
|
||||
|
||||
@@ -262,6 +262,16 @@ out becomes gated.
|
||||
binds a tester from `tester-cli.mjs` for PM tests; it reads the value at bus
|
||||
start, so a change takes a bus restart.
|
||||
|
||||
A managed session's route is three agent vars (row 65, REQ-PLUG-2):
|
||||
`harness` names a harness plug-in module (the loader's name pattern),
|
||||
`model` is a `launch.models` key, and `thinking` is one of `off`,
|
||||
`minimal`, `low`, `medium`, `high`, `xhigh` and `max`. Whether the module
|
||||
exists, is switched on and applies the level is checked at launch and at a
|
||||
write, never at load. `fallback.harness`, `fallback.model` and
|
||||
`fallback.thinking` state a second route; a layer sets all three or none,
|
||||
and a partial set refuses. No layer falls back to the system's
|
||||
`execution.adapter` or `execution.model`, which stay for `run-task.sh`.
|
||||
|
||||
`resolve` prints `provenance` beside `vars`. For a plain key it's the
|
||||
layer that set the value (`default`, `system`, `business:<id>`,
|
||||
`project:<id>`, `project:<id>:roles.<instance>`,
|
||||
@@ -323,12 +333,60 @@ launches keep using the whole validated file on disk, without consulting receipt
|
||||
confirm that its old writer is gone before clearing it; the host does not
|
||||
race another recovering process by deleting locks automatically. Settings reads never repair evidence themselves.
|
||||
`launch-settings.mjs` restricts human edits to limits, model table, instance
|
||||
model selections and assistant settings, and validates effective selections before publication. `plugin-settings.mjs`
|
||||
model selections, routes and assistant settings, and checks every route before publication. `plugin-settings.mjs`
|
||||
is the only writer of `plugins`: the host calls it, and no broker verb, `mosaic`
|
||||
command or Settings form reaches it. A refused `plugins` write throws
|
||||
`invalid-plugins` or `invalid-request` before the writer runs and logs nothing. Migration
|
||||
uses none of this: it only prints a converted document (decision 145).
|
||||
|
||||
### Routes (row 65, #1558, mos-0840)
|
||||
|
||||
`launch-settings.mjs` also has the routing table's read and write. The bus
|
||||
reaches them through the human-only verbs `routes` and `routes.set`. Neither
|
||||
is in `ORGANIZATION_KEYS`, so `organization.set` and the chief of staff's
|
||||
`change_organization` can't send a `routes` key. Through `limits.set` the
|
||||
chief changes an instance's `model` through `instances`, and the assistant's
|
||||
whole vars through `assistants` (Q169). Both writes run the same
|
||||
`checkRoutes`.
|
||||
|
||||
`routes(id)` returns the revision, the switched-on harnesses
|
||||
(`plugins.harness`), the `launch.models` keys, `levels` (each harness the
|
||||
file names or switches on, with the levels its module applies, or null for
|
||||
a module that didn't load) and one entry per instance: `harness`, `model`,
|
||||
`thinking` (null when unset) and `fallback` (`{harness, model, thinking}`
|
||||
or null). `setRoutes(id, args, actor)` takes:
|
||||
|
||||
```json
|
||||
{ "revision": "<64 hex>", "routes": { "coder": { "harness": "pi", "model": "glm-5.3-flash", "thinking": "high", "fallback": null } } }
|
||||
```
|
||||
|
||||
Each named instance's route vars are replaced in one write, and a null
|
||||
`fallback` clears all three fallback vars. It refuses, writing and logging
|
||||
nothing:
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `invalid-request` | a key other than `revision` and `routes`, a bad revision, an empty `routes`, or a route without exactly `harness`, `model`, `thinking` and `fallback` as strings |
|
||||
| `BusinessError` (the bus answers `invalid-request`) | the next document fails `validateBusinessDocument`: a level no harness has, a harness name outside the pattern, or a `model` that isn't a `launch.models` key |
|
||||
| `organization-unknown-instance` | an instance the file lacks |
|
||||
| `organization-partial-fallback` | a fallback that isn't null and isn't exactly three strings |
|
||||
| `organization-no-change` | the file already says that |
|
||||
| `unknown-model` | a `fallback.model` that isn't a `launch.models` key |
|
||||
| `harness-off` | a changed route's harness isn't in `plugins.harness` |
|
||||
| `harness-refused` | a changed route's harness module didn't load |
|
||||
| `thinking-unsupported` | a changed route's module doesn't apply its level |
|
||||
| `revision-conflict` | the file changed since the read |
|
||||
|
||||
`checkRoutes` is shared: `instances.set` and `limits.set` run it too, so no
|
||||
write of the route vars skips it. It checks a route only when the write
|
||||
changes its harness or thinking, so a model change still saves while
|
||||
`plugins.harness` is unset, and that instance's launch refuses
|
||||
`harness-off` instead. The model check covers every set model and
|
||||
fallback model. A route var
|
||||
the instance doesn't set is left to the launch, which refuses `no-route`.
|
||||
The bus host passes `levels` from `harnessLevels` (`packages/bus`); a writer
|
||||
built without it answers null for every harness.
|
||||
|
||||
### Assistant settings (row 155, #1649)
|
||||
|
||||
Jason's ruling Q169 lets the human and the chief of staff configure the
|
||||
@@ -406,8 +464,8 @@ and whether `add` and `set` accept it. `set` takes:
|
||||
- `organization-instance-exists`, `organization-unknown-instance`,
|
||||
`organization-launch-not-configured` and `organization-no-change` refuse
|
||||
what their names say.
|
||||
- The whole next document goes through `validateBusinessDocument`, the model
|
||||
check, and each project file's id and instance resolution
|
||||
- The whole next document goes through `validateBusinessDocument`, the route
|
||||
checks (`checkRoutes`, under "Routes" above), and each project file's id and instance resolution
|
||||
(`organization-project-mismatch`, `organization-unresolvable-instance`),
|
||||
the checks `mosaic business validate` runs.
|
||||
- The organization writer then refuses a stale revision
|
||||
|
||||
@@ -7,7 +7,7 @@ export {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
} from "./vocabulary.mjs";
|
||||
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
|
||||
export { LAYERS, REGISTRY, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
|
||||
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
|
||||
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { organizationWriter, OrganizationError } from "./organization.mjs";
|
||||
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
|
||||
import { checkRoutes } from "./launch-settings.mjs";
|
||||
import { loadProject, projectFilePath } from "./project.mjs";
|
||||
import { resolveInstance } from "./resolve.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
@@ -29,7 +30,9 @@ function definitionFor(rolesDir, name) {
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function instanceSettings({ dir, rolesDir, system }) {
|
||||
// levels(name): the thinking levels harness module `name` applies, or null (launch-settings.mjs
|
||||
// checkRoutes, row 65).
|
||||
export function instanceSettings({ dir, rolesDir, system, levels = () => null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
|
||||
// The definitions in rolesDir and whether add and set accept each one.
|
||||
@@ -124,16 +127,17 @@ export function instanceSettings({ dir, rolesDir, system }) {
|
||||
}
|
||||
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
|
||||
|
||||
// The checks mosaic business validate runs, before the writer publishes anything.
|
||||
// The checks mosaic business validate runs, before the writer publishes anything, then the
|
||||
// route checks every write of the route vars runs (row 65). A file that no longer validates
|
||||
// has every route checked.
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
|
||||
if (proposed.launch) {
|
||||
for (const name of Object.keys(proposed.roles)) {
|
||||
const vars = resolveInstance({ business: proposed, system, instance: name }).vars;
|
||||
if (!Object.hasOwn(proposed.launch.models, vars.model ?? vars["execution.model"])) {
|
||||
throw new OrganizationError("unknown-model");
|
||||
}
|
||||
}
|
||||
let before = null;
|
||||
try {
|
||||
before = validateBusinessDocument(old, businessFilePath(business, dir), { rolesDir });
|
||||
} catch (e) {
|
||||
if (e.name !== "BusinessError") throw e;
|
||||
}
|
||||
checkRoutes({ proposed, old: before, levels });
|
||||
for (const [project, entry] of Object.entries(proposed.projects)) {
|
||||
if (!existsSync(projectFilePath(entry.root))) continue;
|
||||
const loaded = loadProject(entry.root);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { organizationWriter, OrganizationError } from "./organization.mjs";
|
||||
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
|
||||
import { resolveInstance } from "./resolve.mjs";
|
||||
import { usageCounts } from "./launch.mjs";
|
||||
import { requireObject, rejectUnknownKeys } from "./util.mjs";
|
||||
import { canonicalJson, isPlainObject, requireObject, rejectUnknownKeys } from "./util.mjs";
|
||||
import { FALLBACK_VARS } from "./vars.mjs";
|
||||
import { THREAD_DEFINITION } from "./vocabulary.mjs";
|
||||
|
||||
// Row 155 (#1649, Jason's ruling Q169): the fields of an assistant instance's entry the human and
|
||||
@@ -67,7 +68,42 @@ function reset(limits, paths, now) {
|
||||
}
|
||||
}
|
||||
|
||||
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString() }) {
|
||||
// Row 65 (REQ-PLUG-2): the checks every write of an instance's route vars runs: the routes table's,
|
||||
// instances.set's and limits.set's. Each instance's model, and its fallback model when it states a
|
||||
// fallback, must be a launch.models key. A route the write changes must name a harness that is
|
||||
// switched on in plugins.harness (harness-off) and whose module applies its thinking level
|
||||
// (thinking-unsupported). levels(name) answers the levels a harness module applies, or null when
|
||||
// the module didn't load, which refuses harness-refused. An unchanged route isn't checked, so a
|
||||
// model change still saves while plugins.harness is unset; that instance's launch refuses instead.
|
||||
// A route var the instance doesn't set is left to the launch, which refuses no-route.
|
||||
const ROUTES = Object.freeze([["harness", "model", "thinking"], FALLBACK_VARS]);
|
||||
export function checkRoutes({ proposed, old = null, levels }) {
|
||||
const on = Array.isArray(proposed.plugins?.harness) ? proposed.plugins.harness : [];
|
||||
for (const [instance, entry] of Object.entries(proposed.roles)) {
|
||||
const vars = entry.vars;
|
||||
const before = old && Object.hasOwn(old.roles, instance) ? old.roles[instance].vars : {};
|
||||
for (const [harness, model, thinking] of ROUTES) {
|
||||
// A missing model refuses no-route at launch, not here.
|
||||
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
|
||||
throw new OrganizationError("unknown-model", `${instance} ${model} ${JSON.stringify(vars[model] ?? null)} isn't a launch.models key`);
|
||||
}
|
||||
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
|
||||
const name = vars[harness];
|
||||
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} ${harness} ${name} isn't switched on in plugins.harness`);
|
||||
if (vars[thinking] === undefined) continue;
|
||||
const applies = levels(name);
|
||||
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `harness module ${name} didn't load, so its thinking levels are unknown`);
|
||||
if (!applies.includes(vars[thinking])) {
|
||||
throw new OrganizationError("thinking-unsupported", `harness ${name} doesn't apply thinking ${vars[thinking]} (${instance} ${thinking}); it applies ${applies.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]);
|
||||
|
||||
// levels(name): the thinking levels harness module `name` applies, or null (checkRoutes).
|
||||
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
|
||||
// The validated business as the file stands now (row 125: the limit checks read it at each ask).
|
||||
@@ -75,19 +111,29 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir });
|
||||
}
|
||||
|
||||
// The file before a write, validated, for checkRoutes; null when it no longer validates, so every
|
||||
// route is checked.
|
||||
function before(id, document) {
|
||||
try {
|
||||
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
|
||||
} catch (e) {
|
||||
if (e.name === "BusinessError") return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function view(id) {
|
||||
const { document, revision } = writer.read(id);
|
||||
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
|
||||
if (!business.launch) throw new OrganizationError("launch-not-configured");
|
||||
const instances = Object.fromEntries(Object.keys(business.roles).map(instance => {
|
||||
const vars = resolveInstance({ business, system, instance }).vars;
|
||||
const key = vars.model ?? vars["execution.model"];
|
||||
const key = vars.model;
|
||||
// No model var reads as unset; the launch refuses it no-route.
|
||||
if (key === undefined) return [instance, { harness: vars.harness ?? null, provider: null, model: null, modelKey: null }];
|
||||
const entry = Object.hasOwn(business.launch.models, key) ? business.launch.models[key] : null;
|
||||
if (!entry) throw new OrganizationError("unknown-model");
|
||||
return [instance, {
|
||||
harness: vars.harness ?? vars["execution.adapter"],
|
||||
...entry, modelKey: key,
|
||||
}];
|
||||
return [instance, { harness: vars.harness ?? null, ...entry, modelKey: key }];
|
||||
}));
|
||||
// Each assistant instance's entry fields as the file has them, so a change replaces what it shows.
|
||||
const assistants = Object.fromEntries(Object.entries(document.roles)
|
||||
@@ -155,17 +201,82 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
for (const [instance, entry] of Object.entries(old.document.roles)) {
|
||||
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
|
||||
}
|
||||
// Check effective inherited selections before publishing the complete document.
|
||||
// Check every instance's route before publishing the complete document.
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
|
||||
for (const instance of Object.keys(proposed.roles)) {
|
||||
const vars = resolveInstance({ business: proposed, system, instance }).vars;
|
||||
if (!Object.hasOwn(proposed.launch.models, vars.model ?? vars["execution.model"])) {
|
||||
throw new OrganizationError("unknown-model");
|
||||
}
|
||||
}
|
||||
checkRoutes({ proposed, old: before(id, old.document), levels });
|
||||
const receipt = writer.write(id, next, { revision: args.revision, actor });
|
||||
return { ...view(id), transaction: receipt.transaction };
|
||||
}
|
||||
|
||||
return { writer, read: view, set, current };
|
||||
// Row 65 (mos-0840): the routing table. One row per instance with its harness, model, thinking
|
||||
// and fallback, and what each cell offers: the switched-on harnesses, the launch.models keys and
|
||||
// the levels each harness applies (null for a module that didn't load). A route var the instance
|
||||
// doesn't set reads null; its launch refuses no-route.
|
||||
function routes(id) {
|
||||
const { document, revision } = writer.read(id);
|
||||
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
|
||||
if (!business.launch) throw new OrganizationError("launch-not-configured");
|
||||
const harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : [];
|
||||
const named = new Set(harnesses);
|
||||
const instances = Object.fromEntries(Object.entries(business.roles).map(([instance, { vars }]) => {
|
||||
for (const key of ["harness", "fallback.harness"]) if (Object.hasOwn(vars, key)) named.add(vars[key]);
|
||||
return [instance, {
|
||||
harness: vars.harness ?? null, model: vars.model ?? null, thinking: vars.thinking ?? null,
|
||||
fallback: Object.hasOwn(vars, "fallback.harness")
|
||||
? { harness: vars["fallback.harness"], model: vars["fallback.model"], thinking: vars["fallback.thinking"] } : null,
|
||||
}];
|
||||
}));
|
||||
return {
|
||||
business: id, revision, harnesses, models: Object.keys(business.launch.models),
|
||||
levels: Object.fromEntries([...named].sort().map(name => [name, levels(name)])), instances,
|
||||
};
|
||||
}
|
||||
|
||||
// args: { revision, routes: { <instance>: { harness, model, thinking, fallback } } }, fallback
|
||||
// being null or { harness, model, thinking }. Each named instance's route vars are replaced in one
|
||||
// organization write; a null fallback clears all three. It is human-only and outside
|
||||
// ORGANIZATION_KEYS (the broker's routes.set). The chief of staff's tool changes the model key,
|
||||
// and the assistant's whole vars (Q169), through limits.set, which runs the same checkRoutes.
|
||||
function setRoutes(id, args, actor) {
|
||||
requireObject(args, "routes update");
|
||||
rejectUnknownKeys(args, ["revision", "routes"], "routes update");
|
||||
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
|
||||
throw new OrganizationError("invalid-request");
|
||||
}
|
||||
if (!isPlainObject(args.routes) || Object.keys(args.routes).length === 0) throw new OrganizationError("invalid-request");
|
||||
const old = writer.read(id);
|
||||
const next = structuredClone(old.document);
|
||||
if (!next.launch) throw new OrganizationError("launch-not-configured");
|
||||
const exact = (value, keys) => isPlainObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
||||
for (const [instance, route] of Object.entries(args.routes)) {
|
||||
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("organization-unknown-instance");
|
||||
if (!exact(route, [...ROUTE_FIELDS, "fallback"]) || ROUTE_FIELDS.some(key => typeof route[key] !== "string")) {
|
||||
throw new OrganizationError("invalid-request");
|
||||
}
|
||||
const { fallback } = route;
|
||||
if (fallback !== null && (!exact(fallback, ROUTE_FIELDS) || ROUTE_FIELDS.some(key => typeof fallback[key] !== "string"))) {
|
||||
throw new OrganizationError("organization-partial-fallback", `${instance}'s fallback sets all of harness, model and thinking or is cleared`);
|
||||
}
|
||||
// Before the file's own validation, which would refuse an unknown primary model without a code.
|
||||
for (const model of [route.model, fallback?.model]) {
|
||||
if (model !== undefined && !Object.hasOwn(next.launch.models, model)) {
|
||||
throw new OrganizationError("unknown-model", `${instance} model ${JSON.stringify(model)} isn't a launch.models key`);
|
||||
}
|
||||
}
|
||||
const vars = { ...next.roles[instance].vars };
|
||||
for (const key of FALLBACK_VARS) delete vars[key];
|
||||
for (const key of ROUTE_FIELDS) {
|
||||
vars[key] = route[key];
|
||||
if (fallback !== null) vars[`fallback.${key}`] = fallback[key];
|
||||
}
|
||||
next.roles[instance].vars = vars;
|
||||
}
|
||||
if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change");
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
|
||||
checkRoutes({ proposed, old: before(id, old.document), levels });
|
||||
const receipt = writer.write(id, next, { revision: args.revision, actor });
|
||||
return { ...routes(id), transaction: receipt.transaction };
|
||||
}
|
||||
|
||||
return { writer, read: view, set, current, routes, setRoutes };
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
|
||||
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
|
||||
import { NAME as MODULE_NAME } from "../../plugins/src/kinds.mjs";
|
||||
|
||||
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
|
||||
|
||||
@@ -33,6 +34,13 @@ function oneOf(values) {
|
||||
};
|
||||
}
|
||||
|
||||
// A harness plug-in module's name, in the loader's pattern (row 65, REQ-PLUG-2). Whether the
|
||||
// module exists or is switched on is checked at launch and at a write, never at load.
|
||||
function moduleName(v, key) {
|
||||
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function integer(min) {
|
||||
return (v, key) => {
|
||||
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
|
||||
@@ -85,6 +93,10 @@ function absolutePath(v, key) {
|
||||
return v;
|
||||
}
|
||||
|
||||
// Every level some harness applies; each module exports the ones it applies (row 65).
|
||||
export const THINKING = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
export const FALLBACK_VARS = Object.freeze(["fallback.harness", "fallback.model", "fallback.thinking"]);
|
||||
|
||||
const def = (layers, check, extra = {}) => Object.freeze({ layers: Object.freeze(layers), merge: "replace", check, default: undefined, ...extra });
|
||||
|
||||
export const REGISTRY = Object.freeze({
|
||||
@@ -124,10 +136,15 @@ export const REGISTRY = Object.freeze({
|
||||
return v;
|
||||
}),
|
||||
|
||||
// Agent: one role instance.
|
||||
harness: def(["agent"], oneOf(["pi", "claude-code"])),
|
||||
// Agent: one role instance. A managed session's route: the harness module, a launch.models key
|
||||
// and a thinking level, which the harness module must apply (row 65). The fallback route is all
|
||||
// three or none (FALLBACK_VARS).
|
||||
harness: def(["agent"], moduleName),
|
||||
model: def(["agent"], string(200)),
|
||||
thinking: def(["agent"], oneOf(["off", "minimal", "low", "medium", "high", "xhigh"])),
|
||||
thinking: def(["agent"], oneOf(THINKING)),
|
||||
"fallback.harness": def(["agent"], moduleName),
|
||||
"fallback.model": def(["agent"], string(200)),
|
||||
"fallback.thinking": def(["agent"], oneOf(THINKING)),
|
||||
|
||||
// Limits narrow the role definition's ceilings. limits.authority is an
|
||||
// allowlist: a role action it doesn't name becomes gated.
|
||||
@@ -152,6 +169,10 @@ export function checkVars(vars, layer, where) {
|
||||
if (!entry.layers.includes(layer)) refuse(`${where}: variable ${key} can't be set at the ${layer} layer (allowed: ${entry.layers.join(", ")})`);
|
||||
out[key] = entry.check(value, key);
|
||||
}
|
||||
const fallback = FALLBACK_VARS.filter((key) => Object.hasOwn(out, key));
|
||||
if (fallback.length > 0 && fallback.length < FALLBACK_VARS.length) {
|
||||
refuse(`${where}: a fallback sets all of ${FALLBACK_VARS.join(", ")} or none (missing ${FALLBACK_VARS.filter((key) => !fallback.includes(key)).join(", ")})`);
|
||||
}
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ const roots = [];
|
||||
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
|
||||
|
||||
// The business file sits where mosaic business validate looks for it, so the test can run it.
|
||||
function fixture(mutate) {
|
||||
// Row 65: a write that sets a harness needs it switched on in plugins.harness.
|
||||
const harnessOn = (doc, names = ["pi", "claude-code"]) => { doc.plugins = { ...(doc.plugins ?? {}), harness: names }; };
|
||||
const LEVELS = { pi: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], "claude-code": ["low", "medium", "high", "xhigh", "max"] };
|
||||
|
||||
function fixture(mutate, { levels } = {}) {
|
||||
const root = tmp();
|
||||
roots.push(root);
|
||||
const rolesDir = rolesCopy(root);
|
||||
@@ -24,7 +28,7 @@ function fixture(mutate) {
|
||||
const dir = join(root, "config");
|
||||
const file = writeJson(join(dir, "businesses", "acme.json"), doc);
|
||||
const config = systemConfig(root);
|
||||
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) });
|
||||
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root), ...(levels ? { levels } : {}) });
|
||||
const validate = () => spawnSync(process.execPath, [CLI, "validate", "acme"], {
|
||||
env: { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesDir }, encoding: "utf8",
|
||||
});
|
||||
@@ -81,7 +85,7 @@ test("row 165 add: two instances join, validate exits 0, the log names the human
|
||||
});
|
||||
|
||||
test("row 165 change: definition, vars and launch membership log before and after; only a definition change asks for a restart", () => {
|
||||
const f = fixture(doc => { doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi" } }; });
|
||||
const f = fixture(doc => { harnessOn(doc); doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi" } }; });
|
||||
const redefined = f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "sec-1", op: "change", definition: "merge-gate" }, "jason");
|
||||
assert.equal(redefined.instances["sec-1"].definition, "merge-gate");
|
||||
assert.equal(redefined.restart, true);
|
||||
@@ -147,7 +151,7 @@ test("row 165 refuses a definition change to or from one that declares credentia
|
||||
});
|
||||
|
||||
test("row 165 a vars or launch change on an instance with its binding succeeds and keeps the binding", () => {
|
||||
const f = fixture();
|
||||
const f = fixture(doc => harnessOn(doc));
|
||||
const before = JSON.parse(readFileSync(f.file, "utf8")).roles.coder;
|
||||
const done = f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "coder", op: "change", vars: { harness: "claude-code" }, launch: false }, "jason");
|
||||
assert.equal(done.restart, false);
|
||||
@@ -186,3 +190,27 @@ test("row 165 refuses a stale revision and leaves the file's bytes alone", () =>
|
||||
assert.throws(() => f.settings.set("acme", { revision: stale, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason"), e => e.code === "revision-conflict");
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
});
|
||||
|
||||
// Row 65 (REQ-PLUG-2): a write that sets a harness that isn't switched on, a thinking level its
|
||||
// module doesn't apply, a model that isn't a launch.models key or a partial fallback refuses, and
|
||||
// the file keeps its bytes. A route the write doesn't touch isn't rechecked for its harness.
|
||||
test("row 65 an instance write refuses harness-off, thinking-unsupported, an unknown model and a partial fallback", () => {
|
||||
const off = fixture();
|
||||
refuses(off, { instance: "coder", op: "change", vars: { harness: "claude-code" } }, "harness-off");
|
||||
// The file's own unswitched harnesses don't block a write that leaves them alone.
|
||||
const kept = off.settings.set("acme", { revision: off.settings.read("acme").revision, instance: "coder", op: "change", vars: { harness: "pi", "limits.network": "none", "limits.tools": [] } }, "jason");
|
||||
assert.equal(kept.restart, false);
|
||||
const f = fixture(doc => harnessOn(doc, ["pi"]), { levels: name => LEVELS[name] ?? null });
|
||||
refuses(f, { instance: "coder", op: "change", vars: { harness: "claude-code" } }, "harness-off");
|
||||
refuses(f, { instance: "coder", op: "change", vars: { harness: "pi", "fallback.harness": "claude-code", "fallback.model": "claude-opus-5-5", "fallback.thinking": "high" } }, "harness-off");
|
||||
const g = fixture(doc => harnessOn(doc), { levels: name => LEVELS[name] ?? null });
|
||||
refuses(g, { instance: "coder", op: "change", vars: { harness: "claude-code", thinking: "minimal" } }, "thinking-unsupported");
|
||||
// The business file's own launch check names an unknown model first.
|
||||
refuses(g, { instance: "coder", op: "change", vars: { harness: "pi", model: "ghost" } }, "BusinessError");
|
||||
refuses(g, { instance: "coder", op: "change", vars: { harness: "pi", "fallback.harness": "pi" } }, "BusinessError");
|
||||
const done = g.settings.set("acme", { revision: g.settings.read("acme").revision, instance: "coder", op: "change", vars: { harness: "claude-code", model: "claude-opus-5-5", thinking: "max", "fallback.harness": "pi", "fallback.model": "claude-sonnet-5-5", "fallback.thinking": "minimal" } }, "jason");
|
||||
assert.deepEqual(done.instances.coder.vars, { harness: "claude-code", model: "claude-opus-5-5", thinking: "max", "fallback.harness": "pi", "fallback.model": "claude-sonnet-5-5", "fallback.thinking": "minimal" });
|
||||
// A module that didn't load gives no levels, so a thinking write on it refuses.
|
||||
const h = fixture(doc => harnessOn(doc, ["pi", "fixture"]), { levels: name => LEVELS[name] ?? null });
|
||||
refuses(h, { instance: "coder", op: "change", vars: { harness: "fixture", thinking: "high" } }, "harness-refused");
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test('REQ-LIMIT-1 shared settings validates the complete proposed selection and
|
||||
});
|
||||
test('REQ-LIMIT-1 provider is table data: another provider routes separately and aliases share exact counts',async()=>{
|
||||
const {limitHit}=await import('../src/launch.mjs');const {sessionModel}=await import('../../harness/src/bundle.mjs');
|
||||
const models={alias:{provider:'proxy-next',model:'claude-opus-5-5'}},selected=sessionModel({vars:{harness:'claude-code',model:'alias'}},models);assert.equal(selected.provider,'proxy-next');assert.equal(selected.model,'claude-opus-5-5');
|
||||
const models={alias:{provider:'proxy-next',model:'claude-opus-5-5'}},selected=sessionModel({vars:{harness:'claude-code',model:'alias',thinking:'high'}},models);assert.equal(selected.provider,'proxy-next');assert.equal(selected.model,'claude-opus-5-5');
|
||||
const sessions=[{business:'acme',provider:'anthropic',model:selected.model}];assert.equal(limitHit(sessions,'acme',{providers:{'proxy-next':1},models:{'proxy-next':{[selected.model]:1}}},selected),null);sessions.push({business:'acme',provider:'proxy-next',model:selected.model});assert.equal(limitHit(sessions,'acme',{providers:{'proxy-next':1}},selected).scope,'provider');assert.equal(limitHit(sessions,'acme',{models:{'proxy-next':{[selected.model]:1}}},selected).scope,'model');
|
||||
assert.equal(limitHit([],'acme',{providers:{constructor:0}},{provider:'constructor',model:'toString'}).running,0);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ test('Q169 assistants: vars and definition change and log the actor; tracker nul
|
||||
const done=settings.set('acme',{revision:view.revision,assistants:{assistant:{vars:{model:'opus',thinking:'high'},definition:'assistant',tracker:null}}},'jason');
|
||||
assert.deepEqual(done.assistants.assistant,{definition:'assistant',vars:{model:'opus',thinking:'high'},tracker:null});assert.equal(done.instances.assistant.modelKey,'opus');
|
||||
const e=JSON.parse(readFileSync(settings.writer.logFile('acme'),'utf8').trim().split('\n').at(-1));assert.equal(e.actor,'jason');assert.ok(e.changes.some(c=>c.path==='roles.assistant.vars.thinking'&&c.new==='high'));
|
||||
const chief=settings.set('acme',{revision:done.revision,assistants:{assistant:{vars:{}}}},'chief-of-staff.chief');assert.deepEqual(chief.assistants.assistant.vars,{});assert.equal(chief.instances.assistant.modelKey,'sonnet');
|
||||
const chief=settings.set('acme',{revision:done.revision,assistants:{assistant:{vars:{}}}},'chief-of-staff.chief');assert.deepEqual(chief.assistants.assistant.vars,{});assert.equal(chief.instances.assistant.modelKey,null,'row 65: no execution.model fallback');
|
||||
const last=JSON.parse(readFileSync(settings.writer.logFile('acme'),'utf8').trim().split('\n').at(-1));assert.equal(last.actor,'chief-of-staff.chief');
|
||||
});
|
||||
test('Q169 assistants: refuses other instances, other definitions, holder and credentials, empty entries, a tracker the definition has no credential for and a stale revision',async()=>{
|
||||
@@ -75,7 +75,7 @@ test('Q169 assistants: refuses other instances, other definitions, holder and cr
|
||||
for(const definition of ['coder','chief-of-staff',null])assert.throws(()=>set({assistant:{definition}}),code('organization-definition-refused'),String(definition));
|
||||
assert.throws(()=>set({ghost:{vars:{}}}),code('invalid-request'));assert.throws(()=>set({assistant:{}}),code('invalid-request'));
|
||||
assert.throws(()=>set({assistant:{holder:'rex'}}),/unsupported assistant settings assistant key: "holder"/);assert.throws(()=>set({assistant:{credentials:{}}}),/unsupported assistant settings assistant key: "credentials"/);
|
||||
assert.throws(()=>set({assistant:{vars:{harness:'cursor'}}}),/harness/);assert.throws(()=>set({assistant:{vars:{model:'ghost'}}}),/unknown model/);
|
||||
assert.throws(()=>set({assistant:{vars:{harness:'cursor'}}}),code('harness-off'));assert.throws(()=>set({assistant:{vars:{model:'ghost'}}}),/unknown model/);
|
||||
assert.throws(()=>set({assistant:{tracker:{bot:'bot-acme-assistant',botId:11}}}),/has "tracker" but assistant uses no vikunja credential/);
|
||||
const moved=settings.set('acme',{revision,assistants:{assistant:{vars:{model:'opus'}}}},'jason');assert.notEqual(moved.revision,revision);const after=readFileSync(f.file,'utf8');
|
||||
assert.throws(()=>set({assistant:{vars:{model:'sonnet'}}}),code('revision-conflict'));assert.equal(readFileSync(f.file,'utf8'),after);assert.notEqual(after,bytes);
|
||||
@@ -87,3 +87,58 @@ test('Q169 assistants: a definition with a vikunja credential takes a tracker ch
|
||||
assert.throws(()=>set({assistant:{tracker:null}}),/needs "tracker"/);assert.throws(()=>set({assistant:{tracker:{bot:'bot-acme-pm',botId:12}}}),/used by another instance/);assert.throws(()=>set({assistant:{tracker:{bot:'bot-acme-x',botId:12,holder:'rex'}}}),/unsupported/);
|
||||
const done=set({assistant:{tracker:{bot:'bot-acme-helper',botId:12}}});assert.deepEqual(done.assistants.assistant.tracker,{bot:'bot-acme-helper',botId:12});assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.assistant.credentials.vikunja.file,join(f.root,'secrets','assistant-vikunja.token'));
|
||||
});
|
||||
// Row 65 (mos-0840): the routing table. routes() reads one row per instance with its six values, and
|
||||
// setRoutes() saves a route and a whole fallback in one write, refusing what a launch would refuse.
|
||||
const LEVELS={pi:['off','low','high'],'claude-code':['low','high']};
|
||||
async function routeSettings(f,harness=['pi','claude-code']){const doc=structuredClone(f.doc);if(harness)doc.plugins={...doc.plugins,harness};else delete doc.plugins?.harness;for(const r of Object.values(doc.roles))r.vars={harness:'pi',thinking:'high',...r.vars};writeJson(f.file,doc);const {launchSettings}=await import('../src/launch-settings.mjs');return {doc,settings:launchSettings({dir:f.root,rolesDir:f.rolesDir,system:{environment:'development',dataRoot:join(f.root,'data'),'execution.backend':'docker','execution.provider':'anthropic','execution.model':'sonnet','execution.adapter':'pi'},levels:name=>LEVELS[name]??null})};}
|
||||
const routeOf=v=>({harness:v.harness??null,model:v.model??null,thinking:v.thinking??null,fallback:null});
|
||||
test('row 65 routes lists every instance with harness, model, thinking and fallback, the switched-on harnesses, the model keys and each harness\'s levels',async()=>{
|
||||
const f=fixture();const {doc,settings}=await routeSettings(f);const view=settings.routes('acme');
|
||||
assert.deepEqual(Object.keys(view.instances).sort(),Object.keys(doc.roles).sort());
|
||||
for(const [instance,{vars}] of Object.entries(doc.roles))assert.deepEqual(view.instances[instance],routeOf(vars),instance);
|
||||
assert.deepEqual([view.harnesses,view.models,view.levels],[['pi','claude-code'],['opus','sonnet'],{'claude-code':['low','high'],pi:['off','low','high']}]);
|
||||
assert.match(view.revision,/^[a-f0-9]{64}$/);
|
||||
});
|
||||
test('row 65 setRoutes saves a harness, model, thinking and whole fallback, reads each back from the file, logs the actor, and a null fallback clears all three',async()=>{
|
||||
const f=fixture();const {settings}=await routeSettings(f);const {revision}=settings.routes('acme'),{harness,model,thinking,...rest}=JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars;
|
||||
const done=settings.setRoutes('acme',{revision,routes:{coder:{harness:'claude-code',model:'opus',thinking:'low',fallback:{harness:'pi',model:'sonnet',thinking:'off'}}}},'jason');
|
||||
const vars=JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars;
|
||||
// The coder's other vars stay as they were.
|
||||
assert.deepEqual(vars,{...rest,harness:'claude-code',model:'opus',thinking:'low','fallback.harness':'pi','fallback.model':'sonnet','fallback.thinking':'off'});
|
||||
assert.deepEqual(done.instances.coder,{harness:'claude-code',model:'opus',thinking:'low',fallback:{harness:'pi',model:'sonnet',thinking:'off'}});assert.match(done.transaction,/./);
|
||||
const e=JSON.parse(readFileSync(settings.writer.logFile('acme'),'utf8').trim().split('\n').at(-1));assert.equal(e.actor,'jason');assert.ok(e.changes.some(c=>c.path==='roles.coder.vars.fallback.harness'||c.path==='roles.coder.vars'));
|
||||
settings.setRoutes('acme',{revision:done.revision,routes:{coder:{harness:'claude-code',model:'opus',thinking:'low',fallback:null}}},'jason');
|
||||
assert.deepEqual(JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars,{...rest,harness:'claude-code',model:'opus',thinking:'low'});
|
||||
});
|
||||
test('row 65 setRoutes refuses a partial fallback, a switched-off harness, a level the harness doesn\'t apply, an unloaded module, an unknown model or instance, a malformed route, no change and a stale revision, with the file unchanged',async()=>{
|
||||
const f=fixture();const {settings}=await routeSettings(f,['pi']);const {revision}=settings.routes('acme'),bytes=readFileSync(f.file,'utf8');
|
||||
const route=(more={})=>({harness:'pi',model:'sonnet',thinking:'low',fallback:null,...more}),set=(routes,rev=revision)=>settings.setRoutes('acme',{revision:rev,routes},'jason');
|
||||
for(const [routes,c] of [
|
||||
[{coder:route({fallback:{harness:'pi',model:'sonnet'}})},'organization-partial-fallback'],
|
||||
[{coder:route({fallback:{harness:'pi',model:'sonnet',thinking:null}})},'organization-partial-fallback'],
|
||||
[{coder:route({harness:'claude-code'})},'harness-off'],
|
||||
[{coder:route({fallback:{harness:'claude-code',model:'sonnet',thinking:'low'}})},'harness-off'],
|
||||
[{coder:route({thinking:'max'})},'thinking-unsupported'],
|
||||
[{coder:route({fallback:{harness:'pi',model:'sonnet',thinking:'max'}})},'thinking-unsupported'],
|
||||
// setRoutes names an unknown primary model before the file's own validation can refuse it uncoded.
|
||||
[{coder:route({model:'ghost'})},'unknown-model'],
|
||||
[{coder:route({fallback:{harness:'pi',model:'ghost',thinking:'low'}})},'unknown-model'],
|
||||
[{ghost:route()},'organization-unknown-instance'],
|
||||
[{coder:{harness:'pi',model:'sonnet',thinking:'low'}},'invalid-request'],
|
||||
[{coder:route({extra:1})},'invalid-request'],
|
||||
[{},'invalid-request'],
|
||||
])assert.throws(()=>set(routes),typeof c==='string'?code(c):c,JSON.stringify(routes));
|
||||
assert.throws(()=>settings.setRoutes('acme',{revision,routes:{coder:route()},instances:{}},'jason'),/unsupported routes update key/);
|
||||
// A switched-on harness whose module didn't load has no known levels.
|
||||
const g=fixture();const {settings:other}=await routeSettings(g,['pi','cursor']);assert.throws(()=>other.setRoutes('acme',{revision:other.routes('acme').revision,routes:{coder:route({harness:'cursor'})}},'jason'),code('harness-refused'));
|
||||
const {coder}=JSON.parse(bytes).roles;assert.throws(()=>set({coder:{harness:coder.vars.harness,model:coder.vars.model,thinking:coder.vars.thinking,fallback:null}}),code('organization-no-change'));
|
||||
assert.equal(readFileSync(f.file,'utf8'),bytes);
|
||||
const moved=set({coder:route()});assert.notEqual(moved.revision,revision);const after=readFileSync(f.file,'utf8');
|
||||
assert.throws(()=>set({coder:route({thinking:'high'})}),code('revision-conflict'));assert.equal(readFileSync(f.file,'utf8'),after);
|
||||
});
|
||||
test('row 65 a file with harness vars and no plugins.harness loads and its views read; a model change still saves, and a route change refuses harness-off',async()=>{
|
||||
const f=fixture();const {doc,settings}=await routeSettings(f,null);
|
||||
const view=settings.routes('acme');assert.deepEqual(view.harnesses,[]);assert.deepEqual(view.instances.coder,routeOf(doc.roles.coder.vars));assert.equal(settings.read('acme').instances.coder.modelKey,'sonnet');
|
||||
const moved=settings.set('acme',{revision:view.revision,instances:{coder:'opus'}},'jason');assert.equal(moved.instances.coder.modelKey,'opus');
|
||||
assert.throws(()=>settings.setRoutes('acme',{revision:moved.revision,routes:{coder:{harness:'pi',model:'opus',thinking:'low',fallback:null}}},'jason'),code('harness-off'));
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ test("types", () => {
|
||||
ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678", "bus.tester": true }, "business");
|
||||
ok({ "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"], suites: ["scripts/test-task.sh"], "issues.repo": "mosaicstack/stack", "tracker.descendants": true }, "project");
|
||||
ok({ harness: "claude-code", model: "claude-opus-5-5", thinking: "high", "limits.tools": [], "limits.network": "none", "limits.authority": ["message.send"] }, "agent");
|
||||
// Row 65: any module name passes here; whether it is switched on is a launch and write check.
|
||||
ok({ harness: "fixture-harness", model: "m", thinking: "off", "fallback.harness": "pi", "fallback.model": "m", "fallback.thinking": "low" }, "agent");
|
||||
|
||||
const bad = [
|
||||
[{ "tracker.baseUrl": "ftp://x" }, "business", /http or https/],
|
||||
@@ -66,8 +68,12 @@ test("types", () => {
|
||||
[{ "git.protectedBranches": ["main", "main"] }, "project", /duplicate/],
|
||||
[{ suites: "scripts/test-task.sh" }, "project", /must be an array/],
|
||||
[{ "issues.repo": "stack" }, "project", /owner\/name/],
|
||||
[{ harness: "codex" }, "agent", /one of: pi, claude-code/],
|
||||
[{ thinking: "max" }, "agent", /one of/],
|
||||
[{ harness: "Codex" }, "agent", /harness module name/],
|
||||
[{ harness: "../pi" }, "agent", /harness module name/],
|
||||
[{ "fallback.harness": "pi" }, "agent", /a fallback sets all of fallback\.harness, fallback\.model, fallback\.thinking or none \(missing fallback\.model, fallback\.thinking\)/],
|
||||
[{ "fallback.harness": "pi", "fallback.model": "m" }, "agent", /missing fallback\.thinking/],
|
||||
[{ "fallback.harness": "pi", "fallback.model": "m", "fallback.thinking": "huge" }, "agent", /one of/],
|
||||
[{ thinking: "huge" }, "agent", /one of/],
|
||||
[{ model: "" }, "agent", /non-empty string/],
|
||||
[{ "limits.tools": ["render3d"] }, "agent", /unsupported tool/],
|
||||
[{ "web.searxng": "https://search.example.org/" }, "business", /base URL/],
|
||||
|
||||
+31
-13
@@ -338,26 +338,44 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or
|
||||
| the business file has a `launch` block | `launch-not-configured` |
|
||||
| `launch.instances` lists the instance (for `--pm`: it is `launch.by`) | `instance-not-listed` |
|
||||
| the instance isn't running | `instance-running` |
|
||||
| the instance resolves to `pi` or `claude-code` and a model | `unsupported-harness` |
|
||||
| the instance resolves | `unsupported-harness` |
|
||||
| it sets the `harness`, `model` and `thinking` vars, and all three `fallback.` vars if it sets any (row 65); the message names the missing var | `no-route` |
|
||||
| each route's `model` is a `launch.models` key | `unsupported-harness` |
|
||||
| its role has a contract file | `no-role-contract` |
|
||||
| a Claude Code session has `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL` to log in with | `no-session-credential` |
|
||||
| with `launch.account` set, its name is not the value of any of those variables the bus host sets, since usage records keep the name | `account-is-credential` |
|
||||
| with `launch.account` set, a Claude Code session's account names one of those login sets and the bus host holds all of it | `account-credential-missing` |
|
||||
| with `launch.account` set, the bus host sets no other of those variables, `ANTHROPIC_BASE_URL` included | `account-credential-ambiguous` |
|
||||
| `plugins.harness` switches on the primary's harness and a stated fallback's | `harness-off` |
|
||||
| the plug-in loader loads the primary's module; the message names it and the loader's code | `harness-refused` |
|
||||
| the module applies the route's thinking level | `thinking-unsupported` |
|
||||
| the module's preflight passes, else the stated fallback's harness passes the three checks above and its own preflight | the module's code (`no-session-credential`, `harness-binary-missing`, …); a module that throws is `harness-failed` |
|
||||
| on a harness that counts the account, with `launch.account` set, its name is not the value of any session login variable the bus host sets, since usage records keep the name | `account-is-credential` |
|
||||
| on that harness, the account names one of the login sets and the bus host holds all of it | `account-credential-missing` |
|
||||
| on that harness, the bus host sets no other login variable, `ANTHROPIC_BASE_URL` included | `account-credential-ambiguous` |
|
||||
| bwrap runs here | `sandbox-unavailable` |
|
||||
| its model selects an exact entry in `launch.models` | `unsupported-harness` |
|
||||
| total, provider and exact provider/model limits have room; the PM counts too | `capacity-full` |
|
||||
| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
|
||||
| the workspace has a clone of each project, or the host can make one | `checkout-failed`, with the project id |
|
||||
| the bundle builds and the runner starts | `launch-failed` |
|
||||
|
||||
Row 126 (#1620): only a Claude Code launch spends `launch.account.name`. It
|
||||
Row 126 (#1620): only a launch on a harness whose module's `countsAccount()` is true spends `launch.account.name`. It
|
||||
goes to the `mayLaunch` limit checks, to the bus with the run, onto the
|
||||
run's usage records, and onto the `launch` record as `account`. A pi launch
|
||||
run's usage records, and onto the `launch` record as `account`. Of the shipped
|
||||
modules that is Claude Code. A pi launch
|
||||
has account null: its records say null, no daily quota covers it, and the
|
||||
two account checks above are skipped. Both account refusals name the
|
||||
variables, never a value.
|
||||
|
||||
Row 65 (#1558): the route comes from the instance's vars only. The system's
|
||||
`execution.adapter` and `execution.model` never fill a missing one; they stay
|
||||
for `run-task.sh`. The harness is a module under `packages/plugins/harness/`
|
||||
(`packages/plugins/README.md`, "Harness"), loaded through a fresh loader for
|
||||
each launch, and the launcher names no harness itself. A fallback is taken
|
||||
only when the primary's preflight fails: an off or refused primary, or one
|
||||
whose thinking level it doesn't apply, refuses without trying it. If the
|
||||
fallback's preflight fails too, the refusal carries its code and names both
|
||||
reasons. The account, capacity and limit checks run on the route taken. The
|
||||
manifest records `route` (`primary` or `fallback`) and `routeReason` (the
|
||||
primary's preflight code, or null). The reply's `harness` and `model` are
|
||||
the route's.
|
||||
|
||||
Every refusal is an `action.refused` event (the broker's `refuse`, or its
|
||||
own refusal for the last broker check) and a line in the launch log. Then
|
||||
the launcher reads the instance's credential status from the broker
|
||||
@@ -394,11 +412,11 @@ credential check: a Pi role on a provider whose login lives only in those
|
||||
files, or whose key isn't an `ENV_ALLOW` variable, launches and then fails
|
||||
at its first turn.
|
||||
|
||||
The manifest records the Pi version from the repository's pin (the
|
||||
`@earendil-works/pi-coding-agent` dependency in the root `package.json`)
|
||||
and, for Claude Code, `--version` from the binary sessions run:
|
||||
`MOSAIC_CLAUDE_BIN` when set (the unit pins it, below), else `claude` on
|
||||
`PATH`.
|
||||
The manifest's `harnessVersion` is the module's `version()`: for pi the
|
||||
repository's pin (the `@earendil-works/pi-coding-agent` dependency in the
|
||||
root `package.json`), for Claude Code `--version` from the binary sessions
|
||||
run, `MOSAIC_CLAUDE_BIN` when set (the unit pins it, below), else `claude`
|
||||
on `PATH`.
|
||||
|
||||
On disk, under the data root: `launches/<business>/<run>/` (0700) is the run
|
||||
directory; `launches/<business>.jsonl` (0600, append-only) logs every launch,
|
||||
|
||||
@@ -11,10 +11,18 @@
|
||||
// 2. the business file's launch.instances lists the instance
|
||||
// (instance-not-listed)
|
||||
// 3. the instance isn't already running (instance-running)
|
||||
// 4. it resolves, has a role contract (no-role-contract) and a supported
|
||||
// harness and model (unsupported-harness)
|
||||
// 5. a Claude Code session has a credential to log in with
|
||||
// (no-session-credential)
|
||||
// 4. it resolves, has a role contract (no-role-contract), sets the
|
||||
// harness, model and thinking vars (no-route names the missing one)
|
||||
// and its model is a launch.models key (unsupported-harness)
|
||||
// 5. the route's harness (row 65): plugins.harness switches it on, and a
|
||||
// stated fallback's too (harness-off); the plug-in loader loads it
|
||||
// (harness-refused names the module and the loader's reason); it takes
|
||||
// the thinking level (thinking-unsupported); its preflight passes
|
||||
// (the module's code, e.g. no-session-credential or
|
||||
// harness-binary-missing). A failed primary preflight takes the
|
||||
// stated fallback through the same checks; there is no other retry.
|
||||
// A harness whose launches count against launch.account gets the
|
||||
// row 126 account checks.
|
||||
// 6. bwrap runs here (sandbox-unavailable)
|
||||
// 7. its model names an exact provider/model table entry
|
||||
// 8. total, provider and provider/model have room, including the launcher
|
||||
@@ -88,18 +96,16 @@
|
||||
// Nothing here is persisted. After a restart recover() ends the earlier
|
||||
// host's runs, so the first tick starts each holder.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { buildBundle, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
import {
|
||||
ADAPTERS,
|
||||
appendLaunchLog,
|
||||
endReason,
|
||||
hasSessionCredential,
|
||||
gitSocketPath,
|
||||
launchSocketPath,
|
||||
newRun,
|
||||
@@ -118,10 +124,9 @@ import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { CheckoutError, ensureCheckouts } from "../../seat/src/checkout.mjs";
|
||||
import { createGitSocket } from "./git-socket.mjs";
|
||||
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
|
||||
import { REPO, rolesDir } from "./config.mjs";
|
||||
import { rolesDir } from "./config.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
||||
const STOP_TIMEOUT_MS = 30000;
|
||||
const LINE_MAX = 4096;
|
||||
// How often the host asks for the threads' state; a poll period, not a limit.
|
||||
@@ -137,25 +142,19 @@ class Refusal extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// The exact pi pin from the repository's package.json.
|
||||
export function piVersion(repo = REPO) {
|
||||
// A harness module runs with the host's authority, and anything it throws refuses the launch
|
||||
// and names the module rather than escaping the host.
|
||||
function call(name, what, fn) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(repo, "package.json"), "utf8"));
|
||||
return pkg.dependencies?.[PI_PACKAGE] ?? pkg.devDependencies?.[PI_PACKAGE] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
return fn();
|
||||
} catch (e) {
|
||||
throw new Refusal("harness-failed", `harness ${name} ${what}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// `claude --version` from the binary sessions run (MOSAIC_CLAUDE_BIN, else
|
||||
// `claude` on PATH), or null when it doesn't answer.
|
||||
export function claudeVersion(env = process.env) {
|
||||
const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
|
||||
return r.status === 0 ? r.stdout.trim().split("\n")[0] || null : null;
|
||||
}
|
||||
|
||||
// host: startHost()'s handle. tracker: true when the broker has task verbs for
|
||||
// the business. adapters, bwrap, readOnly, sessionDefaults, versions,
|
||||
// the business. makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
|
||||
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
|
||||
// the tests; the command line never sets them.
|
||||
export function createLauncher({
|
||||
@@ -163,11 +162,11 @@ export function createLauncher({
|
||||
system,
|
||||
env = process.env,
|
||||
tracker = false,
|
||||
adapters = ADAPTERS,
|
||||
makeLoader = createLoader,
|
||||
bwrap = BWRAP,
|
||||
readOnly = [],
|
||||
sessionDefaults = {},
|
||||
versions = null,
|
||||
harnessVersion = undefined,
|
||||
stopTimeoutMs = STOP_TIMEOUT_MS,
|
||||
threadTickMs = THREAD_TICK_MS,
|
||||
git = {},
|
||||
@@ -231,22 +230,59 @@ export function createLauncher({
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
if (registry.running(b.id, instance, thread)) throw new Refusal("instance-running");
|
||||
let resolved, model;
|
||||
let resolved;
|
||||
try {
|
||||
resolved = resolveInstance({ system: vars, business: b, instance });
|
||||
model = sessionModel(resolved, b.launch.models);
|
||||
} catch (e) {
|
||||
throw new Refusal("unsupported-harness", e.message);
|
||||
}
|
||||
// Row 65 (#1558): the route's harness, model and thinking come from the instance's vars only.
|
||||
const routeOf = (which) => {
|
||||
try {
|
||||
return sessionModel(resolved, b.launch.models, which);
|
||||
} catch (e) {
|
||||
throw new Refusal(e instanceof BundleError && e.code === "no-route" ? "no-route" : "unsupported-harness", e.message);
|
||||
}
|
||||
};
|
||||
const primary = routeOf("primary");
|
||||
const fallback = hasFallback(resolved) ? routeOf("fallback") : null;
|
||||
if (!resolved.contract) throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
|
||||
if (model.harness === "claude-code" && !hasSessionCredential(env)) {
|
||||
const names = SESSION_CREDENTIALS.map((set) => set.join(" with ")).join(", ");
|
||||
throw new Refusal("no-session-credential", `the bus host has none of ${names}; the bus unit's EnvironmentFile sets one when the bus starts`);
|
||||
// Both of a launch's harnesses must be switched on, whichever it takes.
|
||||
const on = Array.isArray(b.plugins?.harness) ? b.plugins.harness : [];
|
||||
for (const m of [primary, fallback].filter(Boolean)) {
|
||||
if (!on.includes(m.harness)) throw new Refusal("harness-off", `harness ${m.harness} isn't switched on in plugins.harness`);
|
||||
}
|
||||
// Row 126 (#1620): only a claude-code launch spends the named account; a pi launch has none.
|
||||
// The bus environment must hold the named set and no other credential variable, so the session
|
||||
// can't log in some other way. Messages name variables, never values.
|
||||
const account = model.harness === "claude-code" ? b.launch.account?.name ?? null : null;
|
||||
// One harness through a fresh loader, so a refusal never leaves an earlier load's module in use.
|
||||
const harnessOf = async (m) => {
|
||||
const loader = makeLoader({ log: (entry) => record({ ...entry, instance }) });
|
||||
const { refused } = await loader.load({ harness: [m.harness] });
|
||||
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => `${r.code}: ${r.message}`).join("; ")}`);
|
||||
const module = loader.modules("harness")[0];
|
||||
const levels = call(m.harness, "levels", () => module.levels());
|
||||
if (!levels.includes(m.thinking)) throw new Refusal("thinking-unsupported", `harness ${m.harness} takes thinking ${levels.join(", ")}, not ${m.thinking}`);
|
||||
return module;
|
||||
};
|
||||
let model = primary;
|
||||
let harness = await harnessOf(primary);
|
||||
let route = "primary";
|
||||
let routeReason = null;
|
||||
const failed = call(primary.harness, "preflight", () => harness.preflight({ env }));
|
||||
if (failed) {
|
||||
if (!fallback) throw new Refusal(failed.code, failed.message);
|
||||
// The fallback only on a failed primary preflight, and only at launch (row 65). Any refusal of
|
||||
// the fallback names the primary's failure too, so the reason it was tried stays visible.
|
||||
const second = await harnessOf(fallback).catch((e) => {
|
||||
if (e instanceof Refusal) e.message += ` (primary ${primary.harness}: ${failed.message})`;
|
||||
throw e;
|
||||
});
|
||||
const also = call(fallback.harness, "preflight", () => second.preflight({ env }));
|
||||
if (also) throw new Refusal(also.code, `fallback ${fallback.harness}: ${also.message} (primary ${primary.harness}: ${failed.message})`);
|
||||
[model, harness, route, routeReason] = [fallback, second, "fallback", failed.code];
|
||||
}
|
||||
// Row 126 (#1620): only a launch on a harness whose module counts the account spends the named
|
||||
// account. The bus environment must hold the named set and no other credential variable, so the
|
||||
// session can't log in some other way. Messages name variables, never values.
|
||||
const account = call(model.harness, "countsAccount", () => harness.countsAccount()) === true ? b.launch.account?.name ?? null : null;
|
||||
if (account !== null) {
|
||||
// The usage records keep this name (bus schema 3i), so it must never be a credential's value.
|
||||
const holds = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => env[k] === account);
|
||||
@@ -273,7 +309,7 @@ export function createLauncher({
|
||||
if (may?.hit) { const error = new Refusal("limit-reached", JSON.stringify(may.hit)); error.limit = { check: may.check, ...may.hit }; throw error; }
|
||||
throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`);
|
||||
}
|
||||
return { resolved, model, account };
|
||||
return { resolved, model, account, harness, route, routeReason };
|
||||
}
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
@@ -307,13 +343,14 @@ export function createLauncher({
|
||||
if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
|
||||
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
|
||||
}
|
||||
({resolved,model,account}=await check(instance,{pm:instance===b.launch.by,thread}));
|
||||
let harness, route, routeReason;
|
||||
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread}));
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
const sessionDir = store ? store.session : join(runDir, "session");
|
||||
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
||||
const toolSocket = join(runDir, "tools.sock");
|
||||
const turnMarker = join(runDir, "turn.done");
|
||||
const v = versions ?? { pi: piVersion(), claude: model.harness === "claude-code" ? claudeVersion(env) : null };
|
||||
const version = harnessVersion !== undefined ? harnessVersion : call(model.harness, "version", () => harness.version({ env }));
|
||||
// Row 137 (#1631): a role and thread holding an approved, unspent merge decision get land_work.
|
||||
// The broker checks the decision again on every land; this only decides what the bundle shows.
|
||||
// Row 156 (#1651): a non-thread instance's land and landing come from one landHolders read, so a
|
||||
@@ -331,11 +368,17 @@ export function createLauncher({
|
||||
tracker,
|
||||
credentials,
|
||||
session: { run, launchedBy, workspace, toolSocket, turnMarker, checkouts },
|
||||
piVersion: v.pi,
|
||||
claudeVersion: v.claude,
|
||||
harness,
|
||||
route,
|
||||
routeReason,
|
||||
harnessVersion: version,
|
||||
land,
|
||||
});
|
||||
const { manifest, ...files } = bundle.files;
|
||||
const adapter = call(model.harness, "adapter", () => harness.adapter());
|
||||
const turnEnd = call(model.harness, "turnEnd", () => harness.turnEnd());
|
||||
const transcript = call(model.harness, "transcript", () => harness.transcript({ runDir, store }));
|
||||
const harnessMounts = call(model.harness, "mounts", () => harness.mounts({ env }));
|
||||
// Row 93: the git socket, for a role that pushes work or reviews it. Row 137: or lands it.
|
||||
const held = [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole];
|
||||
const git = land || held.includes("git.push.working") || held.includes("review.verdict") ? gitSocketPath(dataRoot) : null;
|
||||
@@ -354,8 +397,10 @@ export function createLauncher({
|
||||
gitSocket: git,
|
||||
workspace,
|
||||
sessionDir,
|
||||
adapter: adapters[model.harness],
|
||||
adapter,
|
||||
bundle: files,
|
||||
harnessEnv: bundle.env,
|
||||
turnEnd,
|
||||
toolSocket,
|
||||
turnMarker,
|
||||
...(holding?.length ? { landing: holding.map(({ decision, task_ref }) => ({ decision, task_ref })) } : {}),
|
||||
@@ -363,12 +408,11 @@ export function createLauncher({
|
||||
|
||||
const paths = {
|
||||
workspace,
|
||||
adapter: adapters[model.harness],
|
||||
harness: model.harness,
|
||||
adapter,
|
||||
harnessMounts,
|
||||
brokerSocket: host.path,
|
||||
launchSocket: resolved.launch ? launchSocketPath(dataRoot) : null,
|
||||
gitSocket: git,
|
||||
claudeBin: env.MOSAIC_CLAUDE_BIN || null,
|
||||
threadStore: store,
|
||||
readOnly,
|
||||
};
|
||||
@@ -383,7 +427,7 @@ export function createLauncher({
|
||||
const outerStart = startTimeOf(child.pid);
|
||||
const runner = await runnerOf(child);
|
||||
if (!outerStart) throw new Error("the session process exited at once");
|
||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
|
||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
|
||||
bound = true;
|
||||
entry = {
|
||||
business: b.id, instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
|
||||
@@ -418,7 +462,7 @@ export function createLauncher({
|
||||
refusedRelease.delete(run);
|
||||
}
|
||||
});
|
||||
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
|
||||
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, route, routeReason, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
|
||||
return { run, instance, harness: model.harness, model: model.model };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:f
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { BusError, Broker } from "../../bus/src/broker.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { tree } from "../../plugins/tests/helpers.mjs";
|
||||
import { AGENT_MARKERS } from "../src/transport.mjs";
|
||||
|
||||
export function tmp(t, prefix = "mosaic-cli-") {
|
||||
@@ -26,11 +28,16 @@ export function humanEnv(extra = {}) {
|
||||
return env;
|
||||
}
|
||||
|
||||
// A config directory with the system config, the roles and business `id`.
|
||||
// A config directory with the system config, the roles and business `id`. Row 65: both harnesses
|
||||
// are switched on, since a launch on one that isn't refuses harness-off, and every instance names a
|
||||
// harness, model and thinking level, as the live file does, since a launch missing one refuses
|
||||
// no-route. The instance's own vars win, and edit can change any of it.
|
||||
export function fixture(root, id = "acme", edit = (doc) => doc) {
|
||||
const config = systemConfig(root);
|
||||
const roles = rolesCopy(root);
|
||||
const doc = edit(businessDoc(root, id));
|
||||
const base = businessDoc(root, id);
|
||||
for (const r of Object.values(base.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars };
|
||||
const doc = edit({ ...base, plugins: { ...base.plugins, harness: ["pi", "claude-code"] } });
|
||||
writeJson(join(root, "config", "businesses", `${id}.json`), doc);
|
||||
return { config, roles, dataRoot: join(root, "data"), doc, env: humanEnv({ MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: roles }) };
|
||||
}
|
||||
@@ -66,6 +73,7 @@ export const BUSINESSES = {
|
||||
demo: {
|
||||
id: "demo",
|
||||
human: "jason",
|
||||
plugins: { harness: ["pi"] },
|
||||
arbiters: { technical: "cto", delivery: "pm" },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
@@ -111,3 +119,28 @@ export function fakeCli(root, body) {
|
||||
chmodSync(file, 0o600);
|
||||
return file;
|
||||
}
|
||||
|
||||
// Row 65: a launcher's makeLoader over a scratch plug-in tree. adapters maps a harness module to the
|
||||
// adapter script a test runs in its place, or to null for the real module unchanged. A faked module
|
||||
// re-exports the real one with only adapter() replaced; a faked claude-code also passes a missing
|
||||
// claude binary, since the fake adapter never runs one (packages/plugins/tests/harness.test.mjs
|
||||
// covers that check). more(write) adds fixture modules, write(kind, name, body).
|
||||
export async function fakeHarnesses(t, adapters, more = () => {}) {
|
||||
const { write, loaderUrl } = tree(t);
|
||||
for (const [name, adapter] of Object.entries(adapters)) {
|
||||
const real = JSON.stringify(pathToFileURL(new URL(`../../plugins/harness/${name}/index.mjs`, import.meta.url).pathname).href);
|
||||
if (adapter === null) {
|
||||
write("harness", name, `export * from ${real};\n`);
|
||||
continue;
|
||||
}
|
||||
const lines = [`export * from ${real};`, `export const adapter = () => ${JSON.stringify(adapter)};`];
|
||||
if (name === "claude-code") {
|
||||
lines.push(`import { preflight as real } from ${real};`,
|
||||
"export function preflight(input) { const p = real(input); return p?.code === \"harness-binary-missing\" ? null : p; }");
|
||||
}
|
||||
write("harness", name, lines.join("\n") + "\n");
|
||||
}
|
||||
more(write);
|
||||
const { createLoader } = await import(loaderUrl);
|
||||
return createLoader;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,8 @@ test("row 165 CLI: instance add puts an instance in the file, validate exits 0,
|
||||
const view = await s.mosaic(["instances", "acme"]);
|
||||
assert.equal(view.code, 0, view.err);
|
||||
assert.match(view.out, /^business acme, revision [a-f0-9]{64}$/m);
|
||||
assert.match(view.out, /^ {2}chief: chief-of-staff \(launcher\)$/m);
|
||||
// Row 65: the cli fixture gives every instance its route vars, so the view lists them.
|
||||
assert.match(view.out, /^ {2}chief: chief-of-staff \(launcher\) vars \{"harness":"pi","model":"glm-5\.3-flash","thinking":"high"\}$/m);
|
||||
assert.match(view.out, /^definitions add and set accept: .*\bmerge-gate\b.*\bsecurity-reviewer\b/m);
|
||||
assert.match(view.out, /^definitions that declare credentials, a hand edit: .*\breviewer \(gitea, vikunja\)/m);
|
||||
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--launch", "--revision", await s.revision()]);
|
||||
|
||||
@@ -72,6 +72,9 @@ test('REQ-LIMIT-4 the launcher reaches token-limit only through the bus: launche
|
||||
const src=readFileSync(fileURLToPath(new URL('../src/launcher.mjs',import.meta.url)),'utf8');
|
||||
assert.doesNotMatch(src,/token-limit/);
|
||||
assert.doesNotMatch(src,/limit-check\//);
|
||||
assert.doesNotMatch(src,/plugins\/src\/loader/);
|
||||
// Row 65: the launcher reaches its harness through the plug-in loader, and every load names only
|
||||
// the harness kind, so a limit check still can't load here.
|
||||
assert.deepEqual([...src.matchAll(/\.load\(([^)]*)\)/g)].map(m=>m[1]),['{ harness: [m.harness] }']);
|
||||
assert.doesNotMatch(src,/modules\(\s*["'](?!harness["'])/);
|
||||
assert.doesNotMatch(src,/\bimport\s*\(/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Row 65 (#1558, REQ-PLUG-2, Q156): the launcher takes a launch's route from the instance's
|
||||
// harness, model and thinking vars and its harness from the plug-in loader. A stub host answers
|
||||
// the ops, as in launch-limit-checks: mayLaunch records the route's provider, model and account,
|
||||
// and authorizeLaunch stops the launch before anything spawns. The fixture modules live in a
|
||||
// scratch plug-in tree; no core file names them.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {fakeHarnesses,fixture,tmp} from './helpers.mjs';
|
||||
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
|
||||
|
||||
const PI=JSON.stringify(new URL('../../plugins/harness/pi/index.mjs',import.meta.url).href);
|
||||
// down fails its preflight; sparse takes one thinking level; metered spends launch.account; broken
|
||||
// lacks the kind's exports; throws throws from its preflight. Each is the pi module otherwise.
|
||||
const MODULES={
|
||||
down:`export * from ${PI};\nexport const preflight = () => ({ code: "fixture-down", message: "fixture preflight fails" });\n`,
|
||||
sparse:`export * from ${PI};\nexport const levels = () => ["low"];\n`,
|
||||
metered:`export * from ${PI};\nexport const countsAccount = () => true;\n`,
|
||||
broken:'export const interfaceVersion = 1;\nexport const adapter = () => "/nowhere";\n',
|
||||
throws:`export * from ${PI};\nexport function preflight() { throw new Error("fixture preflight threw"); }\n`,
|
||||
};
|
||||
const ON=['pi','claude-code','down','sparse','metered','broken','throws','absent'];
|
||||
|
||||
// vars are the coder's whole agent vars; edit changes the rest of the file.
|
||||
async function setup(t,vars,{on=ON,edit=doc=>doc,env:more={}}={}){
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars=vars;doc.plugins={...doc.plugins,harness:on};return edit(doc);}),ops=[];
|
||||
Object.assign(f.env,more);
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
ops.push(x);
|
||||
if(x.op==='identity')return {business:'acme',role:'pm'};
|
||||
if(x.op==='mayLaunch')return {allow:true};
|
||||
if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});
|
||||
return {};
|
||||
}};
|
||||
const makeLoader=await fakeHarnesses(t,{pi:null,'claude-code':null},write=>{for(const [name,body] of Object.entries(MODULES))write('harness',name,body);});
|
||||
// The system config's execution keys are set and must never reach a route.
|
||||
const system={...JSON.parse(readFileSync(f.config,'utf8'))};
|
||||
system.execution={...system.execution,adapter:'pi',model:'glm-5.3-flash'};
|
||||
const launcher=createLauncher({host,system,env:f.env,makeLoader,bwrap:'/usr/bin/true',log:()=>{}});
|
||||
const events=()=>readFileSync(launchLogFile(f.dataRoot,'acme'),'utf8').trim().split('\n').map(l=>JSON.parse(l));
|
||||
const launch=()=>launcher.fromSession('fixture-cap','coder');
|
||||
const may=()=>ops.filter(x=>x.op==='mayLaunch').map(({provider,model,account})=>({provider,model,account}));
|
||||
return {launch,ops,may,last:()=>events().at(-1)};
|
||||
}
|
||||
const route=(harness,model,thinking)=>({harness,model,thinking});
|
||||
const fallback=(harness,model,thinking)=>({'fallback.harness':harness,'fallback.model':model,'fallback.thinking':thinking});
|
||||
const SONNET={provider:'zai',model:'claude-sonnet-5-5',account:null}, FLASH={provider:'zai',model:'glm-5.3-flash',account:null};
|
||||
|
||||
test('row 65 a passing primary is the route: its provider and model reach mayLaunch, and a stated fallback is never used',async t=>{
|
||||
const s=await setup(t,{...route('pi','claude-sonnet-5-5','high'),...fallback('down','glm-5.3-flash','low')});
|
||||
assert.equal((await s.launch()).error,'fixture-policy');
|
||||
assert.deepEqual(s.may(),[SONNET]);
|
||||
});
|
||||
|
||||
test('row 65 a failed primary preflight takes the stated fallback, and the limit checks see the fallback route',async t=>{
|
||||
const s=await setup(t,{...route('down','claude-sonnet-5-5','high'),...fallback('pi','glm-5.3-flash','low')});
|
||||
assert.equal((await s.launch()).error,'fixture-policy');
|
||||
assert.deepEqual(s.may(),[FLASH]);
|
||||
});
|
||||
|
||||
test('row 65 a failed primary with no fallback refuses with the preflight code and never asks mayLaunch',async t=>{
|
||||
const s=await setup(t,route('down','claude-sonnet-5-5','high'));
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'fixture-down'});
|
||||
assert.equal(s.last().why,'fixture preflight fails');
|
||||
assert.deepEqual(s.may(),[]);
|
||||
});
|
||||
|
||||
test('row 65 a fallback whose own preflight fails refuses with its code and names both reasons',async t=>{
|
||||
const s=await setup(t,{...route('down','claude-sonnet-5-5','high'),...fallback('down','glm-5.3-flash','low')});
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'fixture-down'});
|
||||
assert.equal(s.last().why,'fallback down: fixture preflight fails (primary down: fixture preflight fails)');
|
||||
assert.deepEqual(s.may(),[]);
|
||||
});
|
||||
|
||||
test('row 65 the fallback route spends launch.account when its module counts the account',async t=>{
|
||||
const env=Object.fromEntries(SESSION_CREDENTIALS.flat().map(k=>[k,'']));
|
||||
Object.assign(env,{ANTHROPIC_AUTH_TOKEN:'stand-in',ANTHROPIC_BASE_URL:'http://127.0.0.1:9'});
|
||||
const s=await setup(t,{...route('down','claude-sonnet-5-5','high'),...fallback('metered','glm-5.3-flash','low')},{env,edit:doc=>{doc.launch.account={name:'proxy-login',credential:'ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL'};return doc;}});
|
||||
assert.equal((await s.launch()).error,'fixture-policy');
|
||||
assert.deepEqual(s.may(),[{...FLASH,account:'proxy-login'}]);
|
||||
});
|
||||
|
||||
test('row 65 a missing harness, model or thinking var refuses no-route naming it, and the system execution keys never fill it',async t=>{
|
||||
for(const missing of ['harness','model','thinking']){
|
||||
const vars=route('pi','claude-sonnet-5-5','high');delete vars[missing];
|
||||
const s=await setup(t,vars);
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'no-route'},missing);
|
||||
assert.equal(s.last().why,`instance coder: no ${missing} var`);
|
||||
assert.deepEqual(s.may(),[],missing);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 65 a harness that is not switched on refuses harness-off, primary or fallback, and an off primary never falls back',async t=>{
|
||||
const cases=[
|
||||
[route('pi','claude-sonnet-5-5','high'),'pi'],
|
||||
[{...route('pi','claude-sonnet-5-5','high'),...fallback('down','glm-5.3-flash','low')},'down'],
|
||||
[{...route('down','claude-sonnet-5-5','high'),...fallback('pi','glm-5.3-flash','low')},'down'],
|
||||
];
|
||||
for(const [vars,off] of cases){
|
||||
const s=await setup(t,vars,{on:ON.filter(n=>n!==off)});
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'harness-off'},off);
|
||||
assert.equal(s.last().why,`harness ${off} isn't switched on in plugins.harness`);
|
||||
assert.deepEqual(s.may(),[],off);
|
||||
}
|
||||
// A file with no plugins.harness at all refuses the same way.
|
||||
const s=await setup(t,route('pi','claude-sonnet-5-5','high'),{on:undefined,edit:doc=>{delete doc.plugins.harness;return doc;}});
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'harness-off'});
|
||||
});
|
||||
|
||||
test('row 65 the loader refusing a module refuses harness-refused and names it; a module that throws refuses harness-failed',async t=>{
|
||||
for(const [name,reason] of [['broken',/^harness broken: missing-export: /],['absent',/^harness absent: /]]){
|
||||
const s=await setup(t,route(name,'claude-sonnet-5-5','high'));
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'harness-refused'},name);
|
||||
assert.match(s.last().why,reason);
|
||||
}
|
||||
const s=await setup(t,route('throws','claude-sonnet-5-5','high'));
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'harness-failed'});
|
||||
assert.equal(s.last().why,'harness throws preflight: fixture preflight threw');
|
||||
});
|
||||
|
||||
test('row 65 a thinking level the route harness does not apply refuses thinking-unsupported, on the primary or a fallback taken',async t=>{
|
||||
// A refused fallback names the primary's failure too, so the reason it was tried stays visible.
|
||||
for(const [vars,why] of [
|
||||
[route('sparse','claude-sonnet-5-5','high'),'harness sparse takes thinking low, not high'],
|
||||
[{...route('down','claude-sonnet-5-5','high'),...fallback('sparse','glm-5.3-flash','high')},'harness sparse takes thinking low, not high (primary down: fixture preflight fails)'],
|
||||
]){
|
||||
const s=await setup(t,vars);
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'thinking-unsupported'});
|
||||
assert.equal(s.last().why,why);
|
||||
assert.deepEqual(s.may(),[]);
|
||||
}
|
||||
// A fallback the loader refuses names both too.
|
||||
const refused=await setup(t,{...route('down','claude-sonnet-5-5','high'),...fallback('broken','glm-5.3-flash','high')});
|
||||
assert.deepEqual(await refused.launch(),{ok:false,error:'harness-refused'});
|
||||
assert.match(refused.last().why,/^harness broken: missing-export: .* \(primary down: fixture preflight fails\)$/);
|
||||
// claude-code has no off: its module's own levels refuse it.
|
||||
const s=await setup(t,route('claude-code','claude-sonnet-5-5','off'));
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'thinking-unsupported'});
|
||||
assert.equal(s.last().why,'harness claude-code takes thinking low, medium, high, xhigh, max, not off');
|
||||
});
|
||||
@@ -20,9 +20,9 @@ import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { claudeVersion, createLauncher } from "../src/launcher.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { mockAnthropic, REPO } from "../../harness/tests/helpers.mjs";
|
||||
import { fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const HOST = fileURLToPath(new URL("./fixtures/launch-host.mjs", import.meta.url));
|
||||
@@ -31,9 +31,9 @@ const skip = sandboxProblem() ?? false;
|
||||
// chief opus, coder and reviewer sonnet, one of each family; cto is listed but
|
||||
// runs the system model, which is in no family. The chief of staff is the launcher.
|
||||
function edit(doc) {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" };
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.coder.vars.model = "claude-sonnet-5-5";
|
||||
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5" };
|
||||
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
doc.launch = launchDoc("chief",["coder","reviewer","cto"],{opus:1,sonnet:1});
|
||||
doc.launch.limits.models.zai["glm-5.3-flash"]=0;
|
||||
return doc;
|
||||
@@ -81,7 +81,9 @@ function prepare(t, more = (doc) => doc) {
|
||||
}
|
||||
|
||||
// options.wrap(host) is what the launcher sees, so a test can change one of the host's answers.
|
||||
async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, more), { wrap = (h) => h, ...options } = {}) {
|
||||
// options.harnesses is fakeHarnesses' map (row 65): both modules run the fake adapter by default.
|
||||
async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, more), { wrap = (h) => h, harnesses = { pi: adapter, "claude-code": adapter }, ...options } = {}) {
|
||||
const makeLoader = await fakeHarnesses(t, harnesses);
|
||||
const system = loadSystem({ env: f.env });
|
||||
const boot = bootConfig({ system, businessId: "acme", env: f.env });
|
||||
const logs = [];
|
||||
@@ -92,9 +94,9 @@ async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, m
|
||||
host: wrap(host),
|
||||
system,
|
||||
env: f.env,
|
||||
adapters: { pi: adapter, "claude-code": adapter },
|
||||
makeLoader,
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
versions: { pi: "0.85.1", claude: null },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [FAKE],
|
||||
log: (l) => logs.push(l),
|
||||
...options,
|
||||
@@ -166,8 +168,10 @@ test("bus start --pm: the launcher instance (the chief of staff) runs under its
|
||||
assert.equal(session.gitSocket, null);
|
||||
assert.equal(statSync(gitSocketPath(f.dataRoot)).mode & 0o777, 0o600);
|
||||
const manifest = JSON.parse(readFileSync(join(runDir, "bundle", "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.piVersion, "0.85.1");
|
||||
assert.equal(manifest.claudeVersion, null);
|
||||
// Row 65: one version, the route's harness, and the route the launch took.
|
||||
assert.equal(manifest.harnessVersion, "0.85.1");
|
||||
assert.deepEqual([manifest.route, manifest.routeReason], ["primary", null]);
|
||||
assert.ok(!("piVersion" in manifest) && !("claudeVersion" in manifest));
|
||||
const [launched] = log(f).filter((e) => e.event === "launch");
|
||||
assert.equal(launched.run, chief.run);
|
||||
assert.equal(launched.manifest, join(runDir, "bundle", "manifest.json"));
|
||||
@@ -298,7 +302,7 @@ test("the chief of staff launches a coder through its launch tool; the coder ans
|
||||
// tool socket; the request grants no launch, and the chief's launches stay inside the organization's limits.
|
||||
test("a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold", { skip, timeout: 60000 }, async (t) => {
|
||||
const { f, launcher, client, cto } = await setup(t, (doc) => {
|
||||
doc.roles.pm.vars = { harness: "pi", model: "claude-sonnet-5-5" };
|
||||
doc.roles.pm.vars = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
doc.launch.instances.push("pm");
|
||||
doc.launch.limits.models.zai["claude-sonnet-5-5"] = 2;
|
||||
return doc;
|
||||
@@ -667,6 +671,33 @@ test("a pi launch needs no session credential: pi logs in through its own ENV_AL
|
||||
assert.deepEqual(log(f).map((e) => [e.event, e.instance]), [["launch", "chief"]]);
|
||||
});
|
||||
|
||||
// Row 65: a harness module no core file names, switched on in the organization file, runs a real
|
||||
// sandboxed session. The chief's primary harness fails its preflight, so the launch takes the
|
||||
// stated fallback, and the manifest and the launch log record the route and the reason.
|
||||
test("row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason", { skip, timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t, (doc) => {
|
||||
doc.roles.chief.vars = { harness: "down", model: "claude-opus-5-5", thinking: "high", "fallback.harness": "fixture", "fallback.model": "claude-sonnet-5-5", "fallback.thinking": "low" };
|
||||
doc.plugins = { ...doc.plugins, harness: [...doc.plugins.harness, "down", "fixture"] };
|
||||
return doc;
|
||||
});
|
||||
const makeLoader = await fakeHarnesses(t, {}, (write) => {
|
||||
const pi = JSON.stringify(new URL("../../plugins/harness/pi/index.mjs", import.meta.url).href);
|
||||
write("harness", "down", `export * from ${pi};\nexport const preflight = () => ({ code: "fixture-down", message: "fixture preflight fails" });\n`);
|
||||
write("harness", "fixture", `export * from ${pi};\nexport const adapter = () => ${JSON.stringify(given.adapter)};\n`);
|
||||
});
|
||||
const { f, launcher } = await setup(t, undefined, given, { makeLoader });
|
||||
const chief = await launcher.launchPm();
|
||||
assert.deepEqual({ ...chief, run: undefined }, { run: undefined, instance: "chief", harness: "fixture", model: "claude-sonnet-5-5" });
|
||||
const [s] = readSessions(f.dataRoot);
|
||||
const manifest = JSON.parse(readFileSync(join(s.runDir, "bundle", "manifest.json"), "utf8"));
|
||||
assert.deepEqual([manifest.harness, manifest.model, manifest.thinking, manifest.route, manifest.routeReason], ["fixture", "claude-sonnet-5-5", "low", "fallback", "fixture-down"]);
|
||||
const [launched] = log(f).filter((e) => e.event === "launch");
|
||||
assert.deepEqual([launched.harness, launched.model, launched.route, launched.routeReason], ["fixture", "claude-sonnet-5-5", "fallback", "fixture-down"]);
|
||||
// The session runs on the fixture's adapter: the fake adapter claims the run.
|
||||
const runnerLog = join(s.runDir, "runner.log");
|
||||
await until(() => existsSync(runnerLog) && readFileSync(runnerLog, "utf8").includes("claimed by run"));
|
||||
});
|
||||
|
||||
// The runner is pid 1 in its namespace and ignores a SIGKILL sent from
|
||||
// inside it, so the host kills it by its host pid. bwrap then exits 137,
|
||||
// which the launcher reads back as the signal.
|
||||
@@ -722,8 +753,8 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything
|
||||
mkdirSync(join(p, ".."), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(p, "FAKE_TOKEN=stand-in-value\n", { mode: 0o600 });
|
||||
}
|
||||
const claudeAdapter = join(REPO, "adapters", "claude", "adapter.sh");
|
||||
const { f, launcher, client } = await setup(t, undefined, given, { adapters: { pi: given.adapter, "claude-code": claudeAdapter } });
|
||||
// The real claude-code module, so its own adapter and binary check (row 65).
|
||||
const { f, launcher, client } = await setup(t, undefined, given, { harnesses: { pi: given.adapter, "claude-code": null } });
|
||||
const chief = await launcher.launchPm();
|
||||
const chiefRun = readSessions(f.dataRoot).find((s) => s.run === chief.run).runDir;
|
||||
|
||||
@@ -915,20 +946,3 @@ test("a runner that ignores SIGTERM is killed when the host closes", { skip, tim
|
||||
assert.equal(ends.length, 1);
|
||||
assert.equal(ends[0].signal, "SIGKILL");
|
||||
});
|
||||
|
||||
// Row 68: the manifest's claudeVersion asks the binary sessions run.
|
||||
test("claudeVersion asks MOSAIC_CLAUDE_BIN when set, else claude on PATH", (t) => {
|
||||
const dir = tmp(t);
|
||||
mkdirSync(join(dir, "bin"));
|
||||
const stand = (p, v) => {
|
||||
writeFileSync(p, `#!/bin/sh\necho "${v} (Claude Code)"\n`);
|
||||
chmodSync(p, 0o755);
|
||||
return p;
|
||||
};
|
||||
stand(join(dir, "bin", "claude"), "1.0.0");
|
||||
const pinned = stand(join(dir, "pinned"), "9.9.9");
|
||||
const PATH = join(dir, "bin");
|
||||
assert.equal(claudeVersion({ PATH, MOSAIC_CLAUDE_BIN: pinned }), "9.9.9 (Claude Code)");
|
||||
assert.equal(claudeVersion({ PATH }), "1.0.0 (Claude Code)");
|
||||
assert.equal(claudeVersion({ PATH: join(dir, "none") }), null);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ import { launchLogFile, readSessions, SESSION_CREDENTIALS } from "../../seat/src
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -50,8 +50,8 @@ function gitIn(cwd, ...args) {
|
||||
function prepare(t, { sonnet = 2, idle = 1, edit = (doc) => doc } = {}) {
|
||||
const root = tmp(t, "mosaic-thread-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" };
|
||||
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5" } };
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
||||
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet });
|
||||
doc.launch.threadIdleSeconds = idle;
|
||||
return edit(doc);
|
||||
@@ -116,9 +116,9 @@ async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
|
||||
host,
|
||||
system,
|
||||
env: f.env,
|
||||
adapters: { pi: adapter, "claude-code": adapter },
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
versions: { pi: "0.85.1", claude: null },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [ADAPTER],
|
||||
threadTickMs: tickMs,
|
||||
log: (l) => logs.push(l),
|
||||
@@ -394,7 +394,7 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
|
||||
// bus's plug-in loader.
|
||||
const SINCE = "2026-10-18T00:00:00.000Z";
|
||||
const atLimit = (doc) => {
|
||||
doc.plugins = { "limit-check": ["token-limit"] };
|
||||
doc.plugins = { ...doc.plugins, "limit-check": ["token-limit"] };
|
||||
doc.launch.limits.tokens = { instances: { assistant: { limit: 0, since: SINCE } } };
|
||||
return doc;
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ passing it, and shows the memory reaching the model without it.
|
||||
|
||||
## The bundle
|
||||
|
||||
`buildBundle({dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion})`
|
||||
`buildBundle({dir, resolved, contract, arbiter, tracker, credentials, session, harness, route, routeReason, harnessVersion, models, land})`
|
||||
writes the bundle once into `dir` (0700; every file 0600, created with `wx`,
|
||||
so an existing bundle is never overwritten):
|
||||
|
||||
@@ -50,16 +50,25 @@ so an existing bundle is never overwritten):
|
||||
status the host read from the broker (metadata only, never a token).
|
||||
- `tools.json`: the typed tools (below).
|
||||
- `manifest.json`: bundle version, the instance and run, the resolved
|
||||
digest, harness, provider, model, thinking, the Pi or Claude Code version, built-in and
|
||||
digest, harness, provider, model, thinking, the route taken (`primary` or
|
||||
`fallback`) and why (`routeReason`), the harness module's `version()`
|
||||
(`harnessVersion`), built-in and
|
||||
typed tools, the actions without a typed tool, the S0 lines, the gate
|
||||
timeout, and the sha256 of every bundle file and of the gate, extension,
|
||||
MCP server and tools sources.
|
||||
- Claude Code only: `settings.json` (the wrapped PreToolUse hook, matcher
|
||||
`*`) and `mcp.json` (one stdio server, `mosaic`, serving the typed tools).
|
||||
|
||||
`sessionModel(resolved)` picks the harness, provider and model: the agent
|
||||
layer's `harness` and `model` variables win, else the system's execution
|
||||
adapter and model. Only `pi` and `claude-code` are accepted.
|
||||
`harness` is the route's module from `packages/plugins/harness/`, which the
|
||||
launcher loaded; the bundle calls it for its own files and names and never
|
||||
names a harness itself (row 65). `sessionModel(resolved, models, which)`
|
||||
reads one route from the instance's vars only: `harness`, `model` and
|
||||
`thinking` for `primary`, the three `fallback.` vars for `fallback`
|
||||
(`ROUTE_VARS`). A missing var throws `no-route` and names it; the system's
|
||||
`execution.adapter` and `execution.model` never fill one, and stay for
|
||||
`run-task.sh`. `model` is a `launch.models` key, and the entry gives the
|
||||
provider and model. `hasFallback(resolved)` says whether the instance
|
||||
states a fallback.
|
||||
|
||||
## Typed tools
|
||||
|
||||
@@ -306,8 +315,9 @@ These are limits, not bugs, and nothing in this package claims otherwise.
|
||||
and ends those launches as `host-lost` (`packages/cli/README.md`,
|
||||
"Sessions").
|
||||
- **Resolution** runs without the project layer, there is no skills source
|
||||
(bundles list none), and the resolved `thinking` level is recorded in the
|
||||
manifest but not applied to either harness.
|
||||
(bundles list none). The route's `thinking` level reaches the adapter as
|
||||
`MOSAIC_THINKING`: pi gets `--thinking`, Claude Code `--effort`
|
||||
(`adapters/README.md`).
|
||||
- **The reply cap is per reply.** Claude Code also persists the largest
|
||||
results of one message when their total passes 200000 characters, so
|
||||
six or more replies near the cap in one turn can still be saved to a
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
// The launch bundle: everything a managed session runs from, generated
|
||||
// from one resolved role instance and written once into the run
|
||||
// directory. prompt.md, policy.json, tools.json and manifest.json for both
|
||||
// harnesses; settings.json (the gate hook) and mcp.json (the typed tools)
|
||||
// for Claude Code. The Pi extension and the Claude gate stay at their
|
||||
// source paths; the manifest records their digests.
|
||||
// directory. prompt.md, policy.json, tools.json and manifest.json for every
|
||||
// harness, plus the files the session's harness module writes (row 65: for
|
||||
// claude-code, settings.json with the gate hook and mcp.json with the typed
|
||||
// tools). The gate sources stay at their paths; the manifest records their
|
||||
// digests.
|
||||
//
|
||||
// Each harness layer the bundle relies on is a row S0 line
|
||||
// (docs/plans/2026-10-04_slice-1.md, "What S6 can rely on"); the manifest
|
||||
// names them.
|
||||
// (docs/plans/2026-10-04_slice-1.md, "What S6 can rely on"); the module
|
||||
// names them and the manifest records them.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { typedTools, untypedActions } from "./tools.mjs";
|
||||
import { claudeBuiltins, MCP_PREFIX } from "./gate.mjs";
|
||||
|
||||
export const BUNDLE_VERSION = 1;
|
||||
// The role file's "web" is a grant for the broker's typed web tools, not a harness built-in. The
|
||||
// built-ins a harness gets are the rest, so claude-code's own WebSearch and WebFetch are never
|
||||
// on (its --tools list doesn't name them) and pi has no web tool of its own.
|
||||
// built-ins a harness gets are the rest, so a harness's own web tools are never on without the
|
||||
// web source below.
|
||||
// The web source for a session (REQ-HARN-2, Q162, decision 0b81b0c5): the search-source plug-in the
|
||||
// organization file switches on, resolved.webSource. "harness-builtin"
|
||||
// switches the harness's own web tools on for a granted role; claude-code has them, pi doesn't, so
|
||||
// a granted pi role under it is refused rather than left with no tool. Every other role is
|
||||
// unaffected: the built-ins are never on without the grant.
|
||||
export function webPlan(resolved, harness) {
|
||||
// switches the harness's own web tools on for a granted role; a harness whose module has none
|
||||
// (builtinWeb() false) refuses a granted role rather than leaving it with no tool. Every other
|
||||
// role is unaffected: the built-ins are never on without the grant.
|
||||
export function webPlan(resolved, harness, builtinWeb) {
|
||||
const source = resolved.webSource ?? null;
|
||||
const granted = resolved.limits.tools.includes("web");
|
||||
if (granted && source === "harness-builtin" && harness !== "claude-code") {
|
||||
if (granted && source === "harness-builtin" && !builtinWeb) {
|
||||
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on ${harness}`);
|
||||
}
|
||||
return { source, builtin: granted && source === "harness-builtin" };
|
||||
}
|
||||
export const builtinTools = (tools) => tools.filter((t) => t !== "web");
|
||||
export const HARNESSES = Object.freeze(["pi", "claude-code"]);
|
||||
export const SOURCES = Object.freeze({
|
||||
piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)),
|
||||
claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)),
|
||||
@@ -42,17 +41,14 @@ export const SOURCES = Object.freeze({
|
||||
gate: fileURLToPath(new URL("./gate.mjs", import.meta.url)),
|
||||
tools: fileURLToPath(new URL("./tools.mjs", import.meta.url)),
|
||||
});
|
||||
// S0 lines 1-5 for both harnesses; line 6 is the Agent SDK, which S6
|
||||
// doesn't use.
|
||||
const RELIES_ON = Object.freeze({
|
||||
pi: ["S0-1 tool_call block", "S0-2 a throw blocks", "S0-3 a missing -e refuses to start", "S0-4 external wall clock plus agent_end check", "S0-5 bash is limited by the tool limit only"],
|
||||
"claude-code": ["S0-1 command hook deny, no --bare", "S0-2 hook wrapped as <gate> || exit 2", "S0-3 a missing gate blocks through the wrapper", "S0-4 timeout -k 2 10 <gate> || exit 2 with hook timeout 20", "S0-5 bash is limited by the tool limit only"],
|
||||
});
|
||||
// The Claude hook: N = 10, K = 2, hook timeout 20, which leaves the
|
||||
// margin the brief asks for above N + K.
|
||||
const GATE_TIMEOUT = { n: 10, k: 2, hook: 20 };
|
||||
|
||||
export class BundleError extends Error {}
|
||||
// code: "no-route" names the missing route var in `missing`; anything else is "bundle-refused".
|
||||
export class BundleError extends Error {
|
||||
constructor(message, code = "bundle-refused", missing = null) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.missing = missing;
|
||||
}
|
||||
}
|
||||
|
||||
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
||||
const quote = (s) => {
|
||||
@@ -60,17 +56,28 @@ const quote = (s) => {
|
||||
return `'${s}'`;
|
||||
};
|
||||
|
||||
// The harness, provider and model one instance runs with. The agent-layer
|
||||
// `harness` and `model` variables win; otherwise the system's execution
|
||||
// adapter and model. Anything outside the two harnesses refuses.
|
||||
export function sessionModel(resolved, models = null) {
|
||||
// Row 65 (#1558): the vars a route takes, the instance's agent vars and nothing else. A session
|
||||
// never reads the system's execution.adapter or execution.model; those stay for run-task.sh.
|
||||
export const ROUTE_VARS = Object.freeze({
|
||||
primary: Object.freeze(["harness", "model", "thinking"]),
|
||||
fallback: Object.freeze(["fallback.harness", "fallback.model", "fallback.thinking"]),
|
||||
});
|
||||
export const hasFallback = (resolved) => Object.hasOwn(resolved.vars, "fallback.harness");
|
||||
|
||||
// The harness, provider, model and thinking one instance runs with on a route, "primary" or
|
||||
// "fallback". A missing var refuses no-route and names it. models is launch.models: the model var
|
||||
// is one of its keys, and the entry gives the provider and model; without a table the var is the
|
||||
// model and the provider is unknown.
|
||||
export function sessionModel(resolved, models = null, which = "primary") {
|
||||
const v = resolved.vars;
|
||||
const harness = v.harness ?? v["execution.adapter"];
|
||||
if (!HARNESSES.includes(harness)) throw new BundleError(`instance ${resolved.instance}: harness ${JSON.stringify(harness)} isn't one of ${HARNESSES.join(", ")}`);
|
||||
const model = v.model ?? v["execution.model"];
|
||||
if (typeof model !== "string" || !model) throw new BundleError(`instance ${resolved.instance}: no model`);
|
||||
if(models!==null){const entry=Object.hasOwn(models,model)?models[model]:null;if(!entry)throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`);return {harness,provider:entry.provider,model:entry.model,modelKey:model,thinking:v.thinking??null};}
|
||||
return { harness, provider: v["execution.provider"] ?? null, model, thinking: v.thinking ?? null };
|
||||
const [harness, model, thinking] = ROUTE_VARS[which].map((name) => {
|
||||
if (typeof v[name] !== "string" || !v[name]) throw new BundleError(`instance ${resolved.instance}: no ${name} var`, "no-route", name);
|
||||
return v[name];
|
||||
});
|
||||
if (models === null) return { harness, provider: null, model, thinking };
|
||||
const entry = Object.hasOwn(models, model) ? models[model] : null;
|
||||
if (!entry) throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`);
|
||||
return { harness, provider: entry.provider, model: entry.model, modelKey: model, thinking };
|
||||
}
|
||||
|
||||
// Row 91 (#1585): the project clones the host made or kept in the workspace.
|
||||
@@ -90,7 +97,7 @@ function checkoutLines(checkouts) {
|
||||
return [`- Project clones: ${checkouts.map(one).join("; ")}. Each is a git clone of the project. At a launch the host fetches each clone it can safely run git in from its origin, and fast-forwards only a clean clone on the root's branch, never during a session; the origin is outside this sandbox. Don't set git config in a clone. Any key that \`git clone\` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. Your commits already carry your role instance's name and address from the environment.`];
|
||||
}
|
||||
|
||||
function prompt({ contract, resolved, session, tools, untyped }) {
|
||||
function prompt({ contract, resolved, session, tools, untyped, builtins }) {
|
||||
const lines = [
|
||||
contract.trimEnd(),
|
||||
"",
|
||||
@@ -101,7 +108,7 @@ function prompt({ contract, resolved, session, tools, untyped }) {
|
||||
`- Workspace: ${session.workspace}. File tools work inside it only.`,
|
||||
...checkoutLines(session.checkouts ?? []),
|
||||
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
|
||||
`- Built-in tools: ${(session.harness === "pi" ? builtinTools(resolved.limits.tools) : claudeBuiltins({ tools: builtinTools(resolved.limits.tools), webBuiltin: session.webBuiltin })).join(", ") || "none"}.`,
|
||||
`- Built-in tools: ${builtins.join(", ") || "none"}.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
|
||||
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
|
||||
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
|
||||
@@ -130,7 +137,7 @@ function prompt({ contract, resolved, session, tools, untyped }) {
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion, land })
|
||||
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, harness, route, routeReason, harnessVersion, models, land })
|
||||
// dir the bundle directory, created here (0700)
|
||||
// resolved resolveInstance(...) output
|
||||
// contract the role contract's text
|
||||
@@ -140,13 +147,18 @@ function prompt({ contract, resolved, session, tools, untyped }) {
|
||||
// session { run, launchedBy, workspace, toolSocket, turnMarker, checkouts }
|
||||
// checkouts is ensureCheckouts(...) output (packages/seat/src/checkout.mjs),
|
||||
// named in the prompt; none when absent
|
||||
// piVersion the pinned pi version, for the manifest
|
||||
// claudeVersion the host's `claude --version`, for the manifest
|
||||
// harness the route's harness module, as the plug-in loader loaded it (row 65)
|
||||
// route "primary" or "fallback"; routeReason is the primary's preflight
|
||||
// refusal code that chose the fallback, or null
|
||||
// harnessVersion the module's version(), for the manifest
|
||||
// models launch.models
|
||||
// land true when the role and thread hold a merge decision to land (row 137)
|
||||
// Returns { manifest, files } with absolute paths.
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null, models = null, land = false }) {
|
||||
const model = sessionModel(resolved, models);
|
||||
const web = webPlan(resolved, model.harness);
|
||||
// Returns { manifest, files, policy, tools, env } with absolute paths; env is the module's
|
||||
// adapter variables, which the host writes into session.json.
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false }) {
|
||||
if (route !== "primary" && route !== "fallback") throw new BundleError(`route ${JSON.stringify(route)} isn't primary or fallback`);
|
||||
const model = sessionModel(resolved, models, route);
|
||||
const web = webPlan(resolved, model.harness, harness.builtinWeb());
|
||||
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
|
||||
const untyped = untypedActions(resolved, tools);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
@@ -170,28 +182,17 @@ export function buildBundle({ dir, resolved, contract, arbiter = false, tracker
|
||||
tools: join(dir, "tools.json"),
|
||||
manifest: join(dir, "manifest.json"),
|
||||
};
|
||||
const builtins = harness.builtins({ tools: policy.tools, webBuiltin: web.builtin });
|
||||
const digests = [
|
||||
write("prompt.md", prompt({ contract, resolved, session: { ...session, ...model, webBuiltin: web.builtin }, tools, untyped })),
|
||||
write("prompt.md", prompt({ contract, resolved, session: { ...session, ...model, webBuiltin: web.builtin }, tools, untyped, builtins })),
|
||||
write("policy.json", JSON.stringify(policy, null, 2) + "\n"),
|
||||
write("tools.json", JSON.stringify(tools, null, 2) + "\n"),
|
||||
];
|
||||
const sources = { gate: SOURCES.gate, tools: SOURCES.tools };
|
||||
if (model.harness === "pi") {
|
||||
sources.piExtension = SOURCES.piExtension;
|
||||
} else {
|
||||
sources.claudeGate = SOURCES.claudeGate;
|
||||
sources.mcpServer = SOURCES.mcpServer;
|
||||
files.settings = join(dir, "settings.json");
|
||||
files.mcp = join(dir, "mcp.json");
|
||||
const node = process.execPath;
|
||||
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(SOURCES.claudeGate)} ${quote(files.policy)} || exit 2`;
|
||||
digests.push(write("settings.json", JSON.stringify({
|
||||
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
|
||||
}, null, 2) + "\n"));
|
||||
digests.push(write("mcp.json", JSON.stringify({
|
||||
mcpServers: { mosaic: { type: "stdio", command: node, args: [SOURCES.mcpServer, files.tools, session.toolSocket] } },
|
||||
}, null, 2) + "\n"));
|
||||
}
|
||||
// The module's own files. A name the bundle already wrote refuses at write (flag wx).
|
||||
const own = harness.bundle({ dir, files: { ...files }, session, write, quote });
|
||||
Object.assign(files, own.files);
|
||||
digests.push(...own.digests);
|
||||
const sources = { gate: SOURCES.gate, tools: SOURCES.tools, ...own.sources };
|
||||
const manifest = {
|
||||
bundleVersion: BUNDLE_VERSION,
|
||||
business: resolved.business,
|
||||
@@ -201,18 +202,20 @@ export function buildBundle({ dir, resolved, contract, arbiter = false, tracker
|
||||
launchedBy: session.launchedBy,
|
||||
resolvedDigest: resolved.digest,
|
||||
...model,
|
||||
piVersion: model.harness === "pi" ? piVersion : null,
|
||||
claudeVersion: model.harness === "claude-code" ? claudeVersion : null,
|
||||
builtins: model.harness === "pi" ? [...policy.tools] : claudeBuiltins(policy),
|
||||
typed: tools.map((t) => (model.harness === "pi" ? t.name : MCP_PREFIX + t.name)),
|
||||
route,
|
||||
routeReason,
|
||||
harnessVersion,
|
||||
builtins,
|
||||
typed: tools.map((t) => harness.typedName(t.name)),
|
||||
untyped,
|
||||
webSource: resolved.limits.tools.includes("web") ? web.source : null,
|
||||
skills: [],
|
||||
reliesOn: RELIES_ON[model.harness],
|
||||
gateTimeout: model.harness === "claude-code" ? GATE_TIMEOUT : null,
|
||||
reliesOn: [...harness.reliesOn()],
|
||||
gateTimeout: harness.gateTimeout(),
|
||||
files: Object.fromEntries(digests),
|
||||
sources: Object.fromEntries(Object.entries(sources).map(([k, p]) => [k, { path: p, sha256: sha256(readFileSync(p)) }])),
|
||||
};
|
||||
writeFileSync(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
||||
return { manifest, files, policy, tools };
|
||||
const env = harness.env({ files, policy, tools, session: { ...session, ...model } });
|
||||
return { manifest, files, policy, tools, env };
|
||||
}
|
||||
|
||||
@@ -32,8 +32,6 @@ import { createServer, connect } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { SOURCES } from "./bundle.mjs";
|
||||
import { claudeBuiltins } from "./gate.mjs";
|
||||
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
|
||||
|
||||
export const EXIT = Object.freeze({ stopped: 0, founder: 20, claim: 21, session: 22, broker: 23, usage: 2 });
|
||||
@@ -248,19 +246,23 @@ function runTurn({ session, request, n, env, onChild }) {
|
||||
onChild(null);
|
||||
if (timedOut) return resolve({ ok: false, reason: `turn passed its ${session.turnTimeout} s wall clock` });
|
||||
if (code !== 0) return resolve({ ok: false, reason: signal ? `adapter killed by ${signal}` : `adapter exited ${code}` });
|
||||
if (session.harness === "pi" && !existsSync(marker)) return resolve({ ok: false, reason: "pi exited before the turn ended (no agent_end)" });
|
||||
// A "marker" harness (row 65: the module's turnEnd()) writes the marker when its turn ends.
|
||||
if (session.turnEnd === "marker" && !existsSync(marker)) return resolve({ ok: false, reason: `${session.harness} exited before the turn ended (no turn marker)` });
|
||||
resolve({ ok: true, text: out.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The adapter's environment: the host's allowlisted environment plus the
|
||||
// bundle. Nothing here is a credential.
|
||||
export const harnessOk = (s) => (s.turnEnd === "marker" || s.turnEnd === "exit")
|
||||
&& s.harnessEnv !== null && typeof s.harnessEnv === "object" && !Array.isArray(s.harnessEnv)
|
||||
&& Object.values(s.harnessEnv).every((v) => typeof v === "string");
|
||||
|
||||
// The adapter's environment: the host's allowlisted environment, the bundle,
|
||||
// and the harness module's own variables, which the host computed into
|
||||
// session.harnessEnv (row 65). Nothing here is a credential.
|
||||
export function adapterEnv(session, env) {
|
||||
const b = session.bundle;
|
||||
const tools = JSON.parse(readFileSync(b.tools, "utf8"));
|
||||
const policy = JSON.parse(readFileSync(b.policy, "utf8"));
|
||||
const out = {
|
||||
return {
|
||||
...env,
|
||||
MOSAIC_SYSTEM_PROMPT_FILE: b.prompt,
|
||||
MOSAIC_WORKSPACE: session.workspace,
|
||||
@@ -271,18 +273,8 @@ export function adapterEnv(session, env) {
|
||||
MOSAIC_TURN_MARKER: session.turnMarker,
|
||||
MOSAIC_PROVIDER: session.provider ?? "",
|
||||
MOSAIC_MODEL: session.model,
|
||||
...session.harnessEnv,
|
||||
};
|
||||
if (session.harness === "pi") {
|
||||
out.MOSAIC_TOOLS = [...policy.tools, ...tools.map((t) => t.name)].join(",");
|
||||
out.MOSAIC_EXTENSIONS = SOURCES.piExtension;
|
||||
out.PI_PROVIDER = session.provider ?? "";
|
||||
out.PI_MODEL = session.model;
|
||||
} else {
|
||||
out.MOSAIC_TOOLS = claudeBuiltins(policy).join(",");
|
||||
out.MOSAIC_CLAUDE_SETTINGS = b.settings;
|
||||
out.MOSAIC_CLAUDE_MCP_CONFIG = b.mcp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function main(runDir, { stdin = process.stdin, env = process.env, log = (s) => process.stderr.write(s + "\n") } = {}) {
|
||||
@@ -324,6 +316,12 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
log("runner: malformed landing entry");
|
||||
return EXIT.usage;
|
||||
}
|
||||
// Row 65: the harness module's answers. A session.json without them refuses rather than
|
||||
// guessing a turn-end rule or an environment.
|
||||
if (!harnessOk(session)) {
|
||||
log("runner: session.json has no turnEnd of marker or exit, or no harnessEnv of strings");
|
||||
return EXIT.usage;
|
||||
}
|
||||
if (stopping) {
|
||||
log("runner: stopped before claim");
|
||||
return EXIT.stopped;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.mjs";
|
||||
import { resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { HARNESS, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { decide } from "../src/gate.mjs";
|
||||
|
||||
const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex");
|
||||
@@ -21,18 +21,32 @@ function build(t, resolved, extra = {}) {
|
||||
tracker: true,
|
||||
credentials: [{ service: "gitea", date: "2099-01-01", state: "valid" }],
|
||||
session: { run: "run-1", launchedBy: "pm", workspace, toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done") },
|
||||
piVersion: "0.85.1",
|
||||
claudeVersion: "2.1.0 (Claude Code)",
|
||||
harness: HARNESS[sessionModel(resolved, null, extra.route ?? "primary").harness],
|
||||
harnessVersion: "fixture-version",
|
||||
...extra,
|
||||
});
|
||||
return { dir, workspace, ...bundle };
|
||||
}
|
||||
|
||||
test("sessionModel: agent vars win, then the system's execution settings", (t) => {
|
||||
assert.deepEqual(sessionModel(resolvedFor(t, "pm")), { harness: "pi", provider: "zai", model: "claude-opus-5-5", thinking: null });
|
||||
assert.deepEqual(sessionModel(resolvedFor(t, "coder")), { harness: "pi", provider: "zai", model: "glm-5.3-flash", thinking: null });
|
||||
// The test system's adapter is "mock", which isn't a session harness.
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "reviewer")), (e) => e instanceof BundleError && /harness "mock"/.test(e.message));
|
||||
// Row 65: a session's harness, model and thinking are its own three vars; the system's
|
||||
// execution.adapter and execution.model are never read, and a missing var refuses no-route by name.
|
||||
test("sessionModel: the route is the instance's own vars, and a missing one refuses no-route by name", (t) => {
|
||||
assert.deepEqual(sessionModel(resolvedFor(t, "pm")), { harness: "pi", provider: null, model: "claude-opus-5-5", thinking: "high" });
|
||||
const models = { "claude-opus-5-5": { provider: "anthropic", model: "claude-opus-5-5" } };
|
||||
assert.deepEqual(sessionModel(resolvedFor(t, "pm"), models), { harness: "pi", provider: "anthropic", model: "claude-opus-5-5", modelKey: "claude-opus-5-5", thinking: "high" });
|
||||
for (const missing of ["harness", "model", "thinking"]) {
|
||||
const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high" }), { routes: false });
|
||||
const without = { ...r, vars: Object.fromEntries(Object.entries(r.vars).filter(([k]) => k !== missing)) };
|
||||
assert.throws(() => sessionModel(without), (e) => e instanceof BundleError && e.code === "no-route" && e.missing === missing && e.message.includes(`no ${missing} var`), missing);
|
||||
}
|
||||
// The reviewer sets no vars; the test system's execution.adapter and execution.model don't fill in.
|
||||
const reviewer = resolvedFor(t, "reviewer", null, { routes: false });
|
||||
assert.throws(() => sessionModel(reviewer), (e) => e.code === "no-route" && e.missing === "harness");
|
||||
// The fallback route reads only the fallback vars, and is no-route when they are unset.
|
||||
const fb = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-opus-5-5", thinking: "max", "fallback.harness": "pi", "fallback.model": "glm-5.3-flash", "fallback.thinking": "minimal" }));
|
||||
assert.deepEqual(sessionModel(fb, null, "fallback"), { harness: "pi", provider: null, model: "glm-5.3-flash", thinking: "minimal" });
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "pm"), null, "fallback"), (e) => e.code === "no-route" && e.missing === "fallback.harness");
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "coder"), models), /unknown model table selection glm-5.3-flash/);
|
||||
});
|
||||
|
||||
test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory", (t) => {
|
||||
@@ -59,8 +73,8 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory"
|
||||
const m = b.manifest;
|
||||
assert.equal(m.harness, "pi");
|
||||
assert.equal(m.model, "claude-opus-5-5");
|
||||
assert.equal(m.piVersion, "0.85.1");
|
||||
assert.equal(m.claudeVersion, null);
|
||||
assert.equal(m.thinking, "high");
|
||||
assert.deepEqual([m.route, m.routeReason, m.harnessVersion], ["primary", null, "fixture-version"]);
|
||||
assert.equal(m.gateTimeout, null);
|
||||
assert.deepEqual(m.skills, []);
|
||||
assert.equal(m.reliesOn.length, 5);
|
||||
@@ -125,8 +139,7 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) =
|
||||
const mcp = JSON.parse(readFileSync(b.files.mcp, "utf8"));
|
||||
assert.deepEqual(mcp, { mcpServers: { mosaic: { type: "stdio", command: process.execPath, args: [SOURCES.mcpServer, b.files.tools, join(b.dir, "tools.sock")] } } });
|
||||
const m = b.manifest;
|
||||
assert.equal(m.piVersion, null);
|
||||
assert.equal(m.claudeVersion, "2.1.0 (Claude Code)");
|
||||
assert.equal(m.harnessVersion, "fixture-version");
|
||||
assert.deepEqual(m.gateTimeout, { n: 10, k: 2, hook: 20 });
|
||||
assert.ok(m.builtins.includes("Bash"));
|
||||
assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__")));
|
||||
@@ -215,6 +228,7 @@ test("a bundle is written once: an existing file refuses", (t) => {
|
||||
resolved: resolvedFor(t, "pm"),
|
||||
contract: "c",
|
||||
session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") },
|
||||
harness: HARNESS.pi,
|
||||
}), /EEXIST/);
|
||||
assert.equal(readFileSync(join(dir, "bundle", "prompt.md"), "utf8"), "old");
|
||||
});
|
||||
@@ -227,6 +241,7 @@ test("a path with a single quote can't go into the hook command", (t) => {
|
||||
resolved: r,
|
||||
contract: "c",
|
||||
session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") },
|
||||
harness: HARNESS["claude-code"],
|
||||
}), (e) => e instanceof BundleError && /single quote/.test(e.message));
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, w
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { buildBundle } from "../src/bundle.mjs";
|
||||
import { adapterEnv } from "../src/runner.mjs";
|
||||
import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = join(REPO, "adapters", "claude", "adapter.sh");
|
||||
const which = spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" });
|
||||
@@ -42,8 +42,9 @@ async function session(t, script, tools = ["read", "bash"]) {
|
||||
toolSocket: sock.path,
|
||||
turnMarker: join(dir, "turn.done"),
|
||||
};
|
||||
const { files, manifest } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", session: { ...s, run: "run-1", launchedBy: "pm" } });
|
||||
const { files, manifest, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", harness: HARNESS["claude-code"], session: { ...s, run: "run-1", launchedBy: "pm" } });
|
||||
s.bundle = files;
|
||||
s.harnessEnv = harnessEnv;
|
||||
const env = adapterEnv(s, {
|
||||
PATH: `${dirname(CLAUDE)}:${dirname(process.execPath)}:/usr/bin:/bin`,
|
||||
HOME: join(dir, "home"),
|
||||
@@ -157,6 +158,7 @@ test("claude adapter: --restricted is always passed", (t) => {
|
||||
MOSAIC_WORKSPACE: join(dir, "ws"),
|
||||
MOSAIC_SESSION_DIR: join(dir, "session"),
|
||||
MOSAIC_MODEL: "claude-sonnet-5-5",
|
||||
MOSAIC_THINKING: "high",
|
||||
MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"),
|
||||
MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"),
|
||||
},
|
||||
@@ -191,6 +193,7 @@ test("claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute e
|
||||
MOSAIC_WORKSPACE: join(dir, "ws"),
|
||||
MOSAIC_SESSION_DIR: join(dir, "session"),
|
||||
MOSAIC_MODEL: "claude-sonnet-5-5",
|
||||
MOSAIC_THINKING: "high",
|
||||
MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"),
|
||||
MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"),
|
||||
...(bin === undefined ? {} : { MOSAIC_CLAUDE_BIN: bin }),
|
||||
@@ -239,6 +242,7 @@ test("claude adapter: a directory pin exits 2 before it runs; a pin with a space
|
||||
MOSAIC_WORKSPACE: join(dir, "ws"),
|
||||
MOSAIC_SESSION_DIR: join(dir, "session"),
|
||||
MOSAIC_MODEL: "claude-sonnet-5-5",
|
||||
MOSAIC_THINKING: "high",
|
||||
MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"),
|
||||
MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"),
|
||||
MOSAIC_CLAUDE_BIN: bin,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// SLEEP hang until killed
|
||||
// FAIL exit 3
|
||||
// NOMARKER answer without writing the turn marker
|
||||
// ENV <name> answer "env: <value of name>"
|
||||
// anything else answer "echo: <request>"
|
||||
// Every turn appends its pid to $FAKE_PIDS when set.
|
||||
|
||||
@@ -29,6 +30,9 @@ if (body.startsWith("TOOL ")) {
|
||||
setInterval(() => {}, 1000);
|
||||
} else if (body === "FAIL") {
|
||||
process.exit(3);
|
||||
} else if (body.startsWith("ENV ")) {
|
||||
process.stdout.write(`env: ${process.env[body.slice(4)]}\n`);
|
||||
done();
|
||||
} else if (body === "NOMARKER") {
|
||||
process.stdout.write("half an answer");
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveInstance, validateBusinessDocument } from "../../business/src/index.mjs";
|
||||
import { businessDoc, REPO_ROLES, systemFor } from "../../business/tests/helpers.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
|
||||
export { REPO } from "../../business/tests/helpers.mjs";
|
||||
|
||||
@@ -19,11 +20,26 @@ export function scratch(t, prefix = "mosaic-harness-") {
|
||||
return dir;
|
||||
}
|
||||
|
||||
// resolved("pm" | "coder" | ..., { mutate }) in a scratch root.
|
||||
export function resolvedFor(t, instance, mutate) {
|
||||
// Row 65: the two harness modules, each through its own loader as the launcher loads them.
|
||||
async function harnessModule(name) {
|
||||
const loader = createLoader({ log() {} });
|
||||
const { refused } = await loader.load({ harness: [name] });
|
||||
if (refused.length) throw new Error(`harness ${name} refused: ${JSON.stringify(refused)}`);
|
||||
return loader.modules("harness")[0];
|
||||
}
|
||||
export const HARNESS = Object.freeze({ pi: await harnessModule("pi"), "claude-code": await harnessModule("claude-code") });
|
||||
|
||||
// A session's route is its own three vars (row 65); a role the test doesn't route runs pi on the
|
||||
// test system's old execution.model at thinking high, so the bundle tests keep their models.
|
||||
export const ROUTE_DEFAULTS = Object.freeze({ harness: "pi", model: "glm-5.3-flash", thinking: "high" });
|
||||
|
||||
// resolved("pm" | "coder" | ..., { mutate }) in a scratch root. routes: false leaves a role's
|
||||
// route vars as the business file has them.
|
||||
export function resolvedFor(t, instance, mutate, { routes = true } = {}) {
|
||||
const root = scratch(t, "mosaic-harness-biz-");
|
||||
const doc = businessDoc(root);
|
||||
mutate?.(doc);
|
||||
if (routes) for (const entry of Object.values(doc.roles)) entry.vars = { ...ROUTE_DEFAULTS, ...(entry.vars ?? {}) };
|
||||
const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
|
||||
return resolveInstance({ system: systemFor(root), business, instance });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { join } from "node:path";
|
||||
import { buildBundle } from "../src/bundle.mjs";
|
||||
import { adapterEnv } from "../src/runner.mjs";
|
||||
import { REPLY_CAP } from "../src/tools.mjs";
|
||||
import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
|
||||
|
||||
@@ -43,8 +43,10 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
|
||||
toolSocket: sock.path,
|
||||
turnMarker: join(dir, "turn.done"),
|
||||
};
|
||||
const { files } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# PM\n", tracker: true, session: { ...s, run: "run-1", launchedBy: "jason" } });
|
||||
const models = { "probe-model": { provider: "probe", model: "probe-model" } };
|
||||
const { files, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# PM\n", tracker: true, harness: HARNESS.pi, models, session: { ...s, run: "run-1", launchedBy: "jason" } });
|
||||
s.bundle = files;
|
||||
s.harnessEnv = harnessEnv;
|
||||
const env = adapterEnv(s, {
|
||||
PATH: `${join(REPO, "node_modules", ".bin")}:/usr/bin:/bin`,
|
||||
HOME: join(dir, "home"),
|
||||
|
||||
@@ -143,6 +143,8 @@ async function setup(t, { session = {}, policy = {}, tools, as = "coder", organi
|
||||
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
|
||||
toolSocket: join(runDir, "tools.sock"),
|
||||
turnMarker: join(runDir, "turn.done"),
|
||||
turnEnd: "marker",
|
||||
harnessEnv: {},
|
||||
pollInterval: 50,
|
||||
turnTimeout: 20,
|
||||
...session,
|
||||
@@ -452,7 +454,7 @@ test("a RESULT gets no automatic reply; failed turns reply with the reason", asy
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.replace(/^echo: .*/s, "echo")), [
|
||||
"echo",
|
||||
"The turn failed: adapter exited 3",
|
||||
"The turn failed: pi exited before the turn ended (no agent_end)",
|
||||
"The turn failed: pi exited before the turn ended (no turn marker)",
|
||||
"The turn failed: turn passed its 1 s wall clock",
|
||||
]);
|
||||
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", res.id));
|
||||
@@ -794,3 +796,37 @@ test("a malformed landing exits 2 before the claim, with no turn", async (t) =>
|
||||
assert.equal(out.code, EXIT.stopped, out.stderr);
|
||||
assert.match(out.stderr, new RegExp(`runner: landing turn 1 for decision ${"d".repeat(160)} \\(vikunja:3/7\\): ok`));
|
||||
});
|
||||
|
||||
// Row 65: the harness module's answers reach the runner as data in session.json, and the
|
||||
// runner refuses one without them rather than guess.
|
||||
test("row 65 a session.json without a turnEnd of marker or exit, or a harnessEnv of strings, exits 2 before the claim", async (t) => {
|
||||
const cases = {
|
||||
"no turnEnd": { turnEnd: undefined },
|
||||
"another turnEnd": { turnEnd: "agent_end" },
|
||||
"no harnessEnv": { harnessEnv: undefined },
|
||||
"a null harnessEnv": { harnessEnv: null },
|
||||
"an array harnessEnv": { harnessEnv: ["MOSAIC_THINKING=high"] },
|
||||
"a number in harnessEnv": { harnessEnv: { MOSAIC_THINKING: 3 } },
|
||||
};
|
||||
const ctx = await setup(t);
|
||||
const file = join(ctx.runDir, "session.json");
|
||||
const session = JSON.parse(readFileSync(file, "utf8"));
|
||||
for (const [why, change] of Object.entries(cases)) {
|
||||
writeFileSync(file, JSON.stringify({ ...session, ...change }));
|
||||
const out = await startRunner(ctx.runDir, ctx.cap).exited;
|
||||
assert.equal(out.code, EXIT.usage, `${why}: ${out.stderr}`);
|
||||
assert.match(out.stderr, /runner: session\.json has no turnEnd of marker or exit, or no harnessEnv of strings/, why);
|
||||
}
|
||||
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
|
||||
});
|
||||
|
||||
test("row 65 harnessEnv reaches the adapter, and an exit harness needs no turn marker", async (t) => {
|
||||
const ctx = await setup(t, { session: { turnEnd: "exit", harnessEnv: { MOSAIC_THINKING: "xhigh" } } });
|
||||
ctx.inbox = [];
|
||||
const r = startRunner(ctx.runDir, ctx.cap);
|
||||
t.after(() => r.child.kill("SIGKILL"));
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: "ENV MOSAIC_THINKING" });
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: "NOMARKER" });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.trim()), ["env: xhigh", "half an answer"]);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ loader.
|
||||
| `limit-check` | 2 | list | `interfaceVersion`, `mayLaunch`, `mustStop`, `report` |
|
||||
| `search-source` | 1 | one | `interfaceVersion`, `search` |
|
||||
| `channel` | 1 | list | `interfaceVersion`, `dispatch` |
|
||||
| `harness` | 1 | list | `interfaceVersion`, `adapter`, `levels`, `preflight`, `version`, `countsAccount`, `mounts`, `builtinWeb`, `builtins`, `typedName`, `reliesOn`, `gateTimeout`, `bundle`, `env`, `turnEnd`, `transcript` |
|
||||
|
||||
Every module exports `interfaceVersion`, equal to its kind's interface
|
||||
version, plus the kind's functions. PRD REQ-LIMIT-4 says what the two kinds
|
||||
@@ -124,7 +125,67 @@ Limits of the checks:
|
||||
- Node caches a module by URL, so a changed `index.mjs` is not re-read
|
||||
until the process restarts.
|
||||
- The bus host wires the loader: row 124 for usage sources, row 125 for
|
||||
limit checks.
|
||||
limit checks, row 65 for harness levels. The launcher loads a launch's
|
||||
harness itself.
|
||||
|
||||
## Harness (row 65)
|
||||
|
||||
A managed session's harness is a module under `harness/`: `pi` and
|
||||
`claude-code` ship. Core reaches a harness only through the loader, so the
|
||||
launcher, the bundle, the runner and the usage reader name no harness. The
|
||||
host calls the module and writes its answers into `session.json` as data;
|
||||
the sandboxed runner never imports it. `src/kinds.mjs` lists what each
|
||||
export returns:
|
||||
|
||||
- `adapter()` is the absolute path of the adapter script the runner runs.
|
||||
- `levels()` lists the thinking levels the harness applies: pi takes `off`,
|
||||
`minimal`, `low`, `medium`, `high`, `xhigh` and `max` as `--thinking`,
|
||||
Claude Code takes `low`, `medium`, `high`, `xhigh` and `max` as
|
||||
`--effort`.
|
||||
- `preflight({env})` returns `null` or `{code, message}`: pi checks its
|
||||
binary in `node_modules` (`harness-binary-missing`), Claude Code its
|
||||
session credential (`no-session-credential`) and `MOSAIC_CLAUDE_BIN`.
|
||||
- `version({env})` is what the manifest records as `harnessVersion`.
|
||||
- `countsAccount()` says whether its launches spend `launch.account` and get
|
||||
the row 126 account checks.
|
||||
- `mounts`, `builtinWeb`, `builtins`, `typedName`, `reliesOn`,
|
||||
`gateTimeout`, `bundle`, `env`, `turnEnd` and `transcript` give its
|
||||
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
|
||||
how a turn ends and where its transcripts land.
|
||||
|
||||
`plugins.harness` switches harnesses on, and an instance's `harness` and
|
||||
`fallback.harness` vars each name one. A launch on a harness that isn't
|
||||
switched on refuses `harness-off`, primary or fallback, and an off primary
|
||||
never falls back. The launcher then loads the route's one module through a
|
||||
fresh loader, so an earlier load's module is never reused. A module the
|
||||
loader refuses fails the launch with `harness-refused`, naming the module
|
||||
and the loader's code. The switch gates launches and writes, not loads: the
|
||||
bus host loads every harness module in the tree to read its thinking levels,
|
||||
on or off. The broker binds a new run only on a switched-on harness; a run
|
||||
an earlier broker bound binds again.
|
||||
|
||||
Writes check it too. `routes.set`, `instances.set` and `limits.set` refuse a
|
||||
changed route whose harness is off (`harness-off`), whose module didn't load
|
||||
(`harness-refused`), or whose module doesn't apply its thinking level
|
||||
(`thinking-unsupported`). A route the write doesn't change isn't checked,
|
||||
so a model change still saves while `plugins.harness` is unset.
|
||||
|
||||
A new harness is a new module directory and a `plugins set` patch: no core
|
||||
file changes. `tests/harness.test.mjs` launches a fixture module that way,
|
||||
and greps every `packages/*/src` outside this package for a quoted harness
|
||||
name. The exceptions are the tool gates (`harness/src/gate.mjs`,
|
||||
`claude-gate.mjs`, `pi-extension.mjs`), the human-proof check
|
||||
(`bus/src/human.mjs`), the engines that start pi themselves
|
||||
(`conversation/src/`, `discord/src/`) and the process scan
|
||||
(`control-board/src/scan.mjs`). The test fails if an exception stops naming
|
||||
a harness, so the list stays true.
|
||||
|
||||
Switching harnesses on is a `mosaic plugins set` patch. The list replaces
|
||||
the kind's whole setting, so it names every harness that stays on:
|
||||
|
||||
```json
|
||||
{ "revision": "<from mosaic plugins>", "plugins": { "harness": ["pi", "claude-code"] } }
|
||||
```
|
||||
|
||||
## Transcript usage source (row 124)
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// The "claude-code" harness plug-in (row 65, REQ-PLUG-2, Q156): a managed session on the host's
|
||||
// Claude Code CLI, MOSAIC_CLAUDE_BIN when set (the bus unit pins it), else `claude` on PATH. The
|
||||
// tool gate is claude-gate.mjs behind the PreToolUse hook and the typed tools are mcp-server.mjs;
|
||||
// both stay in packages/harness (a module can't change that boundary). The launcher, the bundle
|
||||
// and the usage reader call these on the host; the runner gets the answers as data in
|
||||
// session.json. packages/plugins/README.md lists the interface.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
||||
import { delimiter, isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { claudeBuiltins, MCP_PREFIX } from "../../../harness/src/gate.mjs";
|
||||
import { hasSessionCredential, SESSION_CREDENTIALS } from "../../../seat/src/session.mjs";
|
||||
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const GATE = fileURLToPath(new URL("../../../harness/src/claude-gate.mjs", import.meta.url));
|
||||
const MCP_SERVER = fileURLToPath(new URL("../../../harness/src/mcp-server.mjs", import.meta.url));
|
||||
// `claude --effort <level>` on 2.1.302 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
|
||||
// It has no off or minimal.
|
||||
const LEVELS = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
|
||||
// docs/plans/2026-10-04_slice-1.md, "What S6 can rely on", lines 1-5.
|
||||
const RELIES_ON = Object.freeze(["S0-1 command hook deny, no --bare", "S0-2 hook wrapped as <gate> || exit 2",
|
||||
"S0-3 a missing gate blocks through the wrapper", "S0-4 timeout -k 2 10 <gate> || exit 2 with hook timeout 20",
|
||||
"S0-5 bash is limited by the tool limit only"]);
|
||||
// The hook: N = 10, K = 2, hook timeout 20, which leaves the margin the brief asks for above N + K.
|
||||
const GATE_TIMEOUT = Object.freeze({ n: 10, k: 2, hook: 20 });
|
||||
|
||||
export const adapter = () => join(REPO, "adapters", "claude", "adapter.sh");
|
||||
export const levels = () => LEVELS;
|
||||
|
||||
const executable = (path) => {
|
||||
try {
|
||||
accessSync(path, constants.X_OK);
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// The binary sessions run: MOSAIC_CLAUDE_BIN, else the first `claude` on PATH, or null.
|
||||
function binary(env) {
|
||||
if (env.MOSAIC_CLAUDE_BIN) return isAbsolute(env.MOSAIC_CLAUDE_BIN) && executable(env.MOSAIC_CLAUDE_BIN) ? env.MOSAIC_CLAUDE_BIN : null;
|
||||
for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "claude"))) return join(dir, "claude");
|
||||
return null;
|
||||
}
|
||||
|
||||
// A session needs a credential from the host's environment and the binary it runs.
|
||||
export function preflight({ env }) {
|
||||
if (!hasSessionCredential(env)) {
|
||||
const names = SESSION_CREDENTIALS.map((set) => set.join(" with ")).join(", ");
|
||||
return { code: "no-session-credential", message: `the bus host has none of ${names}; the bus unit's EnvironmentFile sets one when the bus starts` };
|
||||
}
|
||||
if (!binary(env)) {
|
||||
return {
|
||||
code: "harness-binary-missing",
|
||||
message: env.MOSAIC_CLAUDE_BIN ? `MOSAIC_CLAUDE_BIN ${env.MOSAIC_CLAUDE_BIN} isn't an executable file at an absolute path` : "no claude on the bus host's PATH",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// `claude --version` from the binary sessions run, or null when it doesn't answer.
|
||||
export function version({ env }) {
|
||||
const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
|
||||
return r.status === 0 ? r.stdout.trim().split("\n")[0] || null : null;
|
||||
}
|
||||
|
||||
// Its launches spend the business's launch.account (row 126).
|
||||
export const countsAccount = () => true;
|
||||
|
||||
// The pinned binary, when it lives outside /usr (which the sandbox already binds).
|
||||
export function mounts({ env }) {
|
||||
if (!env.MOSAIC_CLAUDE_BIN) return [];
|
||||
const real = realpathSync(env.MOSAIC_CLAUDE_BIN);
|
||||
return real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }];
|
||||
}
|
||||
|
||||
// WebSearch and WebFetch are its own, so it serves harness-builtin web.
|
||||
export const builtinWeb = () => true;
|
||||
export const builtins = ({ tools, webBuiltin = false }) => claudeBuiltins({ tools, webBuiltin });
|
||||
export const typedName = (name) => MCP_PREFIX + name;
|
||||
export const reliesOn = () => RELIES_ON;
|
||||
export const gateTimeout = () => ({ ...GATE_TIMEOUT });
|
||||
|
||||
// settings.json holds the gate hook and mcp.json the typed tools' server. files: the bundle's own
|
||||
// (policy, tools). write(name, text) puts a file in the bundle directory and returns
|
||||
// [name, sha256]; quote(path) refuses a single quote.
|
||||
export function bundle({ dir, files: core, session, write, quote }) {
|
||||
const files = { settings: join(dir, "settings.json"), mcp: join(dir, "mcp.json") };
|
||||
const node = process.execPath;
|
||||
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(GATE)} ${quote(core.policy)} || exit 2`;
|
||||
const digests = [
|
||||
write("settings.json", JSON.stringify({
|
||||
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
|
||||
}, null, 2) + "\n"),
|
||||
write("mcp.json", JSON.stringify({
|
||||
mcpServers: { mosaic: { type: "stdio", command: node, args: [MCP_SERVER, core.tools, session.toolSocket] } },
|
||||
}, null, 2) + "\n"),
|
||||
];
|
||||
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER } };
|
||||
}
|
||||
|
||||
export function env({ files, policy, session }) {
|
||||
return {
|
||||
MOSAIC_TOOLS: claudeBuiltins(policy).join(","),
|
||||
MOSAIC_CLAUDE_SETTINGS: files.settings,
|
||||
MOSAIC_CLAUDE_MCP_CONFIG: files.mcp,
|
||||
MOSAIC_THINKING: session.thinking,
|
||||
};
|
||||
}
|
||||
|
||||
// A turn ends when claude exits; there is no marker.
|
||||
export const turnEnd = () => "exit";
|
||||
export const transcript = ({ runDir, store = null }) => (store ? store.projects : join(runDir, "home", ".claude", "projects"));
|
||||
@@ -0,0 +1,65 @@
|
||||
// The "pi" harness plug-in (row 65, REQ-PLUG-2, Q156): a managed session on the pinned
|
||||
// @earendil-works/pi-coding-agent CLI, the repository's node_modules/.bin/pi. The tool gate is the
|
||||
// Pi extension, which stays in packages/harness (a module can't change that boundary). The
|
||||
// launcher, the bundle and the usage reader call these on the host; the runner gets the answers
|
||||
// as data in session.json. packages/plugins/README.md lists the interface.
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const PACKAGE = "@earendil-works/pi-coding-agent";
|
||||
const BIN = join(REPO, "node_modules", ".bin", "pi");
|
||||
const EXTENSION = fileURLToPath(new URL("../../../harness/src/pi-extension.mjs", import.meta.url));
|
||||
// `pi --thinking <level>` on 0.85.1 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
|
||||
const LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
// docs/plans/2026-10-04_slice-1.md, "What S6 can rely on", lines 1-5.
|
||||
const RELIES_ON = Object.freeze(["S0-1 tool_call block", "S0-2 a throw blocks", "S0-3 a missing -e refuses to start",
|
||||
"S0-4 external wall clock plus agent_end check", "S0-5 bash is limited by the tool limit only"]);
|
||||
|
||||
export const adapter = () => join(REPO, "adapters", "pi", "adapter.sh");
|
||||
export const levels = () => LEVELS;
|
||||
|
||||
// The session runs pi from the repository's node_modules, which the sandbox mounts.
|
||||
export function preflight() {
|
||||
if (!existsSync(BIN)) return { code: "harness-binary-missing", message: `pi isn't installed at ${BIN}; run npm ci in the repository` };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The exact pin from the repository's package.json.
|
||||
export function version() {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
|
||||
return pkg.dependencies?.[PACKAGE] ?? pkg.devDependencies?.[PACKAGE] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Pi sessions run on a provider key, never the Claude account limit.
|
||||
export const countsAccount = () => false;
|
||||
export const mounts = () => [{ src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" }];
|
||||
// Pi has no web tool of its own, so a granted role under harness-builtin is refused.
|
||||
export const builtinWeb = () => false;
|
||||
export const builtins = ({ tools }) => [...tools];
|
||||
export const typedName = (name) => name;
|
||||
export const reliesOn = () => RELIES_ON;
|
||||
export const gateTimeout = () => null;
|
||||
export const bundle = () => ({ files: {}, digests: [], sources: { piExtension: EXTENSION } });
|
||||
|
||||
// The tool limit is the built-ins plus the typed tools, which the extension serves.
|
||||
export function env({ policy, tools, session }) {
|
||||
return {
|
||||
MOSAIC_TOOLS: [...policy.tools, ...tools.map((t) => t.name)].join(","),
|
||||
MOSAIC_EXTENSIONS: EXTENSION,
|
||||
PI_PROVIDER: session.provider ?? "",
|
||||
PI_MODEL: session.model,
|
||||
MOSAIC_THINKING: session.thinking,
|
||||
};
|
||||
}
|
||||
|
||||
// The extension writes the turn marker at agent_end; an exit without it is a failed turn.
|
||||
export const turnEnd = () => "marker";
|
||||
export const transcript = ({ runDir, store = null }) => (store ? store.session : join(runDir, "session"));
|
||||
@@ -32,6 +32,28 @@ export const KINDS = Object.freeze({
|
||||
channel: Object.freeze({
|
||||
kind: "channel", interfaceVersion: 1, exports: Object.freeze(["dispatch"]), switch: "list",
|
||||
}),
|
||||
// A managed session's harness (row 65, REQ-PLUG-2, Q156). Core reaches a harness only through
|
||||
// these, each a function the launcher, the bundle and the usage reader call on the host; the
|
||||
// sandboxed runner gets their answers as data in session.json. The organization file switches
|
||||
// on a list, and an instance's `harness` var names one of them. packages/plugins/README.md has
|
||||
// each one's contract:
|
||||
// adapter() -> the adapter script's absolute path levels() -> the thinking levels it applies
|
||||
// preflight({ env }) -> null | { code, message } version({ env }) -> string | null
|
||||
// countsAccount() -> true when its launches spend launch.account
|
||||
// mounts({ env }) -> read-only binds [{ src, dest, why }]
|
||||
// builtinWeb() -> true when it serves harness-builtin web
|
||||
// builtins({ tools, webBuiltin }) -> its names for the built-in tools
|
||||
// typedName(name) -> its name for a typed bus tool reliesOn() -> the row S0 lines
|
||||
// gateTimeout() -> the gate hook's timeouts | null
|
||||
// bundle({ dir, files, session, write, quote }) -> { files, digests, sources }, its own bundle files
|
||||
// env({ files, policy, tools, session }) -> its adapter variables, written into session.json
|
||||
// turnEnd() -> "marker" (a turn ends only with the turn marker) | "exit"
|
||||
// transcript({ runDir, store }) -> the directory its transcripts land in, for usage
|
||||
harness: Object.freeze({
|
||||
kind: "harness", interfaceVersion: 1, switch: "list",
|
||||
exports: Object.freeze(["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"]),
|
||||
}),
|
||||
});
|
||||
|
||||
export class PluginError extends Error {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// Row 65 (REQ-PLUG-2, Q156): the two harness modules load through the loader with every export
|
||||
// the kind names, answer the questions core used to answer by name, and no core source names a
|
||||
// harness outside the exceptions the brief lists.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createLoader } from "../src/loader.mjs";
|
||||
import { KINDS } from "../src/kinds.mjs";
|
||||
|
||||
const REPO = resolve(fileURLToPath(new URL("../../../", import.meta.url)));
|
||||
|
||||
async function load(name) {
|
||||
const logged = [];
|
||||
const loader = createLoader({ log: entry => logged.push(entry) });
|
||||
const result = await loader.load({ harness: [name] });
|
||||
assert.deepEqual(result.refused, [], name);
|
||||
assert.deepEqual(logged, [], name);
|
||||
const [module] = loader.modules("harness");
|
||||
return module;
|
||||
}
|
||||
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), "mosaic-harness-plugin-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
test("row 65 both harness modules load through the loader with every export the kind names", async () => {
|
||||
for (const name of ["pi", "claude-code"]) {
|
||||
const module = await load(name);
|
||||
for (const key of KINDS.harness.exports) assert.equal(typeof module[key], "function", `${name} ${key}`);
|
||||
assert.equal(module.interfaceVersion, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 65 each module answers for its own harness: adapter, levels, account, web, turn end and session files", async () => {
|
||||
const pi = await load("pi"), claude = await load("claude-code");
|
||||
assert.equal(pi.adapter(), join(REPO, "adapters", "pi", "adapter.sh"));
|
||||
assert.equal(claude.adapter(), join(REPO, "adapters", "claude", "adapter.sh"));
|
||||
// pi 0.85.1 --thinking and claude 2.1.302 --effort, as probed (BUILD.md); claude has no off or minimal.
|
||||
assert.deepEqual([...pi.levels()], ["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
assert.deepEqual([...claude.levels()], ["low", "medium", "high", "xhigh", "max"]);
|
||||
assert.deepEqual([pi.countsAccount(), claude.countsAccount()], [false, true]);
|
||||
assert.deepEqual([pi.builtinWeb(), claude.builtinWeb()], [false, true]);
|
||||
assert.deepEqual([pi.turnEnd(), claude.turnEnd()], ["marker", "exit"]);
|
||||
assert.deepEqual([pi.typedName("list_tasks"), claude.typedName("list_tasks")], ["list_tasks", "mcp__mosaic__list_tasks"]);
|
||||
assert.equal(pi.gateTimeout(), null);
|
||||
assert.deepEqual(claude.gateTimeout(), { n: 10, k: 2, hook: 20 });
|
||||
assert.equal(pi.transcript({ runDir: "/r" }), "/r/session");
|
||||
assert.equal(claude.transcript({ runDir: "/r" }), "/r/home/.claude/projects");
|
||||
assert.equal(pi.transcript({ runDir: "/r", store: { session: "/s", projects: "/p" } }), "/s");
|
||||
assert.equal(claude.transcript({ runDir: "/r", store: { session: "/s", projects: "/p" } }), "/p");
|
||||
assert.equal(pi.reliesOn().length, 5);
|
||||
assert.equal(claude.reliesOn().length, 5);
|
||||
});
|
||||
|
||||
test("row 65 claude-code preflight: a missing credential, then a missing or relative binary, each refuse with their code", async t => {
|
||||
const claude = await load("claude-code");
|
||||
const root = scratch(t);
|
||||
const bin = join(root, "claude");
|
||||
writeFileSync(bin, "#!/bin/sh\necho 2.1.302\n");
|
||||
chmodSync(bin, 0o755);
|
||||
const token = { CLAUDE_CODE_OAUTH_TOKEN: "fixture" };
|
||||
assert.equal(claude.preflight({ env: { MOSAIC_CLAUDE_BIN: bin } }).code, "no-session-credential");
|
||||
assert.equal(claude.preflight({ env: { ...token, PATH: join(root, "none") } }).code, "harness-binary-missing");
|
||||
assert.equal(claude.preflight({ env: { ...token, MOSAIC_CLAUDE_BIN: "claude" } }).code, "harness-binary-missing");
|
||||
assert.equal(claude.preflight({ env: { ...token, MOSAIC_CLAUDE_BIN: bin } }), null);
|
||||
assert.equal(claude.preflight({ env: { ...token, PATH: root } }), null);
|
||||
assert.equal(claude.version({ env: { MOSAIC_CLAUDE_BIN: bin, PATH: process.env.PATH } }), "2.1.302");
|
||||
// The pinned binary outside /usr is mounted where the adapter looks for it.
|
||||
assert.deepEqual(claude.mounts({ env: { MOSAIC_CLAUDE_BIN: bin } }).map(m => [m.src, m.dest]), [[bin, bin]]);
|
||||
assert.deepEqual(claude.mounts({ env: {} }), []);
|
||||
});
|
||||
|
||||
// Row 68's claudeVersion precedence, moved here with the version (row 65): the manifest records
|
||||
// what the binary sessions run answers.
|
||||
test("row 65 claude-code version asks MOSAIC_CLAUDE_BIN when set, else claude on PATH, else null", async t => {
|
||||
const claude = await load("claude-code");
|
||||
const dir = scratch(t);
|
||||
mkdirSync(join(dir, "bin"));
|
||||
const stand = (p, v) => {
|
||||
writeFileSync(p, `#!/bin/sh\necho "${v} (Claude Code)"\n`);
|
||||
chmodSync(p, 0o755);
|
||||
return p;
|
||||
};
|
||||
stand(join(dir, "bin", "claude"), "1.0.0");
|
||||
const pinned = stand(join(dir, "pinned"), "9.9.9");
|
||||
const PATH = join(dir, "bin");
|
||||
assert.equal(claude.version({ env: { PATH, MOSAIC_CLAUDE_BIN: pinned } }), "9.9.9 (Claude Code)");
|
||||
assert.equal(claude.version({ env: { PATH } }), "1.0.0 (Claude Code)");
|
||||
assert.equal(claude.version({ env: { PATH: join(dir, "none") } }), null);
|
||||
});
|
||||
|
||||
test("row 65 pi preflight passes with the repository's pi, and its version is the package pin", async () => {
|
||||
const pi = await load("pi");
|
||||
assert.equal(pi.preflight({ env: {} }), null);
|
||||
const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
|
||||
assert.equal(pi.version({ env: {} }), pkg.dependencies?.["@earendil-works/pi-coding-agent"] ?? pkg.devDependencies?.["@earendil-works/pi-coding-agent"]);
|
||||
assert.deepEqual(pi.mounts({ env: {} }).map(m => m.dest), [join(REPO, "node_modules")]);
|
||||
});
|
||||
|
||||
test("row 65 env passes the thinking level to both adapters, and claude-code's bundle writes its hook and MCP files", async t => {
|
||||
const pi = await load("pi"), claude = await load("claude-code");
|
||||
const session = { provider: "fixture", model: "m", thinking: "high", toolSocket: "/run/tools.sock" };
|
||||
const piEnv = pi.env({ policy: { tools: ["read"] }, tools: [{ name: "list_tasks" }], session });
|
||||
assert.deepEqual([piEnv.MOSAIC_THINKING, piEnv.PI_MODEL, piEnv.MOSAIC_TOOLS], ["high", "m", "read,list_tasks"]);
|
||||
const dir = scratch(t);
|
||||
const written = [];
|
||||
const write = (name, text) => { writeFileSync(join(dir, name), text); written.push(name); return [name, "0".repeat(64)]; };
|
||||
const quote = path => { assert.ok(!path.includes("'")); return `'${path}'`; };
|
||||
const out = claude.bundle({ dir, files: { policy: join(dir, "policy.json"), tools: join(dir, "tools.json") }, session, write, quote });
|
||||
assert.deepEqual(written, ["settings.json", "mcp.json"]);
|
||||
const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8"));
|
||||
assert.match(settings.hooks.PreToolUse[0].hooks[0].command, /^timeout -k 2 10 .* \|\| exit 2$/);
|
||||
assert.equal(settings.hooks.PreToolUse[0].hooks[0].timeout, 20);
|
||||
assert.equal(JSON.parse(readFileSync(join(dir, "mcp.json"), "utf8")).mcpServers.mosaic.args.at(-1), "/run/tools.sock");
|
||||
const claudeEnv = claude.env({ files: out.files, policy: { tools: ["read"], typed: [] }, session });
|
||||
assert.deepEqual([claudeEnv.MOSAIC_THINKING, claudeEnv.MOSAIC_CLAUDE_SETTINGS, claudeEnv.MOSAIC_CLAUDE_MCP_CONFIG], ["high", out.files.settings, out.files.mcp]);
|
||||
});
|
||||
|
||||
// The level a route names reaches the harness binary as its flag: each module's env() feeds the
|
||||
// real adapter, and a stand-in pi or claude records the argv it was given (dewey B3).
|
||||
test("row 65 a route's thinking reaches pi as --thinking and claude as --effort, through each module's env and adapter", async t => {
|
||||
const pi = await load("pi"), claude = await load("claude-code");
|
||||
const dir = scratch(t);
|
||||
mkdirSync(join(dir, "bin"));
|
||||
for (const name of ["pi", "claude"]) {
|
||||
writeFileSync(join(dir, "bin", name), '#!/bin/sh\nprintf "%s\\n" "$@" > "$ARGV_OUT"\n');
|
||||
chmodSync(join(dir, "bin", name), 0o755);
|
||||
}
|
||||
for (const f of ["prompt.md", "settings.json", "mcp.json"]) writeFileSync(join(dir, f), "{}");
|
||||
const run = (adapter, moduleEnv) => {
|
||||
const out = join(dir, "argv");
|
||||
rmSync(out, { force: true });
|
||||
const r = spawnSync("/bin/sh", [join(REPO, "adapters", adapter, "adapter.sh")], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
PATH: `${join(dir, "bin")}:/usr/bin:/bin`, ARGV_OUT: out, MOSAIC_SYSTEM_PROMPT_FILE: join(dir, "prompt.md"),
|
||||
MOSAIC_REQUEST: "x", MOSAIC_WORKSPACE: join(dir, "ws"), MOSAIC_SESSION_DIR: join(dir, "session"), MOSAIC_MODEL: "m",
|
||||
...moduleEnv,
|
||||
},
|
||||
});
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return readFileSync(out, "utf8").split("\n");
|
||||
};
|
||||
const flag = (argv, name) => {
|
||||
assert.equal(argv.filter(a => a === name).length, 1, argv.join(" "));
|
||||
return argv[argv.indexOf(name) + 1];
|
||||
};
|
||||
for (const thinking of ["xhigh", "low"]) {
|
||||
const session = { provider: "fixture", model: "m", thinking, toolSocket: "/run/tools.sock" };
|
||||
assert.equal(flag(run("pi", pi.env({ policy: { tools: ["read"] }, tools: [], session })), "--thinking"), thinking);
|
||||
const files = { settings: join(dir, "settings.json"), mcp: join(dir, "mcp.json") };
|
||||
assert.equal(flag(run("claude", claude.env({ files, policy: { tools: ["read"], typed: [] }, session })), "--effort"), thinking);
|
||||
}
|
||||
});
|
||||
|
||||
// A harness name in core: a quoted "pi" or "claude-code", or either inside a regex alternation.
|
||||
const NAMED = /["'`](?:pi|claude-code)["'`]|[(|](?:pi|claude-code)[|)]/;
|
||||
// The brief's Out of scope list: the tool-policy gate, the human-proof check, the engines that
|
||||
// start pi directly, and the process scan. Each must still name a harness, so the list stays true.
|
||||
const EXCEPT = [
|
||||
"packages/harness/src/gate.mjs",
|
||||
"packages/harness/src/claude-gate.mjs",
|
||||
"packages/harness/src/pi-extension.mjs",
|
||||
"packages/bus/src/human.mjs",
|
||||
"packages/conversation/src/",
|
||||
"packages/discord/src/",
|
||||
"packages/control-board/src/scan.mjs",
|
||||
];
|
||||
function sources(dir, out = []) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) { if (entry.name !== "node_modules" && entry.name !== "third_party") sources(path, out); }
|
||||
else if (/\.(?:mjs|js)$/.test(entry.name)) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test("row 65 no core source outside packages/plugins names a harness, apart from the listed exceptions", () => {
|
||||
const named = [];
|
||||
const hit = new Set();
|
||||
for (const pkg of readdirSync(join(REPO, "packages"))) {
|
||||
if (pkg === "plugins") continue;
|
||||
let files;
|
||||
try { files = sources(join(REPO, "packages", pkg, "src")); } catch (e) { if (e.code === "ENOENT") continue; throw e; }
|
||||
for (const file of files) {
|
||||
const rel = relative(REPO, file);
|
||||
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
|
||||
if (!NAMED.test(line)) return;
|
||||
const exception = EXCEPT.find(e => (e.endsWith("/") ? rel.startsWith(e) : rel === e));
|
||||
if (exception) hit.add(exception);
|
||||
else named.push(`${rel}:${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.deepEqual(named, []);
|
||||
assert.deepEqual([...hit].sort(), [...EXCEPT].sort(), "every exception still names a harness");
|
||||
// human.mjs keeps one: the human-proof regex that refuses any agent harness binary.
|
||||
const human = readFileSync(join(REPO, "packages", "bus", "src", "human.mjs"), "utf8").split("\n").filter(line => NAMED.test(line));
|
||||
assert.deepEqual(human.map(line => line.trim()), ["/^(pi|claude|claude-code|codex)(?:\\.js)?$/.test(command) ||"]);
|
||||
});
|
||||
|
||||
test("row 65 the grep's pattern catches a quoted name and a regex alternation, and not a word in prose", () => {
|
||||
for (const line of ['if (harness === "pi")', "x = 'claude-code';", "/^(pi|codex)$/", "/^(codex|claude-code)$/"]) assert.ok(NAMED.test(line), line);
|
||||
for (const line of ["// pi runs the bundle", "const piModel = 1;", "pipeline"]) assert.ok(!NAMED.test(line), line);
|
||||
});
|
||||
@@ -6,13 +6,20 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
|
||||
const code = expected => e => e instanceof PluginError && e.code === expected;
|
||||
|
||||
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => {
|
||||
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel"]);
|
||||
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness"]);
|
||||
assert.deepEqual({ ...KINDS["usage-source"], exports: [...KINDS["usage-source"].exports] },
|
||||
{ kind: "usage-source", interfaceVersion: 1, exports: ["readTurn"], switch: "list" });
|
||||
assert.deepEqual({ ...KINDS["limit-check"], exports: [...KINDS["limit-check"].exports] },
|
||||
{ kind: "limit-check", interfaceVersion: 2, exports: ["mayLaunch", "mustStop", "report"], switch: "list" });
|
||||
assert.deepEqual({ ...KINDS["search-source"], exports: [...KINDS["search-source"].exports] },
|
||||
{ kind: "search-source", interfaceVersion: 1, exports: ["search"], switch: "one" });
|
||||
// Row 65 (REQ-PLUG-2, Q156): the harness kind, a list, at interface version 1.
|
||||
assert.deepEqual({ ...KINDS.harness, exports: [...KINDS.harness.exports] }, {
|
||||
kind: "harness", interfaceVersion: 1, switch: "list",
|
||||
exports: ["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"],
|
||||
});
|
||||
assert.ok(Object.isFrozen(KINDS.harness) && Object.isFrozen(KINDS.harness.exports));
|
||||
assert.ok(Object.isFrozen(KINDS) && Object.isFrozen(KINDS["usage-source"]) && Object.isFrozen(KINDS["limit-check"].exports));
|
||||
assert.equal(String(NAME), "/^[a-z][a-z0-9-]*$/");
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../business/src/business.mjs";
|
||||
|
||||
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
|
||||
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
|
||||
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [] };
|
||||
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [] };
|
||||
|
||||
// A tree with one good module of each kind, already loaded.
|
||||
async function loadedTree(t, options) {
|
||||
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
|
||||
const loader = await s.loader();
|
||||
const result = await loader.load(business.plugins);
|
||||
assert.deepEqual(result.refused, []);
|
||||
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [] });
|
||||
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [] });
|
||||
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
|
||||
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
|
||||
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
|
||||
@@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
|
||||
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
|
||||
assert.deepEqual(result.refused[0].kept, ["meter"]);
|
||||
assert.deepEqual(s.loader.logged.slice(before), result.refused);
|
||||
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [] });
|
||||
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [] });
|
||||
} finally {
|
||||
if (lock) chmodSync(lock, 0o755);
|
||||
}
|
||||
@@ -232,7 +232,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
|
||||
const loader = await s.loader();
|
||||
// meter loads, absent refuses: the whole kind stays off, not half on.
|
||||
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [] });
|
||||
assert.deepEqual(loader.loaded(), result.loaded);
|
||||
assert.deepEqual(loader.modules("usage-source"), []);
|
||||
assert.equal(loader.logged.length, 1);
|
||||
@@ -260,7 +260,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
|
||||
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
|
||||
// An absent key switches the kind off.
|
||||
result = await s.loader.load({ "limit-check": ["cap"] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [] });
|
||||
});
|
||||
|
||||
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
|
||||
|
||||
@@ -190,10 +190,11 @@ each at its own host path; `binds()` returns them with the reason for each,
|
||||
and the file's header comment has the table.
|
||||
|
||||
- Read-only: `/usr`, `/etc` and the host's `/bin`, `/lib`, `/lib64`,
|
||||
`/sbin` links into `/usr`; the twelve repository files the runner loads
|
||||
(`RUNNER_FILES`); the adapter; for pi the repository's `node_modules`,
|
||||
for Claude Code the `MOSAIC_CLAUDE_BIN` binary when it lives outside
|
||||
`/usr`; node when it lives outside `/usr`; the broker socket, the
|
||||
`/sbin` links into `/usr`; the thirteen repository files the runner loads
|
||||
(`RUNNER_FILES`); the adapter; the harness module's `mounts()` (row 65:
|
||||
pi's gives the repository's `node_modules`, Claude Code's the
|
||||
`MOSAIC_CLAUDE_BIN` binary when it lives outside `/usr`; the sandbox
|
||||
names no harness itself); node when it lives outside `/usr`; the broker socket, the
|
||||
launch socket for the role whose launch tool calls it, and the host's
|
||||
git socket (`bus-host/git.sock`) for a role that holds
|
||||
git.push.working or review.verdict, or a merge decision it could land
|
||||
|
||||
@@ -49,7 +49,6 @@ const KILL_WITH_PARENT = ["/usr/bin/setpriv", "--pdeathsig", "KILL", "--"];
|
||||
// is missing here.
|
||||
export const RUNNER_FILES = Object.freeze([
|
||||
"packages/harness/src/runner.mjs",
|
||||
"packages/harness/src/bundle.mjs",
|
||||
"packages/harness/src/gate.mjs",
|
||||
"packages/harness/src/tools.mjs",
|
||||
"packages/harness/src/git-tools.mjs",
|
||||
@@ -60,6 +59,7 @@ export const RUNNER_FILES = Object.freeze([
|
||||
"packages/bus/src/broker.mjs",
|
||||
"packages/bus/src/landing.mjs",
|
||||
"packages/business/src/vocabulary.mjs",
|
||||
"packages/plugins/src/kinds.mjs",
|
||||
"packages/tasks/src/requirements.mjs",
|
||||
].map((p) => join(REPO, p)));
|
||||
|
||||
@@ -93,20 +93,16 @@ function systemBinds(node = process.execPath) {
|
||||
|
||||
// The binds for one session, in mount order; each says why the session
|
||||
// needs it. paths: the run directory, the instance's workspace, the adapter,
|
||||
// the harness ("pi" or "claude-code"), the broker socket, the launch socket
|
||||
// (null for a role that may not launch), the git socket (null for a role
|
||||
// that neither pushes nor reviews), the Claude Code binary
|
||||
// (MOSAIC_CLAUDE_BIN, or null), the thread's session store (session.mjs
|
||||
// threadStore(), or null) and node. `readOnly` adds files, for tests only.
|
||||
export function binds({ runDir, workspace, adapter, harness, brokerSocket, launchSocket = null, gitSocket = null, claudeBin = null, threadStore = null, node = process.execPath, readOnly = [] }) {
|
||||
// the harness module's mounts (row 65: its mounts(), [{ src, dest, why }],
|
||||
// read-only), the broker socket, the launch socket (null for a role that may
|
||||
// not launch), the git socket (null for a role that neither pushes nor
|
||||
// reviews), the thread's session store (session.mjs threadStore(), or null)
|
||||
// and node. `readOnly` adds files, for tests only.
|
||||
export function binds({ runDir, workspace, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
|
||||
const out = systemBinds(node);
|
||||
for (const f of RUNNER_FILES) out.push({ kind: "ro", src: f, why: "a repository file the runner loads" });
|
||||
out.push({ kind: "ro", src: adapter, why: "the harness adapter the runner starts each turn" });
|
||||
if (harness === "pi") out.push({ kind: "ro", src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" });
|
||||
if (harness === "claude-code" && claudeBin) {
|
||||
const bin = realpathSync(claudeBin);
|
||||
if (!bin.startsWith("/usr/")) out.push({ kind: "ro", src: bin, dest: claudeBin, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" });
|
||||
}
|
||||
for (const m of harnessMounts) out.push({ kind: "ro", src: m.src, ...(m.dest ? { dest: m.dest } : {}), why: m.why });
|
||||
out.push({ kind: "ro", src: brokerSocket, why: "the broker socket the runner claims and calls through" });
|
||||
if (launchSocket) out.push({ kind: "ro", src: launchSocket, why: "the launch socket, for the role whose launch tool calls it" });
|
||||
if (gitSocket) out.push({ kind: "ro", src: gitSocket, why: "the host's git socket, for push_working and fetch_review, and land_work" });
|
||||
|
||||
@@ -38,10 +38,6 @@ import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
|
||||
|
||||
export { REPO };
|
||||
export const RUNNER = join(REPO, "packages", "harness", "src", "runner.mjs");
|
||||
export const ADAPTERS = Object.freeze({
|
||||
pi: join(REPO, "adapters", "pi", "adapter.sh"),
|
||||
"claude-code": join(REPO, "adapters", "claude", "adapter.sh"),
|
||||
});
|
||||
export const SESSIONS_VERSION = 1;
|
||||
|
||||
export const sessionsFile = (dataRoot) => join(dataRoot, "bus-host", "sessions.json");
|
||||
@@ -139,8 +135,8 @@ export function appendLaunchLog(dataRoot, business, entry) {
|
||||
// Start the runner in its sandbox. stdin stays open for the capability line
|
||||
// the host writes once the broker has bound the launch; stdout and stderr go
|
||||
// to runner.log. paths: what sandbox.mjs's binds() takes besides the run
|
||||
// directory (workspace, adapter, harness, brokerSocket, launchSocket,
|
||||
// claudeBin, threadStore; readOnly for tests). business and role (the
|
||||
// directory (workspace, adapter, harnessMounts, brokerSocket, launchSocket,
|
||||
// gitSocket, threadStore; readOnly for tests). business and role (the
|
||||
// instance) give the session its git identity. `bwrap` is for the tests.
|
||||
export function spawnSession({ runDir, env, paths, business, role, node = process.execPath, bwrap }) {
|
||||
prepareRun(runDir, { threadStore: paths.threadStore ?? null });
|
||||
|
||||
@@ -17,11 +17,21 @@ import { serve } from "../../bus/src/server.mjs";
|
||||
import { verifyTester } from "../../bus/src/tester.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { SOURCES } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
import { startTimeOf } from "../src/proc.mjs";
|
||||
import { newRun, RUNNER, runnerOf, sessionEnv, spawnSession, writeSessionFile } from "../src/session.mjs";
|
||||
import { binds, gitIdentity, prepareRun, REPO, RUNNER_FILES, sandboxArgv, sandboxProblem } from "../src/sandbox.mjs";
|
||||
|
||||
const FAKE = new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url).pathname;
|
||||
|
||||
async function harnessModule(name) {
|
||||
const loader = createLoader({ log: () => {} });
|
||||
const { refused } = await loader.load({ harness: [name] });
|
||||
if (refused.length) throw new Error(`harness ${name} refused: ${JSON.stringify(refused)}`);
|
||||
return loader.modules("harness")[0];
|
||||
}
|
||||
// The confined sessions below run pi, so they get the pi module's mounts.
|
||||
const PI_MOUNTS = (await harnessModule("pi")).mounts({ env: {} });
|
||||
const TESTER_CLI = new URL("../../bus/src/tester-cli.mjs", import.meta.url).pathname;
|
||||
const HUMAN_MJS = new URL("../../bus/src/human.mjs", import.meta.url).pathname;
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -56,7 +66,7 @@ test("sandboxProblem names a missing bwrap and a bwrap that fails", (t) => {
|
||||
test("every bind says why; the bundle and session file stay read-only over the run directory", (t) => {
|
||||
const root = scratch(t);
|
||||
const runDir = join(root, "run");
|
||||
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "claude-code", brokerSocket: join(root, "bus.sock") });
|
||||
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") });
|
||||
assert.ok(list.every((b) => b.why));
|
||||
const at = (src) => list.findIndex((b) => b.src === src);
|
||||
assert.equal(list[at(runDir)].kind, "rw");
|
||||
@@ -67,7 +77,23 @@ test("every bind says why; the bundle and session file stay read-only over the r
|
||||
assert.deepEqual(list.at(-1), { kind: "rw", src: join(runDir, "tmp"), dest: "/tmp", why: list.at(-1).why });
|
||||
assert.equal(at(join(root, "launch.sock")), -1, "no launch socket for a role that may not launch");
|
||||
assert.equal(at(join(root, "git.sock")), -1, "no git socket for a role that neither pushes nor reviews");
|
||||
assert.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "Claude Code gets no node_modules");
|
||||
assert.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "no harness mounts unless the module names them");
|
||||
});
|
||||
|
||||
// Row 65: the harness module names its own mounts; binds() mounts each read-only, after the
|
||||
// adapter and before the broker socket, and names no harness itself.
|
||||
test("row 65 a harness module's mounts bind read-only after the adapter, each with the module's reason", async (t) => {
|
||||
const root = scratch(t);
|
||||
const args = { runDir: join(root, "run"), workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") };
|
||||
const pi = await harnessModule("pi");
|
||||
const list = binds({ ...args, harnessMounts: pi.mounts({ env: {} }) });
|
||||
const at = (src) => list.findIndex((b) => b.src === src);
|
||||
const modules = list[at(realpathSync(join(REPO, "node_modules")))];
|
||||
assert.deepEqual(modules, { kind: "ro", src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" });
|
||||
assert.ok(at(join(root, "a.sh")) < at(modules.src) && at(modules.src) < at(join(root, "bus.sock")));
|
||||
const fixture = binds({ ...args, harnessMounts: [{ src: join(root, "tool"), why: "a fixture harness's tool" }] });
|
||||
assert.deepEqual(fixture.find((b) => b.src === join(root, "tool")), { kind: "ro", src: join(root, "tool"), why: "a fixture harness's tool" });
|
||||
assert.equal(binds(args).length, list.length - 1);
|
||||
});
|
||||
|
||||
// Row 121 (#1615): a thread run gets only its own thread's two directories,
|
||||
@@ -76,7 +102,7 @@ test("REQ-THREAD-2 a thread store binds its session directory and Claude transcr
|
||||
const root = scratch(t);
|
||||
const runDir = join(root, "run");
|
||||
const store = { dir: join(root, "threads", "acme", "th"), session: join(root, "threads", "acme", "th", "session"), projects: join(root, "threads", "acme", "th", "claude-projects") };
|
||||
const args = { runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "claude-code", brokerSocket: join(root, "bus.sock") };
|
||||
const args = { runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") };
|
||||
const list = binds({ ...args, threadStore: store });
|
||||
const plain = binds(args);
|
||||
assert.deepEqual(list.slice(0, plain.length), plain, "the other binds are unchanged");
|
||||
@@ -96,7 +122,7 @@ test("REQ-THREAD-2 a thread store binds its session directory and Claude transcr
|
||||
// Row 93 (#1587): the git socket is bound read-only, before the workspace, when the launcher passes one.
|
||||
test("the host's git socket is bound only when given, read-only and before the writable binds", (t) => {
|
||||
const root = scratch(t);
|
||||
const args = { runDir: join(root, "run"), workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "pi", brokerSocket: join(root, "bus.sock") };
|
||||
const args = { runDir: join(root, "run"), workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") };
|
||||
const list = binds({ ...args, gitSocket: join(root, "git.sock") });
|
||||
const at = (src) => list.findIndex((b) => b.src === src);
|
||||
assert.equal(list[at(join(root, "git.sock"))].kind, "ro");
|
||||
@@ -116,7 +142,7 @@ test("the git identity is the instance's, at <instance>@<business>.mosaic.invali
|
||||
}
|
||||
const root = scratch(t);
|
||||
const runDir = join(root, "run");
|
||||
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "pi", brokerSocket: join(root, "bus.sock") });
|
||||
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") });
|
||||
const argv = sandboxArgv(list, runDir, ["/bin/true"], { business: "acme", role: "coder" });
|
||||
const set = {};
|
||||
argv.slice(0, argv.indexOf("--")).forEach((a, i, all) => {
|
||||
@@ -256,10 +282,10 @@ async function session(t, { expose = () => [], tester = false } = {}) {
|
||||
business: "acme", instance: "coder", run, launchedBy: "pm", harness: "pi", provider: "fake", model: "fake-model",
|
||||
brokerSocket, launchSocket: join(root, "launch.sock"), workspace: ws, sessionDir: join(runDir, "session"), adapter,
|
||||
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
|
||||
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), pollInterval: 50,
|
||||
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), turnEnd: "marker", harnessEnv: {}, pollInterval: 50,
|
||||
});
|
||||
|
||||
const paths = { workspace: ws, adapter, harness: "pi", brokerSocket, readOnly: [FAKE, ...(tester ? [TESTER_CLI, HUMAN_MJS] : []), ...expose(s)] };
|
||||
const paths = { workspace: ws, adapter, harnessMounts: PI_MOUNTS, brokerSocket, readOnly: [FAKE, ...(tester ? [TESTER_CLI, HUMAN_MJS] : []), ...expose(s)] };
|
||||
const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH }, run), paths, business: "acme", role: "coder" });
|
||||
const exited = new Promise((r) => child.once("close", (code, signal) => r({ code, signal })));
|
||||
t.after(async () => {
|
||||
@@ -317,7 +343,7 @@ test("a bind that exposes a stand-in, or a missing workspace bind, fails the mat
|
||||
// The same checks in a sandbox started directly from binds(), without the
|
||||
// session: all pass with every bind, and the workspace check fails
|
||||
// without the workspace's.
|
||||
const all = binds({ runDir: x.runDir, workspace: x.ws, adapter: x.adapter, harness: "pi", brokerSocket: x.brokerSocket });
|
||||
const all = binds({ runDir: x.runDir, workspace: x.ws, adapter: x.adapter, harnessMounts: PI_MOUNTS, brokerSocket: x.brokerSocket });
|
||||
const direct = (list) => async (cmd) => {
|
||||
const argv = sandboxArgv(list, x.runDir, ["/bin/sh", "-c", cmd], { business: "acme", role: "coder" });
|
||||
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 10000 });
|
||||
|
||||
@@ -223,10 +223,10 @@ test("a session runs in its sandbox as pid 1 of its namespace, claims, answers,
|
||||
business: "acme", instance: "coder", run, launchedBy: "pm", harness: "pi", provider: "fake", model: "fake-model",
|
||||
brokerSocket, launchSocket: join(root, "launch.sock"), workspace: ws, sessionDir: join(runDir, "session"), adapter,
|
||||
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
|
||||
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), pollInterval: 50,
|
||||
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), turnEnd: "marker", harnessEnv: {}, pollInterval: 50,
|
||||
});
|
||||
|
||||
const paths = { workspace: ws, adapter, harness: "pi", brokerSocket, readOnly: [FAKE] };
|
||||
const paths = { workspace: ws, adapter, brokerSocket, readOnly: [FAKE] };
|
||||
const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH, GITEA_TOKEN: "x" }, run), paths, business: "acme", role: "coder" });
|
||||
t.after(() => {
|
||||
try {
|
||||
|
||||
@@ -629,6 +629,59 @@ changed." `revision-conflict` and `organization-instance-exists` are 409,
|
||||
`sent:"unknown"`. The log line names the user, the instance and the outcome (`done
|
||||
restart=true|false` or the code), never the vars.
|
||||
|
||||
## Routing (row 65, #1558, mos-0840)
|
||||
|
||||
Settings shows a Routing section, read through `/api/bus/routes`: a table
|
||||
with one row per instance in the business file and six selects, harness,
|
||||
model, thinking, fallback harness, fallback model and fallback thinking.
|
||||
It replaces the Agent limits form's old Role models fieldset, which set
|
||||
only the model. The harness cells offer the harnesses `plugins.harness`
|
||||
switches on, the model cells the `launch.models` keys, and each thinking
|
||||
cell the levels its row's harness applies. A value the file holds that
|
||||
isn't offered stays selected and says why: "(not switched on)", "(not in
|
||||
the model table)" or "(not applied by <harness>)". A harness change redraws
|
||||
the row, so its thinking cell offers the new harness's levels. With no
|
||||
harness switched on, the section says every launch refuses `harness-off`
|
||||
and that `mosaic plugins set` switches one on; the page doesn't change
|
||||
`plugins`.
|
||||
|
||||
A fallback is set or cleared as a whole. Save sends one POST `/api/routes`
|
||||
under the revision the page read, with only the changed rows. A fallback
|
||||
whose three cells are `none` is sent as null, which clears it; one with
|
||||
some cells set goes as it is, and the bus refuses it as partial. A
|
||||
submit with nothing changed says "Nothing changed." and sends nothing. A
|
||||
save says which instances it saved and that the next launch of each uses
|
||||
the new route; running sessions keep theirs. A refusal shows the code and
|
||||
the reason and keeps the draft. A lost answer says the outcome is unknown
|
||||
and to read the routes again. "Read current routes and discard draft"
|
||||
drops the draft. Without `--send` every select and Save are off. Nothing
|
||||
is sent again on its own.
|
||||
|
||||
Routes:
|
||||
|
||||
- GET `/api/bus/routes` calls the human-only `routes` through the human
|
||||
transport. A query string is 400, no bus 503, and an answer without a
|
||||
64-hex revision, the `harnesses` and `models` lists and the `levels` and
|
||||
`instances` objects 502.
|
||||
- POST `/api/routes` exists only under `--send` and calls the human-only
|
||||
`routes.set`. It has the Settings boundary: the signed-in user's header
|
||||
(401 without it), the exact `https://<public host>` Origin and
|
||||
`Sec-Fetch-Site: same-origin` (403), and a JSON object of at most 16 KiB
|
||||
(400). It takes exactly `revision` (64 hex) and a non-empty `routes`
|
||||
object; any other key is 400 before the bus. Any other method is 405.
|
||||
The bus sends a refusal's code alone, so the route adds the reason:
|
||||
`{error, message, sent:false}`, where the message is the code, the reason
|
||||
and "Nothing changed." `revision-conflict` is 409, `human-required` 403,
|
||||
and `harness-off`, `harness-refused`, `thinking-unsupported`,
|
||||
`unknown-model`, `organization-partial-fallback`,
|
||||
`organization-unknown-instance`, `organization-no-change` and
|
||||
`invalid-request` 400. A lost or unusable answer is 503 with
|
||||
`sent:"unknown"`. The log line names the user, the instances and the
|
||||
outcome (`done` or the code), never the routes.
|
||||
|
||||
Neither route is in `organization.set`'s keys, so the chief of staff can't
|
||||
reach them (`packages/business/README.md`, "Routes").
|
||||
|
||||
## Data and boundaries
|
||||
|
||||
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
|
||||
|
||||
@@ -52,19 +52,20 @@ try {
|
||||
// the system config, Business can't resolve authority and Settings has
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, busLine, system = null;
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, busLine, system = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
const { dataRoot } = system;
|
||||
const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
chat = createChat({ call });
|
||||
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165
|
||||
// (#1662): so is `instances`, and its write `instances.set` is only under --send.
|
||||
bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances') };
|
||||
// (#1662): so is `instances`, and its write `instances.set` is only under --send. Row 65
|
||||
// (mos-0840): so is `routes`, and its write `routes.set`.
|
||||
bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
||||
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
|
||||
projectFiles = args => call('project.files', args);
|
||||
projectFile = args => call('project.file', args);
|
||||
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);}
|
||||
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);}
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
@@ -72,7 +73,7 @@ try {
|
||||
busLine = `Bus: not configured (${why})`;
|
||||
}
|
||||
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
|
||||
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
|
||||
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}\nCtrl-C stops this server.`);
|
||||
|
||||
@@ -574,14 +574,14 @@
|
||||
const providerRows=[...providers].map(p=>field(['providers',p],p,limits.providers?.[p],usage.providers?.[p])).join('');
|
||||
const modelRows=[...pairs.values()].map(([p,m])=>field(['models',p,m],`${p} / ${m}`,limits.models?.[p]?.[m],usage.models?.[p]?.[m])).join('');
|
||||
const table=Object.entries(modelTable).map(([key,m])=>`<div class="filters"><strong>${txt(key)}</strong><label>Provider <input data-setting="${esc(inputKey(['table',key,'provider']))}" value="${esc(val(['table',key,'provider'],m.provider))}" maxlength="200"${disabled}></label><label>Model <input data-setting="${esc(inputKey(['table',key,'model']))}" value="${esc(val(['table',key,'model'],m.model))}" maxlength="200"${disabled}></label></div>`).join('');
|
||||
const selections=Object.entries(v.instances||{}).map(([instance,entry])=>`<label>${txt(instance)} <select data-setting="${esc(inputKey(['instances',instance]))}"${disabled}>${Object.keys(modelTable).map(key=>`<option value="${esc(key)}"${val(['instances',instance],entry.modelKey)===key?' selected':''}>${txt(key)}</option>`).join('')}</select></label>`).join('');
|
||||
return `<section class="panel" aria-labelledby="launch-settings-title"><h2 id="launch-settings-title">Agent limits</h2><p>Leave a limit blank for no limit. Zero stops new launches. Running sessions keep running when limits or models change.</p><form id="launch-settings-form"><fieldset><legend>All sessions</legend>${field(['total'],'Total',limits.total,usage.total)}</fieldset><fieldset><legend>By provider</legend><div class="filters">${providerRows}</div></fieldset><fieldset><legend>By provider and model</legend><div class="filters">${modelRows}</div></fieldset><fieldset><legend>Model table</legend>${table}</fieldset><fieldset><legend>Role models</legend><div class="filters">${selections}</div></fieldset><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${settingsSaving?'Saving…':'Save launch settings'}</button> <button type="button" id="launch-settings-reload">Read current settings and discard draft</button><p role="status" id="launch-settings-receipt">${txt(settingsReceipt)}</p></form></section>`;
|
||||
// Row 65 (mos-0840): each instance's model moved to the routing table below.
|
||||
return `<section class="panel" aria-labelledby="launch-settings-title"><h2 id="launch-settings-title">Agent limits</h2><p>Leave a limit blank for no limit. Zero stops new launches. Running sessions keep running when limits or models change.</p><form id="launch-settings-form"><fieldset><legend>All sessions</legend>${field(['total'],'Total',limits.total,usage.total)}</fieldset><fieldset><legend>By provider</legend><div class="filters">${providerRows}</div></fieldset><fieldset><legend>By provider and model</legend><div class="filters">${modelRows}</div></fieldset><fieldset><legend>Model table</legend>${table}</fieldset><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${settingsSaving?'Saving…':'Save launch settings'}</button> <button type="button" id="launch-settings-reload">Read current settings and discard draft</button><p role="status" id="launch-settings-receipt">${txt(settingsReceipt)}</p></form></section>`;
|
||||
}
|
||||
function settingDraft(form){const values={};for(const el of form.querySelectorAll('[data-setting]'))values[el.dataset.setting]=el.value;return values;}
|
||||
function settingPayload(){const v=settingsSnapshot, limits={providers:Object.create(null),models:Object.create(null)},models=structuredClone(v.models),instances={};for(const [encoded,text]of Object.entries(settingsInputs)){const [kind,p,m]=JSON.parse(encoded);if(kind==='table'){models[p][m]=text;continue;}if(kind==='instances'){instances[p]=text;continue;}if(text==='')continue;if(!/^\d+$/.test(text)||!Number.isSafeInteger(Number(text)))throw Error('Limits need whole numbers of zero or more.');const n=Number(text);if(kind==='total')limits.total=n;else if(kind==='providers')limits.providers[p]=n;else {limits.models[p]??=Object.create(null);limits.models[p][m]=n;}}
|
||||
function settingPayload(){const v=settingsSnapshot, limits={providers:Object.create(null),models:Object.create(null)},models=structuredClone(v.models);for(const [encoded,text]of Object.entries(settingsInputs)){const [kind,p,m]=JSON.parse(encoded);if(kind==='table'){models[p][m]=text;continue;}if(text==='')continue;if(!/^\d+$/.test(text)||!Number.isSafeInteger(Number(text)))throw Error('Limits need whole numbers of zero or more.');const n=Number(text);if(kind==='total')limits.total=n;else if(kind==='providers')limits.providers[p]=n;else {limits.models[p]??=Object.create(null);limits.models[p][m]=n;}}
|
||||
// Row 125 (#1619): the form doesn't edit token limits; a save keeps them as read.
|
||||
if(v.limits?.tokens)limits.tokens=structuredClone(v.limits.tokens);
|
||||
return {revision:v.revision,limits,models,instances};
|
||||
return {revision:v.revision,limits,models};
|
||||
}
|
||||
document.addEventListener('input',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);});
|
||||
document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);});
|
||||
@@ -727,6 +727,59 @@
|
||||
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{instanceReceipt=unknownInstance;}finally{instanceBusy=false;if(!out())render(false);}
|
||||
});
|
||||
// Row 65 (mos-0840): the routing table. One row per instance: harness, model and thinking, and
|
||||
// the fallback's three. Each cell offers the switched-on harnesses, the model table's keys and the
|
||||
// levels that harness applies; a value the file holds that isn't offered stays, marked, so a save
|
||||
// sends it and the bus says why it refuses. A fallback is set by choosing all three or cleared by
|
||||
// choosing none for all three. Save sends the changed rows through routes.set. Drafts survive
|
||||
// polling and refusals; nothing retries.
|
||||
let routesView=null, routesDraft=null, routesBusy=false, routesReceipt='';
|
||||
const ROUTE_CELLS=[['harness','Harness'],['model','Model'],['thinking','Thinking'],['fallback.harness','Fallback harness'],['fallback.model','Fallback model'],['fallback.thinking','Fallback thinking']];
|
||||
const routeCells=r=>({harness:r.harness??'',model:r.model??'',thinking:r.thinking??'','fallback.harness':r.fallback?.harness??'','fallback.model':r.fallback?.model??'','fallback.thinking':r.fallback?.thinking??''});
|
||||
const sameCells=(a,b)=>ROUTE_CELLS.every(([k])=>a[k]===b[k]);
|
||||
function routesSection(view){
|
||||
const title='<h2 id="routes-title">Routing</h2>';
|
||||
if(!view)return `<section class="panel" aria-labelledby="routes-title">${title}<p class="small warn">The routes could not be read, so there is nothing to change. Read again to retry; they come from the bus host with your identity.</p></section>`;
|
||||
if(!routesDraft)routesView=view;
|
||||
const v=routesView, disabled=!canSend||out()||routesBusy?' disabled':'';
|
||||
const cells=name=>routesDraft?.[name]??routeCells(v.instances[name]);
|
||||
const select=(name,key,list,current,why)=>{const offered=current===''||list.includes(current)?list:[...list,current];
|
||||
return `<select data-route="${esc(inputKey([name,key]))}" aria-label="${esc(`${name} ${ROUTE_CELLS.find(([k])=>k===key)[1].toLowerCase()}`)}"${disabled}>${key.startsWith('fallback.')||current===''?`<option value=""${current===''?' selected':''}>${key.startsWith('fallback.')?'none':'unset'}</option>`:''}${offered.map(x=>`<option value="${esc(x)}"${x===current?' selected':''}>${txt(x)}${list.includes(x)?'':` (${why})`}</option>`).join('')}</select>`;};
|
||||
const levelsOf=h=>Array.isArray(v.levels[h])?v.levels[h]:[];
|
||||
const rows=Object.keys(v.instances).map(name=>{const c=cells(name);
|
||||
const harness=(key)=>select(name,key,v.harnesses,c[key],'not switched on');
|
||||
const model=(key)=>select(name,key,v.models,c[key],'not in the model table');
|
||||
const thinking=(key,h)=>select(name,key,levelsOf(c[h]),c[key],c[h]?`not applied by ${c[h]}`:'no harness');
|
||||
return `<tr><th scope="row">${txt(name)}</th><td>${harness('harness')}</td><td>${model('model')}</td><td>${thinking('thinking','harness')}</td><td>${harness('fallback.harness')}</td><td>${model('fallback.model')}</td><td>${thinking('fallback.thinking','fallback.harness')}</td></tr>`;}).join('');
|
||||
const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. <code>mosaic plugins set</code> switches one on.</p>';
|
||||
return `<section class="panel" aria-labelledby="routes-title">${title}<p>Each instance's harness, model and thinking, and the fallback a launch takes when the primary harness fails its preflight. A fallback sets all three or none. Running sessions keep their route; the next launch reads the change.</p>${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col">Instance</th>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${routesBusy?'Saving…':'Save routes'}</button> <button type="button" id="routes-reload">Read current routes and discard draft</button><p role="status" id="routes-receipt">${txt(routesReceipt)}</p></form></section>`;
|
||||
}
|
||||
function routesFields(form){const draft=Object.fromEntries(Object.keys(routesView.instances).map(name=>[name,routeCells(routesView.instances[name])]));for(const el of form.querySelectorAll('[data-route]')){const [name,key]=JSON.parse(el.dataset.route);if(draft[name])draft[name][key]=el.value;}return draft;}
|
||||
// The changed rows only. A fallback with all three cells empty is cleared; one with some set goes
|
||||
// as it is, and the bus refuses it as partial.
|
||||
function routesPayload(draft){
|
||||
const routes={};
|
||||
for(const [name,c]of Object.entries(draft)){if(sameCells(c,routeCells(routesView.instances[name])))continue;
|
||||
const set=['harness','model','thinking'].filter(k=>c[`fallback.${k}`]!=='');
|
||||
routes[name]={harness:c.harness,model:c.model,thinking:c.thinking,fallback:set.length===0?null:Object.fromEntries(set.map(k=>[k,c[`fallback.${k}`]]))};}
|
||||
if(!Object.keys(routes).length)throw Error('Nothing changed.');
|
||||
return {revision:routesView.revision,routes};
|
||||
}
|
||||
const unknownRoutes='Outcome unknown. Read the routes again before trying. Draft kept.';
|
||||
const routesAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&Array.isArray(r.harnesses)&&Array.isArray(r.models)&&[r.levels,r.instances].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
|
||||
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('#routes-form');if(!form||!routesView)return;routesDraft=routesFields(form);
|
||||
// A harness change redraws the row so its thinking cell offers that harness's levels.
|
||||
if(kind==='change'&&/harness"\]$/.test(e.target.dataset?.route??''))render(false);});
|
||||
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';last.delete('routes');render(true);});
|
||||
document.addEventListener('submit',async e=>{
|
||||
if(e.target.id!=='routes-form')return;e.preventDefault();if(!canSend||out()||routesBusy||!routesView)return;
|
||||
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
|
||||
routesBusy=true;routesReceipt='';render(false);
|
||||
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
|
||||
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;}
|
||||
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
|
||||
});
|
||||
async function settingsView(get) {
|
||||
const s = await get.api('settings'), n = s.notifier || {};
|
||||
let usage, names = null;
|
||||
@@ -734,12 +787,14 @@
|
||||
try { names = await get('names'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; /* the section says the name could not be read */ }
|
||||
let instances = null;
|
||||
try { [instances] = await get('instances'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||||
let routes = null;
|
||||
try { [routes] = await get('routes'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||||
let limitRows = [], limitsRefused = false;
|
||||
try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; }
|
||||
const notify = typeof n.binding === 'string'
|
||||
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
|
||||
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
|
||||
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
||||
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${routesSection(routes)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
||||
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
|
||||
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ async function body(req, max = 4096) {
|
||||
// (#1619, REQ-LIMIT-3) adds `limits`: only the token limits' report, never the document. Row 155
|
||||
// (#1649) adds `names`: each instance's current seat name, for the Assistant section. Row 165
|
||||
// (#1662) adds `instances`: the business file's role instances and the definitions add accepts.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances'];
|
||||
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
|
||||
const DECISION_LISTS = ['roles', 'recent'];
|
||||
async function busRead(res, bus, verb, search) {
|
||||
let arg;
|
||||
@@ -95,6 +96,18 @@ async function busRead(res, bus, verb, search) {
|
||||
return json(res, busStatus(code), { error: code, message: code });
|
||||
}
|
||||
}
|
||||
if (verb === 'routes') {
|
||||
if (search) return json(res, 400, { error: 'invalid-request', message: 'routes takes no arguments' });
|
||||
if (!bus?.routes) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
try {
|
||||
const value = await bus.routes();
|
||||
if (!routesAnswer(value)) throw new BusReadError('invalid-response');
|
||||
return json(res, 200, { rows: [value], at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code, message: code });
|
||||
}
|
||||
}
|
||||
if (verb === 'trail') {
|
||||
arg = new URLSearchParams(search).get('subject');
|
||||
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
|
||||
@@ -317,6 +330,50 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
|
||||
line(`done restart=${result.restart}`);
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
}
|
||||
// Row 65 (mos-0840): the routing table. The Settings boundary and one bus call, `routes.set`,
|
||||
// through the human transport. It is human-only and outside ORGANIZATION_KEYS, so the chief of
|
||||
// staff's tool never reaches it. The bus checks the route rules; a refusal comes back with its code.
|
||||
const ROUTE_KEYS = ['revision', 'routes'];
|
||||
const ROUTE_REASONS = {
|
||||
'harness-off': ['400', 'that harness isn\'t switched on in the business file\'s plugins.harness, which `mosaic plugins set` changes'],
|
||||
'harness-refused': ['400', 'that harness module didn\'t load on the bus host, so its thinking levels are unknown'],
|
||||
'thinking-unsupported': ['400', 'that harness doesn\'t apply that thinking level'],
|
||||
'unknown-model': ['400', 'a model or fallback model isn\'t a key in the model table'],
|
||||
'organization-partial-fallback': ['400', 'a fallback sets its harness, model and thinking together, or is cleared'],
|
||||
'organization-unknown-instance': ['400', 'the business has no such instance'],
|
||||
'organization-no-change': ['400', 'the business file already says that'],
|
||||
'revision-conflict': ['409', 'the business file changed since you read it. Read the routes again'],
|
||||
};
|
||||
const routeObject = o => o && typeof o === 'object' && !Array.isArray(o);
|
||||
const routesAnswer = v => /^[a-f0-9]{64}$/.test(v?.revision ?? '') && Array.isArray(v.harnesses) && Array.isArray(v.models) && routeObject(v.levels) && routeObject(v.instances);
|
||||
async function routesSend(req, res, { routesWrite, preview, log }) {
|
||||
// Instance names only; routes are never logged.
|
||||
let names = [];
|
||||
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
if (!routeObject(value) || Object.keys(value).some(key => !ROUTE_KEYS.includes(key)) || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
|
||||
!routeObject(value.routes) || Object.keys(value.routes).length === 0) {
|
||||
return refuse(400, 'invalid-request', 'this takes a revision and routes by instance, nothing else');
|
||||
}
|
||||
names = Object.keys(value.routes);
|
||||
let result;
|
||||
try { result = await routesWrite({ revision: value.revision, routes: value.routes }); } catch (err) {
|
||||
const { code, sent } = sendOutcome(err);
|
||||
if (sent !== false) return refuse(503, 'outcome-unknown', 'the change may have been made; nothing was retried. Read the routes again before trying', 'unknown');
|
||||
const [status, reason] = Object.hasOwn(ROUTE_REASONS, code) ? ROUTE_REASONS[code] : [String(busStatus(code)), 'refused by the bus'];
|
||||
return refuse(Number(status), code, `${code}: ${reason}. Nothing changed.`);
|
||||
}
|
||||
if (!routesAnswer(result) || names.some(name => !result.instances[name])) {
|
||||
return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the change may have been made. Read the routes again before trying', 'unknown');
|
||||
}
|
||||
line('done');
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
}
|
||||
// Row 148 (#1642): the spec and docs editor. Two reads and one save, all through the human transport.
|
||||
// The save has the Settings boundary (header user, exact Origin, same-origin Fetch Metadata, JSON,
|
||||
// strict keys) with a 1 MiB body on this route only; every other POST keeps its own smaller limit.
|
||||
@@ -375,7 +432,7 @@ async function projectFileRead(res, read, path, search) {
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `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, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = 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, 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';
|
||||
const upstream = boardURL(board);
|
||||
@@ -426,6 +483,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!send || !instancesWrite || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return instanceSend(req, res, { instancesWrite, preview, log });
|
||||
}
|
||||
if (path === '/api/routes') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if (!send || !routesWrite || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return routesSend(req, res, { routesWrite, preview, log });
|
||||
}
|
||||
if (path === '/api/project-file' && req.method === 'POST') {
|
||||
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return projectFileSend(req, res, { projectSave, preview, log });
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Row 65 (#1558, REQ-PLUG-2, mos-0840): Settings shows one routing row per instance with its six
|
||||
// cells, and a save posts the changed rows with the read's revision. The page's POST is held by a
|
||||
// fetch stub and sent on by the test, with the Console's own headers, to a second server on the
|
||||
// same real broker; the browser can't set Origin or Host itself. So a save reaches the business
|
||||
// file through routes.set, and each refusal comes from the bus with the draft kept.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {request} from 'node:http';
|
||||
import {mkdirSync,readFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {browser} from './browser.mjs';
|
||||
import {startServer} from '../src/serve.mjs';
|
||||
import {BusReadError} from '../src/bus.mjs';
|
||||
import {close} from './fixture.mjs';
|
||||
import {BusError} from '../../bus/src/broker.mjs';
|
||||
import {startBroker} from '../../bus/src/runtime.mjs';
|
||||
import {systemFor} from '../../business/tests/helpers.mjs';
|
||||
import {fixture,tmp} from '../../cli/tests/helpers.mjs';
|
||||
|
||||
const preview={host:'console.example',header:'x-user',user:'jason'};
|
||||
const headers={host:'console.example','x-user':'jason',origin:'https://console.example','sec-fetch-site':'same-origin','content-type':'application/json'};
|
||||
const KEYS=['harness','model','thinking','fallback.harness','fallback.model','fallback.thinking'];
|
||||
const post=(port,data)=>new Promise((resolve,reject)=>{
|
||||
const req=request({hostname:'127.0.0.1',port,path:'/api/routes',method:'POST',headers},r=>{let text='';r.on('data',c=>text+=c);r.on('end',()=>resolve({status:r.statusCode,body:JSON.parse(text)}));});
|
||||
req.on('error',reject);req.end(JSON.stringify(data));
|
||||
});
|
||||
|
||||
test('row 65 Settings: the routing table lists every instance, saves a harness, model, thinking and fallback to the file, and keeps the draft on each refusal',{timeout:120000},async t=>{
|
||||
const root=tmp(t,'webui-routes-browser-');
|
||||
// The reviewer starts with a fallback, so the table draws a set one as well as an empty one.
|
||||
const f=fixture(root,'acme',doc=>{doc.roles.reviewer.vars={...doc.roles.reviewer.vars,'fallback.harness':'pi','fallback.model':'glm-5.3-flash','fallback.thinking':'low'};return doc;});
|
||||
mkdirSync(f.dataRoot,{mode:0o700});
|
||||
const dir=join(root,'config'),file=join(dir,'businesses','acme.json');
|
||||
const host=await startBroker({
|
||||
dataRoot:f.dataRoot,
|
||||
businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir,rolesDir:f.roles,system:systemFor(root)},
|
||||
});
|
||||
t.after(()=>host.close());
|
||||
const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});
|
||||
const call=async(verb,args)=>{
|
||||
try{return structuredClone(host.broker.request(human,{verb,args}));}catch(e){if(e instanceof BusError)throw new BusReadError(e.code);throw e;}
|
||||
};
|
||||
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});
|
||||
const lines=[];
|
||||
const writer=await startServer({port:0,preview,send:async()=>({}),routesWrite:args=>call('routes.set',args),log:line=>lines.push(line)});
|
||||
const b=await browser();
|
||||
try{
|
||||
// The stub holds each POST until the test answers it with what the write server said.
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/routes'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Promise(resolve=>{window.answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}));});}return original(url,options);};`});
|
||||
const port=web.address().port;
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
|
||||
const receipt=()=>b.evaluate('document.querySelector("#routes-receipt").textContent');
|
||||
const cell=(name,key)=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify([name,key])}']`)})`;
|
||||
// A harness change redraws the form, so each cell is looked up again, and waited for until the
|
||||
// redraw offers the value.
|
||||
const set=async(name,key,value)=>{assert.equal(await b.evaluate(`(async()=>{for(let n=0;n<100;n++){const e=${cell(name,key)};if(e&&[...e.options].some(o=>o.value===${JSON.stringify(value)})){e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));return true;}await new Promise(r=>setTimeout(r,30));}return false;})()`),true,`${name} ${key} offers ${value}`);};
|
||||
const value=(name,key)=>b.evaluate(`${cell(name,key)}.value`);
|
||||
const shown=(name,key)=>b.evaluate(`${cell(name,key)}.selectedOptions[0].textContent`);
|
||||
let sent=0;
|
||||
const save=async()=>{
|
||||
// The last receipt is cleared here, so only the answer's redraw writes one.
|
||||
await b.evaluate('document.querySelector("#routes-receipt").textContent="";document.querySelector("#routes-form").requestSubmit();true');
|
||||
await wait(`posts.length>${sent}`);sent++;
|
||||
const r=await post(writer.address().port,await b.evaluate('posts.at(-1)'));
|
||||
await b.evaluate(`window.answer(${r.status},${JSON.stringify(r.body)});true`);
|
||||
await wait('!document.querySelector("#routes-form button[type=submit]")?.textContent.startsWith("Saving")&&document.querySelector("#routes-receipt")?.textContent!==""');
|
||||
return r;
|
||||
};
|
||||
const reload=async()=>{
|
||||
await b.evaluate('document.querySelector("#routes-reload").click();true');
|
||||
await wait('!!document.querySelector("#routes-table")&&document.querySelector("#routes-receipt").textContent===""');
|
||||
};
|
||||
const vars=()=>JSON.parse(readFileSync(file,'utf8')).roles;
|
||||
|
||||
// Read-only: one row per instance in the file, each with its six values, every select disabled.
|
||||
await b.navigate(`http://127.0.0.1:${port}/#/settings`);
|
||||
await wait('!!document.querySelector("#routes-table")');
|
||||
const rows=await b.evaluate("[...document.querySelectorAll('#routes-table tbody tr')].map(r=>[r.querySelector('th').textContent,...[...r.querySelectorAll('select')].map(s=>s.value)])");
|
||||
assert.deepEqual(rows,Object.entries(vars()).map(([name,{vars:v}])=>[name,...KEYS.map(k=>v[k]??'')]));
|
||||
assert.ok(rows.length>=5,'every fixture instance has a row');
|
||||
assert.deepEqual(rows.find(([name])=>name==='reviewer').slice(4),['pi','glm-5.3-flash','low']);
|
||||
assert.equal(await b.evaluate('document.querySelector("#routes-title").textContent'),'Routing');
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#routes-form select,#routes-form button[type=submit]")].every(e=>e.disabled)'),true);
|
||||
|
||||
// Send mode: a harness, a model, a thinking level and a whole fallback on the coder, saved and
|
||||
// read back from the file.
|
||||
await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings`);
|
||||
await wait('document.querySelector("#routes-form button[type=submit]")?.disabled===false');
|
||||
const before=vars().coder.vars;
|
||||
await set('coder','harness','claude-code');
|
||||
await set('coder','model','claude-opus-5-5');
|
||||
await set('coder','thinking','xhigh');
|
||||
await set('coder','fallback.harness','pi');
|
||||
await set('coder','fallback.model','glm-5.3-flash');
|
||||
await set('coder','fallback.thinking','off');
|
||||
const saved=await save();
|
||||
assert.equal(saved.status,200,JSON.stringify(saved.body));
|
||||
assert.deepEqual(await b.evaluate('posts.at(-1).routes'),{coder:{harness:'claude-code',model:'claude-opus-5-5',thinking:'xhigh',fallback:{harness:'pi',model:'glm-5.3-flash',thinking:'off'}}},'only the changed row is sent');
|
||||
assert.equal(await receipt(),'Routes saved for coder. The next launch of each uses the new route.');
|
||||
const after=vars().coder.vars;
|
||||
assert.deepEqual(KEYS.map(k=>after[k]),['claude-code','claude-opus-5-5','xhigh','pi','glm-5.3-flash','off']);
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(after).filter(([k])=>!KEYS.includes(k))),Object.fromEntries(Object.entries(before).filter(([k])=>!KEYS.includes(k))),'other vars kept');
|
||||
assert.equal(await value('coder','harness'),'claude-code','the page draws the saved route');
|
||||
assert.match(lines.at(-1),/ routes user="jason" instances="coder" done$/);
|
||||
|
||||
// A partial fallback goes as it is and the bus refuses it; the draft stays on the page.
|
||||
const unchanged=readFileSync(file,'utf8');
|
||||
await set('pm','fallback.harness','pi');
|
||||
let r=await save();
|
||||
assert.deepEqual([r.status,r.body.error],[400,'organization-partial-fallback']);
|
||||
assert.deepEqual(await b.evaluate('posts.at(-1).routes.pm.fallback'),{harness:'pi'});
|
||||
assert.match(await receipt(),/^organization-partial-fallback: .* Nothing changed\. Draft kept\.$/);
|
||||
assert.equal(await value('pm','fallback.harness'),'pi');
|
||||
|
||||
// A level the new harness doesn't apply: shown as such, and refused.
|
||||
await reload();
|
||||
assert.equal(await value('pm','fallback.harness'),'','reload discards the draft');
|
||||
await set('pm','thinking','minimal');
|
||||
await set('pm','harness','claude-code');
|
||||
await wait(`${cell('pm','thinking')}?.selectedOptions[0]?.textContent.includes("(")`);
|
||||
assert.equal(await shown('pm','thinking'),'minimal (not applied by claude-code)');
|
||||
r=await save();
|
||||
assert.deepEqual([r.status,r.body.error],[400,'thinking-unsupported']);
|
||||
assert.match(await receipt(),/^thinking-unsupported: .* Draft kept\.$/);
|
||||
assert.deepEqual([await value('pm','harness'),await value('pm','thinking')],['claude-code','minimal']);
|
||||
|
||||
// A stale revision: the human narrows plugins.harness to pi outside the page, so the page's read
|
||||
// is old, and a save on it is refused.
|
||||
await reload();
|
||||
const {revision}=await call('plugins');
|
||||
await call('plugins.set',{revision,plugins:{harness:['pi']}});
|
||||
const narrowed=readFileSync(file,'utf8');
|
||||
await set('cto','model','claude-sonnet-5-5'===(await value('cto','model'))?'claude-opus-5-5':'claude-sonnet-5-5');
|
||||
r=await save();
|
||||
assert.deepEqual([r.status,r.body.error],[409,'revision-conflict']);
|
||||
assert.match(await receipt(),/^revision-conflict: the business file changed since you read it\. Read the routes again\. Nothing changed\. Draft kept\.$/);
|
||||
|
||||
// Read again: the coder's harness is now off, shown as such, and a thinking change on that route
|
||||
// is refused harness-off.
|
||||
await reload();
|
||||
assert.equal(await shown('coder','harness'),'claude-code (not switched on)');
|
||||
await set('coder','thinking','high');
|
||||
r=await save();
|
||||
assert.deepEqual([r.status,r.body.error],[400,'harness-off']);
|
||||
assert.match(await receipt(),/^harness-off: .*`mosaic plugins set` changes\. Nothing changed\. Draft kept\.$/);
|
||||
assert.equal(await value('coder','thinking'),'high');
|
||||
|
||||
// Nothing changed is caught on the page and posts nothing.
|
||||
await reload();
|
||||
await b.evaluate('document.querySelector("#routes-form").requestSubmit();true');
|
||||
await wait('document.querySelector("#routes-receipt")?.textContent==="Nothing changed."');
|
||||
assert.equal(await b.evaluate('posts.length'),sent);
|
||||
assert.equal(readFileSync(file,'utf8'),narrowed,'no refused save wrote the file');
|
||||
assert.notEqual(narrowed,unchanged);
|
||||
}finally{await b.close();await close(web);await close(writer);}
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// Row 65 (#1558, REQ-PLUG-2, mos-0840): the Console's routing table read and write. The write has
|
||||
// the Settings boundary and makes one human-transport call, `routes.set`; the route rules are the
|
||||
// bus's and the business writer's, tested there. The first test runs a real broker over a temporary
|
||||
// organization directory, so a save reaches the file and the log, and each refusal leaves both.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { startBroker } from '../../bus/src/runtime.mjs';
|
||||
import { systemFor } from '../../business/tests/helpers.mjs';
|
||||
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
||||
|
||||
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
|
||||
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
const REV = 'a'.repeat(64);
|
||||
const ROUTE = { harness: 'pi', model: 'glm-5.3-flash', thinking: 'low', fallback: null };
|
||||
const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), harnesses: ['pi'], models: ['glm-5.3-flash'], levels: { pi: ['low'] }, instances: { coder: ROUTE }, ...extra });
|
||||
|
||||
async function setup(t, { on = true, writer = on, write = async () => view(), bus = null } = {}) {
|
||||
const calls = [], lines = [];
|
||||
const server = await startServer({
|
||||
port: 0, preview, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
|
||||
routesWrite: writer ? async args => { calls.push(args); return write(args); } : null,
|
||||
});
|
||||
t.after(() => new Promise(r => server.close(r)));
|
||||
return { calls, lines, port: server.address().port };
|
||||
}
|
||||
const send = (s, path, data, hs = headers, method = 'POST') => new Promise((resolve, reject) => {
|
||||
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
|
||||
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
||||
});
|
||||
const get = (s, path) => send(s, path, undefined, { host: 'console.example', 'x-user': 'jason' }, 'GET');
|
||||
|
||||
test('row 65 Console: the signed-in human saves a route through a real broker; each refusal says nothing changed and leaves the file and the log', async t => {
|
||||
const root = tmp(t, 'webui-routes-');
|
||||
const f = fixture(root, 'acme');
|
||||
mkdirSync(f.dataRoot, { mode: 0o700 });
|
||||
const dir = join(root, 'config');
|
||||
const host = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => host.close());
|
||||
const human = host.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = async (verb, args) => {
|
||||
try { return structuredClone(host.broker.request(human, { verb, args })); } catch (e) {
|
||||
if (e instanceof BusError) throw new BusReadError(e.code);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
const s = await setup(t, { bus: { routes: () => call('routes') }, write: args => call('routes.set', args) });
|
||||
const file = join(dir, 'businesses', 'acme.json');
|
||||
const log = () => readFileSync(join(dir, 'businesses', '.history', 'acme', 'changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
const read = await get(s, '/api/bus/routes');
|
||||
assert.equal(read.status, 200);
|
||||
const [current] = read.body.rows;
|
||||
assert.deepEqual(current.harnesses, ['pi', 'claude-code']);
|
||||
assert.deepEqual(current.levels['claude-code'], ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
assert.deepEqual(current.instances.coder, { harness: 'pi', model: 'glm-5.3-flash', thinking: 'high', fallback: null });
|
||||
|
||||
const route = { harness: 'claude-code', model: 'claude-opus-5-5', thinking: 'xhigh', fallback: { harness: 'pi', model: 'glm-5.3-flash', thinking: 'off' } };
|
||||
const saved = await send(s, '/api/routes', { revision: current.revision, routes: { coder: route } });
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||
assert.deepEqual(saved.body.instances.coder, route);
|
||||
assert.deepEqual(s.calls, [{ revision: current.revision, routes: { coder: route } }]);
|
||||
assert.match(s.lines.at(-1), / routes user="jason" instances="coder" done$/);
|
||||
const entry = log().at(-1);
|
||||
assert.deepEqual([entry.actor, entry.state, entry.transaction], ['jason', 'committed', saved.body.transaction]);
|
||||
const vars = JSON.parse(readFileSync(file, 'utf8')).roles.coder.vars;
|
||||
assert.deepEqual(['harness', 'model', 'thinking', 'fallback.harness', 'fallback.model', 'fallback.thinking'].map(k => vars[k]), ['claude-code', 'claude-opus-5-5', 'xhigh', 'pi', 'glm-5.3-flash', 'off']);
|
||||
|
||||
const before = readFileSync(file, 'utf8'), entries = log().length;
|
||||
const coder = more => ({ coder: { ...route, ...more } });
|
||||
for (const [routes, revision, status, code, why] of [
|
||||
[coder({ harness: 'cursor' }), saved.body.revision, 400, 'harness-off', /isn't switched on in the business file's plugins\.harness/],
|
||||
[coder({ thinking: 'minimal' }), saved.body.revision, 400, 'thinking-unsupported', /doesn't apply that thinking level/],
|
||||
[coder({ fallback: { harness: 'pi', model: 'glm-5.3-flash' } }), saved.body.revision, 400, 'organization-partial-fallback', /sets its harness, model and thinking together, or is cleared/],
|
||||
[{ ghost: route }, saved.body.revision, 400, 'organization-unknown-instance', /no such instance/],
|
||||
[{ coder: route }, saved.body.revision, 400, 'organization-no-change', /already says that/],
|
||||
[coder({ thinking: 'low' }), current.revision, 409, 'revision-conflict', /changed since you read it/],
|
||||
]) {
|
||||
const r = await send(s, '/api/routes', { revision, routes });
|
||||
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, code, false], code);
|
||||
assert.match(r.body.message, why, code);
|
||||
assert.match(r.body.message, new RegExp(`^${code}: .*Nothing changed\\.$`), code);
|
||||
}
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
assert.equal(log().length, entries);
|
||||
});
|
||||
|
||||
test('row 65 Console: the routes write needs send on, a writer, the public name, the user, the exact Origin and same-origin Fetch Metadata', async t => {
|
||||
const data = { revision: REV, routes: { coder: ROUTE } };
|
||||
const off = await setup(t, { on: false });
|
||||
assert.equal((await send(off, '/api/routes', data)).status, 403);
|
||||
const unsent = await setup(t, { on: false, writer: true });
|
||||
assert.equal((await send(unsent, '/api/routes', data)).status, 403);
|
||||
assert.equal(unsent.calls.length, 0);
|
||||
const unwritten = await setup(t, { writer: false });
|
||||
assert.equal((await send(unwritten, '/api/routes', data)).status, 403);
|
||||
const s = await setup(t);
|
||||
for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['x-user', ['jason', 'jason'], 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
|
||||
const hs = { ...headers };
|
||||
if (value === undefined) delete hs[key]; else hs[key] = value;
|
||||
assert.equal((await send(s, '/api/routes', data, hs)).status, status, key);
|
||||
}
|
||||
assert.equal((await send(s, '/api/routes', undefined, headers, 'GET')).status, 405);
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('row 65 Console: the routes write takes JSON within 16 KiB with a revision and routes only; an actor is refused and routes are never logged', async t => {
|
||||
const s = await setup(t);
|
||||
const base = { revision: REV, routes: { coder: ROUTE } };
|
||||
for (const value of [
|
||||
{ ...base, actor: 'mos' }, { ...base, instances: { coder: 'x' } }, { revision: REV }, { routes: base.routes },
|
||||
{ ...base, revision: 'stale' }, { ...base, revision: 7 }, { ...base, routes: {} }, { ...base, routes: [] }, { ...base, routes: null },
|
||||
[], 'bad', { ...base, routes: { coder: { ...ROUTE, model: 'x'.repeat(17000) } } },
|
||||
]) assert.equal((await send(s, '/api/routes', value)).status, 400, JSON.stringify(value).slice(0, 60));
|
||||
assert.equal((await send(s, '/api/routes', base, { ...headers, 'content-type': 'text/plain' })).status, 400);
|
||||
assert.equal(s.calls.length, 0);
|
||||
// A route with a model the bus refuses: the log names the instance and the code, never the route.
|
||||
const refused = await setup(t, { write: async () => { throw new BusReadError('unknown-model'); } });
|
||||
await send(refused, '/api/routes', { revision: REV, routes: { coder: { ...ROUTE, model: 'secret-model-name' } } });
|
||||
assert.deepEqual(refused.lines.map(l => l.replace(/^\S+ /, '')), ['routes user="jason" instances="coder" error=unknown-model']);
|
||||
assert.ok([...s.lines, ...refused.lines].every(l => !/xxxx|secret-model-name|glm/.test(l)), 'routes are never logged');
|
||||
});
|
||||
|
||||
test('row 65 Console: a bus refusal maps to its status and says nothing changed; a lost or odd answer says the change may have been made', async t => {
|
||||
const fail = code => async () => { throw new BusReadError(code); };
|
||||
for (const [write, status, error, sent] of [
|
||||
[fail('harness-off'), 400, 'harness-off', false], [fail('harness-refused'), 400, 'harness-refused', false],
|
||||
[fail('thinking-unsupported'), 400, 'thinking-unsupported', false], [fail('unknown-model'), 400, 'unknown-model', false],
|
||||
[fail('organization-partial-fallback'), 400, 'organization-partial-fallback', false],
|
||||
[fail('organization-unknown-instance'), 400, 'organization-unknown-instance', false],
|
||||
[fail('organization-no-change'), 400, 'organization-no-change', false], [fail('revision-conflict'), 409, 'revision-conflict', false],
|
||||
[fail('human-required'), 403, 'human-required', false], [fail('invalid-request'), 400, 'invalid-request', false],
|
||||
[fail('outcome-unknown'), 503, 'outcome-unknown', 'unknown'], [async () => { throw new Error('lost'); }, 503, 'outcome-unknown', 'unknown'],
|
||||
// Each part of the answer's shape wrong alone, and an answer without the instance sent.
|
||||
[async () => view({ revision: 'x' }), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => view({ harnesses: null }), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => view({ models: {} }), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => view({ levels: [] }), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => view({ instances: null }), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => view({ instances: {} }), 503, 'outcome-unknown', 'unknown'],
|
||||
]) {
|
||||
const s = await setup(t, { write });
|
||||
const r = await send(s, '/api/routes', { revision: REV, routes: { coder: ROUTE } });
|
||||
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, error, sent], error);
|
||||
if (sent === false) assert.match(r.body.message, new RegExp(`^${error}: .*Nothing changed\\.$`), error);
|
||||
else assert.match(r.body.message, /may have been made/, error);
|
||||
assert.equal(s.calls.length, 1);
|
||||
assert.match(s.lines[0], new RegExp(` routes user="jason" instances="coder" error=${error}$`));
|
||||
}
|
||||
});
|
||||
|
||||
test('row 65 Console: the routes read takes no arguments, needs a bus and refuses an odd answer', async t => {
|
||||
const none = await setup(t);
|
||||
assert.equal((await get(none, '/api/bus/routes')).status, 503);
|
||||
const s = await setup(t, { bus: { routes: async () => view() } });
|
||||
assert.equal((await get(s, '/api/bus/routes?x=1')).status, 400);
|
||||
assert.deepEqual((await get(s, '/api/bus/routes')).body.rows, [view()]);
|
||||
for (const odd of [{ revision: 'x' }, { harnesses: 'pi' }, { models: null }, { levels: [] }, { instances: [] }]) {
|
||||
const r = await setup(t, { bus: { routes: async () => view(odd) } });
|
||||
assert.equal((await get(r, '/api/bus/routes')).status, 502, JSON.stringify(odd));
|
||||
}
|
||||
const refused = await setup(t, { bus: { routes: async () => { throw new BusReadError('human-required'); } } });
|
||||
assert.equal((await get(refused, '/api/bus/routes')).status, 403);
|
||||
});
|
||||
@@ -12,11 +12,12 @@ test('Settings form shows every scope and held role; gated save, exact payload,
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings`);
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
|
||||
await wait('!!document.querySelector("#launch-settings-form")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-form button[type=submit]").disabled'),true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form select").length'),4);assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form input[type=number]").length'),4);
|
||||
// Row 65 (mos-0840): the Role models selects moved to the routing table, so this form has none.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form select").length'),0);assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form input[type=number]").length'),4);
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}/?send=1#/settings`);await wait('document.querySelector("#launch-settings-form button[type=submit]")?.disabled===false');
|
||||
const change=async(parts,value)=>b.evaluate(`(()=>{const e=[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(parts))});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
|
||||
const submit=()=>b.evaluate('document.querySelector("#launch-settings-form").requestSubmit();true');
|
||||
await change(['total'],'0');await change(['providers','anthropic'],'12');await change(['instances','coder'],'opus');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("saved")');const [payload]=await b.evaluate('posts');assert.equal(payload.limits.total,0);assert.equal(payload.limits.providers.anthropic,12);assert.equal(payload.instances.coder,'opus');assert.equal(payload.revision,launch.revision);assert.deepEqual(payload.models,launch.models);
|
||||
await change(['total'],'0');await change(['providers','anthropic'],'12');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("saved")');const [payload]=await b.evaluate('posts');assert.equal(payload.limits.total,0);assert.equal(payload.limits.providers.anthropic,12);assert.equal('instances' in payload,false);assert.equal(payload.revision,launch.revision);assert.deepEqual(payload.models,launch.models);
|
||||
await change(['total'],'9');await b.evaluate('mode="unknown"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("Outcome unknown")');assert.equal(await b.evaluate('posts.length'),2);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');await b.evaluate('new Promise(r=>setTimeout(r,300))');assert.equal(await b.evaluate('posts.length'),2);
|
||||
await b.evaluate('mode="refused"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("fixture conflict")');assert.equal(await b.evaluate('posts.length'),3);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');
|
||||
}finally{await b.close();await close(web);}
|
||||
|
||||
@@ -53,11 +53,11 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
|
||||
// No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has four explicit writes:
|
||||
// row 76's message to a role, row 122's human Settings update, row 125's token-limit reset (one path,
|
||||
// to the same Settings route) and row 148's spec and docs save. Row 165 (#1662) adds the role
|
||||
// instance add or change to /api/instances.
|
||||
// instance add or change to /api/instances, and row 65 the routing table's save to /api/routes.
|
||||
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
|
||||
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
|
||||
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 6);
|
||||
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 7);
|
||||
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'"]);
|
||||
assert.equal(read('icons.svg').toString().includes('<script'), false);
|
||||
|
||||
Reference in New Issue
Block a user