plugins, cli, bus, business, webui: harness, model and thinking from the instance vars, a fallback route, and a harness plug-in kind (row 65, #1558)
Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and a second failure names both. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
This commit is contained in:
1 parent
d8336daef2
commit
ccdbc2232d
64 files changed
+2190
-337
No files matched your search
@@ -77,6 +77,7 @@ session's workspace and its own process group. Same contract, plus:
|
||||
| `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. |
|
||||
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. |
|
||||
| `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. |
|
||||
| `MOSAIC_THINKING` | The route's thinking level (row 65), which the harness module's `levels()` already checked. `pi` passes `--thinking` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) and omits it when unset, as for `run-task.sh` workers. `claude` passes `--effort` (`low`, `medium`, `high`, `xhigh`, `max`) and requires it; unset exits 2. |
|
||||
| `MOSAIC_CLAUDE_BIN` | `claude` only: absolute path of the CLI to run, from the host's environment (the bus unit pins it). Unset or empty runs `claude` from `PATH`; a relative path or a non-executable file exits 2. |
|
||||
|
||||
The layers each adapter relies on, and what they don't cover, are in
|
||||
|
||||
@@ -21,6 +21,7 @@ need MOSAIC_REQUEST
|
||||
need MOSAIC_WORKSPACE
|
||||
need MOSAIC_SESSION_DIR
|
||||
need MOSAIC_MODEL
|
||||
need MOSAIC_THINKING
|
||||
need MOSAIC_CLAUDE_SETTINGS
|
||||
need MOSAIC_CLAUDE_MCP_CONFIG
|
||||
for f in "$MOSAIC_SYSTEM_PROMPT_FILE" "$MOSAIC_CLAUDE_SETTINGS" "$MOSAIC_CLAUDE_MCP_CONFIG"; do
|
||||
@@ -56,6 +57,8 @@ fi
|
||||
# -p one-shot: print the answer and exit
|
||||
# --output-format text stdout is the answer only
|
||||
# --system-prompt the generated prompt replaces the default
|
||||
# --effort the instance's thinking var (row 65; 2.1.302 takes
|
||||
# low, medium, high, xhigh, max)
|
||||
# --restricted no user/project/local settings files; --settings
|
||||
# (the gate hook) still applies; no code-running
|
||||
# tool unless --tools names it; file tools confined
|
||||
@@ -77,6 +80,7 @@ PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
|
||||
--output-format text \
|
||||
--system-prompt "$PROMPT_CONTENT" \
|
||||
--model "$MOSAIC_MODEL" \
|
||||
--effort "$MOSAIC_THINKING" \
|
||||
--restricted \
|
||||
--tools "${MOSAIC_TOOLS:-}" \
|
||||
--allowedTools "$ALLOWED" \
|
||||
|
||||
@@ -73,6 +73,12 @@ if [ -n "${MOSAIC_EXTENSIONS:-}" ]; then
|
||||
IFS=$OLDIFS
|
||||
fi
|
||||
|
||||
# Thinking (row 65): a managed session's level, from its instance's thinking
|
||||
# var (pi 0.85.1: off, minimal, low, medium, high, xhigh, max). Workers from
|
||||
# run-task.sh set none and keep pi's default.
|
||||
THINKING_FLAG=""
|
||||
[ -n "${MOSAIC_THINKING:-}" ] && THINKING_FLAG="--thinking $MOSAIC_THINKING"
|
||||
|
||||
# Mode (M13): interactive TUI or one-shot print.
|
||||
PRINT_MODE="-p"
|
||||
REQUEST_ARG=""
|
||||
@@ -90,6 +96,7 @@ fi
|
||||
# EXT_FLAGS the explicitly provided extensions only (-e)
|
||||
# SESSION_FLAGS ephemeral | persistent | forked (per env)
|
||||
# TOOLS_FLAG per capabilities
|
||||
# THINKING_FLAG --thinking <level> for a managed session, else nothing
|
||||
# --offline no startup network operations (update checks/telemetry)
|
||||
# --no-approve ignore project-local .pi/ files (settings, SYSTEM.md)
|
||||
# whatever trust is saved for the workspace
|
||||
@@ -107,6 +114,7 @@ set -- \
|
||||
$SESSION_FLAGS \
|
||||
--provider "$PI_PROVIDER" \
|
||||
--model "$PI_MODEL" \
|
||||
$THINKING_FLAG \
|
||||
--system-prompt "$PROMPT_CONTENT"
|
||||
# One-shot mode appends -p and the request (both safely quoted);
|
||||
# interactive mode appends nothing - clean TUI.
|
||||
|
||||
@@ -63,6 +63,16 @@ underlying Broker's test-level binding API to bypass runtime process checks.
|
||||
A rebind of a run that already has `session.ended` refuses with `run-ended`,
|
||||
also after a restart, and a refused bind leaves the run unbound.
|
||||
|
||||
Row 65 (#1558): the record's `harness` is a plug-in module name (the loader's
|
||||
pattern), else `invalid-harness`; the broker names no harness itself. A new
|
||||
run binds only when `harnessOn(business, name)` says the organization file's
|
||||
`plugins.harness` switches it on now, else `invalid-harness`. A run the store
|
||||
already has binds again even after its harness is switched off, so it can be
|
||||
ended. A broker built without `harnessOn` (the unit tests') lets every
|
||||
module name through. The record may carry `transcript`, an absolute path
|
||||
where the harness keeps the run's session files, for the usage reader; it
|
||||
is held in memory only.
|
||||
|
||||
A request that carries a safe-integer `id` gets the same `id` on its reply,
|
||||
refusals included, so the host can match each reply to its request
|
||||
(`packages/cli/README.md`). A request without one gets a reply without one.
|
||||
@@ -585,6 +595,16 @@ 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 set a harness, a
|
||||
thinking level or a fallback. `routes` takes no args and answers the routing table;
|
||||
`routes.set` takes exactly `{revision,routes}`. The business package's `launch-settings.mjs`
|
||||
writes them with the human as actor (`packages/business/README.md`, "Routes"). `harness-off`,
|
||||
`harness-refused` and `thinking-unsupported` pass through as they are, from `routes.set`,
|
||||
`instances.set` and `organization.set` alike. The runtime builds the write checks' `levels`
|
||||
from `harnessLevels()`, which loads each harness module in the tree once at start, switched on
|
||||
or not, and reads its `levels()`. Tests: `packages/bus/tests/routes-verb.test.mjs`.
|
||||
|
||||
A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs <socket>` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`; every other verb refuses `tester-refused`. It sends only to a role, with a body that starts `[mos test]` (`tester-prefix`) and no `corrects` or `decision`. Its message is stored `from_role 'tester'`, `from_run 'tester-cli'`, with no request and no event, so no `human.input` backs it and `task_create` can't cite it. A role answers at the `tester` address, and only with `in_reply_to` naming a tester's message (`tester-reply-required`); the human can't send there. Those replies never reach the human's `message.receive` or `messages`.
|
||||
|
||||
## Shared reads and adapter boundary
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID, randomBytes } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
|
||||
@@ -189,11 +191,12 @@ export class Broker {
|
||||
#project;
|
||||
#usage;
|
||||
#limits;
|
||||
#harnessOn;
|
||||
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
|
||||
#stops = new Map();
|
||||
#clock = 0;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
@@ -203,6 +206,11 @@ export class Broker {
|
||||
this.#project = project;
|
||||
this.#usage = usage;
|
||||
this.#limits = limits;
|
||||
// Row 65: harnessOn(business, name) is true when the organization file's plugins.harness switches
|
||||
// the harness on now; the bus runtime reads it live. Without it (the unit tests' brokers) a
|
||||
// module-shaped name passes and the launcher's own harness-off check is the only one.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
this.#harnessOn = harnessOn ?? (() => true);
|
||||
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
|
||||
this.#requirements = loadRequirements();
|
||||
for (const table of [
|
||||
@@ -412,13 +420,18 @@ export class Broker {
|
||||
bindLaunch(record) {
|
||||
keys(
|
||||
record,
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model', 'account'],
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model', 'account', 'transcript'],
|
||||
['business', 'role', 'run', 'harness'],
|
||||
);
|
||||
const b = this.#business(identifier(record.business));
|
||||
if (!Object.hasOwn(b.roles, record.role)) fail('unknown-role');
|
||||
identifier(record.run);
|
||||
if (!['pi', 'claude-code'].includes(record.harness)) fail('invalid-harness');
|
||||
// Row 65 (#1558): a harness is a plug-in module name; whether it is switched on is checked below,
|
||||
// for a new run only.
|
||||
if (typeof record.harness !== 'string' || !HARNESS_NAME.test(record.harness)) fail('invalid-harness');
|
||||
// Where the run's harness keeps its session files, for the usage reader; kept in memory only.
|
||||
if (record.transcript !== undefined && (!text(record.transcript) || !isAbsolute(record.transcript) || record.transcript.split('/').includes('..')))
|
||||
fail('invalid-request');
|
||||
if (record.address !== undefined) string(record.address, 1024);
|
||||
if (record.provider !== undefined) string(record.provider, 1024);
|
||||
if (record.model !== undefined) string(record.model, 1024);
|
||||
@@ -448,6 +461,9 @@ export class Broker {
|
||||
startTime: record.startTime ?? null,
|
||||
...(record.thread === undefined ? {} : { thread: record.thread }),
|
||||
};
|
||||
// A run an earlier broker bound is bound again unchanged (the host's recover()), even after its
|
||||
// harness was switched off, so it can be ended.
|
||||
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
|
||||
if (old) {
|
||||
if (old.actor_role !== record.role || old.body !== JSON.stringify(body)) fail('launch-record-mismatch');
|
||||
// An ended run stays ended across broker restarts.
|
||||
@@ -1205,18 +1221,22 @@ 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, so the chief of staff's tool can't change a harness, a thinking
|
||||
// level or a fallback; it keeps changing only the model key through organization.set.
|
||||
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 +1253,8 @@ 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);
|
||||
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
|
||||
fail("invalid-request");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from './store.mjs';
|
||||
@@ -39,6 +40,30 @@ export async function webServices(businesses, { log = (e) => process.stderr.writ
|
||||
}
|
||||
return services;
|
||||
}
|
||||
// Row 65 (REQ-PLUG-2): the thinking levels each harness module in this tree applies, for the route
|
||||
// checks a write runs, which are synchronous. A module that doesn't load, or answers anything but a
|
||||
// list of names, answers null, which refuses the write. The tree is pinned, so a module changes only
|
||||
// with a re-pin, and a re-pin restarts the bus.
|
||||
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`) } = {}) {
|
||||
const levels = new Map();
|
||||
let names = [];
|
||||
try {
|
||||
names = readdirSync(fileURLToPath(new URL('../../plugins/harness/', import.meta.url))).filter((name) => hasModule('harness', name));
|
||||
} catch (e) {
|
||||
log({ event: 'harness-levels-unread', message: e.message });
|
||||
}
|
||||
for (const name of names) {
|
||||
const loader = createLoader({ log });
|
||||
if ((await loader.load({ harness: [name] })).refused.length) continue;
|
||||
try {
|
||||
const list = loader.modules('harness')[0].levels();
|
||||
if (Array.isArray(list) && list.every((level) => typeof level === 'string')) levels.set(name, Object.freeze([...list]));
|
||||
} catch (e) {
|
||||
log({ event: 'harness-levels-failed', name, message: e.message });
|
||||
}
|
||||
}
|
||||
return (name) => levels.get(name) ?? null;
|
||||
}
|
||||
export async function startBroker({
|
||||
dataRoot,
|
||||
businesses,
|
||||
@@ -66,9 +91,10 @@ export async function startBroker({
|
||||
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
||||
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
|
||||
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
||||
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot)}):null;
|
||||
const levels=organization?await harnessLevels():()=>null;
|
||||
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels}):null;
|
||||
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
|
||||
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system}):null;
|
||||
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels}):null;
|
||||
const usage = await createUsage({ store, dataRoot, businesses,
|
||||
log: entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`) });
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
|
||||
@@ -98,12 +124,25 @@ export async function startBroker({
|
||||
if(verb==='plugins')return plugins.read(id);
|
||||
if(verb==='instances')return instances.read(id);
|
||||
if(verb==='instances.set')return instances.set(id,args,actor);
|
||||
// Row 65 (mos-0840): the Console's routing table.
|
||||
if(verb==='routes')return settings.routes(id);
|
||||
if(verb==='routes.set')return settings.setRoutes(id,args,actor);
|
||||
const result=plugins.set(id,args,actor);
|
||||
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
|
||||
return result;
|
||||
}:null,
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
credentialStatus: () => credentials.status(),
|
||||
// Row 65: a new launch binds only on a harness its business switches on, read from the file
|
||||
// as it stands now; an unreadable file switches none on.
|
||||
harnessOn: (id, name) => {
|
||||
try {
|
||||
const b = settings ? settings.current(id) : businesses[id];
|
||||
return Array.isArray(b?.plugins?.harness) && b.plugins.harness.includes(name);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
// Process identities must be supplied for every managed run when using the human transport.
|
||||
const launchRecords = [];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// REQ-LIMIT-4: the host calls sources only through the registry loader.
|
||||
import { join } from 'node:path';
|
||||
import { createLoader } from '../../plugins/src/loader.mjs';
|
||||
const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
|
||||
const zero = () => ({ input: 0, output: 0, cacheWrite: 0, cacheRead: 0 });
|
||||
@@ -52,7 +51,7 @@ export function turnRecords(store, business) {
|
||||
};
|
||||
});
|
||||
}
|
||||
export async function createUsage({ store, dataRoot, businesses, log }) {
|
||||
export async function createUsage({ store, businesses, log }) {
|
||||
const loaders = new Map();
|
||||
for (const [id, b] of Object.entries(businesses)) {
|
||||
const loader = createLoader({ log });
|
||||
@@ -72,13 +71,13 @@ export async function createUsage({ store, dataRoot, businesses, log }) {
|
||||
// Preserve a failed/disabled baseline even if configuration changes between calls/restarts.
|
||||
for (const base of bases) if (!sources.some(s => s.name === base.source)) sources.push({ name: base.source });
|
||||
const rows = [];
|
||||
const runDir = join(dataRoot, 'launches', business, run);
|
||||
const root = session.thread
|
||||
? join(dataRoot, 'threads', business, session.thread, harness === 'pi' ? 'session' : 'claude-projects')
|
||||
: harness === 'pi' ? join(runDir, 'session') : join(runDir, 'home/.claude/projects');
|
||||
// Row 65 (#1558): where the run's harness keeps its session files is the module's transcript(),
|
||||
// which the launcher binds into the run record. A run bound without one has no reading.
|
||||
const root = session.transcript ?? null;
|
||||
for (const { module, name } of sources) {
|
||||
let sample = unknown('no-source');
|
||||
if (module) {
|
||||
if (module && root === null) sample = unknown('no-transcript');
|
||||
else if (module) {
|
||||
try { sample = snapshot(await module.readTurn({ harness, root })); }
|
||||
catch { sample = unknown('source-failed'); }
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const demo = () => ({
|
||||
roles,
|
||||
definitions: {},
|
||||
projects: { stack: { root: '/nonexistent' } },
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
vars: { 'bus.tester': true },
|
||||
},
|
||||
});
|
||||
@@ -288,7 +290,8 @@ test('REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-runtime-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, businessDoc(root, 'demo'));
|
||||
// Row 65: the chief's launch binds only on a harness the file switches on.
|
||||
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({
|
||||
dataRoot: join(root, 'data'),
|
||||
@@ -357,6 +360,7 @@ test('Q169: the chief of staff\'s change of the assistant is validated, written
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.roles.assistant = { definition: 'assistant', vars: { model: 'claude-sonnet-5-5' } };
|
||||
doc.plugins = { harness: ['pi'] };
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({
|
||||
|
||||
@@ -41,6 +41,8 @@ function seed(root) {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: {
|
||||
|
||||
@@ -17,7 +17,7 @@ async function setup(t, limit = 0) {
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-quota-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { 'limit-check': ['daily-quota'] };
|
||||
doc.plugins = { 'limit-check': ['daily-quota'], harness: ['pi', 'claude-code'] };
|
||||
doc.launch.account = { name: 'jason-max', credential: 'CLAUDE_CODE_OAUTH_TOKEN' };
|
||||
doc.launch.limits = { ...doc.launch.limits,
|
||||
tokens: { daily: { 'jason-max': { limit, since: SINCE, reset: '05:00', timeZone: 'America/Chicago' } } } };
|
||||
|
||||
@@ -13,6 +13,8 @@ const businesses = {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
launch: { by: 'pm', instances: ['coder'], max: { opus: 2, sonnet: 2 } },
|
||||
roles: {
|
||||
|
||||
@@ -15,7 +15,7 @@ async function setup(t, plugins, limit = 0) {
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-limits-tokens-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { 'limit-check': plugins };
|
||||
doc.plugins = { 'limit-check': plugins, harness: ['pi'] };
|
||||
doc.launch.limits = { ...doc.launch.limits, tokens: { instances: { coder: { limit, since: SINCE } } } };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
|
||||
@@ -10,6 +10,8 @@ const businesses = {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
arbiters: { technical: 'cto', delivery: 'cto' },
|
||||
roles: { cto: { authority: { withinRole: ['message.send'], crossRole: [] } } },
|
||||
},
|
||||
|
||||
@@ -9,10 +9,14 @@ import { startTimeOf } from '../../seat/src/proc.mjs';
|
||||
import { initProject } from '../../project/src/index.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
|
||||
test('Broker keeps the existing sandbox client import closure: no new imports or lazy project import', () => {
|
||||
test('Broker keeps the sandbox client import closure: only the listed imports, no lazy project import', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||
"import { randomUUID, randomBytes } from 'node:crypto';",
|
||||
// Row 65 (#1558): bindLaunch checks a run's transcript path is absolute.
|
||||
"import { isAbsolute } from 'node:path';",
|
||||
// Row 65: the plug-in name pattern a harness takes. kinds.mjs is in RUNNER_FILES and imports nothing.
|
||||
"import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';",
|
||||
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
|
||||
// Row 137 (#1631): land-work's checks, in RUNNER_FILES beside the broker. They import nothing.
|
||||
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
|
||||
@@ -24,6 +28,7 @@ test('Broker keeps the existing sandbox client import closure: no new imports or
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
});
|
||||
|
||||
test('without a trusted project adapter all three async paths refuse project-not-configured', async t => {
|
||||
@@ -40,7 +45,8 @@ test('real startBroker injects the project adapter for socket reads and bound-au
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-runtime-'));
|
||||
let runtime;
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code') } },
|
||||
// Row 65: the bound run's harness is switched on.
|
||||
const business = { id: 'demo', human: 'jason', plugins: { harness: ['pi'] }, projects: { stack: { root: join(root, 'code') } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: {
|
||||
definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] },
|
||||
} } };
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// 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 reaches a harness, a thinking level or a fallback. 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 set a route.
|
||||
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');
|
||||
assert.equal(code(coder({ model: 'ghost' })), 'invalid-request');
|
||||
assert.equal(code({ ghost: route }), 'organization-unknown-instance');
|
||||
assert.equal(code({ coder: route }), 'organization-no-change');
|
||||
assert.equal(code(coder({ thinking: 'low' }), view.revision), 'revision-conflict');
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
assert.equal(log().length, entries, 'a refused write logs nothing');
|
||||
});
|
||||
@@ -16,6 +16,8 @@ const businesses = {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
|
||||
@@ -20,7 +20,8 @@ const roles = {
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
};
|
||||
const business = (vars) => ({
|
||||
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles, ...(vars ? { vars } : {}) },
|
||||
// Row 65: the runtime binds only on a harness the business switches on.
|
||||
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles, plugins: { harness: ['pi'] }, ...(vars ? { vars } : {}) },
|
||||
});
|
||||
function setup(t, vars = { 'bus.tester': true }) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-tester-'));
|
||||
|
||||
@@ -11,10 +11,11 @@ test('REQ-PLUG-1 runtime reaches transcript only through loader, over the real b
|
||||
let runtime;
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const dir = join(root, 'launches/demo/run1/session'); mkdirSync(dir, { recursive: true });
|
||||
const business = { id: 'demo', human: 'jason', plugins: { 'usage-source': ['transcript'] },
|
||||
const business = { id: 'demo', human: 'jason', plugins: { 'usage-source': ['transcript'], harness: ['pi'] },
|
||||
roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
runtime = await startBroker({ dataRoot: root, businesses: { demo: business }, launches: [{
|
||||
business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'other-provider', model: 'other-model',
|
||||
// Row 65: the launch names where its harness keeps the session files.
|
||||
business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'other-provider', model: 'other-model', transcript: dir,
|
||||
pid: process.pid, startTime: startTimeOf(process.pid),
|
||||
}] });
|
||||
const client = new Client({ path: runtime.path, cap: runtime.launches[0].cap });
|
||||
|
||||
@@ -9,9 +9,10 @@ async function fixture(t, plugins = { 'usage-source': ['transcript'] }) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-usage-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const store = new Store(root); t.after(() => store.close());
|
||||
const logs = [], session = { business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'fixture', model: 'model' };
|
||||
const dir = join(root, 'launches/demo/run1/session'); mkdirSync(dir, { recursive: true });
|
||||
const usage = await createUsage({ store, dataRoot: root, businesses: { demo: { plugins } }, log: e => logs.push(e) });
|
||||
// Row 65: the launcher binds the harness module's transcript directory into the run record.
|
||||
const logs = [], session = { business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'fixture', model: 'model', transcript: dir };
|
||||
const usage = await createUsage({ store, businesses: { demo: { plugins } }, log: e => logs.push(e) });
|
||||
const put = (id, input = 2) => ({ type: 'message', id, message: { role: 'assistant', usage: { input, output: 3, cacheWrite: 4, cacheRead: 5 } } });
|
||||
const file = join(dir, 'session.jsonl');
|
||||
const save = records => writeFileSync(file, records.map(JSON.stringify).join('\n') + '\n');
|
||||
@@ -50,7 +51,7 @@ for (const kind of ['cut', 'disabled', 'missing-begin', 'no-new-usage', 'rewrite
|
||||
test('REQ-LIMIT-4 unfinished turn remains visibly unknown after accounting restarts', async t => {
|
||||
const f = await fixture(t);
|
||||
await f.usage.collect(f.session, 'begin', 'turn1');
|
||||
const again = await createUsage({ store: f.store, dataRoot: f.root, businesses: { demo: { plugins: {} } }, log: () => {} });
|
||||
const again = await createUsage({ store: f.store, businesses: { demo: { plugins: {} } }, log: () => {} });
|
||||
assert.equal(again.view('demo').total.unknownTurns, 1);
|
||||
});
|
||||
test('usage records are append-only', async t => {
|
||||
@@ -70,8 +71,8 @@ test('REQ-LIMIT-4 parse failure logs its safe code, never transcript content', a
|
||||
test('REQ-LIMIT-4 resumed run baselines history and exact totals do not overflow', async t => {
|
||||
const f = await fixture(t);
|
||||
for (const [n, run] of [['1', 'run1'], ['2', 'run2']]) {
|
||||
const session = { ...f.session, run };
|
||||
const dir = join(f.root, 'launches/demo', run, 'session'); mkdirSync(dir, { recursive: true });
|
||||
const session = { ...f.session, run, transcript: dir };
|
||||
const file = join(dir, 'session.jsonl');
|
||||
await f.usage.collect(session, 'begin', 'turn' + n);
|
||||
writeFileSync(file, JSON.stringify(f.put('new', n === '1' ? Number.MAX_SAFE_INTEGER : 2)) + '\n');
|
||||
@@ -95,7 +96,7 @@ test('REQ-LIMIT-4 a resumed thread bills only new assistant messages to its next
|
||||
const root = join(f.root, 'threads/demo/thread1/session'); mkdirSync(root, { recursive: true });
|
||||
const file = join(root, 'session.jsonl');
|
||||
writeFileSync(file, JSON.stringify(f.put('historical', 900)) + '\n');
|
||||
const session = { ...f.session, run: 'next-run', thread: 'thread1' };
|
||||
const session = { ...f.session, run: 'next-run', thread: 'thread1', transcript: root };
|
||||
await f.usage.collect(session, 'begin', 'turn2');
|
||||
writeFileSync(file, [f.put('historical', 900), f.put('new', 6)].map(JSON.stringify).join('\n') + '\n');
|
||||
await f.usage.collect(session, 'end', 'turn2');
|
||||
@@ -118,7 +119,7 @@ test('REQ-LIMIT-4 every enabled source is called; failure is unknown and a parti
|
||||
const bus = join(plugins.root, 'packages/bus/src'); mkdirSync(bus, { recursive: true });
|
||||
copyFileSync(new URL('../src/usage.mjs', import.meta.url), join(bus, 'usage.mjs'));
|
||||
const { createUsage: create } = await import(pathToFileURL(join(bus, 'usage.mjs')));
|
||||
const usage = await create({ store: f.store, dataRoot: f.root,
|
||||
const usage = await create({ store: f.store,
|
||||
businesses: { demo: { plugins: { 'usage-source': ['meter', 'broken', 'invalid'] } } }, log: e => f.logs.push(e) });
|
||||
await usage.collect(f.session, 'begin', 'turn1');
|
||||
await usage.collect(f.session, 'end', 'turn1');
|
||||
@@ -138,3 +139,17 @@ test('REQ-LIMIT-4 a turn whose reported sum exceeds safe precision is unknown, n
|
||||
assert.equal(f.store.get("SELECT code FROM usage_records WHERE phase='end'").code, 'usage-overflow');
|
||||
assert.equal(f.usage.view('demo').total.unknownTurns, 1);
|
||||
});
|
||||
|
||||
test('row 65 a run bound without a transcript directory records no-transcript', async t => {
|
||||
const f = await fixture(t);
|
||||
f.save([f.put('old', 99)]);
|
||||
const { transcript, ...session } = f.session;
|
||||
await f.usage.collect(session, 'begin', 'turn1');
|
||||
f.save([f.put('old', 99), f.put('new')]);
|
||||
await f.usage.collect(session, 'end', 'turn1');
|
||||
assert.deepEqual(f.store.all('SELECT phase, status, code FROM usage_records ORDER BY phase').map(r => [r.phase, r.status, r.code]),
|
||||
// The end row's delta has no baseline to subtract from.
|
||||
[['begin', 'unknown', 'no-transcript'], ['end', 'unknown', 'baseline-unknown']]);
|
||||
assert.equal(f.usage.view('demo').total.unknownTurns, 1);
|
||||
assert.ok(transcript);
|
||||
});
|
||||
@@ -262,6 +262,16 @@ out becomes gated.
|
||||
binds a tester from `tester-cli.mjs` for PM tests; it reads the value at bus
|
||||
start, so a change takes a bus restart.
|
||||
|
||||
A managed session's route is three agent vars (row 65, REQ-PLUG-2):
|
||||
`harness` names a harness plug-in module (the loader's name pattern),
|
||||
`model` is a `launch.models` key, and `thinking` is one of `off`,
|
||||
`minimal`, `low`, `medium`, `high`, `xhigh` and `max`. Whether the module
|
||||
exists, is switched on and applies the level is checked at launch and at a
|
||||
write, never at load. `fallback.harness`, `fallback.model` and
|
||||
`fallback.thinking` state a second route; a layer sets all three or none,
|
||||
and a partial set refuses. No layer falls back to the system's
|
||||
`execution.adapter` or `execution.model`, which stay for `run-task.sh`.
|
||||
|
||||
`resolve` prints `provenance` beside `vars`. For a plain key it's the
|
||||
layer that set the value (`default`, `system`, `business:<id>`,
|
||||
`project:<id>`, `project:<id>:roles.<instance>`,
|
||||
@@ -323,12 +333,59 @@ 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 set a harness, a thinking level or a fallback;
|
||||
the chief can still change an instance's `model` through `instances` in
|
||||
`limits.set`.
|
||||
|
||||
`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 +463,8 @@ and whether `add` and `set` accept it. `set` takes:
|
||||
- `organization-instance-exists`, `organization-unknown-instance`,
|
||||
`organization-launch-not-configured` and `organization-no-change` refuse
|
||||
what their names say.
|
||||
- The whole next document goes through `validateBusinessDocument`, the model
|
||||
check, and each project file's id and instance resolution
|
||||
- The whole next document goes through `validateBusinessDocument`, the route
|
||||
checks (`checkRoutes`, under "Routes" above), and each project file's id and instance resolution
|
||||
(`organization-project-mismatch`, `organization-unresolvable-instance`),
|
||||
the checks `mosaic business validate` runs.
|
||||
- The organization writer then refuses a stale revision
|
||||
|
||||
@@ -7,7 +7,7 @@ export {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
} from "./vocabulary.mjs";
|
||||
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
|
||||
export { LAYERS, REGISTRY, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
|
||||
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
|
||||
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { organizationWriter, OrganizationError } from "./organization.mjs";
|
||||
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
|
||||
import { checkRoutes } from "./launch-settings.mjs";
|
||||
import { loadProject, projectFilePath } from "./project.mjs";
|
||||
import { resolveInstance } from "./resolve.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
@@ -29,7 +30,9 @@ function definitionFor(rolesDir, name) {
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function instanceSettings({ dir, rolesDir, system }) {
|
||||
// levels(name): the thinking levels harness module `name` applies, or null (launch-settings.mjs
|
||||
// checkRoutes, row 65).
|
||||
export function instanceSettings({ dir, rolesDir, system, levels = () => null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
|
||||
// The definitions in rolesDir and whether add and set accept each one.
|
||||
@@ -124,16 +127,17 @@ export function instanceSettings({ dir, rolesDir, system }) {
|
||||
}
|
||||
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
|
||||
|
||||
// The checks mosaic business validate runs, before the writer publishes anything.
|
||||
// The checks mosaic business validate runs, before the writer publishes anything, then the
|
||||
// route checks every write of the route vars runs (row 65). A file that no longer validates
|
||||
// has every route checked.
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
|
||||
if (proposed.launch) {
|
||||
for (const name of Object.keys(proposed.roles)) {
|
||||
const vars = resolveInstance({ business: proposed, system, instance: name }).vars;
|
||||
if (!Object.hasOwn(proposed.launch.models, vars.model ?? vars["execution.model"])) {
|
||||
throw new OrganizationError("unknown-model");
|
||||
}
|
||||
}
|
||||
let before = null;
|
||||
try {
|
||||
before = validateBusinessDocument(old, businessFilePath(business, dir), { rolesDir });
|
||||
} catch (e) {
|
||||
if (e.name !== "BusinessError") throw e;
|
||||
}
|
||||
checkRoutes({ proposed, old: before, levels });
|
||||
for (const [project, entry] of Object.entries(proposed.projects)) {
|
||||
if (!existsSync(projectFilePath(entry.root))) continue;
|
||||
const loaded = loadProject(entry.root);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { organizationWriter, OrganizationError } from "./organization.mjs";
|
||||
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
|
||||
import { resolveInstance } from "./resolve.mjs";
|
||||
import { usageCounts } from "./launch.mjs";
|
||||
import { requireObject, rejectUnknownKeys } from "./util.mjs";
|
||||
import { canonicalJson, isPlainObject, requireObject, rejectUnknownKeys } from "./util.mjs";
|
||||
import { FALLBACK_VARS } from "./vars.mjs";
|
||||
import { THREAD_DEFINITION } from "./vocabulary.mjs";
|
||||
|
||||
// Row 155 (#1649, Jason's ruling Q169): the fields of an assistant instance's entry the human and
|
||||
@@ -67,7 +68,42 @@ function reset(limits, paths, now) {
|
||||
}
|
||||
}
|
||||
|
||||
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString() }) {
|
||||
// Row 65 (REQ-PLUG-2): the checks every write of an instance's route vars runs: the routes table's,
|
||||
// instances.set's and limits.set's. Each instance's model, and its fallback model when it states a
|
||||
// fallback, must be a launch.models key. A route the write changes must name a harness that is
|
||||
// switched on in plugins.harness (harness-off) and whose module applies its thinking level
|
||||
// (thinking-unsupported). levels(name) answers the levels a harness module applies, or null when
|
||||
// the module didn't load, which refuses harness-refused. An unchanged route isn't checked, so a
|
||||
// model change still saves while plugins.harness is unset; that instance's launch refuses instead.
|
||||
// A route var the instance doesn't set is left to the launch, which refuses no-route.
|
||||
const ROUTES = Object.freeze([["harness", "model", "thinking"], FALLBACK_VARS]);
|
||||
export function checkRoutes({ proposed, old = null, levels }) {
|
||||
const on = Array.isArray(proposed.plugins?.harness) ? proposed.plugins.harness : [];
|
||||
for (const [instance, entry] of Object.entries(proposed.roles)) {
|
||||
const vars = entry.vars;
|
||||
const before = old && Object.hasOwn(old.roles, instance) ? old.roles[instance].vars : {};
|
||||
for (const [harness, model, thinking] of ROUTES) {
|
||||
// A missing model refuses no-route at launch, not here.
|
||||
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
|
||||
throw new OrganizationError("unknown-model", `${instance} ${model} ${JSON.stringify(vars[model] ?? null)} isn't a launch.models key`);
|
||||
}
|
||||
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
|
||||
const name = vars[harness];
|
||||
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} ${harness} ${name} isn't switched on in plugins.harness`);
|
||||
if (vars[thinking] === undefined) continue;
|
||||
const applies = levels(name);
|
||||
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `harness module ${name} didn't load, so its thinking levels are unknown`);
|
||||
if (!applies.includes(vars[thinking])) {
|
||||
throw new OrganizationError("thinking-unsupported", `harness ${name} doesn't apply thinking ${vars[thinking]} (${instance} ${thinking}); it applies ${applies.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]);
|
||||
|
||||
// levels(name): the thinking levels harness module `name` applies, or null (checkRoutes).
|
||||
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
|
||||
// The validated business as the file stands now (row 125: the limit checks read it at each ask).
|
||||
@@ -75,19 +111,29 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir });
|
||||
}
|
||||
|
||||
// The file before a write, validated, for checkRoutes; null when it no longer validates, so every
|
||||
// route is checked.
|
||||
function before(id, document) {
|
||||
try {
|
||||
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
|
||||
} catch (e) {
|
||||
if (e.name === "BusinessError") return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function view(id) {
|
||||
const { document, revision } = writer.read(id);
|
||||
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
|
||||
if (!business.launch) throw new OrganizationError("launch-not-configured");
|
||||
const instances = Object.fromEntries(Object.keys(business.roles).map(instance => {
|
||||
const vars = resolveInstance({ business, system, instance }).vars;
|
||||
const key = vars.model ?? vars["execution.model"];
|
||||
const key = vars.model;
|
||||
// No model var reads as unset; the launch refuses it no-route.
|
||||
if (key === undefined) return [instance, { harness: vars.harness ?? null, provider: null, model: null, modelKey: null }];
|
||||
const entry = Object.hasOwn(business.launch.models, key) ? business.launch.models[key] : null;
|
||||
if (!entry) throw new OrganizationError("unknown-model");
|
||||
return [instance, {
|
||||
harness: vars.harness ?? vars["execution.adapter"],
|
||||
...entry, modelKey: key,
|
||||
}];
|
||||
return [instance, { harness: vars.harness ?? null, ...entry, modelKey: key }];
|
||||
}));
|
||||
// Each assistant instance's entry fields as the file has them, so a change replaces what it shows.
|
||||
const assistants = Object.fromEntries(Object.entries(document.roles)
|
||||
@@ -155,17 +201,76 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
for (const [instance, entry] of Object.entries(old.document.roles)) {
|
||||
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
|
||||
}
|
||||
// Check effective inherited selections before publishing the complete document.
|
||||
// Check every instance's route before publishing the complete document.
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
|
||||
for (const instance of Object.keys(proposed.roles)) {
|
||||
const vars = resolveInstance({ business: proposed, system, instance }).vars;
|
||||
if (!Object.hasOwn(proposed.launch.models, vars.model ?? vars["execution.model"])) {
|
||||
throw new OrganizationError("unknown-model");
|
||||
}
|
||||
}
|
||||
checkRoutes({ proposed, old: before(id, old.document), levels });
|
||||
const receipt = writer.write(id, next, { revision: args.revision, actor });
|
||||
return { ...view(id), transaction: receipt.transaction };
|
||||
}
|
||||
|
||||
return { writer, read: view, set, current };
|
||||
// Row 65 (mos-0840): the routing table. One row per instance with its harness, model, thinking
|
||||
// and fallback, and what each cell offers: the switched-on harnesses, the launch.models keys and
|
||||
// the levels each harness applies (null for a module that didn't load). A route var the instance
|
||||
// doesn't set reads null; its launch refuses no-route.
|
||||
function routes(id) {
|
||||
const { document, revision } = writer.read(id);
|
||||
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
|
||||
if (!business.launch) throw new OrganizationError("launch-not-configured");
|
||||
const harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : [];
|
||||
const named = new Set(harnesses);
|
||||
const instances = Object.fromEntries(Object.entries(business.roles).map(([instance, { vars }]) => {
|
||||
for (const key of ["harness", "fallback.harness"]) if (Object.hasOwn(vars, key)) named.add(vars[key]);
|
||||
return [instance, {
|
||||
harness: vars.harness ?? null, model: vars.model ?? null, thinking: vars.thinking ?? null,
|
||||
fallback: Object.hasOwn(vars, "fallback.harness")
|
||||
? { harness: vars["fallback.harness"], model: vars["fallback.model"], thinking: vars["fallback.thinking"] } : null,
|
||||
}];
|
||||
}));
|
||||
return {
|
||||
business: id, revision, harnesses, models: Object.keys(business.launch.models),
|
||||
levels: Object.fromEntries([...named].sort().map(name => [name, levels(name)])), instances,
|
||||
};
|
||||
}
|
||||
|
||||
// args: { revision, routes: { <instance>: { harness, model, thinking, fallback } } }, fallback
|
||||
// being null or { harness, model, thinking }. Each named instance's route vars are replaced in one
|
||||
// organization write; a null fallback clears all three. It is human-only and outside
|
||||
// ORGANIZATION_KEYS (the broker's routes.set), so the chief of staff's tool changes only the
|
||||
// model key, through limits.set.
|
||||
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`);
|
||||
}
|
||||
const vars = { ...next.roles[instance].vars };
|
||||
for (const key of FALLBACK_VARS) delete vars[key];
|
||||
for (const key of ROUTE_FIELDS) {
|
||||
vars[key] = route[key];
|
||||
if (fallback !== null) vars[`fallback.${key}`] = fallback[key];
|
||||
}
|
||||
next.roles[instance].vars = vars;
|
||||
}
|
||||
if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change");
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
|
||||
checkRoutes({ proposed, old: before(id, old.document), levels });
|
||||
const receipt = writer.write(id, next, { revision: args.revision, actor });
|
||||
return { ...routes(id), transaction: receipt.transaction };
|
||||
}
|
||||
|
||||
return { writer, read: view, set, current, routes, setRoutes };
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
|
||||
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
|
||||
import { NAME as MODULE_NAME } from "../../plugins/src/kinds.mjs";
|
||||
|
||||
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
|
||||
|
||||
@@ -33,6 +34,13 @@ function oneOf(values) {
|
||||
};
|
||||
}
|
||||
|
||||
// A harness plug-in module's name, in the loader's pattern (row 65, REQ-PLUG-2). Whether the
|
||||
// module exists or is switched on is checked at launch and at a write, never at load.
|
||||
function moduleName(v, key) {
|
||||
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function integer(min) {
|
||||
return (v, key) => {
|
||||
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
|
||||
@@ -85,6 +93,10 @@ function absolutePath(v, key) {
|
||||
return v;
|
||||
}
|
||||
|
||||
// Every level some harness applies; each module exports the ones it applies (row 65).
|
||||
export const THINKING = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
export const FALLBACK_VARS = Object.freeze(["fallback.harness", "fallback.model", "fallback.thinking"]);
|
||||
|
||||
const def = (layers, check, extra = {}) => Object.freeze({ layers: Object.freeze(layers), merge: "replace", check, default: undefined, ...extra });
|
||||
|
||||
export const REGISTRY = Object.freeze({
|
||||
@@ -124,10 +136,15 @@ export const REGISTRY = Object.freeze({
|
||||
return v;
|
||||
}),
|
||||
|
||||
// Agent: one role instance.
|
||||
harness: def(["agent"], oneOf(["pi", "claude-code"])),
|
||||
// Agent: one role instance. A managed session's route: the harness module, a launch.models key
|
||||
// and a thinking level, which the harness module must apply (row 65). The fallback route is all
|
||||
// three or none (FALLBACK_VARS).
|
||||
harness: def(["agent"], moduleName),
|
||||
model: def(["agent"], string(200)),
|
||||
thinking: def(["agent"], oneOf(["off", "minimal", "low", "medium", "high", "xhigh"])),
|
||||
thinking: def(["agent"], oneOf(THINKING)),
|
||||
"fallback.harness": def(["agent"], moduleName),
|
||||
"fallback.model": def(["agent"], string(200)),
|
||||
"fallback.thinking": def(["agent"], oneOf(THINKING)),
|
||||
|
||||
// Limits narrow the role definition's ceilings. limits.authority is an
|
||||
// allowlist: a role action it doesn't name becomes gated.
|
||||
@@ -152,6 +169,10 @@ export function checkVars(vars, layer, where) {
|
||||
if (!entry.layers.includes(layer)) refuse(`${where}: variable ${key} can't be set at the ${layer} layer (allowed: ${entry.layers.join(", ")})`);
|
||||
out[key] = entry.check(value, key);
|
||||
}
|
||||
const fallback = FALLBACK_VARS.filter((key) => Object.hasOwn(out, key));
|
||||
if (fallback.length > 0 && fallback.length < FALLBACK_VARS.length) {
|
||||
refuse(`${where}: a fallback sets all of ${FALLBACK_VARS.join(", ")} or none (missing ${FALLBACK_VARS.filter((key) => !fallback.includes(key)).join(", ")})`);
|
||||
}
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ const roots = [];
|
||||
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
|
||||
|
||||
// The business file sits where mosaic business validate looks for it, so the test can run it.
|
||||
function fixture(mutate) {
|
||||
// Row 65: a write that sets a harness needs it switched on in plugins.harness.
|
||||
const harnessOn = (doc, names = ["pi", "claude-code"]) => { doc.plugins = { ...(doc.plugins ?? {}), harness: names }; };
|
||||
const LEVELS = { pi: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], "claude-code": ["low", "medium", "high", "xhigh", "max"] };
|
||||
|
||||
function fixture(mutate, { levels } = {}) {
|
||||
const root = tmp();
|
||||
roots.push(root);
|
||||
const rolesDir = rolesCopy(root);
|
||||
@@ -24,7 +28,7 @@ function fixture(mutate) {
|
||||
const dir = join(root, "config");
|
||||
const file = writeJson(join(dir, "businesses", "acme.json"), doc);
|
||||
const config = systemConfig(root);
|
||||
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) });
|
||||
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root), ...(levels ? { levels } : {}) });
|
||||
const validate = () => spawnSync(process.execPath, [CLI, "validate", "acme"], {
|
||||
env: { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesDir }, encoding: "utf8",
|
||||
});
|
||||
@@ -81,7 +85,7 @@ test("row 165 add: two instances join, validate exits 0, the log names the human
|
||||
});
|
||||
|
||||
test("row 165 change: definition, vars and launch membership log before and after; only a definition change asks for a restart", () => {
|
||||
const f = fixture(doc => { doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi" } }; });
|
||||
const f = fixture(doc => { harnessOn(doc); doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi" } }; });
|
||||
const redefined = f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "sec-1", op: "change", definition: "merge-gate" }, "jason");
|
||||
assert.equal(redefined.instances["sec-1"].definition, "merge-gate");
|
||||
assert.equal(redefined.restart, true);
|
||||
@@ -147,7 +151,7 @@ test("row 165 refuses a definition change to or from one that declares credentia
|
||||
});
|
||||
|
||||
test("row 165 a vars or launch change on an instance with its binding succeeds and keeps the binding", () => {
|
||||
const f = fixture();
|
||||
const f = fixture(doc => harnessOn(doc));
|
||||
const before = JSON.parse(readFileSync(f.file, "utf8")).roles.coder;
|
||||
const done = f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "coder", op: "change", vars: { harness: "claude-code" }, launch: false }, "jason");
|
||||
assert.equal(done.restart, false);
|
||||
@@ -186,3 +190,27 @@ test("row 165 refuses a stale revision and leaves the file's bytes alone", () =>
|
||||
assert.throws(() => f.settings.set("acme", { revision: stale, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason"), e => e.code === "revision-conflict");
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
});
|
||||
|
||||
// Row 65 (REQ-PLUG-2): a write that sets a harness that isn't switched on, a thinking level its
|
||||
// module doesn't apply, a model that isn't a launch.models key or a partial fallback refuses, and
|
||||
// the file keeps its bytes. A route the write doesn't touch isn't rechecked for its harness.
|
||||
test("row 65 an instance write refuses harness-off, thinking-unsupported, an unknown model and a partial fallback", () => {
|
||||
const off = fixture();
|
||||
refuses(off, { instance: "coder", op: "change", vars: { harness: "claude-code" } }, "harness-off");
|
||||
// The file's own unswitched harnesses don't block a write that leaves them alone.
|
||||
const kept = off.settings.set("acme", { revision: off.settings.read("acme").revision, instance: "coder", op: "change", vars: { harness: "pi", "limits.network": "none", "limits.tools": [] } }, "jason");
|
||||
assert.equal(kept.restart, false);
|
||||
const f = fixture(doc => harnessOn(doc, ["pi"]), { levels: name => LEVELS[name] ?? null });
|
||||
refuses(f, { instance: "coder", op: "change", vars: { harness: "claude-code" } }, "harness-off");
|
||||
refuses(f, { instance: "coder", op: "change", vars: { harness: "pi", "fallback.harness": "claude-code", "fallback.model": "claude-opus-5-5", "fallback.thinking": "high" } }, "harness-off");
|
||||
const g = fixture(doc => harnessOn(doc), { levels: name => LEVELS[name] ?? null });
|
||||
refuses(g, { instance: "coder", op: "change", vars: { harness: "claude-code", thinking: "minimal" } }, "thinking-unsupported");
|
||||
// The business file's own launch check names an unknown model first.
|
||||
refuses(g, { instance: "coder", op: "change", vars: { harness: "pi", model: "ghost" } }, "BusinessError");
|
||||
refuses(g, { instance: "coder", op: "change", vars: { harness: "pi", "fallback.harness": "pi" } }, "BusinessError");
|
||||
const done = g.settings.set("acme", { revision: g.settings.read("acme").revision, instance: "coder", op: "change", vars: { harness: "claude-code", model: "claude-opus-5-5", thinking: "max", "fallback.harness": "pi", "fallback.model": "claude-sonnet-5-5", "fallback.thinking": "minimal" } }, "jason");
|
||||
assert.deepEqual(done.instances.coder.vars, { harness: "claude-code", model: "claude-opus-5-5", thinking: "max", "fallback.harness": "pi", "fallback.model": "claude-sonnet-5-5", "fallback.thinking": "minimal" });
|
||||
// A module that didn't load gives no levels, so a thinking write on it refuses.
|
||||
const h = fixture(doc => harnessOn(doc, ["pi", "fixture"]), { levels: name => LEVELS[name] ?? null });
|
||||
refuses(h, { instance: "coder", op: "change", vars: { harness: "fixture", thinking: "high" } }, "harness-refused");
|
||||
});
|
||||
@@ -46,7 +46,7 @@ test('REQ-LIMIT-1 shared settings validates the complete proposed selection and
|
||||
});
|
||||
test('REQ-LIMIT-1 provider is table data: another provider routes separately and aliases share exact counts',async()=>{
|
||||
const {limitHit}=await import('../src/launch.mjs');const {sessionModel}=await import('../../harness/src/bundle.mjs');
|
||||
const models={alias:{provider:'proxy-next',model:'claude-opus-5-5'}},selected=sessionModel({vars:{harness:'claude-code',model:'alias'}},models);assert.equal(selected.provider,'proxy-next');assert.equal(selected.model,'claude-opus-5-5');
|
||||
const models={alias:{provider:'proxy-next',model:'claude-opus-5-5'}},selected=sessionModel({vars:{harness:'claude-code',model:'alias',thinking:'high'}},models);assert.equal(selected.provider,'proxy-next');assert.equal(selected.model,'claude-opus-5-5');
|
||||
const sessions=[{business:'acme',provider:'anthropic',model:selected.model}];assert.equal(limitHit(sessions,'acme',{providers:{'proxy-next':1},models:{'proxy-next':{[selected.model]:1}}},selected),null);sessions.push({business:'acme',provider:'proxy-next',model:selected.model});assert.equal(limitHit(sessions,'acme',{providers:{'proxy-next':1}},selected).scope,'provider');assert.equal(limitHit(sessions,'acme',{models:{'proxy-next':{[selected.model]:1}}},selected).scope,'model');
|
||||
assert.equal(limitHit([],'acme',{providers:{constructor:0}},{provider:'constructor',model:'toString'}).running,0);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ test('Q169 assistants: vars and definition change and log the actor; tracker nul
|
||||
const done=settings.set('acme',{revision:view.revision,assistants:{assistant:{vars:{model:'opus',thinking:'high'},definition:'assistant',tracker:null}}},'jason');
|
||||
assert.deepEqual(done.assistants.assistant,{definition:'assistant',vars:{model:'opus',thinking:'high'},tracker:null});assert.equal(done.instances.assistant.modelKey,'opus');
|
||||
const e=JSON.parse(readFileSync(settings.writer.logFile('acme'),'utf8').trim().split('\n').at(-1));assert.equal(e.actor,'jason');assert.ok(e.changes.some(c=>c.path==='roles.assistant.vars.thinking'&&c.new==='high'));
|
||||
const chief=settings.set('acme',{revision:done.revision,assistants:{assistant:{vars:{}}}},'chief-of-staff.chief');assert.deepEqual(chief.assistants.assistant.vars,{});assert.equal(chief.instances.assistant.modelKey,'sonnet');
|
||||
const chief=settings.set('acme',{revision:done.revision,assistants:{assistant:{vars:{}}}},'chief-of-staff.chief');assert.deepEqual(chief.assistants.assistant.vars,{});assert.equal(chief.instances.assistant.modelKey,null,'row 65: no execution.model fallback');
|
||||
const last=JSON.parse(readFileSync(settings.writer.logFile('acme'),'utf8').trim().split('\n').at(-1));assert.equal(last.actor,'chief-of-staff.chief');
|
||||
});
|
||||
test('Q169 assistants: refuses other instances, other definitions, holder and credentials, empty entries, a tracker the definition has no credential for and a stale revision',async()=>{
|
||||
@@ -75,7 +75,7 @@ test('Q169 assistants: refuses other instances, other definitions, holder and cr
|
||||
for(const definition of ['coder','chief-of-staff',null])assert.throws(()=>set({assistant:{definition}}),code('organization-definition-refused'),String(definition));
|
||||
assert.throws(()=>set({ghost:{vars:{}}}),code('invalid-request'));assert.throws(()=>set({assistant:{}}),code('invalid-request'));
|
||||
assert.throws(()=>set({assistant:{holder:'rex'}}),/unsupported assistant settings assistant key: "holder"/);assert.throws(()=>set({assistant:{credentials:{}}}),/unsupported assistant settings assistant key: "credentials"/);
|
||||
assert.throws(()=>set({assistant:{vars:{harness:'cursor'}}}),/harness/);assert.throws(()=>set({assistant:{vars:{model:'ghost'}}}),/unknown model/);
|
||||
assert.throws(()=>set({assistant:{vars:{harness:'cursor'}}}),code('harness-off'));assert.throws(()=>set({assistant:{vars:{model:'ghost'}}}),/unknown model/);
|
||||
assert.throws(()=>set({assistant:{tracker:{bot:'bot-acme-assistant',botId:11}}}),/has "tracker" but assistant uses no vikunja credential/);
|
||||
const moved=settings.set('acme',{revision,assistants:{assistant:{vars:{model:'opus'}}}},'jason');assert.notEqual(moved.revision,revision);const after=readFileSync(f.file,'utf8');
|
||||
assert.throws(()=>set({assistant:{vars:{model:'sonnet'}}}),code('revision-conflict'));assert.equal(readFileSync(f.file,'utf8'),after);assert.notEqual(after,bytes);
|
||||
@@ -87,3 +87,58 @@ test('Q169 assistants: a definition with a vikunja credential takes a tracker ch
|
||||
assert.throws(()=>set({assistant:{tracker:null}}),/needs "tracker"/);assert.throws(()=>set({assistant:{tracker:{bot:'bot-acme-pm',botId:12}}}),/used by another instance/);assert.throws(()=>set({assistant:{tracker:{bot:'bot-acme-x',botId:12,holder:'rex'}}}),/unsupported/);
|
||||
const done=set({assistant:{tracker:{bot:'bot-acme-helper',botId:12}}});assert.deepEqual(done.assistants.assistant.tracker,{bot:'bot-acme-helper',botId:12});assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.assistant.credentials.vikunja.file,join(f.root,'secrets','assistant-vikunja.token'));
|
||||
});
|
||||
// Row 65 (mos-0840): the routing table. routes() reads one row per instance with its six values, and
|
||||
// setRoutes() saves a route and a whole fallback in one write, refusing what a launch would refuse.
|
||||
const LEVELS={pi:['off','low','high'],'claude-code':['low','high']};
|
||||
async function routeSettings(f,harness=['pi','claude-code']){const doc=structuredClone(f.doc);if(harness)doc.plugins={...doc.plugins,harness};else delete doc.plugins?.harness;for(const r of Object.values(doc.roles))r.vars={harness:'pi',thinking:'high',...r.vars};writeJson(f.file,doc);const {launchSettings}=await import('../src/launch-settings.mjs');return {doc,settings:launchSettings({dir:f.root,rolesDir:f.rolesDir,system:{environment:'development',dataRoot:join(f.root,'data'),'execution.backend':'docker','execution.provider':'anthropic','execution.model':'sonnet','execution.adapter':'pi'},levels:name=>LEVELS[name]??null})};}
|
||||
const routeOf=v=>({harness:v.harness??null,model:v.model??null,thinking:v.thinking??null,fallback:null});
|
||||
test('row 65 routes lists every instance with harness, model, thinking and fallback, the switched-on harnesses, the model keys and each harness\'s levels',async()=>{
|
||||
const f=fixture();const {doc,settings}=await routeSettings(f);const view=settings.routes('acme');
|
||||
assert.deepEqual(Object.keys(view.instances).sort(),Object.keys(doc.roles).sort());
|
||||
for(const [instance,{vars}] of Object.entries(doc.roles))assert.deepEqual(view.instances[instance],routeOf(vars),instance);
|
||||
assert.deepEqual([view.harnesses,view.models,view.levels],[['pi','claude-code'],['opus','sonnet'],{'claude-code':['low','high'],pi:['off','low','high']}]);
|
||||
assert.match(view.revision,/^[a-f0-9]{64}$/);
|
||||
});
|
||||
test('row 65 setRoutes saves a harness, model, thinking and whole fallback, reads each back from the file, logs the actor, and a null fallback clears all three',async()=>{
|
||||
const f=fixture();const {settings}=await routeSettings(f);const {revision}=settings.routes('acme'),{harness,model,thinking,...rest}=JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars;
|
||||
const done=settings.setRoutes('acme',{revision,routes:{coder:{harness:'claude-code',model:'opus',thinking:'low',fallback:{harness:'pi',model:'sonnet',thinking:'off'}}}},'jason');
|
||||
const vars=JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars;
|
||||
// The coder's other vars stay as they were.
|
||||
assert.deepEqual(vars,{...rest,harness:'claude-code',model:'opus',thinking:'low','fallback.harness':'pi','fallback.model':'sonnet','fallback.thinking':'off'});
|
||||
assert.deepEqual(done.instances.coder,{harness:'claude-code',model:'opus',thinking:'low',fallback:{harness:'pi',model:'sonnet',thinking:'off'}});assert.match(done.transaction,/./);
|
||||
const e=JSON.parse(readFileSync(settings.writer.logFile('acme'),'utf8').trim().split('\n').at(-1));assert.equal(e.actor,'jason');assert.ok(e.changes.some(c=>c.path==='roles.coder.vars.fallback.harness'||c.path==='roles.coder.vars'));
|
||||
settings.setRoutes('acme',{revision:done.revision,routes:{coder:{harness:'claude-code',model:'opus',thinking:'low',fallback:null}}},'jason');
|
||||
assert.deepEqual(JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars,{...rest,harness:'claude-code',model:'opus',thinking:'low'});
|
||||
});
|
||||
test('row 65 setRoutes refuses a partial fallback, a switched-off harness, a level the harness doesn\'t apply, an unloaded module, an unknown model or instance, a malformed route, no change and a stale revision, with the file unchanged',async()=>{
|
||||
const f=fixture();const {settings}=await routeSettings(f,['pi']);const {revision}=settings.routes('acme'),bytes=readFileSync(f.file,'utf8');
|
||||
const route=(more={})=>({harness:'pi',model:'sonnet',thinking:'low',fallback:null,...more}),set=(routes,rev=revision)=>settings.setRoutes('acme',{revision:rev,routes},'jason');
|
||||
for(const [routes,c] of [
|
||||
[{coder:route({fallback:{harness:'pi',model:'sonnet'}})},'organization-partial-fallback'],
|
||||
[{coder:route({fallback:{harness:'pi',model:'sonnet',thinking:null}})},'organization-partial-fallback'],
|
||||
[{coder:route({harness:'claude-code'})},'harness-off'],
|
||||
[{coder:route({fallback:{harness:'claude-code',model:'sonnet',thinking:'low'}})},'harness-off'],
|
||||
[{coder:route({thinking:'max'})},'thinking-unsupported'],
|
||||
[{coder:route({fallback:{harness:'pi',model:'sonnet',thinking:'max'}})},'thinking-unsupported'],
|
||||
// The file's own validation refuses an unknown primary model before the route checks run.
|
||||
[{coder:route({model:'ghost'})},/instance coder selects unknown model ghost/],
|
||||
[{coder:route({fallback:{harness:'pi',model:'ghost',thinking:'low'}})},'unknown-model'],
|
||||
[{ghost:route()},'organization-unknown-instance'],
|
||||
[{coder:{harness:'pi',model:'sonnet',thinking:'low'}},'invalid-request'],
|
||||
[{coder:route({extra:1})},'invalid-request'],
|
||||
[{},'invalid-request'],
|
||||
])assert.throws(()=>set(routes),typeof c==='string'?code(c):c,JSON.stringify(routes));
|
||||
assert.throws(()=>settings.setRoutes('acme',{revision,routes:{coder:route()},instances:{}},'jason'),/unsupported routes update key/);
|
||||
// A switched-on harness whose module didn't load has no known levels.
|
||||
const g=fixture();const {settings:other}=await routeSettings(g,['pi','cursor']);assert.throws(()=>other.setRoutes('acme',{revision:other.routes('acme').revision,routes:{coder:route({harness:'cursor'})}},'jason'),code('harness-refused'));
|
||||
const {coder}=JSON.parse(bytes).roles;assert.throws(()=>set({coder:{harness:coder.vars.harness,model:coder.vars.model,thinking:coder.vars.thinking,fallback:null}}),code('organization-no-change'));
|
||||
assert.equal(readFileSync(f.file,'utf8'),bytes);
|
||||
const moved=set({coder:route()});assert.notEqual(moved.revision,revision);const after=readFileSync(f.file,'utf8');
|
||||
assert.throws(()=>set({coder:route({thinking:'high'})}),code('revision-conflict'));assert.equal(readFileSync(f.file,'utf8'),after);
|
||||
});
|
||||
test('row 65 a file with harness vars and no plugins.harness loads and its views read; a model change still saves, and a route change refuses harness-off',async()=>{
|
||||
const f=fixture();const {doc,settings}=await routeSettings(f,null);
|
||||
const view=settings.routes('acme');assert.deepEqual(view.harnesses,[]);assert.deepEqual(view.instances.coder,routeOf(doc.roles.coder.vars));assert.equal(settings.read('acme').instances.coder.modelKey,'sonnet');
|
||||
const moved=settings.set('acme',{revision:view.revision,instances:{coder:'opus'}},'jason');assert.equal(moved.instances.coder.modelKey,'opus');
|
||||
assert.throws(()=>settings.setRoutes('acme',{revision:moved.revision,routes:{coder:{harness:'pi',model:'opus',thinking:'low',fallback:null}}},'jason'),code('harness-off'));
|
||||
});
|
||||
@@ -36,6 +36,8 @@ test("types", () => {
|
||||
ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678", "bus.tester": true }, "business");
|
||||
ok({ "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"], suites: ["scripts/test-task.sh"], "issues.repo": "mosaicstack/stack", "tracker.descendants": true }, "project");
|
||||
ok({ harness: "claude-code", model: "claude-opus-5-5", thinking: "high", "limits.tools": [], "limits.network": "none", "limits.authority": ["message.send"] }, "agent");
|
||||
// Row 65: any module name passes here; whether it is switched on is a launch and write check.
|
||||
ok({ harness: "fixture-harness", model: "m", thinking: "off", "fallback.harness": "pi", "fallback.model": "m", "fallback.thinking": "low" }, "agent");
|
||||
|
||||
const bad = [
|
||||
[{ "tracker.baseUrl": "ftp://x" }, "business", /http or https/],
|
||||
@@ -66,8 +68,12 @@ test("types", () => {
|
||||
[{ "git.protectedBranches": ["main", "main"] }, "project", /duplicate/],
|
||||
[{ suites: "scripts/test-task.sh" }, "project", /must be an array/],
|
||||
[{ "issues.repo": "stack" }, "project", /owner\/name/],
|
||||
[{ harness: "codex" }, "agent", /one of: pi, claude-code/],
|
||||
[{ thinking: "max" }, "agent", /one of/],
|
||||
[{ harness: "Codex" }, "agent", /harness module name/],
|
||||
[{ harness: "../pi" }, "agent", /harness module name/],
|
||||
[{ "fallback.harness": "pi" }, "agent", /a fallback sets all of fallback\.harness, fallback\.model, fallback\.thinking or none \(missing fallback\.model, fallback\.thinking\)/],
|
||||
[{ "fallback.harness": "pi", "fallback.model": "m" }, "agent", /missing fallback\.thinking/],
|
||||
[{ "fallback.harness": "pi", "fallback.model": "m", "fallback.thinking": "huge" }, "agent", /one of/],
|
||||
[{ thinking: "huge" }, "agent", /one of/],
|
||||
[{ model: "" }, "agent", /non-empty string/],
|
||||
[{ "limits.tools": ["render3d"] }, "agent", /unsupported tool/],
|
||||
[{ "web.searxng": "https://search.example.org/" }, "business", /base URL/],
|
||||
|
||||
+31
-13
@@ -338,26 +338,44 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or
|
||||
| the business file has a `launch` block | `launch-not-configured` |
|
||||
| `launch.instances` lists the instance (for `--pm`: it is `launch.by`) | `instance-not-listed` |
|
||||
| the instance isn't running | `instance-running` |
|
||||
| the instance resolves to `pi` or `claude-code` and a model | `unsupported-harness` |
|
||||
| the instance resolves | `unsupported-harness` |
|
||||
| it sets the `harness`, `model` and `thinking` vars, and all three `fallback.` vars if it sets any (row 65); the message names the missing var | `no-route` |
|
||||
| each route's `model` is a `launch.models` key | `unsupported-harness` |
|
||||
| its role has a contract file | `no-role-contract` |
|
||||
| a Claude Code session has `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL` to log in with | `no-session-credential` |
|
||||
| with `launch.account` set, its name is not the value of any of those variables the bus host sets, since usage records keep the name | `account-is-credential` |
|
||||
| with `launch.account` set, a Claude Code session's account names one of those login sets and the bus host holds all of it | `account-credential-missing` |
|
||||
| with `launch.account` set, the bus host sets no other of those variables, `ANTHROPIC_BASE_URL` included | `account-credential-ambiguous` |
|
||||
| `plugins.harness` switches on the primary's harness and a stated fallback's | `harness-off` |
|
||||
| the plug-in loader loads the primary's module; the message names it and the loader's code | `harness-refused` |
|
||||
| the module applies the route's thinking level | `thinking-unsupported` |
|
||||
| the module's preflight passes, else the stated fallback's harness passes the three checks above and its own preflight | the module's code (`no-session-credential`, `harness-binary-missing`, …); a module that throws is `harness-failed` |
|
||||
| on a harness that counts the account, with `launch.account` set, its name is not the value of any session login variable the bus host sets, since usage records keep the name | `account-is-credential` |
|
||||
| on that harness, the account names one of the login sets and the bus host holds all of it | `account-credential-missing` |
|
||||
| on that harness, the bus host sets no other login variable, `ANTHROPIC_BASE_URL` included | `account-credential-ambiguous` |
|
||||
| bwrap runs here | `sandbox-unavailable` |
|
||||
| its model selects an exact entry in `launch.models` | `unsupported-harness` |
|
||||
| total, provider and exact provider/model limits have room; the PM counts too | `capacity-full` |
|
||||
| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
|
||||
| the workspace has a clone of each project, or the host can make one | `checkout-failed`, with the project id |
|
||||
| the bundle builds and the runner starts | `launch-failed` |
|
||||
|
||||
Row 126 (#1620): only a Claude Code launch spends `launch.account.name`. It
|
||||
Row 126 (#1620): only a launch on a harness whose module's `countsAccount()` is true spends `launch.account.name`. It
|
||||
goes to the `mayLaunch` limit checks, to the bus with the run, onto the
|
||||
run's usage records, and onto the `launch` record as `account`. A pi launch
|
||||
run's usage records, and onto the `launch` record as `account`. Of the shipped
|
||||
modules that is Claude Code. A pi launch
|
||||
has account null: its records say null, no daily quota covers it, and the
|
||||
two account checks above are skipped. Both account refusals name the
|
||||
variables, never a value.
|
||||
|
||||
Row 65 (#1558): the route comes from the instance's vars only. The system's
|
||||
`execution.adapter` and `execution.model` never fill a missing one; they stay
|
||||
for `run-task.sh`. The harness is a module under `packages/plugins/harness/`
|
||||
(`packages/plugins/README.md`, "Harness"), loaded through a fresh loader for
|
||||
each launch, and the launcher names no harness itself. A fallback is taken
|
||||
only when the primary's preflight fails: an off or refused primary, or one
|
||||
whose thinking level it doesn't apply, refuses without trying it. If the
|
||||
fallback's preflight fails too, the refusal carries its code and names both
|
||||
reasons. The account, capacity and limit checks run on the route taken. The
|
||||
manifest records `route` (`primary` or `fallback`) and `routeReason` (the
|
||||
primary's preflight code, or null). The reply's `harness` and `model` are
|
||||
the route's.
|
||||
|
||||
Every refusal is an `action.refused` event (the broker's `refuse`, or its
|
||||
own refusal for the last broker check) and a line in the launch log. Then
|
||||
the launcher reads the instance's credential status from the broker
|
||||
@@ -394,11 +412,11 @@ credential check: a Pi role on a provider whose login lives only in those
|
||||
files, or whose key isn't an `ENV_ALLOW` variable, launches and then fails
|
||||
at its first turn.
|
||||
|
||||
The manifest records the Pi version from the repository's pin (the
|
||||
`@earendil-works/pi-coding-agent` dependency in the root `package.json`)
|
||||
and, for Claude Code, `--version` from the binary sessions run:
|
||||
`MOSAIC_CLAUDE_BIN` when set (the unit pins it, below), else `claude` on
|
||||
`PATH`.
|
||||
The manifest's `harnessVersion` is the module's `version()`: for pi the
|
||||
repository's pin (the `@earendil-works/pi-coding-agent` dependency in the
|
||||
root `package.json`), for Claude Code `--version` from the binary sessions
|
||||
run, `MOSAIC_CLAUDE_BIN` when set (the unit pins it, below), else `claude`
|
||||
on `PATH`.
|
||||
|
||||
On disk, under the data root: `launches/<business>/<run>/` (0700) is the run
|
||||
directory; `launches/<business>.jsonl` (0600, append-only) logs every launch,
|
||||
|
||||
@@ -11,10 +11,18 @@
|
||||
// 2. the business file's launch.instances lists the instance
|
||||
// (instance-not-listed)
|
||||
// 3. the instance isn't already running (instance-running)
|
||||
// 4. it resolves, has a role contract (no-role-contract) and a supported
|
||||
// harness and model (unsupported-harness)
|
||||
// 5. a Claude Code session has a credential to log in with
|
||||
// (no-session-credential)
|
||||
// 4. it resolves, has a role contract (no-role-contract), sets the
|
||||
// harness, model and thinking vars (no-route names the missing one)
|
||||
// and its model is a launch.models key (unsupported-harness)
|
||||
// 5. the route's harness (row 65): plugins.harness switches it on, and a
|
||||
// stated fallback's too (harness-off); the plug-in loader loads it
|
||||
// (harness-refused names the module and the loader's reason); it takes
|
||||
// the thinking level (thinking-unsupported); its preflight passes
|
||||
// (the module's code, e.g. no-session-credential or
|
||||
// harness-binary-missing). A failed primary preflight takes the
|
||||
// stated fallback through the same checks; there is no other retry.
|
||||
// A harness whose launches count against launch.account gets the
|
||||
// row 126 account checks.
|
||||
// 6. bwrap runs here (sandbox-unavailable)
|
||||
// 7. its model names an exact provider/model table entry
|
||||
// 8. total, provider and provider/model have room, including the launcher
|
||||
@@ -88,18 +96,16 @@
|
||||
// Nothing here is persisted. After a restart recover() ends the earlier
|
||||
// host's runs, so the first tick starts each holder.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { buildBundle, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
import {
|
||||
ADAPTERS,
|
||||
appendLaunchLog,
|
||||
endReason,
|
||||
hasSessionCredential,
|
||||
gitSocketPath,
|
||||
launchSocketPath,
|
||||
newRun,
|
||||
@@ -118,10 +124,9 @@ import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { CheckoutError, ensureCheckouts } from "../../seat/src/checkout.mjs";
|
||||
import { createGitSocket } from "./git-socket.mjs";
|
||||
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
|
||||
import { REPO, rolesDir } from "./config.mjs";
|
||||
import { rolesDir } from "./config.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
||||
const STOP_TIMEOUT_MS = 30000;
|
||||
const LINE_MAX = 4096;
|
||||
// How often the host asks for the threads' state; a poll period, not a limit.
|
||||
@@ -137,25 +142,19 @@ class Refusal extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// The exact pi pin from the repository's package.json.
|
||||
export function piVersion(repo = REPO) {
|
||||
// A harness module runs with the host's authority, and anything it throws refuses the launch
|
||||
// and names the module rather than escaping the host.
|
||||
function call(name, what, fn) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(repo, "package.json"), "utf8"));
|
||||
return pkg.dependencies?.[PI_PACKAGE] ?? pkg.devDependencies?.[PI_PACKAGE] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
return fn();
|
||||
} catch (e) {
|
||||
throw new Refusal("harness-failed", `harness ${name} ${what}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// `claude --version` from the binary sessions run (MOSAIC_CLAUDE_BIN, else
|
||||
// `claude` on PATH), or null when it doesn't answer.
|
||||
export function claudeVersion(env = process.env) {
|
||||
const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
|
||||
return r.status === 0 ? r.stdout.trim().split("\n")[0] || null : null;
|
||||
}
|
||||
|
||||
// host: startHost()'s handle. tracker: true when the broker has task verbs for
|
||||
// the business. adapters, bwrap, readOnly, sessionDefaults, versions,
|
||||
// the business. makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
|
||||
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
|
||||
// the tests; the command line never sets them.
|
||||
export function createLauncher({
|
||||
@@ -163,11 +162,11 @@ export function createLauncher({
|
||||
system,
|
||||
env = process.env,
|
||||
tracker = false,
|
||||
adapters = ADAPTERS,
|
||||
makeLoader = createLoader,
|
||||
bwrap = BWRAP,
|
||||
readOnly = [],
|
||||
sessionDefaults = {},
|
||||
versions = null,
|
||||
harnessVersion = undefined,
|
||||
stopTimeoutMs = STOP_TIMEOUT_MS,
|
||||
threadTickMs = THREAD_TICK_MS,
|
||||
git = {},
|
||||
@@ -231,22 +230,55 @@ 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).
|
||||
const second = await harnessOf(fallback);
|
||||
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 +305,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 +339,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 +364,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 +393,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 +404,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 +423,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 +458,7 @@ export function createLauncher({
|
||||
refusedRelease.delete(run);
|
||||
}
|
||||
});
|
||||
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
|
||||
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, route, routeReason, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
|
||||
return { run, instance, harness: model.harness, model: model.model };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:f
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { BusError, Broker } from "../../bus/src/broker.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { tree } from "../../plugins/tests/helpers.mjs";
|
||||
import { AGENT_MARKERS } from "../src/transport.mjs";
|
||||
|
||||
export function tmp(t, prefix = "mosaic-cli-") {
|
||||
@@ -26,11 +28,16 @@ export function humanEnv(extra = {}) {
|
||||
return env;
|
||||
}
|
||||
|
||||
// A config directory with the system config, the roles and business `id`.
|
||||
// A config directory with the system config, the roles and business `id`. Row 65: both harnesses
|
||||
// are switched on, since a launch on one that isn't refuses harness-off, and every instance names a
|
||||
// harness, model and thinking level, as the live file does, since a launch missing one refuses
|
||||
// no-route. The instance's own vars win, and edit can change any of it.
|
||||
export function fixture(root, id = "acme", edit = (doc) => doc) {
|
||||
const config = systemConfig(root);
|
||||
const roles = rolesCopy(root);
|
||||
const doc = edit(businessDoc(root, id));
|
||||
const base = businessDoc(root, id);
|
||||
for (const r of Object.values(base.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars };
|
||||
const doc = edit({ ...base, plugins: { ...base.plugins, harness: ["pi", "claude-code"] } });
|
||||
writeJson(join(root, "config", "businesses", `${id}.json`), doc);
|
||||
return { config, roles, dataRoot: join(root, "data"), doc, env: humanEnv({ MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: roles }) };
|
||||
}
|
||||
@@ -66,6 +73,7 @@ export const BUSINESSES = {
|
||||
demo: {
|
||||
id: "demo",
|
||||
human: "jason",
|
||||
plugins: { harness: ["pi"] },
|
||||
arbiters: { technical: "cto", delivery: "pm" },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
@@ -111,3 +119,28 @@ export function fakeCli(root, body) {
|
||||
chmodSync(file, 0o600);
|
||||
return file;
|
||||
}
|
||||
|
||||
// Row 65: a launcher's makeLoader over a scratch plug-in tree. adapters maps a harness module to the
|
||||
// adapter script a test runs in its place, or to null for the real module unchanged. A faked module
|
||||
// re-exports the real one with only adapter() replaced; a faked claude-code also passes a missing
|
||||
// claude binary, since the fake adapter never runs one (packages/plugins/tests/harness.test.mjs
|
||||
// covers that check). more(write) adds fixture modules, write(kind, name, body).
|
||||
export async function fakeHarnesses(t, adapters, more = () => {}) {
|
||||
const { write, loaderUrl } = tree(t);
|
||||
for (const [name, adapter] of Object.entries(adapters)) {
|
||||
const real = JSON.stringify(pathToFileURL(new URL(`../../plugins/harness/${name}/index.mjs`, import.meta.url).pathname).href);
|
||||
if (adapter === null) {
|
||||
write("harness", name, `export * from ${real};\n`);
|
||||
continue;
|
||||
}
|
||||
const lines = [`export * from ${real};`, `export const adapter = () => ${JSON.stringify(adapter)};`];
|
||||
if (name === "claude-code") {
|
||||
lines.push(`import { preflight as real } from ${real};`,
|
||||
"export function preflight(input) { const p = real(input); return p?.code === \"harness-binary-missing\" ? null : p; }");
|
||||
}
|
||||
write("harness", name, lines.join("\n") + "\n");
|
||||
}
|
||||
more(write);
|
||||
const { createLoader } = await import(loaderUrl);
|
||||
return createLoader;
|
||||
}
|
||||
@@ -63,7 +63,8 @@ test("row 165 CLI: instance add puts an instance in the file, validate exits 0,
|
||||
const view = await s.mosaic(["instances", "acme"]);
|
||||
assert.equal(view.code, 0, view.err);
|
||||
assert.match(view.out, /^business acme, revision [a-f0-9]{64}$/m);
|
||||
assert.match(view.out, /^ {2}chief: chief-of-staff \(launcher\)$/m);
|
||||
// Row 65: the cli fixture gives every instance its route vars, so the view lists them.
|
||||
assert.match(view.out, /^ {2}chief: chief-of-staff \(launcher\) vars \{"harness":"pi","model":"glm-5\.3-flash","thinking":"high"\}$/m);
|
||||
assert.match(view.out, /^definitions add and set accept: .*\bmerge-gate\b.*\bsecurity-reviewer\b/m);
|
||||
assert.match(view.out, /^definitions that declare credentials, a hand edit: .*\breviewer \(gitea, vikunja\)/m);
|
||||
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--launch", "--revision", await s.revision()]);
|
||||
|
||||
@@ -72,6 +72,9 @@ test('REQ-LIMIT-4 the launcher reaches token-limit only through the bus: launche
|
||||
const src=readFileSync(fileURLToPath(new URL('../src/launcher.mjs',import.meta.url)),'utf8');
|
||||
assert.doesNotMatch(src,/token-limit/);
|
||||
assert.doesNotMatch(src,/limit-check\//);
|
||||
assert.doesNotMatch(src,/plugins\/src\/loader/);
|
||||
// Row 65: the launcher reaches its harness through the plug-in loader, and every load names only
|
||||
// the harness kind, so a limit check still can't load here.
|
||||
assert.deepEqual([...src.matchAll(/\.load\(([^)]*)\)/g)].map(m=>m[1]),['{ harness: [m.harness] }']);
|
||||
assert.doesNotMatch(src,/modules\(\s*["'](?!harness["'])/);
|
||||
assert.doesNotMatch(src,/\bimport\s*\(/);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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=>{
|
||||
for(const vars of [route('sparse','claude-sonnet-5-5','high'),{...route('down','claude-sonnet-5-5','high'),...fallback('sparse','glm-5.3-flash','high')}]){
|
||||
const s=await setup(t,vars);
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'thinking-unsupported'});
|
||||
assert.equal(s.last().why,'harness sparse takes thinking low, not high');
|
||||
assert.deepEqual(s.may(),[]);
|
||||
}
|
||||
// claude-code has no off: its module's own levels refuse it.
|
||||
const s=await setup(t,route('claude-code','claude-sonnet-5-5','off'));
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'thinking-unsupported'});
|
||||
assert.equal(s.last().why,'harness claude-code takes thinking low, medium, high, xhigh, max, not off');
|
||||
});
|
||||
@@ -20,9 +20,9 @@ import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { claudeVersion, createLauncher } from "../src/launcher.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { mockAnthropic, REPO } from "../../harness/tests/helpers.mjs";
|
||||
import { fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const HOST = fileURLToPath(new URL("./fixtures/launch-host.mjs", import.meta.url));
|
||||
@@ -31,9 +31,9 @@ const skip = sandboxProblem() ?? false;
|
||||
// chief opus, coder and reviewer sonnet, one of each family; cto is listed but
|
||||
// runs the system model, which is in no family. The chief of staff is the launcher.
|
||||
function edit(doc) {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" };
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.coder.vars.model = "claude-sonnet-5-5";
|
||||
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5" };
|
||||
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
doc.launch = launchDoc("chief",["coder","reviewer","cto"],{opus:1,sonnet:1});
|
||||
doc.launch.limits.models.zai["glm-5.3-flash"]=0;
|
||||
return doc;
|
||||
@@ -81,7 +81,9 @@ function prepare(t, more = (doc) => doc) {
|
||||
}
|
||||
|
||||
// options.wrap(host) is what the launcher sees, so a test can change one of the host's answers.
|
||||
async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, more), { wrap = (h) => h, ...options } = {}) {
|
||||
// options.harnesses is fakeHarnesses' map (row 65): both modules run the fake adapter by default.
|
||||
async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, more), { wrap = (h) => h, harnesses = { pi: adapter, "claude-code": adapter }, ...options } = {}) {
|
||||
const makeLoader = await fakeHarnesses(t, harnesses);
|
||||
const system = loadSystem({ env: f.env });
|
||||
const boot = bootConfig({ system, businessId: "acme", env: f.env });
|
||||
const logs = [];
|
||||
@@ -92,9 +94,9 @@ async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, m
|
||||
host: wrap(host),
|
||||
system,
|
||||
env: f.env,
|
||||
adapters: { pi: adapter, "claude-code": adapter },
|
||||
makeLoader,
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
versions: { pi: "0.85.1", claude: null },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [FAKE],
|
||||
log: (l) => logs.push(l),
|
||||
...options,
|
||||
@@ -166,8 +168,10 @@ test("bus start --pm: the launcher instance (the chief of staff) runs under its
|
||||
assert.equal(session.gitSocket, null);
|
||||
assert.equal(statSync(gitSocketPath(f.dataRoot)).mode & 0o777, 0o600);
|
||||
const manifest = JSON.parse(readFileSync(join(runDir, "bundle", "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.piVersion, "0.85.1");
|
||||
assert.equal(manifest.claudeVersion, null);
|
||||
// Row 65: one version, the route's harness, and the route the launch took.
|
||||
assert.equal(manifest.harnessVersion, "0.85.1");
|
||||
assert.deepEqual([manifest.route, manifest.routeReason], ["primary", null]);
|
||||
assert.ok(!("piVersion" in manifest) && !("claudeVersion" in manifest));
|
||||
const [launched] = log(f).filter((e) => e.event === "launch");
|
||||
assert.equal(launched.run, chief.run);
|
||||
assert.equal(launched.manifest, join(runDir, "bundle", "manifest.json"));
|
||||
@@ -298,7 +302,7 @@ test("the chief of staff launches a coder through its launch tool; the coder ans
|
||||
// tool socket; the request grants no launch, and the chief's launches stay inside the organization's limits.
|
||||
test("a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold", { skip, timeout: 60000 }, async (t) => {
|
||||
const { f, launcher, client, cto } = await setup(t, (doc) => {
|
||||
doc.roles.pm.vars = { harness: "pi", model: "claude-sonnet-5-5" };
|
||||
doc.roles.pm.vars = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
doc.launch.instances.push("pm");
|
||||
doc.launch.limits.models.zai["claude-sonnet-5-5"] = 2;
|
||||
return doc;
|
||||
@@ -667,6 +671,33 @@ test("a pi launch needs no session credential: pi logs in through its own ENV_AL
|
||||
assert.deepEqual(log(f).map((e) => [e.event, e.instance]), [["launch", "chief"]]);
|
||||
});
|
||||
|
||||
// Row 65: a harness module no core file names, switched on in the organization file, runs a real
|
||||
// sandboxed session. The chief's primary harness fails its preflight, so the launch takes the
|
||||
// stated fallback, and the manifest and the launch log record the route and the reason.
|
||||
test("row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason", { skip, timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t, (doc) => {
|
||||
doc.roles.chief.vars = { harness: "down", model: "claude-opus-5-5", thinking: "high", "fallback.harness": "fixture", "fallback.model": "claude-sonnet-5-5", "fallback.thinking": "low" };
|
||||
doc.plugins = { ...doc.plugins, harness: [...doc.plugins.harness, "down", "fixture"] };
|
||||
return doc;
|
||||
});
|
||||
const makeLoader = await fakeHarnesses(t, {}, (write) => {
|
||||
const pi = JSON.stringify(new URL("../../plugins/harness/pi/index.mjs", import.meta.url).href);
|
||||
write("harness", "down", `export * from ${pi};\nexport const preflight = () => ({ code: "fixture-down", message: "fixture preflight fails" });\n`);
|
||||
write("harness", "fixture", `export * from ${pi};\nexport const adapter = () => ${JSON.stringify(given.adapter)};\n`);
|
||||
});
|
||||
const { f, launcher } = await setup(t, undefined, given, { makeLoader });
|
||||
const chief = await launcher.launchPm();
|
||||
assert.deepEqual({ ...chief, run: undefined }, { run: undefined, instance: "chief", harness: "fixture", model: "claude-sonnet-5-5" });
|
||||
const [s] = readSessions(f.dataRoot);
|
||||
const manifest = JSON.parse(readFileSync(join(s.runDir, "bundle", "manifest.json"), "utf8"));
|
||||
assert.deepEqual([manifest.harness, manifest.model, manifest.thinking, manifest.route, manifest.routeReason], ["fixture", "claude-sonnet-5-5", "low", "fallback", "fixture-down"]);
|
||||
const [launched] = log(f).filter((e) => e.event === "launch");
|
||||
assert.deepEqual([launched.harness, launched.model, launched.route, launched.routeReason], ["fixture", "claude-sonnet-5-5", "fallback", "fixture-down"]);
|
||||
// The session runs on the fixture's adapter: the fake adapter claims the run.
|
||||
const runnerLog = join(s.runDir, "runner.log");
|
||||
await until(() => existsSync(runnerLog) && readFileSync(runnerLog, "utf8").includes("claimed by run"));
|
||||
});
|
||||
|
||||
// The runner is pid 1 in its namespace and ignores a SIGKILL sent from
|
||||
// inside it, so the host kills it by its host pid. bwrap then exits 137,
|
||||
// which the launcher reads back as the signal.
|
||||
@@ -722,8 +753,8 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything
|
||||
mkdirSync(join(p, ".."), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(p, "FAKE_TOKEN=stand-in-value\n", { mode: 0o600 });
|
||||
}
|
||||
const claudeAdapter = join(REPO, "adapters", "claude", "adapter.sh");
|
||||
const { f, launcher, client } = await setup(t, undefined, given, { adapters: { pi: given.adapter, "claude-code": claudeAdapter } });
|
||||
// The real claude-code module, so its own adapter and binary check (row 65).
|
||||
const { f, launcher, client } = await setup(t, undefined, given, { harnesses: { pi: given.adapter, "claude-code": null } });
|
||||
const chief = await launcher.launchPm();
|
||||
const chiefRun = readSessions(f.dataRoot).find((s) => s.run === chief.run).runDir;
|
||||
|
||||
@@ -915,20 +946,3 @@ test("a runner that ignores SIGTERM is killed when the host closes", { skip, tim
|
||||
assert.equal(ends.length, 1);
|
||||
assert.equal(ends[0].signal, "SIGKILL");
|
||||
});
|
||||
|
||||
// Row 68: the manifest's claudeVersion asks the binary sessions run.
|
||||
test("claudeVersion asks MOSAIC_CLAUDE_BIN when set, else claude on PATH", (t) => {
|
||||
const dir = tmp(t);
|
||||
mkdirSync(join(dir, "bin"));
|
||||
const stand = (p, v) => {
|
||||
writeFileSync(p, `#!/bin/sh\necho "${v} (Claude Code)"\n`);
|
||||
chmodSync(p, 0o755);
|
||||
return p;
|
||||
};
|
||||
stand(join(dir, "bin", "claude"), "1.0.0");
|
||||
const pinned = stand(join(dir, "pinned"), "9.9.9");
|
||||
const PATH = join(dir, "bin");
|
||||
assert.equal(claudeVersion({ PATH, MOSAIC_CLAUDE_BIN: pinned }), "9.9.9 (Claude Code)");
|
||||
assert.equal(claudeVersion({ PATH }), "1.0.0 (Claude Code)");
|
||||
assert.equal(claudeVersion({ PATH: join(dir, "none") }), null);
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import { launchLogFile, readSessions, SESSION_CREDENTIALS } from "../../seat/src
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -50,8 +50,8 @@ function gitIn(cwd, ...args) {
|
||||
function prepare(t, { sonnet = 2, idle = 1, edit = (doc) => doc } = {}) {
|
||||
const root = tmp(t, "mosaic-thread-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" };
|
||||
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5" } };
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
||||
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet });
|
||||
doc.launch.threadIdleSeconds = idle;
|
||||
return edit(doc);
|
||||
@@ -116,9 +116,9 @@ async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
|
||||
host,
|
||||
system,
|
||||
env: f.env,
|
||||
adapters: { pi: adapter, "claude-code": adapter },
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
versions: { pi: "0.85.1", claude: null },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [ADAPTER],
|
||||
threadTickMs: tickMs,
|
||||
log: (l) => logs.push(l),
|
||||
@@ -394,7 +394,7 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
|
||||
// bus's plug-in loader.
|
||||
const SINCE = "2026-10-18T00:00:00.000Z";
|
||||
const atLimit = (doc) => {
|
||||
doc.plugins = { "limit-check": ["token-limit"] };
|
||||
doc.plugins = { ...doc.plugins, "limit-check": ["token-limit"] };
|
||||
doc.launch.limits.tokens = { instances: { assistant: { limit: 0, since: SINCE } } };
|
||||
return doc;
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ passing it, and shows the memory reaching the model without it.
|
||||
|
||||
## The bundle
|
||||
|
||||
`buildBundle({dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion})`
|
||||
`buildBundle({dir, resolved, contract, arbiter, tracker, credentials, session, harness, route, routeReason, harnessVersion, models, land})`
|
||||
writes the bundle once into `dir` (0700; every file 0600, created with `wx`,
|
||||
so an existing bundle is never overwritten):
|
||||
|
||||
@@ -50,16 +50,25 @@ so an existing bundle is never overwritten):
|
||||
status the host read from the broker (metadata only, never a token).
|
||||
- `tools.json`: the typed tools (below).
|
||||
- `manifest.json`: bundle version, the instance and run, the resolved
|
||||
digest, harness, provider, model, thinking, the Pi or Claude Code version, built-in and
|
||||
digest, harness, provider, model, thinking, the route taken (`primary` or
|
||||
`fallback`) and why (`routeReason`), the harness module's `version()`
|
||||
(`harnessVersion`), built-in and
|
||||
typed tools, the actions without a typed tool, the S0 lines, the gate
|
||||
timeout, and the sha256 of every bundle file and of the gate, extension,
|
||||
MCP server and tools sources.
|
||||
- Claude Code only: `settings.json` (the wrapped PreToolUse hook, matcher
|
||||
`*`) and `mcp.json` (one stdio server, `mosaic`, serving the typed tools).
|
||||
|
||||
`sessionModel(resolved)` picks the harness, provider and model: the agent
|
||||
layer's `harness` and `model` variables win, else the system's execution
|
||||
adapter and model. Only `pi` and `claude-code` are accepted.
|
||||
`harness` is the route's module from `packages/plugins/harness/`, which the
|
||||
launcher loaded; the bundle calls it for its own files and names and never
|
||||
names a harness itself (row 65). `sessionModel(resolved, models, which)`
|
||||
reads one route from the instance's vars only: `harness`, `model` and
|
||||
`thinking` for `primary`, the three `fallback.` vars for `fallback`
|
||||
(`ROUTE_VARS`). A missing var throws `no-route` and names it; the system's
|
||||
`execution.adapter` and `execution.model` never fill one, and stay for
|
||||
`run-task.sh`. `model` is a `launch.models` key, and the entry gives the
|
||||
provider and model. `hasFallback(resolved)` says whether the instance
|
||||
states a fallback.
|
||||
|
||||
## Typed tools
|
||||
|
||||
@@ -306,8 +315,9 @@ These are limits, not bugs, and nothing in this package claims otherwise.
|
||||
and ends those launches as `host-lost` (`packages/cli/README.md`,
|
||||
"Sessions").
|
||||
- **Resolution** runs without the project layer, there is no skills source
|
||||
(bundles list none), and the resolved `thinking` level is recorded in the
|
||||
manifest but not applied to either harness.
|
||||
(bundles list none). The route's `thinking` level reaches the adapter as
|
||||
`MOSAIC_THINKING`: pi gets `--thinking`, Claude Code `--effort`
|
||||
(`adapters/README.md`).
|
||||
- **The reply cap is per reply.** Claude Code also persists the largest
|
||||
results of one message when their total passes 200000 characters, so
|
||||
six or more replies near the cap in one turn can still be saved to a
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
// The launch bundle: everything a managed session runs from, generated
|
||||
// from one resolved role instance and written once into the run
|
||||
// directory. prompt.md, policy.json, tools.json and manifest.json for both
|
||||
// harnesses; settings.json (the gate hook) and mcp.json (the typed tools)
|
||||
// for Claude Code. The Pi extension and the Claude gate stay at their
|
||||
// source paths; the manifest records their digests.
|
||||
// directory. prompt.md, policy.json, tools.json and manifest.json for every
|
||||
// harness, plus the files the session's harness module writes (row 65: for
|
||||
// claude-code, settings.json with the gate hook and mcp.json with the typed
|
||||
// tools). The gate sources stay at their paths; the manifest records their
|
||||
// digests.
|
||||
//
|
||||
// Each harness layer the bundle relies on is a row S0 line
|
||||
// (docs/plans/2026-10-04_slice-1.md, "What S6 can rely on"); the manifest
|
||||
// names them.
|
||||
// (docs/plans/2026-10-04_slice-1.md, "What S6 can rely on"); the module
|
||||
// names them and the manifest records them.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { typedTools, untypedActions } from "./tools.mjs";
|
||||
import { claudeBuiltins, MCP_PREFIX } from "./gate.mjs";
|
||||
|
||||
export const BUNDLE_VERSION = 1;
|
||||
// The role file's "web" is a grant for the broker's typed web tools, not a harness built-in. The
|
||||
// built-ins a harness gets are the rest, so claude-code's own WebSearch and WebFetch are never
|
||||
// on (its --tools list doesn't name them) and pi has no web tool of its own.
|
||||
// built-ins a harness gets are the rest, so a harness's own web tools are never on without the
|
||||
// web source below.
|
||||
// The web source for a session (REQ-HARN-2, Q162, decision 0b81b0c5): the search-source plug-in the
|
||||
// organization file switches on, resolved.webSource. "harness-builtin"
|
||||
// switches the harness's own web tools on for a granted role; claude-code has them, pi doesn't, so
|
||||
// a granted pi role under it is refused rather than left with no tool. Every other role is
|
||||
// unaffected: the built-ins are never on without the grant.
|
||||
export function webPlan(resolved, harness) {
|
||||
// switches the harness's own web tools on for a granted role; a harness whose module has none
|
||||
// (builtinWeb() false) refuses a granted role rather than leaving it with no tool. Every other
|
||||
// role is unaffected: the built-ins are never on without the grant.
|
||||
export function webPlan(resolved, harness, builtinWeb) {
|
||||
const source = resolved.webSource ?? null;
|
||||
const granted = resolved.limits.tools.includes("web");
|
||||
if (granted && source === "harness-builtin" && harness !== "claude-code") {
|
||||
if (granted && source === "harness-builtin" && !builtinWeb) {
|
||||
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on ${harness}`);
|
||||
}
|
||||
return { source, builtin: granted && source === "harness-builtin" };
|
||||
}
|
||||
export const builtinTools = (tools) => tools.filter((t) => t !== "web");
|
||||
export const HARNESSES = Object.freeze(["pi", "claude-code"]);
|
||||
export const SOURCES = Object.freeze({
|
||||
piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)),
|
||||
claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)),
|
||||
@@ -42,17 +41,14 @@ export const SOURCES = Object.freeze({
|
||||
gate: fileURLToPath(new URL("./gate.mjs", import.meta.url)),
|
||||
tools: fileURLToPath(new URL("./tools.mjs", import.meta.url)),
|
||||
});
|
||||
// S0 lines 1-5 for both harnesses; line 6 is the Agent SDK, which S6
|
||||
// doesn't use.
|
||||
const RELIES_ON = Object.freeze({
|
||||
pi: ["S0-1 tool_call block", "S0-2 a throw blocks", "S0-3 a missing -e refuses to start", "S0-4 external wall clock plus agent_end check", "S0-5 bash is limited by the tool limit only"],
|
||||
"claude-code": ["S0-1 command hook deny, no --bare", "S0-2 hook wrapped as <gate> || exit 2", "S0-3 a missing gate blocks through the wrapper", "S0-4 timeout -k 2 10 <gate> || exit 2 with hook timeout 20", "S0-5 bash is limited by the tool limit only"],
|
||||
});
|
||||
// The Claude hook: N = 10, K = 2, hook timeout 20, which leaves the
|
||||
// margin the brief asks for above N + K.
|
||||
const GATE_TIMEOUT = { n: 10, k: 2, hook: 20 };
|
||||
|
||||
export class BundleError extends Error {}
|
||||
// code: "no-route" names the missing route var in `missing`; anything else is "bundle-refused".
|
||||
export class BundleError extends Error {
|
||||
constructor(message, code = "bundle-refused", missing = null) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.missing = missing;
|
||||
}
|
||||
}
|
||||
|
||||
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
||||
const quote = (s) => {
|
||||
@@ -60,17 +56,28 @@ const quote = (s) => {
|
||||
return `'${s}'`;
|
||||
};
|
||||
|
||||
// The harness, provider and model one instance runs with. The agent-layer
|
||||
// `harness` and `model` variables win; otherwise the system's execution
|
||||
// adapter and model. Anything outside the two harnesses refuses.
|
||||
export function sessionModel(resolved, models = null) {
|
||||
// Row 65 (#1558): the vars a route takes, the instance's agent vars and nothing else. A session
|
||||
// never reads the system's execution.adapter or execution.model; those stay for run-task.sh.
|
||||
export const ROUTE_VARS = Object.freeze({
|
||||
primary: Object.freeze(["harness", "model", "thinking"]),
|
||||
fallback: Object.freeze(["fallback.harness", "fallback.model", "fallback.thinking"]),
|
||||
});
|
||||
export const hasFallback = (resolved) => Object.hasOwn(resolved.vars, "fallback.harness");
|
||||
|
||||
// The harness, provider, model and thinking one instance runs with on a route, "primary" or
|
||||
// "fallback". A missing var refuses no-route and names it. models is launch.models: the model var
|
||||
// is one of its keys, and the entry gives the provider and model; without a table the var is the
|
||||
// model and the provider is unknown.
|
||||
export function sessionModel(resolved, models = null, which = "primary") {
|
||||
const v = resolved.vars;
|
||||
const harness = v.harness ?? v["execution.adapter"];
|
||||
if (!HARNESSES.includes(harness)) throw new BundleError(`instance ${resolved.instance}: harness ${JSON.stringify(harness)} isn't one of ${HARNESSES.join(", ")}`);
|
||||
const model = v.model ?? v["execution.model"];
|
||||
if (typeof model !== "string" || !model) throw new BundleError(`instance ${resolved.instance}: no model`);
|
||||
if(models!==null){const entry=Object.hasOwn(models,model)?models[model]:null;if(!entry)throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`);return {harness,provider:entry.provider,model:entry.model,modelKey:model,thinking:v.thinking??null};}
|
||||
return { harness, provider: v["execution.provider"] ?? null, model, thinking: v.thinking ?? null };
|
||||
const [harness, model, thinking] = ROUTE_VARS[which].map((name) => {
|
||||
if (typeof v[name] !== "string" || !v[name]) throw new BundleError(`instance ${resolved.instance}: no ${name} var`, "no-route", name);
|
||||
return v[name];
|
||||
});
|
||||
if (models === null) return { harness, provider: null, model, thinking };
|
||||
const entry = Object.hasOwn(models, model) ? models[model] : null;
|
||||
if (!entry) throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`);
|
||||
return { harness, provider: entry.provider, model: entry.model, modelKey: model, thinking };
|
||||
}
|
||||
|
||||
// Row 91 (#1585): the project clones the host made or kept in the workspace.
|
||||
@@ -90,7 +97,7 @@ function checkoutLines(checkouts) {
|
||||
return [`- Project clones: ${checkouts.map(one).join("; ")}. Each is a git clone of the project. At a launch the host fetches each clone it can safely run git in from its origin, and fast-forwards only a clean clone on the root's branch, never during a session; the origin is outside this sandbox. Don't set git config in a clone. Any key that \`git clone\` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. Your commits already carry your role instance's name and address from the environment.`];
|
||||
}
|
||||
|
||||
function prompt({ contract, resolved, session, tools, untyped }) {
|
||||
function prompt({ contract, resolved, session, tools, untyped, builtins }) {
|
||||
const lines = [
|
||||
contract.trimEnd(),
|
||||
"",
|
||||
@@ -101,7 +108,7 @@ function prompt({ contract, resolved, session, tools, untyped }) {
|
||||
`- Workspace: ${session.workspace}. File tools work inside it only.`,
|
||||
...checkoutLines(session.checkouts ?? []),
|
||||
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
|
||||
`- Built-in tools: ${(session.harness === "pi" ? builtinTools(resolved.limits.tools) : claudeBuiltins({ tools: builtinTools(resolved.limits.tools), webBuiltin: session.webBuiltin })).join(", ") || "none"}.`,
|
||||
`- Built-in tools: ${builtins.join(", ") || "none"}.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
|
||||
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
|
||||
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
|
||||
@@ -130,7 +137,7 @@ function prompt({ contract, resolved, session, tools, untyped }) {
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion, land })
|
||||
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, harness, route, routeReason, harnessVersion, models, land })
|
||||
// dir the bundle directory, created here (0700)
|
||||
// resolved resolveInstance(...) output
|
||||
// contract the role contract's text
|
||||
@@ -140,13 +147,18 @@ function prompt({ contract, resolved, session, tools, untyped }) {
|
||||
// session { run, launchedBy, workspace, toolSocket, turnMarker, checkouts }
|
||||
// checkouts is ensureCheckouts(...) output (packages/seat/src/checkout.mjs),
|
||||
// named in the prompt; none when absent
|
||||
// piVersion the pinned pi version, for the manifest
|
||||
// claudeVersion the host's `claude --version`, for the manifest
|
||||
// harness the route's harness module, as the plug-in loader loaded it (row 65)
|
||||
// route "primary" or "fallback"; routeReason is the primary's preflight
|
||||
// refusal code that chose the fallback, or null
|
||||
// harnessVersion the module's version(), for the manifest
|
||||
// models launch.models
|
||||
// land true when the role and thread hold a merge decision to land (row 137)
|
||||
// Returns { manifest, files } with absolute paths.
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null, models = null, land = false }) {
|
||||
const model = sessionModel(resolved, models);
|
||||
const web = webPlan(resolved, model.harness);
|
||||
// Returns { manifest, files, policy, tools, env } with absolute paths; env is the module's
|
||||
// adapter variables, which the host writes into session.json.
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false }) {
|
||||
if (route !== "primary" && route !== "fallback") throw new BundleError(`route ${JSON.stringify(route)} isn't primary or fallback`);
|
||||
const model = sessionModel(resolved, models, route);
|
||||
const web = webPlan(resolved, model.harness, harness.builtinWeb());
|
||||
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
|
||||
const untyped = untypedActions(resolved, tools);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
@@ -170,28 +182,17 @@ export function buildBundle({ dir, resolved, contract, arbiter = false, tracker
|
||||
tools: join(dir, "tools.json"),
|
||||
manifest: join(dir, "manifest.json"),
|
||||
};
|
||||
const builtins = harness.builtins({ tools: policy.tools, webBuiltin: web.builtin });
|
||||
const digests = [
|
||||
write("prompt.md", prompt({ contract, resolved, session: { ...session, ...model, webBuiltin: web.builtin }, tools, untyped })),
|
||||
write("prompt.md", prompt({ contract, resolved, session: { ...session, ...model, webBuiltin: web.builtin }, tools, untyped, builtins })),
|
||||
write("policy.json", JSON.stringify(policy, null, 2) + "\n"),
|
||||
write("tools.json", JSON.stringify(tools, null, 2) + "\n"),
|
||||
];
|
||||
const sources = { gate: SOURCES.gate, tools: SOURCES.tools };
|
||||
if (model.harness === "pi") {
|
||||
sources.piExtension = SOURCES.piExtension;
|
||||
} else {
|
||||
sources.claudeGate = SOURCES.claudeGate;
|
||||
sources.mcpServer = SOURCES.mcpServer;
|
||||
files.settings = join(dir, "settings.json");
|
||||
files.mcp = join(dir, "mcp.json");
|
||||
const node = process.execPath;
|
||||
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(SOURCES.claudeGate)} ${quote(files.policy)} || exit 2`;
|
||||
digests.push(write("settings.json", JSON.stringify({
|
||||
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
|
||||
}, null, 2) + "\n"));
|
||||
digests.push(write("mcp.json", JSON.stringify({
|
||||
mcpServers: { mosaic: { type: "stdio", command: node, args: [SOURCES.mcpServer, files.tools, session.toolSocket] } },
|
||||
}, null, 2) + "\n"));
|
||||
}
|
||||
// The module's own files. A name the bundle already wrote refuses at write (flag wx).
|
||||
const own = harness.bundle({ dir, files: { ...files }, session, write, quote });
|
||||
Object.assign(files, own.files);
|
||||
digests.push(...own.digests);
|
||||
const sources = { gate: SOURCES.gate, tools: SOURCES.tools, ...own.sources };
|
||||
const manifest = {
|
||||
bundleVersion: BUNDLE_VERSION,
|
||||
business: resolved.business,
|
||||
@@ -201,18 +202,20 @@ export function buildBundle({ dir, resolved, contract, arbiter = false, tracker
|
||||
launchedBy: session.launchedBy,
|
||||
resolvedDigest: resolved.digest,
|
||||
...model,
|
||||
piVersion: model.harness === "pi" ? piVersion : null,
|
||||
claudeVersion: model.harness === "claude-code" ? claudeVersion : null,
|
||||
builtins: model.harness === "pi" ? [...policy.tools] : claudeBuiltins(policy),
|
||||
typed: tools.map((t) => (model.harness === "pi" ? t.name : MCP_PREFIX + t.name)),
|
||||
route,
|
||||
routeReason,
|
||||
harnessVersion,
|
||||
builtins,
|
||||
typed: tools.map((t) => harness.typedName(t.name)),
|
||||
untyped,
|
||||
webSource: resolved.limits.tools.includes("web") ? web.source : null,
|
||||
skills: [],
|
||||
reliesOn: RELIES_ON[model.harness],
|
||||
gateTimeout: model.harness === "claude-code" ? GATE_TIMEOUT : null,
|
||||
reliesOn: [...harness.reliesOn()],
|
||||
gateTimeout: harness.gateTimeout(),
|
||||
files: Object.fromEntries(digests),
|
||||
sources: Object.fromEntries(Object.entries(sources).map(([k, p]) => [k, { path: p, sha256: sha256(readFileSync(p)) }])),
|
||||
};
|
||||
writeFileSync(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
||||
return { manifest, files, policy, tools };
|
||||
const env = harness.env({ files, policy, tools, session: { ...session, ...model } });
|
||||
return { manifest, files, policy, tools, env };
|
||||
}
|
||||
@@ -32,8 +32,6 @@ import { createServer, connect } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { SOURCES } from "./bundle.mjs";
|
||||
import { claudeBuiltins } from "./gate.mjs";
|
||||
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
|
||||
|
||||
export const EXIT = Object.freeze({ stopped: 0, founder: 20, claim: 21, session: 22, broker: 23, usage: 2 });
|
||||
@@ -248,19 +246,23 @@ function runTurn({ session, request, n, env, onChild }) {
|
||||
onChild(null);
|
||||
if (timedOut) return resolve({ ok: false, reason: `turn passed its ${session.turnTimeout} s wall clock` });
|
||||
if (code !== 0) return resolve({ ok: false, reason: signal ? `adapter killed by ${signal}` : `adapter exited ${code}` });
|
||||
if (session.harness === "pi" && !existsSync(marker)) return resolve({ ok: false, reason: "pi exited before the turn ended (no agent_end)" });
|
||||
// A "marker" harness (row 65: the module's turnEnd()) writes the marker when its turn ends.
|
||||
if (session.turnEnd === "marker" && !existsSync(marker)) return resolve({ ok: false, reason: `${session.harness} exited before the turn ended (no turn marker)` });
|
||||
resolve({ ok: true, text: out.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The adapter's environment: the host's allowlisted environment plus the
|
||||
// bundle. Nothing here is a credential.
|
||||
export const harnessOk = (s) => (s.turnEnd === "marker" || s.turnEnd === "exit")
|
||||
&& s.harnessEnv !== null && typeof s.harnessEnv === "object" && !Array.isArray(s.harnessEnv)
|
||||
&& Object.values(s.harnessEnv).every((v) => typeof v === "string");
|
||||
|
||||
// The adapter's environment: the host's allowlisted environment, the bundle,
|
||||
// and the harness module's own variables, which the host computed into
|
||||
// session.harnessEnv (row 65). Nothing here is a credential.
|
||||
export function adapterEnv(session, env) {
|
||||
const b = session.bundle;
|
||||
const tools = JSON.parse(readFileSync(b.tools, "utf8"));
|
||||
const policy = JSON.parse(readFileSync(b.policy, "utf8"));
|
||||
const out = {
|
||||
return {
|
||||
...env,
|
||||
MOSAIC_SYSTEM_PROMPT_FILE: b.prompt,
|
||||
MOSAIC_WORKSPACE: session.workspace,
|
||||
@@ -271,18 +273,8 @@ export function adapterEnv(session, env) {
|
||||
MOSAIC_TURN_MARKER: session.turnMarker,
|
||||
MOSAIC_PROVIDER: session.provider ?? "",
|
||||
MOSAIC_MODEL: session.model,
|
||||
...session.harnessEnv,
|
||||
};
|
||||
if (session.harness === "pi") {
|
||||
out.MOSAIC_TOOLS = [...policy.tools, ...tools.map((t) => t.name)].join(",");
|
||||
out.MOSAIC_EXTENSIONS = SOURCES.piExtension;
|
||||
out.PI_PROVIDER = session.provider ?? "";
|
||||
out.PI_MODEL = session.model;
|
||||
} else {
|
||||
out.MOSAIC_TOOLS = claudeBuiltins(policy).join(",");
|
||||
out.MOSAIC_CLAUDE_SETTINGS = b.settings;
|
||||
out.MOSAIC_CLAUDE_MCP_CONFIG = b.mcp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function main(runDir, { stdin = process.stdin, env = process.env, log = (s) => process.stderr.write(s + "\n") } = {}) {
|
||||
@@ -324,6 +316,12 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
log("runner: malformed landing entry");
|
||||
return EXIT.usage;
|
||||
}
|
||||
// Row 65: the harness module's answers. A session.json without them refuses rather than
|
||||
// guessing a turn-end rule or an environment.
|
||||
if (!harnessOk(session)) {
|
||||
log("runner: session.json has no turnEnd of marker or exit, or no harnessEnv of strings");
|
||||
return EXIT.usage;
|
||||
}
|
||||
if (stopping) {
|
||||
log("runner: stopped before claim");
|
||||
return EXIT.stopped;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.mjs";
|
||||
import { resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { HARNESS, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { decide } from "../src/gate.mjs";
|
||||
|
||||
const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex");
|
||||
@@ -21,18 +21,32 @@ function build(t, resolved, extra = {}) {
|
||||
tracker: true,
|
||||
credentials: [{ service: "gitea", date: "2099-01-01", state: "valid" }],
|
||||
session: { run: "run-1", launchedBy: "pm", workspace, toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done") },
|
||||
piVersion: "0.85.1",
|
||||
claudeVersion: "2.1.0 (Claude Code)",
|
||||
harness: HARNESS[sessionModel(resolved, null, extra.route ?? "primary").harness],
|
||||
harnessVersion: "fixture-version",
|
||||
...extra,
|
||||
});
|
||||
return { dir, workspace, ...bundle };
|
||||
}
|
||||
|
||||
test("sessionModel: agent vars win, then the system's execution settings", (t) => {
|
||||
assert.deepEqual(sessionModel(resolvedFor(t, "pm")), { harness: "pi", provider: "zai", model: "claude-opus-5-5", thinking: null });
|
||||
assert.deepEqual(sessionModel(resolvedFor(t, "coder")), { harness: "pi", provider: "zai", model: "glm-5.3-flash", thinking: null });
|
||||
// The test system's adapter is "mock", which isn't a session harness.
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "reviewer")), (e) => e instanceof BundleError && /harness "mock"/.test(e.message));
|
||||
// Row 65: a session's harness, model and thinking are its own three vars; the system's
|
||||
// execution.adapter and execution.model are never read, and a missing var refuses no-route by name.
|
||||
test("sessionModel: the route is the instance's own vars, and a missing one refuses no-route by name", (t) => {
|
||||
assert.deepEqual(sessionModel(resolvedFor(t, "pm")), { harness: "pi", provider: null, model: "claude-opus-5-5", thinking: "high" });
|
||||
const models = { "claude-opus-5-5": { provider: "anthropic", model: "claude-opus-5-5" } };
|
||||
assert.deepEqual(sessionModel(resolvedFor(t, "pm"), models), { harness: "pi", provider: "anthropic", model: "claude-opus-5-5", modelKey: "claude-opus-5-5", thinking: "high" });
|
||||
for (const missing of ["harness", "model", "thinking"]) {
|
||||
const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high" }), { routes: false });
|
||||
const without = { ...r, vars: Object.fromEntries(Object.entries(r.vars).filter(([k]) => k !== missing)) };
|
||||
assert.throws(() => sessionModel(without), (e) => e instanceof BundleError && e.code === "no-route" && e.missing === missing && e.message.includes(`no ${missing} var`), missing);
|
||||
}
|
||||
// The reviewer sets no vars; the test system's execution.adapter and execution.model don't fill in.
|
||||
const reviewer = resolvedFor(t, "reviewer", null, { routes: false });
|
||||
assert.throws(() => sessionModel(reviewer), (e) => e.code === "no-route" && e.missing === "harness");
|
||||
// The fallback route reads only the fallback vars, and is no-route when they are unset.
|
||||
const fb = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-opus-5-5", thinking: "max", "fallback.harness": "pi", "fallback.model": "glm-5.3-flash", "fallback.thinking": "minimal" }));
|
||||
assert.deepEqual(sessionModel(fb, null, "fallback"), { harness: "pi", provider: null, model: "glm-5.3-flash", thinking: "minimal" });
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "pm"), null, "fallback"), (e) => e.code === "no-route" && e.missing === "fallback.harness");
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "coder"), models), /unknown model table selection glm-5.3-flash/);
|
||||
});
|
||||
|
||||
test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory", (t) => {
|
||||
@@ -59,8 +73,8 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory"
|
||||
const m = b.manifest;
|
||||
assert.equal(m.harness, "pi");
|
||||
assert.equal(m.model, "claude-opus-5-5");
|
||||
assert.equal(m.piVersion, "0.85.1");
|
||||
assert.equal(m.claudeVersion, null);
|
||||
assert.equal(m.thinking, "high");
|
||||
assert.deepEqual([m.route, m.routeReason, m.harnessVersion], ["primary", null, "fixture-version"]);
|
||||
assert.equal(m.gateTimeout, null);
|
||||
assert.deepEqual(m.skills, []);
|
||||
assert.equal(m.reliesOn.length, 5);
|
||||
@@ -125,8 +139,7 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) =
|
||||
const mcp = JSON.parse(readFileSync(b.files.mcp, "utf8"));
|
||||
assert.deepEqual(mcp, { mcpServers: { mosaic: { type: "stdio", command: process.execPath, args: [SOURCES.mcpServer, b.files.tools, join(b.dir, "tools.sock")] } } });
|
||||
const m = b.manifest;
|
||||
assert.equal(m.piVersion, null);
|
||||
assert.equal(m.claudeVersion, "2.1.0 (Claude Code)");
|
||||
assert.equal(m.harnessVersion, "fixture-version");
|
||||
assert.deepEqual(m.gateTimeout, { n: 10, k: 2, hook: 20 });
|
||||
assert.ok(m.builtins.includes("Bash"));
|
||||
assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__")));
|
||||
@@ -215,6 +228,7 @@ test("a bundle is written once: an existing file refuses", (t) => {
|
||||
resolved: resolvedFor(t, "pm"),
|
||||
contract: "c",
|
||||
session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") },
|
||||
harness: HARNESS.pi,
|
||||
}), /EEXIST/);
|
||||
assert.equal(readFileSync(join(dir, "bundle", "prompt.md"), "utf8"), "old");
|
||||
});
|
||||
@@ -227,6 +241,7 @@ test("a path with a single quote can't go into the hook command", (t) => {
|
||||
resolved: r,
|
||||
contract: "c",
|
||||
session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") },
|
||||
harness: HARNESS["claude-code"],
|
||||
}), (e) => e instanceof BundleError && /single quote/.test(e.message));
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, w
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { buildBundle } from "../src/bundle.mjs";
|
||||
import { adapterEnv } from "../src/runner.mjs";
|
||||
import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = join(REPO, "adapters", "claude", "adapter.sh");
|
||||
const which = spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" });
|
||||
@@ -42,8 +42,9 @@ async function session(t, script, tools = ["read", "bash"]) {
|
||||
toolSocket: sock.path,
|
||||
turnMarker: join(dir, "turn.done"),
|
||||
};
|
||||
const { files, manifest } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", session: { ...s, run: "run-1", launchedBy: "pm" } });
|
||||
const { files, manifest, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", harness: HARNESS["claude-code"], session: { ...s, run: "run-1", launchedBy: "pm" } });
|
||||
s.bundle = files;
|
||||
s.harnessEnv = harnessEnv;
|
||||
const env = adapterEnv(s, {
|
||||
PATH: `${dirname(CLAUDE)}:${dirname(process.execPath)}:/usr/bin:/bin`,
|
||||
HOME: join(dir, "home"),
|
||||
@@ -157,6 +158,7 @@ test("claude adapter: --restricted is always passed", (t) => {
|
||||
MOSAIC_WORKSPACE: join(dir, "ws"),
|
||||
MOSAIC_SESSION_DIR: join(dir, "session"),
|
||||
MOSAIC_MODEL: "claude-sonnet-5-5",
|
||||
MOSAIC_THINKING: "high",
|
||||
MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"),
|
||||
MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"),
|
||||
},
|
||||
@@ -191,6 +193,7 @@ test("claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute e
|
||||
MOSAIC_WORKSPACE: join(dir, "ws"),
|
||||
MOSAIC_SESSION_DIR: join(dir, "session"),
|
||||
MOSAIC_MODEL: "claude-sonnet-5-5",
|
||||
MOSAIC_THINKING: "high",
|
||||
MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"),
|
||||
MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"),
|
||||
...(bin === undefined ? {} : { MOSAIC_CLAUDE_BIN: bin }),
|
||||
@@ -239,6 +242,7 @@ test("claude adapter: a directory pin exits 2 before it runs; a pin with a space
|
||||
MOSAIC_WORKSPACE: join(dir, "ws"),
|
||||
MOSAIC_SESSION_DIR: join(dir, "session"),
|
||||
MOSAIC_MODEL: "claude-sonnet-5-5",
|
||||
MOSAIC_THINKING: "high",
|
||||
MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"),
|
||||
MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"),
|
||||
MOSAIC_CLAUDE_BIN: bin,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// SLEEP hang until killed
|
||||
// FAIL exit 3
|
||||
// NOMARKER answer without writing the turn marker
|
||||
// ENV <name> answer "env: <value of name>"
|
||||
// anything else answer "echo: <request>"
|
||||
// Every turn appends its pid to $FAKE_PIDS when set.
|
||||
|
||||
@@ -29,6 +30,9 @@ if (body.startsWith("TOOL ")) {
|
||||
setInterval(() => {}, 1000);
|
||||
} else if (body === "FAIL") {
|
||||
process.exit(3);
|
||||
} else if (body.startsWith("ENV ")) {
|
||||
process.stdout.write(`env: ${process.env[body.slice(4)]}\n`);
|
||||
done();
|
||||
} else if (body === "NOMARKER") {
|
||||
process.stdout.write("half an answer");
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveInstance, validateBusinessDocument } from "../../business/src/index.mjs";
|
||||
import { businessDoc, REPO_ROLES, systemFor } from "../../business/tests/helpers.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
|
||||
export { REPO } from "../../business/tests/helpers.mjs";
|
||||
|
||||
@@ -19,11 +20,26 @@ export function scratch(t, prefix = "mosaic-harness-") {
|
||||
return dir;
|
||||
}
|
||||
|
||||
// resolved("pm" | "coder" | ..., { mutate }) in a scratch root.
|
||||
export function resolvedFor(t, instance, mutate) {
|
||||
// Row 65: the two harness modules, each through its own loader as the launcher loads them.
|
||||
async function harnessModule(name) {
|
||||
const loader = createLoader({ log() {} });
|
||||
const { refused } = await loader.load({ harness: [name] });
|
||||
if (refused.length) throw new Error(`harness ${name} refused: ${JSON.stringify(refused)}`);
|
||||
return loader.modules("harness")[0];
|
||||
}
|
||||
export const HARNESS = Object.freeze({ pi: await harnessModule("pi"), "claude-code": await harnessModule("claude-code") });
|
||||
|
||||
// A session's route is its own three vars (row 65); a role the test doesn't route runs pi on the
|
||||
// test system's old execution.model at thinking high, so the bundle tests keep their models.
|
||||
export const ROUTE_DEFAULTS = Object.freeze({ harness: "pi", model: "glm-5.3-flash", thinking: "high" });
|
||||
|
||||
// resolved("pm" | "coder" | ..., { mutate }) in a scratch root. routes: false leaves a role's
|
||||
// route vars as the business file has them.
|
||||
export function resolvedFor(t, instance, mutate, { routes = true } = {}) {
|
||||
const root = scratch(t, "mosaic-harness-biz-");
|
||||
const doc = businessDoc(root);
|
||||
mutate?.(doc);
|
||||
if (routes) for (const entry of Object.values(doc.roles)) entry.vars = { ...ROUTE_DEFAULTS, ...(entry.vars ?? {}) };
|
||||
const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
|
||||
return resolveInstance({ system: systemFor(root), business, instance });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { join } from "node:path";
|
||||
import { buildBundle } from "../src/bundle.mjs";
|
||||
import { adapterEnv } from "../src/runner.mjs";
|
||||
import { REPLY_CAP } from "../src/tools.mjs";
|
||||
import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
|
||||
|
||||
@@ -43,8 +43,10 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
|
||||
toolSocket: sock.path,
|
||||
turnMarker: join(dir, "turn.done"),
|
||||
};
|
||||
const { files } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# PM\n", tracker: true, session: { ...s, run: "run-1", launchedBy: "jason" } });
|
||||
const models = { "probe-model": { provider: "probe", model: "probe-model" } };
|
||||
const { files, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# PM\n", tracker: true, harness: HARNESS.pi, models, session: { ...s, run: "run-1", launchedBy: "jason" } });
|
||||
s.bundle = files;
|
||||
s.harnessEnv = harnessEnv;
|
||||
const env = adapterEnv(s, {
|
||||
PATH: `${join(REPO, "node_modules", ".bin")}:/usr/bin:/bin`,
|
||||
HOME: join(dir, "home"),
|
||||
|
||||
@@ -143,6 +143,8 @@ async function setup(t, { session = {}, policy = {}, tools, as = "coder", organi
|
||||
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
|
||||
toolSocket: join(runDir, "tools.sock"),
|
||||
turnMarker: join(runDir, "turn.done"),
|
||||
turnEnd: "marker",
|
||||
harnessEnv: {},
|
||||
pollInterval: 50,
|
||||
turnTimeout: 20,
|
||||
...session,
|
||||
@@ -452,7 +454,7 @@ test("a RESULT gets no automatic reply; failed turns reply with the reason", asy
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.replace(/^echo: .*/s, "echo")), [
|
||||
"echo",
|
||||
"The turn failed: adapter exited 3",
|
||||
"The turn failed: pi exited before the turn ended (no agent_end)",
|
||||
"The turn failed: pi exited before the turn ended (no turn marker)",
|
||||
"The turn failed: turn passed its 1 s wall clock",
|
||||
]);
|
||||
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", res.id));
|
||||
@@ -794,3 +796,37 @@ test("a malformed landing exits 2 before the claim, with no turn", async (t) =>
|
||||
assert.equal(out.code, EXIT.stopped, out.stderr);
|
||||
assert.match(out.stderr, new RegExp(`runner: landing turn 1 for decision ${"d".repeat(160)} \\(vikunja:3/7\\): ok`));
|
||||
});
|
||||
|
||||
// Row 65: the harness module's answers reach the runner as data in session.json, and the
|
||||
// runner refuses one without them rather than guess.
|
||||
test("row 65 a session.json without a turnEnd of marker or exit, or a harnessEnv of strings, exits 2 before the claim", async (t) => {
|
||||
const cases = {
|
||||
"no turnEnd": { turnEnd: undefined },
|
||||
"another turnEnd": { turnEnd: "agent_end" },
|
||||
"no harnessEnv": { harnessEnv: undefined },
|
||||
"a null harnessEnv": { harnessEnv: null },
|
||||
"an array harnessEnv": { harnessEnv: ["MOSAIC_THINKING=high"] },
|
||||
"a number in harnessEnv": { harnessEnv: { MOSAIC_THINKING: 3 } },
|
||||
};
|
||||
const ctx = await setup(t);
|
||||
const file = join(ctx.runDir, "session.json");
|
||||
const session = JSON.parse(readFileSync(file, "utf8"));
|
||||
for (const [why, change] of Object.entries(cases)) {
|
||||
writeFileSync(file, JSON.stringify({ ...session, ...change }));
|
||||
const out = await startRunner(ctx.runDir, ctx.cap).exited;
|
||||
assert.equal(out.code, EXIT.usage, `${why}: ${out.stderr}`);
|
||||
assert.match(out.stderr, /runner: session\.json has no turnEnd of marker or exit, or no harnessEnv of strings/, why);
|
||||
}
|
||||
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
|
||||
});
|
||||
|
||||
test("row 65 harnessEnv reaches the adapter, and an exit harness needs no turn marker", async (t) => {
|
||||
const ctx = await setup(t, { session: { turnEnd: "exit", harnessEnv: { MOSAIC_THINKING: "xhigh" } } });
|
||||
ctx.inbox = [];
|
||||
const r = startRunner(ctx.runDir, ctx.cap);
|
||||
t.after(() => r.child.kill("SIGKILL"));
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: "ENV MOSAIC_THINKING" });
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: "NOMARKER" });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.trim()), ["env: xhigh", "half an answer"]);
|
||||
});
|
||||
@@ -20,6 +20,7 @@ loader.
|
||||
| `limit-check` | 2 | list | `interfaceVersion`, `mayLaunch`, `mustStop`, `report` |
|
||||
| `search-source` | 1 | one | `interfaceVersion`, `search` |
|
||||
| `channel` | 1 | list | `interfaceVersion`, `dispatch` |
|
||||
| `harness` | 1 | list | `interfaceVersion`, `adapter`, `levels`, `preflight`, `version`, `countsAccount`, `mounts`, `builtinWeb`, `builtins`, `typedName`, `reliesOn`, `gateTimeout`, `bundle`, `env`, `turnEnd`, `transcript` |
|
||||
|
||||
Every module exports `interfaceVersion`, equal to its kind's interface
|
||||
version, plus the kind's functions. PRD REQ-LIMIT-4 says what the two kinds
|
||||
@@ -124,7 +125,67 @@ Limits of the checks:
|
||||
- Node caches a module by URL, so a changed `index.mjs` is not re-read
|
||||
until the process restarts.
|
||||
- The bus host wires the loader: row 124 for usage sources, row 125 for
|
||||
limit checks.
|
||||
limit checks, row 65 for harness levels. The launcher loads a launch's
|
||||
harness itself.
|
||||
|
||||
## Harness (row 65)
|
||||
|
||||
A managed session's harness is a module under `harness/`: `pi` and
|
||||
`claude-code` ship. Core reaches a harness only through the loader, so the
|
||||
launcher, the bundle, the runner and the usage reader name no harness. The
|
||||
host calls the module and writes its answers into `session.json` as data;
|
||||
the sandboxed runner never imports it. `src/kinds.mjs` lists what each
|
||||
export returns:
|
||||
|
||||
- `adapter()` is the absolute path of the adapter script the runner runs.
|
||||
- `levels()` lists the thinking levels the harness applies: pi takes `off`,
|
||||
`minimal`, `low`, `medium`, `high`, `xhigh` and `max` as `--thinking`,
|
||||
Claude Code takes `low`, `medium`, `high`, `xhigh` and `max` as
|
||||
`--effort`.
|
||||
- `preflight({env})` returns `null` or `{code, message}`: pi checks its
|
||||
binary in `node_modules` (`harness-binary-missing`), Claude Code its
|
||||
session credential (`no-session-credential`) and `MOSAIC_CLAUDE_BIN`.
|
||||
- `version({env})` is what the manifest records as `harnessVersion`.
|
||||
- `countsAccount()` says whether its launches spend `launch.account` and get
|
||||
the row 126 account checks.
|
||||
- `mounts`, `builtinWeb`, `builtins`, `typedName`, `reliesOn`,
|
||||
`gateTimeout`, `bundle`, `env`, `turnEnd` and `transcript` give its
|
||||
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
|
||||
how a turn ends and where its transcripts land.
|
||||
|
||||
`plugins.harness` switches harnesses on, and an instance's `harness` and
|
||||
`fallback.harness` vars each name one. A launch on a harness that isn't
|
||||
switched on refuses `harness-off`, primary or fallback, and an off primary
|
||||
never falls back. The launcher then loads the route's one module through a
|
||||
fresh loader, so an earlier load's module is never reused. A module the
|
||||
loader refuses fails the launch with `harness-refused`, naming the module
|
||||
and the loader's code. The switch gates launches and writes, not loads: the
|
||||
bus host loads every harness module in the tree to read its thinking levels,
|
||||
on or off. The broker binds a new run only on a switched-on harness; a run
|
||||
an earlier broker bound binds again.
|
||||
|
||||
Writes check it too. `routes.set`, `instances.set` and `limits.set` refuse a
|
||||
changed route whose harness is off (`harness-off`), whose module didn't load
|
||||
(`harness-refused`), or whose module doesn't apply its thinking level
|
||||
(`thinking-unsupported`). A route the write doesn't change isn't checked,
|
||||
so a model change still saves while `plugins.harness` is unset.
|
||||
|
||||
A new harness is a new module directory and a `plugins set` patch: no core
|
||||
file changes. `tests/harness.test.mjs` launches a fixture module that way,
|
||||
and greps every `packages/*/src` outside this package for a quoted harness
|
||||
name. The exceptions are the tool gates (`harness/src/gate.mjs`,
|
||||
`claude-gate.mjs`, `pi-extension.mjs`), the human-proof check
|
||||
(`bus/src/human.mjs`), the engines that start pi themselves
|
||||
(`conversation/src/`, `discord/src/`) and the process scan
|
||||
(`control-board/src/scan.mjs`). The test fails if an exception stops naming
|
||||
a harness, so the list stays true.
|
||||
|
||||
Switching harnesses on is a `mosaic plugins set` patch. The list replaces
|
||||
the kind's whole setting, so it names every harness that stays on:
|
||||
|
||||
```json
|
||||
{ "revision": "<from mosaic plugins>", "plugins": { "harness": ["pi", "claude-code"] } }
|
||||
```
|
||||
|
||||
## Transcript usage source (row 124)
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// The "claude-code" harness plug-in (row 65, REQ-PLUG-2, Q156): a managed session on the host's
|
||||
// Claude Code CLI, MOSAIC_CLAUDE_BIN when set (the bus unit pins it), else `claude` on PATH. The
|
||||
// tool gate is claude-gate.mjs behind the PreToolUse hook and the typed tools are mcp-server.mjs;
|
||||
// both stay in packages/harness (a module can't change that boundary). The launcher, the bundle
|
||||
// and the usage reader call these on the host; the runner gets the answers as data in
|
||||
// session.json. packages/plugins/README.md lists the interface.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
||||
import { delimiter, isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { claudeBuiltins, MCP_PREFIX } from "../../../harness/src/gate.mjs";
|
||||
import { hasSessionCredential, SESSION_CREDENTIALS } from "../../../seat/src/session.mjs";
|
||||
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const GATE = fileURLToPath(new URL("../../../harness/src/claude-gate.mjs", import.meta.url));
|
||||
const MCP_SERVER = fileURLToPath(new URL("../../../harness/src/mcp-server.mjs", import.meta.url));
|
||||
// `claude --effort <level>` on 2.1.302 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
|
||||
// It has no off or minimal.
|
||||
const LEVELS = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
|
||||
// docs/plans/2026-10-04_slice-1.md, "What S6 can rely on", lines 1-5.
|
||||
const RELIES_ON = Object.freeze(["S0-1 command hook deny, no --bare", "S0-2 hook wrapped as <gate> || exit 2",
|
||||
"S0-3 a missing gate blocks through the wrapper", "S0-4 timeout -k 2 10 <gate> || exit 2 with hook timeout 20",
|
||||
"S0-5 bash is limited by the tool limit only"]);
|
||||
// The hook: N = 10, K = 2, hook timeout 20, which leaves the margin the brief asks for above N + K.
|
||||
const GATE_TIMEOUT = Object.freeze({ n: 10, k: 2, hook: 20 });
|
||||
|
||||
export const adapter = () => join(REPO, "adapters", "claude", "adapter.sh");
|
||||
export const levels = () => LEVELS;
|
||||
|
||||
const executable = (path) => {
|
||||
try {
|
||||
accessSync(path, constants.X_OK);
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// The binary sessions run: MOSAIC_CLAUDE_BIN, else the first `claude` on PATH, or null.
|
||||
function binary(env) {
|
||||
if (env.MOSAIC_CLAUDE_BIN) return isAbsolute(env.MOSAIC_CLAUDE_BIN) && executable(env.MOSAIC_CLAUDE_BIN) ? env.MOSAIC_CLAUDE_BIN : null;
|
||||
for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "claude"))) return join(dir, "claude");
|
||||
return null;
|
||||
}
|
||||
|
||||
// A session needs a credential from the host's environment and the binary it runs.
|
||||
export function preflight({ env }) {
|
||||
if (!hasSessionCredential(env)) {
|
||||
const names = SESSION_CREDENTIALS.map((set) => set.join(" with ")).join(", ");
|
||||
return { code: "no-session-credential", message: `the bus host has none of ${names}; the bus unit's EnvironmentFile sets one when the bus starts` };
|
||||
}
|
||||
if (!binary(env)) {
|
||||
return {
|
||||
code: "harness-binary-missing",
|
||||
message: env.MOSAIC_CLAUDE_BIN ? `MOSAIC_CLAUDE_BIN ${env.MOSAIC_CLAUDE_BIN} isn't an executable file at an absolute path` : "no claude on the bus host's PATH",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// `claude --version` from the binary sessions run, or null when it doesn't answer.
|
||||
export function version({ env }) {
|
||||
const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
|
||||
return r.status === 0 ? r.stdout.trim().split("\n")[0] || null : null;
|
||||
}
|
||||
|
||||
// Its launches spend the business's launch.account (row 126).
|
||||
export const countsAccount = () => true;
|
||||
|
||||
// The pinned binary, when it lives outside /usr (which the sandbox already binds).
|
||||
export function mounts({ env }) {
|
||||
if (!env.MOSAIC_CLAUDE_BIN) return [];
|
||||
const real = realpathSync(env.MOSAIC_CLAUDE_BIN);
|
||||
return real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }];
|
||||
}
|
||||
|
||||
// WebSearch and WebFetch are its own, so it serves harness-builtin web.
|
||||
export const builtinWeb = () => true;
|
||||
export const builtins = ({ tools, webBuiltin = false }) => claudeBuiltins({ tools, webBuiltin });
|
||||
export const typedName = (name) => MCP_PREFIX + name;
|
||||
export const reliesOn = () => RELIES_ON;
|
||||
export const gateTimeout = () => ({ ...GATE_TIMEOUT });
|
||||
|
||||
// settings.json holds the gate hook and mcp.json the typed tools' server. files: the bundle's own
|
||||
// (policy, tools). write(name, text) puts a file in the bundle directory and returns
|
||||
// [name, sha256]; quote(path) refuses a single quote.
|
||||
export function bundle({ dir, files: core, session, write, quote }) {
|
||||
const files = { settings: join(dir, "settings.json"), mcp: join(dir, "mcp.json") };
|
||||
const node = process.execPath;
|
||||
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(GATE)} ${quote(core.policy)} || exit 2`;
|
||||
const digests = [
|
||||
write("settings.json", JSON.stringify({
|
||||
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
|
||||
}, null, 2) + "\n"),
|
||||
write("mcp.json", JSON.stringify({
|
||||
mcpServers: { mosaic: { type: "stdio", command: node, args: [MCP_SERVER, core.tools, session.toolSocket] } },
|
||||
}, null, 2) + "\n"),
|
||||
];
|
||||
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER } };
|
||||
}
|
||||
|
||||
export function env({ files, policy, session }) {
|
||||
return {
|
||||
MOSAIC_TOOLS: claudeBuiltins(policy).join(","),
|
||||
MOSAIC_CLAUDE_SETTINGS: files.settings,
|
||||
MOSAIC_CLAUDE_MCP_CONFIG: files.mcp,
|
||||
MOSAIC_THINKING: session.thinking,
|
||||
};
|
||||
}
|
||||
|
||||
// A turn ends when claude exits; there is no marker.
|
||||
export const turnEnd = () => "exit";
|
||||
export const transcript = ({ runDir, store = null }) => (store ? store.projects : join(runDir, "home", ".claude", "projects"));
|
||||
@@ -0,0 +1,65 @@
|
||||
// The "pi" harness plug-in (row 65, REQ-PLUG-2, Q156): a managed session on the pinned
|
||||
// @earendil-works/pi-coding-agent CLI, the repository's node_modules/.bin/pi. The tool gate is the
|
||||
// Pi extension, which stays in packages/harness (a module can't change that boundary). The
|
||||
// launcher, the bundle and the usage reader call these on the host; the runner gets the answers
|
||||
// as data in session.json. packages/plugins/README.md lists the interface.
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const PACKAGE = "@earendil-works/pi-coding-agent";
|
||||
const BIN = join(REPO, "node_modules", ".bin", "pi");
|
||||
const EXTENSION = fileURLToPath(new URL("../../../harness/src/pi-extension.mjs", import.meta.url));
|
||||
// `pi --thinking <level>` on 0.85.1 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
|
||||
const LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
// docs/plans/2026-10-04_slice-1.md, "What S6 can rely on", lines 1-5.
|
||||
const RELIES_ON = Object.freeze(["S0-1 tool_call block", "S0-2 a throw blocks", "S0-3 a missing -e refuses to start",
|
||||
"S0-4 external wall clock plus agent_end check", "S0-5 bash is limited by the tool limit only"]);
|
||||
|
||||
export const adapter = () => join(REPO, "adapters", "pi", "adapter.sh");
|
||||
export const levels = () => LEVELS;
|
||||
|
||||
// The session runs pi from the repository's node_modules, which the sandbox mounts.
|
||||
export function preflight() {
|
||||
if (!existsSync(BIN)) return { code: "harness-binary-missing", message: `pi isn't installed at ${BIN}; run npm ci in the repository` };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The exact pin from the repository's package.json.
|
||||
export function version() {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
|
||||
return pkg.dependencies?.[PACKAGE] ?? pkg.devDependencies?.[PACKAGE] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Pi sessions run on a provider key, never the Claude account limit.
|
||||
export const countsAccount = () => false;
|
||||
export const mounts = () => [{ src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" }];
|
||||
// Pi has no web tool of its own, so a granted role under harness-builtin is refused.
|
||||
export const builtinWeb = () => false;
|
||||
export const builtins = ({ tools }) => [...tools];
|
||||
export const typedName = (name) => name;
|
||||
export const reliesOn = () => RELIES_ON;
|
||||
export const gateTimeout = () => null;
|
||||
export const bundle = () => ({ files: {}, digests: [], sources: { piExtension: EXTENSION } });
|
||||
|
||||
// The tool limit is the built-ins plus the typed tools, which the extension serves.
|
||||
export function env({ policy, tools, session }) {
|
||||
return {
|
||||
MOSAIC_TOOLS: [...policy.tools, ...tools.map((t) => t.name)].join(","),
|
||||
MOSAIC_EXTENSIONS: EXTENSION,
|
||||
PI_PROVIDER: session.provider ?? "",
|
||||
PI_MODEL: session.model,
|
||||
MOSAIC_THINKING: session.thinking,
|
||||
};
|
||||
}
|
||||
|
||||
// The extension writes the turn marker at agent_end; an exit without it is a failed turn.
|
||||
export const turnEnd = () => "marker";
|
||||
export const transcript = ({ runDir, store = null }) => (store ? store.session : join(runDir, "session"));
|
||||
@@ -32,6 +32,28 @@ export const KINDS = Object.freeze({
|
||||
channel: Object.freeze({
|
||||
kind: "channel", interfaceVersion: 1, exports: Object.freeze(["dispatch"]), switch: "list",
|
||||
}),
|
||||
// A managed session's harness (row 65, REQ-PLUG-2, Q156). Core reaches a harness only through
|
||||
// these, each a function the launcher, the bundle and the usage reader call on the host; the
|
||||
// sandboxed runner gets their answers as data in session.json. The organization file switches
|
||||
// on a list, and an instance's `harness` var names one of them. packages/plugins/README.md has
|
||||
// each one's contract:
|
||||
// adapter() -> the adapter script's absolute path levels() -> the thinking levels it applies
|
||||
// preflight({ env }) -> null | { code, message } version({ env }) -> string | null
|
||||
// countsAccount() -> true when its launches spend launch.account
|
||||
// mounts({ env }) -> read-only binds [{ src, dest, why }]
|
||||
// builtinWeb() -> true when it serves harness-builtin web
|
||||
// builtins({ tools, webBuiltin }) -> its names for the built-in tools
|
||||
// typedName(name) -> its name for a typed bus tool reliesOn() -> the row S0 lines
|
||||
// gateTimeout() -> the gate hook's timeouts | null
|
||||
// bundle({ dir, files, session, write, quote }) -> { files, digests, sources }, its own bundle files
|
||||
// env({ files, policy, tools, session }) -> its adapter variables, written into session.json
|
||||
// turnEnd() -> "marker" (a turn ends only with the turn marker) | "exit"
|
||||
// transcript({ runDir, store }) -> the directory its transcripts land in, for usage
|
||||
harness: Object.freeze({
|
||||
kind: "harness", interfaceVersion: 1, switch: "list",
|
||||
exports: Object.freeze(["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"]),
|
||||
}),
|
||||
});
|
||||
|
||||
export class PluginError extends Error {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// 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 { 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 adapters pass the level as the probed flag.
|
||||
test("row 65 the adapters pass thinking as pi --thinking and claude --effort", () => {
|
||||
const pi = readFileSync(join(REPO, "adapters", "pi", "adapter.sh"), "utf8");
|
||||
const claude = readFileSync(join(REPO, "adapters", "claude", "adapter.sh"), "utf8");
|
||||
assert.match(pi, /THINKING_FLAG="--thinking \$MOSAIC_THINKING"/);
|
||||
assert.match(pi, /\$THINKING_FLAG \\\n/);
|
||||
assert.match(claude, /^need MOSAIC_THINKING$/m);
|
||||
assert.match(claude, /--effort "\$MOSAIC_THINKING" \\\n/);
|
||||
});
|
||||
|
||||
// A harness name in core: a quoted "pi" or "claude-code", or either inside a regex alternation.
|
||||
const NAMED = /["'`](?:pi|claude-code)["'`]|[(|](?:pi|claude-code)[|)]/;
|
||||
// The brief's Out of scope list: the tool-policy gate, the human-proof check, the engines that
|
||||
// start pi directly, and the process scan. Each must still name a harness, so the list stays true.
|
||||
const EXCEPT = [
|
||||
"packages/harness/src/gate.mjs",
|
||||
"packages/harness/src/claude-gate.mjs",
|
||||
"packages/harness/src/pi-extension.mjs",
|
||||
"packages/bus/src/human.mjs",
|
||||
"packages/conversation/src/",
|
||||
"packages/discord/src/",
|
||||
"packages/control-board/src/scan.mjs",
|
||||
];
|
||||
function sources(dir, out = []) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) { if (entry.name !== "node_modules" && entry.name !== "third_party") sources(path, out); }
|
||||
else if (/\.(?:mjs|js)$/.test(entry.name)) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test("row 65 no core source outside packages/plugins names a harness, apart from the listed exceptions", () => {
|
||||
const named = [];
|
||||
const hit = new Set();
|
||||
for (const pkg of readdirSync(join(REPO, "packages"))) {
|
||||
if (pkg === "plugins") continue;
|
||||
let files;
|
||||
try { files = sources(join(REPO, "packages", pkg, "src")); } catch (e) { if (e.code === "ENOENT") continue; throw e; }
|
||||
for (const file of files) {
|
||||
const rel = relative(REPO, file);
|
||||
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
|
||||
if (!NAMED.test(line)) return;
|
||||
const exception = EXCEPT.find(e => (e.endsWith("/") ? rel.startsWith(e) : rel === e));
|
||||
if (exception) hit.add(exception);
|
||||
else named.push(`${rel}:${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.deepEqual(named, []);
|
||||
assert.deepEqual([...hit].sort(), [...EXCEPT].sort(), "every exception still names a harness");
|
||||
// human.mjs keeps one: the human-proof regex that refuses any agent harness binary.
|
||||
const human = readFileSync(join(REPO, "packages", "bus", "src", "human.mjs"), "utf8").split("\n").filter(line => NAMED.test(line));
|
||||
assert.deepEqual(human.map(line => line.trim()), ["/^(pi|claude|claude-code|codex)(?:\\.js)?$/.test(command) ||"]);
|
||||
});
|
||||
|
||||
test("row 65 the grep's pattern catches a quoted name and a regex alternation, and not a word in prose", () => {
|
||||
for (const line of ['if (harness === "pi")', "x = 'claude-code';", "/^(pi|codex)$/", "/^(codex|claude-code)$/"]) assert.ok(NAMED.test(line), line);
|
||||
for (const line of ["// pi runs the bundle", "const piModel = 1;", "pipeline"]) assert.ok(!NAMED.test(line), line);
|
||||
});
|
||||
@@ -6,13 +6,20 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
|
||||
const code = expected => e => e instanceof PluginError && e.code === expected;
|
||||
|
||||
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => {
|
||||
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel"]);
|
||||
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness"]);
|
||||
assert.deepEqual({ ...KINDS["usage-source"], exports: [...KINDS["usage-source"].exports] },
|
||||
{ kind: "usage-source", interfaceVersion: 1, exports: ["readTurn"], switch: "list" });
|
||||
assert.deepEqual({ ...KINDS["limit-check"], exports: [...KINDS["limit-check"].exports] },
|
||||
{ kind: "limit-check", interfaceVersion: 2, exports: ["mayLaunch", "mustStop", "report"], switch: "list" });
|
||||
assert.deepEqual({ ...KINDS["search-source"], exports: [...KINDS["search-source"].exports] },
|
||||
{ kind: "search-source", interfaceVersion: 1, exports: ["search"], switch: "one" });
|
||||
// Row 65 (REQ-PLUG-2, Q156): the harness kind, a list, at interface version 1.
|
||||
assert.deepEqual({ ...KINDS.harness, exports: [...KINDS.harness.exports] }, {
|
||||
kind: "harness", interfaceVersion: 1, switch: "list",
|
||||
exports: ["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"],
|
||||
});
|
||||
assert.ok(Object.isFrozen(KINDS.harness) && Object.isFrozen(KINDS.harness.exports));
|
||||
assert.ok(Object.isFrozen(KINDS) && Object.isFrozen(KINDS["usage-source"]) && Object.isFrozen(KINDS["limit-check"].exports));
|
||||
assert.equal(String(NAME), "/^[a-z][a-z0-9-]*$/");
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../business/src/business.mjs";
|
||||
|
||||
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
|
||||
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
|
||||
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [] };
|
||||
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [] };
|
||||
|
||||
// A tree with one good module of each kind, already loaded.
|
||||
async function loadedTree(t, options) {
|
||||
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
|
||||
const loader = await s.loader();
|
||||
const result = await loader.load(business.plugins);
|
||||
assert.deepEqual(result.refused, []);
|
||||
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [] });
|
||||
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [] });
|
||||
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
|
||||
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
|
||||
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
|
||||
@@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
|
||||
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
|
||||
assert.deepEqual(result.refused[0].kept, ["meter"]);
|
||||
assert.deepEqual(s.loader.logged.slice(before), result.refused);
|
||||
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [] });
|
||||
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [] });
|
||||
} finally {
|
||||
if (lock) chmodSync(lock, 0o755);
|
||||
}
|
||||
@@ -232,7 +232,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
|
||||
const loader = await s.loader();
|
||||
// meter loads, absent refuses: the whole kind stays off, not half on.
|
||||
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [] });
|
||||
assert.deepEqual(loader.loaded(), result.loaded);
|
||||
assert.deepEqual(loader.modules("usage-source"), []);
|
||||
assert.equal(loader.logged.length, 1);
|
||||
@@ -260,7 +260,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
|
||||
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
|
||||
// An absent key switches the kind off.
|
||||
result = await s.loader.load({ "limit-check": ["cap"] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [] });
|
||||
});
|
||||
|
||||
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
|
||||
|
||||
@@ -190,10 +190,11 @@ each at its own host path; `binds()` returns them with the reason for each,
|
||||
and the file's header comment has the table.
|
||||
|
||||
- Read-only: `/usr`, `/etc` and the host's `/bin`, `/lib`, `/lib64`,
|
||||
`/sbin` links into `/usr`; the twelve repository files the runner loads
|
||||
(`RUNNER_FILES`); the adapter; for pi the repository's `node_modules`,
|
||||
for Claude Code the `MOSAIC_CLAUDE_BIN` binary when it lives outside
|
||||
`/usr`; node when it lives outside `/usr`; the broker socket, the
|
||||
`/sbin` links into `/usr`; the thirteen repository files the runner loads
|
||||
(`RUNNER_FILES`); the adapter; the harness module's `mounts()` (row 65:
|
||||
pi's gives the repository's `node_modules`, Claude Code's the
|
||||
`MOSAIC_CLAUDE_BIN` binary when it lives outside `/usr`; the sandbox
|
||||
names no harness itself); node when it lives outside `/usr`; the broker socket, the
|
||||
launch socket for the role whose launch tool calls it, and the host's
|
||||
git socket (`bus-host/git.sock`) for a role that holds
|
||||
git.push.working or review.verdict, or a merge decision it could land
|
||||
|
||||
@@ -49,7 +49,6 @@ const KILL_WITH_PARENT = ["/usr/bin/setpriv", "--pdeathsig", "KILL", "--"];
|
||||
// is missing here.
|
||||
export const RUNNER_FILES = Object.freeze([
|
||||
"packages/harness/src/runner.mjs",
|
||||
"packages/harness/src/bundle.mjs",
|
||||
"packages/harness/src/gate.mjs",
|
||||
"packages/harness/src/tools.mjs",
|
||||
"packages/harness/src/git-tools.mjs",
|
||||
@@ -60,6 +59,7 @@ export const RUNNER_FILES = Object.freeze([
|
||||
"packages/bus/src/broker.mjs",
|
||||
"packages/bus/src/landing.mjs",
|
||||
"packages/business/src/vocabulary.mjs",
|
||||
"packages/plugins/src/kinds.mjs",
|
||||
"packages/tasks/src/requirements.mjs",
|
||||
].map((p) => join(REPO, p)));
|
||||
|
||||
@@ -93,20 +93,16 @@ function systemBinds(node = process.execPath) {
|
||||
|
||||
// The binds for one session, in mount order; each says why the session
|
||||
// needs it. paths: the run directory, the instance's workspace, the adapter,
|
||||
// the harness ("pi" or "claude-code"), the broker socket, the launch socket
|
||||
// (null for a role that may not launch), the git socket (null for a role
|
||||
// that neither pushes nor reviews), the Claude Code binary
|
||||
// (MOSAIC_CLAUDE_BIN, or null), the thread's session store (session.mjs
|
||||
// threadStore(), or null) and node. `readOnly` adds files, for tests only.
|
||||
export function binds({ runDir, workspace, adapter, harness, brokerSocket, launchSocket = null, gitSocket = null, claudeBin = null, threadStore = null, node = process.execPath, readOnly = [] }) {
|
||||
// the harness module's mounts (row 65: its mounts(), [{ src, dest, why }],
|
||||
// read-only), the broker socket, the launch socket (null for a role that may
|
||||
// not launch), the git socket (null for a role that neither pushes nor
|
||||
// reviews), the thread's session store (session.mjs threadStore(), or null)
|
||||
// and node. `readOnly` adds files, for tests only.
|
||||
export function binds({ runDir, workspace, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
|
||||
const out = systemBinds(node);
|
||||
for (const f of RUNNER_FILES) out.push({ kind: "ro", src: f, why: "a repository file the runner loads" });
|
||||
out.push({ kind: "ro", src: adapter, why: "the harness adapter the runner starts each turn" });
|
||||
if (harness === "pi") out.push({ kind: "ro", src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" });
|
||||
if (harness === "claude-code" && claudeBin) {
|
||||
const bin = realpathSync(claudeBin);
|
||||
if (!bin.startsWith("/usr/")) out.push({ kind: "ro", src: bin, dest: claudeBin, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" });
|
||||
}
|
||||
for (const m of harnessMounts) out.push({ kind: "ro", src: m.src, ...(m.dest ? { dest: m.dest } : {}), why: m.why });
|
||||
out.push({ kind: "ro", src: brokerSocket, why: "the broker socket the runner claims and calls through" });
|
||||
if (launchSocket) out.push({ kind: "ro", src: launchSocket, why: "the launch socket, for the role whose launch tool calls it" });
|
||||
if (gitSocket) out.push({ kind: "ro", src: gitSocket, why: "the host's git socket, for push_working and fetch_review, and land_work" });
|
||||
|
||||
@@ -38,10 +38,6 @@ import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
|
||||
|
||||
export { REPO };
|
||||
export const RUNNER = join(REPO, "packages", "harness", "src", "runner.mjs");
|
||||
export const ADAPTERS = Object.freeze({
|
||||
pi: join(REPO, "adapters", "pi", "adapter.sh"),
|
||||
"claude-code": join(REPO, "adapters", "claude", "adapter.sh"),
|
||||
});
|
||||
export const SESSIONS_VERSION = 1;
|
||||
|
||||
export const sessionsFile = (dataRoot) => join(dataRoot, "bus-host", "sessions.json");
|
||||
@@ -139,8 +135,8 @@ export function appendLaunchLog(dataRoot, business, entry) {
|
||||
// Start the runner in its sandbox. stdin stays open for the capability line
|
||||
// the host writes once the broker has bound the launch; stdout and stderr go
|
||||
// to runner.log. paths: what sandbox.mjs's binds() takes besides the run
|
||||
// directory (workspace, adapter, harness, brokerSocket, launchSocket,
|
||||
// claudeBin, threadStore; readOnly for tests). business and role (the
|
||||
// directory (workspace, adapter, harnessMounts, brokerSocket, launchSocket,
|
||||
// gitSocket, threadStore; readOnly for tests). business and role (the
|
||||
// instance) give the session its git identity. `bwrap` is for the tests.
|
||||
export function spawnSession({ runDir, env, paths, business, role, node = process.execPath, bwrap }) {
|
||||
prepareRun(runDir, { threadStore: paths.threadStore ?? null });
|
||||
|
||||
@@ -17,11 +17,21 @@ import { serve } from "../../bus/src/server.mjs";
|
||||
import { verifyTester } from "../../bus/src/tester.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { SOURCES } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
import { startTimeOf } from "../src/proc.mjs";
|
||||
import { newRun, RUNNER, runnerOf, sessionEnv, spawnSession, writeSessionFile } from "../src/session.mjs";
|
||||
import { binds, gitIdentity, prepareRun, REPO, RUNNER_FILES, sandboxArgv, sandboxProblem } from "../src/sandbox.mjs";
|
||||
|
||||
const FAKE = new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url).pathname;
|
||||
|
||||
async function harnessModule(name) {
|
||||
const loader = createLoader({ log: () => {} });
|
||||
const { refused } = await loader.load({ harness: [name] });
|
||||
if (refused.length) throw new Error(`harness ${name} refused: ${JSON.stringify(refused)}`);
|
||||
return loader.modules("harness")[0];
|
||||
}
|
||||
// The confined sessions below run pi, so they get the pi module's mounts.
|
||||
const PI_MOUNTS = (await harnessModule("pi")).mounts({ env: {} });
|
||||
const TESTER_CLI = new URL("../../bus/src/tester-cli.mjs", import.meta.url).pathname;
|
||||
const HUMAN_MJS = new URL("../../bus/src/human.mjs", import.meta.url).pathname;
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -56,7 +66,7 @@ test("sandboxProblem names a missing bwrap and a bwrap that fails", (t) => {
|
||||
test("every bind says why; the bundle and session file stay read-only over the run directory", (t) => {
|
||||
const root = scratch(t);
|
||||
const runDir = join(root, "run");
|
||||
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "claude-code", brokerSocket: join(root, "bus.sock") });
|
||||
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") });
|
||||
assert.ok(list.every((b) => b.why));
|
||||
const at = (src) => list.findIndex((b) => b.src === src);
|
||||
assert.equal(list[at(runDir)].kind, "rw");
|
||||
@@ -67,7 +77,23 @@ test("every bind says why; the bundle and session file stay read-only over the r
|
||||
assert.deepEqual(list.at(-1), { kind: "rw", src: join(runDir, "tmp"), dest: "/tmp", why: list.at(-1).why });
|
||||
assert.equal(at(join(root, "launch.sock")), -1, "no launch socket for a role that may not launch");
|
||||
assert.equal(at(join(root, "git.sock")), -1, "no git socket for a role that neither pushes nor reviews");
|
||||
assert.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "Claude Code gets no node_modules");
|
||||
assert.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "no harness mounts unless the module names them");
|
||||
});
|
||||
|
||||
// Row 65: the harness module names its own mounts; binds() mounts each read-only, after the
|
||||
// adapter and before the broker socket, and names no harness itself.
|
||||
test("row 65 a harness module's mounts bind read-only after the adapter, each with the module's reason", async (t) => {
|
||||
const root = scratch(t);
|
||||
const args = { runDir: join(root, "run"), workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") };
|
||||
const pi = await harnessModule("pi");
|
||||
const list = binds({ ...args, harnessMounts: pi.mounts({ env: {} }) });
|
||||
const at = (src) => list.findIndex((b) => b.src === src);
|
||||
const modules = list[at(realpathSync(join(REPO, "node_modules")))];
|
||||
assert.deepEqual(modules, { kind: "ro", src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" });
|
||||
assert.ok(at(join(root, "a.sh")) < at(modules.src) && at(modules.src) < at(join(root, "bus.sock")));
|
||||
const fixture = binds({ ...args, harnessMounts: [{ src: join(root, "tool"), why: "a fixture harness's tool" }] });
|
||||
assert.deepEqual(fixture.find((b) => b.src === join(root, "tool")), { kind: "ro", src: join(root, "tool"), why: "a fixture harness's tool" });
|
||||
assert.equal(binds(args).length, list.length - 1);
|
||||
});
|
||||
|
||||
// Row 121 (#1615): a thread run gets only its own thread's two directories,
|
||||
@@ -76,7 +102,7 @@ test("REQ-THREAD-2 a thread store binds its session directory and Claude transcr
|
||||
const root = scratch(t);
|
||||
const runDir = join(root, "run");
|
||||
const store = { dir: join(root, "threads", "acme", "th"), session: join(root, "threads", "acme", "th", "session"), projects: join(root, "threads", "acme", "th", "claude-projects") };
|
||||
const args = { runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "claude-code", brokerSocket: join(root, "bus.sock") };
|
||||
const args = { runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") };
|
||||
const list = binds({ ...args, threadStore: store });
|
||||
const plain = binds(args);
|
||||
assert.deepEqual(list.slice(0, plain.length), plain, "the other binds are unchanged");
|
||||
@@ -96,7 +122,7 @@ test("REQ-THREAD-2 a thread store binds its session directory and Claude transcr
|
||||
// Row 93 (#1587): the git socket is bound read-only, before the workspace, when the launcher passes one.
|
||||
test("the host's git socket is bound only when given, read-only and before the writable binds", (t) => {
|
||||
const root = scratch(t);
|
||||
const args = { runDir: join(root, "run"), workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "pi", brokerSocket: join(root, "bus.sock") };
|
||||
const args = { runDir: join(root, "run"), workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") };
|
||||
const list = binds({ ...args, gitSocket: join(root, "git.sock") });
|
||||
const at = (src) => list.findIndex((b) => b.src === src);
|
||||
assert.equal(list[at(join(root, "git.sock"))].kind, "ro");
|
||||
@@ -116,7 +142,7 @@ test("the git identity is the instance's, at <instance>@<business>.mosaic.invali
|
||||
}
|
||||
const root = scratch(t);
|
||||
const runDir = join(root, "run");
|
||||
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "pi", brokerSocket: join(root, "bus.sock") });
|
||||
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") });
|
||||
const argv = sandboxArgv(list, runDir, ["/bin/true"], { business: "acme", role: "coder" });
|
||||
const set = {};
|
||||
argv.slice(0, argv.indexOf("--")).forEach((a, i, all) => {
|
||||
@@ -256,10 +282,10 @@ async function session(t, { expose = () => [], tester = false } = {}) {
|
||||
business: "acme", instance: "coder", run, launchedBy: "pm", harness: "pi", provider: "fake", model: "fake-model",
|
||||
brokerSocket, launchSocket: join(root, "launch.sock"), workspace: ws, sessionDir: join(runDir, "session"), adapter,
|
||||
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
|
||||
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), pollInterval: 50,
|
||||
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), turnEnd: "marker", harnessEnv: {}, pollInterval: 50,
|
||||
});
|
||||
|
||||
const paths = { workspace: ws, adapter, harness: "pi", brokerSocket, readOnly: [FAKE, ...(tester ? [TESTER_CLI, HUMAN_MJS] : []), ...expose(s)] };
|
||||
const paths = { workspace: ws, adapter, harnessMounts: PI_MOUNTS, brokerSocket, readOnly: [FAKE, ...(tester ? [TESTER_CLI, HUMAN_MJS] : []), ...expose(s)] };
|
||||
const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH }, run), paths, business: "acme", role: "coder" });
|
||||
const exited = new Promise((r) => child.once("close", (code, signal) => r({ code, signal })));
|
||||
t.after(async () => {
|
||||
@@ -317,7 +343,7 @@ test("a bind that exposes a stand-in, or a missing workspace bind, fails the mat
|
||||
// The same checks in a sandbox started directly from binds(), without the
|
||||
// session: all pass with every bind, and the workspace check fails
|
||||
// without the workspace's.
|
||||
const all = binds({ runDir: x.runDir, workspace: x.ws, adapter: x.adapter, harness: "pi", brokerSocket: x.brokerSocket });
|
||||
const all = binds({ runDir: x.runDir, workspace: x.ws, adapter: x.adapter, harnessMounts: PI_MOUNTS, brokerSocket: x.brokerSocket });
|
||||
const direct = (list) => async (cmd) => {
|
||||
const argv = sandboxArgv(list, x.runDir, ["/bin/sh", "-c", cmd], { business: "acme", role: "coder" });
|
||||
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 10000 });
|
||||
|
||||
@@ -223,10 +223,10 @@ test("a session runs in its sandbox as pid 1 of its namespace, claims, answers,
|
||||
business: "acme", instance: "coder", run, launchedBy: "pm", harness: "pi", provider: "fake", model: "fake-model",
|
||||
brokerSocket, launchSocket: join(root, "launch.sock"), workspace: ws, sessionDir: join(runDir, "session"), adapter,
|
||||
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
|
||||
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), pollInterval: 50,
|
||||
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), turnEnd: "marker", harnessEnv: {}, pollInterval: 50,
|
||||
});
|
||||
|
||||
const paths = { workspace: ws, adapter, harness: "pi", brokerSocket, readOnly: [FAKE] };
|
||||
const paths = { workspace: ws, adapter, brokerSocket, readOnly: [FAKE] };
|
||||
const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH, GITEA_TOKEN: "x" }, run), paths, business: "acme", role: "coder" });
|
||||
t.after(() => {
|
||||
try {
|
||||
|
||||
@@ -629,6 +629,59 @@ changed." `revision-conflict` and `organization-instance-exists` are 409,
|
||||
`sent:"unknown"`. The log line names the user, the instance and the outcome (`done
|
||||
restart=true|false` or the code), never the vars.
|
||||
|
||||
## Routing (row 65, #1558, mos-0840)
|
||||
|
||||
Settings shows a Routing section, read through `/api/bus/routes`: a table
|
||||
with one row per instance in the business file and six selects, harness,
|
||||
model, thinking, fallback harness, fallback model and fallback thinking.
|
||||
It replaces the Agent limits form's old Role models fieldset, which set
|
||||
only the model. The harness cells offer the harnesses `plugins.harness`
|
||||
switches on, the model cells the `launch.models` keys, and each thinking
|
||||
cell the levels its row's harness applies. A value the file holds that
|
||||
isn't offered stays selected and says why: "(not switched on)", "(not in
|
||||
the model table)" or "(not applied by <harness>)". A harness change redraws
|
||||
the row, so its thinking cell offers the new harness's levels. With no
|
||||
harness switched on, the section says every launch refuses `harness-off`
|
||||
and that `mosaic plugins set` switches one on; the page doesn't change
|
||||
`plugins`.
|
||||
|
||||
A fallback is set or cleared as a whole. Save sends one POST `/api/routes`
|
||||
under the revision the page read, with only the changed rows. A fallback
|
||||
whose three cells are `none` is sent as null, which clears it; one with
|
||||
some cells set goes as it is, and the bus refuses it as partial. A
|
||||
submit with nothing changed says "Nothing changed." and sends nothing. A
|
||||
save says which instances it saved and that the next launch of each uses
|
||||
the new route; running sessions keep theirs. A refusal shows the code and
|
||||
the reason and keeps the draft. A lost answer says the outcome is unknown
|
||||
and to read the routes again. "Read current routes and discard draft"
|
||||
drops the draft. Without `--send` every select and Save are off. Nothing
|
||||
is sent again on its own.
|
||||
|
||||
Routes:
|
||||
|
||||
- GET `/api/bus/routes` calls the human-only `routes` through the human
|
||||
transport. A query string is 400, no bus 503, and an answer without a
|
||||
64-hex revision, the `harnesses` and `models` lists and the `levels` and
|
||||
`instances` objects 502.
|
||||
- POST `/api/routes` exists only under `--send` and calls the human-only
|
||||
`routes.set`. It has the Settings boundary: the signed-in user's header
|
||||
(401 without it), the exact `https://<public host>` Origin and
|
||||
`Sec-Fetch-Site: same-origin` (403), and a JSON object of at most 16 KiB
|
||||
(400). It takes exactly `revision` (64 hex) and a non-empty `routes`
|
||||
object; any other key is 400 before the bus. Any other method is 405.
|
||||
The bus sends a refusal's code alone, so the route adds the reason:
|
||||
`{error, message, sent:false}`, where the message is the code, the reason
|
||||
and "Nothing changed." `revision-conflict` is 409, `human-required` 403,
|
||||
and `harness-off`, `harness-refused`, `thinking-unsupported`,
|
||||
`unknown-model`, `organization-partial-fallback`,
|
||||
`organization-unknown-instance`, `organization-no-change` and
|
||||
`invalid-request` 400. A lost or unusable answer is 503 with
|
||||
`sent:"unknown"`. The log line names the user, the instances and the
|
||||
outcome (`done` or the code), never the routes.
|
||||
|
||||
Neither route is in `organization.set`'s keys, so the chief of staff can't
|
||||
reach them (`packages/business/README.md`, "Routes").
|
||||
|
||||
## Data and boundaries
|
||||
|
||||
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
|
||||
|
||||
@@ -52,19 +52,20 @@ try {
|
||||
// the system config, Business can't resolve authority and Settings has
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, busLine, system = null;
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, busLine, system = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
const { dataRoot } = system;
|
||||
const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
chat = createChat({ call });
|
||||
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165
|
||||
// (#1662): so is `instances`, and its write `instances.set` is only under --send.
|
||||
bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances') };
|
||||
// (#1662): so is `instances`, and its write `instances.set` is only under --send. Row 65
|
||||
// (mos-0840): so is `routes`, and its write `routes.set`.
|
||||
bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
||||
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
|
||||
projectFiles = args => call('project.files', args);
|
||||
projectFile = args => call('project.file', args);
|
||||
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);}
|
||||
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);}
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
@@ -72,7 +73,7 @@ try {
|
||||
busLine = `Bus: not configured (${why})`;
|
||||
}
|
||||
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
|
||||
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}\nCtrl-C stops this server.`);
|
||||
|
||||
@@ -574,14 +574,14 @@
|
||||
const providerRows=[...providers].map(p=>field(['providers',p],p,limits.providers?.[p],usage.providers?.[p])).join('');
|
||||
const modelRows=[...pairs.values()].map(([p,m])=>field(['models',p,m],`${p} / ${m}`,limits.models?.[p]?.[m],usage.models?.[p]?.[m])).join('');
|
||||
const table=Object.entries(modelTable).map(([key,m])=>`<div class="filters"><strong>${txt(key)}</strong><label>Provider <input data-setting="${esc(inputKey(['table',key,'provider']))}" value="${esc(val(['table',key,'provider'],m.provider))}" maxlength="200"${disabled}></label><label>Model <input data-setting="${esc(inputKey(['table',key,'model']))}" value="${esc(val(['table',key,'model'],m.model))}" maxlength="200"${disabled}></label></div>`).join('');
|
||||
const selections=Object.entries(v.instances||{}).map(([instance,entry])=>`<label>${txt(instance)} <select data-setting="${esc(inputKey(['instances',instance]))}"${disabled}>${Object.keys(modelTable).map(key=>`<option value="${esc(key)}"${val(['instances',instance],entry.modelKey)===key?' selected':''}>${txt(key)}</option>`).join('')}</select></label>`).join('');
|
||||
return `<section class="panel" aria-labelledby="launch-settings-title"><h2 id="launch-settings-title">Agent limits</h2><p>Leave a limit blank for no limit. Zero stops new launches. Running sessions keep running when limits or models change.</p><form id="launch-settings-form"><fieldset><legend>All sessions</legend>${field(['total'],'Total',limits.total,usage.total)}</fieldset><fieldset><legend>By provider</legend><div class="filters">${providerRows}</div></fieldset><fieldset><legend>By provider and model</legend><div class="filters">${modelRows}</div></fieldset><fieldset><legend>Model table</legend>${table}</fieldset><fieldset><legend>Role models</legend><div class="filters">${selections}</div></fieldset><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${settingsSaving?'Saving…':'Save launch settings'}</button> <button type="button" id="launch-settings-reload">Read current settings and discard draft</button><p role="status" id="launch-settings-receipt">${txt(settingsReceipt)}</p></form></section>`;
|
||||
// Row 65 (mos-0840): each instance's model moved to the routing table below.
|
||||
return `<section class="panel" aria-labelledby="launch-settings-title"><h2 id="launch-settings-title">Agent limits</h2><p>Leave a limit blank for no limit. Zero stops new launches. Running sessions keep running when limits or models change.</p><form id="launch-settings-form"><fieldset><legend>All sessions</legend>${field(['total'],'Total',limits.total,usage.total)}</fieldset><fieldset><legend>By provider</legend><div class="filters">${providerRows}</div></fieldset><fieldset><legend>By provider and model</legend><div class="filters">${modelRows}</div></fieldset><fieldset><legend>Model table</legend>${table}</fieldset><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${settingsSaving?'Saving…':'Save launch settings'}</button> <button type="button" id="launch-settings-reload">Read current settings and discard draft</button><p role="status" id="launch-settings-receipt">${txt(settingsReceipt)}</p></form></section>`;
|
||||
}
|
||||
function settingDraft(form){const values={};for(const el of form.querySelectorAll('[data-setting]'))values[el.dataset.setting]=el.value;return values;}
|
||||
function settingPayload(){const v=settingsSnapshot, limits={providers:Object.create(null),models:Object.create(null)},models=structuredClone(v.models),instances={};for(const [encoded,text]of Object.entries(settingsInputs)){const [kind,p,m]=JSON.parse(encoded);if(kind==='table'){models[p][m]=text;continue;}if(kind==='instances'){instances[p]=text;continue;}if(text==='')continue;if(!/^\d+$/.test(text)||!Number.isSafeInteger(Number(text)))throw Error('Limits need whole numbers of zero or more.');const n=Number(text);if(kind==='total')limits.total=n;else if(kind==='providers')limits.providers[p]=n;else {limits.models[p]??=Object.create(null);limits.models[p][m]=n;}}
|
||||
function settingPayload(){const v=settingsSnapshot, limits={providers:Object.create(null),models:Object.create(null)},models=structuredClone(v.models);for(const [encoded,text]of Object.entries(settingsInputs)){const [kind,p,m]=JSON.parse(encoded);if(kind==='table'){models[p][m]=text;continue;}if(text==='')continue;if(!/^\d+$/.test(text)||!Number.isSafeInteger(Number(text)))throw Error('Limits need whole numbers of zero or more.');const n=Number(text);if(kind==='total')limits.total=n;else if(kind==='providers')limits.providers[p]=n;else {limits.models[p]??=Object.create(null);limits.models[p][m]=n;}}
|
||||
// Row 125 (#1619): the form doesn't edit token limits; a save keeps them as read.
|
||||
if(v.limits?.tokens)limits.tokens=structuredClone(v.limits.tokens);
|
||||
return {revision:v.revision,limits,models,instances};
|
||||
return {revision:v.revision,limits,models};
|
||||
}
|
||||
document.addEventListener('input',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);});
|
||||
document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);});
|
||||
@@ -727,6 +727,59 @@
|
||||
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{instanceReceipt=unknownInstance;}finally{instanceBusy=false;if(!out())render(false);}
|
||||
});
|
||||
// Row 65 (mos-0840): the routing table. One row per instance: harness, model and thinking, and
|
||||
// the fallback's three. Each cell offers the switched-on harnesses, the model table's keys and the
|
||||
// levels that harness applies; a value the file holds that isn't offered stays, marked, so a save
|
||||
// sends it and the bus says why it refuses. A fallback is set by choosing all three or cleared by
|
||||
// choosing none for all three. Save sends the changed rows through routes.set. Drafts survive
|
||||
// polling and refusals; nothing retries.
|
||||
let routesView=null, routesDraft=null, routesBusy=false, routesReceipt='';
|
||||
const ROUTE_CELLS=[['harness','Harness'],['model','Model'],['thinking','Thinking'],['fallback.harness','Fallback harness'],['fallback.model','Fallback model'],['fallback.thinking','Fallback thinking']];
|
||||
const routeCells=r=>({harness:r.harness??'',model:r.model??'',thinking:r.thinking??'','fallback.harness':r.fallback?.harness??'','fallback.model':r.fallback?.model??'','fallback.thinking':r.fallback?.thinking??''});
|
||||
const sameCells=(a,b)=>ROUTE_CELLS.every(([k])=>a[k]===b[k]);
|
||||
function routesSection(view){
|
||||
const title='<h2 id="routes-title">Routing</h2>';
|
||||
if(!view)return `<section class="panel" aria-labelledby="routes-title">${title}<p class="small warn">The routes could not be read, so there is nothing to change. Read again to retry; they come from the bus host with your identity.</p></section>`;
|
||||
if(!routesDraft)routesView=view;
|
||||
const v=routesView, disabled=!canSend||out()||routesBusy?' disabled':'';
|
||||
const cells=name=>routesDraft?.[name]??routeCells(v.instances[name]);
|
||||
const select=(name,key,list,current,why)=>{const offered=current===''||list.includes(current)?list:[...list,current];
|
||||
return `<select data-route="${esc(inputKey([name,key]))}" aria-label="${esc(`${name} ${ROUTE_CELLS.find(([k])=>k===key)[1].toLowerCase()}`)}"${disabled}>${key.startsWith('fallback.')||current===''?`<option value=""${current===''?' selected':''}>${key.startsWith('fallback.')?'none':'unset'}</option>`:''}${offered.map(x=>`<option value="${esc(x)}"${x===current?' selected':''}>${txt(x)}${list.includes(x)?'':` (${why})`}</option>`).join('')}</select>`;};
|
||||
const levelsOf=h=>Array.isArray(v.levels[h])?v.levels[h]:[];
|
||||
const rows=Object.keys(v.instances).map(name=>{const c=cells(name);
|
||||
const harness=(key)=>select(name,key,v.harnesses,c[key],'not switched on');
|
||||
const model=(key)=>select(name,key,v.models,c[key],'not in the model table');
|
||||
const thinking=(key,h)=>select(name,key,levelsOf(c[h]),c[key],c[h]?`not applied by ${c[h]}`:'no harness');
|
||||
return `<tr><th scope="row">${txt(name)}</th><td>${harness('harness')}</td><td>${model('model')}</td><td>${thinking('thinking','harness')}</td><td>${harness('fallback.harness')}</td><td>${model('fallback.model')}</td><td>${thinking('fallback.thinking','fallback.harness')}</td></tr>`;}).join('');
|
||||
const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. <code>mosaic plugins set</code> switches one on.</p>';
|
||||
return `<section class="panel" aria-labelledby="routes-title">${title}<p>Each instance's harness, model and thinking, and the fallback a launch takes when the primary harness fails its preflight. A fallback sets all three or none. Running sessions keep their route; the next launch reads the change.</p>${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col">Instance</th>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${routesBusy?'Saving…':'Save routes'}</button> <button type="button" id="routes-reload">Read current routes and discard draft</button><p role="status" id="routes-receipt">${txt(routesReceipt)}</p></form></section>`;
|
||||
}
|
||||
function routesFields(form){const draft=Object.fromEntries(Object.keys(routesView.instances).map(name=>[name,routeCells(routesView.instances[name])]));for(const el of form.querySelectorAll('[data-route]')){const [name,key]=JSON.parse(el.dataset.route);if(draft[name])draft[name][key]=el.value;}return draft;}
|
||||
// The changed rows only. A fallback with all three cells empty is cleared; one with some set goes
|
||||
// as it is, and the bus refuses it as partial.
|
||||
function routesPayload(draft){
|
||||
const routes={};
|
||||
for(const [name,c]of Object.entries(draft)){if(sameCells(c,routeCells(routesView.instances[name])))continue;
|
||||
const set=['harness','model','thinking'].filter(k=>c[`fallback.${k}`]!=='');
|
||||
routes[name]={harness:c.harness,model:c.model,thinking:c.thinking,fallback:set.length===0?null:Object.fromEntries(set.map(k=>[k,c[`fallback.${k}`]]))};}
|
||||
if(!Object.keys(routes).length)throw Error('Nothing changed.');
|
||||
return {revision:routesView.revision,routes};
|
||||
}
|
||||
const unknownRoutes='Outcome unknown. Read the routes again before trying. Draft kept.';
|
||||
const routesAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&Array.isArray(r.harnesses)&&Array.isArray(r.models)&&[r.levels,r.instances].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
|
||||
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('#routes-form');if(!form||!routesView)return;routesDraft=routesFields(form);
|
||||
// A harness change redraws the row so its thinking cell offers that harness's levels.
|
||||
if(kind==='change'&&/harness"\]$/.test(e.target.dataset?.route??''))render(false);});
|
||||
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';last.delete('routes');render(true);});
|
||||
document.addEventListener('submit',async e=>{
|
||||
if(e.target.id!=='routes-form')return;e.preventDefault();if(!canSend||out()||routesBusy||!routesView)return;
|
||||
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
|
||||
routesBusy=true;routesReceipt='';render(false);
|
||||
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
|
||||
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;}
|
||||
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
|
||||
});
|
||||
async function settingsView(get) {
|
||||
const s = await get.api('settings'), n = s.notifier || {};
|
||||
let usage, names = null;
|
||||
@@ -734,12 +787,14 @@
|
||||
try { names = await get('names'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; /* the section says the name could not be read */ }
|
||||
let instances = null;
|
||||
try { [instances] = await get('instances'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||||
let routes = null;
|
||||
try { [routes] = await get('routes'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||||
let limitRows = [], limitsRefused = false;
|
||||
try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; }
|
||||
const notify = typeof n.binding === 'string'
|
||||
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
|
||||
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
|
||||
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
||||
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${routesSection(routes)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
||||
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
|
||||
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ async function body(req, max = 4096) {
|
||||
// (#1619, REQ-LIMIT-3) adds `limits`: only the token limits' report, never the document. Row 155
|
||||
// (#1649) adds `names`: each instance's current seat name, for the Assistant section. Row 165
|
||||
// (#1662) adds `instances`: the business file's role instances and the definitions add accepts.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances'];
|
||||
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
|
||||
const DECISION_LISTS = ['roles', 'recent'];
|
||||
async function busRead(res, bus, verb, search) {
|
||||
let arg;
|
||||
@@ -95,6 +96,18 @@ async function busRead(res, bus, verb, search) {
|
||||
return json(res, busStatus(code), { error: code, message: code });
|
||||
}
|
||||
}
|
||||
if (verb === 'routes') {
|
||||
if (search) return json(res, 400, { error: 'invalid-request', message: 'routes takes no arguments' });
|
||||
if (!bus?.routes) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
try {
|
||||
const value = await bus.routes();
|
||||
if (!routesAnswer(value)) throw new BusReadError('invalid-response');
|
||||
return json(res, 200, { rows: [value], at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code, message: code });
|
||||
}
|
||||
}
|
||||
if (verb === 'trail') {
|
||||
arg = new URLSearchParams(search).get('subject');
|
||||
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
|
||||
@@ -317,6 +330,50 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
|
||||
line(`done restart=${result.restart}`);
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
}
|
||||
// Row 65 (mos-0840): the routing table. The Settings boundary and one bus call, `routes.set`,
|
||||
// through the human transport. It is human-only and outside ORGANIZATION_KEYS, so the chief of
|
||||
// staff's tool never reaches it. The bus checks the route rules; a refusal comes back with its code.
|
||||
const ROUTE_KEYS = ['revision', 'routes'];
|
||||
const ROUTE_REASONS = {
|
||||
'harness-off': ['400', 'that harness isn\'t switched on in the business file\'s plugins.harness, which `mosaic plugins set` changes'],
|
||||
'harness-refused': ['400', 'that harness module didn\'t load on the bus host, so its thinking levels are unknown'],
|
||||
'thinking-unsupported': ['400', 'that harness doesn\'t apply that thinking level'],
|
||||
'unknown-model': ['400', 'a model or fallback model isn\'t a key in the model table'],
|
||||
'organization-partial-fallback': ['400', 'a fallback sets its harness, model and thinking together, or is cleared'],
|
||||
'organization-unknown-instance': ['400', 'the business has no such instance'],
|
||||
'organization-no-change': ['400', 'the business file already says that'],
|
||||
'revision-conflict': ['409', 'the business file changed since you read it. Read the routes again'],
|
||||
};
|
||||
const routeObject = o => o && typeof o === 'object' && !Array.isArray(o);
|
||||
const routesAnswer = v => /^[a-f0-9]{64}$/.test(v?.revision ?? '') && Array.isArray(v.harnesses) && Array.isArray(v.models) && routeObject(v.levels) && routeObject(v.instances);
|
||||
async function routesSend(req, res, { routesWrite, preview, log }) {
|
||||
// Instance names only; routes are never logged.
|
||||
let names = [];
|
||||
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
if (!routeObject(value) || Object.keys(value).some(key => !ROUTE_KEYS.includes(key)) || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
|
||||
!routeObject(value.routes) || Object.keys(value.routes).length === 0) {
|
||||
return refuse(400, 'invalid-request', 'this takes a revision and routes by instance, nothing else');
|
||||
}
|
||||
names = Object.keys(value.routes);
|
||||
let result;
|
||||
try { result = await routesWrite({ revision: value.revision, routes: value.routes }); } catch (err) {
|
||||
const { code, sent } = sendOutcome(err);
|
||||
if (sent !== false) return refuse(503, 'outcome-unknown', 'the change may have been made; nothing was retried. Read the routes again before trying', 'unknown');
|
||||
const [status, reason] = Object.hasOwn(ROUTE_REASONS, code) ? ROUTE_REASONS[code] : [String(busStatus(code)), 'refused by the bus'];
|
||||
return refuse(Number(status), code, `${code}: ${reason}. Nothing changed.`);
|
||||
}
|
||||
if (!routesAnswer(result) || names.some(name => !result.instances[name])) {
|
||||
return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the change may have been made. Read the routes again before trying', 'unknown');
|
||||
}
|
||||
line('done');
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
}
|
||||
// Row 148 (#1642): the spec and docs editor. Two reads and one save, all through the human transport.
|
||||
// The save has the Settings boundary (header user, exact Origin, same-origin Fetch Metadata, JSON,
|
||||
// strict keys) with a 1 MiB body on this route only; every other POST keeps its own smaller limit.
|
||||
@@ -375,7 +432,7 @@ async function projectFileRead(res, read, path, search) {
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
|
||||
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
const upstream = boardURL(board);
|
||||
@@ -426,6 +483,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!send || !instancesWrite || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return instanceSend(req, res, { instancesWrite, preview, log });
|
||||
}
|
||||
if (path === '/api/routes') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if (!send || !routesWrite || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return routesSend(req, res, { routesWrite, preview, log });
|
||||
}
|
||||
if (path === '/api/project-file' && req.method === 'POST') {
|
||||
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return projectFileSend(req, res, { projectSave, preview, log });
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Row 65 (#1558, REQ-PLUG-2, mos-0840): Settings shows one routing row per instance with its six
|
||||
// cells, and a save posts the changed rows with the read's revision. The page's POST is held by a
|
||||
// fetch stub and sent on by the test, with the Console's own headers, to a second server on the
|
||||
// same real broker; the browser can't set Origin or Host itself. So a save reaches the business
|
||||
// file through routes.set, and each refusal comes from the bus with the draft kept.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {request} from 'node:http';
|
||||
import {mkdirSync,readFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {browser} from './browser.mjs';
|
||||
import {startServer} from '../src/serve.mjs';
|
||||
import {BusReadError} from '../src/bus.mjs';
|
||||
import {close} from './fixture.mjs';
|
||||
import {BusError} from '../../bus/src/broker.mjs';
|
||||
import {startBroker} from '../../bus/src/runtime.mjs';
|
||||
import {systemFor} from '../../business/tests/helpers.mjs';
|
||||
import {fixture,tmp} from '../../cli/tests/helpers.mjs';
|
||||
|
||||
const preview={host:'console.example',header:'x-user',user:'jason'};
|
||||
const headers={host:'console.example','x-user':'jason',origin:'https://console.example','sec-fetch-site':'same-origin','content-type':'application/json'};
|
||||
const KEYS=['harness','model','thinking','fallback.harness','fallback.model','fallback.thinking'];
|
||||
const post=(port,data)=>new Promise((resolve,reject)=>{
|
||||
const req=request({hostname:'127.0.0.1',port,path:'/api/routes',method:'POST',headers},r=>{let text='';r.on('data',c=>text+=c);r.on('end',()=>resolve({status:r.statusCode,body:JSON.parse(text)}));});
|
||||
req.on('error',reject);req.end(JSON.stringify(data));
|
||||
});
|
||||
|
||||
test('row 65 Settings: the routing table lists every instance, saves a harness, model, thinking and fallback to the file, and keeps the draft on each refusal',{timeout:120000},async t=>{
|
||||
const root=tmp(t,'webui-routes-browser-');
|
||||
// The reviewer starts with a fallback, so the table draws a set one as well as an empty one.
|
||||
const f=fixture(root,'acme',doc=>{doc.roles.reviewer.vars={...doc.roles.reviewer.vars,'fallback.harness':'pi','fallback.model':'glm-5.3-flash','fallback.thinking':'low'};return doc;});
|
||||
mkdirSync(f.dataRoot,{mode:0o700});
|
||||
const dir=join(root,'config'),file=join(dir,'businesses','acme.json');
|
||||
const host=await startBroker({
|
||||
dataRoot:f.dataRoot,
|
||||
businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir,rolesDir:f.roles,system:systemFor(root)},
|
||||
});
|
||||
t.after(()=>host.close());
|
||||
const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});
|
||||
const call=async(verb,args)=>{
|
||||
try{return structuredClone(host.broker.request(human,{verb,args}));}catch(e){if(e instanceof BusError)throw new BusReadError(e.code);throw e;}
|
||||
};
|
||||
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});
|
||||
const lines=[];
|
||||
const writer=await startServer({port:0,preview,send:async()=>({}),routesWrite:args=>call('routes.set',args),log:line=>lines.push(line)});
|
||||
const b=await browser();
|
||||
try{
|
||||
// The stub holds each POST until the test answers it with what the write server said.
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/routes'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Promise(resolve=>{window.answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}));});}return original(url,options);};`});
|
||||
const port=web.address().port;
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
|
||||
const receipt=()=>b.evaluate('document.querySelector("#routes-receipt").textContent');
|
||||
const cell=(name,key)=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify([name,key])}']`)})`;
|
||||
// A harness change redraws the form, so each cell is looked up again, and waited for until the
|
||||
// redraw offers the value.
|
||||
const set=async(name,key,value)=>{assert.equal(await b.evaluate(`(async()=>{for(let n=0;n<100;n++){const e=${cell(name,key)};if(e&&[...e.options].some(o=>o.value===${JSON.stringify(value)})){e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));return true;}await new Promise(r=>setTimeout(r,30));}return false;})()`),true,`${name} ${key} offers ${value}`);};
|
||||
const value=(name,key)=>b.evaluate(`${cell(name,key)}.value`);
|
||||
const shown=(name,key)=>b.evaluate(`${cell(name,key)}.selectedOptions[0].textContent`);
|
||||
let sent=0;
|
||||
const save=async()=>{
|
||||
// The last receipt is cleared here, so only the answer's redraw writes one.
|
||||
await b.evaluate('document.querySelector("#routes-receipt").textContent="";document.querySelector("#routes-form").requestSubmit();true');
|
||||
await wait(`posts.length>${sent}`);sent++;
|
||||
const r=await post(writer.address().port,await b.evaluate('posts.at(-1)'));
|
||||
await b.evaluate(`window.answer(${r.status},${JSON.stringify(r.body)});true`);
|
||||
await wait('!document.querySelector("#routes-form button[type=submit]")?.textContent.startsWith("Saving")&&document.querySelector("#routes-receipt")?.textContent!==""');
|
||||
return r;
|
||||
};
|
||||
const reload=async()=>{
|
||||
await b.evaluate('document.querySelector("#routes-reload").click();true');
|
||||
await wait('!!document.querySelector("#routes-table")&&document.querySelector("#routes-receipt").textContent===""');
|
||||
};
|
||||
const vars=()=>JSON.parse(readFileSync(file,'utf8')).roles;
|
||||
|
||||
// Read-only: one row per instance in the file, each with its six values, every select disabled.
|
||||
await b.navigate(`http://127.0.0.1:${port}/#/settings`);
|
||||
await wait('!!document.querySelector("#routes-table")');
|
||||
const rows=await b.evaluate("[...document.querySelectorAll('#routes-table tbody tr')].map(r=>[r.querySelector('th').textContent,...[...r.querySelectorAll('select')].map(s=>s.value)])");
|
||||
assert.deepEqual(rows,Object.entries(vars()).map(([name,{vars:v}])=>[name,...KEYS.map(k=>v[k]??'')]));
|
||||
assert.ok(rows.length>=5,'every fixture instance has a row');
|
||||
assert.deepEqual(rows.find(([name])=>name==='reviewer').slice(4),['pi','glm-5.3-flash','low']);
|
||||
assert.equal(await b.evaluate('document.querySelector("#routes-title").textContent'),'Routing');
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#routes-form select,#routes-form button[type=submit]")].every(e=>e.disabled)'),true);
|
||||
|
||||
// Send mode: a harness, a model, a thinking level and a whole fallback on the coder, saved and
|
||||
// read back from the file.
|
||||
await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings`);
|
||||
await wait('document.querySelector("#routes-form button[type=submit]")?.disabled===false');
|
||||
const before=vars().coder.vars;
|
||||
await set('coder','harness','claude-code');
|
||||
await set('coder','model','claude-opus-5-5');
|
||||
await set('coder','thinking','xhigh');
|
||||
await set('coder','fallback.harness','pi');
|
||||
await set('coder','fallback.model','glm-5.3-flash');
|
||||
await set('coder','fallback.thinking','off');
|
||||
const saved=await save();
|
||||
assert.equal(saved.status,200,JSON.stringify(saved.body));
|
||||
assert.deepEqual(await b.evaluate('posts.at(-1).routes'),{coder:{harness:'claude-code',model:'claude-opus-5-5',thinking:'xhigh',fallback:{harness:'pi',model:'glm-5.3-flash',thinking:'off'}}},'only the changed row is sent');
|
||||
assert.equal(await receipt(),'Routes saved for coder. The next launch of each uses the new route.');
|
||||
const after=vars().coder.vars;
|
||||
assert.deepEqual(KEYS.map(k=>after[k]),['claude-code','claude-opus-5-5','xhigh','pi','glm-5.3-flash','off']);
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(after).filter(([k])=>!KEYS.includes(k))),Object.fromEntries(Object.entries(before).filter(([k])=>!KEYS.includes(k))),'other vars kept');
|
||||
assert.equal(await value('coder','harness'),'claude-code','the page draws the saved route');
|
||||
assert.match(lines.at(-1),/ routes user="jason" instances="coder" done$/);
|
||||
|
||||
// A partial fallback goes as it is and the bus refuses it; the draft stays on the page.
|
||||
const unchanged=readFileSync(file,'utf8');
|
||||
await set('pm','fallback.harness','pi');
|
||||
let r=await save();
|
||||
assert.deepEqual([r.status,r.body.error],[400,'organization-partial-fallback']);
|
||||
assert.deepEqual(await b.evaluate('posts.at(-1).routes.pm.fallback'),{harness:'pi'});
|
||||
assert.match(await receipt(),/^organization-partial-fallback: .* Nothing changed\. Draft kept\.$/);
|
||||
assert.equal(await value('pm','fallback.harness'),'pi');
|
||||
|
||||
// A level the new harness doesn't apply: shown as such, and refused.
|
||||
await reload();
|
||||
assert.equal(await value('pm','fallback.harness'),'','reload discards the draft');
|
||||
await set('pm','thinking','minimal');
|
||||
await set('pm','harness','claude-code');
|
||||
await wait(`${cell('pm','thinking')}?.selectedOptions[0]?.textContent.includes("(")`);
|
||||
assert.equal(await shown('pm','thinking'),'minimal (not applied by claude-code)');
|
||||
r=await save();
|
||||
assert.deepEqual([r.status,r.body.error],[400,'thinking-unsupported']);
|
||||
assert.match(await receipt(),/^thinking-unsupported: .* Draft kept\.$/);
|
||||
assert.deepEqual([await value('pm','harness'),await value('pm','thinking')],['claude-code','minimal']);
|
||||
|
||||
// A stale revision: the human narrows plugins.harness to pi outside the page, so the page's read
|
||||
// is old, and a save on it is refused.
|
||||
await reload();
|
||||
const {revision}=await call('plugins');
|
||||
await call('plugins.set',{revision,plugins:{harness:['pi']}});
|
||||
const narrowed=readFileSync(file,'utf8');
|
||||
await set('cto','model','claude-sonnet-5-5'===(await value('cto','model'))?'claude-opus-5-5':'claude-sonnet-5-5');
|
||||
r=await save();
|
||||
assert.deepEqual([r.status,r.body.error],[409,'revision-conflict']);
|
||||
assert.match(await receipt(),/^revision-conflict: the business file changed since you read it\. Read the routes again\. Nothing changed\. Draft kept\.$/);
|
||||
|
||||
// Read again: the coder's harness is now off, shown as such, and a thinking change on that route
|
||||
// is refused harness-off.
|
||||
await reload();
|
||||
assert.equal(await shown('coder','harness'),'claude-code (not switched on)');
|
||||
await set('coder','thinking','high');
|
||||
r=await save();
|
||||
assert.deepEqual([r.status,r.body.error],[400,'harness-off']);
|
||||
assert.match(await receipt(),/^harness-off: .*`mosaic plugins set` changes\. Nothing changed\. Draft kept\.$/);
|
||||
assert.equal(await value('coder','thinking'),'high');
|
||||
|
||||
// Nothing changed is caught on the page and posts nothing.
|
||||
await reload();
|
||||
await b.evaluate('document.querySelector("#routes-form").requestSubmit();true');
|
||||
await wait('document.querySelector("#routes-receipt")?.textContent==="Nothing changed."');
|
||||
assert.equal(await b.evaluate('posts.length'),sent);
|
||||
assert.equal(readFileSync(file,'utf8'),narrowed,'no refused save wrote the file');
|
||||
assert.notEqual(narrowed,unchanged);
|
||||
}finally{await b.close();await close(web);await close(writer);}
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// Row 65 (#1558, REQ-PLUG-2, mos-0840): the Console's routing table read and write. The write has
|
||||
// the Settings boundary and makes one human-transport call, `routes.set`; the route rules are the
|
||||
// bus's and the business writer's, tested there. The first test runs a real broker over a temporary
|
||||
// organization directory, so a save reaches the file and the log, and each refusal leaves both.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { startBroker } from '../../bus/src/runtime.mjs';
|
||||
import { systemFor } from '../../business/tests/helpers.mjs';
|
||||
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
||||
|
||||
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
|
||||
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
const REV = 'a'.repeat(64);
|
||||
const ROUTE = { harness: 'pi', model: 'glm-5.3-flash', thinking: 'low', fallback: null };
|
||||
const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), harnesses: ['pi'], models: ['glm-5.3-flash'], levels: { pi: ['low'] }, instances: { coder: ROUTE }, ...extra });
|
||||
|
||||
async function setup(t, { on = true, writer = on, write = async () => view(), bus = null } = {}) {
|
||||
const calls = [], lines = [];
|
||||
const server = await startServer({
|
||||
port: 0, preview, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
|
||||
routesWrite: writer ? async args => { calls.push(args); return write(args); } : null,
|
||||
});
|
||||
t.after(() => new Promise(r => server.close(r)));
|
||||
return { calls, lines, port: server.address().port };
|
||||
}
|
||||
const send = (s, path, data, hs = headers, method = 'POST') => new Promise((resolve, reject) => {
|
||||
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
|
||||
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
||||
});
|
||||
const get = (s, path) => send(s, path, undefined, { host: 'console.example', 'x-user': 'jason' }, 'GET');
|
||||
|
||||
test('row 65 Console: the signed-in human saves a route through a real broker; each refusal says nothing changed and leaves the file and the log', async t => {
|
||||
const root = tmp(t, 'webui-routes-');
|
||||
const f = fixture(root, 'acme');
|
||||
mkdirSync(f.dataRoot, { mode: 0o700 });
|
||||
const dir = join(root, 'config');
|
||||
const host = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => host.close());
|
||||
const human = host.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = async (verb, args) => {
|
||||
try { return structuredClone(host.broker.request(human, { verb, args })); } catch (e) {
|
||||
if (e instanceof BusError) throw new BusReadError(e.code);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
const s = await setup(t, { bus: { routes: () => call('routes') }, write: args => call('routes.set', args) });
|
||||
const file = join(dir, 'businesses', 'acme.json');
|
||||
const log = () => readFileSync(join(dir, 'businesses', '.history', 'acme', 'changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
const read = await get(s, '/api/bus/routes');
|
||||
assert.equal(read.status, 200);
|
||||
const [current] = read.body.rows;
|
||||
assert.deepEqual(current.harnesses, ['pi', 'claude-code']);
|
||||
assert.deepEqual(current.levels['claude-code'], ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
assert.deepEqual(current.instances.coder, { harness: 'pi', model: 'glm-5.3-flash', thinking: 'high', fallback: null });
|
||||
|
||||
const route = { harness: 'claude-code', model: 'claude-opus-5-5', thinking: 'xhigh', fallback: { harness: 'pi', model: 'glm-5.3-flash', thinking: 'off' } };
|
||||
const saved = await send(s, '/api/routes', { revision: current.revision, routes: { coder: route } });
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||
assert.deepEqual(saved.body.instances.coder, route);
|
||||
assert.deepEqual(s.calls, [{ revision: current.revision, routes: { coder: route } }]);
|
||||
assert.match(s.lines.at(-1), / routes user="jason" instances="coder" done$/);
|
||||
const entry = log().at(-1);
|
||||
assert.deepEqual([entry.actor, entry.state, entry.transaction], ['jason', 'committed', saved.body.transaction]);
|
||||
const vars = JSON.parse(readFileSync(file, 'utf8')).roles.coder.vars;
|
||||
assert.deepEqual(['harness', 'model', 'thinking', 'fallback.harness', 'fallback.model', 'fallback.thinking'].map(k => vars[k]), ['claude-code', 'claude-opus-5-5', 'xhigh', 'pi', 'glm-5.3-flash', 'off']);
|
||||
|
||||
const before = readFileSync(file, 'utf8'), entries = log().length;
|
||||
const coder = more => ({ coder: { ...route, ...more } });
|
||||
for (const [routes, revision, status, code, why] of [
|
||||
[coder({ harness: 'cursor' }), saved.body.revision, 400, 'harness-off', /isn't switched on in the business file's plugins\.harness/],
|
||||
[coder({ thinking: 'minimal' }), saved.body.revision, 400, 'thinking-unsupported', /doesn't apply that thinking level/],
|
||||
[coder({ fallback: { harness: 'pi', model: 'glm-5.3-flash' } }), saved.body.revision, 400, 'organization-partial-fallback', /sets its harness, model and thinking together, or is cleared/],
|
||||
[{ ghost: route }, saved.body.revision, 400, 'organization-unknown-instance', /no such instance/],
|
||||
[{ coder: route }, saved.body.revision, 400, 'organization-no-change', /already says that/],
|
||||
[coder({ thinking: 'low' }), current.revision, 409, 'revision-conflict', /changed since you read it/],
|
||||
]) {
|
||||
const r = await send(s, '/api/routes', { revision, routes });
|
||||
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, code, false], code);
|
||||
assert.match(r.body.message, why, code);
|
||||
assert.match(r.body.message, new RegExp(`^${code}: .*Nothing changed\\.$`), code);
|
||||
}
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
assert.equal(log().length, entries);
|
||||
});
|
||||
|
||||
test('row 65 Console: the routes write needs send on, a writer, the public name, the user, the exact Origin and same-origin Fetch Metadata', async t => {
|
||||
const data = { revision: REV, routes: { coder: ROUTE } };
|
||||
const off = await setup(t, { on: false });
|
||||
assert.equal((await send(off, '/api/routes', data)).status, 403);
|
||||
const unsent = await setup(t, { on: false, writer: true });
|
||||
assert.equal((await send(unsent, '/api/routes', data)).status, 403);
|
||||
assert.equal(unsent.calls.length, 0);
|
||||
const unwritten = await setup(t, { writer: false });
|
||||
assert.equal((await send(unwritten, '/api/routes', data)).status, 403);
|
||||
const s = await setup(t);
|
||||
for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['x-user', ['jason', 'jason'], 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
|
||||
const hs = { ...headers };
|
||||
if (value === undefined) delete hs[key]; else hs[key] = value;
|
||||
assert.equal((await send(s, '/api/routes', data, hs)).status, status, key);
|
||||
}
|
||||
assert.equal((await send(s, '/api/routes', undefined, headers, 'GET')).status, 405);
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('row 65 Console: the routes write takes JSON within 16 KiB with a revision and routes only; an actor is refused and routes are never logged', async t => {
|
||||
const s = await setup(t);
|
||||
const base = { revision: REV, routes: { coder: ROUTE } };
|
||||
for (const value of [
|
||||
{ ...base, actor: 'mos' }, { ...base, instances: { coder: 'x' } }, { revision: REV }, { routes: base.routes },
|
||||
{ ...base, revision: 'stale' }, { ...base, revision: 7 }, { ...base, routes: {} }, { ...base, routes: [] }, { ...base, routes: null },
|
||||
[], 'bad', { ...base, routes: { coder: { ...ROUTE, model: 'x'.repeat(17000) } } },
|
||||
]) assert.equal((await send(s, '/api/routes', value)).status, 400, JSON.stringify(value).slice(0, 60));
|
||||
assert.equal((await send(s, '/api/routes', base, { ...headers, 'content-type': 'text/plain' })).status, 400);
|
||||
assert.equal(s.calls.length, 0);
|
||||
// A route with a model the bus refuses: the log names the instance and the code, never the route.
|
||||
const refused = await setup(t, { write: async () => { throw new BusReadError('unknown-model'); } });
|
||||
await send(refused, '/api/routes', { revision: REV, routes: { coder: { ...ROUTE, model: 'secret-model-name' } } });
|
||||
assert.deepEqual(refused.lines.map(l => l.replace(/^\S+ /, '')), ['routes user="jason" instances="coder" error=unknown-model']);
|
||||
assert.ok([...s.lines, ...refused.lines].every(l => !/xxxx|secret-model-name|glm/.test(l)), 'routes are never logged');
|
||||
});
|
||||
|
||||
test('row 65 Console: a bus refusal maps to its status and says nothing changed; a lost or odd answer says the change may have been made', async t => {
|
||||
const fail = code => async () => { throw new BusReadError(code); };
|
||||
for (const [write, status, error, sent] of [
|
||||
[fail('harness-off'), 400, 'harness-off', false], [fail('harness-refused'), 400, 'harness-refused', false],
|
||||
[fail('thinking-unsupported'), 400, 'thinking-unsupported', false], [fail('unknown-model'), 400, 'unknown-model', false],
|
||||
[fail('organization-partial-fallback'), 400, 'organization-partial-fallback', false],
|
||||
[fail('organization-unknown-instance'), 400, 'organization-unknown-instance', false],
|
||||
[fail('organization-no-change'), 400, 'organization-no-change', false], [fail('revision-conflict'), 409, 'revision-conflict', false],
|
||||
[fail('human-required'), 403, 'human-required', false], [fail('invalid-request'), 400, 'invalid-request', false],
|
||||
[fail('outcome-unknown'), 503, 'outcome-unknown', 'unknown'], [async () => { throw new Error('lost'); }, 503, 'outcome-unknown', 'unknown'],
|
||||
// Each part of the answer's shape wrong alone, and an answer without the instance sent.
|
||||
[async () => view({ revision: 'x' }), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => view({ harnesses: null }), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => view({ models: {} }), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => view({ levels: [] }), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => view({ instances: null }), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => view({ instances: {} }), 503, 'outcome-unknown', 'unknown'],
|
||||
]) {
|
||||
const s = await setup(t, { write });
|
||||
const r = await send(s, '/api/routes', { revision: REV, routes: { coder: ROUTE } });
|
||||
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, error, sent], error);
|
||||
if (sent === false) assert.match(r.body.message, new RegExp(`^${error}: .*Nothing changed\\.$`), error);
|
||||
else assert.match(r.body.message, /may have been made/, error);
|
||||
assert.equal(s.calls.length, 1);
|
||||
assert.match(s.lines[0], new RegExp(` routes user="jason" instances="coder" error=${error}$`));
|
||||
}
|
||||
});
|
||||
|
||||
test('row 65 Console: the routes read takes no arguments, needs a bus and refuses an odd answer', async t => {
|
||||
const none = await setup(t);
|
||||
assert.equal((await get(none, '/api/bus/routes')).status, 503);
|
||||
const s = await setup(t, { bus: { routes: async () => view() } });
|
||||
assert.equal((await get(s, '/api/bus/routes?x=1')).status, 400);
|
||||
assert.deepEqual((await get(s, '/api/bus/routes')).body.rows, [view()]);
|
||||
for (const odd of [{ revision: 'x' }, { harnesses: 'pi' }, { models: null }, { levels: [] }, { instances: [] }]) {
|
||||
const r = await setup(t, { bus: { routes: async () => view(odd) } });
|
||||
assert.equal((await get(r, '/api/bus/routes')).status, 502, JSON.stringify(odd));
|
||||
}
|
||||
const refused = await setup(t, { bus: { routes: async () => { throw new BusReadError('human-required'); } } });
|
||||
assert.equal((await get(refused, '/api/bus/routes')).status, 403);
|
||||
});
|
||||
@@ -12,11 +12,12 @@ test('Settings form shows every scope and held role; gated save, exact payload,
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings`);
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
|
||||
await wait('!!document.querySelector("#launch-settings-form")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-form button[type=submit]").disabled'),true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form select").length'),4);assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form input[type=number]").length'),4);
|
||||
// Row 65 (mos-0840): the Role models selects moved to the routing table, so this form has none.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form select").length'),0);assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form input[type=number]").length'),4);
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}/?send=1#/settings`);await wait('document.querySelector("#launch-settings-form button[type=submit]")?.disabled===false');
|
||||
const change=async(parts,value)=>b.evaluate(`(()=>{const e=[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(parts))});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
|
||||
const submit=()=>b.evaluate('document.querySelector("#launch-settings-form").requestSubmit();true');
|
||||
await change(['total'],'0');await change(['providers','anthropic'],'12');await change(['instances','coder'],'opus');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("saved")');const [payload]=await b.evaluate('posts');assert.equal(payload.limits.total,0);assert.equal(payload.limits.providers.anthropic,12);assert.equal(payload.instances.coder,'opus');assert.equal(payload.revision,launch.revision);assert.deepEqual(payload.models,launch.models);
|
||||
await change(['total'],'0');await change(['providers','anthropic'],'12');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("saved")');const [payload]=await b.evaluate('posts');assert.equal(payload.limits.total,0);assert.equal(payload.limits.providers.anthropic,12);assert.equal('instances' in payload,false);assert.equal(payload.revision,launch.revision);assert.deepEqual(payload.models,launch.models);
|
||||
await change(['total'],'9');await b.evaluate('mode="unknown"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("Outcome unknown")');assert.equal(await b.evaluate('posts.length'),2);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');await b.evaluate('new Promise(r=>setTimeout(r,300))');assert.equal(await b.evaluate('posts.length'),2);
|
||||
await b.evaluate('mode="refused"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("fixture conflict")');assert.equal(await b.evaluate('posts.length'),3);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');
|
||||
}finally{await b.close();await close(web);}
|
||||
|
||||
@@ -53,11 +53,11 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
|
||||
// No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has four explicit writes:
|
||||
// row 76's message to a role, row 122's human Settings update, row 125's token-limit reset (one path,
|
||||
// to the same Settings route) and row 148's spec and docs save. Row 165 (#1662) adds the role
|
||||
// instance add or change to /api/instances.
|
||||
// instance add or change to /api/instances, and row 65 the routing table's save to /api/routes.
|
||||
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
|
||||
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
|
||||
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 6);
|
||||
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 7);
|
||||
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'"]);
|
||||
assert.equal(read('icons.svg').toString().includes('<script'), false);
|
||||
|
||||
Reference in new issue
Block a user