4328 lines
290 KiB
Diff
4328 lines
290 KiB
Diff
diff --git a/adapters/README.md b/adapters/README.md
|
|
index 19a82629f..ab5d1793d 100644
|
|
--- a/adapters/README.md
|
|
+++ b/adapters/README.md
|
|
@@ -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
|
|
diff --git a/adapters/claude/adapter.sh b/adapters/claude/adapter.sh
|
|
index a0e04dae5..ccf48c014 100644
|
|
--- a/adapters/claude/adapter.sh
|
|
+++ b/adapters/claude/adapter.sh
|
|
@@ -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" \
|
|
diff --git a/adapters/pi/adapter.sh b/adapters/pi/adapter.sh
|
|
index 3ebb1efe6..486c1aba1 100644
|
|
--- a/adapters/pi/adapter.sh
|
|
+++ b/adapters/pi/adapter.sh
|
|
@@ -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.
|
|
diff --git a/packages/bus/README.md b/packages/bus/README.md
|
|
index 3ea66529c..38a64c22f 100644
|
|
--- a/packages/bus/README.md
|
|
+++ b/packages/bus/README.md
|
|
@@ -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
|
|
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
|
index e20509eac..d5e6f2bc3 100644
|
|
--- a/packages/bus/src/broker.mjs
|
|
+++ b/packages/bus/src/broker.mjs
|
|
@@ -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");
|
|
}
|
|
diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs
|
|
index 625ea0146..a0bc51fd9 100644
|
|
--- a/packages/bus/src/runtime.mjs
|
|
+++ b/packages/bus/src/runtime.mjs
|
|
@@ -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 = [];
|
|
diff --git a/packages/bus/src/usage.mjs b/packages/bus/src/usage.mjs
|
|
index eecd20150..de6bef44b 100644
|
|
--- a/packages/bus/src/usage.mjs
|
|
+++ b/packages/bus/src/usage.mjs
|
|
@@ -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'); }
|
|
}
|
|
diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs
|
|
index a7679b180..280eec6c6 100644
|
|
--- a/packages/bus/tests/chief-of-staff.test.mjs
|
|
+++ b/packages/bus/tests/chief-of-staff.test.mjs
|
|
@@ -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);
|
|
diff --git a/packages/bus/tests/credential-status.test.mjs b/packages/bus/tests/credential-status.test.mjs
|
|
index f3f20142c..0403968de 100644
|
|
--- a/packages/bus/tests/credential-status.test.mjs
|
|
+++ b/packages/bus/tests/credential-status.test.mjs
|
|
@@ -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: {
|
|
diff --git a/packages/bus/tests/daily-quota-runtime.test.mjs b/packages/bus/tests/daily-quota-runtime.test.mjs
|
|
index 421330e31..12bf1c08c 100644
|
|
--- a/packages/bus/tests/daily-quota-runtime.test.mjs
|
|
+++ b/packages/bus/tests/daily-quota-runtime.test.mjs
|
|
@@ -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' } } } };
|
|
diff --git a/packages/bus/tests/end-launch.test.mjs b/packages/bus/tests/end-launch.test.mjs
|
|
index cdd206a4c..6540277e1 100644
|
|
--- a/packages/bus/tests/end-launch.test.mjs
|
|
+++ b/packages/bus/tests/end-launch.test.mjs
|
|
@@ -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: {
|
|
diff --git a/packages/bus/tests/limits-runtime.test.mjs b/packages/bus/tests/limits-runtime.test.mjs
|
|
index eca43f407..099e33b53 100644
|
|
--- a/packages/bus/tests/limits-runtime.test.mjs
|
|
+++ b/packages/bus/tests/limits-runtime.test.mjs
|
|
@@ -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 });
|
|
diff --git a/packages/bus/tests/process.test.mjs b/packages/bus/tests/process.test.mjs
|
|
index 8e9a7820f..e34c7c7c7 100644
|
|
--- a/packages/bus/tests/process.test.mjs
|
|
+++ b/packages/bus/tests/process.test.mjs
|
|
@@ -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: [] } } },
|
|
},
|
|
diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs
|
|
index 5dfebf2aa..21c49c81b 100644
|
|
--- a/packages/bus/tests/project-runtime.test.mjs
|
|
+++ b/packages/bus/tests/project-runtime.test.mjs
|
|
@@ -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: [] },
|
|
} } };
|
|
diff --git a/packages/bus/tests/routes-verb.test.mjs b/packages/bus/tests/routes-verb.test.mjs
|
|
new file mode 100644
|
|
index 000000000..192e2bb05
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/routes-verb.test.mjs
|
|
@@ -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');
|
|
+});
|
|
diff --git a/packages/bus/tests/tasks.test.mjs b/packages/bus/tests/tasks.test.mjs
|
|
index a7eb3ec49..75a047642 100644
|
|
--- a/packages/bus/tests/tasks.test.mjs
|
|
+++ b/packages/bus/tests/tasks.test.mjs
|
|
@@ -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: [] } },
|
|
diff --git a/packages/bus/tests/tester.test.mjs b/packages/bus/tests/tester.test.mjs
|
|
index 244129537..c24376be3 100644
|
|
--- a/packages/bus/tests/tester.test.mjs
|
|
+++ b/packages/bus/tests/tester.test.mjs
|
|
@@ -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-'));
|
|
diff --git a/packages/bus/tests/usage-runtime.test.mjs b/packages/bus/tests/usage-runtime.test.mjs
|
|
index 1b3ccc122..69f31a8ba 100644
|
|
--- a/packages/bus/tests/usage-runtime.test.mjs
|
|
+++ b/packages/bus/tests/usage-runtime.test.mjs
|
|
@@ -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 });
|
|
diff --git a/packages/bus/tests/usage.test.mjs b/packages/bus/tests/usage.test.mjs
|
|
index a9179a6d5..2f4e0aef0 100644
|
|
--- a/packages/bus/tests/usage.test.mjs
|
|
+++ b/packages/bus/tests/usage.test.mjs
|
|
@@ -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);
|
|
+});
|
|
diff --git a/packages/business/README.md b/packages/business/README.md
|
|
index 953a8b4da..f0c61813d 100644
|
|
--- a/packages/business/README.md
|
|
+++ b/packages/business/README.md
|
|
@@ -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
|
|
diff --git a/packages/business/src/index.mjs b/packages/business/src/index.mjs
|
|
index 95fd05c3d..a6fdb45ae 100644
|
|
--- a/packages/business/src/index.mjs
|
|
+++ b/packages/business/src/index.mjs
|
|
@@ -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";
|
|
diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs
|
|
index 0a8262da4..b406b1972 100644
|
|
--- a/packages/business/src/instance-settings.mjs
|
|
+++ b/packages/business/src/instance-settings.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);
|
|
diff --git a/packages/business/src/launch-settings.mjs b/packages/business/src/launch-settings.mjs
|
|
index be8a0ee99..bfd314d4c 100644
|
|
--- a/packages/business/src/launch-settings.mjs
|
|
+++ b/packages/business/src/launch-settings.mjs
|
|
@@ -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 };
|
|
+ }
|
|
+
|
|
+ // 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 { ...view(id), transaction: receipt.transaction };
|
|
+ return { ...routes(id), transaction: receipt.transaction };
|
|
}
|
|
|
|
- return { writer, read: view, set, current };
|
|
+ return { writer, read: view, set, current, routes, setRoutes };
|
|
}
|
|
diff --git a/packages/business/src/vars.mjs b/packages/business/src/vars.mjs
|
|
index c71e093da..d91dc227e 100644
|
|
--- a/packages/business/src/vars.mjs
|
|
+++ b/packages/business/src/vars.mjs
|
|
@@ -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);
|
|
}
|
|
|
|
diff --git a/packages/business/tests/instance-settings.test.mjs b/packages/business/tests/instance-settings.test.mjs
|
|
index 3a50b56c4..c0a3cec37 100644
|
|
--- a/packages/business/tests/instance-settings.test.mjs
|
|
+++ b/packages/business/tests/instance-settings.test.mjs
|
|
@@ -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");
|
|
+});
|
|
diff --git a/packages/business/tests/launch-settings.test.mjs b/packages/business/tests/launch-settings.test.mjs
|
|
index 760acb13f..4dd3fde59 100644
|
|
--- a/packages/business/tests/launch-settings.test.mjs
|
|
+++ b/packages/business/tests/launch-settings.test.mjs
|
|
@@ -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'));
|
|
+});
|
|
diff --git a/packages/business/tests/vars.test.mjs b/packages/business/tests/vars.test.mjs
|
|
index 1d8f5801d..aad00a79b 100644
|
|
--- a/packages/business/tests/vars.test.mjs
|
|
+++ b/packages/business/tests/vars.test.mjs
|
|
@@ -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/],
|
|
diff --git a/packages/cli/README.md b/packages/cli/README.md
|
|
index e732b4c75..fb3ad8350 100644
|
|
--- a/packages/cli/README.md
|
|
+++ b/packages/cli/README.md
|
|
@@ -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,
|
|
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
|
|
index e85f71666..6cb9c4f00 100644
|
|
--- a/packages/cli/src/launcher.mjs
|
|
+++ b/packages/cli/src/launcher.mjs
|
|
@@ -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 };
|
|
}
|
|
|
|
diff --git a/packages/cli/tests/helpers.mjs b/packages/cli/tests/helpers.mjs
|
|
index 79f653687..b03045c1d 100644
|
|
--- a/packages/cli/tests/helpers.mjs
|
|
+++ b/packages/cli/tests/helpers.mjs
|
|
@@ -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;
|
|
+}
|
|
diff --git a/packages/cli/tests/instances.test.mjs b/packages/cli/tests/instances.test.mjs
|
|
index eecff8fbf..33d88b1c2 100644
|
|
--- a/packages/cli/tests/instances.test.mjs
|
|
+++ b/packages/cli/tests/instances.test.mjs
|
|
@@ -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()]);
|
|
diff --git a/packages/cli/tests/launch-limit-checks.test.mjs b/packages/cli/tests/launch-limit-checks.test.mjs
|
|
index 488f421b6..e31ef2666 100644
|
|
--- a/packages/cli/tests/launch-limit-checks.test.mjs
|
|
+++ b/packages/cli/tests/launch-limit-checks.test.mjs
|
|
@@ -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*\(/);
|
|
});
|
|
diff --git a/packages/cli/tests/launch-routes.test.mjs b/packages/cli/tests/launch-routes.test.mjs
|
|
new file mode 100644
|
|
index 000000000..0c61a7de4
|
|
--- /dev/null
|
|
+++ b/packages/cli/tests/launch-routes.test.mjs
|
|
@@ -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');
|
|
+});
|
|
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
|
|
index 7c45e54df..fb0bf47ae 100644
|
|
--- a/packages/cli/tests/launcher.test.mjs
|
|
+++ b/packages/cli/tests/launcher.test.mjs
|
|
@@ -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);
|
|
-});
|
|
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
|
|
index 62f0bc1fb..3c9f076a9 100644
|
|
--- a/packages/cli/tests/thread-launch.test.mjs
|
|
+++ b/packages/cli/tests/thread-launch.test.mjs
|
|
@@ -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;
|
|
};
|
|
diff --git a/packages/harness/README.md b/packages/harness/README.md
|
|
index 0c24a44ec..6f4539b4f 100644
|
|
--- a/packages/harness/README.md
|
|
+++ b/packages/harness/README.md
|
|
@@ -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
|
|
diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs
|
|
index 478c898a3..95ee60d20 100644
|
|
--- a/packages/harness/src/bundle.mjs
|
|
+++ b/packages/harness/src/bundle.mjs
|
|
@@ -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 };
|
|
}
|
|
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
|
|
index cad6a0e9b..c2108c4db 100644
|
|
--- a/packages/harness/src/runner.mjs
|
|
+++ b/packages/harness/src/runner.mjs
|
|
@@ -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;
|
|
diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs
|
|
index 318f8e9b6..cf436ac02 100644
|
|
--- a/packages/harness/tests/bundle.test.mjs
|
|
+++ b/packages/harness/tests/bundle.test.mjs
|
|
@@ -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));
|
|
});
|
|
|
|
diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs
|
|
index 213c811e8..02e59a83e 100644
|
|
--- a/packages/harness/tests/claude-session.test.mjs
|
|
+++ b/packages/harness/tests/claude-session.test.mjs
|
|
@@ -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,
|
|
diff --git a/packages/harness/tests/fixtures/fake-adapter.mjs b/packages/harness/tests/fixtures/fake-adapter.mjs
|
|
index 4e1ec9bd3..d5811b241 100644
|
|
--- a/packages/harness/tests/fixtures/fake-adapter.mjs
|
|
+++ b/packages/harness/tests/fixtures/fake-adapter.mjs
|
|
@@ -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 {
|
|
diff --git a/packages/harness/tests/helpers.mjs b/packages/harness/tests/helpers.mjs
|
|
index 96c1923f4..e4bdb3058 100644
|
|
--- a/packages/harness/tests/helpers.mjs
|
|
+++ b/packages/harness/tests/helpers.mjs
|
|
@@ -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 });
|
|
}
|
|
diff --git a/packages/harness/tests/pi-session.test.mjs b/packages/harness/tests/pi-session.test.mjs
|
|
index 879dfc66d..56005f865 100644
|
|
--- a/packages/harness/tests/pi-session.test.mjs
|
|
+++ b/packages/harness/tests/pi-session.test.mjs
|
|
@@ -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"),
|
|
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
|
|
index faa97d47c..6c013e6c1 100644
|
|
--- a/packages/harness/tests/runner.test.mjs
|
|
+++ b/packages/harness/tests/runner.test.mjs
|
|
@@ -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"]);
|
|
+});
|
|
diff --git a/packages/plugins/README.md b/packages/plugins/README.md
|
|
index e2723c1d7..cf2046703 100644
|
|
--- a/packages/plugins/README.md
|
|
+++ b/packages/plugins/README.md
|
|
@@ -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)
|
|
|
|
diff --git a/packages/plugins/harness/claude-code/index.mjs b/packages/plugins/harness/claude-code/index.mjs
|
|
new file mode 100644
|
|
index 000000000..9a625d269
|
|
--- /dev/null
|
|
+++ b/packages/plugins/harness/claude-code/index.mjs
|
|
@@ -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"));
|
|
diff --git a/packages/plugins/harness/pi/index.mjs b/packages/plugins/harness/pi/index.mjs
|
|
new file mode 100644
|
|
index 000000000..15de737c7
|
|
--- /dev/null
|
|
+++ b/packages/plugins/harness/pi/index.mjs
|
|
@@ -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"));
|
|
diff --git a/packages/plugins/src/kinds.mjs b/packages/plugins/src/kinds.mjs
|
|
index f28909efe..da289d86e 100644
|
|
--- a/packages/plugins/src/kinds.mjs
|
|
+++ b/packages/plugins/src/kinds.mjs
|
|
@@ -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 {
|
|
diff --git a/packages/plugins/tests/harness.test.mjs b/packages/plugins/tests/harness.test.mjs
|
|
new file mode 100644
|
|
index 000000000..96b0183d5
|
|
--- /dev/null
|
|
+++ b/packages/plugins/tests/harness.test.mjs
|
|
@@ -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);
|
|
+});
|
|
diff --git a/packages/plugins/tests/kinds.test.mjs b/packages/plugins/tests/kinds.test.mjs
|
|
index 86d1a93af..a9d03f79e 100644
|
|
--- a/packages/plugins/tests/kinds.test.mjs
|
|
+++ b/packages/plugins/tests/kinds.test.mjs
|
|
@@ -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-]*$/");
|
|
});
|
|
diff --git a/packages/plugins/tests/loader.test.mjs b/packages/plugins/tests/loader.test.mjs
|
|
index 691419f79..effbbf4da 100644
|
|
--- a/packages/plugins/tests/loader.test.mjs
|
|
+++ b/packages/plugins/tests/loader.test.mjs
|
|
@@ -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 => {
|
|
diff --git a/packages/seat/README.md b/packages/seat/README.md
|
|
index 92a7f9bac..ab744d268 100644
|
|
--- a/packages/seat/README.md
|
|
+++ b/packages/seat/README.md
|
|
@@ -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
|
|
diff --git a/packages/seat/src/sandbox.mjs b/packages/seat/src/sandbox.mjs
|
|
index a71156ca0..447fa3676 100644
|
|
--- a/packages/seat/src/sandbox.mjs
|
|
+++ b/packages/seat/src/sandbox.mjs
|
|
@@ -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" });
|
|
diff --git a/packages/seat/src/session.mjs b/packages/seat/src/session.mjs
|
|
index 7ffb89c04..fa3e8ae77 100644
|
|
--- a/packages/seat/src/session.mjs
|
|
+++ b/packages/seat/src/session.mjs
|
|
@@ -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 });
|
|
diff --git a/packages/seat/tests/sandbox.test.mjs b/packages/seat/tests/sandbox.test.mjs
|
|
index 7cbb25327..4fcf7e8fd 100644
|
|
--- a/packages/seat/tests/sandbox.test.mjs
|
|
+++ b/packages/seat/tests/sandbox.test.mjs
|
|
@@ -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 });
|
|
diff --git a/packages/seat/tests/session.test.mjs b/packages/seat/tests/session.test.mjs
|
|
index a31297594..23d288c2b 100644
|
|
--- a/packages/seat/tests/session.test.mjs
|
|
+++ b/packages/seat/tests/session.test.mjs
|
|
@@ -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 {
|
|
diff --git a/packages/webui/README.md b/packages/webui/README.md
|
|
index 49146d119..a6b861725 100644
|
|
--- a/packages/webui/README.md
|
|
+++ b/packages/webui/README.md
|
|
@@ -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
|
|
diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs
|
|
index 7d7cf3d5c..7f05c6332 100644
|
|
--- a/packages/webui/src/cli.mjs
|
|
+++ b/packages/webui/src/cli.mjs
|
|
@@ -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.`);
|
|
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
|
|
index 24d36d654..b0ffec0ce 100644
|
|
--- a/packages/webui/src/public/bus.js
|
|
+++ b/packages/webui/src/public/bus.js
|
|
@@ -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>`;
|
|
}
|
|
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
|
|
index 5276cf086..805d2bc6a 100644
|
|
--- a/packages/webui/src/serve.mjs
|
|
+++ b/packages/webui/src/serve.mjs
|
|
@@ -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 });
|
|
diff --git a/packages/webui/tests/routes-browser.test.mjs b/packages/webui/tests/routes-browser.test.mjs
|
|
new file mode 100644
|
|
index 000000000..80e33839a
|
|
--- /dev/null
|
|
+++ b/packages/webui/tests/routes-browser.test.mjs
|
|
@@ -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);}
|
|
+});
|
|
diff --git a/packages/webui/tests/routes.test.mjs b/packages/webui/tests/routes.test.mjs
|
|
new file mode 100644
|
|
index 000000000..fa4f389a5
|
|
--- /dev/null
|
|
+++ b/packages/webui/tests/routes.test.mjs
|
|
@@ -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);
|
|
+});
|
|
diff --git a/packages/webui/tests/settings-form.test.mjs b/packages/webui/tests/settings-form.test.mjs
|
|
index 4778af293..f8c4d2935 100644
|
|
--- a/packages/webui/tests/settings-form.test.mjs
|
|
+++ b/packages/webui/tests/settings-form.test.mjs
|
|
@@ -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);}
|
|
diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs
|
|
index 4ca035457..f195c7f6a 100644
|
|
--- a/packages/webui/tests/shell.test.mjs
|
|
+++ b/packages/webui/tests/shell.test.mjs
|
|
@@ -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);
|