roles, harness, cli: a managed session gets its role's skills as prompt text on both harnesses (row 64, #1557)
Author and gate owner: Sage (mos-0855). Role files take an optional skills list. The bundle reads each named skill from <dataRoot>/skills-enabled, copies it into the bundle and renders every file into prompt.md between skill-file markers, so the prompt text matches the bundle copy byte for byte. The role's skills together are capped at SKILL_BYTES (64 KiB): the claude adapter passes prompt.md as one argument, and Linux caps one argument at 128 KiB. Symlinks are refused, not followed. The launcher checks the list before a run exists: skill-not-enabled names the activate command, anything else refuses skill-refused. Pi keeps --no-skills; Claude Code keeps --disable-slash-commands and --restricted. The two harness modules and MOSAIC_SKILLS are unchanged. First lists: coder, reviewer, cto and pm, ms-unslop on each.
This commit is contained in:
@@ -82,3 +82,27 @@ session's workspace and its own process group. Same contract, plus:
|
||||
|
||||
The layers each adapter relies on, and what they don't cover, are in
|
||||
`packages/harness/README.md`.
|
||||
|
||||
### A role's skills (row 64)
|
||||
|
||||
A managed session's skills reach both adapters as text in the bundle's
|
||||
`prompt.md`, which they pass with `--system-prompt`. Neither adapter loads a
|
||||
skill of its own: `pi` keeps `--no-skills`, since role sessions never get
|
||||
`MOSAIC_SKILLS`, and `claude` keeps `--disable-slash-commands` and
|
||||
`--restricted`. Sage chose this on 2026-10-19 from a probe, recorded in
|
||||
`agents/sage/work/queue-64/probe.md`:
|
||||
|
||||
- Pi's `--skill` puts only a skill's name and description in the prompt,
|
||||
and the model has to `read` the SKILL.md itself. The gate refuses every
|
||||
file-tool path outside the workspace, so the session couldn't open its
|
||||
own skills. Opening the gate to the bundle would change a security
|
||||
boundary.
|
||||
- Claude Code loads a bundle-local `--plugin-dir` skill under `--restricted`
|
||||
only with its `Skill` tool on, and then it also offers its own bundled
|
||||
skills. Which ones it offers changes with the Claude Code version, and the
|
||||
`Skill` tool would pass through the gate hook.
|
||||
|
||||
The cost is prompt size: a role's skill text is in every turn, where the
|
||||
other routes load a skill when it's used. The bundle caps a role's skills at
|
||||
64 KiB together, since `claude` passes `prompt.md` as one argument and Linux
|
||||
caps one argument at 128 KiB.
|
||||
|
||||
@@ -72,7 +72,8 @@ fi
|
||||
# --settings the gate hook (S0 lines 1-4)
|
||||
# --strict-mcp-config only the bundle's MCP server, which serves the
|
||||
# --mcp-config typed bus tools
|
||||
# --disable-slash-commands no skills (the bundle lists none)
|
||||
# --disable-slash-commands no skill discovery; the role's skills are
|
||||
# text in the system prompt (row 64)
|
||||
ALLOWED=mcp__mosaic
|
||||
[ -z "${MOSAIC_TOOLS:-}" ] || ALLOWED="$MOSAIC_TOOLS,mcp__mosaic"
|
||||
PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
|
||||
|
||||
@@ -49,6 +49,9 @@ TOOLS_FLAG="--no-tools"
|
||||
|
||||
# Skills (M17): explicitly provided skill dirs replace discovery. When none
|
||||
# are provided the agent runs with --no-skills (nothing ambient to find).
|
||||
# Role sessions never get MOSAIC_SKILLS (ENV_ALLOW in packages/seat/src/
|
||||
# session.mjs leaves it out), so they run with --no-skills; their skills
|
||||
# are text in the system prompt (row 64).
|
||||
SKILLS_FLAG="--no-skills"
|
||||
if [ -n "${MOSAIC_SKILLS:-}" ]; then
|
||||
SKILLS_FLAG=""
|
||||
|
||||
@@ -92,6 +92,8 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
launch,
|
||||
// REQ-HARN-2: the search-source plug-in the organization file switches on, by name.
|
||||
webSource: business.plugins?.["search-source"] ?? null,
|
||||
// The role file's skill names. The bundle resolves each against <dataRoot>/skills-enabled.
|
||||
skills: [...definition.skills],
|
||||
};
|
||||
return deepFreeze({ ...resolved, digest: sha256(canonicalJson(resolved)) });
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
|
||||
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
|
||||
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||||
const V2_KEYS = [...V2_REQUIRED, "messaging"];
|
||||
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills"];
|
||||
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function checkTools(tools) {
|
||||
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
|
||||
@@ -64,6 +65,16 @@ function checkMessaging(messaging) {
|
||||
return lists;
|
||||
}
|
||||
|
||||
// Optional: the skills a session of this role gets, by their directory name under
|
||||
// <dataRoot>/skills-enabled. "." and ".." match the pattern but name no skill directory.
|
||||
function checkSkills(skills) {
|
||||
return requireDistinctList(skills, "role skills", (name) => {
|
||||
if (typeof name !== "string" || !SKILL_NAME.test(name) || name === "." || name === "..") {
|
||||
refuse(`role skill must be a skill directory name matching ${SKILL_NAME} (got ${JSON.stringify(name)})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkGiteaScopes(scopes) {
|
||||
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
|
||||
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
||||
@@ -123,7 +134,7 @@ function checkContract(contract, file) {
|
||||
// Validate a parsed role document read from `file`. Returns a frozen role:
|
||||
// { roleVersion, name, title, contract, contractPath, tools, network,
|
||||
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
|
||||
// messaging: { sendTo, receiveFrom } | null }.
|
||||
// messaging: { sendTo, receiveFrom } | null, skills: [name] }.
|
||||
export function validateRoleDocument(document, file) {
|
||||
requireObject(document, "role");
|
||||
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
|
||||
@@ -136,7 +147,7 @@ export function validateRoleDocument(document, file) {
|
||||
const network = document.network === undefined ? "none" : checkNetwork(document.network);
|
||||
return deepFreeze({
|
||||
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
|
||||
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null,
|
||||
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [],
|
||||
});
|
||||
}
|
||||
for (const key of V2_REQUIRED) {
|
||||
@@ -153,6 +164,7 @@ export function validateRoleDocument(document, file) {
|
||||
authority: checkAuthority(document.authority),
|
||||
credentials: checkCredentials(document.credentials),
|
||||
messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
|
||||
skills: document.skills === undefined ? [] : checkSkills(document.skills),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ test("the record carries what the broker and launcher need", () => {
|
||||
const s = setup();
|
||||
const pm = resolve(s, "pm");
|
||||
assert.deepEqual(Object.keys(pm).sort(), [
|
||||
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "tracker", "vars", "webSource",
|
||||
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "skills", "tracker", "vars", "webSource",
|
||||
]);
|
||||
assert.equal(pm.contract, join(REPO_ROLES, "pm.md"));
|
||||
assert.equal(pm.holder, "sage");
|
||||
|
||||
@@ -161,6 +161,21 @@ test("messaging: optional, role definitions and human, never the tester", () =>
|
||||
refuses(() => loadRoleFile(file), /unsupported role key: "messaging"/);
|
||||
});
|
||||
|
||||
test("row 64 skills: optional, distinct skill directory names, none on version 1", () => {
|
||||
assert.deepEqual(check({}).skills, []);
|
||||
const role = check({ skills: ["ms-unslop", "ms_sdlc.v2"] });
|
||||
assert.deepEqual(role.skills, ["ms-unslop", "ms_sdlc.v2"]);
|
||||
assert.ok(Object.isFrozen(role.skills));
|
||||
refuses(() => check({ skills: "ms-unslop" }), /role skills must be an array/);
|
||||
refuses(() => check({ skills: ["ms-unslop", "ms-unslop"] }), /duplicate entry in role skills: "ms-unslop"/);
|
||||
for (const bad of [".", "..", "", "a/b", "../ms-unslop", "ms unslop", 7, null]) {
|
||||
refuses(() => check({ skills: [bad] }), /role skill must be a skill directory name/);
|
||||
}
|
||||
const file = writeJson(join(tmp(), "v1s.json"), { roleVersion: 1, name: "v1s", tools: ["read"], skills: ["ms-unslop"] });
|
||||
refuses(() => loadRoleFile(file), /unsupported role key: "skills"/);
|
||||
assert.deepEqual(validateRoleDocument({ roleVersion: 1, name: "v1s", tools: ["read"] }, file).skills, []);
|
||||
});
|
||||
|
||||
test("credentials: Gitea scopes", () => {
|
||||
const gitea = (scopes) => check({ credentials: [{ service: "gitea", scopes }] });
|
||||
assert.deepEqual(gitea(["write:repository", "read:user"]).credentials[0].scopes, ["write:repository", "read:user"]);
|
||||
|
||||
@@ -101,7 +101,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } fro
|
||||
import { join } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
import {
|
||||
appendLaunchLog,
|
||||
@@ -173,6 +173,8 @@ export function createLauncher({
|
||||
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
|
||||
}) {
|
||||
const dataRoot = system.dataRoot;
|
||||
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
|
||||
const skillsDir = join(dataRoot, "skills-enabled");
|
||||
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
const vars = systemVars(system);
|
||||
|
||||
@@ -247,6 +249,13 @@ export function createLauncher({
|
||||
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`);
|
||||
// Row 64 (#1557): every skill on the role's list is enabled and readable before a run exists.
|
||||
try {
|
||||
readSkills(resolved.skills, skillsDir);
|
||||
} catch (e) {
|
||||
if (e instanceof BundleError) throw new Refusal(e.code === "skill-not-enabled" ? "skill-not-enabled" : "skill-refused", e.message);
|
||||
throw e;
|
||||
}
|
||||
// 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)) {
|
||||
@@ -373,6 +382,7 @@ export function createLauncher({
|
||||
routeReason,
|
||||
harnessVersion: version,
|
||||
land,
|
||||
skillsDir,
|
||||
});
|
||||
const { manifest, ...files } = bundle.files;
|
||||
const adapter = call(model.harness, "adapter", () => harness.adapter());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Private fixture runtime only; no live host, unit, store or organization file.
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -33,7 +33,6 @@ export async function provedRuntime(t) {
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
const f = fixture(root, "demo", doc => ({ ...doc, plugins: { channel: [] } }));
|
||||
mkdirSync(f.dataRoot, { mode: 0o700 });
|
||||
const url = rel => new URL(rel, import.meta.url).href;
|
||||
const humanURL = url("../../bus/src/human.mjs");
|
||||
const script = join(root, "fixture-runtime.mjs");
|
||||
|
||||
@@ -73,7 +73,6 @@ async function setup(t, { wrap = (h) => h, ...git } = {}) {
|
||||
}
|
||||
});
|
||||
const f = fixture(root, "acme");
|
||||
mkdirSync(f.dataRoot, { mode: 0o700 });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
const main = commit(project, "one");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// points MOSAIC_CONFIG there; nothing reads the real ~/.config, a real
|
||||
// token, a real binding, or the real human transport.
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
@@ -10,6 +10,7 @@ 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 { REPO_SKILLS } from "../../harness/tests/helpers.mjs";
|
||||
import { tree } from "../../plugins/tests/helpers.mjs";
|
||||
import { AGENT_MARKERS } from "../src/transport.mjs";
|
||||
|
||||
@@ -31,7 +32,9 @@ export function humanEnv(extra = {}) {
|
||||
// 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.
|
||||
// no-route. The instance's own vars win, and edit can change any of it. Row 64: the data root's
|
||||
// skills-enabled holds the repository's skills, since a launch whose role names one that isn't
|
||||
// there refuses skill-not-enabled. The fixture makes the data root (0700) to hold them.
|
||||
export function fixture(root, id = "acme", edit = (doc) => doc) {
|
||||
const config = systemConfig(root);
|
||||
const roles = rolesCopy(root);
|
||||
@@ -39,6 +42,8 @@ export function fixture(root, id = "acme", edit = (doc) => doc) {
|
||||
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);
|
||||
mkdirSync(join(root, "data"), { recursive: true, mode: 0o700 });
|
||||
cpSync(REPO_SKILLS, join(root, "data", "skills-enabled"), { recursive: true });
|
||||
return { config, roles, dataRoot: join(root, "data"), doc, env: humanEnv({ MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: roles }) };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
@@ -20,7 +20,6 @@ const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url)
|
||||
async function setup(t) {
|
||||
const root = tmp(t, "mosaic-cli-instances-");
|
||||
const f = fixture(root, "acme");
|
||||
mkdirSync(f.dataRoot, { mode: 0o700 });
|
||||
const dir = join(root, "config");
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { spawn, spawnSync } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { createInterface } from "node:readline";
|
||||
import { connect } from "node:net";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
@@ -70,7 +70,6 @@ function prepare(t, more = (doc) => doc) {
|
||||
const f = fixture(root, "acme", (doc) => more(edit(doc)));
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
f.env.CLAUDE_CODE_OAUTH_TOKEN = "stand-in";
|
||||
mkdirSync(f.dataRoot, { mode: 0o700 });
|
||||
// The project root is a repository on main, so each launch can clone it.
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
@@ -728,6 +727,43 @@ test("a missing or failing bwrap refuses sandbox-unavailable before anything sta
|
||||
assert.deepEqual(readdirSync(join(f.dataRoot, "launches")), ["acme.jsonl"], "no run was made");
|
||||
});
|
||||
|
||||
test("row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts", { timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
// The chief of staff's role file names no skill; this copy gives it one, so --chief takes the check too.
|
||||
const chief = join(given.f.roles, "chief-of-staff.json");
|
||||
writeFileSync(chief, JSON.stringify({ ...JSON.parse(readFileSync(chief, "utf8")), skills: ["ms-unslop"] }, null, 2));
|
||||
const enabled = join(given.f.dataRoot, "skills-enabled");
|
||||
rmSync(join(enabled, "ms-unslop"), { recursive: true });
|
||||
const { f, launcher, cto } = await setup(t, undefined, given);
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && e.message === "PM launch refused: skill-not-enabled (skill ms-unslop isn't enabled; run scripts/skill.sh activate ms-unslop)");
|
||||
// The coder's list is ms-unslop, ms-sdlc, ms-file-read, ms-file-write: the first one missing is named.
|
||||
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "skill-not-enabled" });
|
||||
// A symlink in skills-enabled is refused, not followed.
|
||||
symlinkSync(join(REPO, "skills", "ms-unslop"), join(enabled, "ms-unslop"));
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && e.message === `PM launch refused: skill-refused (skill ms-unslop isn't a plain directory under ${enabled})`);
|
||||
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "skill-refused" });
|
||||
assert.deepEqual(log(f).map((e) => [e.event, e.code]), [["refused", "skill-not-enabled"], ["refused", "skill-not-enabled"], ["refused", "skill-refused"], ["refused", "skill-refused"]]);
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
assert.deepEqual(readdirSync(join(f.dataRoot, "launches")), ["acme.jsonl"], "no run was made");
|
||||
});
|
||||
|
||||
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t, (doc) => {
|
||||
doc.roles.chief.vars.harness = "claude-code";
|
||||
doc.roles.coder.vars.harness = "claude-code";
|
||||
return doc;
|
||||
});
|
||||
const chief = join(given.f.roles, "chief-of-staff.json");
|
||||
writeFileSync(chief, JSON.stringify({ ...JSON.parse(readFileSync(chief, "utf8")), skills: ["ms-unslop"] }, null, 2));
|
||||
rmSync(join(given.f.dataRoot, "skills-enabled", "ms-unslop"), { recursive: true });
|
||||
const { f, launcher, cto } = await setup(t, undefined, given);
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && e.message === "PM launch refused: skill-not-enabled (skill ms-unslop isn't enabled; run scripts/skill.sh activate ms-unslop)");
|
||||
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "skill-not-enabled" });
|
||||
assert.deepEqual(log(f).map((e) => [e.event, e.code]), [["refused", "skill-not-enabled"], ["refused", "skill-not-enabled"]]);
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
assert.deepEqual(readdirSync(join(f.dataRoot, "launches")), ["acme.jsonl"], "no run was made");
|
||||
});
|
||||
|
||||
// The brief's one real Claude Code launch: the host's claude in a coder's
|
||||
// sandbox, logged in with a stand-in proxy key against the scripted Messages
|
||||
// API, runs one Bash call that tries the founder's stand-ins, another run
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { launchDoc } from "../../business/tests/helpers.mjs";
|
||||
@@ -57,7 +57,6 @@ function prepare(t, { sonnet = 2, idle = 1, edit = (doc) => doc } = {}) {
|
||||
return edit(doc);
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
mkdirSync(f.dataRoot, { mode: 0o700 });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
|
||||
@@ -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, harness, route, routeReason, harnessVersion, models, land})`
|
||||
`buildBundle({dir, resolved, contract, arbiter, tracker, credentials, session, harness, route, routeReason, harnessVersion, models, land, skillsDir})`
|
||||
writes the bundle once into `dir` (0700; every file 0600, created with `wx`,
|
||||
so an existing bundle is never overwritten):
|
||||
|
||||
@@ -45,6 +45,18 @@ so an existing bundle is never overwritten):
|
||||
tools). The built-in tools are named as the harness names them, as the
|
||||
manifest lists them: `limits.tools` for Pi, `claudeBuiltins` for Claude
|
||||
Code (Read, Write, Edit, Grep, Glob).
|
||||
When the role file lists skills, a "Skills" section follows, holding
|
||||
every file of each listed skill between `<skill-file skill="NAME"
|
||||
path="REL">` and `</skill-file>`, byte for byte, in sorted path order.
|
||||
- `skills/<name>/`: the bundle's copy of each listed skill, taken from
|
||||
`skillsDir` (`<dataRoot>/skills-enabled`, which `scripts/skill.sh
|
||||
activate` fills). The prompt text is read from the copy, so a skill
|
||||
deactivated after the build leaves the run's copy as it was. A listed
|
||||
skill that isn't enabled refuses `skill-not-enabled`. So does a symlinked
|
||||
skill, `SKILL.md` or sub-file, a file name outside `[A-Za-z0-9._/-]`,
|
||||
non-UTF-8 bytes, a file holding `</skill-file>`, or a role whose skills
|
||||
add up to more than `SKILL_BYTES` (64 KiB; the prompt goes to the harness as
|
||||
one argv argument, and Linux caps one argument at 128 KiB).
|
||||
- `policy.json`: harness, workspace, built-in tools (`limits.tools`), typed
|
||||
tool names, network, the services the role needs, and the credential
|
||||
status the host read from the broker (metadata only, never a token).
|
||||
@@ -317,8 +329,7 @@ These are limits, not bugs, and nothing in this package claims otherwise.
|
||||
`brokerRetries` times, then exit 23. The next host kills what still runs
|
||||
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). The route's `thinking` level reaches the adapter as
|
||||
- **Resolution** runs without the project layer. 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
|
||||
@@ -339,6 +350,10 @@ These are limits, not bugs, and nothing in this package claims otherwise.
|
||||
changed between the check and the tool's own open (by `bash`, or by
|
||||
another process of the same user) isn't seen. That is the same reach as
|
||||
`bash` itself.
|
||||
- **Skills are prompt text, not harness skills.** Pi keeps `--no-skills`
|
||||
and Claude Code keeps `--disable-slash-commands`, so neither harness
|
||||
discovers or loads a skill itself; the role reads its skills in the
|
||||
prompt. A skill's scripts or assets are text there too, never run.
|
||||
- **The system prompt is in argv** for both adapters, so the same UID can
|
||||
read it in `/proc/<pid>/cmdline` outside the sandbox; the PID namespace
|
||||
hides it from other sessions. It holds no secret.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// names them and the manifest records them.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { typedTools, untypedActions } from "./tools.mjs";
|
||||
@@ -97,6 +97,81 @@ 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.`];
|
||||
}
|
||||
|
||||
// Row 64 (#1557): a role's skills, by name, from the skills-enabled directory into the bundle.
|
||||
// The copy is what the session reads, so a deactivate during the session changes nothing. Only
|
||||
// plain files and directories copy; a symlink, a file that isn't UTF-8 or skills past the size
|
||||
// limit refuse, as does a file holding the closing marker the prompt puts after it. The limit is
|
||||
// for all of a role's skills together: they go into prompt.md, and the claude adapter passes
|
||||
// prompt.md as one argument, which Linux caps at 128 KiB.
|
||||
export const SKILL_BYTES = 64 * 1024;
|
||||
const SKILL_END = "</skill-file>";
|
||||
const SKILL_PATH = /^[A-Za-z0-9._-]+$/;
|
||||
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
||||
export function skillNotEnabled(name) {
|
||||
return new BundleError(`skill ${name} isn't enabled; run scripts/skill.sh activate ${name}`, "skill-not-enabled", name);
|
||||
}
|
||||
function readSkill(name, root, size) {
|
||||
const files = [];
|
||||
const walk = (from, rel) => {
|
||||
for (const entry of readdirSync(from).sort()) {
|
||||
const path = join(from, entry), at = rel ? `${rel}/${entry}` : entry;
|
||||
// The name goes into the marker line, so it keeps to the skill name's characters.
|
||||
if (!SKILL_PATH.test(entry)) throw new BundleError(`skill ${name}: ${JSON.stringify(at)} isn't a plain file name`);
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isDirectory()) { walk(path, at); continue; }
|
||||
if (!stat.isFile()) throw new BundleError(`skill ${name}: ${at} isn't a plain file or directory`);
|
||||
const data = readFileSync(path);
|
||||
size.bytes += data.length;
|
||||
if (size.bytes > SKILL_BYTES) throw new BundleError(`the role's skills are over ${SKILL_BYTES} bytes together, at skill ${name}`);
|
||||
let text;
|
||||
try { text = utf8.decode(data); } catch { throw new BundleError(`skill ${name}: ${at} isn't UTF-8 text`); }
|
||||
if (text.includes(SKILL_END)) throw new BundleError(`skill ${name}: ${at} holds ${SKILL_END}`);
|
||||
files.push({ rel: at, text });
|
||||
}
|
||||
};
|
||||
const found = (path) => {
|
||||
try { return lstatSync(path); } catch (e) { if (e.code === "ENOENT") throw skillNotEnabled(name); throw e; }
|
||||
};
|
||||
// scripts/skill.sh activate moves a real directory in, so a symlink here is refused, not followed.
|
||||
if (!found(join(root, name)).isDirectory()) throw new BundleError(`skill ${name} isn't a plain directory under ${root}`);
|
||||
if (!found(join(root, name, "SKILL.md")).isFile()) throw new BundleError(`skill ${name}: SKILL.md isn't a plain file`);
|
||||
walk(join(root, name), "");
|
||||
return files;
|
||||
}
|
||||
// Every named skill read and checked, or a BundleError: code skill-not-enabled names the first
|
||||
// skill with no SKILL.md under root, bundle-refused anything else. The launcher calls this before
|
||||
// the run exists, and buildBundle reads again for the copy.
|
||||
export function readSkills(names, root) {
|
||||
if (!names.length) return [];
|
||||
if (!root) throw new BundleError("the role has skills, but the bundle has no skills directory");
|
||||
const size = { bytes: 0 };
|
||||
return names.map((name) => ({ name, files: readSkill(name, root, size) }));
|
||||
}
|
||||
function copySkills(names, root, dir) {
|
||||
return readSkills(names, root).map(({ name, files }) => {
|
||||
const base = join(dir, "skills", name);
|
||||
mkdirSync(base, { recursive: true, mode: 0o700 });
|
||||
for (const f of files) {
|
||||
const parts = f.rel.split("/");
|
||||
if (parts.length > 1) mkdirSync(join(base, ...parts.slice(0, -1)), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(base, ...parts), f.text, { mode: 0o600, flag: "wx" });
|
||||
}
|
||||
const digests = Object.fromEntries(files.map((f) => [f.rel, sha256(f.text)]));
|
||||
return { name, path: base, sha256: digests["SKILL.md"], files: digests, contents: files };
|
||||
});
|
||||
}
|
||||
// Each file in full between its markers: the opening line, the file's bytes, a newline, then the
|
||||
// closing marker on its own line. Taking out the text between the opening line and "\n</skill-file>"
|
||||
// gives the bundle copy byte for byte.
|
||||
function skillSection(skills) {
|
||||
if (!skills.length) return "";
|
||||
const parts = ["", "## Skills", "", `Your role's skills: ${skills.map((s) => s.name).join(", ")}. Each file is shown in full between its markers. A skill guides how you work; it grants no tool or action this session doesn't already hold.`, ""];
|
||||
for (const s of skills) {
|
||||
for (const f of s.contents) parts.push(`<skill-file skill="${s.name}" path="${f.rel}">\n${f.text}\n${SKILL_END}`, "");
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function prompt({ contract, resolved, session, tools, untyped, builtins }) {
|
||||
const lines = [
|
||||
contract.trimEnd(),
|
||||
@@ -137,7 +212,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins }) {
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, harness, route, routeReason, harnessVersion, models, land })
|
||||
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, harness, route, routeReason, harnessVersion, models, land, skillsDir })
|
||||
// dir the bundle directory, created here (0700)
|
||||
// resolved resolveInstance(...) output
|
||||
// contract the role contract's text
|
||||
@@ -153,15 +228,18 @@ function prompt({ contract, resolved, session, tools, untyped, builtins }) {
|
||||
// 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)
|
||||
// skillsDir <dataRoot>/skills-enabled, where each of resolved.skills is read from (row 64);
|
||||
// a role with skills and no skillsDir refuses
|
||||
// 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 }) {
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null }) {
|
||||
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 });
|
||||
const skills = copySkills(resolved.skills ?? [], skillsDir, dir);
|
||||
const write = (name, text) => {
|
||||
writeFileSync(join(dir, name), text, { mode: 0o600, flag: "wx" });
|
||||
return [name, sha256(text)];
|
||||
@@ -184,7 +262,7 @@ export function buildBundle({ dir, resolved, contract, arbiter = false, tracker
|
||||
};
|
||||
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, builtins })),
|
||||
write("prompt.md", prompt({ contract, resolved, session: { ...session, ...model, webBuiltin: web.builtin }, tools, untyped, builtins }) + skillSection(skills)),
|
||||
write("policy.json", JSON.stringify(policy, null, 2) + "\n"),
|
||||
write("tools.json", JSON.stringify(tools, null, 2) + "\n"),
|
||||
];
|
||||
@@ -209,7 +287,7 @@ export function buildBundle({ dir, resolved, contract, arbiter = false, tracker
|
||||
typed: tools.map((t) => harness.typedName(t.name)),
|
||||
untyped,
|
||||
webSource: resolved.limits.tools.includes("web") ? web.source : null,
|
||||
skills: [],
|
||||
skills: skills.map(({ name, path, sha256, files }) => ({ name, path, sha256, files })),
|
||||
reliesOn: [...harness.reliesOn()],
|
||||
gateTimeout: harness.gateTimeout(),
|
||||
files: Object.fromEntries(digests),
|
||||
|
||||
@@ -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 { HARNESS, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { decide } from "../src/gate.mjs";
|
||||
|
||||
const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex");
|
||||
@@ -23,6 +23,7 @@ function build(t, resolved, extra = {}) {
|
||||
session: { run: "run-1", launchedBy: "pm", workspace, toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done") },
|
||||
harness: HARNESS[sessionModel(resolved, null, extra.route ?? "primary").harness],
|
||||
harnessVersion: "fixture-version",
|
||||
skillsDir: REPO_SKILLS,
|
||||
...extra,
|
||||
});
|
||||
return { dir, workspace, ...bundle };
|
||||
@@ -76,7 +77,7 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory"
|
||||
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.deepEqual(m.skills.map((x) => x.name), ["ms-unslop", "ms-goal", "ms-communications"]);
|
||||
assert.equal(m.reliesOn.length, 5);
|
||||
assert.ok(!m.typed.includes("launch"), "row 119: the PM no longer launches");
|
||||
assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools"]);
|
||||
@@ -229,6 +230,7 @@ test("a bundle is written once: an existing file refuses", (t) => {
|
||||
contract: "c",
|
||||
session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") },
|
||||
harness: HARNESS.pi,
|
||||
skillsDir: REPO_SKILLS,
|
||||
}), /EEXIST/);
|
||||
assert.equal(readFileSync(join(dir, "bundle", "prompt.md"), "utf8"), "old");
|
||||
});
|
||||
@@ -242,6 +244,7 @@ test("a path with a single quote can't go into the hook command", (t) => {
|
||||
contract: "c",
|
||||
session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") },
|
||||
harness: HARNESS["claude-code"],
|
||||
skillsDir: REPO_SKILLS,
|
||||
}), (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, HARNESS, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = join(REPO, "adapters", "claude", "adapter.sh");
|
||||
const which = spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" });
|
||||
@@ -42,7 +42,7 @@ async function session(t, script, tools = ["read", "bash"]) {
|
||||
toolSocket: sock.path,
|
||||
turnMarker: join(dir, "turn.done"),
|
||||
};
|
||||
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" } });
|
||||
const { files, manifest, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", harness: HARNESS["claude-code"], skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "pm" } });
|
||||
s.bundle = files;
|
||||
s.harnessEnv = harnessEnv;
|
||||
const env = adapterEnv(s, {
|
||||
|
||||
@@ -13,6 +13,9 @@ import { businessDoc, REPO_ROLES, systemFor } from "../../business/tests/helpers
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
|
||||
export { REPO } from "../../business/tests/helpers.mjs";
|
||||
import { REPO } from "../../business/tests/helpers.mjs";
|
||||
// Row 64: the repository's skill directories, as <dataRoot>/skills-enabled holds them once activated.
|
||||
export const REPO_SKILLS = join(REPO, "skills");
|
||||
|
||||
export function scratch(t, prefix = "mosaic-harness-") {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
||||
|
||||
@@ -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, HARNESS, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
|
||||
|
||||
@@ -44,7 +44,7 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
|
||||
turnMarker: join(dir, "turn.done"),
|
||||
};
|
||||
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" } });
|
||||
const { files, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# PM\n", tracker: true, harness: HARNESS.pi, models, skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "jason" } });
|
||||
s.bundle = files;
|
||||
s.harnessEnv = harnessEnv;
|
||||
const env = adapterEnv(s, {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// Row 64 (#1557): a role's skills reach the session as prompt text. The bundle copies each
|
||||
// skill on the role's list from <dataRoot>/skills-enabled, and prompt.md carries every copied
|
||||
// file between markers, byte for byte. Neither harness discovers a skill on its own.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BundleError, buildBundle, readSkills, SKILL_BYTES } from "../src/bundle.mjs";
|
||||
import { adapterEnv } from "../src/runner.mjs";
|
||||
import { HARNESS, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const sha = (text) => createHash("sha256").update(text).digest("hex");
|
||||
const MARKED = /<skill-file skill="([^"]+)" path="([^"]+)">\n([\s\S]*?)\n<\/skill-file>/g;
|
||||
|
||||
function build(t, resolved, skillsDir = REPO_SKILLS) {
|
||||
const dir = scratch(t);
|
||||
const workspace = join(dir, "ws");
|
||||
mkdirSync(workspace);
|
||||
const harness = HARNESS[resolved.vars.harness];
|
||||
const bundle = buildBundle({
|
||||
dir: join(dir, "bundle"),
|
||||
resolved,
|
||||
contract: "# Contract\n",
|
||||
session: { run: "run-1", launchedBy: "pm", workspace, toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done") },
|
||||
harness,
|
||||
skillsDir,
|
||||
});
|
||||
return { dir, ...bundle, prompt: readFileSync(bundle.files.prompt, "utf8") };
|
||||
}
|
||||
|
||||
// A skills-enabled directory in scratch holding copies of the named repo skills.
|
||||
function enabled(t, names) {
|
||||
const root = join(scratch(t, "mosaic-skills-"), "skills-enabled");
|
||||
mkdirSync(root);
|
||||
for (const name of names) cpSync(join(REPO_SKILLS, name), join(root, name), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
const withSkills = (t, skills, instance = "reviewer") => ({ ...resolvedFor(t, instance), skills });
|
||||
const refused = (pattern, code = "bundle-refused") => (e) => e instanceof BundleError && e.code === code && pattern.test(e.message);
|
||||
|
||||
test("each repo role's prompt carries exactly its listed skills, each file as the bundle copy and the source", (t) => {
|
||||
for (const instance of ["coder", "reviewer", "pm", "cto"]) {
|
||||
for (const harness of ["pi", "claude-code"]) {
|
||||
const r = resolvedFor(t, instance, (d) => (d.roles[instance].vars = { harness, model: "claude-opus-5-5", thinking: "high" }));
|
||||
assert.ok(r.skills.length > 0, instance);
|
||||
const b = build(t, r);
|
||||
const marked = [...b.prompt.matchAll(MARKED)].map(([, name, rel, text]) => ({ name, rel, text }));
|
||||
assert.deepEqual([...new Set(marked.map((m) => m.name))], r.skills, `${instance} ${harness}`);
|
||||
assert.match(b.prompt, new RegExp(`\\n## Skills\\n\\nYour role's skills: ${r.skills.join(", ")}\\. `));
|
||||
assert.deepEqual(b.manifest.skills.map((s) => s.name), r.skills);
|
||||
for (const s of b.manifest.skills) {
|
||||
assert.equal(s.path, join(b.dir, "bundle", "skills", s.name));
|
||||
assert.equal(s.sha256, s.files["SKILL.md"]);
|
||||
const files = marked.filter((m) => m.name === s.name);
|
||||
assert.deepEqual(files.map((f) => f.rel), Object.keys(s.files), s.name);
|
||||
for (const f of files) {
|
||||
const copy = readFileSync(join(s.path, f.rel), "utf8");
|
||||
assert.equal(f.text, copy, `${s.name}/${f.rel} prompt vs copy`);
|
||||
assert.equal(copy, readFileSync(join(REPO_SKILLS, s.name, f.rel), "utf8"), `${s.name}/${f.rel} copy vs source`);
|
||||
assert.equal(s.files[f.rel], sha(copy), `${s.name}/${f.rel} digest`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(JSON.parse(readFileSync(b.files.manifest, "utf8")).skills, JSON.parse(JSON.stringify(b.manifest.skills)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a skill's files go into the prompt in sorted path order, sub-directories included", (t) => {
|
||||
const b = build(t, withSkills(t, ["ms-grill-me"]));
|
||||
assert.deepEqual([...b.prompt.matchAll(MARKED)].map((m) => m[2]), ["SKILL.md", "upstream-grill-me.md", "upstream-grilling.md"]);
|
||||
const goal = build(t, withSkills(t, ["ms-goal"]));
|
||||
assert.deepEqual([...goal.prompt.matchAll(MARKED)].map((m) => m[2]), ["SKILL.md", "references/execution-checks.md"]);
|
||||
});
|
||||
|
||||
test("a role with no skills gets no Skills section, no skills directory and an empty manifest list", (t) => {
|
||||
for (const harness of ["pi", "claude-code"]) {
|
||||
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
||||
assert.deepEqual(r.skills, []);
|
||||
for (const skillsDir of [REPO_SKILLS, null]) {
|
||||
const b = build(t, r, skillsDir);
|
||||
assert.doesNotMatch(b.prompt, /## Skills|<skill-file/, harness);
|
||||
assert.deepEqual(b.manifest.skills, [], harness);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(readSkills([], null), []);
|
||||
});
|
||||
|
||||
test("an enabled skill the role doesn't list stays out of the prompt", (t) => {
|
||||
const root = enabled(t, ["ms-unslop", "ms-sdlc", "ms-file-read", "ms-archify"]);
|
||||
for (const harness of ["pi", "claude-code"]) {
|
||||
const b = build(t, resolvedFor(t, "reviewer", (d) => (d.roles.reviewer.vars = { harness, model: "claude-opus-5-5", thinking: "high" })), root);
|
||||
assert.doesNotMatch(b.prompt, /ms-archify/, harness);
|
||||
assert.deepEqual(b.manifest.skills.map((s) => s.name), ["ms-unslop", "ms-sdlc", "ms-file-read"], harness);
|
||||
}
|
||||
});
|
||||
|
||||
test("the bundle copy outlives the source: deactivating a skill after the build leaves the run's copy", (t) => {
|
||||
const root = enabled(t, ["ms-unslop", "ms-sdlc", "ms-file-read"]);
|
||||
const b = build(t, resolvedFor(t, "reviewer"), root);
|
||||
rmSync(join(root, "ms-sdlc"), { recursive: true });
|
||||
const s = b.manifest.skills.find((x) => x.name === "ms-sdlc");
|
||||
assert.equal(sha(readFileSync(join(s.path, "SKILL.md"), "utf8")), s.sha256);
|
||||
});
|
||||
|
||||
test("a listed skill that isn't enabled refuses skill-not-enabled, naming the activate command", (t) => {
|
||||
const root = enabled(t, ["ms-unslop", "ms-file-read"]);
|
||||
for (const harness of ["pi", "claude-code"]) {
|
||||
const r = resolvedFor(t, "reviewer", (d) => (d.roles.reviewer.vars = { harness, model: "claude-opus-5-5", thinking: "high" }));
|
||||
assert.throws(() => build(t, r, root), refused(/^skill ms-sdlc isn't enabled; run scripts\/skill\.sh activate ms-sdlc$/, "skill-not-enabled"), harness);
|
||||
}
|
||||
assert.throws(() => readSkills(["ms-sdlc"], root), (e) => e.code === "skill-not-enabled" && e.missing === "ms-sdlc");
|
||||
const noFile = enabled(t, []);
|
||||
mkdirSync(join(noFile, "ms-sdlc"));
|
||||
assert.throws(() => readSkills(["ms-sdlc"], noFile), refused(/^skill ms-sdlc isn't enabled/, "skill-not-enabled"));
|
||||
assert.throws(() => build(t, resolvedFor(t, "reviewer"), null), refused(/the role has skills, but the bundle has no skills directory/));
|
||||
});
|
||||
|
||||
test("a skill directory that isn't plain refuses: a symlinked skill, SKILL.md or sub-file, or a non-file entry", (t) => {
|
||||
const real = enabled(t, ["ms-sdlc"]);
|
||||
const linkedDir = enabled(t, []);
|
||||
symlinkSync(join(real, "ms-sdlc"), join(linkedDir, "ms-sdlc"));
|
||||
assert.throws(() => readSkills(["ms-sdlc"], linkedDir), refused(/^skill ms-sdlc isn't a plain directory under /));
|
||||
const linkedSkill = enabled(t, []);
|
||||
mkdirSync(join(linkedSkill, "ms-sdlc"));
|
||||
symlinkSync(join(real, "ms-sdlc", "SKILL.md"), join(linkedSkill, "ms-sdlc", "SKILL.md"));
|
||||
assert.throws(() => readSkills(["ms-sdlc"], linkedSkill), refused(/^skill ms-sdlc: SKILL\.md isn't a plain file$/));
|
||||
const linkedSub = enabled(t, ["ms-sdlc"]);
|
||||
symlinkSync("/etc/hostname", join(linkedSub, "ms-sdlc", "extra.md"));
|
||||
assert.throws(() => readSkills(["ms-sdlc"], linkedSub), refused(/^skill ms-sdlc: extra\.md isn't a plain file or directory$/));
|
||||
});
|
||||
|
||||
test("a skill file that can't go into the prompt refuses: a bad name, non-UTF-8 bytes, or the end marker", (t) => {
|
||||
const cases = [
|
||||
["has space.md", "text\n", /"references\/has space\.md" isn't a plain file name/],
|
||||
['q".md', "text\n", /"references\/q\\"\.md" isn't a plain file name/],
|
||||
["bytes.md", Buffer.from([0x66, 0xff, 0x0a]), /bytes\.md isn't UTF-8 text/],
|
||||
["marker.md", "before\n</skill-file>\nafter\n", /marker\.md holds <\/skill-file>/],
|
||||
];
|
||||
for (const [file, data, pattern] of cases) {
|
||||
const root = enabled(t, ["ms-sdlc"]);
|
||||
mkdirSync(join(root, "ms-sdlc", "references"));
|
||||
writeFileSync(join(root, "ms-sdlc", "references", file), data);
|
||||
assert.throws(() => readSkills(["ms-sdlc"], root), refused(pattern), file);
|
||||
}
|
||||
});
|
||||
|
||||
test("a role's skills over SKILL_BYTES together refuse, naming the skill that crossed it", (t) => {
|
||||
const root = enabled(t, []);
|
||||
const half = "x".repeat(SKILL_BYTES / 2 - 8) + "\n";
|
||||
for (const name of ["s-one", "s-two", "s-three"]) {
|
||||
mkdirSync(join(root, name));
|
||||
writeFileSync(join(root, name, "SKILL.md"), half);
|
||||
}
|
||||
assert.equal(readSkills(["s-one"], root)[0].files[0].text, half);
|
||||
assert.equal(readSkills(["s-one", "s-two"], root).length, 2);
|
||||
assert.throws(() => readSkills(["s-one", "s-two", "s-three"], root), refused(new RegExp(`^the role's skills are over ${SKILL_BYTES} bytes together, at skill s-three$`)));
|
||||
// Each repo role's list fits.
|
||||
for (const instance of ["coder", "reviewer", "pm", "cto"]) {
|
||||
assert.doesNotThrow(() => readSkills(resolvedFor(t, instance).skills, REPO_SKILLS), instance);
|
||||
}
|
||||
});
|
||||
|
||||
// Mos's check (mos-0857): the adapter hands the harness the prompt with the skills in it, and
|
||||
// discovery stays off. A stand-in for each harness's CLI records the adapter's argv, NUL-separated.
|
||||
// The chief of staff lists no skill: its prompt carries none and discovery is off all the same.
|
||||
test("each adapter passes the skills only as --system-prompt text, with skill discovery off", (t) => {
|
||||
for (const [instance, harness] of [["reviewer", "pi"], ["reviewer", "claude-code"], ["chief", "pi"], ["chief", "claude-code"]]) {
|
||||
const dir = scratch(t);
|
||||
const workspace = join(dir, "ws");
|
||||
mkdirSync(workspace);
|
||||
mkdirSync(join(dir, "bin"));
|
||||
const cli = join(dir, "bin", harness === "pi" ? "pi" : "claude");
|
||||
writeFileSync(cli, '#!/bin/sh\nprintf "%s\\0" "$@" > "$ARGV_OUT"\necho ok\n');
|
||||
chmodSync(cli, 0o755);
|
||||
const resolved = resolvedFor(t, instance, (d) => (d.roles[instance].vars = { harness, model: "claude-opus-5-5", thinking: "high" }));
|
||||
assert.equal(resolved.skills.length > 0, instance === "reviewer", instance);
|
||||
const s = { harness, provider: harness === "pi" ? "probe" : null, model: "claude-opus-5-5", workspace, sessionDir: join(dir, "session"), toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done") };
|
||||
const models = { "claude-opus-5-5": { provider: "probe", model: "claude-opus-5-5" } };
|
||||
const b = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Contract\n", harness: HARNESS[harness], models, skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "pm" } });
|
||||
s.bundle = b.files;
|
||||
s.harnessEnv = b.env;
|
||||
const env = { ...adapterEnv(s, { PATH: `${join(dir, "bin")}:/usr/bin:/bin`, HOME: join(dir, "home") }), ARGV_OUT: join(dir, "argv"), MOSAIC_REQUEST: "x" };
|
||||
const r = spawnSync("/bin/sh", [join(REPO, "adapters", harness === "pi" ? "pi" : "claude", "adapter.sh")], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env });
|
||||
assert.equal(r.status, 0, `${harness}: ${r.stderr}`);
|
||||
const argv = readFileSync(join(dir, "argv"), "utf8").split("\0");
|
||||
const i = argv.indexOf("--system-prompt");
|
||||
assert.ok(i >= 0 && argv.indexOf("--system-prompt", i + 1) < 0, harness);
|
||||
// The adapter reads the file with $(cat ...), which drops trailing newlines.
|
||||
assert.equal(argv[i + 1], readFileSync(b.files.prompt, "utf8").replace(/\n+$/, ""), harness);
|
||||
const marked = [...argv[i + 1].matchAll(MARKED)];
|
||||
assert.deepEqual([...new Set(marked.map((m) => m[1]))], resolved.skills, harness);
|
||||
if (instance === "chief") assert.doesNotMatch(argv[i + 1], /## Skills|<skill-file/, harness);
|
||||
for (const [, name, rel, text] of marked) assert.equal(text, readFileSync(join(dir, "bundle", "skills", name, rel), "utf8"), `${harness} ${name}/${rel}`);
|
||||
// No skill reaches the harness any other way.
|
||||
const rest = argv.filter((a, k) => k !== i + 1);
|
||||
assert.ok(!rest.some((a) => a.includes(join(dir, "bundle", "skills")) || a.includes(REPO_SKILLS) || /SKILL\.md|<skill-file/.test(a)), `${harness}: ${rest.join(" ")}`);
|
||||
if (harness === "pi") {
|
||||
assert.ok(argv.includes("--no-skills"), argv.join(" "));
|
||||
assert.ok(!argv.includes("--skill"), argv.join(" "));
|
||||
} else {
|
||||
assert.ok(argv.includes("--disable-slash-commands"), argv.join(" "));
|
||||
assert.ok(argv.includes("--restricted"), argv.join(" "));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
@@ -42,7 +42,6 @@ const send = (s, path, data, hs = headers, method = 'POST') => new Promise((reso
|
||||
test('row 165 Console: the signed-in human adds an instance through a real broker; validate exits 0 and the log names the human', async t => {
|
||||
const root = tmp(t, 'webui-instances-');
|
||||
const f = fixture(root, 'acme');
|
||||
mkdirSync(f.dataRoot, { mode: 0o700 });
|
||||
const dir = join(root, 'config');
|
||||
const host = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {request} from 'node:http';
|
||||
import {mkdirSync,readFileSync} from 'node:fs';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {browser} from './browser.mjs';
|
||||
import {startServer} from '../src/serve.mjs';
|
||||
@@ -29,7 +29,6 @@ test('row 65 Settings: the routing table lists every instance, saves a harness,
|
||||
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,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
@@ -41,7 +41,6 @@ const get = (s, path) => send(s, path, undefined, { host: 'console.example', 'x-
|
||||
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,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"contract": "coder.md",
|
||||
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"skills": ["ms-unslop", "ms-sdlc", "ms-file-read", "ms-file-write"],
|
||||
"authority": {
|
||||
"withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send", "record.write"],
|
||||
"crossRole": ["task.scope.change"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"contract": "cto.md",
|
||||
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"skills": ["ms-unslop", "ms-sdlc", "ms-archify", "ms-grill-me"],
|
||||
"authority": {
|
||||
"withinRole": ["review.request", "task.update.assigned", "message.send", "decision.resolve.technical", "record.write"],
|
||||
"crossRole": ["task.scope.change"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"contract": "pm.md",
|
||||
"tools": ["read", "write", "edit", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"skills": ["ms-unslop", "ms-goal", "ms-communications"],
|
||||
"authority": {
|
||||
"withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "message.send", "record.write"],
|
||||
"crossRole": ["task.priority.change", "task.scope.change"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"contract": "reviewer.md",
|
||||
"tools": ["read", "grep", "find", "ls", "bash"],
|
||||
"network": "api-only",
|
||||
"skills": ["ms-unslop", "ms-sdlc", "ms-file-read"],
|
||||
"authority": {
|
||||
"withinRole": ["review.verdict", "message.send", "task.update.assigned", "record.write"],
|
||||
"crossRole": []
|
||||
|
||||
Reference in New Issue
Block a user