fix(agents): A3 hidden checkouts cover every left-out project and refuse links, final admission binds the organization revision (#1873)

This commit was merged in pull request #1917.
This commit is contained in:
jason.woltje committed 2026-11-10 20:27:33 -06:00
1 parent 29f7c3af85
commit 82dcba2d45
6 files changed
+146 -17

No files matched your search

+1 -1
View File
@@ -88,7 +88,7 @@ function launchOp(m) {
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
if (m.op === 'request.chief') return runtime.requestChief(m.business);
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, thread: m.thread, session: m.session });
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
+10 -2
View File
@@ -631,7 +631,9 @@ export async function startBroker({
// broker's reload now follows both readiness records (#802).
// With `instance` (#1873 A3), the launch boundary also asks whether that instance may work in each
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
function admission({ business: id, project, projects, instance, thread, session }) {
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
if (projects !== undefined) {
const no = code => ({ ready: false, revision: null, code });
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
@@ -640,7 +642,13 @@ export async function startBroker({
if (!row) return no('session-not-found');
if (row.archived) return no('project-archived');
}
if (instance !== undefined && typeof instance !== 'string') return no('project-unknown');
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
if (instance !== undefined) {
let now;
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
if (now === undefined) return no('organization-unreadable');
if (now !== launched) return no('organization-changed');
}
for (const p of projects) {
const result = admission({ business: id, project: p });
if (!result.ready) return result;
+27 -9
View File
@@ -551,12 +551,12 @@ export function createLauncher({
await pickRoute(() => standIn);
await gate(model, harness, null);
admission(admissionGeneration);
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver };
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver, revision: businessRevision };
}
const sessionProxy = await pickRoute(proxyFor);
const account = await gate(model, harness, sessionProxy);
admission(admissionGeneration);
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver };
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver, revision: businessRevision };
}
// Reread host state on every new-run path, including opens/clears queued before archive.
@@ -635,12 +635,13 @@ export function createLauncher({
// own project isn't admitted. A checkout an earlier launch left in the workspace for a project
// the instance may not work in now is hidden from the session, never deleted (sandbox.mjs).
const own = copyOf(b, instance)?.project ?? null;
const barred = [];
const declared = new Set();
for (const [project, entry] of Object.entries(b.projects)) {
if (entry.kind === 'library') continue;
if (own !== null && project !== own) { barred.push(project); continue; }
declared.add(project);
if (own !== null && project !== own) continue;
const unavailable = projectRefusal(b, instance, project);
if (unavailable) { barred.push(project); record({ event: "checkout-skipped", instance, project, reason: unavailable.code }); continue; }
if (unavailable) { record({ event: "checkout-skipped", instance, project, reason: unavailable.code }); continue; }
let answer = null;
try { answer = await host.op({ op: 'request.admission', business: b.id, project }); } catch (e) { answer = { ready: false, code: e.code ?? 'admission-unavailable' }; }
if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; }
@@ -670,8 +671,8 @@ export function createLauncher({
catch (error) { throw new Refusal(error.code ?? 'proxy-down', error.code ?? 'proxy-down'); }
if (!admittedProxy || typeof admittedProxy.key !== 'string' || !admittedProxy.key || admittedProxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
}
let harness, route, routeReason, sessionProxy, resolver;
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
let harness, route, routeReason, sessionProxy, resolver, revision;
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver,revision}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
admission(admissionGeneration);
// #1873 A3: check() read the organization again. A project that excluded or refused this
// instance while its checkouts were prepared refuses the launch; the next one leaves it out.
@@ -679,6 +680,21 @@ export function createLauncher({
const unavailable = projectRefusal(b, instance, project);
if (unavailable) throw new Refusal(unavailable.code, unavailable.message);
}
// #1873 A3: every declared code project this launch leaves out, in the document it started from
// or the one check() read, is hidden if an earlier launch left its checkout in the workspace,
// whatever kept it out (not admitted, excluded, refused, another project's agent). Only a real
// directory can be covered: a tmpfs mounted at a symlink would land on its target, so anything
// else at that path refuses the launch, and nothing on the host is changed or removed.
for (const [project, entry] of Object.entries(b.projects)) if (entry.kind !== 'library') declared.add(project);
const hidden = [];
for (const project of declared) {
if (Object.hasOwn(codeProjects, project)) continue;
const path = join(workspace, project);
let stat;
try { stat = lstatSync(path); } catch (e) { if (e.code === "ENOENT") continue; throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} can't be read`); }
if (!stat.isDirectory()) throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} isn't a directory, so it can't be hidden; a person removes it before ${instance} launches again`);
hidden.push(path);
}
const { run, runDir } = newRun(dataRoot, b.id);
let modelKeyId;
if (admittedProxy) {
@@ -774,7 +790,7 @@ export function createLauncher({
gitSocket: git,
threadStore: store,
readOnly,
hidden: barred.map((project) => join(workspace, project)).filter((p) => { try { return lstatSync(p).isDirectory(); } catch { return false; } }),
hidden,
};
admission(admissionGeneration);
await threadAdmission(thread, session);
@@ -785,7 +801,9 @@ export function createLauncher({
}
// One synchronous broker snapshot covers the thread and the entire manifest after every
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, thread, session });
// #1873 A3: `revision` is the organization file check() resolved the bundle from; a file that
// changed since (a narrow, a define's vars, an exclude) refuses, and the next launch resolves anew.
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session });
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
let child;
+94 -3
View File
@@ -9,7 +9,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { businessDoc, launchDoc, tokenFile, writeJson } from "../../organization/tests/helpers.mjs";
@@ -340,9 +340,100 @@ test("A3: a project that excludes the agent while its launch is prepared refuses
assert.equal(s.running("gate").length, 0);
// Once the bus has the change, its launch-boundary admission refuses the instance there too.
reload(s);
assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate" }).code, "project-refused");
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).ready, true);
const revision = s.call("instances").revision;
assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate", revision }).code, "project-refused");
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision }).ready, true);
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack", "web"] }).ready, true);
// The launch boundary takes an instance only with the revision its bundle came from.
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).code, "project-unknown");
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision: "0".repeat(64) }).code, "organization-changed");
});
// Launches gate with both projects, reads web from inside the sandbox, and ends the run, so the
// workspace keeps a web checkout an earlier launch made.
async function launchBoth(s) {
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const [run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack", "web"]);
assert.equal(await webSeen(s, run), "READABLE");
await s.carry(s.call("instance.end", { instance: "gate" }).request);
await until(() => s.running("gate").length === 0);
}
const readsWeb = (p) => writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
test("A3: a checkout of a project the bus doesn't admit now is out of sight too, not only an excluded one", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
readsWeb(p);
const s = await start(t, p);
await launchBoth(s);
// web is declared and gate is in its union, but the bus doesn't admit the project for this launch.
const op = s.host.op;
s.host.op = async (m) => (m.op === "request.admission" && m.project === "web" ? { ready: false, revision: null, code: "project-pending" } : op(m));
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const [run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack"]);
assert.equal(await webSeen(s, run), "ABSENT");
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
});
test("A3: a symlink or a file where a hidden checkout would be refuses the launch, and nothing on the host is touched", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
readsWeb(p);
const s = await start(t, p);
await launchBoth(s);
const ws = join(s.dataRoot, "workspaces", "acme", "gate");
// The run's own workspace moves the checkout aside and leaves a link at web, then web excludes gate.
renameSync(join(ws, "web"), join(ws, "kept"));
symlinkSync(join(ws, "kept"), join(ws, "web"));
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
reload(s);
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
assert.equal(s.running("gate").length, 0);
assert.equal(lstatSync(join(ws, "web")).isSymbolicLink(), true);
assert.equal(readFileSync(join(ws, "kept", "file.txt"), "utf8"), "one\n");
// A plain file there refuses the same way.
renameSync(join(ws, "web"), join(ws, "link"));
writeFileSync(join(ws, "web"), "not a checkout\n");
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
assert.equal(readFileSync(join(ws, "web"), "utf8"), "not a checkout\n");
});
test("A3: a narrow that lands after the launcher's recheck and before the bus's last admission refuses the launch; the next one takes it", { skip, timeout: 90000 }, async (t) => {
for (const [instance, narrow, after] of [
// An inherited agent's narrow in its project.
["gate", (d) => { d.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } }; }, "launches"],
// A project agent's define: its vars change after boot.
["checker.stack", (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; }, "project-refused"],
]) {
const p = prepare(t);
const s = await start(t, p);
const op = s.host.op;
let landed = false;
// The narrow lands after every launcher read, just before the bus's last admission answers.
s.host.op = async (m) => {
if (!landed && m.op === "request.admission" && m.projects !== undefined) { landed = true; editOrg(p, narrow); reload(s); }
return op(m);
};
let out = await s.carry(s.call("instance.launch", { instance }).request);
assert.equal(landed, true, instance);
assert.equal(out.done.refusal, "organization-changed", `${instance} ${JSON.stringify(out)}`);
assert.equal(s.running(instance).length, 0, instance);
// The next launch resolves from the narrowed file.
if (after === "launches") {
out = await s.carry(s.call("instance.launch", { instance }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const tools = bundleOf(s.running(instance)[0], "policy.json").tools;
assert.ok(tools.includes("read"), JSON.stringify(tools));
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
} else {
assert.equal(refusedAt(s, instance).code, after, instance);
}
await s.close();
}
});
test("A3: a project define whose vars change after boot refuses until the next start rather than launch with the old ones", { skip, timeout: 90000 }, async (t) => {
+4
View File
@@ -114,6 +114,7 @@ function systemBinds(node = process.execPath) {
// `hidden` (#1873 A3) lists directories inside the workspace the session may not see: a project
// checkout a launch before this one made, in a project that now excludes or refuses the instance.
// Each gets an empty tmpfs over it, so its files stay on the host, untouched, and out of sight.
// Each must be a real directory: a tmpfs mounted at a symlink would land on the link's target.
// `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, workspaceDest = null, hidden = [], adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
const out = systemBinds(node);
@@ -129,6 +130,9 @@ export function binds({ runDir, workspace, workspaceDest = null, hidden = [], ad
...hidden.map((h) => {
const inside = relative(workspace, h);
if (!inside || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`hidden path ${h} is not inside the workspace`);
let real = false;
try { real = lstatSync(h).isDirectory(); } catch { real = false; }
if (!real) throw new Error(`hidden path ${h} is not a directory`);
return { kind: "tmpfs", dest: join(workspaceDest ?? workspace, inside), why: "a project checkout this launch may not see, covered by an empty tmpfs" };
}),
{ kind: "rw", src: runDir, why: "the run directory: turns/, session/, tools.sock, turn.done, home/, tmp/" },
+10 -2
View File
@@ -9,7 +9,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { Broker } from "../../bus/src/broker.mjs";
@@ -93,9 +93,11 @@ test("every bind says why; the bundle and session file stay read-only over the r
// #1873 A3: a checkout the launch may not see is covered by an empty tmpfs after the workspace bind,
// at the path the session sees; a path outside the workspace is refused, never mounted.
test("A3 a hidden checkout is a tmpfs over the workspace bind; a path outside the workspace refuses", (t) => {
test("A3 a hidden checkout is a tmpfs over the workspace bind; a path outside the workspace, a symlink or a file refuses", (t) => {
const root = scratch(t);
const workspace = join(root, "ws");
mkdirSync(join(workspace, "web"), { recursive: true });
mkdirSync(join(workspace, "stack"));
const args = { runDir: join(root, "run"), workspace, adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") };
const list = binds({ ...args, hidden: [join(workspace, "web")] });
const ws = list.findIndex((b) => b.src === workspace);
@@ -109,6 +111,12 @@ test("A3 a hidden checkout is a tmpfs over the workspace bind; a path outside th
for (const bad of [workspace, join(root, "other"), join(workspace, "..", "ws2")]) {
assert.throws(() => binds({ ...args, hidden: [bad] }), /is not inside the workspace/, bad);
}
// A tmpfs at a symlink would mount over its target, so only a real directory is covered.
symlinkSync(join(workspace, "stack"), join(workspace, "linked"));
writeFileSync(join(workspace, "file"), "x\n");
for (const bad of [join(workspace, "linked"), join(workspace, "file"), join(workspace, "missing")]) {
assert.throws(() => binds({ ...args, hidden: [bad] }), /is not a directory/, bad);
}
});
// Row 65: the harness module names its own mounts; binds() mounts each read-only, after the