4699 lines
368 KiB
Diff
4699 lines
368 KiB
Diff
diff --git a/docs/plans/2026-10-24_model-proxy-rows.md b/docs/plans/2026-10-24_model-proxy-rows.md
|
||
index fd4325c05..f6329f41d 100644
|
||
--- a/docs/plans/2026-10-24_model-proxy-rows.md
|
||
+++ b/docs/plans/2026-10-24_model-proxy-rows.md
|
||
@@ -216,7 +216,10 @@ delists the agent, and no session starts on a key the proxy refuses.
|
||
how the code relies on that.
|
||
6. **Preflight (D17).** Before each launch in proxy mode, the bus sends one `GET /v1/models` with the agent's
|
||
key. A 401 refuses `proxy-key-refused`, and a connection failure refuses `proxy-down`. A session with no
|
||
- output for `proxy.firstOutputSeconds` (default 120) is stopped and logged as `proxy-stall`.
|
||
+ output during the one initial `proxy.firstOutputSeconds` budget (default 120), armed by successful
|
||
+ first actual adapter-child spawn, is stopped and logged as `proxy-stall`. Idle before then is
|
||
+ unarmed; later turns do not reset or extend it; first output cancels. Invalid first-turn metadata
|
||
+ uses the same existing stop and value-free receipt once, unless output already cancelled it.
|
||
7. If P1 found that a client key can be limited to some models, narrow each agent's key to its role's
|
||
models. If it can't, say so in BUILD.md. The bundle's model choice stays the guard.
|
||
|
||
diff --git a/docs/plans/2026-10-24_stack-model-proxy.md b/docs/plans/2026-10-24_stack-model-proxy.md
|
||
index 5f2679e98..29b1057e1 100644
|
||
--- a/docs/plans/2026-10-24_stack-model-proxy.md
|
||
+++ b/docs/plans/2026-10-24_stack-model-proxy.md
|
||
@@ -249,8 +249,13 @@ D17. **Key and proxy preflight.** Before a launch in proxy mode, the bus makes o
|
||
`GET /v1/models`. The build row confirms on the stand-in that this is answered locally with no upstream call. A
|
||
401 refuses the launch with `proxy-key-refused`. A connection failure refuses with `proxy-down`. Neither starts a
|
||
session, so a revoked key never reaches Claude Code's silent retry. A key that is revoked mid-session is
|
||
-revoked by the stack, which stops that session first (D14). A session with no output for
|
||
-`proxy.firstOutputSeconds` (default 120) is stopped and logged as `proxy-stall`.
|
||
+revoked by the stack, which stops that session first (D14). Idle before the first actual adapter turn
|
||
+is unarmed. The successful adapter-child spawn arms one initial `proxy.firstOutputSeconds` budget
|
||
+(default 120) per proxy run; later turns never reset or extend it and first output cancels it. A turn
|
||
+still silent when that budget expires is stopped through the existing trusted path and logged as
|
||
+`proxy-stall`. Invalid first-turn metadata also uses that same stop and value-free receipt once,
|
||
+unless first output already cancelled the watcher. The internal timestamp protocol uses the current
|
||
+Linux host/runner shared monotonic clock; a confined timestamp-bound check is required verification.
|
||
|
||
## 7. Usage and limits
|
||
|
||
diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md
|
||
index be228c699..16c862ba6 100644
|
||
--- a/docs/plans/DEFERRED.md
|
||
+++ b/docs/plans/DEFERRED.md
|
||
@@ -820,3 +820,11 @@ refresh. The proxy kept serving with its fallback version. Evidence: origin/feth
|
||
cutover, find a config switch that turns these off and set it in P1's initial config with a test, or
|
||
decide on purpose to allow or block that egress and record it in P7's receipt. Tracked as Vikunja 633
|
||
(umbrella 237).
|
||
+
|
||
+## Row 331: human-only per-agent model rotation override — P7
|
||
+
|
||
+Sage ruling sage-4388 (2026-10-24) limits P2 to the 30-day model-key rotation default,
|
||
+with no setter and no `proxy.rotate --agent --rotate-by` verb. A human-only
|
||
+per-agent rotation override is bound to P7, with Jason's cutover/settings review.
|
||
+It must preserve D20/Q222: no agent or tester can change the period. Row 331 ships
|
||
+only the default and the approved proxy.json grace/first-output settings.
|
||
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
||
index 338a52dbc..5ad4a0edc 100644
|
||
--- a/packages/bus/src/broker.mjs
|
||
+++ b/packages/bus/src/broker.mjs
|
||
@@ -1,4 +1,5 @@
|
||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||
+import { validManagementKey, proxyTiming } from '../../model-proxy/src/validation.mjs';
|
||
import { parseAddress } from './address.mjs';
|
||
import { isAbsolute, dirname } from 'node:path';
|
||
import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';
|
||
@@ -1365,7 +1366,7 @@ export class Broker {
|
||
}));
|
||
}
|
||
// Trusted launcher API, deliberately NOT a socket verb. S6 supplies immutable launch records.
|
||
- bindLaunch(record) {
|
||
+ #launchProof(record) {
|
||
keys(
|
||
record,
|
||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript'],
|
||
@@ -1414,22 +1415,28 @@ export class Broker {
|
||
// A record without a session keeps the body it had before row 264a.
|
||
...(record.session === undefined ? {} : { session: record.session }),
|
||
};
|
||
- // 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) {
|
||
+ if (old.actor_role !== record.role || old.body !== JSON.stringify(body)) fail('launch-record-mismatch');
|
||
+ if (this.#store.get("SELECT 1 FROM events WHERE business=? AND kind='session.ended' AND subject=?", record.business, record.run)) fail('run-ended');
|
||
+ }
|
||
+ return { key, session, old, body };
|
||
+ }
|
||
+ bindLaunch(record) {
|
||
+ const { key, session, old, body } = this.#launchProof(record);
|
||
if (!old) this.#processingWork();
|
||
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
|
||
- // FED R4 (design 3.7, D1): a new run of a revoked thread refuses, which covers a start racing the
|
||
- // revocation. recover()'s rebind of an earlier run isn't refused, so it can still end that run.
|
||
if (!old && record.thread !== undefined && this.#threadLost(record.business, record.thread)) fail('thread-revoked');
|
||
- 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.
|
||
- if (this.#store.get("SELECT 1 FROM events WHERE business=? AND kind='session.ended' AND subject=?", record.business, record.run))
|
||
- fail('run-ended');
|
||
- } else this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
|
||
+ if (!old) this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
|
||
this.#runs.set(key, session);
|
||
return this.#cap(session);
|
||
}
|
||
+ // Trusted historical cleanup ONLY: exact persisted launch proof, no new run,
|
||
+ // capability or launch/rotation binding; existing end/release semantics.
|
||
+ recoverLaunch(record) {
|
||
+ const { session, old } = this.#launchProof(record);
|
||
+ if (!old) fail('unknown-run');
|
||
+ return this.#endSession(session, { business: record.business, run: record.run, reason: 'host-lost', exitCode: null });
|
||
+ }
|
||
// Trusted launcher API, the counterpart of bindLaunch and likewise NOT a socket verb. The S6 host
|
||
// calls it once when a launched run's process exits: session.ended is written, the run's claim is
|
||
// released if it still holds one, and every capability of the run stops working.
|
||
@@ -1438,6 +1445,10 @@ export class Broker {
|
||
this.#business(identifier(record.business));
|
||
const key = record.business + ':' + identifier(record.run);
|
||
const session = this.#runs.get(key) ?? fail('unknown-run');
|
||
+ return this.#endSession(session, record);
|
||
+ }
|
||
+ #endSession(session, record) {
|
||
+ const key = session.business + ':' + session.run;
|
||
if (this.#ended.has(key)) fail('run-ended');
|
||
identifier(record.reason);
|
||
const exitCode = record.exitCode ?? null;
|
||
@@ -2587,7 +2598,7 @@ export class Broker {
|
||
}
|
||
let step = 'B';
|
||
try {
|
||
- const result = write('instance.fire');
|
||
+ const result = await write('instance.fire');
|
||
step = 'C';
|
||
this.#store.transaction(() => {
|
||
this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||
@@ -4593,7 +4604,8 @@ export class Broker {
|
||
this.#organizationFail(e, 'instances.set');
|
||
}
|
||
// sage-1610 (b): a deleted agent's Vikunja bot is named, so the human removes it in Vikunja.
|
||
- return { transaction: out.transaction, retry: false, ...(out.tracker ? { bot: out.tracker.bot } : {}) };
|
||
+ const finish = out => ({ transaction: out.transaction, retry: false, ...(out.tracker ? { bot: out.tracker.bot } : {}) });
|
||
+ return out?.then ? out.then(finish, error => this.#organizationFail(error, 'instances.set')) : finish(out);
|
||
}
|
||
// The close of a taken request, with the run it started, the delete's transaction or the code
|
||
// that stopped it. A delete also names the agent's Vikunja bot (sage-1610 b). A fire's end
|
||
@@ -4823,14 +4835,16 @@ export class Broker {
|
||
if (request.verb === 'proxy.init') {
|
||
this.#human(s);
|
||
if (!this.#proxyInit) fail('not-configured');
|
||
- keys(args, ['key', 'port'], ['key']);
|
||
- if (typeof args.key !== 'string' || !args.key.trim() || args.key.length > 4096 || /[\x00-\x1f\x7f]/.test(args.key)) fail('proxy-key-invalid');
|
||
+ keys(args, ['key', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'], ['key']);
|
||
+ if (!validManagementKey(args.key)) fail('proxy-key-invalid');
|
||
if (Object.hasOwn(args, 'port') && (!Number.isInteger(args.port) || args.port < 1024 || args.port > 65535 || args.port === 8317)) fail('proxy-config-invalid');
|
||
+ try { proxyTiming(args); } catch (error) { fail(error.code); }
|
||
// Async initialization is outside the store transaction. Refusals retain codes only.
|
||
return Promise.resolve().then(() => this.#proxyInit(args)).then(result => {
|
||
- if (!object(result) || Object.keys(result).sort().join(',') !== 'binary,port'
|
||
+ if (!object(result) || Object.keys(result).some(k => !['binary', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'].includes(k))
|
||
|| !Number.isInteger(result.port) || result.port < 1024 || result.port > 65535 || result.port === 8317
|
||
|| typeof result.binary !== 'string' || !result.binary.startsWith('/')) fail('proxy-init-failed');
|
||
+ try { proxyTiming(result); } catch (error) { fail(error.code); }
|
||
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.init', port: result.port }, null));
|
||
return result;
|
||
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
|
||
@@ -4882,7 +4896,10 @@ export class Broker {
|
||
}
|
||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
|
||
- return this.#chiefLifecycle(s, request.verb, structuredClone(args));
|
||
+ {
|
||
+ const result = this.#chiefLifecycle(s, request.verb, structuredClone(args));
|
||
+ return result?.then ? result.catch(error => { throw this.#refused(s, error, request); }) : result;
|
||
+ }
|
||
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
|
||
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
|
||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
|
||
@@ -5083,7 +5100,8 @@ export class Broker {
|
||
const { reason: _, ...written } = args;
|
||
const write = (op) => {
|
||
try {
|
||
- return this.#organization(s.business, op, written, actor, context);
|
||
+ const result = this.#organization(s.business, op, written, actor, context);
|
||
+ return result?.then ? result.catch(error => this.#organizationFail(error, verb)) : result;
|
||
} catch (e) {
|
||
this.#organizationFail(e, verb);
|
||
}
|
||
@@ -5091,8 +5109,11 @@ export class Broker {
|
||
if (hire) {
|
||
this.#hireable(s, args.definition);
|
||
const result = write('instance.hire');
|
||
- this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
|
||
- return result;
|
||
+ const finish = result => {
|
||
+ this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
|
||
+ return result;
|
||
+ };
|
||
+ return result?.then ? result.then(finish) : finish(result);
|
||
}
|
||
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
|
||
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
|
||
@@ -5142,11 +5163,14 @@ export class Broker {
|
||
// Step B: the delist. An instance already delisted counts as done.
|
||
const result = write('instance.fire');
|
||
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
|
||
- this.#store.transaction(() => {
|
||
- this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||
- this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
|
||
- });
|
||
- return { ...result, ...stopped };
|
||
+ const finish = result => {
|
||
+ this.#store.transaction(() => {
|
||
+ this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||
+ this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
|
||
+ });
|
||
+ return { ...result, ...stopped };
|
||
+ };
|
||
+ return result?.then ? result.then(finish) : finish(result);
|
||
}
|
||
// The shape of hire and fire arguments.
|
||
#hireFireArgs(action, args) {
|
||
@@ -5179,6 +5203,7 @@ export class Broker {
|
||
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
|
||
// outcome-unknown: the write may have committed.
|
||
#organizationFail(e, verb) {
|
||
+ if (['proxy-revoke-revision-conflict', 'proxy-key-issue-failed', 'proxy-key-revoking', 'proxy-key-refused', 'proxy-down', 'proxy-open'].includes(e.code)) fail(e.code);
|
||
// Row 239 (#1745): the writer marks an error raised after a fire's or a delete's revoke ran, so
|
||
// the human reads that the tokens may already be revoked. Without the mark, today's code.
|
||
if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");
|
||
diff --git a/packages/bus/src/credentials.mjs b/packages/bus/src/credentials.mjs
|
||
index 1f5336757..78717811c 100644
|
||
--- a/packages/bus/src/credentials.mjs
|
||
+++ b/packages/bus/src/credentials.mjs
|
||
@@ -1,11 +1,12 @@
|
||
import {
|
||
- openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants, mkdirSync, writeSync, fsyncSync,
|
||
+ openSync, closeSync, fstatSync, readSync, readFileSync, lstatSync, realpathSync, constants, mkdirSync, writeSync, fsyncSync,
|
||
renameSync, unlinkSync, rmdirSync, readdirSync,
|
||
} from 'node:fs';
|
||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||
import { createHash } from 'node:crypto';
|
||
+import { isDeepStrictEqual } from 'node:util';
|
||
import { BusError } from './broker.mjs';
|
||
-import { credentialKind, isHostKind } from '../../business/src/vocabulary.mjs';
|
||
+import { credentialKind, isHostKind, ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||
const deny = (code) => {
|
||
throw new BusError(code);
|
||
};
|
||
@@ -111,6 +112,8 @@ export const privateDir = (path, { missing = false } = {}) => {
|
||
};
|
||
export class Credentials {
|
||
#tokens = new Map();
|
||
+ #modelProofs = new Map();
|
||
+ #modelTokens = new WeakMap();
|
||
// Row 199: every reference the bus loaded, so issue, rotate, revoke and status work per entry.
|
||
#refs = new Map();
|
||
#revoked = new Set();
|
||
@@ -125,7 +128,7 @@ export class Credentials {
|
||
// storeRoot is <configDir>/secrets/agents (design D1); a reference with store: true must name the
|
||
// path under it for its own business, agent and service. `held` lists "instance:service" keys whose
|
||
// reference loads and whose file doesn't (#436b section 5).
|
||
- constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null, held = [] }) {
|
||
+ constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null, held = [], modelAgents = [] }) {
|
||
this.#storeRoot = storeRoot;
|
||
this.#repoRoots = repoRoots;
|
||
this.#dataRoot = dataRoot;
|
||
@@ -139,6 +142,7 @@ export class Credentials {
|
||
const value = this.#read(ref, env, service);
|
||
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
|
||
}
|
||
+ for (const instance of modelAgents) this.registerModel(instance);
|
||
} catch (e) {
|
||
this.close();
|
||
throw e;
|
||
@@ -146,7 +150,7 @@ export class Credentials {
|
||
}
|
||
// One reference's checks, for the constructor and register (row 235, r6 X4). Returns its date.
|
||
#checkRef(instance, service, ref) {
|
||
- if (!credentialKind(service) || !ref || typeof ref !== 'object') deny('credential-reference');
|
||
+ if (!(credentialKind(service) || service === 'model') || !ref || typeof ref !== 'object') deny('credential-reference');
|
||
const date = service === 'vikunja' ? ref.expires : ref.rotateBy;
|
||
if (!day(date)) deny('credential-date');
|
||
if (
|
||
@@ -167,6 +171,7 @@ export class Credentials {
|
||
const k = instance + ':' + service;
|
||
if (this.#tokens.has(k)) deny('credential-reference');
|
||
const date = this.#checkRef(instance, service, ref);
|
||
+ this.#invalidateModelProof(instance, service);
|
||
this.#refs.set(k, { ref, date });
|
||
this.#revoked.delete(k);
|
||
}
|
||
@@ -174,14 +179,159 @@ export class Credentials {
|
||
unregister(instance, service) {
|
||
const k = instance + ':' + service;
|
||
if (this.#tokens.has(k)) deny('credential-reference');
|
||
+ this.#invalidateModelProof(instance, service);
|
||
this.#refs.delete(k);
|
||
this.#revoked.delete(k);
|
||
}
|
||
+ // Host-only implicit kind. It is deliberately absent from role service vocabulary:
|
||
+ // neither role authority nor an agent's credential reference can opt into it.
|
||
+ registerModel(instance) {
|
||
+ const parts = typeof instance === 'string' ? instance.split('/') : [];
|
||
+ if (parts.length !== 2 || parts.some(p => !ID_PATTERN.test(p))) deny('credential-reference');
|
||
+ const k = instance + ':model';
|
||
+ if (this.#refs.has(k)) return;
|
||
+ const file = this.#storePath(instance, 'model');
|
||
+ const ref = { store: true, file, rotateBy: '9999-12-31' };
|
||
+ this.#invalidateModelProof(instance);
|
||
+ this.#refs.set(k, { ref, date: ref.rotateBy });
|
||
+ // An instance may have no credential directory before its first hire/launch.
|
||
+ if (!privateDir(this.#storeRoot, { missing: true })) return;
|
||
+ const value = this.#read(ref, {}, 'model');
|
||
+ if (value) {
|
||
+ this.#tokens.set(k, { ...value, date: value.model.rotateBy });
|
||
+ this.#refs.set(k, { ref: { ...ref, rotateBy: value.model.rotateBy }, date: value.model.rotateBy });
|
||
+ }
|
||
+ }
|
||
+ // Trusted lifecycle use only; no socket verb returns this private record.
|
||
+ modelRecord(instance) {
|
||
+ const entry = this.#tokens.get(instance + ':model');
|
||
+ return entry?.model ? structuredClone(entry.model) : null;
|
||
+ }
|
||
+ writeModel(instance, record) {
|
||
+ this.registerModel(instance);
|
||
+ this.issue(instance, 'model', JSON.stringify(record), record?.rotateBy);
|
||
+ return this.status().find(e => e.instance === instance && e.service === 'model');
|
||
+ }
|
||
+ // Host-only proof state, never returned by status or an agent/IPC verb.
|
||
+ #invalidateModelProof(instance, service = 'model') {
|
||
+ if (service === 'model') this.#modelProofs.delete(instance);
|
||
+ }
|
||
+ #modelFileSnapshot(file, optional = false) {
|
||
+ let fd;
|
||
+ try {
|
||
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
||
+ catch (error) { if (optional && error.code === 'ENOENT') return null; throw error; }
|
||
+ const first = fstatSync(fd, { bigint: true });
|
||
+ if (!first.isFile() || first.nlink !== 1n || first.uid !== BigInt(process.getuid()) || (first.mode & 0o777n) !== 0o600n || first.size > 16384n) deny('credential-revoke-failed');
|
||
+ const bytes = Buffer.alloc(16385); let length = 0;
|
||
+ while (length < bytes.length) {
|
||
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
|
||
+ if (!count) break;
|
||
+ length += count;
|
||
+ }
|
||
+ const identity = stat => ({ dev: stat.dev, ino: stat.ino, size: stat.size, mode: stat.mode,
|
||
+ uid: stat.uid, nlink: stat.nlink, mtime: stat.mtimeNs, ctime: stat.ctimeNs });
|
||
+ const last = fstatSync(fd, { bigint: true }), path = lstatSync(file, { bigint: true });
|
||
+ if (length > 16384 || BigInt(length) !== first.size || !isDeepStrictEqual(identity(first), identity(last))
|
||
+ || !isDeepStrictEqual(identity(first), identity(path))) deny('credential-revoke-failed');
|
||
+ const content = Buffer.from(bytes.subarray(0, length));
|
||
+ return { identity: identity(first), content, model: Credentials.#format('model', content.toString('utf8')).model };
|
||
+ } catch { deny('credential-revoke-failed'); }
|
||
+ finally { if (fd !== undefined) closeSync(fd); }
|
||
+ }
|
||
+ #modelRecoveryPaths(instance) {
|
||
+ const known = this.#known(instance, 'model'), file = this.#storePath(instance, 'model');
|
||
+ if (!known.ref.store || known.ref.file !== file) deny('credential-revoke-failed');
|
||
+ const directories = [this.#storeRoot, dirname(dirname(file)), dirname(file)].map(path => {
|
||
+ privateDir(path);
|
||
+ const stat = lstatSync(path, { bigint: true });
|
||
+ if (!stat.isDirectory() || stat.uid !== BigInt(process.getuid()) || (stat.mode & 0o777n) !== 0o700n) deny('credential-revoke-failed');
|
||
+ return { dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid };
|
||
+ });
|
||
+ return { file, directories };
|
||
+ }
|
||
+ #modelRecoveryFence(file) {
|
||
+ // Read-only refusal, never a generic cleanup/deletion allowlist.
|
||
+ if (readdirSync(dirname(file)).some(name => name !== 'model' && name !== 'model.tmp' && !credentialKind(name.replace(/\.tmp$/, '')))) deny('credential-revoke-failed');
|
||
+ }
|
||
+ #modelProof(instance, token) {
|
||
+ const proof = this.#modelTokens.get(token);
|
||
+ if (!proof || proof.instance !== instance || this.#modelProofs.get(instance) !== proof) deny('credential-revoke-failed');
|
||
+ return proof;
|
||
+ }
|
||
+ #verifyModelProof(instance, proof, temporaryExpected) {
|
||
+ const paths = this.#modelRecoveryPaths(instance), k = instance + ':model';
|
||
+ if (!isDeepStrictEqual(paths.directories, proof.directories) || this.#held.has(k) || this.#inflight.has(k)
|
||
+ || !isDeepStrictEqual(this.modelRecord(instance), proof.final.model)) deny('credential-revoke-failed');
|
||
+ this.#modelRecoveryFence(paths.file);
|
||
+ const final = this.#modelFileSnapshot(paths.file), temporary = this.#modelFileSnapshot(paths.file + '.tmp', true);
|
||
+ if (!isDeepStrictEqual(final, proof.final) || !isDeepStrictEqual(temporary, temporaryExpected)) deny('credential-revoke-failed');
|
||
+ return paths.file;
|
||
+ }
|
||
+ captureModelRevocation(instance) {
|
||
+ this.#invalidateModelProof(instance);
|
||
+ try {
|
||
+ const { file, directories } = this.#modelRecoveryPaths(instance), k = instance + ':model';
|
||
+ const final = this.#modelFileSnapshot(file), temporary = this.#modelFileSnapshot(file + '.tmp', true);
|
||
+ const record = this.modelRecord(instance);
|
||
+ if (record?.state !== 'revoking' || this.#held.has(k) || this.#inflight.has(k)
|
||
+ || !isDeepStrictEqual(record, final.model) || (temporary && !isDeepStrictEqual(final.model, temporary.model))) deny('credential-revoke-failed');
|
||
+ this.#modelRecoveryFence(file);
|
||
+ const token = Object.freeze({}), proof = { instance, directories, final, temporary, phase: 'captured' };
|
||
+ this.#modelProofs.set(instance, proof); this.#modelTokens.set(token, proof);
|
||
+ return token;
|
||
+ } catch { deny('credential-revoke-failed'); }
|
||
+ }
|
||
+ prepareModelCompletion(instance, token) {
|
||
+ try {
|
||
+ const proof = this.#modelProof(instance, token);
|
||
+ if (!['captured', 'tmp-removed', 'prepared'].includes(proof.phase)) deny('credential-revoke-failed');
|
||
+ const expected = proof.phase === 'captured' ? proof.temporary : null;
|
||
+ const file = this.#verifyModelProof(instance, proof, expected);
|
||
+ if (expected) {
|
||
+ // Cooperating single-controller/private0700 model writers only. NO await
|
||
+ // or callback after final verification. NOT inode/content CAS against an
|
||
+ // arbitrary external same-UID path replacement in the final syscall gap.
|
||
+ unlinkSync(file + '.tmp');
|
||
+ proof.phase = 'tmp-removed'; // Own successful unlink provenance before absence read.
|
||
+ try { lstatSync(file + '.tmp'); deny('credential-revoke-failed'); }
|
||
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||
+ }
|
||
+ proof.phase = 'prepared';
|
||
+ } catch { deny('credential-revoke-failed'); }
|
||
+ }
|
||
+ finishModelRevocation(instance, token) {
|
||
+ try {
|
||
+ const proof = this.#modelProof(instance, token), k = instance + ':model';
|
||
+ if (proof.phase === 'finished') {
|
||
+ // Exact same private completion provenance, invalidated by every model
|
||
+ // rewrite/reload/ref mutation/close. Not proof from initial absence.
|
||
+ const { file, directories } = this.#modelRecoveryPaths(instance);
|
||
+ if (!isDeepStrictEqual(directories, proof.directories) || this.#tokens.has(k) || this.#held.has(k) || this.#inflight.has(k)) deny('credential-revoke-failed');
|
||
+ this.#modelRecoveryFence(file);
|
||
+ for (const path of [file, file + '.tmp']) {
|
||
+ try { lstatSync(path); deny('credential-revoke-failed'); }
|
||
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||
+ }
|
||
+ return;
|
||
+ }
|
||
+ if (proof.phase !== 'prepared') deny('credential-revoke-failed');
|
||
+ const file = this.#verifyModelProof(instance, proof, null);
|
||
+ // Same qualified path-unlink limitation as tmp removal; no callback/await.
|
||
+ unlinkSync(file);
|
||
+ // ONE final unlink only. NO further fallible filesystem operation or abort
|
||
+ // callback after success. Ref/date remain as generic revoke bookkeeping;
|
||
+ // model held/inflight slots were required absent BEFORE final verification.
|
||
+ this.#tokens.delete(k); this.#revoked.add(k); this.#held.delete(k);
|
||
+ proof.phase = 'finished'; proof.final = null; proof.temporary = null;
|
||
+ } catch { deny('credential-revoke-failed'); }
|
||
+ }
|
||
// r6 X5: the executor holds the entry's #inflight slot for its steps, so a host.run refuses host-busy
|
||
// and revoke's abort() reaches the executor. A slot already held refuses host-busy. Returns the signal.
|
||
claim(instance, service) {
|
||
const k = instance + ':' + service;
|
||
if (this.#inflight.has(k)) deny('host-busy');
|
||
+ this.#invalidateModelProof(instance, service);
|
||
const controller = new AbortController();
|
||
this.#inflight.set(k, controller);
|
||
return controller.signal;
|
||
@@ -271,6 +421,20 @@ export class Credentials {
|
||
}
|
||
// { token } for gitea and vikunja, { token, key } for host.<id> (design D2).
|
||
static #format(service, token) {
|
||
+ if (service === 'model') {
|
||
+ let m;
|
||
+ try { m = JSON.parse(token); } catch { deny('credential-format'); }
|
||
+ const keyOk = k => typeof k === 'string' && /^msk-[a-z0-9]+-[A-Za-z0-9_-]{43}$/.test(k);
|
||
+ const keyId = k => createHash('sha256').update(k).digest('hex').slice(0, 12);
|
||
+ const instant = s => typeof s === 'string' && !Number.isNaN(Date.parse(s)) && new Date(s).toISOString() === s;
|
||
+ if (!m || Array.isArray(m) || Object.keys(m).some(k => !['key', 'keyId', 'issuedAt', 'rotateBy', 'state', 'revokingAt', 'retiring'].includes(k))
|
||
+ || !keyOk(m.key) || m.keyId !== keyId(m.key) || !instant(m.issuedAt) || !day(m.rotateBy)
|
||
+ || !['valid', 'revoking'].includes(m.state) || (m.state === 'revoking' ? !instant(m.revokingAt) : m.revokingAt !== undefined)
|
||
+ || !Array.isArray(m.retiring) || m.retiring.length > 64 || m.retiring.some(r => !r || Array.isArray(r)
|
||
+ || Object.keys(r).sort().join(',') !== 'issuedAt,key,keyId,retireAt'
|
||
+ || !keyOk(r.key) || r.keyId !== keyId(r.key) || !instant(r.issuedAt) || !instant(r.retireAt))) deny('credential-format');
|
||
+ return { token: m.key, model: m };
|
||
+ }
|
||
if (isHostKind(service)) {
|
||
const key = parseHostKey(token);
|
||
return { token: key.text, key };
|
||
@@ -292,6 +456,7 @@ export class Credentials {
|
||
this.#known(instance, service);
|
||
if (!day(date)) deny('credential-date');
|
||
const value = Credentials.#format(service, token);
|
||
+ this.#invalidateModelProof(instance, service);
|
||
const file = this.#storePath(instance, service);
|
||
const agentDir = dirname(file);
|
||
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
|
||
@@ -309,7 +474,7 @@ export class Credentials {
|
||
let fd;
|
||
try {
|
||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||
- writeSync(fd, value.key ? value.token : value.token + '\n');
|
||
+ writeSync(fd, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
|
||
fsyncSync(fd);
|
||
} finally {
|
||
if (fd !== undefined) closeSync(fd);
|
||
@@ -335,6 +500,7 @@ export class Credentials {
|
||
const { ref } = this.#known(instance, service);
|
||
if (this.#held.has(k) || !ref.store) deny('credential-reference');
|
||
if (!day(date)) deny('credential-date');
|
||
+ this.#invalidateModelProof(instance, service);
|
||
const value = this.#read(ref, {}, service);
|
||
if (value === null) deny('credential-file');
|
||
this.#refs.set(k, { ref: { ...ref, rotateBy: date }, date });
|
||
@@ -347,6 +513,7 @@ export class Credentials {
|
||
hold(instance, service) {
|
||
const k = instance + ':' + service;
|
||
this.#known(instance, service);
|
||
+ this.#invalidateModelProof(instance, service);
|
||
this.#tokens.delete(k);
|
||
this.#held.add(k);
|
||
this.#inflight.get(k)?.abort();
|
||
@@ -380,8 +547,10 @@ export class Credentials {
|
||
}
|
||
// Memory first, then the store file (design section 6, D12). A file or env reference outside the
|
||
// store is never unlinked; the caller turns its reference into store, so a restart finds nothing.
|
||
- revoke(instance, service) {
|
||
+ revoke(instance, service, { modelConfirmed = false } = {}) {
|
||
const { ref } = this.#known(instance, service);
|
||
+ if (service === 'model' && this.#tokens.has(instance + ':model') && !modelConfirmed) deny('credential-revoke-failed');
|
||
+ this.#invalidateModelProof(instance, service);
|
||
this.#tokens.delete(instance + ':' + service);
|
||
this.#revoked.add(instance + ':' + service);
|
||
this.#held.delete(instance + ':' + service);
|
||
@@ -399,7 +568,12 @@ export class Credentials {
|
||
// revoke for every service, then the agent's dir. A file other than a token or its tmp refuses.
|
||
remove(instance) {
|
||
const services = [...this.#refs.keys()].filter((k) => k.startsWith(instance + ':')).map((k) => k.slice(instance.length + 1));
|
||
- for (const service of services) this.revoke(instance, service);
|
||
+ for (const service of services) {
|
||
+ const proof = service === 'model' ? this.#modelProofs.get(instance) : null;
|
||
+ const completedModel = proof?.instance === instance && proof.phase === 'finished'
|
||
+ && !this.#tokens.has(instance + ':model') && !this.#held.has(instance + ':model') && !this.#inflight.has(instance + ':model');
|
||
+ if (!completedModel) this.revoke(instance, service);
|
||
+ }
|
||
const agentDir = dirname(this.#storePath(instance, 'gitea'));
|
||
let names;
|
||
try {
|
||
@@ -426,7 +600,8 @@ export class Credentials {
|
||
} catch {
|
||
deny('invalid-request');
|
||
}
|
||
- for (const { token } of this.#tokens.values()) if (encoded?.includes(token)) deny('credential-leak');
|
||
+ for (const { token, model } of this.#tokens.values())
|
||
+ if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak');
|
||
}
|
||
// Row 210 (design D5): whether a host.run for this agent and host is in flight.
|
||
busy(instance, service) {
|
||
@@ -438,6 +613,7 @@ export class Credentials {
|
||
const k = instance + ':' + service;
|
||
const entry = this.#tokens.get(k);
|
||
if (!entry) deny('credential-unavailable');
|
||
+ if (entry.model?.state === 'revoking') deny('proxy-key-revoking');
|
||
if (service === 'vikunja' && Date.now() >= Date.parse(entry.date + 'T00:00:00Z'))
|
||
deny('credential-expired');
|
||
if (!entry.key) {
|
||
@@ -474,6 +650,7 @@ export class Credentials {
|
||
status() {
|
||
return [...this.#refs.entries()].map(([key, { date }]) => {
|
||
const hostKey = this.#tokens.get(key)?.key;
|
||
+ const model = this.#tokens.get(key)?.model;
|
||
const i = key.lastIndexOf(':'),
|
||
service = key.slice(i + 1),
|
||
left = Date.parse(date + 'T00:00:00Z') - Date.now();
|
||
@@ -482,13 +659,16 @@ export class Credentials {
|
||
service,
|
||
date,
|
||
...(hostKey ? { fingerprint: hostKey.fingerprint, publicKey: hostKey.publicLine } : {}),
|
||
+ ...(model ? { keyId: model.keyId, issuedAt: model.issuedAt,
|
||
+ ...(model.revokingAt ? { revokingAt: model.revokingAt } : {}),
|
||
+ retiring: model.retiring.map(({ keyId, issuedAt, retireAt }) => ({ keyId, issuedAt, retireAt })) } : {}),
|
||
state: !this.#tokens.has(key)
|
||
? this.#held.has(key)
|
||
? 'held'
|
||
: this.#revoked.has(key)
|
||
? 'revoked'
|
||
: 'not-issued'
|
||
- : service !== 'vikunja'
|
||
+ : model?.state === 'revoking' ? 'revoking' : service !== 'vikunja'
|
||
? left <= 0
|
||
? 'rotation-due'
|
||
: 'valid'
|
||
@@ -501,6 +681,7 @@ export class Credentials {
|
||
});
|
||
}
|
||
close() {
|
||
+ this.#modelProofs.clear(); this.#modelTokens = new WeakMap();
|
||
this.#tokens.clear();
|
||
this.#refs.clear();
|
||
}
|
||
diff --git a/packages/bus/src/model-keys.mjs b/packages/bus/src/model-keys.mjs
|
||
new file mode 100644
|
||
index 000000000..560c84748
|
||
--- /dev/null
|
||
+++ b/packages/bus/src/model-keys.mjs
|
||
@@ -0,0 +1,316 @@
|
||
+// P2: one broker holds this lifecycle controller after Store acquired its writer lock.
|
||
+// Every management-list read/modify/write is serialized. Only Credentials owns files.
|
||
+import { randomBytes, createHash } from 'node:crypto';
|
||
+import { performance } from 'node:perf_hooks';
|
||
+import { isDeepStrictEqual } from 'node:util';
|
||
+import { BusError } from './broker.mjs';
|
||
+export const REVOKE_TIMEOUT_MS = 5000;
|
||
+export const REVOKE_POLL_MS = 25;
|
||
+export const MODEL_STOP_TIMEOUT_MS = 5000;
|
||
+const keyId = key => createHash('sha256').update(key).digest('hex').slice(0, 12);
|
||
+const fail = code => { throw new BusError(code); };
|
||
+export class ModelKeys {
|
||
+ #credentials; #client; #baseUrl; #prefix; #receipt; #now; #runs; #stopRuns; #grace;
|
||
+ #tail = Promise.resolve(); #ready = false; #failure = 'proxy-down'; #confirmed = new Map();
|
||
+ constructor({ credentials, client, configDir, baseUrl, receipt = () => {}, now = () => Date.now(),
|
||
+ runs = () => [], stopRuns = async () => {}, rotateGraceMinutes = 120 }) {
|
||
+ this.#credentials = credentials; this.#client = client; this.#baseUrl = baseUrl;
|
||
+ this.#prefix = `msk-${keyId(configDir)}-`; this.#receipt = receipt; this.#now = now;
|
||
+ this.#runs = runs; this.#stopRuns = stopRuns; this.#grace = rotateGraceMinutes * 60_000;
|
||
+ }
|
||
+ #exclusive(operation) {
|
||
+ const result = this.#tail.then(operation);
|
||
+ this.#tail = result.catch(() => {});
|
||
+ return result;
|
||
+ }
|
||
+ #instances() { return this.#credentials.status().filter(s => s.service === 'model').map(s => s.instance); }
|
||
+ #record(instance) { return this.#credentials.modelRecord(instance); }
|
||
+ hasRecord(instance) { return this.#record(instance) !== null; }
|
||
+ assertLaunchable(instance) { this.#launchable(instance); }
|
||
+ #sentinel(keys) {
|
||
+ if (!keys.some(k => k.startsWith(this.#prefix + 'sentinel-') && /^[A-Za-z0-9_-]{43}$/.test(k.slice((this.#prefix + 'sentinel-').length)))) {
|
||
+ this.#ready = false; this.#failure = 'proxy-open'; fail('proxy-open');
|
||
+ }
|
||
+ }
|
||
+ #launchable(instance) {
|
||
+ if (this.#record(instance)?.state === 'revoking') fail('proxy-key-revoking');
|
||
+ if (!this.#ready) fail(this.#failure);
|
||
+ }
|
||
+ async #replace(before, after) {
|
||
+ this.#sentinel(before); this.#sentinel(after);
|
||
+ await this.#client.putKeys(after);
|
||
+ const confirmed = await this.#client.getKeys();
|
||
+ this.#sentinel(confirmed);
|
||
+ const removed = before.filter(k => !after.includes(k));
|
||
+ if (after.some(k => !confirmed.includes(k)) || removed.some(k => confirmed.includes(k))) fail('proxy-client-response');
|
||
+ }
|
||
+ async #pollRevoked(keys) {
|
||
+ const deadline = performance.now() + REVOKE_TIMEOUT_MS;
|
||
+ for (const key of keys) {
|
||
+ for (;;) {
|
||
+ const remaining = deadline - performance.now();
|
||
+ if (remaining <= 0) fail('credential-revoke-failed');
|
||
+ let status;
|
||
+ try { status = await this.#client.probeKey(key, { timeout: remaining }); }
|
||
+ catch { status = 0; }
|
||
+ if (status === 401) break;
|
||
+ const left = deadline - performance.now();
|
||
+ if (left <= 0) fail('credential-revoke-failed');
|
||
+ await new Promise(resolve => setTimeout(resolve, Math.min(REVOKE_POLL_MS, left)));
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ async #removeKeys(keys, confirm = () => {}) {
|
||
+ const before = await this.#client.getKeys();
|
||
+ confirm();
|
||
+ await this.#replace(before, before.filter(k => !keys.includes(k)));
|
||
+ await this.#pollRevoked(keys);
|
||
+ }
|
||
+ #captureRevocation(instance) {
|
||
+ const record = this.#record(instance);
|
||
+ if (record?.state !== 'revoking' || [record.key, ...record.retiring.map(r => r.key)].some(key => !key.startsWith(this.#prefix))) fail('credential-revoke-failed');
|
||
+ return { record, snapshot: this.#credentials.captureModelRevocation(instance) };
|
||
+ }
|
||
+ #confirmRevocation(instance, proof) {
|
||
+ if (!isDeepStrictEqual(this.#record(instance), proof.record)) fail('credential-revoke-failed');
|
||
+ // A prior tmp removal may leave only final at retry/restart. Neither its
|
||
+ // absence nor a revoking label proves exit. Fresh views grant NO stop authority.
|
||
+ if (this.#runs(instance).length) fail('proxy-stop-failed');
|
||
+ }
|
||
+ async reconcile() {
|
||
+ return this.#exclusive(async () => {
|
||
+ this.#ready = false;
|
||
+ try {
|
||
+ const before = await this.#client.getKeys(); this.#sentinel(before);
|
||
+ const records = this.#instances().map(instance => ({ instance, record: this.#record(instance) })).filter(e => e.record);
|
||
+ const retained = records.filter(e => e.record.state !== 'revoking').flatMap(e => [e.record.key, ...e.record.retiring.map(r => r.key)]);
|
||
+ const revoking = records.filter(e => e.record.state === 'revoking');
|
||
+ const proofs = new Map(revoking.map(({ instance }) => { this.#confirmed.delete(instance); return [instance, this.#captureRevocation(instance)]; }));
|
||
+ const confirm = () => { for (const [instance, proof] of proofs) this.#confirmRevocation(instance, proof); };
|
||
+ confirm();
|
||
+ const intended = revoking.flatMap(e => [e.record.key, ...e.record.retiring.map(r => r.key)]);
|
||
+ const removed = before.filter(k => k.startsWith(this.#prefix) && !k.startsWith(this.#prefix + 'sentinel-') && !retained.includes(k));
|
||
+ const after = [...new Set([...before.filter(k => !removed.includes(k) && !intended.includes(k)), ...retained])];
|
||
+ const added = after.filter(k => !before.includes(k));
|
||
+ if (added.length || removed.length || intended.length) await this.#replace(before, after);
|
||
+ await this.#pollRevoked([...new Set([...removed, ...intended])]);
|
||
+ confirm();
|
||
+ for (const { instance } of revoking) {
|
||
+ const proof = proofs.get(instance);
|
||
+ this.#credentials.prepareModelCompletion(instance, proof.snapshot);
|
||
+ this.#confirmed.set(instance, proof);
|
||
+ this.completeRevoke(instance);
|
||
+ }
|
||
+ this.#ready = true;
|
||
+ const receipt = { action: 'proxy.reconcile', addedKeyIds: added.map(keyId), removedKeyIds: [...new Set([...removed, ...intended])].map(keyId) };
|
||
+ this.#receipt(receipt); return receipt;
|
||
+ } catch (error) {
|
||
+ this.#failure = error.code === 'proxy-open' ? 'proxy-open' : 'proxy-down';
|
||
+ this.#receipt({ action: 'proxy.reconcile', code: this.#failure });
|
||
+ fail(this.#failure);
|
||
+ }
|
||
+ });
|
||
+ }
|
||
+ async #issue(instance, rotate = false, currentKeys = null) {
|
||
+ this.#launchable(instance); this.#credentials.registerModel(instance); this.#launchable(instance);
|
||
+ const old = this.#record(instance);
|
||
+ let before;
|
||
+ try { before = currentKeys ?? await this.#client.getKeys(); this.#sentinel(before); }
|
||
+ catch (error) { if (error.code === 'proxy-open') throw error; fail('proxy-key-issue-failed'); }
|
||
+ if (old && !rotate && before.includes(old.key)) return { baseUrl: this.#baseUrl, key: old.key };
|
||
+ const key = this.#prefix + randomBytes(32).toString('base64url');
|
||
+ const now = this.#now(), issuedAt = new Date(now).toISOString();
|
||
+ const date = new Date(now + 30 * 86400_000).toISOString().slice(0, 10);
|
||
+ const retiring = old ? [...old.retiring, ...(rotate && before.includes(old.key) ? [{ key: old.key, keyId: old.keyId, issuedAt: old.issuedAt, retireAt: new Date(now + this.#grace).toISOString() }] : [])] : [];
|
||
+ try {
|
||
+ await this.#replace(before, [...new Set([...before, key])]);
|
||
+ this.#credentials.writeModel(instance, { key, keyId: keyId(key), issuedAt, rotateBy: date, state: 'valid', retiring });
|
||
+ this.#receipt({ action: rotate ? 'proxy.key.rotated' : 'proxy.key.issued', keyIds: [keyId(key)], instance });
|
||
+ return { baseUrl: this.#baseUrl, key };
|
||
+ } catch (error) {
|
||
+ if (error.code === 'proxy-open') throw error;
|
||
+ fail('proxy-key-issue-failed');
|
||
+ }
|
||
+ }
|
||
+ ensure(instance) { return this.#exclusive(() => this.#issue(instance)); }
|
||
+ rotate(instance) { return this.#exclusive(() => this.#issue(instance, true)); }
|
||
+ // Host-only callbacks are the existing synchronous organization path.
|
||
+ hire(instance, check, publish) {
|
||
+ return this.#exclusive(async () => {
|
||
+ check(); // Fresh validation and captured revision AFTER controller queue.
|
||
+ const existed = this.hasRecord(instance);
|
||
+ try {
|
||
+ await this.#issue(instance);
|
||
+ check(); // Recheck captured revision AFTER HTTP, before publication.
|
||
+ return publish();
|
||
+ } catch (error) {
|
||
+ // A newly issued generation has not escaped to queued admission yet.
|
||
+ if (!existed && this.hasRecord(instance)) {
|
||
+ try { await this.#prepareRevoke(instance); this.completeRevoke(instance); } catch {}
|
||
+ }
|
||
+ throw error;
|
||
+ }
|
||
+ });
|
||
+ }
|
||
+ async preflight(instance) {
|
||
+ // The revoking check must run before even an issue/preflight request.
|
||
+ this.#launchable(instance);
|
||
+ const proxy = await this.#exclusive(async () => {
|
||
+ this.#launchable(instance);
|
||
+ let keys;
|
||
+ try { keys = await this.#client.getKeys(); } catch { fail('proxy-down'); }
|
||
+ this.#sentinel(keys);
|
||
+ return this.#issue(instance, false, keys);
|
||
+ });
|
||
+ let status;
|
||
+ try { status = await this.#client.probeKey(proxy.key); }
|
||
+ catch { fail('proxy-down'); }
|
||
+ if (status < 200 || status >= 300) fail('proxy-key-refused');
|
||
+ return proxy;
|
||
+ }
|
||
+ async #prepareRevoke(instance) {
|
||
+ const record = this.#record(instance);
|
||
+ if (!record) { if (this.#confirmed.has(instance)) fail('credential-revoke-failed'); return; }
|
||
+ this.#confirmed.delete(instance);
|
||
+ if (record.state !== 'revoking') this.#credentials.writeModel(instance, { ...record, state: 'revoking', revokingAt: new Date(this.#now()).toISOString() });
|
||
+ try {
|
||
+ const proof = this.#captureRevocation(instance), confirm = () => this.#confirmRevocation(instance, proof);
|
||
+ confirm();
|
||
+ await this.#removeKeys([record.key, ...record.retiring.map(r => r.key)], confirm);
|
||
+ confirm();
|
||
+ this.#credentials.prepareModelCompletion(instance, proof.snapshot);
|
||
+ this.#confirmed.set(instance, proof);
|
||
+ this.#receipt({ action: 'proxy.key.revoked', keyIds: [record.keyId, ...record.retiring.map(r => r.keyId)], remoteState: 'revoked', localState: 'kept' });
|
||
+ } catch { fail('credential-revoke-failed'); }
|
||
+ }
|
||
+ prepareRevoke(instance) { return this.#exclusive(() => this.#prepareRevoke(instance)); }
|
||
+ completeRevoke(instance) {
|
||
+ try {
|
||
+ const record = this.#record(instance), proof = this.#confirmed.get(instance);
|
||
+ if (!record && !proof) return; // Ordinary no-record/no-supplied-proof idempotence.
|
||
+ if (!record || !proof || record.state !== 'revoking') fail('credential-revoke-failed');
|
||
+ this.#confirmRevocation(instance, proof);
|
||
+ this.#credentials.finishModelRevocation(instance, proof.snapshot);
|
||
+ this.#confirmed.delete(instance);
|
||
+ } catch { fail('credential-revoke-failed'); }
|
||
+ }
|
||
+ revisionConflict(instance) {
|
||
+ const record = this.#record(instance);
|
||
+ this.#receipt({ action: 'proxy.key.revoke-conflict', keyIds: record ? [record.keyId] : [], remoteState: 'revoked', localState: 'kept' });
|
||
+ fail('proxy-revoke-revision-conflict');
|
||
+ }
|
||
+ sweep() {
|
||
+ return this.#exclusive(async () => {
|
||
+ let firstError;
|
||
+ for (const instance of this.#instances()) {
|
||
+ try {
|
||
+ let record = this.#record(instance);
|
||
+ if (!record || record.state === 'revoking') continue;
|
||
+ if (this.#now() >= Date.parse(record.rotateBy + 'T00:00:00Z')) {
|
||
+ await this.#issue(instance, true); record = this.#record(instance);
|
||
+ }
|
||
+ for (const old of record.retiring) {
|
||
+ const runs = this.#runs(instance).filter(r => r.keyId === old.keyId);
|
||
+ if (runs.length && this.#now() < Date.parse(old.retireAt)) continue;
|
||
+ if (runs.length) {
|
||
+ try { await this.#stopRuns(runs); } catch { fail('proxy-stop-failed'); }
|
||
+ }
|
||
+ if (this.#runs(instance).some(r => r.keyId === old.keyId)) fail('proxy-stop-failed');
|
||
+ const confirm = () => {
|
||
+ if (this.#runs(instance).some(r => r.keyId === old.keyId)) fail('proxy-stop-failed');
|
||
+ };
|
||
+ await this.#removeKeys([old.key], confirm);
|
||
+ confirm();
|
||
+ const current = this.#record(instance);
|
||
+ this.#credentials.writeModel(instance, { ...current, retiring: current.retiring.filter(r => r.keyId !== old.keyId) });
|
||
+ this.#receipt({ action: 'proxy.key.retired', keyIds: [old.keyId] });
|
||
+ }
|
||
+ } catch (error) {
|
||
+ firstError ??= error;
|
||
+ this.#receipt({ action: 'proxy.sweep', instance, outcome: 'refused', code: error.code ?? 'proxy-stop-failed' });
|
||
+ }
|
||
+ }
|
||
+ if (firstError) throw firstError;
|
||
+ });
|
||
+ }
|
||
+}
|
||
+
|
||
+// No new processing-control stop reason/schema: stopSession owns bounded SIGTERM
|
||
+// and start-time verification. This layer additionally checks agent/key ownership.
|
||
+export async function stopModelRuns({ runs, sessions, stop, receipt, reason = 'proxy-rotation', timeoutMs = MODEL_STOP_TIMEOUT_MS }) {
|
||
+ for (const run of runs) {
|
||
+ let timer;
|
||
+ const ids = typeof run.keyId === 'string' && /^[a-f0-9]{12}$/.test(run.keyId) ? [run.keyId] : [];
|
||
+ try {
|
||
+ if (!ids.length) fail('proxy-stop-failed');
|
||
+ const entry = sessions().find(s => s.business === run.business && s.run === run.run);
|
||
+ if (!entry || entry.instance !== run.role || entry.modelKeyId !== run.keyId
|
||
+ || entry.pid !== run.pid || entry.startTime !== run.startTime) fail('proxy-stop-failed');
|
||
+ if (entry.live) {
|
||
+ const result = await Promise.race([
|
||
+ stop(run.run),
|
||
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new BusError('proxy-stop-failed')), timeoutMs); }),
|
||
+ ]);
|
||
+ if (!result?.stopped || sessions().some(s => s.business === run.business && s.run === run.run && s.live)) fail('proxy-stop-failed');
|
||
+ }
|
||
+ receipt({ action: reason, keyIds: ids, business: run.business, agent: run.role, run: run.run, outcome: 'stopped' });
|
||
+ } catch {
|
||
+ receipt({ action: reason, code: 'proxy-stop-failed', keyIds: ids, run: run.run });
|
||
+ fail('proxy-stop-failed');
|
||
+ } finally { clearTimeout(timer); }
|
||
+ }
|
||
+}
|
||
+
|
||
+// Option C: HTTP is outside the organization lock. This adapter preserves write's
|
||
+// synchronous contract and passes the captured revision into its existing lock check.
|
||
+// Neither organization.mjs nor its synchronous settings callers needs modification.
|
||
+export function modelOrganization({ modelKeys, writer }) {
|
||
+ const pending = new Map();
|
||
+ const adapter = {
|
||
+ ...writer,
|
||
+ write(business, document, options) {
|
||
+ const prepare = pending.get(business);
|
||
+ return writer.write(business, document, prepare ? { ...options, revision: prepare.revision } : options);
|
||
+ },
|
||
+ };
|
||
+ const revoke = original => (business, agent, how, services) => {
|
||
+ const prepare = pending.get(business), instance = business + '/' + agent;
|
||
+ if (!prepare || prepare.instance !== instance) fail('credential-revoke-failed');
|
||
+ // For an already-delisted repeat there is no write callback/lock, but the
|
||
+ // revision still must match before completing the model credential cleanup.
|
||
+ if (writer.read(business).revision !== prepare.revision) modelKeys.revisionConflict(instance);
|
||
+ modelKeys.completeRevoke(instance);
|
||
+ return original(business, agent, how, services);
|
||
+ };
|
||
+ const wrap = invoke => (business, verb, args, actor, context) => {
|
||
+ const isRevoke = verb === 'instance.fire' || (verb === 'instances.set' && args.op === 'delete');
|
||
+ if (!isRevoke && verb !== 'instance.hire') return invoke(business, verb, args, actor, context);
|
||
+ return (async () => {
|
||
+ if (pending.has(business)) fail('organization-busy');
|
||
+ const agent = args.name ?? args.instance, instance = business + '/' + agent;
|
||
+ if (verb === 'instance.fire') invoke(business, 'instance.fireCheck', { name: agent }, actor, context);
|
||
+ if (verb === 'instances.set') invoke(business, 'instances.check', { instance: agent }, actor, context);
|
||
+ const { revision } = writer.read(business);
|
||
+ if (args.revision !== undefined && args.revision !== revision) fail('revision-conflict');
|
||
+ const checkHire = () => {
|
||
+ if (writer.read(business).revision !== revision) fail('revision-conflict');
|
||
+ const checked = invoke(business, 'instance.hireCheck', args, actor, context);
|
||
+ if (checked.revision !== revision) fail('revision-conflict');
|
||
+ return checked;
|
||
+ };
|
||
+ if (!isRevoke) checkHire(); // No registration or proxy I/O before shared checks.
|
||
+ const prepare = { instance, revision, remoteRevoked: false };
|
||
+ pending.set(business, prepare);
|
||
+ const existed = modelKeys.hasRecord(instance);
|
||
+ try {
|
||
+ if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
|
||
+ else return await modelKeys.hire(instance, checkHire, () => invoke(business, verb, args, actor, context));
|
||
+ return invoke(business, verb, args, actor, context);
|
||
+ } catch (error) {
|
||
+ if (isRevoke && prepare.remoteRevoked && (error.code === 'revision-conflict' || error.code === 'revision-conflict-revoked')) modelKeys.revisionConflict(instance);
|
||
+ throw error;
|
||
+ } finally { pending.delete(business); }
|
||
+ })();
|
||
+ };
|
||
+ return { writer: adapter, revoke, wrap };
|
||
+}
|
||
diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs
|
||
index 4ab944af5..955a147c9 100644
|
||
--- a/packages/bus/src/process.mjs
|
||
+++ b/packages/bus/src/process.mjs
|
||
@@ -7,7 +7,10 @@ const LAUNCH_OPS = new Set([
|
||
'authorizeLaunch',
|
||
'refuse',
|
||
'endLaunch',
|
||
+ 'recoverLaunch',
|
||
'credentialStatus',
|
||
+ 'modelPreflight',
|
||
+ 'modelStall',
|
||
'checkPush',
|
||
'recordPush',
|
||
'reviewFetch',
|
||
@@ -40,6 +43,7 @@ function launchOp(m) {
|
||
if (m.op === 'authorizeLaunch') return runtime.authorizeLaunch(m.cap, m.instance);
|
||
if (m.op === 'refuse') return runtime.refuse(m.cap, m.code, m.subject);
|
||
if (m.op === 'endLaunch') return runtime.endLaunch(m.record);
|
||
+ if (m.op === 'recoverLaunch') return runtime.recoverLaunch(m.record);
|
||
// Row 93 (#1587): the git socket's ops.
|
||
if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref);
|
||
if (m.op === 'recordPush') return runtime.recordPush(m.cap, m.push);
|
||
@@ -76,6 +80,8 @@ function launchOp(m) {
|
||
if (m.op === 'sessionClearRetire') return runtime.sessionClearRetire(m.record);
|
||
if (m.op === 'sessionClearStart') return runtime.sessionClearStart(m.record);
|
||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||
+ if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
|
||
+ if (m.op === 'modelStall') return runtime.modelStall(m);
|
||
// Every LAUNCH_OPS name has its line above. A missing one used to answer as credentialStatus,
|
||
// whose [] for an unknown role read as "no holders" for landHolders (row 156 mutant P02).
|
||
throw new BusError('launch-op-refused');
|
||
diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs
|
||
index 342314b94..dbb920ee3 100644
|
||
--- a/packages/bus/src/runtime.mjs
|
||
+++ b/packages/bus/src/runtime.mjs
|
||
@@ -1,4 +1,4 @@
|
||
-import { lstatSync, readdirSync } from 'node:fs';
|
||
+import { lstatSync, readdirSync, readFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { Store } from './store.mjs';
|
||
@@ -8,13 +8,14 @@ import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
|
||
import { Credentials } from './credentials.mjs';
|
||
import { serve } from './server.mjs';
|
||
import { launchSettings } from '../../business/src/launch-settings.mjs';
|
||
-import { readSessions } from '../../seat/src/session.mjs';
|
||
+import { readSessions, sessionsFile, stopSession } from '../../seat/src/session.mjs';
|
||
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
|
||
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
|
||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
|
||
import { initializeProxy } from '../../model-proxy/src/settings.mjs';
|
||
+import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
|
||
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
|
||
import { credentialVerbs } from './credential-verbs.mjs';
|
||
@@ -121,6 +122,110 @@ export function createLibraryLookup({ dataRoot, sync, now = () => Date.now() })
|
||
};
|
||
}
|
||
|
||
+// B1: discovery is evidence of a process, never authority to stop it. Keep this
|
||
+// host-only inventory across the launcher's Registry[] recovery gap and endLaunch.
|
||
+// A failed boot read cannot be repaired by a later empty registry: that would lose
|
||
+// the very prior runs whose existence this inventory must establish.
|
||
+function modelRunInventory(dataRoot, bound, credentials) {
|
||
+ const known = new Map(), terminal = new Map(), validated = new Map();
|
||
+ const refuse = () => { throw new BusError('proxy-stop-failed'); };
|
||
+ const same = (a, b) => ['business', 'role', 'run', 'pid', 'startTime', 'runnerPid', 'runnerStartTime', 'keyId'].every(k => a[k] === b[k]);
|
||
+ const shape = s => {
|
||
+ if (!s || ['business', 'instance', 'run'].some(k => typeof s[k] !== 'string' || !s[k])
|
||
+ || ['pid', 'runnerPid'].some(k => !Number.isSafeInteger(s[k]) || s[k] < 2)
|
||
+ || ['startTime', 'runnerStartTime'].some(k => typeof s[k] !== 'string' || !/^\d+$/.test(s[k]))
|
||
+ || (s.modelKeyId !== undefined && (typeof s.modelKeyId !== 'string' || !/^[a-f0-9]{12}$/.test(s.modelKeyId)))) refuse();
|
||
+ return { business: s.business, role: s.instance, instance: s.business + '/' + s.instance,
|
||
+ run: s.run, pid: s.pid, startTime: s.startTime, runnerPid: s.runnerPid,
|
||
+ runnerStartTime: s.runnerStartTime, keyId: s.modelKeyId };
|
||
+ };
|
||
+ const refresh = () => {
|
||
+ const file = sessionsFile(dataRoot), stat = lstatSync(file);
|
||
+ if (!stat.isFile() || stat.isSymbolicLink()) refuse();
|
||
+ const entries = readSessions(dataRoot).map(shape), seen = new Set();
|
||
+ const after = lstatSync(file);
|
||
+ if (!['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs'].every(k => stat[k] === after[k])) refuse();
|
||
+ // Validate the whole snapshot before merging any of it. Missing entries do
|
||
+ // not erase known identities; the host may have just rewritten Registry[].
|
||
+ for (const r of entries) {
|
||
+ const key = r.business + ':' + r.run;
|
||
+ if (seen.has(key) || (known.has(key) && !same(known.get(key), r))) refuse();
|
||
+ seen.add(key);
|
||
+ }
|
||
+ for (const r of entries) known.set(r.business + ':' + r.run, r);
|
||
+ };
|
||
+ let bootChecked = false;
|
||
+ try { refresh(); bootChecked = true; } catch { /* hold retirement, not the human/control surface */ }
|
||
+ // startTimeOf/live alone conflate absence and read errors. Only a missing proc
|
||
+ // directory, or a zombie with the SAME recorded start ticks, confirms an exit.
|
||
+ const liveIdentity = (pid, ticks, proof, part) => {
|
||
+ if (proof[part]) return false;
|
||
+ let raw;
|
||
+ try { raw = readFileSync(`/proc/${pid}/stat`, 'utf8'); }
|
||
+ catch (error) {
|
||
+ if (error.code !== 'ENOENT') refuse();
|
||
+ try { lstatSync(`/proc/${pid}`); } catch (missing) { if (missing.code === 'ENOENT') { proof[part] = true; return false; } }
|
||
+ refuse();
|
||
+ }
|
||
+ const fields = raw.slice(raw.lastIndexOf(')') + 2).trim().split(/\s+/);
|
||
+ if (!/^[A-Z]$/.test(fields[0]) || !/^\d+$/.test(fields[19]) || fields[19] !== ticks) refuse();
|
||
+ if (fields[0] === 'Z') { proof[part] = true; return false; }
|
||
+ return true;
|
||
+ };
|
||
+ const runs = instance => {
|
||
+ try {
|
||
+ if (!bootChecked) refuse();
|
||
+ refresh();
|
||
+ const live = [], checked = [];
|
||
+ for (const r of known.values()) {
|
||
+ if (r.instance !== instance) continue;
|
||
+ const key = r.business + ':' + r.run;
|
||
+ const proof = terminal.get(key) ?? { outer: false, runner: false };
|
||
+ terminal.set(key, proof);
|
||
+ let associated = false;
|
||
+ if (!proof.outer || !proof.runner) {
|
||
+ const record = credentials.modelRecord(instance);
|
||
+ associated = !!record && [record.keyId, ...record.retiring.map(k => k.keyId)].includes(r.keyId);
|
||
+ const prior = validated.get(key);
|
||
+ if (!associated && (!prior || !same(prior, r))) refuse();
|
||
+ }
|
||
+ const outerLive = liveIdentity(r.pid, r.startTime, proof, 'outer'), runnerLive = liveIdentity(r.runnerPid, r.runnerStartTime, proof, 'runner');
|
||
+ // Never interpret an unknown/wrong key on a surviving prior run as no
|
||
+ // old-key users. A confirmed exit may be retired without key guessing.
|
||
+ if (outerLive || runnerLive) {
|
||
+ // Provenance permits strict exit confirmation, NEVER filtering away a
|
||
+ // surviving run whose model-key association has disappeared.
|
||
+ if (!associated) refuse();
|
||
+ const current = bound.get(r.business + ':' + r.run);
|
||
+ if (current && (current.instance !== r.instance || current.keyId !== r.keyId
|
||
+ || current.pid !== r.pid || current.startTime !== r.startTime)) refuse();
|
||
+ live.push(r);
|
||
+ }
|
||
+ if (associated && (outerLive || runnerLive)) checked.push(r);
|
||
+ }
|
||
+ // A newly bound launch whose registry publication has not happened is
|
||
+ // uncertain, not absent. Do not grant retirement through that startup gap.
|
||
+ for (const r of bound.values()) if (r.instance === instance && !known.has(r.business + ':' + r.run)) refuse();
|
||
+ // Commit ONLY after the entire same-instance view passed both identities,
|
||
+ // live binding consistency, later known entries and unpublished bindings.
|
||
+ // Existing M1 terminal bits remain independently monotonic and unchanged.
|
||
+ for (const r of checked) validated.set(r.business + ':' + r.run, { ...r });
|
||
+ return live;
|
||
+ } catch { refuse(); }
|
||
+ };
|
||
+ return {
|
||
+ runs,
|
||
+ authorizeStop(requests) {
|
||
+ for (const r of requests) {
|
||
+ const current = bound.get(r.business + ':' + r.run);
|
||
+ if (!current || current.instance !== r.instance || current.keyId !== r.keyId
|
||
+ || current.pid !== r.pid || current.startTime !== r.startTime) refuse();
|
||
+ if (!runs(r.instance).some(found => same(found, r))) refuse();
|
||
+ }
|
||
+ },
|
||
+ };
|
||
+}
|
||
+
|
||
export async function startBroker({
|
||
dataRoot,
|
||
businesses,
|
||
@@ -159,6 +264,8 @@ export async function startBroker({
|
||
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
|
||
throw new BusError('invalid-host-request');
|
||
let modelProxyClient = null;
|
||
+ let modelKeys = null, modelTimer = null, modelSweep = null;
|
||
+ const modelRuns = new Map();
|
||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
|
||
try {
|
||
const proxyReceipt = entry => process.stderr.write(`model-proxy: ${JSON.stringify(entry)}\n`);
|
||
@@ -198,7 +305,24 @@ export async function startBroker({
|
||
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
|
||
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
|
||
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
|
||
- credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held });
|
||
+ credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
|
||
+ modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
|
||
+ if (proxy) {
|
||
+ const inventory = modelRunInventory(dataRoot, modelRuns, credentials);
|
||
+ modelKeys = new ModelKeys({ credentials, client: modelProxyClient, configDir: organization.dir,
|
||
+ baseUrl: `http://127.0.0.1:${proxy.port}`, receipt: proxyReceipt, rotateGraceMinutes: proxy.rotateGraceMinutes ?? 120,
|
||
+ runs: inventory.runs,
|
||
+ stopRuns: async runs => {
|
||
+ inventory.authorizeStop(runs);
|
||
+ await stopModelRuns({ runs, sessions: () => readSessions(dataRoot), stop: run => stopSession(dataRoot, run, { timeoutMs: MODEL_STOP_TIMEOUT_MS }), receipt: proxyReceipt });
|
||
+ for (const r of runs) {
|
||
+ if (inventory.runs(r.instance).some(found => found.run === r.run)) throw new BusError('proxy-stop-failed');
|
||
+ }
|
||
+ for (const r of runs) modelRuns.delete(r.business + ':' + r.run);
|
||
+ } });
|
||
+ // Keep the control/human surface available, but refuse every launch on failed reconcile.
|
||
+ try { await modelKeys.reconcile(); } catch (error) { proxyReceipt({ action: 'proxy.boot-refused', code: error.code }); }
|
||
+ }
|
||
// 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;
|
||
@@ -240,7 +364,9 @@ export async function startBroker({
|
||
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
|
||
}
|
||
// 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,levels,revoke}):null;
|
||
+ const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) : null;
|
||
+ const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke,
|
||
+ ...(proxyOrganization ? { writer: proxyOrganization.writer, revoke: proxyOrganization.revoke(revoke) } : {})}):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
|
||
@@ -378,7 +504,7 @@ export async function startBroker({
|
||
},
|
||
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
|
||
// plugins.set reloads the business's limit checks in the background.
|
||
- organization:settings?(id,verb,args,actor,context)=>{
|
||
+ organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
|
||
if(verb==='limits')return withTokens(id,settings.read(id));
|
||
if(verb==='plugins')return plugins.read(id);
|
||
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
|
||
@@ -391,6 +517,8 @@ export async function startBroker({
|
||
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
|
||
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
|
||
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
|
||
+ // Internal read-only path: no reload, journal, credential or publication.
|
||
+ if(verb==='instance.hireCheck')return instances.checkHire(id,args);
|
||
let result;
|
||
if(verb==='limits.set')result=withTokens(id,settings.set(id,args,actor));
|
||
else if(verb==='instances.set')result=instances.set(id,args,actor);
|
||
@@ -413,7 +541,7 @@ export async function startBroker({
|
||
// took, so a refused one (credentials, roots, ...) shows the restart line for a keep or a
|
||
// hire too. The other writers' answers carry no restart field.
|
||
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
|
||
- }:null,
|
||
+ }):null,
|
||
// Row 194 (D2.3): the role files as they stand, for the hire checks.
|
||
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
|
||
secretCheck: (value) => credentials.assertClean(value),
|
||
@@ -472,8 +600,15 @@ export async function startBroker({
|
||
!/^\d+$/.test(r.startTime)
|
||
)
|
||
throw new BusError('invalid-launch-process');
|
||
- const cap = broker.bindLaunch(r);
|
||
+ const { modelKeyId, ...record } = r;
|
||
+ if (modelKeys) {
|
||
+ modelKeys.assertLaunchable(r.business + '/' + r.role);
|
||
+ const m = credentials.modelRecord(r.business + '/' + r.role);
|
||
+ if (!m || ![m.keyId, ...m.retiring.map(k => k.keyId)].includes(modelKeyId)) throw new BusError('proxy-key-refused');
|
||
+ }
|
||
+ const cap = broker.bindLaunch(record);
|
||
launchRecords.push({ ...r });
|
||
+ if (modelKeys) modelRuns.set(r.business + ':' + r.run, { ...r, instance: r.business + '/' + r.role, keyId: modelKeyId });
|
||
return { business: r.business, run: r.run, cap };
|
||
}
|
||
const bound = launches.map(bindLaunch);
|
||
@@ -634,7 +769,7 @@ export async function startBroker({
|
||
return credentials
|
||
.status()
|
||
.filter((c) => c.instance === business + '/' + role)
|
||
- .map(({ service, date, state }) => ({ service, date, state }));
|
||
+ .map(({ service, date, state, keyId, issuedAt }) => ({ service, date, state, ...(service === 'model' ? { keyId, issuedAt } : {}) }));
|
||
}
|
||
// C2-03a: each library entry with a sync user fetches once at start, then every 60 s. A pass
|
||
// still running skips the tick. Only codes are logged, never git's output.
|
||
@@ -679,6 +814,13 @@ export async function startBroker({
|
||
}, access.intervalMs ?? ACCESS_SWEEP_MS);
|
||
accessTimer.unref();
|
||
}
|
||
+ if (modelKeys) {
|
||
+ modelTimer = setInterval(() => {
|
||
+ if (modelSweep) return;
|
||
+ modelSweep = modelKeys.sweep().catch(error => proxyReceipt({ action: 'proxy.sweep-refused', code: error.code ?? 'proxy-key-issue-failed' })).finally(() => { modelSweep = null; });
|
||
+ }, 1000);
|
||
+ modelTimer.unref();
|
||
+ }
|
||
return {
|
||
broker,
|
||
credentials,
|
||
@@ -686,7 +828,21 @@ export async function startBroker({
|
||
get modelProxy() { return modelProxyClient; },
|
||
path,
|
||
bindLaunch,
|
||
- endLaunch: (r) => broker.endLaunch(r),
|
||
+ recoverLaunch: ({ modelKeyId, ...record }) => broker.recoverLaunch(record),
|
||
+ endLaunch: (r) => { const out = broker.endLaunch(r); modelRuns.delete(r.business + ':' + r.run); return out; },
|
||
+ modelPreflight: async ({ business, role }) => {
|
||
+ const current = settings ? settings.current(business) : businesses[business];
|
||
+ if (!Object.hasOwn(current?.roles ?? {}, role)) throw new BusError('unknown-role');
|
||
+ return modelKeys ? modelKeys.preflight(business + '/' + role) : null;
|
||
+ },
|
||
+ modelStall: async ({ business, role, run, keyId }) => {
|
||
+ const record = modelRuns.get(business + ':' + run);
|
||
+ if (!record || record.role !== role || record.keyId !== keyId) throw new BusError('proxy-stop-failed');
|
||
+ await stopModelRuns({ runs: [record], reason: 'proxy-stall', sessions: () => readSessions(dataRoot),
|
||
+ stop: id => stopSession(dataRoot, id, { timeoutMs: MODEL_STOP_TIMEOUT_MS }), receipt: proxyReceipt });
|
||
+ modelRuns.delete(business + ':' + run);
|
||
+ return { stopped: true };
|
||
+ },
|
||
threadsState: (business) => broker.threadsState(business),
|
||
threadIdle: (r) => broker.threadIdle(r),
|
||
// Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch.
|
||
@@ -728,6 +884,8 @@ export async function startBroker({
|
||
launches: bound,
|
||
readers: readCaps,
|
||
async close() {
|
||
+ clearInterval(modelTimer);
|
||
+ await modelSweep;
|
||
clearInterval(accessTimer);
|
||
await stopSync();
|
||
await server.close();
|
||
@@ -738,6 +896,7 @@ export async function startBroker({
|
||
},
|
||
};
|
||
} catch (e) {
|
||
+ clearInterval(modelTimer);
|
||
clearInterval(accessTimer);
|
||
await server?.close();
|
||
await adapter?.close?.();
|
||
diff --git a/packages/bus/tests/model-completion-remove.test.mjs b/packages/bus/tests/model-completion-remove.test.mjs
|
||
new file mode 100644
|
||
index 000000000..e925f1e25
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-completion-remove.test.mjs
|
||
@@ -0,0 +1,67 @@
|
||
+// #656/1745 STATIC-only authored tests; no measured outcome until separately cleared.
|
||
+// Generic fallback retains its current behavior; absent cache is NOT a universal refusal.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { dirname, join } from 'node:path';
|
||
+import { revocationFixture, outcome, id } from './model-revocation-fixture.mjs';
|
||
+import { Credentials } from '../src/credentials.mjs';
|
||
+const timeout=30000;
|
||
+async function prepared(f){f.seed({matchingTmp:false});await f.prepare();}
|
||
+async function finished(f){await prepared(f);f.complete();}
|
||
+function proof(f){assert.equal(f.proofs.length,1,'real controller minted exact opaque model completion proof');return f.proofs[0];}
|
||
+
|
||
+test('656 generic remove after exact completion skips only redundant model unlink and keeps metadata',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);await finished(f);const before=f.credentials.status(),count=f.counts.unlinkFinal;
|
||
+ const result=f.credentials.remove(f.instance);assert.equal(f.counts.unlinkFinal,count,'generic remove does not perform SECOND model unlink after exact completion');assert.deepEqual(result.services,['model']);assert.deepEqual(f.credentials.status(),before,'reference date and revoked status metadata unchanged');assert.equal(f.exists(f.file)||f.exists(f.temporary),false);f.valueFree();
|
||
+});
|
||
+for(const entry of ['model','model.tmp'])test('656 '+entry+' appearing after completed proof is preserved by unchanged unknown fence',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);await finished(f);const file=join(dirname(f.file),entry),body='owned unrecognized\n';fs.writeFileSync(file,body,{mode:0o600,flag:'wx'});const count=f.counts.unlinkFinal,result=await outcome(()=>f.credentials.remove(f.instance));
|
||
+ assert.equal(f.counts.unlinkFinal,count,'finished proof never authorizes another model pathname unlink');assert.equal(result.ok,false);assert.equal(result.code,'credential-file');assert.equal(id(fs.readFileSync(file)),id(body),'postcompletion unknown entry preserved');f.valueFree();
|
||
+});
|
||
+
|
||
+for(const state of ['valid','revoking'])test('656 '+state+' cached unconfirmed model keeps existing generic removal guard',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false,valid:state==='valid'});const before=id(fs.readFileSync(f.file)),count=f.counts.unlinkFinal,result=await outcome(()=>f.credentials.remove(f.instance));assert.equal(result.ok,false);assert.equal(result.code,'credential-revoke-failed');assert.equal(f.counts.unlinkFinal,count);assert.equal(id(fs.readFileSync(f.file)),before);assert.equal(f.record()?.state,state);f.valueFree();
|
||
+});
|
||
+test('656 unconfirmed prepared model still cannot use finished-proof skip',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);await prepared(f);const count=f.counts.unlinkFinal,result=await outcome(()=>f.credentials.remove(f.instance));assert.equal(result.ok,false);assert.equal(result.code,'credential-revoke-failed');assert.equal(f.counts.unlinkFinal,count);assert.equal(f.record()?.state,'revoking');f.valueFree();
|
||
+});
|
||
+test('656 no-proof absent-cache model retains existing generic file removal behavior',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});f.credentials.hold(f.instance,'model');const count=f.counts.unlinkFinal,result=f.credentials.remove(f.instance);assert.deepEqual(result.services,['model']);assert.equal(f.counts.unlinkFinal-count,1,'ordinary generic no-cache behavior remains allowed');assert.equal(f.exists(f.file),false);f.valueFree();
|
||
+});
|
||
+test('656 no-record no-proof generic remove retains existing ENOENT behavior',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t),count=f.counts.unlinkFinal,result=f.credentials.remove(f.instance);assert.deepEqual(result.services,['model']);assert.equal(f.counts.unlinkFinal-count,1,'no proof does not invent completed-proof skip');f.valueFree();
|
||
+});
|
||
+
|
||
+for(const action of ['register','unregister','reload','hold','claim','revoke','rewrite','close'])test('656 '+action+' invalidates latest completed proof without granting fallback authority',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);await finished(f);const token=proof(f),c=f.credentials;let signal;
|
||
+ if(action==='register')c.register(f.instance,'model',{store:true,file:f.file,rotateBy:'2099-01-01'});
|
||
+ if(action==='unregister')c.unregister(f.instance,'model');
|
||
+ if(action==='reload'){const result=await outcome(()=>c.reload(f.instance,'model','2099-01-01'));assert.equal(result.ok,false);assert.equal(result.code,'credential-file');}
|
||
+ if(action==='hold')c.hold(f.instance,'model');
|
||
+ if(action==='claim')signal=c.claim(f.instance,'model');
|
||
+ if(action==='revoke')c.revoke(f.instance,'model');
|
||
+ if(action==='rewrite')f.seed({matchingTmp:false,valid:true});
|
||
+ if(action==='close')c.close();
|
||
+ const result=await outcome(()=>c.finishModelRevocation(f.instance,token));assert.equal(result.ok,false);assert.equal(result.code,'credential-revoke-failed');if(signal)c.release(f.instance,'model',signal);if(action==='rewrite')assert.equal(f.exists(f.file),true);f.valueFree();
|
||
+});
|
||
+test('656 new snapshot invalidates old token even if later capture refuses on absent final',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);await finished(f);const token=proof(f),capture=await outcome(()=>f.credentials.captureModelRevocation(f.instance));assert.equal(capture.ok,false);assert.equal(capture.code,'credential-revoke-failed');const result=await outcome(()=>f.credentials.finishModelRevocation(f.instance,token));assert.equal(result.ok,false);assert.equal(result.code,'credential-revoke-failed');f.valueFree();
|
||
+});
|
||
+test('656 wrong instance cannot reuse a completed token',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);await finished(f);const token=proof(f),count=f.counts.unlinkFinal,result=await outcome(()=>f.credentials.finishModelRevocation('demo/other',token));assert.equal(result.ok,false);assert.equal(result.code,'credential-revoke-failed');assert.equal(f.counts.unlinkFinal,count);f.credentials.finishModelRevocation(f.instance,token);assert.equal(f.counts.unlinkFinal,count);f.valueFree();
|
||
+});
|
||
+
|
||
+test('656 nonmodel memory-first failure and retry remain unchanged after model completion',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});const file=join(dirname(f.file),'gitea'),value='fixture-gitea-key';f.remember(value);f.credentials.register(f.instance,'gitea',{store:true,file,rotateBy:'2099-01-01'});f.credentials.issue(f.instance,'gitea',value,'2099-01-01');await f.prepare();f.complete();const count=f.counts.unlinkFinal;f.faults.unlinkService=true;
|
||
+ const failure=await outcome(()=>f.credentials.remove(f.instance));assert.equal(f.counts.unlinkFinal,count,'nonmodel failure does not repeat model unlink');assert.equal(failure.ok,false);assert.equal(failure.code,'credential-file');assert.equal(f.credentials.status().find(s=>s.instance===f.instance&&s.service==='gitea').state,'revoked','nonmodel keeps memory-first semantics');assert.equal(f.exists(file),true,'failed nonmodel unlink remains honestly present');f.faults.unlinkService=false;
|
||
+ const result=f.credentials.remove(f.instance);assert.deepEqual([...result.services].sort(),['gitea','model']);assert.equal(f.counts.unlinkFinal,count);assert.equal(f.counts.unlinkService,2,'one failed then one successful service unlink; model remains completed');f.valueFree();
|
||
+});
|
||
+
|
||
+test('656 replaced private agent directory cannot reuse exact historical completion proof',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);await finished(f);const token=proof(f),directory=dirname(f.file);fs.renameSync(directory,join(f.root,'former-owned-agent-dir'));fs.mkdirSync(directory,{mode:0o700});const count=f.counts.unlinkFinal,result=await outcome(()=>f.credentials.finishModelRevocation(f.instance,token));assert.equal(result.ok,false);assert.equal(result.code,'credential-revoke-failed');assert.equal(f.counts.unlinkFinal,count);f.valueFree();
|
||
+});
|
||
+
|
||
+test('656 another Credentials epoch cannot use a former objects completed token',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);await finished(f);const token=proof(f);let other;t.after(()=>other?.close());other=new Credentials({storeRoot:f.storeRoot,dataRoot:f.dataRoot,modelAgents:[f.instance]});const count=f.counts.unlinkFinal,result=await outcome(()=>other.finishModelRevocation(f.instance,token));assert.equal(result.ok,false);assert.equal(result.code,'credential-revoke-failed');assert.equal(f.counts.unlinkFinal,count);f.credentials.finishModelRevocation(f.instance,token);assert.equal(f.counts.unlinkFinal,count);f.valueFree();
|
||
+});
|
||
diff --git a/packages/bus/tests/model-credential-store.test.mjs b/packages/bus/tests/model-credential-store.test.mjs
|
||
new file mode 100644
|
||
index 000000000..e319400d3
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-credential-store.test.mjs
|
||
@@ -0,0 +1,76 @@
|
||
+// P2 RED: model is implicit, durable/private, and status never exposes current/retiring values.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { randomBytes, createHash } from 'node:crypto';
|
||
+import { Credentials } from '../src/credentials.mjs';
|
||
+
|
||
+function fixture(t) {
|
||
+ const root = fs.mkdtempSync(join(tmpdir(), 'p2-store-'));
|
||
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||
+ const storeRoot = join(root, 'config', 'secrets', 'agents');
|
||
+ fs.mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||
+ const open = () => new Credentials({ storeRoot, dataRoot: join(root, 'data'), modelAgents: ['demo/coder'] });
|
||
+ const file = join(storeRoot, 'demo', 'coder', 'model');
|
||
+ return { root, storeRoot, open, file };
|
||
+}
|
||
+const key = () => 'msk-fixture-' + randomBytes(32).toString('base64url');
|
||
+const id = value => createHash('sha256').update(value).digest('hex').slice(0, 12);
|
||
+const record = value => ({ key: value, keyId: id(value), issuedAt: '2026-10-24T00:00:00.000Z', rotateBy: '2026-11-23', state: 'valid', retiring: [] });
|
||
+
|
||
+test('implicit model kind starts not-issued without a business or role reference', t => {
|
||
+ const f = fixture(t), c = f.open(); t.after(() => c.close());
|
||
+ const status = c.status().find(s => s.instance === 'demo/coder' && s.service === 'model');
|
||
+ assert.equal(status?.state, 'not-issued');
|
||
+ assert.equal(fs.existsSync(f.file), false);
|
||
+});
|
||
+test('model record persists at 0600 in 0700 dirs and reloads ids/issue time/state only in status', t => {
|
||
+ const f = fixture(t), c = f.open(), value = key(); t.after(() => c.close());
|
||
+ assert.equal(typeof c.writeModel, 'function');
|
||
+ c.writeModel('demo/coder', record(value));
|
||
+ assert.equal(fs.statSync(f.file).mode & 0o777, 0o600);
|
||
+ for (const dir of [f.storeRoot, join(f.storeRoot, 'demo'), join(f.storeRoot, 'demo', 'coder')]) assert.equal(fs.statSync(dir).mode & 0o777, 0o700);
|
||
+ c.close();
|
||
+ const again = f.open(); t.after(() => again.close());
|
||
+ const status = again.status().find(s => s.service === 'model');
|
||
+ assert.equal(status.keyId === id(value), true);
|
||
+ assert.equal(status.issuedAt, '2026-10-24T00:00:00.000Z');
|
||
+ assert.equal(status.state, 'valid');
|
||
+ assert.equal(JSON.stringify(again.status()).includes(value), false);
|
||
+ assert.equal(again.modelRecord('demo/coder').key === value, true);
|
||
+});
|
||
+test('revoking model metadata survives restart without losing the private record', t => {
|
||
+ const f = fixture(t), c = f.open(), value = key(); t.after(() => c.close());
|
||
+ assert.equal(typeof c.writeModel, 'function');
|
||
+ c.writeModel('demo/coder', { ...record(value), state: 'revoking', revokingAt: '2026-10-24T01:00:00.000Z' });
|
||
+ c.close(); const again = f.open(); t.after(() => again.close());
|
||
+ assert.equal(again.status().find(s => s.service === 'model').state, 'revoking');
|
||
+ assert.equal(again.modelRecord('demo/coder').key === value, true);
|
||
+ assert.equal(JSON.stringify(again.status()).includes(value), false);
|
||
+});
|
||
+test('model record wrong file mode refuses startup rather than inventing a replacement', t => {
|
||
+ const f = fixture(t), c = f.open(); t.after(() => c.close());
|
||
+ assert.equal(typeof c.writeModel, 'function'); c.writeModel('demo/coder', record(key())); c.close();
|
||
+ fs.chmodSync(f.file, 0o640);
|
||
+ assert.throws(() => f.open(), e => e.code === 'credential-file');
|
||
+});
|
||
+test('non-proxy Credentials has no implicit model kind', t => {
|
||
+ const f = fixture(t), c = new Credentials({ storeRoot: f.storeRoot, dataRoot: join(f.root, 'data') });
|
||
+ t.after(() => c.close()); assert.deepEqual(c.status(), []);
|
||
+});
|
||
+test('credential-revoke-failed: generic remove cannot bypass remote model confirmation', t => {
|
||
+ const f = fixture(t), c = f.open(); t.after(() => c.close());
|
||
+ c.writeModel('demo/coder', record(key()));
|
||
+ assert.throws(() => c.remove('demo/coder'), e => e.code === 'credential-revoke-failed');
|
||
+ assert.equal(c.modelRecord('demo/coder').state, 'valid'); assert.equal(fs.existsSync(f.file), true);
|
||
+});
|
||
+test('retiring keys reload privately and remain covered by the credential leak guard', t => {
|
||
+ const f = fixture(t), c = f.open(), value = key(), old = key(); t.after(() => c.close());
|
||
+ c.writeModel('demo/coder', { ...record(value), retiring: [{ key: old, keyId: id(old), issuedAt: '2026-10-23T00:00:00.000Z', retireAt: '2026-10-24T02:00:00.000Z' }] });
|
||
+ c.close(); const again = f.open(); t.after(() => again.close());
|
||
+ assert.equal(again.modelRecord('demo/coder').retiring[0].key === old, true);
|
||
+ const status = JSON.stringify(again.status()); assert.equal(status.includes(old), false); assert.equal(status.includes(value), false);
|
||
+ assert.throws(() => again.assertClean({ value: old }), e => e.code === 'credential-leak');
|
||
+});
|
||
diff --git a/packages/bus/tests/model-final-completion.test.mjs b/packages/bus/tests/model-final-completion.test.mjs
|
||
new file mode 100644
|
||
index 000000000..1b88e271e
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-final-completion.test.mjs
|
||
@@ -0,0 +1,83 @@
|
||
+// #656 STATIC unit proof/bookkeeping tests: real controller, real HTTP/private files.
|
||
+// Empty injected view here is NOT runtime identity evidence; runtime file tests it.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join, dirname, basename } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { fork } from 'node:child_process';
|
||
+import { fileURLToPath } from 'node:url';
|
||
+import { revocationFixture, outcome, id, bounded } from './model-revocation-fixture.mjs';
|
||
+const timeout=30000;
|
||
+const cached=f=>assert.equal(f.record()?.state==='revoking',true,'failed final completion retains cached revoking admission fence');
|
||
+async function completed(f){await f.prepare();f.complete();}
|
||
+
|
||
+test('656 ordinary present final completes with exactly one final pathname unlink',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});const before=f.counts.unlinkFinal;await completed(f);assert.equal(f.record()===null,true);assert.equal(f.exists(f.file),false);assert.equal(f.counts.unlinkFinal-before,1,'one final unlink with no generic second unlink');const state=f.credentials.status().find(s=>s.instance===f.instance&&s.service==='model');assert.equal(state.state,'revoked');assert.equal(state.date,'2099-01-01');f.complete();assert.equal(f.counts.unlinkFinal-before,1,'ordinary no-record/no-supplied-proof repeat is a no-op');f.valueFree();
|
||
+});
|
||
+test('656 completion without confirmed remote preparation remains refused',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});const result=await outcome(()=>f.complete());assert.equal(result.ok,false);assert.equal(result.code,'credential-revoke-failed');cached(f);assert.equal(f.exists(f.file),true);f.valueFree();
|
||
+});
|
||
+test('656 failed final unlink retains cache disk confirmation and exact retry without another remote operation',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();const calls=f.state.calls.length,before=f.counts.unlinkFinal;f.faults.unlinkFinal=true;const failure=await outcome(()=>f.complete());cached(f);assert.equal(failure.code,'credential-revoke-failed');assert.equal(f.exists(f.file),true);assert.throws(()=>f.manager.assertLaunchable(f.instance),e=>e.code==='proxy-key-revoking');
|
||
+ f.faults.unlinkFinal=false;f.complete();assert.equal(f.record()===null,true);assert.equal(f.exists(f.file),false);assert.equal(f.counts.unlinkFinal-before,2,'one failed then one successful final unlink, no second unlink after cache clear');assert.equal(f.state.calls.length,calls,'exact retained confirmation permits local retry without HTTP under writer lock');f.valueFree();
|
||
+});
|
||
+test('656 final read failure keeps cached intent and closes all opened model descriptors',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();f.faults.readFinal=true;const failure=await outcome(()=>f.complete());cached(f);assert.equal(f.counts.readFinal>0,true,'owned bounded final read fault was reached');assert.equal(failure.code,'credential-revoke-failed');assert.equal(f.exists(f.file),true);assert.equal(f.openModelFds(),0,'bounded final reader closes descriptor on error');f.faults.readFinal=false;f.complete();assert.equal(f.record()===null,true);assert.equal(f.exists(f.file),false);f.valueFree();
|
||
+});
|
||
+
|
||
+for(const mutation of ['inode replacement','content change','invalid shape'])test('656 '+mutation+' before final completion is not keyID-only confirmation',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();
|
||
+ if(mutation==='inode replacement'){const replacement=join(f.root,'owned-final-replacement');fs.writeFileSync(replacement,fs.readFileSync(f.file),{mode:0o600,flag:'wx'});fs.renameSync(replacement,f.file);}else if(mutation==='content change'){fs.writeFileSync(f.file,JSON.stringify({...f.record(),rotateBy:'2098-01-01'})+'\n',{mode:0o600});}else fs.writeFileSync(f.file,'{',{mode:0o600});
|
||
+ const before=id(fs.readFileSync(f.file)),failure=await outcome(()=>f.complete());cached(f);assert.equal(failure.code,'credential-revoke-failed');assert.equal(f.exists(f.file),true);assert.equal(id(fs.readFileSync(f.file)),before,'detected existing bytes are never overwritten or deleted');f.valueFree();
|
||
+});
|
||
+test('656 initially missing final never mints completion proof from temp or label',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed();fs.unlinkSync(f.file);const before=f.state.calls.length,failure=await outcome(()=>f.prepare());assert.equal(!failure.ok&&failure.code==='credential-revoke-failed',true,'initially missing final cannot authorize completion preparation');cached(f);assert.equal(f.exists(f.file),false,'missing input is not restored');assert.equal(f.exists(f.temporary),true);assert.equal(f.state.calls.length,before);f.valueFree();
|
||
+});
|
||
+test('656 final missing after confirmed preparation is not treated as proven prior completion',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();fs.unlinkSync(f.file);const failure=await outcome(()=>f.complete());cached(f);assert.equal(failure.code,'credential-revoke-failed');assert.equal(f.exists(f.file),false,'external missing final is not falsely reported retained or restored');f.valueFree();
|
||
+});
|
||
+test('656 same keyID but changed FULL cached and durable record refuses stale confirmation',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();const changed={...f.record(),revokingAt:'2026-10-25T00:00:01.000Z'};f.credentials.writeModel(f.instance,changed);const failure=await outcome(()=>f.complete());cached(f);assert.equal(failure.code,'credential-revoke-failed');assert.equal(f.exists(f.file),true);assert.equal(f.record().revokingAt,changed.revokingAt);f.valueFree();
|
||
+});
|
||
+for(const action of ['reload','rewrite'])test('656 '+action+' invalidates prior proof even for identical FULL record',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();const record=f.record();if(action==='reload')f.credentials.reload(f.instance,'model',record.rotateBy);else f.credentials.writeModel(f.instance,record);const failure=await outcome(()=>f.complete());cached(f);assert.equal(failure.code,'credential-revoke-failed');assert.equal(f.exists(f.file),true);await completed(f);assert.equal(f.record()===null,true);assert.equal(f.exists(f.file),false);f.valueFree();
|
||
+});
|
||
+test('656 close invalidates supplied confirmation and absent cache is not silent success',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();f.credentials.close();const failure=await outcome(()=>f.complete());assert.equal(!failure.ok&&failure.code==='credential-revoke-failed',true,'absent cache and reference cannot silently complete supplied confirmation');assert.equal(f.exists(f.file),true);f.valueFree();
|
||
+});
|
||
+test('656 model hold invalidates supplied proof without deleting final',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();f.credentials.hold(f.instance,'model');const failure=await outcome(()=>f.complete());assert.equal(!failure.ok&&failure.code==='credential-revoke-failed',true,'held absent cache cannot silently complete old confirmation');assert.equal(f.exists(f.file),true);assert.equal(f.credentials.held(f.instance,'model'),true);f.valueFree();
|
||
+});
|
||
+test('656 model in-flight slot is uncertainty and never an abort callback after final unlink',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});const signal=f.credentials.claim(f.instance,'model');let aborted=0;signal.addEventListener('abort',()=>{aborted++;});const failure=await outcome(()=>f.prepare());assert.equal(!failure.ok&&failure.code==='credential-revoke-failed',true,'unproven model inflight slot refuses preparation');cached(f);assert.equal(f.exists(f.file),true);assert.equal(aborted,0);f.credentials.release(f.instance,'model',signal);await completed(f);assert.equal(aborted,0,'model-only completion invokes no abort callback');assert.equal(f.credentials.abortClaim(f.instance,'model').pending,false);f.valueFree();
|
||
+});
|
||
+test('656 new tmp after preparation refuses before final removal',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();fs.copyFileSync(f.file,f.temporary);fs.chmodSync(f.temporary,0o600);const failure=await outcome(()=>f.complete());cached(f);assert.equal(failure.code,'credential-revoke-failed');assert.equal(f.exists(f.file)&&f.exists(f.temporary),true);await completed(f);assert.equal(f.exists(f.file)||f.exists(f.temporary),false);f.valueFree();
|
||
+});
|
||
+test('656 failed tmp absence confirmation retains final intent and fresh retry rechecks before completion',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed();f.faults.absenceTmp=true;const failure=await outcome(()=>f.prepare());assert.equal(!failure.ok&&failure.code==='credential-revoke-failed',true,'uncertain absence does not mint local completion confirmation');assert.equal(f.counts.absenceTmp>0,true,'owned post-unlink absence fault was reached');cached(f);assert.equal(f.exists(f.file),true);assert.equal(f.exists(f.temporary),false,'own successful tmp unlink is not falsely rolled back');f.faults.absenceTmp=false;await completed(f);assert.equal(f.exists(f.file)||f.exists(f.temporary),false);f.valueFree();
|
||
+});
|
||
+test('656 exact prior opaque completion proof alone permits already-completed repeat',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();assert.equal(f.proofs.length,1,'real controller produced complete-record completion proof');const proof=f.proofs[0];f.complete();const count=f.counts.unlinkFinal;f.credentials.finishModelRevocation(f.instance,proof);assert.equal(f.counts.unlinkFinal,count,'same proven completion repeat does not unlink a path');
|
||
+ const forged=await outcome(()=>f.credentials.finishModelRevocation(f.instance,Object.freeze({})));assert.equal(forged.ok,false);assert.equal(forged.code,'credential-revoke-failed');assert.equal(f.exists(f.file)||f.exists(f.temporary),false);f.valueFree();
|
||
+});
|
||
+test('656 a newer snapshot invalidates an older confirmed token and grants no remote authority',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();assert.equal(f.proofs.length,1,'real controller produced complete-record completion proof');f.credentials.captureModelRevocation(f.instance);const failure=await outcome(()=>f.complete());cached(f);assert.equal(failure.code,'credential-revoke-failed');assert.equal(f.exists(f.file),true);await completed(f);assert.equal(f.record()===null,true);f.valueFree();
|
||
+});
|
||
+
|
||
+test('656 FIFO snapshot is nonblocking in owned bounded child and retains intent',{timeout},async t=>{
|
||
+ let child,closed,ownedRoot,watchdog,answer,startNice,spawnFailed=false,badRoot=false,timedOut=false;let readyResolve;const ready=new Promise(resolve=>{readyResolve=resolve;});
|
||
+ t.after(async()=>{clearTimeout(watchdog);const errors=[];try{if(child?.exitCode===null&&child.signalCode===null)child.kill('SIGTERM');if(closed)await bounded(closed,'owned FIFO child cleanup timeout');}catch{errors.push('owned-child-close');}try{if(ownedRoot&&fs.existsSync(ownedRoot))fs.rmSync(ownedRoot,{recursive:true,force:true});}catch{errors.push('owned-root-close');}if(errors.length)throw new AggregateError(errors.map(code=>Error(code)),'owned FIFO parent cleanup failed');});
|
||
+ child=fork(fileURLToPath(new URL('./model-revocation-fifo-child.mjs',import.meta.url)),[],{execArgv:[],env:{...process.env},stdio:['ignore','ignore','ignore','ipc']});
|
||
+ closed=new Promise(resolve=>child.once('close',(code,signal)=>resolve({code,signal})));
|
||
+ child.once('error',()=>{spawnFailed=true;readyResolve();});
|
||
+ child.on('message',message=>{if(message?.kind==='owned-root'){if(typeof message.root==='string'&&dirname(message.root)===tmpdir()&&basename(message.root).startsWith('r656-'))ownedRoot=message.root;else badRoot=true;}if(message?.kind==='ready'){startNice=message.nice;watchdog=setTimeout(()=>{timedOut=true;child.kill('SIGTERM');},5000);readyResolve();}if(message?.kind==='result'||message?.kind==='error'){answer=message;readyResolve();}});
|
||
+ await bounded(ready,'owned FIFO child readiness unavailable',15000);const end=await bounded(closed,'owned FIFO child result unavailable',15000);clearTimeout(watchdog);
|
||
+ assert.equal(spawnFailed||badRoot,false,'only captured owned child/root accepted');assert.equal(timedOut,false,'FIFO read does not block owned child');assert.equal(end.code,0,'owned child setup and cleanup complete');assert.equal(end.signal,null);assert.equal(answer?.kind,'result','owned child reaches real controller outcome');assert.equal(startNice,19);assert.equal(answer.nice,19,'child priority is real measured19');assert.equal(answer.ownedCleanup,true);
|
||
+ assert.equal(answer.cachedRevoking,true,'FIFO cannot authorize clearing cached revoking intent');assert.equal(answer.code,'credential-revoke-failed');assert.equal(answer.finalPresent&&answer.tmpPresent,true);assert.equal(answer.openModelFds,0,'FIFO descriptor closed after type refusal');assert.equal(ownedRoot?fs.existsSync(ownedRoot):true,false,'owned child removed private fixture root');
|
||
+});
|
||
+
|
||
+test('656 successful later generation rewrite invalidates completed historical token',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t);f.seed({matchingTmp:false});await f.prepare();assert.equal(f.proofs.length,1,'real controller produced complete-record completion proof');const proof=f.proofs[0];f.complete();const next=f.seed({matchingTmp:false,valid:true}),before=id(fs.readFileSync(f.file));const failure=await outcome(()=>f.credentials.finishModelRevocation(f.instance,proof));assert.equal(failure.ok,false);assert.equal(failure.code,'credential-revoke-failed');assert.equal(f.record()?.keyId,next.keyId);assert.equal(id(fs.readFileSync(f.file)),before);f.valueFree();
|
||
+});
|
||
diff --git a/packages/bus/tests/model-hire-ownership-fixture.mjs b/packages/bus/tests/model-hire-ownership-fixture.mjs
|
||
new file mode 100644
|
||
index 000000000..a8da4a87c
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-hire-ownership-fixture.mjs
|
||
@@ -0,0 +1,126 @@
|
||
+// #655 isolated STATIC fixture: actual managed fromSession caller + runtime/writer/controller.
|
||
+import fs from 'node:fs';
|
||
+import http from 'node:http';
|
||
+import assert from 'node:assert/strict';
|
||
+import { createHash, randomBytes } from 'node:crypto';
|
||
+import { spawnSync } from 'node:child_process';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { fixture, fakeHarnesses } from '../../cli/tests/helpers.mjs';
|
||
+import { loadSystem, bootConfig } from '../../cli/src/config.mjs';
|
||
+import { createLauncher } from '../../cli/src/launcher.mjs';
|
||
+import { startBroker } from '../../bus/src/runtime.mjs';
|
||
+import { gitEnv } from '../../seat/src/checkout.mjs';
|
||
+import { organizationWriter } from '../../business/src/organization.mjs';
|
||
+import { writeJson } from '../../business/tests/helpers.mjs';
|
||
+import { readSessions, runsDir, sessionsFile, SESSIONS_VERSION, launchLogFile } from '../../seat/src/session.mjs';
|
||
+export const waitFor = async (read, message, ms=15000) => { const end=Date.now()+ms;while(Date.now()<end){const value=await read();if(value)return value;await new Promise(r=>setTimeout(r,20));}throw Error(message); };
|
||
+export const pause = ms => new Promise(r=>setTimeout(r,ms));
|
||
+const id = value => createHash('sha256').update(value).digest('hex').slice(0,12);
|
||
+export async function ownershipFixture(t,{launches=true,listed=true,keep=false,retired=false,noHire=false}={}){
|
||
+ const proxy=true,refuseProbe=false;
|
||
+ const root=fs.mkdtempSync(join(tmpdir(),'own655-')),closers=[],history=new Set(['fx655-manage']),proxyReceipts=[],logs=[];
|
||
+ let server,bus,launcher,stderr;const active=new Set(),gates=[];const track=p=>{active.add(p);p.then(()=>active.delete(p),()=>active.delete(p));return p;};const trace={preflightAttempts:0,successfulPreflights:0,enableBeforeSpawn:[],bindings:[],stalls:[]};
|
||
+ const checkHistory=entries=>{const text=JSON.stringify(entries);for(const value of history)if(text.includes(value))throw Error('fixture credential leaked');};
|
||
+ // Available before any acquired resource/setup can fail; no later f/launcher TDZ.
|
||
+ const launchLog=()=>{const file=launchLogFile(join(root,'data'),'acme');return fs.existsSync(file)?fs.readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)):[];};
|
||
+ t.after(async()=>{const failures=[];for(const g of gates)g.release();try{await waitFor(()=>active.size===0,'owned operations did not settle',5000);}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}try{checkHistory({launch:launchLog()});}catch{failures.push('owned-launch-log-guard');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');});
|
||
+ const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];return doc;});
|
||
+ // The established scratch aide pattern only; repository roles/policy stay untouched.
|
||
+ for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
|
||
+ const chiefFile=join(f.roles,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide','helper']});
|
||
+ const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles};
|
||
+ const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);
|
||
+ const state={keys:[sentinel],calls:[],refuseProbe,counts:{GET:0,PUT:0,PROBE:0},puts:[],failRemoval:false};
|
||
+ server=http.createServer(async(req,res)=>{try{
|
||
+ const supplied=req.headers.authorization?.slice(7);if(supplied)history.add(supplied);for(const key of state.keys)history.add(key);
|
||
+ if(req.url==='/v8/management/config/access/api-keys'){
|
||
+ state.calls.push(req.method);const ordinal=++state.counts[req.method];if(supplied!=='fx655-manage'){res.writeHead(401);res.end('{}');return;}
|
||
+ let values;if(req.method==='PUT'){const parts=[];for await(const part of req)parts.push(part);values=JSON.parse(Buffer.concat(parts));for(const value of values)history.add(value);state.puts.push([...values]);}
|
||
+ const gate=gates.find(g=>!g.used&&!g.cancelled&&g.method===req.method&&g.ordinal===ordinal);if(gate){gate.used=true;gate.seen=true;await gate.promise;}
|
||
+ if(res.destroyed)return;
|
||
+ if(req.method==='GET'){res.end(JSON.stringify(state.keys));return;}
|
||
+ if(state.failRemoval&&state.keys.some(key=>!values.includes(key))){res.writeHead(503);res.end('{}');return;}
|
||
+ state.keys=values;res.end('{"status":"ok"}');return;
|
||
+ }
|
||
+ state.calls.push('PROBE');state.counts.PROBE++;res.writeHead(!state.refuseProbe&&state.keys.includes(supplied)?200:401);res.end('{}');
|
||
+ }catch{if(!res.destroyed){res.writeHead(500);res.end('{}');}}});
|
||
+ await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});const port=server.address().port;assert.notEqual(port,8317);
|
||
+ fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx655-manage',{mode:0o600});
|
||
+ fs.mkdirSync(join(f.dataRoot,'bus-host'),{recursive:true,mode:0o700});fs.writeFileSync(sessionsFile(f.dataRoot),JSON.stringify({sessionsVersion:SESSIONS_VERSION,sessions:[]}),{mode:0o600});
|
||
+ const git=(...args)=>{const result=spawnSync('git',['-c','user.name=test','-c','[email protected]','-C',f.doc.projects.stack.root,...args],{env:gitEnv(env),encoding:'utf8'});assert.equal(result.status,0,'owned local git fixture succeeds');};
|
||
+ git('init','-q','-b','main');fs.writeFileSync(join(f.doc.projects.stack.root,'file.txt'),'one\n');git('add','file.txt');git('commit','-q','-m','one');
|
||
+ const adapter=join(root,'adapter.sh');fs.writeFileSync(adapter,'case "$MOSAIC_REQUEST" in *SILENT*) exec /usr/bin/sleep 30;; *) printf %s fx655-output; printf "done\\n" > "$MOSAIC_TURN_MARKER";; esac\n',{mode:0o644});
|
||
+ const system={...loadSystem({env}),...(proxy?{proxy:{port,binary:'/fixture-unused',firstOutputSeconds:120}}:{})};
|
||
+ const {trackers,...boot}=bootConfig({system,businessId:'acme',env});assert.equal(trackers,undefined);
|
||
+ stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){proxyReceipts.push(String(chunk));const callback=args.find(a=>typeof a==='function');callback?.();return true;}return stderr.call(this,chunk,...args);};
|
||
+ bus=await startBroker(boot);closers.push(()=>bus.close());
|
||
+ const hooks=[];let expectedKeyId;
|
||
+ const actualOp=async m=>{
|
||
+ switch(m.op){
|
||
+ case 'modelPreflight':return bus.modelPreflight(m);
|
||
+ case 'modelStall':return bus.modelStall(m);
|
||
+ case 'identity':return bus.identity(m.cap);
|
||
+ case 'authorizeLaunch':return bus.authorizeLaunch(m.cap,m.instance);
|
||
+ case 'refuse':return bus.refuse(m.cap,m.code,m.subject);
|
||
+ case 'endLaunch':return bus.endLaunch(m.record);
|
||
+ case 'recoverLaunch':return bus.recoverLaunch(m.record);
|
||
+ case 'credentialStatus':return bus.credentialStatus(m.business,m.role);
|
||
+ case 'mayLaunch':return bus.mayLaunch(m);
|
||
+ case 'landHolders':return bus.landHolders(m.business);
|
||
+ case 'landHeld':return bus.landHeld(m.record);
|
||
+ case 'landRelease':return bus.landRelease(m.record);
|
||
+ case 'threadsState':return bus.threadsState(m.business);
|
||
+ case 'threadIdle':return bus.threadIdle(m.record);
|
||
+ case 'lifecycleRequests':return bus.lifecycleRequests(m.business);
|
||
+ case 'lifecycleTake':return bus.lifecycleTake(m.record);
|
||
+ case 'lifecycleDelete':return bus.lifecycleDelete(m.record);
|
||
+ case 'lifecycleDone':return bus.lifecycleDone(m.record);
|
||
+ case 'sessionClears':return bus.sessionClears(m.business);
|
||
+ default:throw Error('fixture unrequested trusted operation');
|
||
+ }
|
||
+ };
|
||
+ const host={business:'acme',path:bus.path,dataRoot:f.dataRoot,beforeClose:fn=>hooks.push(fn),
|
||
+ op:async m=>{
|
||
+ if(m.op==='modelPreflight'){trace.preflightAttempts++;const value=await actualOp(m);if(value){trace.successfulPreflights++;expectedKeyId=id(value.key);}return value;}
|
||
+ if(m.op==='landHolders'&&trace.successfulPreflights){
|
||
+ // Existing trusted host call occurs after enable publication and BEFORE bundle,
|
||
+ // registerRuntime/spawnSession/bind in the production startLocked sequence.
|
||
+ const dirs=fs.readdirSync(runsDir(f.dataRoot,'acme'));assert.equal(dirs.length,1);const file=join(runsDir(f.dataRoot,'acme'),dirs[0],'proxy-first-turn-enabled');
|
||
+ assert.equal(fs.readFileSync(file,'utf8'),'enabled\n','managed caller publishes enable before spawn');assert.equal(fs.statSync(file).mode&0o777,0o600);assert.equal(trace.bindings.length,0);assert.equal(readSessions(f.dataRoot).length,0);trace.enableBeforeSpawn.push(true);
|
||
+ }
|
||
+ if(m.op==='modelStall'){const result=await actualOp(m);trace.stalls.push({run:m.run,keyId:m.keyId,stopped:result.stopped});return result;}
|
||
+ return actualOp(m);
|
||
+ },
|
||
+ bindLaunch:async record=>{const result=bus.bindLaunch(record);trace.bindings.push({run:record.run,pid:record.pid,startTime:record.startTime,modelKeyId:record.modelKeyId});return result;}};
|
||
+ const makeLoader=await fakeHarnesses({after:close=>closers.push(close)},{pi:adapter});
|
||
+ launcher=createLauncher({host,system,env,launches,makeLoader,sessionDefaults:{pollInterval:50},harnessVersion:'0.85.1',log:line=>logs.push(line)});
|
||
+ // The existing trusted host hook owns all launcher-created children. No registry
|
||
+ // PID is signalled by fixture teardown; normal bounded launcher close is used.
|
||
+ closers.push(async()=>{for(const close of hooks)await close(0);});await launcher.listen();
|
||
+ const chief=bus.broker.bindLaunch({business:'acme',role:'chief',run:'own-chief',harness:'pi'});bus.broker.request(chief,{verb:'role.claim'});
|
||
+ const human=bus.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});
|
||
+ const valueFree=()=>checkHistory({logs,proxyReceipts,launch:launchLog(),trace});
|
||
+ const writer=organizationWriter({dir:configDir,rolesDir:f.roles});let revisionEdits=0;
|
||
+ const file=join(configDir,'businesses','acme.json');
|
||
+ const hold=(method='GET',ordinal=state.counts[method]+1)=>{let release;const promise=new Promise(r=>{release=r;});const gate={method,ordinal,seen:false,used:false,cancelled:false,promise,release};gates.push(gate);return {ordinal,seen:()=>gate.seen,entered:()=>waitFor(()=>gate.seen,'owned HTTP gate not reached',5000),release,cancel:()=>{gate.cancelled=true;release();}};};
|
||
+ const outcome=promise=>{const box={settled:false};box.promise=track(Promise.resolve(promise).then(value=>{box.settled=true;box.ok=true;box.value=value;return box;},error=>{box.settled=true;box.ok=false;box.code=error.code??'unexpected-error';return box;}));return box;};
|
||
+ const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=bus.broker.request(cap,{verb:'instance.hire',args:{name,definition:'aide',reason:'fixture ownership',...extra}});called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
|
||
+ const record=(name='aide')=>bus.credentials.modelRecord('acme/'+name);
|
||
+ const snapshot=()=>{const journal=join(configDir,'businesses','.history','acme','changes.jsonl');return {membership:id(fs.readFileSync(file)),journal:fs.existsSync(journal)?id(fs.readFileSync(journal)):null,credentials:id(JSON.stringify(bus.credentials.status())),model:record()?id(JSON.stringify(record())):null,remote:id(JSON.stringify(state.keys)),calls:state.calls.length};};
|
||
+ return {root,f,state,trace,proxyReceipts,logs,launcher,bus,chief,record,hold,outcome,hire,snapshot,file,launchLog,expectedKeyId:()=>expectedKeyId,valueFree,
|
||
+ launchAide:()=>outcome(launcher.fromSession(chief,'aide')),
|
||
+ preflight:(name='aide')=>outcome(bus.modelPreflight({business:'acme',role:name})),
|
||
+ listed:name=>JSON.parse(fs.readFileSync(file,'utf8')).launch.instances.includes(name),
|
||
+ bumpRevision:()=>{const {document,revision}=writer.read('acme');document.vars['tracker.pollSeconds']=60+(++revisionEdits);writer.write('acme',document,{revision,actor:'jason'});},
|
||
+ wrongActor:()=>{const cap=bus.broker.bindLaunch({business:'acme',role:'coder',run:'own-coder',harness:'pi'});bus.broker.request(cap,{verb:'role.claim'});return cap;},
|
||
+ healthy:async key=>{const response=await fetch('http://127.0.0.1:'+port+'/v1/models',{headers:{authorization:'Bearer '+key},signal:AbortSignal.timeout(5000)});await response.arrayBuffer();return response.status;},
|
||
+ assertModelAbsent:name=>{assert.equal(bus.credentials.modelRecord('acme/'+name)===null,true);assert.equal(fs.existsSync(join(configDir,'secrets','agents','acme',name,'model')),false);},
|
||
+ rotateBound:async()=>{const before=record();bus.credentials.writeModel('acme/aide',{...before,rotateBy:new Date(Date.now()-86400_000).toISOString().slice(0,10)});await waitFor(()=>record()?.retiring.some(r=>r.keyId===before.keyId),'original runtime sweep rotates bound generation');return before.keyId;},
|
||
+ explicitEnd:()=>bus.broker.request(chief,{verb:'role.revoke',args:{role:'aide',reason:'fixture explicit end'}}),
|
||
+ fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture explicit fire'}}))),
|
||
+ runDirectories:()=>fs.existsSync(runsDir(f.dataRoot,'acme'))?fs.readdirSync(runsDir(f.dataRoot,'acme')).map(name=>join(runsDir(f.dataRoot,'acme'),name)):[],
|
||
+ sessions:()=>readSessions(f.dataRoot),send:body=>bus.broker.request(human,{verb:'message.send',args:{to:'aide',body,class:'REQUEST'}}),
|
||
+ reply:sent=>waitFor(()=>bus.broker.request(human,{verb:'message.receive'}).find(m=>m.in_reply_to===sent.id),'real managed reply observed'),
|
||
+ assertHistoryGuard:()=>{try{checkHistory({probe:sentinel});}catch(e){if(e.message==='fixture credential leaked')return;}throw Error('fixture history guard failed');}};
|
||
+}
|
||
diff --git a/packages/bus/tests/model-hire-ownership.test.mjs b/packages/bus/tests/model-hire-ownership.test.mjs
|
||
new file mode 100644
|
||
index 000000000..cf94a470a
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-hire-ownership.test.mjs
|
||
@@ -0,0 +1,103 @@
|
||
+// #655 STATIC test-first preparation, real runtime/writer/controller and actual launcher.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { sandboxProblem } from '../../seat/src/sandbox.mjs';
|
||
+import { startTimeOf } from '../../seat/src/proc.mjs';
|
||
+import { ownershipFixture, waitFor } from './model-hire-ownership-fixture.mjs';
|
||
+const unavailable=sandboxProblem()??false; // Any skip means UNVERIFIED, not green acceptance.
|
||
+const done=async box=>{await box.promise;return box;};
|
||
+const refused=async(box,code)=>{await done(box);assert.equal(box.ok,false,'real operation refuses');assert.equal(box.code,code,'real operation preserves exact refusal');};
|
||
+const shape=record=>JSON.stringify(record);
|
||
+
|
||
+test('655 listed duplicate with no record refuses before any model or management effects',{timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t),before=f.snapshot();assert.equal(f.record()===null,true);
|
||
+ await refused(f.hire(),'organization-instance-exists');
|
||
+ assert.deepEqual(f.snapshot(),before,'duplicate hire makes no credential or management effects');f.assertHistoryGuard();f.valueFree();
|
||
+});
|
||
+
|
||
+test('655 actual launch own preflight generation and bound consumer survive concurrent duplicate hire',{skip:unavailable,timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t),pGet=f.hold('GET'),p=f.launchAide();await pGet.entered();
|
||
+ // P's own acquisition GET is held. Its PUT confirmation GET is ordinal+1;
|
||
+ // only an OLD H reuse GET can be ordinal+2. The correct early refusal removes it.
|
||
+ const hGet=f.hold('GET',pGet.ordinal+2),h=f.hire();await waitFor(()=>h.invoked,'real H Broker request returned its asynchronous lifecycle operation',5000);pGet.release();
|
||
+ await waitFor(()=>h.settled||hGet.seen(),'duplicate either refuses early or reaches real reuse GET',5000);
|
||
+ await done(p);assert.equal(p.ok,true,'actual own launch call settles');assert.equal(p.value.ok,true,'same actual launch succeeds after its own preflight');
|
||
+ assert.equal(f.trace.successfulPreflights,1);assert.deepEqual(f.trace.enableBeforeSpawn,[true]);
|
||
+ const session=await waitFor(()=>f.sessions().find(s=>s.instance==='aide'&&s.live),'same P is bound and live');
|
||
+ const generation=f.record();assert.equal(session.modelKeyId,generation.keyId);assert.equal(f.trace.bindings[0].modelKeyId,generation.keyId);
|
||
+ const beforeFile=shape(generation),beforeCalls=f.state.calls.length;
|
||
+ if(hGet.seen())hGet.release();else hGet.cancel();await refused(h,'organization-instance-exists');
|
||
+ assert.equal(f.state.keys.includes(generation.key)&&shape(f.record())===beforeFile,true,'admitted launch generation remains installed after duplicate failure');
|
||
+ assert.equal(startTimeOf(session.runnerPid),session.runnerStartTime,'same owned bound runner remains live');assert.equal(f.sessions().some(s=>s.run===session.run&&s.live),true);
|
||
+ assert.equal(f.launchLog().some(e=>e.event==='end'&&e.run===session.run),false);assert.equal(f.trace.stalls.length,0);
|
||
+ if(!hGet.seen())assert.equal(f.state.calls.length,beforeCalls,'early refused H has no later management effects');
|
||
+ const sent=f.send('OUTPUT');assert.equal((await f.reply(sent)).body,'fx655-output');assert.equal(await f.healthy(generation.key),200);f.valueFree();
|
||
+});
|
||
+
|
||
+for(const [label,options,args,code] of [
|
||
+ ['retired name',{retired:true},{name:'alpha'},'organization-instance-retired'],
|
||
+ ['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
|
||
+ ['fired mismatched legal definition',{listed:false},{definition:'helper'},'organization-instance-exists'],
|
||
+ ['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
|
||
+])test('655 shared validation '+label+' has no credential effects',{timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t,options),before=f.snapshot(),{name='aide',...extra}=args;await refused(f.hire(name,extra),code);
|
||
+ assert.deepEqual(f.snapshot(),before,'shared hire refusal precedes credential registration or proxy I/O');f.valueFree();
|
||
+});
|
||
+
|
||
+for(const [label,options,extra,code] of [
|
||
+ ['kept target',{keep:true},{},'instance-kept'],
|
||
+ ['noHire definition',{noHire:true},{},'definition-not-hireable'],
|
||
+ ['ineligible definition',{}, {definition:'security-reviewer'},'definition-not-hireable'],
|
||
+ ['invalid vars',{}, {vars:{harness:'pi'}},'invalid-request'],
|
||
+])test('655 existing Broker '+label+' refusal remains before model effects',{timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t,options),before=f.snapshot();await refused(f.hire('aide',extra),code);assert.deepEqual(f.snapshot(),before);f.valueFree();
|
||
+});
|
||
+
|
||
+test('655 existing nonchief actor refusal remains before shared checks and model effects',{timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t),cap=f.wrongActor(),before=f.snapshot();await refused(f.hire('aide',{},cap),'role-not-chief');assert.deepEqual(f.snapshot(),before);f.valueFree();
|
||
+});
|
||
+
|
||
+test('655 valid fired matching definition rehire publishes before queued acquisition',{timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();assert.equal(f.listed('aide'),false);
|
||
+ put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.ok,true);assert.equal(f.listed('aide'),true);assert.equal(p.value.key===f.record().key,true);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
|
||
+});
|
||
+
|
||
+test('655 preexisting admitted generation and retiring metadata survive duplicate failure',{skip:unavailable,timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t),p=await done(f.launchAide());assert.equal(p.ok&&p.value.ok,true);const sent=f.send('OUTPUT');await f.reply(sent);
|
||
+ const retiring=await f.rotateBound(),before=shape(f.record()),puts=f.state.counts.PUT;
|
||
+ await refused(f.hire(),'organization-instance-exists');assert.equal(shape(f.record())===before,true);assert.equal(f.record().retiring[0].keyId,retiring);assert.equal(f.state.counts.PUT,puts);assert.equal(await f.healthy(f.record().key),200);assert.equal(f.sessions().some(s=>s.modelKeyId===retiring&&s.live),true);f.valueFree();
|
||
+});
|
||
+
|
||
+test('655 captured revision is checked after controller queue before generation issue',{timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t),gate=f.hold('GET'),p=f.preflight('chief');await gate.entered();const h=f.hire('alpha');
|
||
+ // A following same-business operation proves H established its pending slot,
|
||
+ // rather than an arbitrary sleep before the external owner revision change.
|
||
+ await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'revision-conflict');
|
||
+ assert.equal(f.state.counts.PUT,1,'queued stale hire does not issue a generation');f.assertModelAbsent('alpha');assert.equal(f.listed('alpha'),false);f.valueFree();
|
||
+});
|
||
+
|
||
+test('655 genuine new generation revision failure cleans before queued acquisition receives a key',{timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();
|
||
+ const failedGeneration=f.state.puts[0].find(k=>!k.includes('-sentinel-'));assert.ok(failedGeneration,'real first issuance payload observed');
|
||
+ const p=f.preflight();f.bumpRevision();put.release();await refused(h,'revision-conflict');await done(p);
|
||
+ const valid=p.ok&&p.value.key!==failedGeneration&&f.record()?.key===p.value.key&&f.state.keys.includes(p.value.key)&&!f.state.keys.includes(failedGeneration);
|
||
+ assert.equal(valid,true,'queued acquisition receives a valid post-cleanup generation');assert.equal(f.listed('aide'),false);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
|
||
+});
|
||
+
|
||
+test('655 failed cleanup retains revoking intent and refuses queued admission',{timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();f.bumpRevision();f.state.failRemoval=true;put.release();
|
||
+ await refused(h,'revision-conflict');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
|
||
+ assert.equal(!p.ok&&p.code==='proxy-key-revoking',true,'queued admission refuses retained revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
|
||
+});
|
||
+
|
||
+test('655 no-launch remains before own preflight and grants no consumer or stop authority',{timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t,{launches:false}),before=f.snapshot(),p=await done(f.launchAide());assert.equal(p.ok,true);assert.deepEqual(p.value,{ok:false,error:'launches-disabled'});assert.deepEqual(f.snapshot(),before);assert.equal(f.sessions().length,0);assert.equal(f.trace.preflightAttempts,0);assert.equal(f.trace.bindings.length,0);f.valueFree();
|
||
+});
|
||
+
|
||
+test('655 explicit lifecycle end confirms own exit before ordinary fire removes generation',{skip:unavailable,timeout:30000},async t=>{
|
||
+ const f=await ownershipFixture(t),p=await done(f.launchAide());assert.equal(p.ok&&p.value.ok,true);const session=await waitFor(()=>f.sessions().find(s=>s.instance==='aide'&&s.live),'owned aide registered');const record=f.record();
|
||
+ const sent=f.send('OUTPUT');await f.reply(sent);f.explicitEnd();await f.launcher.tick();await waitFor(()=>!f.sessions().some(s=>s.run===session.run),'explicit lifecycle end reaches normal session exit');
|
||
+ assert.equal(startTimeOf(session.runnerPid),null,'only recorded owned runner exited before credential removal');const fire=await done(f.fire());assert.equal(fire.ok,true);assert.equal(f.record()===null,true);assert.equal(f.state.keys.includes(record.key),false);assert.equal(f.listed('aide'),false);f.valueFree();
|
||
+});
|
||
diff --git a/packages/bus/tests/model-historical-cleanup.test.mjs b/packages/bus/tests/model-historical-cleanup.test.mjs
|
||
new file mode 100644
|
||
index 000000000..b0290358f
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-historical-cleanup.test.mjs
|
||
@@ -0,0 +1,120 @@
|
||
+// M2/#649: actual Store reopen and createLauncher recovery, no real proxy/config.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { DatabaseSync } from 'node:sqlite';
|
||
+import { createLauncher } from '../../cli/src/launcher.mjs';
|
||
+import { sessionsFile, launchLogFile } from '../../seat/src/session.mjs';
|
||
+import { startTimeOf } from '../../seat/src/proc.mjs';
|
||
+import { reviewFixture } from './model-review-fixtures.mjs';
|
||
+
|
||
+async function historical(t, mode, { ended = false, live = false } = {}) {
|
||
+ const f = await reviewFixture(t);
|
||
+ const original = { ...f.entry };
|
||
+ if (mode === 'legacy') delete f.entry.modelKeyId;
|
||
+ const first = await f.boot({ proxyMode: false });
|
||
+ const record = { business: 'demo', role: 'chief', run: original.run, harness: 'pi', pid: original.pid, startTime: original.startTime };
|
||
+ const launched = first.bindLaunch(record);
|
||
+ first.broker.request(launched.cap, { verb: 'role.claim' });
|
||
+ const db = new DatabaseSync(join(f.dataRoot, 'bus', 'bus.sqlite'), { readOnly: true });
|
||
+ t.after(() => db.close());
|
||
+ const count = (kind, run = original.run) => db.prepare('SELECT count(*) n FROM events WHERE business=? AND kind=? AND subject=?').get('demo', kind, run).n;
|
||
+ const claim = () => db.prepare('SELECT op,holder_run FROM role_claims WHERE business=? AND role=? ORDER BY seq DESC LIMIT 1').get('demo', 'chief');
|
||
+ assert.equal(count('session.launched'), 1); assert.equal(claim().op, 'claim'); assert.equal(claim().holder_run, original.run);
|
||
+ if (ended) first.endLaunch({ business: 'demo', run: original.run, reason: 'host-lost', exitCode: null });
|
||
+ await f.closeBus();
|
||
+ // SQLite and immutable launch/claim history persist through the real runtime Store reopen.
|
||
+ if (!live) await f.exit();
|
||
+ f.writeSessions([f.entry]);
|
||
+ if (mode === 'down') f.state.failGet = true;
|
||
+ if (mode === 'open') f.state.hideSentinel = true;
|
||
+ if (mode === 'revoking' || mode === 'revoked') {
|
||
+ f.setModel({ state: 'revoking', revokingAt: new Date().toISOString() });
|
||
+ if (mode === 'revoking') f.state.failPut = true;
|
||
+ }
|
||
+ const bus = await f.boot();
|
||
+ assert.equal(count('session.launched'), 1);
|
||
+ assert.equal(claim().op, ended ? 'release' : 'claim', 'persisted claim state survives Store reopen');
|
||
+ const logs = [], binds = [], cleanupReplies = [];
|
||
+ const host = {
|
||
+ business: 'demo', beforeClose() {},
|
||
+ bindLaunch: async r => { const result = bus.bindLaunch(r); binds.push(true); return result; },
|
||
+ op: async m => {
|
||
+ if (m.op === 'endLaunch') return bus.endLaunch(m.record);
|
||
+ // Proposed trusted method/caller contract ONLY; old launcher never invokes
|
||
+ // this branch. No product implementation or admission bypass in this fixture.
|
||
+ if (m.op === 'recoverLaunch') { const result = bus.recoverLaunch(m.record); cleanupReplies.push(result); return result; }
|
||
+ if (m.op === 'lifecycleRequests') return bus.lifecycleRequests(m.business);
|
||
+ if (m.op === 'threadsState') return bus.threadsState(m.business);
|
||
+ throw Error('fixture-unrequested-op');
|
||
+ },
|
||
+ };
|
||
+ const recover = async ({ launches = true } = {}) => {
|
||
+ const launcher = createLauncher({ host, system: JSON.parse(fs.readFileSync(f.config, 'utf8')),
|
||
+ env: { MOSAIC_CONFIG: f.config, MOSAIC_ROLES_DIR: f.rolesDir, PATH: '/usr/bin' }, launches, log: line => logs.push(line) });
|
||
+ f.attachLauncher(launcher); await launcher.listen();
|
||
+ };
|
||
+ const endedOnce = () => {
|
||
+ assert.equal(count('session.ended'), 1, 'historical cleanup appends one end despite proxy admission refusal');
|
||
+ assert.equal(claim().op, 'release', 'historical cleanup releases persisted claim');
|
||
+ const body = JSON.parse(db.prepare("SELECT body FROM events WHERE kind='session.ended' AND subject=? ORDER BY seq DESC LIMIT 1").get(original.run).body);
|
||
+ assert.equal(body.reason, 'host-lost'); assert.equal(body.released, true);
|
||
+ const lines = fs.readFileSync(launchLogFile(f.dataRoot, 'demo'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
||
+ assert.equal(lines.filter(line => line.event === 'end' && line.run === original.run && line.reason === 'host-lost').length, 1);
|
||
+ };
|
||
+ const noCapability = () => {
|
||
+ assert.equal(binds.length, 0, 'historical cleanup never calls capability-minting bindLaunch');
|
||
+ assert.ok(cleanupReplies.every(reply => !Object.hasOwn(reply, 'cap')), 'cleanup result contains no capability');
|
||
+ };
|
||
+ return { f, bus, original, record, count, claim, logs, recover, endedOnce, noCapability };
|
||
+}
|
||
+
|
||
+for (const mode of ['down', 'open', 'revoking', 'revoked', 'legacy']) test(`M2 historical cleanup survives ${mode} proxy admission with persisted end and release`, async t => {
|
||
+ const h = await historical(t, mode); await h.recover(); h.endedOnce(); h.noCapability();
|
||
+});
|
||
+test('M2 healthy historical cleanup also mints no capability or model-run binding', async t => {
|
||
+ const h = await historical(t, 'healthy'); await h.recover(); h.endedOnce(); h.noCapability();
|
||
+});
|
||
+test('M2 fresh ordinary launch still requires current proxy admission', async t => {
|
||
+ const h = await historical(t, 'down');
|
||
+ await assert.rejects(async () => h.bus.bindLaunch({ ...h.record, run: 'fresh-m2' }), error => error.code === 'proxy-down');
|
||
+ assert.equal(h.count('session.launched', 'fresh-m2'), 0); assert.equal(h.count('session.ended', 'fresh-m2'), 0);
|
||
+});
|
||
+test('M2 forged historical recovery without immutable launch cannot create or end a run', async t => {
|
||
+ const h = await historical(t, 'healthy'); h.f.writeSessions([{ ...h.f.entry, run: 'forged-m2' }]);
|
||
+ await h.recover();
|
||
+ assert.equal(h.count('session.launched', 'forged-m2'), 0, 'cleanup cannot synthesize a fresh immutable launch');
|
||
+ assert.equal(h.count('session.ended', 'forged-m2'), 0); assert.equal(h.claim().op, 'claim');
|
||
+});
|
||
+for (const [field, value] of [['instance', 'coder'], ['harness', 'claude-code'], ['pid', 'runner'], ['startTime', '0'], ['thread', 'thread-m2'], ['session', 'session-m2']]) {
|
||
+ test(`M2 mismatched historical ${field} never releases or ends the immutable run`, async t => {
|
||
+ const h = await historical(t, 'healthy');
|
||
+ h.f.writeSessions([{ ...h.f.entry, [field]: value === 'runner' ? h.f.entry.runnerPid : value }]);
|
||
+ await h.recover(); assert.equal(h.count('session.ended'), 0); assert.equal(h.claim().op, 'claim');
|
||
+ });
|
||
+}
|
||
+test('M2 already-ended historical cleanup is classified before proxy admission without duplicate release', async t => {
|
||
+ const h = await historical(t, 'down', { ended: true }); await h.recover();
|
||
+ assert.equal(h.count('session.ended'), 1); assert.equal(h.claim().op, 'release'); h.noCapability();
|
||
+ assert.ok(h.logs.some(line => line.includes('already ended')), 'ended history is recognized despite proxy down');
|
||
+});
|
||
+test('M2 no-launch preserves prior live sessions and claim with no cleanup or binding', async t => {
|
||
+ const h = await historical(t, 'down', { live: true }); const before = fs.readFileSync(sessionsFile(h.f.dataRoot), 'utf8');
|
||
+ await h.recover({ launches: false });
|
||
+ assert.equal(h.count('session.ended'), 0); assert.equal(h.claim().op, 'claim'); h.noCapability();
|
||
+ assert.equal(startTimeOf(h.original.pid), h.original.startTime); assert.equal(startTimeOf(h.original.runnerPid), h.original.runnerStartTime);
|
||
+ assert.equal(fs.readFileSync(sessionsFile(h.f.dataRoot), 'utf8'), before);
|
||
+});
|
||
+
|
||
+test('M2 persisted claim blocks a new claim until actual historical cleanup releases it', async t => {
|
||
+ const h = await historical(t, 'healthy');
|
||
+ const model = h.bus.credentials.status().find(row => row.instance === 'demo/chief' && row.service === 'model');
|
||
+ const fresh = h.bus.bindLaunch({ ...h.record, run: 'claim-m2', modelKeyId: model.keyId });
|
||
+ assert.throws(() => h.bus.broker.request(fresh.cap, { verb: 'role.claim' }), error => error.code === 'role-already-held');
|
||
+ await h.recover();
|
||
+ assert.equal(h.claim().op, 'release');
|
||
+ h.bus.broker.request(fresh.cap, { verb: 'role.claim' });
|
||
+ assert.equal(h.claim().holder_run, 'claim-m2');
|
||
+ h.bus.endLaunch({ business: 'demo', run: 'claim-m2', reason: 'stopped', exitCode: null });
|
||
+});
|
||
diff --git a/packages/bus/tests/model-historical-ipc.test.mjs b/packages/bus/tests/model-historical-ipc.test.mjs
|
||
new file mode 100644
|
||
index 000000000..95bbf968c
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-historical-ipc.test.mjs
|
||
@@ -0,0 +1,110 @@
|
||
+// M2/#649 actual trusted host -> forked bus/process.mjs -> runtime -> Broker route.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { DatabaseSync } from 'node:sqlite';
|
||
+import { createHash } from 'node:crypto';
|
||
+import { startHost } from '../../cli/src/host.mjs';
|
||
+import { createLauncher } from '../../cli/src/launcher.mjs';
|
||
+import { loadBusiness } from '../../business/src/business.mjs';
|
||
+import { resolveInstance } from '../../business/src/resolve.mjs';
|
||
+import { busBusiness } from '../src/business.mjs';
|
||
+import { sessionsFile, launchLogFile } from '../../seat/src/session.mjs';
|
||
+import { reviewFixture } from './model-review-fixtures.mjs';
|
||
+const id = key => createHash('sha256').update(key).digest('hex').slice(0, 12);
|
||
+
|
||
+async function ipcFixture(t, { down = true, ended = false, live = false } = {}) {
|
||
+ let host, db;
|
||
+ const owned = [];
|
||
+ t.after(async () => {
|
||
+ const errors = [];
|
||
+ for (const close of [() => host?.close(), () => db?.close(), ...owned]) {
|
||
+ try { await close(); } catch (error) { errors.push(error); }
|
||
+ }
|
||
+ if (errors.length) throw new AggregateError(errors, 'owned IPC fixture cleanup failed');
|
||
+ });
|
||
+ // Capture helper cleanup so real fork exits BEFORE the fake proxy/root disappear.
|
||
+ const f = await reviewFixture({ after: close => owned.push(close) });
|
||
+ const first = await f.boot();
|
||
+ const proxy = await first.modelPreflight({ business: 'demo', role: 'chief' });
|
||
+ const record = { business: 'demo', role: 'chief', run: f.entry.run, harness: 'pi', pid: f.entry.pid, startTime: f.entry.startTime };
|
||
+ const launch = first.bindLaunch({ ...record, modelKeyId: f.entry.modelKeyId });
|
||
+ first.broker.request(launch.cap, { verb: 'role.claim' });
|
||
+ if (ended) first.endLaunch({ business: 'demo', run: record.run, reason: 'host-lost', exitCode: null });
|
||
+ await f.closeBus(); if (!live) await f.exit(); f.writeSessions([f.entry]);
|
||
+ db = new DatabaseSync(join(f.dataRoot, 'bus', 'bus.sqlite'), { readOnly: true });
|
||
+ const count = (kind, run = record.run) => db.prepare('SELECT count(*) n FROM events WHERE business=? AND kind=? AND subject=?').get('demo', kind, run).n;
|
||
+ const claim = () => db.prepare('SELECT op,holder_run FROM role_claims WHERE business=? AND role=? ORDER BY seq DESC LIMIT 1').get('demo', 'chief');
|
||
+ assert.equal(count('session.launched'), 1); assert.equal(claim().op, ended ? 'release' : 'claim');
|
||
+ f.state.failGet = down;
|
||
+ const business = loadBusiness('demo', { dir: f.configDir, rolesDir: f.rolesDir }), resolved = {};
|
||
+ for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system: f.system, business, instance });
|
||
+ const logs = [];
|
||
+ host = await startHost({ business: 'demo', log: line => logs.push(line), boot: {
|
||
+ dataRoot: f.dataRoot, readers: ['demo'], businesses: { demo: busBusiness(business, resolved) },
|
||
+ organization: { dir: f.configDir, rolesDir: f.rolesDir, system: f.system },
|
||
+ proxy: { port: Number(new URL(proxy.baseUrl).port), binary: '/fixture-unused' },
|
||
+ } });
|
||
+ assert.equal(Number.isSafeInteger(host.pids.broker), true); assert.notEqual(host.pids.broker, process.pid, 'actual broker is a forked process');
|
||
+ const cleanup = input => host.op({ op: 'recoverLaunch', record: input });
|
||
+ const channelSurvives = async () => assert.equal(Array.isArray(await host.op({ op: 'credentialStatus', business: 'demo', role: 'chief' })), true);
|
||
+ return { f, host, record, logs, cleanup, channelSurvives, count, claim,
|
||
+ actualLauncher: async (launches = true) => {
|
||
+ const launcher = createLauncher({ host, launches, system: JSON.parse(fs.readFileSync(f.config, 'utf8')),
|
||
+ env: { MOSAIC_CONFIG: f.config, MOSAIC_ROLES_DIR: f.rolesDir, PATH: '/usr/bin' }, log: line => logs.push(line) });
|
||
+ await launcher.listen(); return launcher; // real host beforeClose owns its cleanup
|
||
+ } };
|
||
+}
|
||
+
|
||
+test('M2 IPC exact historical cleanup releases persisted claim with no cap or model-stall binding', async t => {
|
||
+ const h = await ipcFixture(t); const beforePut = h.f.state.put;
|
||
+ let reply;
|
||
+ await assert.doesNotReject(async () => { reply = await h.cleanup(h.record); }, 'actual trusted historical IPC cleanup must dispatch');
|
||
+ assert.deepEqual(Object.keys(reply), ['released'], 'historical IPC cleanup returns end metadata only');
|
||
+ assert.equal(reply.released, true); assert.equal(h.claim().op, 'release'); assert.equal(h.count('session.ended'), 1);
|
||
+ assert.equal(h.f.state.put, beforePut, 'cleanup does not mutate proxy keys');
|
||
+ await assert.rejects(h.host.op({ op: 'modelStall', business: 'demo', role: 'chief', run: h.record.run, keyId: h.f.entry.modelKeyId }), error => error.code === 'proxy-stop-failed');
|
||
+ await h.channelSurvives();
|
||
+});
|
||
+test('M2 IPC actual launcher recovery dispatches cleanup despite proxy-down admission', async t => {
|
||
+ const h = await ipcFixture(t); await h.actualLauncher();
|
||
+ assert.equal(h.count('session.ended'), 1, 'real fork/launcher recovery appends historical end');
|
||
+ assert.equal(h.claim().op, 'release');
|
||
+ const lines = fs.readFileSync(launchLogFile(h.f.dataRoot, 'demo'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
||
+ assert.equal(lines.filter(line => line.event === 'end' && line.run === h.record.run).length, 1);
|
||
+ await h.channelSurvives();
|
||
+});
|
||
+for (const [label, patch, code] of [
|
||
+ ['fresh', { run: 'ipc-fresh' }, 'unknown-run'],
|
||
+ ['forged role', { role: 'coder' }, 'launch-record-mismatch'],
|
||
+ ['mismatched harness', { harness: 'claude-code' }, 'launch-record-mismatch'],
|
||
+ ['mismatched start', { startTime: '0' }, 'launch-record-mismatch'],
|
||
+]) test(`M2 IPC ${label} cannot use historical cleanup and leaves channel usable`, async t => {
|
||
+ const h = await ipcFixture(t);
|
||
+ await assert.rejects(h.cleanup({ ...h.record, ...patch }), error => error.code === code, `trusted historical IPC ${label} refusal uses ${code}`);
|
||
+ assert.equal(h.count('session.ended'), 0); assert.equal(h.claim().op, 'claim');
|
||
+ if (patch.run) assert.equal(h.count('session.launched', patch.run), 0);
|
||
+ await h.channelSurvives();
|
||
+});
|
||
+test('M2 IPC already-ended history refuses without duplicate release and preserves channel', async t => {
|
||
+ const h = await ipcFixture(t, { ended: true });
|
||
+ await assert.rejects(h.cleanup(h.record), error => error.code === 'run-ended', 'trusted historical IPC ended refusal uses run-ended');
|
||
+ assert.equal(h.count('session.ended'), 1); assert.equal(h.claim().op, 'release'); await h.channelSurvives();
|
||
+});
|
||
+test('M2 IPC ordinary fresh bind remains proxy-admitted and capable only through normal bind', async t => {
|
||
+ const h = await ipcFixture(t, { down: false });
|
||
+ const proxy = await h.host.op({ op: 'modelPreflight', business: 'demo', role: 'chief' });
|
||
+ const launch = await h.host.bindLaunch({ ...h.record, run: 'ipc-normal', modelKeyId: id(proxy.key) });
|
||
+ assert.equal(typeof launch.cap, 'string');
|
||
+ assert.equal(h.count('session.launched', 'ipc-normal'), 1);
|
||
+ await h.host.op({ op: 'endLaunch', record: { business: 'demo', run: 'ipc-normal', reason: 'stopped', exitCode: null } });
|
||
+ await h.channelSurvives();
|
||
+});
|
||
+test('M2 IPC ordinary fresh bind still refuses proxy-down and no-launch recovery remains inert', async t => {
|
||
+ const h = await ipcFixture(t, { live: true }); const before = fs.readFileSync(sessionsFile(h.f.dataRoot), 'utf8');
|
||
+ await assert.rejects(h.host.bindLaunch({ ...h.record, run: 'ipc-denied', modelKeyId: h.f.entry.modelKeyId }), error => error.code === 'proxy-down');
|
||
+ await h.actualLauncher(false);
|
||
+ assert.equal(h.count('session.ended'), 0); assert.equal(h.claim().op, 'claim');
|
||
+ assert.equal(fs.readFileSync(sessionsFile(h.f.dataRoot), 'utf8'), before); await h.channelSurvives();
|
||
+});
|
||
diff --git a/packages/bus/tests/model-key-loss-interaction.test.mjs b/packages/bus/tests/model-key-loss-interaction.test.mjs
|
||
new file mode 100644
|
||
index 000000000..4bee0ffef
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-key-loss-interaction.test.mjs
|
||
@@ -0,0 +1,67 @@
|
||
+// #652 diagnostic PREPARATION ONLY. Candidate6d3, not an observed regression.
|
||
+// Real runtime/private records/fakeHTTP/owned OS children/original completed sweeps.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { reviewFixture } from './model-review-fixtures.mjs';
|
||
+import { Credentials } from '../src/credentials.mjs';
|
||
+import { ManagementClient } from '../../model-proxy/src/client.mjs';
|
||
+import { startTimeOf } from '../../seat/src/proc.mjs';
|
||
+const agent='demo/chief';
|
||
+const absent=pid=>assert.throws(()=>fs.lstatSync(`/proc/${pid}`),error=>error.code==='ENOENT','own scratch process reaped; OS exit alone is not acquired runtime proof');
|
||
+async function setup(t,{noRun=false,corrupt=null}={}){
|
||
+ const f=await reviewFixture(t,{overdue:true});
|
||
+ const privateView=new Credentials({storeRoot:join(f.configDir,'secrets','agents'),dataRoot:f.dataRoot,modelAgents:[agent]});let initial;
|
||
+ try{initial=privateView.modelRecord(agent);}finally{privateView.close();}
|
||
+ // Existing seed has unrelated retiring key1 and current key2. Associate own live
|
||
+ // run with CURRENT key2; retiring key1 forces the first real authoritative view.
|
||
+ f.entry.modelKeyId=initial.keyId;
|
||
+ if(noRun){await f.exit();absent(f.entry.pid);absent(f.entry.runnerPid);f.writeSessions([]);}
|
||
+ else f.writeSessions([{...f.entry,...(corrupt?{[corrupt]:corrupt==='modelKeyId'?'fedcba987654':'0'}:{})}]);
|
||
+ const bus=await f.boot();const proxy=await bus.modelPreflight({business:'demo',role:'chief'});
|
||
+ const first=await f.observeNextSweep(),manager=f.sweptManager;
|
||
+ assert.equal(typeof manager?.ensure,'function','real runtime controller observed through unchanged original sweep');
|
||
+ if(!corrupt){assert.equal(first.outcome,'fulfilled');assert.equal(bus.credentials.modelRecord(agent).retiring.length,0,'unrelated retiring generation completed, current identity actually checked');}
|
||
+ else assert.equal(first.outcome,'proxy-stop-failed','initial corrupt identity refuses before proof');
|
||
+ const client=new ManagementClient({baseUrl:proxy.baseUrl,key:'fx647-manage'});
|
||
+ const loseCurrent=async verb=>{
|
||
+ const old=bus.credentials.modelRecord(agent),before=await client.getKeys();assert.equal(before.includes(old.key),true,'setup current remotely present before deliberate loss');
|
||
+ await client.putKeys(before.filter(key=>key!==old.key));const confirmed=await client.getKeys();assert.equal(confirmed.includes(old.key),false,'real HTTP confirms remote absence');assert.equal(await client.probeKey(old.key),401);
|
||
+ const answer=await manager[verb](agent),after=bus.credentials.modelRecord(agent);
|
||
+ assert.equal(answer.key===old.key,false,'accepted absent-current contract issued fresh generation');assert.equal(after.retiring.some(r=>r.keyId===old.keyId),false,'absent old current not appended');assert.equal(f.state.keys.includes(old.key),false,'old current never restored');assert.equal(f.state.keys.includes(answer.key),true);return after;
|
||
+ };
|
||
+ const laterRetirement=async()=>{
|
||
+ const retiring=bus.credentials.modelRecord(agent);await manager.rotate(agent);const record=bus.credentials.modelRecord(agent);
|
||
+ assert.equal(record.retiring.some(r=>r.keyId===retiring.keyId),true,'later actual rotation appends confirmed current');
|
||
+ bus.credentials.writeModel(agent,{...record,retiring:record.retiring.map(r=>({...r,retireAt:new Date(Date.now()-60_000).toISOString()}))});
|
||
+ return {key:retiring.key,keyId:retiring.keyId};
|
||
+ };
|
||
+ const retained=later=>{assert.equal(f.state.keys.includes(later.key),true,'later generation remains remote');assert.equal(bus.credentials.modelRecord(agent).retiring.some(r=>r.keyId===later.keyId),true,'later retiring metadata retained');};
|
||
+ const retired=later=>{assert.equal(f.state.keys.includes(later.key),false,'later generation retires after exact own outer and runner OS exits');assert.equal(bus.credentials.modelRecord(agent).retiring.some(r=>r.keyId===later.keyId),false);};
|
||
+ // Establish terminal proof only through an associated retiring key and separately
|
||
+ // observed ORIGINAL sweeps, never infer it merely from ChildProcess close.
|
||
+ const acquireProof=async({outerOnly=false}={})=>{
|
||
+ const current=bus.credentials.modelRecord(agent);await manager.rotate(agent);
|
||
+ await f.exitOuter();absent(f.entry.pid);let seen=await f.observeNextSweep();assert.equal(seen.outcome,'fulfilled');assert.equal(f.state.keys.includes(current.key),true,'live runner retains associated key before grace');
|
||
+ assert.equal(startTimeOf(f.entry.runnerPid)===f.entry.runnerStartTime,true,'runner counterpart still exact/live');
|
||
+ if(outerOnly)return;
|
||
+ await f.exitRunner();absent(f.entry.runnerPid);seen=await f.observeNextSweep();assert.equal(seen.outcome,'fulfilled');assert.equal(f.state.keys.includes(current.key),false,'ordinary sweep removed associated key after both strict exits');
|
||
+ };
|
||
+ return {...f,bus,manager,initial,loseCurrent,laterRetirement,retained,retired,acquireProof};
|
||
+}
|
||
+for(const verb of ['ensure','rotate'])for(const registry of ['stale','empty'])test(`652 ${verb} absent current then ${registry} registry distinguishes OS exits from acquired proof`,async t=>{
|
||
+ const f=await setup(t);assert.equal(startTimeOf(f.entry.pid)===f.entry.startTime,true);assert.equal(startTimeOf(f.entry.runnerPid)===f.entry.runnerStartTime,true);
|
||
+ await f.loseCurrent(verb);const later=await f.laterRetirement();f.writeSessions(registry==='empty'?[]:[f.entry]);
|
||
+ let seen=await f.observeNextSweep();assert.equal(seen.outcome,'proxy-stop-failed');f.retained(later);
|
||
+ await f.exitOuter();absent(f.entry.pid);seen=await f.observeNextSweep();assert.equal(seen.outcome,'proxy-stop-failed');f.retained(later);assert.equal(startTimeOf(f.entry.runnerPid)===f.entry.runnerStartTime,true,'unbound live runner never signalled');
|
||
+ await f.exitRunner();absent(f.entry.runnerPid);seen=await f.observeNextSweep();
|
||
+ // Desired liveness assertion is a DIAGNOSTIC expectation only until measured and
|
||
+ // owner adjudicated. Predicted RED here is not claimed as an accepted blocker.
|
||
+ f.retired(later);assert.equal(seen.outcome,'fulfilled');
|
||
+});
|
||
+for(const verb of ['ensure','rotate'])test(`652 ${verb} absent current with valid no-run inventory retires normally`,async t=>{const f=await setup(t,{noRun:true});await f.loseCurrent(verb);const later=await f.laterRetirement();const seen=await f.observeNextSweep();assert.equal(seen.outcome,'fulfilled');f.retired(later);});
|
||
+for(const verb of ['ensure','rotate'])test(`652 ${verb} previously acquired pair proof survives later absent-current repair`,async t=>{const f=await setup(t);await f.acquireProof();await f.loseCurrent(verb);const later=await f.laterRetirement();const seen=await f.observeNextSweep();assert.equal(seen.outcome,'fulfilled');f.retired(later);});
|
||
+test('652 outer proof alone never authorizes stop of live unbound runner after key loss',async t=>{const f=await setup(t);await f.acquireProof({outerOnly:true});await f.loseCurrent('ensure');const later=await f.laterRetirement();const seen=await f.observeNextSweep();assert.equal(seen.outcome,'proxy-stop-failed');f.retained(later);assert.equal(startTimeOf(f.entry.runnerPid)===f.entry.runnerStartTime,true,'live counterpart retained and never signalled');});
|
||
+for(const field of ['modelKeyId','startTime','runnerStartTime'])test(`652 initial corrupt ${field} stays refused after actual OS exits`,async t=>{const f=await setup(t,{corrupt:field});await f.loseCurrent('ensure');const later=await f.laterRetirement();await f.exit();absent(f.entry.pid);absent(f.entry.runnerPid);const seen=await f.observeNextSweep();assert.equal(seen.outcome,'proxy-stop-failed');f.retained(later);});
|
||
+test('652 prior pair proof never permits a changed immutable persisted identity',async t=>{const f=await setup(t);await f.acquireProof();await f.loseCurrent('rotate');const later=await f.laterRetirement();f.writeSessions([{...f.entry,modelKeyId:'fedcba987654'}]);const seen=await f.observeNextSweep();assert.equal(seen.outcome,'proxy-stop-failed');f.retained(later);});
|
||
diff --git a/packages/bus/tests/model-key-provenance.test.mjs b/packages/bus/tests/model-key-provenance.test.mjs
|
||
new file mode 100644
|
||
index 000000000..420bdddaf
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-key-provenance.test.mjs
|
||
@@ -0,0 +1,65 @@
|
||
+// #652 STATIC preparation: no execution yet. Real runtime, HTTP and original sweeps.
|
||
+// Interceptions affect ONLY recorded scratch-owned /proc identities; no synthetic signals.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { syncBuiltinESMExports } from 'node:module';
|
||
+import { spawn } from 'node:child_process';
|
||
+import { once } from 'node:events';
|
||
+import { join } from 'node:path';
|
||
+import { reviewFixture } from './model-review-fixtures.mjs';
|
||
+import { Credentials } from '../src/credentials.mjs';
|
||
+import { ManagementClient } from '../../model-proxy/src/client.mjs';
|
||
+import { startTimeOf } from '../../seat/src/proc.mjs';
|
||
+const agent='demo/chief';
|
||
+function procFault(t,pid,kind){
|
||
+ const original=fs.readFileSync;let active=true,reads=0;
|
||
+ fs.readFileSync=function(file,...args){
|
||
+ if(active&&file===`/proc/${pid}/stat`){reads++;if(kind==='read'){const error=Error('owned proc read refused');error.code='EACCES';throw error;}
|
||
+ const raw=original.call(this,file,'utf8'),end=raw.lastIndexOf(')')+2,fields=raw.slice(end).trim().split(/\s+/);fields[19]=String(BigInt(fields[19])+1n);const changed=raw.slice(0,end)+fields.join(' ')+'\n';return args[0]==='utf8'?changed:Buffer.from(changed);
|
||
+ }
|
||
+ return original.call(this,file,...args);
|
||
+ };syncBuiltinESMExports();
|
||
+ const restore=()=>{active=false;fs.readFileSync=original;syncBuiltinESMExports();};t.after(restore);
|
||
+ return {restore,reads:()=>reads};
|
||
+}
|
||
+async function ownPair(t){
|
||
+ const children=[];t.after(async()=>{const failures=[];for(const {child,closed} of children){try{if(child.exitCode===null&&child.signalCode===null)child.kill('SIGTERM');await closed;}catch{failures.push(Error('owned extra child cleanup'));}}if(failures.length)throw new AggregateError(failures,'owned pair cleanup failed');});
|
||
+ for(let i=0;i<2;i++){const child=spawn('/usr/bin/sleep',['120'],{stdio:'ignore'}),closed=new Promise(resolve=>child.once('close',resolve));children.push({child,closed});await once(child,'spawn');}
|
||
+ return {pid:children[0].child.pid,startTime:startTimeOf(children[0].child.pid),runnerPid:children[1].child.pid,runnerStartTime:startTimeOf(children[1].child.pid),exit:async()=>{for(const {child,closed} of children){if(child.exitCode===null&&child.signalCode===null)child.kill('SIGTERM');await closed;}}};
|
||
+}
|
||
+const absent=pid=>assert.throws(()=>fs.lstatSync(`/proc/${pid}`),e=>e.code==='ENOENT','own process actually reaped');
|
||
+async function setup(t,{failure=null}={}){
|
||
+ const f=await reviewFixture(t,{overdue:true}),own=new Credentials({storeRoot:join(f.configDir,'secrets','agents'),dataRoot:f.dataRoot,modelAgents:[agent]});let record;try{record=own.modelRecord(agent);}finally{own.close();}
|
||
+ f.entry.modelKeyId=record.keyId;let entry={...f.entry},extra,fault,binding;
|
||
+ if(failure==='runner ticks')entry.runnerStartTime='0';
|
||
+ if(failure==='later entry')extra=await ownPair(t);
|
||
+ f.writeSessions([entry,...(extra?[{...entry,...extra,exit:undefined,run:'proof-second',modelKeyId:record.retiring[0].keyId}]:[])]);
|
||
+ if(failure==='runner read')fault=procFault(t,entry.runnerPid,'read');
|
||
+ if(extra)fault=procFault(t,extra.runnerPid,'read');
|
||
+ const bus=await f.boot();const proxy=await bus.modelPreflight({business:'demo',role:'chief'});
|
||
+ if(failure==='unpublished bound'||failure==='bound mismatch'){
|
||
+ binding={business:'demo',role:'chief',run:failure==='unpublished bound'?'proof-unpublished':entry.run,harness:'pi',pid:entry.runnerPid,startTime:entry.runnerStartTime,modelKeyId:record.keyId};bus.bindLaunch(binding);
|
||
+ }
|
||
+ const first=await f.observeNextSweep(),manager=f.sweptManager;
|
||
+ assert.equal(first.outcome,failure?'proxy-stop-failed':'fulfilled','first complete checked view outcome');
|
||
+ if(!failure)assert.equal(bus.credentials.modelRecord(agent).retiring.length,0,'associated live view actually completed');
|
||
+ const client=new ManagementClient({baseUrl:proxy.baseUrl,key:'fx647-manage'});
|
||
+ const lose=async()=>{
|
||
+ const old=bus.credentials.modelRecord(agent),before=await client.getKeys();assert.equal(before.includes(old.key),true);await client.putKeys(before.filter(k=>k!==old.key));assert.equal((await client.getKeys()).includes(old.key),false);assert.equal(await client.probeKey(old.key),401);
|
||
+ await manager.ensure(agent);const fresh=bus.credentials.modelRecord(agent);assert.notEqual(fresh.keyId,old.keyId);assert.equal(fresh.retiring.some(r=>r.keyId===old.keyId),false);assert.equal(f.state.keys.includes(old.key),false);
|
||
+ // Keep failure armed until association is gone; no accidental successful view.
|
||
+ if(binding)bus.endLaunch({business:'demo',run:binding.run,reason:'host-lost',exitCode:null});
|
||
+ await manager.rotate(agent);const current=bus.credentials.modelRecord(agent);bus.credentials.writeModel(agent,{...current,retiring:current.retiring.map(r=>({...r,retireAt:new Date(Date.now()-60000).toISOString()}))});return {key:fresh.key,keyId:fresh.keyId};
|
||
+ };
|
||
+ const retained=later=>{assert.equal(f.state.keys.includes(later.key),true,'later remote generation retained');assert.equal(bus.credentials.modelRecord(agent).retiring.some(r=>r.keyId===later.keyId),true,'later local retiring record retained');};
|
||
+ const exit=async()=>{await f.exit();await extra?.exit();absent(entry.pid);absent(entry.runnerPid);if(extra){absent(extra.pid);absent(extra.runnerPid);}};
|
||
+ return {...f,bus,entry,lose,retained,exit,fault};
|
||
+}
|
||
+for(const failure of ['runner ticks','runner read','later entry','unpublished bound','bound mismatch'])test(`652 provenance not committed after failed whole view: ${failure}`,async t=>{
|
||
+ const f=await setup(t,{failure}),later=await f.lose();await f.exit();f.fault?.restore();const seen=await f.observeNextSweep();assert.equal(seen.outcome,'proxy-stop-failed','failed first view cannot grant association provenance after key loss');f.retained(later);
|
||
+});
|
||
+for(const kind of ['read','ticks'])test(`652 prior validated identity still strictly checks ${kind} after association loss`,async t=>{
|
||
+ const f=await setup(t),later=await f.lose();f.writeSessions([]);const fault=procFault(t,f.entry.runnerPid,kind);const seen=await f.observeNextSweep();assert.equal(seen.outcome,'proxy-stop-failed');f.retained(later);
|
||
+ assert.ok(fault.reads()>0,'prior validated identity is rechecked strictly after association loss');fault.restore();assert.equal(startTimeOf(f.entry.runnerPid),f.entry.runnerStartTime,'live counterpart remains exact and unsignalled');await f.exit();const done=await f.observeNextSweep();assert.equal(done.outcome,'fulfilled');assert.equal(f.state.keys.includes(later.key),false,'strict exits after restored reads permit later retirement');
|
||
+});
|
||
diff --git a/packages/bus/tests/model-keys.test.mjs b/packages/bus/tests/model-keys.test.mjs
|
||
new file mode 100644
|
||
index 000000000..52605a22d
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-keys.test.mjs
|
||
@@ -0,0 +1,272 @@
|
||
+// P2: real private Credentials, fake loopback management/model server. No live config.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import http from 'node:http';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { createHash, randomBytes } from 'node:crypto';
|
||
+import { Credentials } from '../src/credentials.mjs';
|
||
+import { ManagementClient } from '../../model-proxy/src/client.mjs';
|
||
+import { ModelKeys, REVOKE_TIMEOUT_MS, REVOKE_POLL_MS } from '../src/model-keys.mjs';
|
||
+import { startBroker } from '../src/runtime.mjs';
|
||
+import { busBusiness } from '../src/business.mjs';
|
||
+import { Registry } from '../../seat/src/session.mjs';
|
||
+import { loadBusiness } from '../../business/src/business.mjs';
|
||
+import { resolveInstance } from '../../business/src/resolve.mjs';
|
||
+import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||
+const hash = s => createHash('sha256').update(s).digest('hex').slice(0, 12);
|
||
+const failCode = code => e => e.code === code;
|
||
+async function fixture(t, { delayed = 0 } = {}) {
|
||
+ const root = fs.mkdtempSync(join(tmpdir(), 'p2-keys-')), configDir = join(root, 'config');
|
||
+ const storeRoot = join(configDir, 'secrets', 'agents'); fs.mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||
+ const prefix = `msk-${hash(configDir)}-`, makeKey = () => prefix + randomBytes(32).toString('base64url');
|
||
+ const sentinel = prefix + 'sentinel-' + randomBytes(32).toString('base64url');
|
||
+ const state = { keys: [sentinel], calls: [], delay: delayed, removed: new Map(), failGet: false, failPut: false, hideConfirm: false, probes: 0, status: null };
|
||
+ const server = http.createServer(async (req, res) => {
|
||
+ const management = req.url === '/v8/management/config/access/api-keys';
|
||
+ state.calls.push(management ? req.method : 'PROBE');
|
||
+ if (management) {
|
||
+ if (req.headers.authorization !== 'Bearer fx331-manage') { res.writeHead(401); res.end('{}'); return; }
|
||
+ if ((req.method === 'GET' && state.failGet) || (req.method === 'PUT' && state.failPut)) { res.writeHead(503); res.end('{}'); return; }
|
||
+ if (req.method === 'GET') { res.end(JSON.stringify(state.hideConfirm ? [sentinel] : state.keys)); return; }
|
||
+ const chunks = []; for await (const c of req) chunks.push(c);
|
||
+ const next = JSON.parse(Buffer.concat(chunks));
|
||
+ for (const k of state.keys.filter(k => !next.includes(k))) state.removed.set(k, performance.now());
|
||
+ state.keys = next; res.end('{"status":"ok"}'); return;
|
||
+ }
|
||
+ state.probes++;
|
||
+ const key = req.headers.authorization?.slice(7);
|
||
+ const accepted = state.keys.includes(key) || (state.removed.has(key) && performance.now() - state.removed.get(key) < state.delay);
|
||
+ state.onProbe?.(state.status ?? (accepted ? 200 : 401));
|
||
+ res.writeHead(state.status ?? (accepted ? 200 : 401)); res.end('{}');
|
||
+ });
|
||
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||
+ const baseUrl = `http://127.0.0.1:${server.address().port}`, receipts = [], stopped = [], runs = [];
|
||
+ let now = Date.parse('2026-10-24T00:00:00.000Z');
|
||
+ const credentials = new Credentials({ storeRoot, dataRoot: join(root, 'data'), modelAgents: ['demo/coder', 'demo/other'] });
|
||
+ const client = new ManagementClient({ baseUrl, key: 'fx331-manage', receipt: r => receipts.push(r) });
|
||
+ const create = () => new ModelKeys({ credentials, client, configDir, baseUrl, receipt: r => receipts.push(r), now: () => now,
|
||
+ runs: instance => runs.filter(r => r.instance === instance), stopRuns: async rs => { stopped.push(...rs.map(r => r.run)); for (const r of rs) runs.splice(runs.indexOf(r), 1); } });
|
||
+ const manager = create();
|
||
+ const closers = [];
|
||
+ t.after(async () => { for (const close of closers) await close(); credentials.close(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); fs.rmSync(root, { recursive: true, force: true }); });
|
||
+ return { root, configDir, credentials, client, server, closers, state, manager, create, makeKey, sentinel, receipts, baseUrl, stopped, runs, advance: ms => { now += ms; } };
|
||
+}
|
||
+test('issue confirms GET/PUT/GET before private file and returns P3 shape with only own key', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); f.state.calls.length = 0;
|
||
+ const answer = await f.manager.ensure('demo/coder');
|
||
+ assert.deepEqual(Object.keys(answer).sort(), ['baseUrl', 'key']); assert.equal(answer.baseUrl, f.baseUrl);
|
||
+ assert.deepEqual(f.state.calls, ['GET', 'PUT', 'GET']);
|
||
+ assert.equal(f.state.keys.includes(answer.key), true);
|
||
+ assert.equal(f.credentials.status().find(s => s.instance === 'demo/coder').date, '2026-11-23');
|
||
+ assert.equal(JSON.stringify(f.receipts).includes(answer.key), false);
|
||
+ const other = await f.manager.ensure('demo/other'); assert.equal(other.key === answer.key, false);
|
||
+});
|
||
+for (const fault of ['failGet', 'failPut', 'hideConfirm']) test(`proxy-key-issue-failed: ${fault} writes no model record`, async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); f.state[fault] = true;
|
||
+ await assert.rejects(f.manager.ensure('demo/coder'), failCode('proxy-key-issue-failed'));
|
||
+ assert.equal(f.credentials.modelRecord('demo/coder') === null, true);
|
||
+ assert.equal(fs.existsSync(join(f.configDir, 'secrets', 'agents', 'demo', 'coder', 'model')), false);
|
||
+});
|
||
+test('credential-revoke-failed: missing confirming GET keeps model file and revoking status', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); await f.manager.ensure('demo/coder');
|
||
+ f.state.failGet = true;
|
||
+ await assert.rejects(f.manager.prepareRevoke('demo/coder'), failCode('credential-revoke-failed'));
|
||
+ assert.equal(f.credentials.status().find(s => s.instance === 'demo/coder').state, 'revoking');
|
||
+});
|
||
+test('revoke confirms absence then polls delayed client refusal before local removal', async t => {
|
||
+ const f = await fixture(t, { delayed: 105 }); await f.manager.reconcile(); const { key } = await f.manager.ensure('demo/coder');
|
||
+ f.state.calls.length = 0; const before = performance.now();
|
||
+ await f.manager.prepareRevoke('demo/coder');
|
||
+ assert.ok(performance.now() - before >= 100); assert.deepEqual(f.state.calls.slice(0, 3), ['GET', 'PUT', 'GET']);
|
||
+ assert.ok(f.state.probes >= 2); assert.equal(f.state.keys.includes(key), false);
|
||
+ assert.equal(f.credentials.modelRecord('demo/coder')?.state, 'revoking');
|
||
+ f.manager.completeRevoke('demo/coder'); assert.equal(f.credentials.modelRecord('demo/coder') === null, true);
|
||
+ assert.equal(JSON.stringify(f.receipts).includes(key), false);
|
||
+});
|
||
+test('reconcile restores missing recorded keys, removes own unknown, and preserves foreign and sentinel', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); const { key } = await f.manager.ensure('demo/coder');
|
||
+ const unknown = f.makeKey(), foreign = 'fx331-human'; f.state.keys = [f.sentinel, unknown, foreign]; f.state.calls.length = 0;
|
||
+ await f.create().reconcile();
|
||
+ assert.equal(f.state.keys.includes(key), true); assert.equal(f.state.keys.includes(unknown), false);
|
||
+ assert.equal(f.state.keys.includes(foreign), true); assert.equal(f.state.keys.includes(f.sentinel), true);
|
||
+ assert.deepEqual(f.state.calls.slice(0, 3), ['GET', 'PUT', 'GET']);
|
||
+});
|
||
+test('reconcile revoking never restores key and completes delayed revocation after restart', async t => {
|
||
+ const f = await fixture(t, { delayed: 105 }); await f.manager.reconcile(); const { key } = await f.manager.ensure('demo/coder');
|
||
+ const record = f.credentials.modelRecord('demo/coder');
|
||
+ f.credentials.writeModel('demo/coder', { ...record, state: 'revoking', revokingAt: '2026-10-24T00:00:00.000Z' });
|
||
+ await f.create().reconcile();
|
||
+ assert.equal(f.state.keys.includes(key), false); assert.equal(f.credentials.modelRecord('demo/coder') === null, true);
|
||
+});
|
||
+test('proxy-open: no own sentinel blocks reconcile and every launch', async t => {
|
||
+ const f = await fixture(t); f.state.keys = ['fx331-human'];
|
||
+ await assert.rejects(f.manager.reconcile(), failCode('proxy-open'));
|
||
+ await assert.rejects(f.manager.preflight('demo/coder'), failCode('proxy-open'));
|
||
+ assert.equal(f.state.probes, 0);
|
||
+});
|
||
+test('proxy-key-revoking: launch hold occurs before any preflight call', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); await f.manager.ensure('demo/coder');
|
||
+ const record = f.credentials.modelRecord('demo/coder'); f.credentials.writeModel('demo/coder', { ...record, state: 'revoking', revokingAt: '2026-10-24T00:00:00.000Z' });
|
||
+ await assert.rejects(f.manager.preflight('demo/coder'), failCode('proxy-key-revoking')); assert.equal(f.state.probes, 0);
|
||
+});
|
||
+test('proxy-revoke-revision-conflict keeps local revoking record and ids-only remote-revoked receipt', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); const { key } = await f.manager.ensure('demo/coder'); await f.manager.prepareRevoke('demo/coder');
|
||
+ assert.throws(() => f.manager.revisionConflict('demo/coder'), failCode('proxy-revoke-revision-conflict'));
|
||
+ assert.equal(f.credentials.modelRecord('demo/coder').state, 'revoking');
|
||
+ assert.equal(f.state.keys.includes(key), false);
|
||
+ assert.ok(f.receipts.some(r => r.remoteState === 'revoked' && r.localState === 'kept'));
|
||
+ assert.equal(JSON.stringify(f.receipts).includes(key), false);
|
||
+ await f.create().reconcile(); assert.equal(f.credentials.modelRecord('demo/coder') === null, true);
|
||
+});
|
||
+for (const status of [401, 503, 302]) test(`proxy-key-refused: preflight rejects HTTP ${status} with exactly one probe`, async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); await f.manager.ensure('demo/coder'); f.state.status = status;
|
||
+ await assert.rejects(f.manager.preflight('demo/coder'), failCode('proxy-key-refused')); assert.equal(f.state.probes, 1);
|
||
+});
|
||
+test('rotate keeps old key while its run lives, new launches use new, exit removes old', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); const first = await f.manager.ensure('demo/coder');
|
||
+ f.runs.push({ instance: 'demo/coder', run: 'old', keyId: hash(first.key) });
|
||
+ const next = await f.manager.rotate('demo/coder'); assert.equal(next.key === first.key, false);
|
||
+ assert.equal(f.state.keys.includes(first.key), true); assert.equal((await f.manager.ensure('demo/coder')).key === next.key, true);
|
||
+ await f.manager.sweep(); assert.equal(f.state.keys.includes(first.key), true);
|
||
+ f.runs.length = 0; await f.manager.sweep(); assert.equal(f.state.keys.includes(first.key), false);
|
||
+});
|
||
+test('rotation grace stops surviving old-key runs before remote revocation', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); const first = await f.manager.ensure('demo/coder');
|
||
+ f.runs.push({ instance: 'demo/coder', run: 'old', keyId: hash(first.key) }); await f.manager.rotate('demo/coder');
|
||
+ f.advance(120 * 60_000); await f.manager.sweep(); assert.deepEqual(f.stopped, ['old']); assert.equal(f.state.keys.includes(first.key), false);
|
||
+});
|
||
+test('revocation polling constants retain the approved five-second bound and 25ms spacing', () => {
|
||
+ assert.equal(REVOKE_TIMEOUT_MS, 5000); assert.equal(REVOKE_POLL_MS, 25);
|
||
+});
|
||
+
|
||
+test('credential-revoke-failed: polling cannot treat management absence as client revocation', async t => {
|
||
+ const f = await fixture(t, { delayed: Infinity }); await f.manager.reconcile(); await f.manager.ensure('demo/coder');
|
||
+ const start = performance.now();
|
||
+ await assert.rejects(f.manager.prepareRevoke('demo/coder'), failCode('credential-revoke-failed'));
|
||
+ const elapsed = performance.now() - start;
|
||
+ assert.ok(elapsed >= 4900 && elapsed < 6500, 'five-second total revocation polling bound');
|
||
+ assert.equal(f.credentials.modelRecord('demo/coder').state, 'revoking');
|
||
+});
|
||
+test('proxy-down: unreachable listener refuses preflight', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); await f.manager.ensure('demo/coder');
|
||
+ await new Promise(resolve => f.server.close(resolve));
|
||
+ await assert.rejects(f.manager.preflight('demo/coder'), failCode('proxy-down'));
|
||
+});
|
||
+test('proxy-stop-failed: old proxy key and retiring record survive a failed grace stop, next sweep retries', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); const first = await f.manager.ensure('demo/coder');
|
||
+ f.runs.push({ instance: 'demo/coder', run: 'old', keyId: hash(first.key) });
|
||
+ let stopped = false;
|
||
+ const manager = new ModelKeys({ credentials: f.credentials, client: f.client, configDir: f.configDir, baseUrl: f.baseUrl,
|
||
+ now: () => Date.parse('2026-10-24T03:00:00.000Z'), runs: () => f.runs,
|
||
+ stopRuns: async () => { if (!stopped) throw Error('fx331-stop'); f.runs.length = 0; } });
|
||
+ await manager.reconcile(); await manager.rotate('demo/coder');
|
||
+ // This manager created a fresh 120-minute window at 03:00; use a second view
|
||
+ // with time beyond that deadline to exercise failure then retry of the same record.
|
||
+ const overdue = new ModelKeys({ credentials: f.credentials, client: f.client, configDir: f.configDir, baseUrl: f.baseUrl,
|
||
+ now: () => Date.parse('2026-10-24T06:00:00.000Z'), runs: () => f.runs,
|
||
+ stopRuns: async () => { if (!stopped) throw Error('fx331-stop'); f.runs.length = 0; } });
|
||
+ await overdue.reconcile();
|
||
+ await assert.rejects(overdue.sweep(), failCode('proxy-stop-failed'));
|
||
+ assert.equal(f.state.keys.includes(first.key), true); assert.equal(f.credentials.modelRecord('demo/coder').retiring.length, 1);
|
||
+ stopped = true; await overdue.sweep(); assert.equal(f.state.keys.includes(first.key), false);
|
||
+});
|
||
+
|
||
+async function bootBusiness(f) {
|
||
+ const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo');
|
||
+ // Aide is needed because shipped chief hireable:[] and security-reviewer has review.verdict outside HIRE_ALLOWED.
|
||
+ fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||
+ writeJson(join(rolesDir, 'aide.json'), {
|
||
+ roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||
+ authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||
+ });
|
||
+ const chiefFile = join(rolesDir, 'chief-of-staff.json');
|
||
+ writeJson(chiefFile, { ...JSON.parse(fs.readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
|
||
+ doc.plugins = { ...doc.plugins, harness: ['pi'] };
|
||
+ doc.roles['sec-1'] = { definition: 'security-reviewer' }; doc.launch.instances.push('sec-1');
|
||
+ const file = writeJson(join(f.configDir, 'businesses', 'demo.json'), doc);
|
||
+ fs.writeFileSync(join(f.configDir, 'secrets', 'proxy-management.key'), 'fx331-manage', { mode: 0o600 });
|
||
+ const business = loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
|
||
+ for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system, business, instance });
|
||
+ fs.mkdirSync(join(f.root, 'data'), { mode: 0o700 });
|
||
+ // Fresh mkdtemp fixture + exclusive mkdir above: no prior session registry can exist.
|
||
+ new Registry(join(f.root, 'data'));
|
||
+ const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busBusiness(business, resolved) },
|
||
+ organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' } });
|
||
+ f.closers.push(() => bus.close());
|
||
+ const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||
+ bus.broker.request(chief, { verb: 'role.claim' });
|
||
+ const fire = () => bus.broker.request(chief, { verb: 'instance.fire', args: { name: 'sec-1', reason: 'fixture fire' } });
|
||
+ const listed = () => JSON.parse(fs.readFileSync(file, 'utf8')).launch.instances.includes('sec-1');
|
||
+ return { bus, chief, fire, listed, file };
|
||
+}
|
||
+test('bus fire proves remote 401 before delisting the agent and deleting model file', async t => {
|
||
+ const f = await fixture(t, { delayed: 105 }); await f.manager.reconcile(); const issued = await f.manager.ensure('demo/sec-1');
|
||
+ const b = await bootBusiness(f); let sawRefusal = false;
|
||
+ f.state.onProbe = status => {
|
||
+ assert.equal(b.listed(), true); assert.equal(b.bus.credentials.modelRecord('demo/sec-1')?.state, 'revoking');
|
||
+ if (status === 401) sawRefusal = true;
|
||
+ };
|
||
+ const result = await b.fire(); f.state.onProbe = null;
|
||
+ assert.equal(sawRefusal, true); assert.equal(result.delisted, true); assert.equal(b.listed(), false);
|
||
+ assert.equal(f.state.keys.includes(issued.key), false); assert.equal(b.bus.credentials.modelRecord('demo/sec-1') === null, true);
|
||
+});
|
||
+test('credential-revoke-failed: bus fire on failed remote PUT keeps membership and model record', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); const issued = await f.manager.ensure('demo/sec-1'); const b = await bootBusiness(f);
|
||
+ const before = fs.readFileSync(b.file, 'utf8'); f.state.failPut = true;
|
||
+ await assert.rejects(b.fire(), failCode('credential-revoke-failed'));
|
||
+ assert.equal(fs.readFileSync(b.file, 'utf8'), before); assert.equal(b.listed(), true);
|
||
+ assert.equal(f.state.keys.includes(issued.key), true); assert.equal(b.bus.credentials.modelRecord('demo/sec-1')?.state, 'revoking');
|
||
+});
|
||
+test('proxy-revoke-revision-conflict: racing organization write keeps record, next boot finishes intent', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); const issued = await f.manager.ensure('demo/sec-1'); const b = await bootBusiness(f);
|
||
+ let raced = false;
|
||
+ f.state.onProbe = status => { if (status === 401 && !raced) { raced = true; fs.appendFileSync(b.file, '\n'); } };
|
||
+ await assert.rejects(b.fire(), failCode('proxy-revoke-revision-conflict')); f.state.onProbe = null;
|
||
+ assert.equal(b.listed(), true); assert.equal(f.state.keys.includes(issued.key), false);
|
||
+ assert.equal(b.bus.credentials.modelRecord('demo/sec-1')?.state, 'revoking');
|
||
+ await b.bus.close(); f.closers.length = 0;
|
||
+ const record = new Credentials({ storeRoot: join(f.configDir, 'secrets', 'agents'), dataRoot: join(f.root, 'data'), modelAgents: ['demo/sec-1'] });
|
||
+ const manager = new ModelKeys({ credentials: record, client: f.client, configDir: f.configDir, baseUrl: f.baseUrl });
|
||
+ await manager.reconcile(); assert.equal(record.modelRecord('demo/sec-1') === null, true); record.close();
|
||
+});
|
||
+test('bus hire issues and confirms implicit model key before publishing new membership', async t => {
|
||
+ const f = await fixture(t); const b = await bootBusiness(f); const calls = f.state.calls.length;
|
||
+ const result = await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
|
||
+ assert.equal(typeof result.transaction, 'string');
|
||
+ assert.equal(JSON.parse(fs.readFileSync(b.file, 'utf8')).launch.instances.includes('sec-new'), true);
|
||
+ assert.equal(b.bus.credentials.modelRecord('demo/sec-new')?.state, 'valid');
|
||
+ assert.deepEqual(f.state.calls.slice(calls, calls + 3), ['GET', 'PUT', 'GET']);
|
||
+});
|
||
+test('proxy-key-issue-failed: bus hire failure publishes neither membership nor model file', async t => {
|
||
+ const f = await fixture(t); const b = await bootBusiness(f); f.state.failPut = true;
|
||
+ const before = fs.readFileSync(b.file, 'utf8');
|
||
+ await assert.rejects(b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } }), failCode('proxy-key-issue-failed'));
|
||
+ assert.equal(fs.readFileSync(b.file, 'utf8'), before); assert.equal(b.bus.credentials.modelRecord('demo/sec-new') === null, true);
|
||
+});
|
||
+test('proxy-open: removing sentinel after boot refuses the next launch before models probe', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); await f.manager.ensure('demo/coder');
|
||
+ f.state.keys = f.state.keys.filter(k => k !== f.sentinel);
|
||
+ await assert.rejects(f.manager.preflight('demo/coder'), failCode('proxy-open')); assert.equal(f.state.probes, 0);
|
||
+});
|
||
+test('newly hired role can use runtime preflight without a broker restart', async t => {
|
||
+ const f = await fixture(t); const b = await bootBusiness(f);
|
||
+ await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
|
||
+ const proxy = await b.bus.modelPreflight({ business: 'demo', role: 'sec-new' });
|
||
+ assert.equal(proxy.baseUrl, f.baseUrl); assert.equal(f.state.keys.includes(proxy.key), true);
|
||
+});
|
||
+test('first launch issues implicit key and performs exactly one successful models probe', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile();
|
||
+ const answer = await f.manager.preflight('demo/coder');
|
||
+ assert.equal(f.state.probes, 1); assert.equal(f.state.keys.includes(answer.key), true);
|
||
+});
|
||
+test('steady boot reconcile reads management list once and writes nothing', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); assert.deepEqual(f.state.calls, ['GET']);
|
||
+});
|
||
+test('thirty-day rotation due automatically replaces and retires an unused old key', async t => {
|
||
+ const f = await fixture(t); await f.manager.reconcile(); const first = await f.manager.ensure('demo/coder');
|
||
+ f.advance(30 * 86400_000); await f.manager.sweep();
|
||
+ const current = await f.manager.ensure('demo/coder'); assert.equal(current.key === first.key, false);
|
||
+ assert.equal(f.state.keys.includes(first.key), false); assert.equal(f.state.keys.includes(current.key), true);
|
||
+});
|
||
diff --git a/packages/bus/tests/model-lifecycle-fixtures.mjs b/packages/bus/tests/model-lifecycle-fixtures.mjs
|
||
new file mode 100644
|
||
index 000000000..18d46f216
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-lifecycle-fixtures.mjs
|
||
@@ -0,0 +1,38 @@
|
||
+// Task651 test-only real controller/private store/fake HTTP; no live instance.
|
||
+import fs from 'node:fs';
|
||
+import http from 'node:http';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { createHash, randomBytes } from 'node:crypto';
|
||
+import { Credentials } from '../src/credentials.mjs';
|
||
+import { ManagementClient } from '../../model-proxy/src/client.mjs';
|
||
+import { ModelKeys } from '../src/model-keys.mjs';
|
||
+import { BusError } from '../src/broker.mjs';
|
||
+export const id = value => createHash('sha256').update(value).digest('hex').slice(0, 12);
|
||
+export async function lifecycleFixture(t) {
|
||
+ const root=fs.mkdtempSync(join(tmpdir(),'p2-low-')); let server; const owned=[],resources=[],history=new Set(['fx651-manage']);
|
||
+ t.after(async()=>{const errors=[];for(const close of [...resources].reverse()){try{await close();}catch{errors.push('owned-resource-close');}}for(const c of owned){try{c.close();}catch{errors.push('credential-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(error=>error?reject(error):resolve()));}catch{errors.push('server-close');}try{fs.rmSync(root,{recursive:true,force:true});}catch{errors.push('root-cleanup');}if(errors.length)throw new AggregateError(errors.map(code=>Error(code)),'owned lifecycle cleanup failed');});
|
||
+ const configDir=join(root,'config'),storeRoot=join(configDir,'secrets','agents');fs.mkdirSync(storeRoot,{recursive:true,mode:0o700});
|
||
+ const prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');
|
||
+ history.add(sentinel);
|
||
+ const state={keys:[sentinel],calls:[],failGet:false,failPut:false,stopFailure:true,runs:[],stopAttempts:[],discoveryFaults:new Map(),discoveries:[]},receipts=[];
|
||
+ server=http.createServer(async(req,res)=>{
|
||
+ const management=req.url==='/v8/management/config/access/api-keys';state.calls.push(management?req.method:'PROBE');for(const key of state.keys)history.add(key);const supplied=req.headers.authorization?.slice(7);if(supplied)history.add(supplied);
|
||
+ if(management){
|
||
+ if(req.headers.authorization!=='Bearer fx651-manage'){res.writeHead(401);res.end('{}');return;}
|
||
+ if(req.method==='GET'&&state.failGet){res.writeHead(503);res.end('{}');return;}
|
||
+ if(req.method==='GET'){res.end(JSON.stringify(state.keys));return;}
|
||
+ const chunks=[];for await(const c of req)chunks.push(c);const attempted=JSON.parse(Buffer.concat(chunks));for(const key of attempted)history.add(key);state.observePut?.();if(state.failPut){res.writeHead(503);res.end('{}');return;}state.keys=attempted;res.end('{"status":"ok"}');return;
|
||
+ }
|
||
+ res.writeHead(state.keys.includes(req.headers.authorization?.slice(7))?200:401);res.end('{}');
|
||
+ });await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||
+ const baseUrl=`http://127.0.0.1:${server.address().port}`;let now=Date.parse('2026-10-25T00:00:00Z');
|
||
+ function create(agents=['demo/coder','demo/other']){
|
||
+ const credentials=new Credentials({storeRoot,dataRoot:join(root,'data'),modelAgents:agents});owned.push(credentials);
|
||
+ const manager=new ModelKeys({credentials,configDir,baseUrl,client:new ManagementClient({baseUrl,key:'fx651-manage',receipt:r=>receipts.push(r)}),receipt:r=>receipts.push(r),now:()=>now,
|
||
+ runs:instance=>{state.discoveries.push(instance);if(state.discoveryFaults.has(instance))throw state.discoveryFaults.get(instance);return state.runs.filter(r=>r.instance===instance);},stopRuns:async runs=>{state.stopAttempts.push(...runs.map(r=>r.run));if(state.stopFailure)throw new BusError('proxy-stop-failed');state.runs=state.runs.filter(r=>!runs.includes(r));}});
|
||
+ return {credentials,manager};
|
||
+ }
|
||
+ const valueFree=(entries=receipts)=>{const text=JSON.stringify(entries);for(const value of history)if(text.includes(value))throw Error('credential leaked');};
|
||
+ return {root,configDir,storeRoot,baseUrl,state,sentinel,receipts,create,valueFree,own:close=>resources.push(close),assertHistoryGuard:()=>{try{valueFree([{canary:sentinel}]);}catch(error){if(error.message==='credential leaked')return;}throw Error('history guard missed canary');},advance:ms=>{now+=ms;}};
|
||
+}
|
||
diff --git a/packages/bus/tests/model-organization-pending.test.mjs b/packages/bus/tests/model-organization-pending.test.mjs
|
||
new file mode 100644
|
||
index 000000000..a1265c544
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-organization-pending.test.mjs
|
||
@@ -0,0 +1,28 @@
|
||
+// #654 CURRENT reachable branch coverage; expected GREEN, no invented pre-fix RED.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import { pendingFixture } from './model-pending-fixture.mjs';
|
||
+
|
||
+test('654 second same-business lifecycle call refuses organization-busy before effects while first completes and gate clears',{timeout:30000},async t=>{
|
||
+ const f=await pendingFixture(t),gate=f.holdNext(),first=f.hire('demo','alpha');await gate.entered();const before=f.snapshot('demo');assert.equal(f.listed('demo','alpha'),false);
|
||
+ await f.busy(f.hire('demo','beta'));assert.deepEqual(f.snapshot('demo'),before,'busy refusal changes no model remote or membership/publication state');assert.equal(f.bus.credentials.modelRecord('demo/beta')===null,true);assert.equal(f.listed('demo','beta'),false);
|
||
+ gate.release();assert.equal(typeof (await first).transaction,'string','first authorized operation remains valid');assert.equal(f.listed('demo','alpha'),true);await f.healthy('demo','alpha');assert.equal(f.listed('demo','beta'),false);
|
||
+ assert.equal(typeof (await f.hire('demo','beta')).transaction,'string','pending gate clears after first successful operation');await f.healthy('demo','beta');f.assertHistoryGuard();f.valueFree();
|
||
+});
|
||
+test('654 independent business owns its pending gate while global controller waits then both publish serially',{timeout:30000},async t=>{
|
||
+ const f=await pendingFixture(t,{twoBusinesses:true}),gate=f.holdNext(),first=f.hire('demo','alpha');await gate.entered();const otherBefore=f.snapshot('other'),second=f.hire('other','beta');
|
||
+ // A third call proves OTHER's first call reached its own pending wrapper while
|
||
+ // queued behind the controller; this does not invent concurrent HTTP completion.
|
||
+ await f.busy(f.hire('other','gamma'));assert.deepEqual(f.snapshot('other'),otherBefore);assert.equal(f.listed('other','beta'),false);assert.equal(f.listed('demo','alpha'),false);assert.equal(f.bus.credentials.modelRecord('other/beta')===null,true);
|
||
+ gate.release();assert.equal(typeof (await first).transaction,'string');assert.equal(typeof (await second).transaction,'string');assert.equal(f.listed('demo','alpha'),true);assert.equal(f.listed('other','beta'),true);await f.healthy('demo','alpha');await f.healthy('other','beta');
|
||
+ assert.equal(typeof (await f.hire('other','gamma')).transaction,'string','other pending gate clears independently');f.valueFree();
|
||
+});
|
||
+test('654 failed first remote issue releases pending gate without publishing and later authorized call succeeds',{timeout:30000},async t=>{
|
||
+ const f=await pendingFixture(t),gate=f.holdNext(),first=f.hire('demo','alpha');await gate.entered();const before=f.snapshot('demo');await f.busy(f.hire('demo','beta'));assert.deepEqual(f.snapshot('demo'),before);
|
||
+ f.state.failPut=true;gate.release();await assert.rejects(first,e=>e.code==='proxy-key-issue-failed');assert.equal(f.listed('demo','alpha'),false);assert.equal(f.bus.credentials.modelRecord('demo/alpha')===null,true);assert.equal(f.listed('demo','beta'),false);
|
||
+ f.state.failPut=false;assert.equal(typeof (await f.hire('demo','beta')).transaction,'string','error path finally releases business gate');await f.healthy('demo','beta');f.valueFree();
|
||
+});
|
||
+test('654 production actor refusal never acquires pending gate or model effects and valid chief still succeeds',{timeout:30000},async t=>{
|
||
+ const f=await pendingFixture(t),cap=f.wrongActor(),before=f.snapshot('demo');await assert.rejects(f.hire('demo','alpha','aide',cap),e=>e.code==='role-not-chief');assert.deepEqual(f.snapshot('demo'),before);assert.equal(f.listed('demo','alpha'),false);
|
||
+ assert.equal(typeof (await f.hire('demo','alpha')).transaction,'string');await f.healthy('demo','alpha');f.valueFree();
|
||
+});
|
||
diff --git a/packages/bus/tests/model-orphan-admission.test.mjs b/packages/bus/tests/model-orphan-admission.test.mjs
|
||
new file mode 100644
|
||
index 000000000..2856ecb24
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-orphan-admission.test.mjs
|
||
@@ -0,0 +1,48 @@
|
||
+// L3b/#651: orphan private file is not proof of current remote membership.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { lifecycleFixture } from './model-lifecycle-fixtures.mjs';
|
||
+async function orphan(t,{revoking=false,withRetiring=false,currentPresent=false}={}){
|
||
+ const f=await lifecycleFixture(t),old=f.create(['demo/orphan']);await old.manager.reconcile();let answer=await old.manager.ensure('demo/orphan');if(withRetiring)answer=await old.manager.rotate('demo/orphan');const prior=old.credentials.modelRecord('demo/orphan');
|
||
+ if(revoking){const r=old.credentials.modelRecord('demo/orphan');old.credentials.writeModel('demo/orphan',{...r,state:'revoking',revokingAt:'2026-10-25T00:00:00.000Z'});}
|
||
+ old.credentials.close();f.assertHistoryGuard();const fresh=f.create();await fresh.manager.reconcile();assert.equal(f.state.keys.includes(answer.key),false,'boot removed unregistered remote orphan');if(currentPresent)f.state.keys.push(answer.key);
|
||
+ const file=join(f.storeRoot,'demo','orphan','model'),before=fs.readFileSync(file);f.state.calls=[];return {...f,...fresh,answer,file,before,prior};
|
||
+}
|
||
+test('L3b removed orphan rehire obtains fresh confirmed credential before publication',async t=>{
|
||
+ const f=await orphan(t);let published=false;const proxy=await f.manager.ensure('demo/orphan');
|
||
+ assert.equal(proxy.key===f.answer.key,false,'removed orphan credential must not be returned');
|
||
+ assert.equal(f.state.keys.includes(proxy.key),true);assert.equal(f.state.keys.includes(f.answer.key),false);published=true;
|
||
+ assert.equal(published,true);assert.deepEqual(f.state.calls,['GET','PUT','GET']);f.valueFree();
|
||
+});
|
||
+test('L3b registered orphan revoking state refuses before preflight or mutation',async t=>{
|
||
+ const f=await orphan(t,{revoking:true});await assert.rejects(f.manager.preflight('demo/orphan'),e=>e.code==='proxy-key-revoking','post-register revoking hold required');
|
||
+ assert.equal(f.state.calls.includes('PUT'),false);assert.equal(f.state.calls.includes('PROBE'),false);assert.equal(fs.readFileSync(f.file).equals(f.before),true);f.valueFree();
|
||
+});
|
||
+test('L3b orphan fresh issuance failure publishes nothing and preserves old private file',async t=>{
|
||
+ const f=await orphan(t);f.state.failPut=true;let published=false;
|
||
+ await assert.rejects(f.manager.ensure('demo/orphan').then(()=>{published=true;}),e=>e.code==='proxy-key-issue-failed','orphan needs confirmed fresh issuance');
|
||
+ assert.equal(published,false);assert.equal(f.state.keys.includes(f.answer.key),false);assert.equal(fs.readFileSync(f.file).equals(f.before),true);f.valueFree();
|
||
+});
|
||
+test('L3b cached orphan never bypasses missing sentinel',async t=>{
|
||
+ const f=await orphan(t);f.state.keys=[];await assert.rejects(f.manager.ensure('demo/orphan'),e=>e.code==='proxy-open','cached record still needs sentinel');assert.equal(f.state.calls.includes('PUT'),false);f.valueFree();
|
||
+});
|
||
+test('L3b cached orphan never bypasses unreadable remote membership',async t=>{
|
||
+ const f=await orphan(t);f.state.failGet=true;await assert.rejects(f.manager.ensure('demo/orphan'),e=>e.code==='proxy-key-issue-failed','cached record needs readable membership');assert.equal(f.state.calls.includes('PUT'),false);f.valueFree();
|
||
+});
|
||
+test('L3b existing remotely confirmed record is reused without key rotation',async t=>{
|
||
+ const f=await lifecycleFixture(t),c=f.create();await c.manager.reconcile();const first=await c.manager.ensure('demo/coder');f.state.calls=[];const next=await c.manager.ensure('demo/coder');
|
||
+ assert.equal(first.key===next.key,true);assert.equal(f.state.calls.includes('PUT'),false);f.valueFree();
|
||
+});
|
||
+
|
||
+for(const verb of ['ensure','rotate'])for(const present of [true,false])test(`L3b ${verb} current-${present?'present':'absent'} preserves prior retiring metadata and approved append rule`,async t=>{
|
||
+ const f=await orphan(t,{withRetiring:true,currentPresent:present});const proxy=await f.manager[verb]('demo/orphan'),record=f.credentials.modelRecord('demo/orphan');
|
||
+ assert.equal(JSON.stringify(record.retiring.slice(0,f.prior.retiring.length))===JSON.stringify(f.prior.retiring),true,'prior retiring identities and times preserved');
|
||
+ assert.equal(record.retiring.length,f.prior.retiring.length+(verb==='rotate'&&present?1:0),'only rotate with remotely present current appends retirement');
|
||
+ assert.equal(proxy.key===f.answer.key,verb==='ensure'&&present,'current reuse requires confirmed present ensure');assert.equal(f.state.keys.includes(proxy.key),true,'returned key confirmed remotely');
|
||
+ if(!present)assert.equal(f.state.keys.includes(f.answer.key),false,'absent old current never restored');
|
||
+ assert.equal(f.state.calls.filter(c=>c==='GET').length,verb==='ensure'&&present?1:2,'fresh serialized list required even for cached reuse');f.valueFree();
|
||
+});
|
||
+for(const verb of ['ensure','rotate'])test(`L3b ${verb} without prior record creates no retiring entries`,async t=>{const f=await lifecycleFixture(t),c=f.create();await c.manager.reconcile();const proxy=await c.manager[verb]('demo/coder');assert.equal(c.credentials.modelRecord('demo/coder').retiring.length,0);assert.equal(f.state.keys.includes(proxy.key),true);f.valueFree();});
|
||
+for(const verb of ['ensure','rotate'])test(`L3b ${verb} known revoking record refuses without management call`,async t=>{const f=await lifecycleFixture(t),c=f.create();await c.manager.reconcile();await c.manager.ensure('demo/coder');const r=c.credentials.modelRecord('demo/coder');c.credentials.writeModel('demo/coder',{...r,state:'revoking',revokingAt:'2026-10-25T00:00:00.000Z'});f.state.calls=[];await assert.rejects(c.manager[verb]('demo/coder'),e=>e.code==='proxy-key-revoking');assert.deepEqual(f.state.calls,[]);f.valueFree();});
|
||
diff --git a/packages/bus/tests/model-orphan-hire.test.mjs b/packages/bus/tests/model-orphan-hire.test.mjs
|
||
new file mode 100644
|
||
index 000000000..981c17154
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-orphan-hire.test.mjs
|
||
@@ -0,0 +1,48 @@
|
||
+// #651 actual runtime + organization.hire publication with orphan private records.
|
||
+// In-process trusted Broker, real SQLite/organization writer/HTTP; not process IPC.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { startBroker } from '../src/runtime.mjs';
|
||
+import { busBusiness } from '../src/business.mjs';
|
||
+import { loadBusiness } from '../../business/src/business.mjs';
|
||
+import { resolveInstance } from '../../business/src/resolve.mjs';
|
||
+import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||
+import { lifecycleFixture } from './model-lifecycle-fixtures.mjs';
|
||
+async function hireFixture(t,{revoking=false}={}){
|
||
+ const f=await lifecycleFixture(t),old=f.create(['demo/orphan']);await old.manager.reconcile();const issued=await old.manager.ensure('demo/orphan');
|
||
+ if(revoking){const r=old.credentials.modelRecord('demo/orphan');old.credentials.writeModel('demo/orphan',{...r,state:'revoking',revokingAt:'2026-10-25T00:00:00.000Z'});}old.credentials.close();
|
||
+ const rolesDir=rolesCopy(f.root),system=systemFor(f.root),doc=businessDoc(f.root,'demo');
|
||
+ // Scratch aide: shipped chief hireable:[] and review.verdict is outside HIRE_ALLOWED.
|
||
+ fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||
+ const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});doc.plugins={...doc.plugins,harness:['pi']};
|
||
+ const file=writeJson(join(f.configDir,'businesses','demo.json'),doc);fs.writeFileSync(join(f.configDir,'secrets','proxy-management.key'),'fx651-manage',{mode:0o600});
|
||
+ const business=loadBusiness('demo',{dir:f.configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});
|
||
+ const dataRoot=join(f.root,'data');fs.mkdirSync(dataRoot,{mode:0o700});
|
||
+ // Capture only value-free runtime proxy receipts; never print a leaked value.
|
||
+ const log=[],original=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){log.push(String(chunk));const callback=args.find(a=>typeof a==='function');callback?.();return true;}return original.call(this,chunk,...args);};f.own(()=>{process.stderr.write=original;});
|
||
+ const bus=await startBroker({dataRoot,businesses:{demo:busBusiness(business,resolved)},organization:{dir:f.configDir,rolesDir,system},proxy:{port:Number(new URL(f.baseUrl).port),binary:'/fixture-unused'}});
|
||
+ f.own(()=>bus.close());
|
||
+ assert.equal(f.state.keys.includes(issued.key),false,'runtime boot reconciled omitted orphan');assert.equal(bus.credentials.modelRecord('demo/orphan')===null,true,'orphan remains unregistered before hire');
|
||
+ const cap=bus.broker.bindLaunch({business:'demo',role:'chief',run:'orphan-hire-chief',harness:'pi'});bus.broker.request(cap,{verb:'role.claim'});
|
||
+ const listed=()=>JSON.parse(fs.readFileSync(file,'utf8')).launch.instances.includes('orphan'),observed=[];f.state.observePut=()=>observed.push(listed());f.state.calls=[];
|
||
+ const hire=()=>bus.broker.request(cap,{verb:'instance.hire',args:{name:'orphan',definition:'aide',reason:'fixture orphan hire'}});
|
||
+ return {...f,bus,issued,file,log,listed,observed,hire};
|
||
+}
|
||
+test('L3b runtime hire publishes orphan only after fresh remote confirmation',async t=>{
|
||
+ const f=await hireFixture(t);const result=await f.hire();assert.equal(typeof result.transaction,'string');const record=f.bus.credentials.modelRecord('demo/orphan');
|
||
+ assert.equal(record.key===f.issued.key,false,'runtime hire must replace removed orphan credential');assert.equal(f.state.keys.includes(record.key),true,'published member has confirmed remote key');assert.equal(f.listed(),true);
|
||
+ assert.ok(f.observed.length>0,'actual fresh issue reached proxy PUT');assert.equal(f.observed.every(published=>published===false),true,'membership absent during remote writes');assert.deepEqual(f.state.calls.slice(0,3),['GET','PUT','GET']);
|
||
+ const proxy=await f.bus.modelPreflight({business:'demo',role:'orphan'});assert.equal(proxy.key===record.key,true);f.valueFree(f.log);f.valueFree();
|
||
+});
|
||
+test('L3b runtime orphan fresh PUT failure preserves membership exactly',async t=>{
|
||
+ const f=await hireFixture(t),before=fs.readFileSync(f.file);f.state.failPut=true;
|
||
+ await assert.rejects(f.hire(),e=>e.code==='proxy-key-issue-failed','runtime orphan issue failure refuses before membership publication');
|
||
+ assert.equal(fs.readFileSync(f.file).equals(before),true);assert.equal(f.listed(),false);assert.equal(f.state.keys.includes(f.issued.key),false);assert.ok(f.observed.length>0,'rejected fresh PUT observed');assert.equal(f.observed.every(published=>published===false),true,'membership absent through failed issue and existing cleanup');f.valueFree(f.log);f.valueFree();
|
||
+});
|
||
+test('L3b runtime unregistered revoking orphan cannot publish membership',async t=>{
|
||
+ const f=await hireFixture(t,{revoking:true}),before=fs.readFileSync(f.file);
|
||
+ await assert.rejects(f.hire(),e=>e.code==='proxy-key-revoking','runtime post-register revoking hold refuses hire');
|
||
+ assert.equal(fs.readFileSync(f.file).equals(before),true);assert.equal(f.listed(),false);assert.equal(f.state.keys.includes(f.issued.key),false);f.valueFree(f.log);f.valueFree();
|
||
+});
|
||
diff --git a/packages/bus/tests/model-pending-fixture.mjs b/packages/bus/tests/model-pending-fixture.mjs
|
||
new file mode 100644
|
||
index 000000000..f148bb021
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-pending-fixture.mjs
|
||
@@ -0,0 +1,53 @@
|
||
+// #654 isolated real runtime/writer/controller with owned HTTP request gates.
|
||
+import fs from 'node:fs';
|
||
+import http from 'node:http';
|
||
+import assert from 'node:assert/strict';
|
||
+import { createHash, randomBytes } from 'node:crypto';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { startBroker } from '../src/runtime.mjs';
|
||
+import { busBusiness } from '../src/business.mjs';
|
||
+import { loadBusiness } from '../../business/src/business.mjs';
|
||
+import { resolveInstance } from '../../business/src/resolve.mjs';
|
||
+import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||
+import { sessionsFile, SESSIONS_VERSION } from '../../seat/src/session.mjs';
|
||
+const hash = value => createHash('sha256').update(value).digest('hex');
|
||
+const deferred = () => {let resolve;const promise=new Promise(r=>{resolve=r;});return {promise,resolve};};
|
||
+export async function bounded(promise,message,ms=5000){let timer;try{return await Promise.race([promise,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error(message)),ms);})]);}finally{clearTimeout(timer);}}
|
||
+export async function pendingFixture(t,{twoBusinesses=false}={}){
|
||
+ const root=fs.mkdtempSync(join(tmpdir(),'p654-')),closers=[],active=new Set(),gates=[],history=new Set(['fx654-manage']),receipts=[];
|
||
+ let server,bus,stderr;const state={keys:[],calls:[],failPut:false,nextGate:null};
|
||
+ const valueFree=(entries=receipts)=>{const text=JSON.stringify(entries);for(const value of history)if(text.includes(value))throw Error('fixture credential leaked');};
|
||
+ t.after(async()=>{const failures=[];for(const g of gates)g.release.resolve();try{await bounded(Promise.allSettled([...active]),'owned operations did not settle');}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-resource-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{valueFree();}catch{failures.push('value-free-receipts');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned pending fixture cleanup failed');});
|
||
+ const configDir=join(root,'config'),dataRoot=join(root,'data'),rolesDir=rolesCopy(root),system=systemFor(root);fs.mkdirSync(dataRoot,{mode:0o700});
|
||
+ const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
|
||
+ // Existing row331 scratch aide pattern only; repository role authority unchanged.
|
||
+ fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
|
||
+ const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busBusiness(business,resolved);}
|
||
+ fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
|
||
+ const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
|
||
+ server=http.createServer(async(req,res)=>{try{
|
||
+ const supplied=req.headers.authorization?.slice(7);if(supplied)history.add(supplied);for(const key of state.keys)history.add(key);
|
||
+ const management=req.url==='/v8/management/config/access/api-keys';state.calls.push(management?req.method:'PROBE');let attempted;
|
||
+ if(management&&req.method==='PUT'){const chunks=[];for await(const chunk of req)chunks.push(chunk);attempted=JSON.parse(Buffer.concat(chunks));for(const key of attempted)history.add(key);}
|
||
+ // Faults/gates are ONLY the owned fake HTTP transport boundary. Existing
|
||
+ // ModelKeys, pending Map, writer locks and publication remain untouched.
|
||
+ const g=state.nextGate;if(g&&management&&req.method===g.method){state.nextGate=null;g.entered.resolve();await g.release.promise;}
|
||
+ if(res.destroyed)return;
|
||
+ if(management){if(supplied!=='fx654-manage'){res.writeHead(401);res.end('{}');return;}if(req.method==='GET'){res.end(JSON.stringify(state.keys));return;}if(state.failPut){res.writeHead(503);res.end('{}');return;}state.keys=attempted;res.end('{"status":"ok"}');return;}
|
||
+ res.writeHead(state.keys.includes(supplied)?200:401);res.end('{}');
|
||
+ }catch{if(!res.destroyed){res.writeHead(500);res.end('{}');}}});await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});assert.notEqual(server.address().port,8317);
|
||
+ stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){receipts.push(String(chunk));const callback=args.find(a=>typeof a==='function');callback?.();return true;}return stderr.call(this,chunk,...args);};
|
||
+ bus=await startBroker({dataRoot,businesses,organization:{dir:configDir,rolesDir,system},proxy:{port:server.address().port,binary:'/fixture-unused'}});closers.push(()=>bus.close());
|
||
+ const chief={};for(const name of ids){chief[name]=bus.broker.bindLaunch({business:name,role:'chief',run:'pending-'+name,harness:'pi'});bus.broker.request(chief[name],{verb:'role.claim'});}
|
||
+ const track=promise=>{active.add(promise);promise.then(()=>active.delete(promise),()=>active.delete(promise));return promise;};
|
||
+ const holdNext=(method='GET')=>{assert.equal(state.nextGate,null,'only one owned HTTP hold armed');const g={method,entered:deferred(),release:deferred()};gates.push(g);state.nextGate=g;return {entered:()=>bounded(g.entered.promise,'owned lifecycle HTTP boundary not observed'),release:()=>g.release.resolve()};};
|
||
+ const listed=(business,name)=>JSON.parse(fs.readFileSync(files[business],'utf8')).launch.instances.includes(name);
|
||
+ const snapshot=business=>{const journal=join(configDir,'businesses','.history',business,'changes.jsonl');return {membership:hash(fs.readFileSync(files[business])),publicationJournal:fs.existsSync(journal)?hash(fs.readFileSync(journal)):null,modelStatus:hash(JSON.stringify(bus.credentials.status())),remote:hash(JSON.stringify(state.keys)),calls:state.calls.length};};
|
||
+ const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>bus.broker.request(cap,{verb:'instance.hire',args:{name,definition,reason:'fixture pending hire'}})));
|
||
+ const busy=async promise=>{const result=await bounded(promise.then(()=>({code:'unexpected-success'}),e=>({code:e.code??'unexpected-error'})),'same-business refusal not observed');assert.equal(result.code,'organization-busy','second same-business lifecycle call refuses exact pending code');};
|
||
+ return {root,configDir,dataRoot,rolesDir,bus,state,receipts,chief,holdNext,listed,snapshot,hire,busy,valueFree,
|
||
+ healthy:async(business,name)=>{const value=await bus.modelPreflight({business,role:name});const record=bus.credentials.modelRecord(business+'/'+name);assert.equal(value.key===record.key,true);assert.equal(state.keys.includes(record.key),true,'published member retains confirmed model credential');},
|
||
+ wrongActor:()=>{const cap=bus.broker.bindLaunch({business:'demo',role:'coder',run:'pending-coder',harness:'pi'});bus.broker.request(cap,{verb:'role.claim'});return cap;},
|
||
+ assertHistoryGuard:()=>{try{valueFree([{canary:sentinel}]);}catch(e){if(e.message==='fixture credential leaked')return;}throw Error('fixture history guard failed');}};
|
||
+}
|
||
diff --git a/packages/bus/tests/model-proxy.test.mjs b/packages/bus/tests/model-proxy.test.mjs
|
||
index df0dbe2fd..e4fd74bee 100644
|
||
--- a/packages/bus/tests/model-proxy.test.mjs
|
||
+++ b/packages/bus/tests/model-proxy.test.mjs
|
||
@@ -14,3 +14,25 @@ test('proxy.init not-configured',t=>{const f=fixture(t,null);assert.throws(()=>f
|
||
test('proxy.init callback error redacts key and maps outcome to named refusal',async t=>{const f=fixture(t,()=>{throw Error('p1-management');});await assert.rejects(f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='proxy-init-failed'&&!e.message.includes('p1-management'));assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes('p1-management'),false);});
|
||
test('socket awaits proxy.init and does not return a Promise object or retain human cap',async t=>{const {serve}=await import('../src/server.mjs');const {Client}=await import('../src/client.mjs');const f=fixture(t,async()=>{await new Promise(r=>setTimeout(r,10));return {port:18432,binary:'/fixture'};});const root=mkdtempSync(join(tmpdir(),'p1-sock-'));const socket=join(root,'s');const server=await serve({broker:f.b,path:socket,peerReader:{read:async()=>({pid:process.pid,uid:process.getuid(),gid:process.getgid()}),close(){}},authenticateHuman:()=>f.b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true})});t.after(()=>server.close());const client=new Client({path:socket,human:{business:'demo',pid:process.pid,startTime:'1',nonce:'fixture'}});assert.deepEqual(await client.call('proxy.init',{key:'p1-management'}),{port:18432,binary:'/fixture'});});
|
||
test('proxy-init-failed: extra callback fields cannot return a management key',async t=>{const f=fixture(t,async()=>({port:18432,binary:'/fixture',key:'p1-management'}));await assert.rejects(f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='proxy-init-failed');assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes('p1-management'),false);});
|
||
+
|
||
+// P2 R2-N1: catches local broker predicates diverging from management-client rules.
|
||
+for (const [label, key] of [['Latin-1', 'fx331-caf\u00e9'], ['leading space', ' fx331-key'], ['trailing space', 'fx331-key '], ['internal space', 'fx331 key']]) {
|
||
+ test(`proxy-key-invalid: broker rejects ${label} before initializer`, async t => {
|
||
+ let called = false;
|
||
+ const f = fixture(t, () => { called = true; return { port: 18432, binary: '/fixture' }; });
|
||
+ let pending;
|
||
+ try {
|
||
+ assert.throws(() => { pending = f.b.request(f.human, { verb: 'proxy.init', args: { key } }); }, e => e.code === 'proxy-key-invalid');
|
||
+ } finally { await Promise.resolve(pending).catch(() => {}); }
|
||
+ assert.equal(called, false);
|
||
+ assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes(key), false);
|
||
+ });
|
||
+}
|
||
+
|
||
+for (const [field, code, value] of [['rotateGraceMinutes', 'proxy-rotate-grace-invalid', 1441], ['firstOutputSeconds', 'proxy-first-output-invalid', 3601]]) {
|
||
+ test(`proxy.init ${code}: human setting validated before initializer`, t => {
|
||
+ let called = false; const f = fixture(t, () => { called = true; });
|
||
+ assert.throws(() => f.b.request(f.human, { verb: 'proxy.init', args: { key: 'fx331-manage', [field]: value } }), e => e.code === code);
|
||
+ assert.equal(called, false);
|
||
+ });
|
||
+}
|
||
diff --git a/packages/bus/tests/model-restart.test.mjs b/packages/bus/tests/model-restart.test.mjs
|
||
new file mode 100644
|
||
index 000000000..e322cb862
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-restart.test.mjs
|
||
@@ -0,0 +1,158 @@
|
||
+// B1 / 647: real runtime timer, scratch proxy and persisted sessions, never the host instance.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import http from 'node:http';
|
||
+import { spawn } from 'node:child_process';
|
||
+import { once } from 'node:events';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { createHash, randomBytes } from 'node:crypto';
|
||
+import { setTimeout as wait } from 'node:timers/promises';
|
||
+import { startBroker } from '../src/runtime.mjs';
|
||
+import { Credentials } from '../src/credentials.mjs';
|
||
+import { ModelKeys } from '../src/model-keys.mjs';
|
||
+import { ManagementClient } from '../../model-proxy/src/client.mjs';
|
||
+import { busBusiness } from '../src/business.mjs';
|
||
+import { loadBusiness } from '../../business/src/business.mjs';
|
||
+import { resolveInstance } from '../../business/src/resolve.mjs';
|
||
+import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../business/tests/helpers.mjs';
|
||
+import { startTimeOf } from '../../seat/src/proc.mjs';
|
||
+import { Registry, SESSIONS_VERSION, sessionsFile } from '../../seat/src/session.mjs';
|
||
+import { createLauncher } from '../../cli/src/launcher.mjs';
|
||
+const id = value => createHash('sha256').update(value).digest('hex').slice(0, 12);
|
||
+
|
||
+async function fixture(t, { overdue = false, exited = false } = {}) {
|
||
+ const root = fs.mkdtempSync(join(tmpdir(), 'b1-'));
|
||
+ let server, credentials, child, childClosed, bus, launcher;
|
||
+ let completed = 0;
|
||
+ const originalSweep = ModelKeys.prototype.sweep;
|
||
+ const exit = async () => {
|
||
+ // Only the ChildProcess this fixture spawned: never use a registry-supplied PID.
|
||
+ if (child?.pid && child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
||
+ if (childClosed) await childClosed;
|
||
+ };
|
||
+ t.after(async () => {
|
||
+ const errors = [];
|
||
+ for (const close of [() => launcher?.close(), () => bus?.close(), () => credentials?.close(), exit,
|
||
+ async () => { server?.closeAllConnections(); if (server?.listening) await new Promise(resolve => server.close(resolve)); }]) {
|
||
+ try { await close(); } catch (error) { errors.push(error); }
|
||
+ }
|
||
+ ModelKeys.prototype.sweep = originalSweep;
|
||
+ fs.rmSync(root, { recursive: true, force: true });
|
||
+ if (errors.length) throw new AggregateError(errors, 'scratch fixture cleanup failed');
|
||
+ });
|
||
+ // Await the ORIGINAL sweep, including its real HTTP/filesystem work. Count both
|
||
+ // fulfilled and rejected completions; the runtime still handles its own refusal.
|
||
+ ModelKeys.prototype.sweep = async function (...args) {
|
||
+ try { return await originalSweep.apply(this, args); }
|
||
+ finally { completed++; }
|
||
+ };
|
||
+ const observeSweep = async () => {
|
||
+ const before = completed, deadline = Date.now() + 5000;
|
||
+ // Same five-second bounded wait budget as bus/tests/host-run.test.mjs:47.
|
||
+ // Production interval remains 1000ms; no clock or scheduler is replaced.
|
||
+ while (completed <= before && Date.now() < deadline) await wait(20);
|
||
+ assert.ok(completed > before, 'original runtime sweep completed within five seconds');
|
||
+ };
|
||
+ const configDir = join(root, 'config'), dataRoot = join(root, 'data');
|
||
+ fs.mkdirSync(dataRoot, { mode: 0o700 });
|
||
+ const config = systemConfig(root), system = systemFor(root), rolesDir = rolesCopy(root);
|
||
+ const doc = businessDoc(root, 'demo'); doc.plugins = { ...doc.plugins, harness: ['pi'] };
|
||
+ writeJson(join(configDir, 'businesses', 'demo.json'), doc);
|
||
+ const business = loadBusiness('demo', { dir: configDir, rolesDir }), resolved = {};
|
||
+ for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system, business, instance });
|
||
+ const storeRoot = join(configDir, 'secrets', 'agents'); fs.mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||
+ fs.writeFileSync(join(configDir, 'secrets', 'proxy-management.key'), 'fx647-manage', { mode: 0o600 });
|
||
+ const prefix = `msk-${id(configDir)}-`;
|
||
+ const state = { keys: [prefix + 'sentinel-' + randomBytes(32).toString('base64url')], put: 0, removed: 0 };
|
||
+ server = http.createServer(async (req, res) => {
|
||
+ if (req.url === '/v8/management/config/access/api-keys') {
|
||
+ if (req.headers.authorization !== 'Bearer fx647-manage') { res.writeHead(401); res.end('{}'); return; }
|
||
+ if (req.method === 'GET') { res.end(JSON.stringify(state.keys)); return; }
|
||
+ const chunks = []; for await (const chunk of req) chunks.push(chunk);
|
||
+ const next = JSON.parse(Buffer.concat(chunks));
|
||
+ state.removed += state.keys.filter(key => !next.includes(key)).length;
|
||
+ state.keys = next; state.put++; res.end(JSON.stringify({ status: 'ok' })); return;
|
||
+ }
|
||
+ res.writeHead(state.keys.includes(req.headers.authorization?.slice(7)) ? 200 : 401); res.end('{}');
|
||
+ });
|
||
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||
+ const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||
+ credentials = new Credentials({ storeRoot, dataRoot, modelAgents: ['demo/chief'] });
|
||
+ const manager = new ModelKeys({ credentials, configDir, baseUrl, client: new ManagementClient({ baseUrl, key: 'fx647-manage' }) });
|
||
+ await manager.reconcile(); const first = await manager.ensure('demo/chief'); await manager.rotate('demo/chief');
|
||
+ const record = credentials.modelRecord('demo/chief');
|
||
+ credentials.writeModel('demo/chief', { ...record, retiring: record.retiring.map(r => ({ ...r, retireAt: new Date(Date.now() + (overdue ? -60_000 : 3_600_000)).toISOString() })) });
|
||
+ credentials.close(); credentials = null; state.put = 0; state.removed = 0;
|
||
+ // A harmless scratch-owned process supplies real /proc identity. It is deliberately
|
||
+ // not a session runner, so stopSession must refuse rather than signal this stand-in.
|
||
+ child = spawn('/usr/bin/sleep', ['60'], { stdio: 'ignore' });
|
||
+ childClosed = new Promise(resolve => child.once('close', resolve));
|
||
+ await once(child, 'spawn');
|
||
+ const ticks = startTimeOf(child.pid); assert.equal(typeof ticks, 'string');
|
||
+ const entry = { business: 'demo', instance: 'chief', run: 'r647-old', harness: 'pi', provider: 'fake', model: 'fake',
|
||
+ pid: child.pid, startTime: ticks, runnerPid: child.pid, runnerStartTime: ticks, modelKeyId: id(first.key), launchedBy: 'chief' };
|
||
+ const writeSessions = entries => writeJson(sessionsFile(dataRoot), { sessionsVersion: SESSIONS_VERSION, sessions: entries });
|
||
+ writeSessions([entry]);
|
||
+ if (exited) await exit();
|
||
+ const boot = async () => {
|
||
+ bus = await startBroker({ dataRoot, businesses: { demo: busBusiness(business, resolved) }, organization: { dir: configDir, rolesDir, system },
|
||
+ proxy: { port: server.address().port, binary: '/fixture-unused' } });
|
||
+ return bus;
|
||
+ };
|
||
+ const noLaunch = async () => {
|
||
+ const ops = [];
|
||
+ const host = { business: 'demo', beforeClose() {}, bindLaunch: async r => { ops.push('bindLaunch'); return bus.bindLaunch(r); },
|
||
+ op: async r => { ops.push(r.op); throw Error('fixture-unrequested-op'); } };
|
||
+ launcher = createLauncher({ host, system: JSON.parse(fs.readFileSync(config, 'utf8')), launches: false, env: { MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesDir, PATH: '/usr/bin' }, log: () => {} });
|
||
+ await launcher.listen(); return ops;
|
||
+ };
|
||
+ const retained = () => {
|
||
+ assert.equal(state.keys.includes(first.key), true, 'old key stays remotely installed');
|
||
+ assert.equal(bus.credentials.modelRecord('demo/chief').retiring.some(r => r.keyId === entry.modelKeyId), true, 'retiring id stays locally recorded');
|
||
+ assert.equal(state.removed, 0, 'no remote key removal');
|
||
+ };
|
||
+ return { root, dataRoot, state, entry, writeSessions, boot, noLaunch, exit, retained, observeSweep,
|
||
+ alive: () => startTimeOf(child.pid) === ticks,
|
||
+ absent: () => { assert.equal(state.keys.includes(first.key), false); assert.equal(bus.credentials.modelRecord('demo/chief').retiring.length, 0); },
|
||
+ bind: () => bus.bindLaunch({ business: entry.business, role: entry.instance, run: entry.run, harness: entry.harness, pid: entry.pid, startTime: entry.startTime, modelKeyId: entry.modelKeyId }) };
|
||
+}
|
||
+
|
||
+test('B1 restart before grace discovers persisted old-key run without bindLaunch', async t => {
|
||
+ const f = await fixture(t); await f.boot(); await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
|
||
+});
|
||
+test('B1 no-launch restart past grace neither signals nor ends a prior session', async t => {
|
||
+ const f = await fixture(t, { overdue: true }); const before = fs.readFileSync(sessionsFile(f.dataRoot), 'utf8');
|
||
+ await f.boot(); const ops = await f.noLaunch(); await f.observeSweep();
|
||
+ f.retained(); assert.equal(f.alive(), true); assert.equal(ops.includes('bindLaunch') || ops.includes('endLaunch'), false);
|
||
+ assert.equal(fs.readFileSync(sessionsFile(f.dataRoot), 'utf8'), before);
|
||
+});
|
||
+test('B1 normal recovery gap retains boot inventory when Registry clears before rebinding', async t => {
|
||
+ const f = await fixture(t); await f.boot(); new Registry(f.dataRoot); await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
|
||
+});
|
||
+for (const corruption of ['malformed', 'missing']) test(`B1 ${corruption} persisted discovery never authorizes retirement`, async t => {
|
||
+ const f = await fixture(t, { overdue: true });
|
||
+ if (corruption === 'malformed') fs.writeFileSync(sessionsFile(f.dataRoot), '{'); else fs.unlinkSync(sessionsFile(f.dataRoot));
|
||
+ await f.boot(); await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
|
||
+});
|
||
+for (const field of ['startTime', 'runnerStartTime', 'modelKeyId']) test(`B1 persisted identity disagreement in ${field} retains key and record`, async t => {
|
||
+ const f = await fixture(t, { overdue: true });
|
||
+ f.writeSessions([{ ...f.entry, [field]: field === 'modelKeyId' ? 'fedcba987654' : '0' }]);
|
||
+ await f.boot(); await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
|
||
+});
|
||
+test('B1 discovery failure after boot retains the cached live old-key inventory', async t => {
|
||
+ const f = await fixture(t, { overdue: true }); await f.boot(); fs.writeFileSync(sessionsFile(f.dataRoot), '{');
|
||
+ await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
|
||
+});
|
||
+test('B1 bound run with unauthorized runner stop retains key and retiring record', async t => {
|
||
+ const f = await fixture(t, { overdue: true }); await f.boot(); f.bind(); await f.observeSweep(); f.retained(); assert.equal(f.alive(), true);
|
||
+});
|
||
+test('B1 confirmed persisted runner exit permits old-key retirement after grace', async t => {
|
||
+ const f = await fixture(t, { overdue: true, exited: true }); await f.boot(); await f.observeSweep(); f.absent();
|
||
+});
|
||
+
|
||
+test('B1 an observed persisted exit after restart permits retirement, never an empty registry alone', async t => {
|
||
+ const f = await fixture(t, { overdue: true }); await f.boot(); await f.observeSweep(); f.retained();
|
||
+ await f.exit(); await f.observeSweep(); f.absent();
|
||
+});
|
||
diff --git a/packages/bus/tests/model-retirement-boundary.test.mjs b/packages/bus/tests/model-retirement-boundary.test.mjs
|
||
new file mode 100644
|
||
index 000000000..b19683cae
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-retirement-boundary.test.mjs
|
||
@@ -0,0 +1,86 @@
|
||
+// B1/647: discovery changes while real retirement HTTP is in flight.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import http from 'node:http';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { createHash, randomBytes } from 'node:crypto';
|
||
+import { Credentials } from '../src/credentials.mjs';
|
||
+import { BusError } from '../src/broker.mjs';
|
||
+import { ModelKeys } from '../src/model-keys.mjs';
|
||
+import { ManagementClient } from '../../model-proxy/src/client.mjs';
|
||
+const id = value => createHash('sha256').update(value).digest('hex').slice(0, 12);
|
||
+
|
||
+async function fixture(t, boundary) {
|
||
+ const root = fs.mkdtempSync(join(tmpdir(), 'b1-boundary-'));
|
||
+ let server, credentials;
|
||
+ t.after(async () => {
|
||
+ try { credentials?.close(); }
|
||
+ finally {
|
||
+ server?.closeAllConnections();
|
||
+ if (server?.listening) await new Promise(resolve => server.close(resolve));
|
||
+ fs.rmSync(root, { recursive: true, force: true });
|
||
+ }
|
||
+ });
|
||
+ const configDir = join(root, 'config'), storeRoot = join(configDir, 'secrets', 'agents');
|
||
+ fs.mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||
+ const sentinel = `msk-${id(configDir)}-sentinel-${randomBytes(32).toString('base64url')}`;
|
||
+ const state = { keys: [sentinel], armed: false, discoveryFailed: false, calls: [], faults: 0, refusals: 0 };
|
||
+ const receipts = [];
|
||
+ server = http.createServer(async (req, res) => {
|
||
+ const management = req.url === '/v8/management/config/access/api-keys';
|
||
+ state.calls.push(management ? req.method : 'PROBE');
|
||
+ if (management) {
|
||
+ if (req.headers.authorization !== 'Bearer fx647-manage') { res.writeHead(401); res.end('{}'); return; }
|
||
+ if (req.method === 'GET') {
|
||
+ if (state.armed && boundary === 'management-GET' && !state.discoveryFailed) { state.discoveryFailed = true; state.faults++; }
|
||
+ res.end(JSON.stringify(state.keys)); return;
|
||
+ }
|
||
+ const chunks = []; for await (const chunk of req) chunks.push(chunk);
|
||
+ state.keys = JSON.parse(Buffer.concat(chunks));
|
||
+ res.end(JSON.stringify({ status: 'ok' })); return;
|
||
+ }
|
||
+ const status = state.keys.includes(req.headers.authorization?.slice(7)) ? 200 : 401;
|
||
+ if (state.armed && boundary === 'confirmed-401' && status === 401) { state.discoveryFailed = true; state.faults++; }
|
||
+ res.writeHead(status); res.end('{}');
|
||
+ });
|
||
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||
+ const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||
+ credentials = new Credentials({ storeRoot, dataRoot: join(root, 'data'), modelAgents: ['demo/chief'] });
|
||
+ const manager = new ModelKeys({ credentials, configDir, baseUrl,
|
||
+ client: new ManagementClient({ baseUrl, key: 'fx647-manage', receipt: entry => receipts.push(entry) }),
|
||
+ receipt: entry => receipts.push(entry),
|
||
+ // Fault models authoritative discovery becoming uncertain. Do not replace the
|
||
+ // controller, mutation method, HTTP operation or private-record implementation.
|
||
+ runs: () => { if (state.discoveryFailed) { state.refusals++; throw new BusError('proxy-stop-failed'); } return []; } });
|
||
+ await manager.reconcile(); const first = await manager.ensure('demo/chief'); await manager.rotate('demo/chief');
|
||
+ state.calls.length = 0; receipts.length = 0; state.armed = true;
|
||
+ return { state, receipts, credentials, manager, oldKey: first.key, oldId: id(first.key),
|
||
+ retained: () => assert.equal(credentials.modelRecord('demo/chief').retiring.some(r => r.keyId === id(first.key)), true, 'local retiring record retained'),
|
||
+ valueFree: () => {
|
||
+ const output = JSON.stringify(receipts);
|
||
+ assert.equal(output.includes(first.key), false);
|
||
+ assert.equal(output.includes('fx647-manage'), false);
|
||
+ assert.equal(output.includes(credentials.modelRecord('demo/chief').key), false);
|
||
+ } };
|
||
+}
|
||
+
|
||
+test('B1 discovery failure during fresh management GET refuses before retirement PUT', async t => {
|
||
+ const f = await fixture(t, 'management-GET');
|
||
+ await assert.rejects(f.manager.sweep(), error => error.code === 'proxy-stop-failed', 'fresh discovery refusal required before remote mutation');
|
||
+ assert.equal(f.state.faults, 1); assert.equal(f.state.refusals, 1);
|
||
+ assert.deepEqual(f.state.calls, ['GET'], 'no removal PUT or extra mutation after discovery failure');
|
||
+ assert.equal(f.state.keys.includes(f.oldKey), true, 'remote old key retained');
|
||
+ f.retained(); f.valueFree();
|
||
+});
|
||
+
|
||
+test('B1 discovery failure during confirmed401 refuses before retiring metadata deletion', async t => {
|
||
+ const f = await fixture(t, 'confirmed-401');
|
||
+ await assert.rejects(f.manager.sweep(), error => error.code === 'proxy-stop-failed', 'fresh discovery refusal required before local deletion');
|
||
+ assert.equal(f.state.faults, 1); assert.equal(f.state.refusals, 1);
|
||
+ assert.deepEqual(f.state.calls, ['GET', 'PUT', 'GET', 'PROBE'], 'one remote revoke, no rollback or extra mutation');
|
||
+ assert.equal(f.state.keys.includes(f.oldKey), false, 'remote key already revoked; no rollback claim');
|
||
+ assert.ok(f.receipts.some(entry => entry.call === 'GET /v1/models' && entry.status === 401 && entry.keyIds.includes(f.oldId)), 'observed remote401 receipt');
|
||
+ f.retained(); f.valueFree();
|
||
+});
|
||
diff --git a/packages/bus/tests/model-review-fixtures.mjs b/packages/bus/tests/model-review-fixtures.mjs
|
||
new file mode 100644
|
||
index 000000000..13c78ea9d
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-review-fixtures.mjs
|
||
@@ -0,0 +1,156 @@
|
||
+// Test-only review fixtures cloned from B1; old fixture/assertions remain unchanged.
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import http from 'node:http';
|
||
+import { spawn } from 'node:child_process';
|
||
+import { once } from 'node:events';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { createHash, randomBytes } from 'node:crypto';
|
||
+import { setTimeout as wait } from 'node:timers/promises';
|
||
+import { startBroker } from '../src/runtime.mjs';
|
||
+import { Credentials } from '../src/credentials.mjs';
|
||
+import { ModelKeys } from '../src/model-keys.mjs';
|
||
+import { ManagementClient } from '../../model-proxy/src/client.mjs';
|
||
+import { busBusiness } from '../src/business.mjs';
|
||
+import { loadBusiness } from '../../business/src/business.mjs';
|
||
+import { resolveInstance } from '../../business/src/resolve.mjs';
|
||
+import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../business/tests/helpers.mjs';
|
||
+import { startTimeOf } from '../../seat/src/proc.mjs';
|
||
+import { Registry, SESSIONS_VERSION, sessionsFile } from '../../seat/src/session.mjs';
|
||
+import { createLauncher } from '../../cli/src/launcher.mjs';
|
||
+const id = value => createHash('sha256').update(value).digest('hex').slice(0, 12);
|
||
+
|
||
+export async function reviewFixture(t, { overdue = false, exited = false } = {}) {
|
||
+ const root = fs.mkdtempSync(join(tmpdir(), 'b1-'));
|
||
+ let server, credentials, child, childClosed, runner, runnerClosed, bus, launcher;
|
||
+ let completed = 0, begun = 0, sweptManager = null;
|
||
+ const sweepObservations = [];
|
||
+ const originalSweep = ModelKeys.prototype.sweep;
|
||
+ const exitOne = async (process, closed) => {
|
||
+ if (process?.pid && process.exitCode === null && process.signalCode === null) process.kill('SIGTERM');
|
||
+ if (closed) await closed;
|
||
+ };
|
||
+ const exitOuter = () => exitOne(child, childClosed);
|
||
+ const exitRunner = () => exitOne(runner, runnerClosed);
|
||
+ const exit = async () => { await exitOuter(); await exitRunner(); };
|
||
+ t.after(async () => {
|
||
+ const errors = [];
|
||
+ for (const close of [() => launcher?.close(), () => bus?.close(), () => credentials?.close(), exit,
|
||
+ async () => { server?.closeAllConnections(); if (server?.listening) await new Promise(resolve => server.close(resolve)); }]) {
|
||
+ try { await close(); } catch (error) { errors.push(error); }
|
||
+ }
|
||
+ ModelKeys.prototype.sweep = originalSweep;
|
||
+ fs.rmSync(root, { recursive: true, force: true });
|
||
+ if (errors.length) throw new AggregateError(errors, 'scratch fixture cleanup failed');
|
||
+ });
|
||
+ // Await the ORIGINAL sweep, including its real HTTP/filesystem work. Count both
|
||
+ // fulfilled and rejected completions; the runtime still handles its own refusal.
|
||
+ ModelKeys.prototype.sweep = async function (...args) {
|
||
+ const epoch = ++begun; sweptManager = this;
|
||
+ let outcome = 'fulfilled';
|
||
+ try { return await originalSweep.apply(this, args); }
|
||
+ catch (error) { outcome = error.code === 'proxy-stop-failed' ? 'proxy-stop-failed' : 'other-refusal'; throw error; }
|
||
+ finally { completed++; sweepObservations.push({ epoch, outcome }); }
|
||
+ };
|
||
+ const observeSweep = async () => {
|
||
+ const before = completed, deadline = Date.now() + 5000;
|
||
+ // Same five-second bounded wait budget as bus/tests/host-run.test.mjs:47.
|
||
+ // Production interval remains 1000ms; no clock or scheduler is replaced.
|
||
+ while (completed <= before && Date.now() < deadline) await wait(20);
|
||
+ assert.ok(completed > before, 'original runtime sweep completed within five seconds');
|
||
+ };
|
||
+ // #652 waits for a sweep STARTED after its setup phase, then fully completed.
|
||
+ // It observes the unchanged original method, never calls/replaces its work.
|
||
+ const observeNextSweep = async () => {
|
||
+ const after = begun, deadline = Date.now() + 5000;
|
||
+ while (!sweepObservations.some(s => s.epoch > after) && Date.now() < deadline) await wait(20);
|
||
+ const observation = sweepObservations.find(s => s.epoch > after);
|
||
+ assert.ok(observation, 'phase-specific original runtime sweep completed within five seconds');
|
||
+ return observation;
|
||
+ };
|
||
+ const configDir = join(root, 'config'), dataRoot = join(root, 'data');
|
||
+ fs.mkdirSync(dataRoot, { mode: 0o700 });
|
||
+ const config = systemConfig(root), system = systemFor(root), rolesDir = rolesCopy(root);
|
||
+ const doc = businessDoc(root, 'demo'); doc.plugins = { ...doc.plugins, harness: ['pi'] };
|
||
+ writeJson(join(configDir, 'businesses', 'demo.json'), doc);
|
||
+ const business = loadBusiness('demo', { dir: configDir, rolesDir }), resolved = {};
|
||
+ for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system, business, instance });
|
||
+ const storeRoot = join(configDir, 'secrets', 'agents'); fs.mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||
+ fs.writeFileSync(join(configDir, 'secrets', 'proxy-management.key'), 'fx647-manage', { mode: 0o600 });
|
||
+ const prefix = `msk-${id(configDir)}-`;
|
||
+ const state = { failGet: false, failPut: false, hideSentinel: false, keys: [prefix + 'sentinel-' + randomBytes(32).toString('base64url')], put: 0, removed: 0 };
|
||
+ server = http.createServer(async (req, res) => {
|
||
+ if (req.url === '/v8/management/config/access/api-keys') {
|
||
+ if (req.headers.authorization !== 'Bearer fx647-manage') { res.writeHead(401); res.end('{}'); return; }
|
||
+ if ((req.method === 'GET' && state.failGet) || (req.method === 'PUT' && state.failPut)) { res.writeHead(503); res.end('{}'); return; }
|
||
+ if (req.method === 'GET') { res.end(JSON.stringify(state.hideSentinel ? state.keys.filter(k => !k.includes('-sentinel-')) : state.keys)); return; }
|
||
+ const chunks = []; for await (const chunk of req) chunks.push(chunk);
|
||
+ const next = JSON.parse(Buffer.concat(chunks));
|
||
+ state.removed += state.keys.filter(key => !next.includes(key)).length;
|
||
+ state.keys = next; state.put++; res.end(JSON.stringify({ status: 'ok' })); return;
|
||
+ }
|
||
+ res.writeHead(state.keys.includes(req.headers.authorization?.slice(7)) ? 200 : 401); res.end('{}');
|
||
+ });
|
||
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||
+ const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||
+ credentials = new Credentials({ storeRoot, dataRoot, modelAgents: ['demo/chief'] });
|
||
+ const manager = new ModelKeys({ credentials, configDir, baseUrl, client: new ManagementClient({ baseUrl, key: 'fx647-manage' }) });
|
||
+ await manager.reconcile(); const first = await manager.ensure('demo/chief'); await manager.rotate('demo/chief');
|
||
+ const record = credentials.modelRecord('demo/chief');
|
||
+ credentials.writeModel('demo/chief', { ...record, retiring: record.retiring.map(r => ({ ...r, retireAt: new Date(Date.now() + (overdue ? -60_000 : 3_600_000)).toISOString() })) });
|
||
+ credentials.close(); credentials = null; state.put = 0; state.removed = 0;
|
||
+ // A harmless scratch-owned process supplies real /proc identity. It is deliberately
|
||
+ // not a session runner, so stopSession must refuse rather than signal this stand-in.
|
||
+ child = spawn('/usr/bin/sleep', ['120'], { stdio: 'ignore' });
|
||
+ childClosed = new Promise(resolve => child.once('close', resolve));
|
||
+ await once(child, 'spawn');
|
||
+ const ticks = startTimeOf(child.pid); assert.equal(typeof ticks, 'string');
|
||
+ runner = spawn('/usr/bin/sleep', ['120'], { stdio: 'ignore' });
|
||
+ runnerClosed = new Promise(resolve => runner.once('close', resolve));
|
||
+ await once(runner, 'spawn');
|
||
+ const runnerTicks = startTimeOf(runner.pid); assert.equal(typeof runnerTicks, 'string');
|
||
+ const entry = { business: 'demo', instance: 'chief', run: 'r647-old', harness: 'pi', provider: 'fake', model: 'fake',
|
||
+ pid: child.pid, startTime: ticks, runnerPid: runner.pid, runnerStartTime: runnerTicks, modelKeyId: id(first.key), launchedBy: 'chief' };
|
||
+ const writeSessions = entries => writeJson(sessionsFile(dataRoot), { sessionsVersion: SESSIONS_VERSION, sessions: entries });
|
||
+ writeSessions([entry]);
|
||
+ if (exited) await exit();
|
||
+ const boot = async ({ proxyMode = true } = {}) => {
|
||
+ bus = await startBroker({ dataRoot, businesses: { demo: busBusiness(business, resolved) }, organization: { dir: configDir, rolesDir, system },
|
||
+ proxy: proxyMode ? { port: server.address().port, binary: '/fixture-unused' } : null });
|
||
+ return bus;
|
||
+ };
|
||
+ const noLaunch = async () => {
|
||
+ const ops = [];
|
||
+ const host = { business: 'demo', beforeClose() {}, bindLaunch: async r => { ops.push('bindLaunch'); return bus.bindLaunch(r); },
|
||
+ op: async r => { ops.push(r.op); throw Error('fixture-unrequested-op'); } };
|
||
+ launcher = createLauncher({ host, system: JSON.parse(fs.readFileSync(config, 'utf8')), launches: false, env: { MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesDir, PATH: '/usr/bin' }, log: () => {} });
|
||
+ await launcher.listen(); return ops;
|
||
+ };
|
||
+ const retained = () => {
|
||
+ assert.equal(state.keys.includes(first.key), true, 'old key stays remotely installed');
|
||
+ assert.equal(bus.credentials.modelRecord('demo/chief').retiring.some(r => r.keyId === entry.modelKeyId), true, 'retiring id stays locally recorded');
|
||
+ assert.equal(state.removed, 0, 'no remote key removal');
|
||
+ };
|
||
+ return { root, configDir, config, rolesDir, system, dataRoot, state, entry, writeSessions, boot, noLaunch, exit, exitOuter, exitRunner, retained, observeSweep, observeNextSweep,
|
||
+ get sweptManager() { return sweptManager; },
|
||
+ get bus() { return bus; },
|
||
+ attachLauncher: value => { launcher = value; },
|
||
+ closeBus: async () => { await bus?.close(); bus = null; },
|
||
+ setModel: patch => {
|
||
+ const own = new Credentials({ storeRoot, dataRoot, modelAgents: ['demo/chief'] });
|
||
+ try { own.writeModel('demo/chief', { ...own.modelRecord('demo/chief'), ...patch }); } finally { own.close(); }
|
||
+ },
|
||
+ nextGeneration: () => {
|
||
+ const previous = bus.credentials.modelRecord('demo/chief'), key = prefix + randomBytes(32).toString('base64url');
|
||
+ state.keys.push(key);
|
||
+ bus.credentials.writeModel('demo/chief', { ...previous, key, keyId: id(key), issuedAt: new Date().toISOString(),
|
||
+ retiring: [{ key: previous.key, keyId: previous.keyId, issuedAt: previous.issuedAt, retireAt: new Date(Date.now() - 60000).toISOString() }] });
|
||
+ return { removed: () => assert.equal(state.keys.includes(previous.key), false, 'later generation retiring key removed after confirmed terminal pair'),
|
||
+ retained: () => assert.equal(state.keys.includes(previous.key), true, 'later generation retiring key retained on disagreement') };
|
||
+ },
|
||
+ alive: () => startTimeOf(child.pid) === ticks,
|
||
+ absent: () => { assert.equal(state.keys.includes(first.key), false); assert.equal(bus.credentials.modelRecord('demo/chief').retiring.length, 0); },
|
||
+ bind: () => bus.bindLaunch({ business: entry.business, role: entry.instance, run: entry.run, harness: entry.harness, pid: entry.pid, startTime: entry.startTime, modelKeyId: entry.modelKeyId }) };
|
||
+}
|
||
+
|
||
diff --git a/packages/bus/tests/model-revocation-fifo-child.mjs b/packages/bus/tests/model-revocation-fifo-child.mjs
|
||
new file mode 100644
|
||
index 000000000..af4e3ba6b
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-revocation-fifo-child.mjs
|
||
@@ -0,0 +1,18 @@
|
||
+// Owned bounded child ONLY for #656 FIFO nonblocking control, not a production entry.
|
||
+import fs from 'node:fs';
|
||
+import { spawnSync } from 'node:child_process';
|
||
+import { getPriority, setPriority } from 'node:os';
|
||
+import { revocationFixture, outcome } from './model-revocation-fixture.mjs';
|
||
+setPriority(0,19);
|
||
+const cleanup=[];let answer;
|
||
+try {
|
||
+ const f=await revocationFixture({after:close=>cleanup.push(close)},{onRoot:root=>process.send?.({kind:'owned-root',root})});
|
||
+ f.seed();fs.unlinkSync(f.temporary);const made=spawnSync('/usr/bin/mkfifo',['-m','600',f.temporary],{stdio:'ignore',timeout:5000,killSignal:'SIGTERM'});
|
||
+ if(made.status!==0)throw Error('owned fifo setup failed');
|
||
+ await new Promise((resolve,reject)=>process.send({kind:'ready',nice:getPriority(0)},error=>error?reject(error):resolve()));
|
||
+ const began=Date.now(),result=await outcome(async()=>{await f.prepare();f.complete();});
|
||
+ answer={kind:'result',ok:result.ok,code:result.code??null,cachedRevoking:f.record()?.state==='revoking',finalPresent:f.exists(f.file),tmpPresent:f.exists(f.temporary),openModelFds:f.openModelFds(),elapsedMs:Date.now()-began,nice:getPriority(0)};f.valueFree();
|
||
+}catch{answer={kind:'error',code:'owned-fifo-fixture-failed'};process.exitCode=1;}
|
||
+let clean=true;for(const close of [...cleanup].reverse()){try{await close();}catch{clean=false;}}
|
||
+if(!clean){answer={kind:'error',code:'owned-fifo-cleanup-failed'};process.exitCode=1;}
|
||
+if(process.connected){await new Promise(resolve=>process.send({...answer,ownedCleanup:clean},()=>resolve()));process.disconnect();}
|
||
diff --git a/packages/bus/tests/model-revocation-fixture.mjs b/packages/bus/tests/model-revocation-fixture.mjs
|
||
new file mode 100644
|
||
index 000000000..5c8581db7
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-revocation-fixture.mjs
|
||
@@ -0,0 +1,86 @@
|
||
+// #656 STATIC isolated fixtures. No real config/key/proxy; no production seam.
|
||
+import fs from 'node:fs';
|
||
+import http from 'node:http';
|
||
+import assert from 'node:assert/strict';
|
||
+import { syncBuiltinESMExports } from 'node:module';
|
||
+import { randomBytes, createHash } from 'node:crypto';
|
||
+import { spawn } from 'node:child_process';
|
||
+import { once } from 'node:events';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { Credentials } from '../src/credentials.mjs';
|
||
+import { ModelKeys } from '../src/model-keys.mjs';
|
||
+import { ManagementClient } from '../../model-proxy/src/client.mjs';
|
||
+import { startBroker } from '../src/runtime.mjs';
|
||
+import { busBusiness } from '../src/business.mjs';
|
||
+import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||
+import { loadBusiness } from '../../business/src/business.mjs';
|
||
+import { resolveInstance } from '../../business/src/resolve.mjs';
|
||
+import { organizationWriter } from '../../business/src/organization.mjs';
|
||
+import { startTimeOf } from '../../seat/src/proc.mjs';
|
||
+import { sessionsFile, SESSIONS_VERSION, Registry } from '../../seat/src/session.mjs';
|
||
+export const id = value => createHash('sha256').update(value).digest('hex').slice(0,12);
|
||
+export async function bounded(promise,message,ms=5000){let timer;try{return await Promise.race([promise,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error(message)),ms);})]);}finally{clearTimeout(timer);}}
|
||
+export async function outcome(operation){try{return {ok:true,value:await operation()};}catch(error){return {ok:false,code:error.code??'unexpected-error'};}}
|
||
+export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBoot=false}={}){
|
||
+ const root=fs.mkdtempSync(join(tmpdir(),'r656-')),configDir=join(root,'config'),dataRoot=join(root,'data'),storeRoot=join(configDir,'secrets','agents'),instance='demo/aide';
|
||
+ const file=join(storeRoot,'demo','aide','model'),temporary=file+'.tmp';
|
||
+ const history=new Set(['fx656-manage']),receipts=[],proofs=[],active=new Set(),closers=[],children=[],fdPaths=new Map();
|
||
+ let server,c,manager,bus,stderr,currentBusClose,restoreFs;
|
||
+ const state={keys:[],calls:[],probes:[],forceProbe:new Map(),failPut:false,failGet:false,onProbe:null,callbackFailed:false,failView:false,liveView:[]};
|
||
+ const faults={unlinkService:false,unlinkTmp:false,unlinkFinal:false,readFinal:false,absenceTmp:false,procRead:null};const counts={procRead:0,readFinal:0,absenceTmp:0,unlinkService:0,unlinkTmp:0,unlinkFinal:0};let removedTmp=false;
|
||
+ const valueFree=()=>{const text=JSON.stringify(receipts);for(const value of history)if(text.includes(value))throw Error('fixture credential leaked');};
|
||
+ t.after(async()=>{const errors=[];try{await bounded(Promise.allSettled([...active]),'owned operation cleanup timeout');}catch{errors.push('owned-operations');}try{restoreFs?.();}catch{errors.push('owned-fs-restore');}for(const child of children){try{if(child.handle.exitCode===null&&child.handle.signalCode===null)child.handle.kill('SIGTERM');await bounded(child.closed,'owned child cleanup timeout');}catch{errors.push('owned-child');}}for(const close of [...closers].reverse()){try{await close();}catch{errors.push('owned-resource');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{errors.push('owned-server');}try{valueFree();}catch{errors.push('value-free-receipts');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{errors.push('owned-root');}if(errors.length)throw new AggregateError(errors.map(code=>Error(code)),'owned revocation fixture cleanup failed');});
|
||
+ onRoot(root);
|
||
+ fs.mkdirSync(dataRoot,{mode:0o700});fs.mkdirSync(storeRoot,{recursive:true,mode:0o700});fs.mkdirSync(join(dataRoot,'bus-host'),{mode:0o700});assert.equal(fs.existsSync(sessionsFile(dataRoot)),false,'fresh owned data root has no prior sessions');if(!missingBoot){const registry=new Registry(dataRoot);assert.deepEqual(registry.sessions,[]);assert.equal(fs.statSync(sessionsFile(dataRoot)).mode&0o777,0o600);}
|
||
+ const prefix=`msk-${id(configDir)}-`,key=()=>{const value=prefix+randomBytes(32).toString('base64url');history.add(value);return value;};const sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);state.keys.push(sentinel);
|
||
+ server=http.createServer(async(req,res)=>{try{
|
||
+ const supplied=req.headers.authorization?.slice(7);if(supplied)history.add(supplied);for(const value of state.keys)history.add(value);
|
||
+ if(req.url==='/v8/management/config/access/api-keys'){
|
||
+ state.calls.push(req.method);if(supplied!=='fx656-manage'){res.writeHead(401);res.end('{}');return;}
|
||
+ let next;if(req.method==='PUT'){const chunks=[];for await(const chunk of req)chunks.push(chunk);next=JSON.parse(Buffer.concat(chunks));for(const value of next)history.add(value);}
|
||
+ if((req.method==='PUT'&&state.failPut)||(req.method==='GET'&&state.failGet)){res.writeHead(503);res.end('{}');return;}
|
||
+ if(req.method==='GET'){res.end(JSON.stringify(state.keys));return;}state.keys=next;res.end('{"status":"ok"}');return;
|
||
+ }
|
||
+ const status=state.forceProbe.get(supplied)??(state.keys.includes(supplied)?200:401);state.calls.push('PROBE');state.probes.push({keyId:id(supplied??''),status});
|
||
+ if(status===401&&state.onProbe){const callback=state.onProbe;state.onProbe=null;try{callback();}catch{state.callbackFailed=true;res.writeHead(500);res.end('{}');return;}}
|
||
+ res.writeHead(status);res.end('{}');
|
||
+ }catch{if(!res.destroyed){res.writeHead(500);res.end('{}');}}});await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});const port=server.address().port;assert.notEqual(port,8317);const baseUrl='http://127.0.0.1:'+port;
|
||
+ fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx656-manage',{mode:0o600});
|
||
+ const track=promise=>{active.add(promise);promise.then(()=>active.delete(promise),()=>active.delete(promise));return promise;};
|
||
+ let boot,writer,chief,human,busBoots=0;
|
||
+ if(runtime){
|
||
+ const rolesDir=rolesCopy(root),system=systemFor(root),doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('aide');
|
||
+ fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
|
||
+ const business=loadBusiness('demo',{dir:configDir,rolesDir}),resolved={};for(const name of Object.keys(business.roles))resolved[name]=resolveInstance({system,business,instance:name});
|
||
+ boot={dataRoot,businesses:{demo:busBusiness(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir});
|
||
+ stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){receipts.push(String(chunk));args.find(a=>typeof a==='function')?.();return true;}return stderr.call(this,chunk,...args);};
|
||
+ const openBus=async()=>{bus=await startBroker(boot);const ownedBus=bus,run='tmp-chief-'+(++busBoots);let closed=false,bound=false;currentBusClose=async()=>{if(closed)return;closed=true;const errors=[];if(bound){try{ownedBus.endLaunch({business:'demo',run,reason:'host-lost',exitCode:null});}catch{errors.push('owned-chief-end');}}try{await ownedBus.close();}catch{errors.push('owned-bus-close');}if(errors.length)throw new AggregateError(errors.map(code=>Error(code)),'owned bus cleanup failed');};closers.push(currentBusClose);c=bus.credentials;chief=bus.broker.bindLaunch({business:'demo',role:'chief',run,harness:'pi'});bound=true;bus.broker.request(chief,{verb:'role.claim'});human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});};
|
||
+ await openBus();boot.openBus=openBus;
|
||
+ }else{
|
||
+ // Unit proof/bookkeeping tests use the existing constructor callback with no
|
||
+ // processes. Actual identity/live/unbound cases below use REAL runtime inventory.
|
||
+ class ObservedCredentials extends Credentials {captureModelRevocation(...args){const result=super.captureModelRevocation(...args);proofs.push(result);return result;}}
|
||
+ c=new ObservedCredentials({storeRoot,dataRoot,modelAgents:[instance,'demo/other']});closers.push(()=>c.close());
|
||
+ manager=new ModelKeys({credentials:c,configDir,baseUrl,client:new ManagementClient({baseUrl,key:'fx656-manage',receipt:r=>receipts.push(r)}),receipt:r=>receipts.push(r),runs:()=>{if(state.failView)throw Error('owned uncertain view');return state.liveView;}});await manager.reconcile();
|
||
+ }
|
||
+ const original={openSync:fs.openSync,closeSync:fs.closeSync,readSync:fs.readSync,readFileSync:fs.readFileSync,unlinkSync:fs.unlinkSync,lstatSync:fs.lstatSync};
|
||
+ fs.openSync=function(path,...args){const fd=original.openSync(path,...args);if(path===file||path===temporary)fdPaths.set(fd,path);return fd;};
|
||
+ fs.closeSync=function(fd,...args){fdPaths.delete(fd);return original.closeSync(fd,...args);};
|
||
+ fs.readSync=function(fd,...args){if(faults.readFinal&&fdPaths.get(fd)===file){counts.readFinal++;throw Object.assign(Error('owned read refused'),{code:'EACCES'});}return original.readSync(fd,...args);};
|
||
+ fs.readFileSync=function(path,...args){if(faults.procRead&&path==='/proc/'+faults.procRead+'/stat'){counts.procRead++;throw Object.assign(Error('owned proc read refused'),{code:'EACCES'});}return original.readFileSync(path,...args);};
|
||
+ fs.unlinkSync=function(path,...args){if(path===join(storeRoot,'demo','aide','gitea')){counts.unlinkService++;if(faults.unlinkService)throw Object.assign(Error('owned service unlink refused'),{code:'EACCES'});}if(path===file){counts.unlinkFinal++;if(faults.unlinkFinal)throw Object.assign(Error('owned unlink refused'),{code:'EACCES'});}if(path===temporary){counts.unlinkTmp++;if(faults.unlinkTmp)throw Object.assign(Error('owned unlink refused'),{code:'EACCES'});}const result=original.unlinkSync(path,...args);if(path===temporary)removedTmp=true;return result;};
|
||
+ fs.lstatSync=function(path,...args){if(path===temporary&&faults.absenceTmp&&removedTmp){counts.absenceTmp++;throw Object.assign(Error('owned absence read refused'),{code:'EIO'});}return original.lstatSync(path,...args);};
|
||
+ syncBuiltinESMExports();restoreFs=()=>{Object.assign(fs,original);syncBuiltinESMExports();};
|
||
+ const record=()=>c.modelRecord(instance),exists=path=>{try{original.lstatSync(path);return true;}catch(error){if(error.code==='ENOENT')return false;throw error;}};
|
||
+ const seed=({matchingTmp=true,retiring=0,valid=false}={})=>{const current=key(),old=Array.from({length:retiring},()=>({key:key(),issuedAt:'2026-10-23T00:00:00.000Z',retireAt:'2099-01-01T00:00:00.000Z'})).map(r=>({...r,keyId:id(r.key)}));const value={key:current,keyId:id(current),issuedAt:'2026-10-24T00:00:00.000Z',rotateBy:'2099-01-01',state:valid?'valid':'revoking',...(valid?{}:{revokingAt:'2026-10-25T00:00:00.000Z'}),retiring:old};state.keys.push(current,...old.map(r=>r.key));c.writeModel(instance,value);if(matchingTmp){fs.copyFileSync(file,temporary);fs.chmodSync(temporary,0o600);}removedTmp=false;return value;};
|
||
+ const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
|
||
+ const writeSessions=entries=>writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:entries});
|
||
+ const ownRun=async({wrongTicks=false,wrongKey=false,publish=true}={})=>{
|
||
+ const spawnOwn=async()=>{const handle=spawn('/usr/bin/sleep',['60'],{stdio:'ignore'}),closed=new Promise(resolve=>handle.once('close',resolve));const owned={handle,closed};children.push(owned);await once(handle,'spawn');const ticks=startTimeOf(handle.pid);assert.equal(typeof ticks,'string');return {...owned,ticks,exit:async()=>{if(handle.exitCode===null&&handle.signalCode===null)handle.kill('SIGTERM');await bounded(closed,'owned stand-in exit timeout');}};};
|
||
+ const outer=await spawnOwn(),runner=await spawnOwn();
|
||
+ const entry={business:'demo',instance:'aide',run:'tmp-unbound',harness:'pi',provider:'fake',model:'fake',pid:outer.handle.pid,startTime:wrongTicks?'1':outer.ticks,runnerPid:runner.handle.pid,runnerStartTime:runner.ticks,modelKeyId:wrongKey?(record().keyId==='ffffffffffff'?'000000000000':'ffffffffffff'):record().keyId,launchedBy:'chief'};if(publish)writeSessions([entry]);
|
||
+ return {entry,alive:()=>startTimeOf(outer.handle.pid)===outer.ticks&&startTimeOf(runner.handle.pid)===runner.ticks,exitOuter:outer.exit,exitRunner:runner.exit,exit:async()=>{await outer.exit();await runner.exit();}};
|
||
+ };
|
||
+ return {root,configDir,dataRoot,storeRoot,file,temporary,instance,remember:value=>history.add(value),state,faults,counts,proofs,valueFree,seed,record,exists,manager,makeKey:key,prepare:()=>manager.prepareRevoke(instance),complete:()=>manager.completeRevoke(instance),get credentials(){return c;},get bus(){return bus;},lifecycle,ownRun,writeSessions,openModelFds:()=>fdPaths.size,reboot:async()=>{await currentBusClose();await boot.openBus();},membershipHash:()=>id(fs.readFileSync(join(configDir,'businesses','demo.json'))),listed:()=>JSON.parse(fs.readFileSync(join(configDir,'businesses','demo.json'),'utf8')).launch.instances.includes('aide'),remoteHas:key=>state.keys.includes(key),assertHistoryGuard:()=>{try{const text=JSON.stringify({canary:sentinel});for(const value of history)if(text.includes(value))throw Error('fixture credential leaked');}catch(error){if(error.message==='fixture credential leaked')return;}throw Error('fixture history guard failed');}};
|
||
+}
|
||
diff --git a/packages/bus/tests/model-stop.test.mjs b/packages/bus/tests/model-stop.test.mjs
|
||
new file mode 100644
|
||
index 000000000..a580b2dc2
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-stop.test.mjs
|
||
@@ -0,0 +1,22 @@
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import { stopModelRuns, MODEL_STOP_TIMEOUT_MS } from '../src/model-keys.mjs';
|
||
+const run = { business: 'demo', role: 'coder', run: 'r-test', keyId: '0123456789ab', pid: 123, startTime: '456' };
|
||
+const entry = { business: 'demo', instance: 'coder', run: 'r-test', modelKeyId: '0123456789ab', pid: 123, startTime: '456', live: true };
|
||
+test('bounded proxy stop is five seconds by default', () => assert.equal(MODEL_STOP_TIMEOUT_MS, 5000));
|
||
+for (const patch of [{ instance: 'other' }, { modelKeyId: 'fedcba987654' }, { pid: 999 }, { startTime: '999' }]) test(`proxy-stop-failed: mismatched ${Object.keys(patch)[0]} is never signalled`, async () => {
|
||
+ let signals = 0; const receipts = [];
|
||
+ await assert.rejects(stopModelRuns({ runs: [run], sessions: () => [{ ...entry, ...patch }], stop: async () => { signals++; }, receipt: r => receipts.push(r) }), e => e.code === 'proxy-stop-failed');
|
||
+ assert.equal(signals, 0); assert.equal(receipts[0].code, 'proxy-stop-failed');
|
||
+});
|
||
+test('proxy-stop-failed: hung SIGTERM path is bounded and error text is not logged', async () => {
|
||
+ const receipts = [], start = performance.now();
|
||
+ await assert.rejects(stopModelRuns({ runs: [run], sessions: () => [entry], stop: () => new Promise(() => {}), receipt: r => receipts.push(r), timeoutMs: 20 }), e => e.code === 'proxy-stop-failed');
|
||
+ assert.ok(performance.now() - start < 1000);
|
||
+ assert.deepEqual(receipts, [{ action: 'proxy-rotation', code: 'proxy-stop-failed', keyIds: ['0123456789ab'], run: 'r-test' }]);
|
||
+});
|
||
+test('stop verifies the run exited and logs the proxy reason with ids only', async () => {
|
||
+ let live = true; const receipts = [];
|
||
+ await stopModelRuns({ runs: [run], sessions: () => [{ ...entry, live }], stop: async () => { live = false; return { stopped: true }; }, receipt: r => receipts.push(r), reason: 'proxy-stall' });
|
||
+ assert.deepEqual(receipts, [{ action: 'proxy-stall', keyIds: ['0123456789ab'], business: 'demo', agent: 'coder', run: 'r-test', outcome: 'stopped' }]);
|
||
+});
|
||
diff --git a/packages/bus/tests/model-sweep-isolation.test.mjs b/packages/bus/tests/model-sweep-isolation.test.mjs
|
||
new file mode 100644
|
||
index 000000000..c6f5b0870
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-sweep-isolation.test.mjs
|
||
@@ -0,0 +1,33 @@
|
||
+// L1/#651: real independent agent retirements, first failure still reported.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import { BusError } from '../src/broker.mjs';
|
||
+import { lifecycleFixture, id } from './model-lifecycle-fixtures.mjs';
|
||
+async function setup(t){const f=await lifecycleFixture(t),c=f.create();await c.manager.reconcile();const a=await c.manager.ensure('demo/coder'),b=await c.manager.ensure('demo/other');await c.manager.rotate('demo/coder');await c.manager.rotate('demo/other');f.state.runs=[{instance:'demo/coder',run:'blocked-own-run',keyId:id(a.key)}];f.advance(121*60_000);return {...f,...c,a,b};}
|
||
+test('L1 blocked first agent does not starve independent later retirement',async t=>{
|
||
+ const f=await setup(t);await assert.rejects(f.manager.sweep(),e=>e.code==='proxy-stop-failed');
|
||
+ assert.equal(f.state.keys.includes(f.b.key),false,'later independent key retired despite earlier failure');
|
||
+ assert.equal(f.credentials.modelRecord('demo/other').retiring.length,0);assert.equal(f.state.keys.includes(f.a.key),true);assert.equal(f.credentials.modelRecord('demo/coder').retiring.length,1);
|
||
+ assert.deepEqual(f.state.stopAttempts,['blocked-own-run']);f.valueFree();
|
||
+});
|
||
+test('L1 successful retry retains first refusal semantics without deleting blocked metadata',async t=>{
|
||
+ const f=await setup(t);await assert.rejects(f.manager.sweep(),e=>e.code==='proxy-stop-failed');
|
||
+ assert.ok(f.receipts.some(r=>r.action==='proxy.sweep'&&r.instance==='demo/coder'&&r.code==='proxy-stop-failed'),'failed instance has value-free sweep outcome');
|
||
+ f.state.stopFailure=false;await f.manager.sweep();assert.equal(f.state.keys.includes(f.a.key),false);assert.equal(f.state.keys.includes(f.b.key),false);f.valueFree();
|
||
+});
|
||
+test('L1 no-error sweep still completes both independent retirements',async t=>{
|
||
+ const f=await setup(t);f.assertHistoryGuard();f.state.runs=[];await f.manager.sweep();assert.equal(f.state.keys.includes(f.a.key),false);assert.equal(f.state.keys.includes(f.b.key),false);f.valueFree();
|
||
+});
|
||
+
|
||
+test('L1 preserves exact original first error after visiting later independently failing agent',async t=>{
|
||
+ const f=await setup(t),first=new BusError('proxy-stop-failed'),second=new BusError('proxy-down');
|
||
+ f.state.discoveryFaults.set('demo/coder',first);f.state.discoveryFaults.set('demo/other',second);
|
||
+ await assert.rejects(f.manager.sweep(),error=>error===first,'original first error identity retained');
|
||
+ assert.deepEqual(f.state.discoveries,['demo/coder','demo/other'],'later independent failure also visited');f.valueFree();
|
||
+});
|
||
+
|
||
+test('L1 repeated no-op sweeps produce no per-instance complete receipts',async t=>{
|
||
+ const f=await lifecycleFixture(t),c=f.create();await c.manager.reconcile();await c.manager.ensure('demo/coder');f.receipts.length=0;
|
||
+ await c.manager.sweep();await c.manager.sweep();await c.manager.sweep();
|
||
+ assert.equal(f.receipts.some(r=>r.action==='proxy.sweep'&&r.outcome==='complete'),false,'idle sweeps do not emit per-second success churn');assert.deepEqual(f.receipts,[]);f.valueFree();
|
||
+});
|
||
diff --git a/packages/bus/tests/model-terminal-proof.test.mjs b/packages/bus/tests/model-terminal-proof.test.mjs
|
||
new file mode 100644
|
||
index 000000000..0cdfd06dc
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-terminal-proof.test.mjs
|
||
@@ -0,0 +1,62 @@
|
||
+// M1/#648: deterministic synthetic reuse ONLY after actual owned-process exit.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { syncBuiltinESMExports } from 'node:module';
|
||
+import { reviewFixture } from './model-review-fixtures.mjs';
|
||
+
|
||
+function syntheticReuse(t, entries) {
|
||
+ const originalRead = fs.readFileSync, originalKill = process.kill;
|
||
+ const stats = new Map(entries.map(({ pid, startTime }) => {
|
||
+ const fields = ['S', ...Array(18).fill('0'), String(BigInt(startTime) + 1n)];
|
||
+ return [`/proc/${pid}/stat`, `${pid} (synthetic-scratch-reuse) ${fields.join(' ')}\n`];
|
||
+ }));
|
||
+ const protectedPids = new Set(entries.map(e => e.pid));
|
||
+ let signals = 0;
|
||
+ t.after(() => { fs.readFileSync = originalRead; process.kill = originalKill; syncBuiltinESMExports(); });
|
||
+ fs.readFileSync = function (file, ...args) {
|
||
+ if (typeof file === 'string' && stats.has(file)) return args[0] === 'utf8' ? stats.get(file) : Buffer.from(stats.get(file));
|
||
+ return originalRead.call(this, file, ...args);
|
||
+ };
|
||
+ process.kill = function (pid, ...args) {
|
||
+ if (protectedPids.has(pid)) { signals++; throw Error('synthetic identity must never be signalled'); }
|
||
+ return originalKill.call(this, pid, ...args);
|
||
+ };
|
||
+ syncBuiltinESMExports();
|
||
+ return () => assert.equal(signals, 0, 'no signal to synthetic reused identity');
|
||
+}
|
||
+
|
||
+test('M1 confirmed outer and runner exits stay terminal through stale registry and synthetic reuse for a later generation', async t => {
|
||
+ const f = await reviewFixture(t, { overdue: true }); await f.boot(); await f.observeSweep(); f.retained();
|
||
+ await f.exitOuter(); await f.observeSweep(); f.retained();
|
||
+ await f.exitRunner(); await f.observeSweep(); f.absent();
|
||
+ // Both actual owned children have exited/reaped and distinct completions observed.
|
||
+ // This synthetic proc view is NOT a claim of actual OS PID wraparound/reuse.
|
||
+ const noSignals = syntheticReuse(t, [{ pid: f.entry.pid, startTime: f.entry.startTime }, { pid: f.entry.runnerPid, startTime: f.entry.runnerStartTime }]);
|
||
+ f.writeSessions([f.entry]); // same stale immutable identity must not erase terminal proof
|
||
+ const removed = f.nextGeneration(); await f.observeSweep(); removed.removed(); noSignals();
|
||
+});
|
||
+
|
||
+test('M1 separately confirmed outer exit does not mark a live runner terminal', async t => {
|
||
+ const f = await reviewFixture(t, { overdue: true }); await f.boot(); await f.observeSweep();
|
||
+ await f.exitOuter(); await f.observeSweep(); f.retained();
|
||
+});
|
||
+for (const field of ['startTime', 'runnerStartTime', 'modelKeyId']) test(`M1 initial corrupt ${field} does not acquire terminal proof`, async t => {
|
||
+ const f = await reviewFixture(t, { overdue: true });
|
||
+ f.writeSessions([{ ...f.entry, [field]: field === 'modelKeyId' ? 'fedcba987654' : '0' }]);
|
||
+ await f.boot(); await f.observeSweep(); f.retained();
|
||
+});
|
||
+test('M1 terminal proof never authorizes a changed persisted key identity', async t => {
|
||
+ const f = await reviewFixture(t, { overdue: true }); await f.boot(); await f.observeSweep();
|
||
+ await f.exitOuter(); await f.observeSweep(); await f.exitRunner(); await f.observeSweep(); f.absent();
|
||
+ const removed = f.nextGeneration();
|
||
+ f.writeSessions([{ ...f.entry, modelKeyId: 'fedcba987654' }]);
|
||
+ await f.observeSweep();
|
||
+ removed.retained();
|
||
+});
|
||
+
|
||
+test('M1 an initially corrupt key on an exited pair cannot acquire terminal permission', async t => {
|
||
+ const f = await reviewFixture(t, { overdue: true, exited: true });
|
||
+ f.writeSessions([{ ...f.entry, modelKeyId: 'fedcba987654' }]);
|
||
+ await f.boot(); await f.observeSweep(); f.retained();
|
||
+});
|
||
diff --git a/packages/bus/tests/model-tmp-recovery.test.mjs b/packages/bus/tests/model-tmp-recovery.test.mjs
|
||
new file mode 100644
|
||
index 000000000..e281b1f09
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/model-tmp-recovery.test.mjs
|
||
@@ -0,0 +1,100 @@
|
||
+// #656 STATIC preparation: real runtime inventory + Broker/writer/controller/HTTP.
|
||
+// No code/test has run for this packet. Controlled faults are BEFORE verification.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { startTimeOf } from '../../seat/src/proc.mjs';
|
||
+import { revocationFixture, outcome, id } from './model-revocation-fixture.mjs';
|
||
+const timeout=30000;
|
||
+async function refused(f,how='fire'){const result=await outcome(()=>f.lifecycle(how));assert.equal(result.ok,false,'controlled lifecycle refuses');assert.equal(result.code,'credential-revoke-failed','existing lifecycle refusal code');assert.equal(f.state.callbackFailed,false,'owned transport mutation completed');return result;}
|
||
+function retained(f){assert.equal(f.record()?.state==='revoking',true,'uncertain matched recovery retains cached revoking intent');assert.equal(f.exists(f.file),true,'existing final path retained');assert.equal(f.exists(f.temporary),true,'uncertain tmp path retained');}
|
||
+
|
||
+for(const how of ['fire','delete'])test('656 matched durable revoking final and tmp '+how+' complete through real lifecycle',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true}),record=f.seed({retiring:2}),ids=[record.keyId,...record.retiring.map(r=>r.keyId)];
|
||
+ await assert.doesNotReject(()=>f.lifecycle(how),'matched intent '+how+' completes before membership publication');
|
||
+ assert.equal(f.counts.unlinkFinal,1,'composite '+how+' performs ONE final pathname unlink');assert.equal(f.record()===null,true);assert.equal(f.exists(f.file)||f.exists(f.temporary),false);assert.equal(f.listed(),false);assert.equal(ids.every(keyId=>f.state.probes.some(p=>p.keyId===keyId&&p.status===401)),true,'EVERY current and retiring key has confirmed401');assert.equal(f.openModelFds(),0);f.assertHistoryGuard();f.valueFree();
|
||
+});
|
||
+
|
||
+for(const how of ['fire','delete'])test('656 matched tmp unlink failure keeps durable cache and '+how+' retry completes',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed();const before=f.membershipHash();f.faults.unlinkTmp=true;await refused(f,how);retained(f);assert.equal(f.membershipHash(),before);await assert.rejects(f.bus.modelPreflight({business:'demo',role:'aide'}),e=>e.code==='proxy-key-revoking');
|
||
+ f.faults.unlinkTmp=false;await assert.doesNotReject(()=>f.lifecycle(how),'recognized tmp unlink retry completes');assert.equal(f.record()===null,true);assert.equal(f.exists(f.file)||f.exists(f.temporary),false);assert.equal(f.listed(),false);f.valueFree();
|
||
+});
|
||
+
|
||
+test('656 failed remote confirmation preserves matching intent and membership without rollback claim',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true}),record=f.seed({retiring:2}),before=f.membershipHash();f.state.failPut=true;await refused(f);retained(f);assert.equal(f.membershipHash(),before);assert.equal([record.key,...record.retiring.map(r=>r.key)].every(key=>f.remoteHas(key)),true);f.valueFree();
|
||
+});
|
||
+test('656 non401 last retiring key never authorizes local deletion',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true}),record=f.seed({retiring:2}),old=record.retiring.at(-1),before=f.membershipHash();f.state.forceProbe.set(old.key,200);await refused(f);retained(f);assert.equal(f.membershipHash(),before);assert.equal(f.state.probes.some(p=>p.keyId===old.keyId&&p.status===401),false);assert.equal([record.keyId,...record.retiring.map(r=>r.keyId)].every(keyId=>f.state.probes.some(p=>p.keyId===keyId)),true);f.valueFree();
|
||
+});
|
||
+
|
||
+for(const mode of ['live-unbound','partial-exit','wrong-ticks','wrong-key','proc-read-error'])test('656 '+mode+' real inventory refuses matching recovery without signalling',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed();const run=await f.ownRun({wrongTicks:mode==='wrong-ticks',wrongKey:mode==='wrong-key'});if(mode==='partial-exit')await run.exitOuter();if(mode==='proc-read-error')f.faults.procRead=run.entry.runnerPid;
|
||
+ const before=f.membershipHash(),puts=f.state.calls.filter(m=>m==='PUT').length;await refused(f);f.faults.procRead=null;retained(f);if(mode==='proc-read-error')assert.equal(f.counts.procRead>0,true,'owned proc read fault was reached');assert.equal(f.membershipHash(),before);assert.equal(f.state.calls.filter(m=>m==='PUT').length,puts,'known live or uncertain view causes no removal PUT');
|
||
+ if(mode==='partial-exit')assert.equal(startTimeOf(run.entry.runnerPid),run.entry.runnerStartTime,'live counterpart is retained');else assert.equal(run.alive(),true,'only owned stand-ins remain alive; discovery grants no stop authority');f.valueFree();
|
||
+});
|
||
+
|
||
+test('656 confirmed outer and runner exits permit matching intent recovery without rebind',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed();const run=await f.ownRun();await run.exit();await assert.doesNotReject(()=>f.lifecycle('fire'),'confirmed exited pair permits matched intent completion');assert.equal(f.record()===null,true);assert.equal(f.exists(f.file)||f.exists(f.temporary),false);assert.equal(f.listed(),false);f.valueFree();
|
||
+});
|
||
+test('656 malformed actual persisted registry holds matching intent',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed();f.writeSessions([]);fs.writeFileSync(join(f.dataRoot,'bus-host','sessions.json'),'not-json\n');const before=f.membershipHash();await refused(f);retained(f);assert.equal(f.membershipHash(),before);f.valueFree();
|
||
+});
|
||
+
|
||
+const negatives=[
|
||
+ ['tmp symlink',f=>{fs.unlinkSync(f.temporary);const target=join(f.root,'unrelated');fs.writeFileSync(target,'owned unrelated\n',{mode:0o600});fs.symlinkSync(target,f.temporary);return {path:target,hash:id('owned unrelated\n')};}],
|
||
+ ['tmp directory',f=>{fs.unlinkSync(f.temporary);fs.mkdirSync(f.temporary,{mode:0o700});}],
|
||
+ ['tmp wrong mode',f=>fs.chmodSync(f.temporary,0o644)],
|
||
+ ['tmp oversize',f=>fs.writeFileSync(f.temporary,'x'.repeat(16385),{mode:0o600})],
|
||
+ ['tmp partial JSON',f=>fs.writeFileSync(f.temporary,'{',{mode:0o600})],
|
||
+ ['tmp full metadata mismatch',f=>{const record=f.record();fs.writeFileSync(f.temporary,JSON.stringify({...record,rotateBy:'2098-01-01'})+'\n',{mode:0o600});}],
|
||
+ ['tmp hardlink alias',f=>{fs.unlinkSync(f.temporary);fs.linkSync(f.file,f.temporary);}],
|
||
+ ['final symlink',f=>{fs.unlinkSync(f.file);const target=join(f.root,'unrelated-final');fs.writeFileSync(target,'owned unrelated\n',{mode:0o600});fs.symlinkSync(target,f.file);return {path:target,hash:id('owned unrelated\n')};}],
|
||
+ ['final wrong mode',f=>fs.chmodSync(f.file,0o644)],
|
||
+ ['final malformed record',f=>fs.writeFileSync(f.file,'{',{mode:0o600})],
|
||
+ ['unknown extra entry',f=>fs.writeFileSync(join(f.root,'config','secrets','agents','demo','aide','unknown'),'owned unrelated\n',{mode:0o600})],
|
||
+];
|
||
+for(const [name,mutate] of negatives)test('656 '+name+' is not recognized matching recovery authority',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed();const unrelated=mutate(f);const before=f.membershipHash(),puts=f.state.calls.filter(m=>m==='PUT').length;await refused(f);retained(f);assert.equal(f.membershipHash(),before);assert.equal(f.state.calls.filter(m=>m==='PUT').length,puts);assert.equal(f.openModelFds(),0);if(unrelated)assert.equal(id(fs.readFileSync(unrelated.path)),unrelated.hash,'owned unrelated target is never followed or deleted');f.valueFree();
|
||
+});
|
||
+
|
||
+for(const target of ['tmp replacement','tmp content mutation','final replacement','final content mutation'])test('656 controlled '+target+' BEFORE final verification retains local intent',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed({retiring:1});let reached=false;
|
||
+ f.state.onProbe=()=>{reached=true;const path=target.startsWith('tmp')?f.temporary:f.file;if(target.includes('replacement')){const replacement=join(f.root,'owned-replacement');fs.writeFileSync(replacement,fs.readFileSync(path),{mode:0o600});fs.renameSync(replacement,path);}else{const value=f.record();fs.writeFileSync(path,JSON.stringify({...value,rotateBy:'2098-01-01'})+'\n',{mode:0o600});}};
|
||
+ const before=f.membershipHash();await refused(f);assert.equal(reached,true,'controlled fault actually ran before response401');retained(f);assert.equal(f.membershipHash(),before);assert.equal(f.openModelFds(),0);f.valueFree();
|
||
+});
|
||
+
|
||
+test('656 late actual registry publication after remote401 retains local intent without remote rollback',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true}),record=f.seed(),run=await f.ownRun({publish:false});let reached=false;f.state.onProbe=()=>{reached=true;f.writeSessions([run.entry]);};const before=f.membershipHash();await refused(f);assert.equal(reached,true);retained(f);assert.equal(f.membershipHash(),before);assert.equal(f.remoteHas(record.key),false,'late refusal does not pretend to roll back the remote operation');assert.equal(run.alive(),true);f.valueFree();
|
||
+});
|
||
+
|
||
+test('656 boot reconcile also removes strictly matched intent tmp only after every401',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true}),record=f.seed({retiring:2});await f.reboot();assert.equal(f.record()===null&&!f.exists(f.file)&&!f.exists(f.temporary),true,'boot reconcile completes recognized matching tmp recovery');assert.equal([record.keyId,...record.retiring.map(r=>r.keyId)].every(keyId=>f.state.probes.some(p=>p.keyId===keyId&&p.status===401)),true);f.valueFree();
|
||
+});
|
||
+test('656 active final rewrite self-repair remains a separate existing control',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed({valid:true});await f.lifecycle('fire');assert.equal(f.record()===null,true);assert.equal(f.exists(f.file)||f.exists(f.temporary),false);assert.equal(f.listed(),false);f.valueFree();
|
||
+});
|
||
+for(const how of ['fire','delete'])test('656 ordinary no-model no-tmp '+how+' stays ordinary idempotence',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});await f.lifecycle(how);assert.equal(f.record()===null,true);assert.equal(f.exists(f.file)||f.exists(f.temporary),false);assert.equal(f.listed(),false);f.valueFree();
|
||
+});
|
||
+test('656 unrelated service fence and bytes remain unchanged after model work',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed();const other=join(f.storeRoot,'demo','aide','gitea'),body='owned unrelated\n';fs.writeFileSync(other,body,{mode:0o600});const before=f.membershipHash();await refused(f);assert.equal(f.membershipHash(),before);assert.equal(id(fs.readFileSync(other)),id(body));f.valueFree();
|
||
+});
|
||
+
|
||
+test('656 ordinary present no-tmp intent with missing boot registry refuses before removal',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true,missingBoot:true}),record=f.seed({matchingTmp:false}),before=f.membershipHash(),puts=f.state.calls.filter(m=>m==='PUT').length;
|
||
+ const failure=await outcome(()=>f.lifecycle('fire'));assert.equal(!failure.ok&&failure.code==='credential-revoke-failed',true,'ordinary present intent with missing boot view refuses before removal');assert.equal(f.record()?.state,'revoking');assert.equal(f.exists(f.file),true);assert.equal(f.state.calls.filter(m=>m==='PUT').length,puts);assert.equal(f.remoteHas(record.key),true);assert.equal(f.membershipHash(),before);
|
||
+ f.writeSessions([]);await refused(f);assert.equal(f.state.calls.filter(m=>m==='PUT').length,puts,'later Registry empty does not repair failed boot proof');assert.equal(f.membershipHash(),before);f.valueFree();
|
||
+});
|
||
+test('656 authoritative owned empty boot permits ordinary present no-tmp lifecycle completion',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed({matchingTmp:false});await f.lifecycle('fire');assert.equal(f.record()===null,true);assert.equal(f.exists(f.file)||f.exists(f.temporary),false);assert.equal(f.listed(),false);f.valueFree();
|
||
+});
|
||
+for(const late of ['final unlink failure','tmp absence read failure'])test('656 '+late+' after tmp removal stays revoking then authoritative restart completes',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed();const before=f.membershipHash();if(late==='final unlink failure')f.faults.unlinkFinal=true;else f.faults.absenceTmp=true;
|
||
+ const failure=await outcome(()=>f.lifecycle('fire'));assert.equal(f.record()?.state==='revoking',true,'late recovery failure retains cached revoking fence');assert.equal(failure.ok,false);assert.equal(failure.code,'credential-revoke-failed');assert.equal(f.exists(f.file),true);assert.equal(f.exists(f.temporary),false,'tmp removal succeeded before this late refusal');assert.equal(f.membershipHash(),before);f.faults.unlinkFinal=false;f.faults.absenceTmp=false;
|
||
+ await f.reboot();assert.equal(f.record()===null,true);assert.equal(f.exists(f.file)||f.exists(f.temporary),false,'new authoritative boot reacquires strict proof, not vanished token');f.valueFree();
|
||
+});
|
||
+test('656 missing boot after tmp removed never turns remaining final into exit proof',{timeout},async t=>{
|
||
+ const f=await revocationFixture(t,{runtime:true});f.seed();f.faults.unlinkFinal=true;await outcome(()=>f.lifecycle('fire'));assert.equal(f.record()?.state==='revoking',true,'late recovery failure retains cached revoking fence');assert.equal(f.exists(f.temporary),false);f.faults.unlinkFinal=false;fs.unlinkSync(join(f.dataRoot,'bus-host','sessions.json'));const puts=f.state.calls.filter(m=>m==='PUT').length,before=f.membershipHash();await f.reboot();
|
||
+ assert.equal(f.record()?.state,'revoking');assert.equal(f.exists(f.file),true);assert.equal(f.state.calls.filter(m=>m==='PUT').length,puts,'failed boot refuses before new removal PUT');assert.equal(f.membershipHash(),before);await assert.rejects(f.bus.modelPreflight({business:'demo',role:'aide'}),e=>e.code==='proxy-key-revoking');f.writeSessions([]);await refused(f);assert.equal(f.state.calls.filter(m=>m==='PUT').length,puts);f.valueFree();
|
||
+});
|
||
diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs
|
||
index 2aed8876a..fe8622be1 100644
|
||
--- a/packages/bus/tests/project-runtime.test.mjs
|
||
+++ b/packages/bus/tests/project-runtime.test.mjs
|
||
@@ -14,6 +14,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
|
||
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
|
||
+ // Row 331 r5: parsing-only validation, individually mounted; no PIN or host state.
|
||
+ "import { validManagementKey, proxyTiming } from '../../model-proxy/src/validation.mjs';",
|
||
// Row 265 (#1770): pure room address parsing, mounted in RUNNER_FILES beside the broker.
|
||
"import { parseAddress } from './address.mjs';",
|
||
// Row 65 (#1558): bindLaunch checks a run's transcript path is absolute.
|
||
diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs
|
||
index a7035298a..25828527e 100644
|
||
--- a/packages/business/src/instance-settings.mjs
|
||
+++ b/packages/business/src/instance-settings.mjs
|
||
@@ -351,7 +351,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
|
||
// (declared, not listed) is listed again only with its own definition and no vars. The writer
|
||
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
|
||
- function hire(business, args, { actor, context }) {
|
||
+ function prepareHire(business, args) {
|
||
const { document: old, revision } = writer.read(business);
|
||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||
const { name, definition } = args;
|
||
@@ -380,6 +380,14 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||
// mos-1585: duplicates refuse before definition eligibility, with no file or credential effect.
|
||
definitionFor(rolesDir, definition);
|
||
next.launch.instances.push(name);
|
||
+ return { old, next, revision };
|
||
+ }
|
||
+ // Trusted read-only lifecycle preparation; the writer uses the same checks again.
|
||
+ function checkHire(business, args) {
|
||
+ return { revision: prepareHire(business, args).revision };
|
||
+ }
|
||
+ function hire(business, args, { actor, context }) {
|
||
+ const { old, next, revision } = prepareHire(business, args);
|
||
return commit(business, old, next, { revision, actor, context });
|
||
}
|
||
|
||
@@ -465,5 +473,5 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
|
||
}
|
||
|
||
- return { writer, read, set, checkDelete, checkFire, veto, hire, fire, grantScope, grantScopeRead };
|
||
+ return { writer, read, set, checkDelete, checkFire, checkHire, veto, hire, fire, grantScope, grantScopeRead };
|
||
}
|
||
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
|
||
index a20b34fff..7c3424b08 100644
|
||
--- a/packages/cli/src/cli.mjs
|
||
+++ b/packages/cli/src/cli.mjs
|
||
@@ -45,7 +45,7 @@ export const USAGE = `usage:
|
||
${LIMITS_USAGE}
|
||
${PLUGINS_USAGE}
|
||
${CREDENTIALS_USAGE}
|
||
- mosaic proxy fetch | init [--port N] [--business ID]
|
||
+ mosaic proxy fetch | init [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
|
||
${HOSTS_USAGE}
|
||
${GRANTS_USAGE}
|
||
${INSTANCES_USAGE}
|
||
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
|
||
index c59203c0d..556f09c69 100644
|
||
--- a/packages/cli/src/launcher.mjs
|
||
+++ b/packages/cli/src/launcher.mjs
|
||
@@ -139,7 +139,9 @@
|
||
// transaction; a later delete retries it.
|
||
|
||
import { createServer } from "node:net";
|
||
-import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||
+import { createHash } from 'node:crypto';
|
||
+import { watchModelOutput } from './model-stall.mjs';
|
||
+import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||
import { dirname, join, relative } from "node:path";
|
||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||
import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
||
@@ -507,6 +509,16 @@ export function createLauncher({
|
||
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,session,human}));
|
||
admission(admissionGeneration);
|
||
const { run, runDir } = newRun(dataRoot, b.id);
|
||
+ let proxy = null, modelKeyId;
|
||
+ if (system.proxy) {
|
||
+ try { proxy = await host.op({ op: 'modelPreflight', business: b.id, role: instance }); }
|
||
+ catch (error) { throw new Refusal(error.code ?? 'proxy-down', error.code ?? 'proxy-down'); }
|
||
+ if (!proxy || typeof proxy.key !== 'string' || proxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
|
||
+ modelKeyId = createHash('sha256').update(proxy.key).digest('hex').slice(0, 12);
|
||
+ // Internal opt-in only: no session.json, user environment or config field.
|
||
+ try { writeFileSync(join(runDir, 'proxy-first-turn-enabled'), 'enabled\n', { flag: 'wx', mode: 0o600 }); }
|
||
+ catch { throw new Refusal('launch-failed', 'first-turn protocol setup failed'); }
|
||
+ }
|
||
const sessionDir = store ? store.session : join(runDir, "session");
|
||
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
||
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
|
||
@@ -522,6 +534,7 @@ export function createLauncher({
|
||
const holding = holders?.filter((h) => h.instance === instance) ?? null;
|
||
const land = thread ? await host.op({ op: "landHeld", record: { business: b.id, role: instance, thread } }) : holding.length > 0;
|
||
const bundle = buildBundle({
|
||
+ ...(proxy ? { proxy } : {}),
|
||
dir: join(runDir, "bundle"),
|
||
resolved,
|
||
models: b.launch.models,
|
||
@@ -611,12 +624,13 @@ export function createLauncher({
|
||
admission(admissionGeneration);
|
||
if (!outerStart) throw new Error("the session process exited at once");
|
||
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
|
||
- 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, session } : {}) });
|
||
+ 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, session } : {}), ...(proxy ? { modelKeyId } : {}) });
|
||
bound = true;
|
||
admission(admissionGeneration);
|
||
entry = {
|
||
business: b.id, instance, ...(thread ? { thread, session, workspace } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
|
||
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
|
||
+ ...(proxy ? { modelKeyId } : {}),
|
||
};
|
||
registry.add(entry);
|
||
// Row 156: one start per decision, whichever launch wrote it into landing.
|
||
@@ -630,10 +644,14 @@ export function createLauncher({
|
||
spawnedAt.delete(run);
|
||
unregisterRuntime(dataRoot, managed.id);
|
||
if (e instanceof Refusal) throw e;
|
||
+ if (e.code?.startsWith('proxy-')) throw new Refusal(e.code, e.code);
|
||
throw new Refusal("launch-failed", e.message);
|
||
}
|
||
children.set(run, exited);
|
||
+ const outputWatch = proxy ? watchModelOutput({ runDir, armAtFirstTurn: true, keyId: modelKeyId, seconds: system.proxy.firstOutputSeconds ?? 120,
|
||
+ stop: () => host.op({ op: 'modelStall', business: b.id, role: instance, run, keyId: modelKeyId }), receipt: record }) : null;
|
||
exited.then(async ({ code, signal }) => {
|
||
+ outputWatch?.cancel();
|
||
let receipt;
|
||
try { receipt = runtime(dataRoot, managed.id); }
|
||
catch { log(`run ${run}: processing receipt unavailable at exit`); }
|
||
@@ -1191,14 +1209,13 @@ export function createLauncher({
|
||
log(`run ${s.run} (${s.instance}) from an earlier host still runs (pid ${s.pid}, runner ${s.runnerPid}): no session of ${s.instance} starts until it exits`);
|
||
}
|
||
try {
|
||
- await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}) });
|
||
+ await host.op({ op: 'recoverLaunch', record: { business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}) } });
|
||
} catch (e) {
|
||
// A host that died after ending the run but before updating sessions.json.
|
||
if (e.code === "run-ended") log(`run ${s.run} (${s.instance}) from an earlier host was already ended`);
|
||
- else log(`run ${s.run} (${s.instance}) from an earlier host: not ended, bindLaunch refused: ${e.code ?? e.message}`);
|
||
+ else log(`run ${s.run} (${s.instance}) from an earlier host: not ended, historical cleanup refused: ${e.code ?? e.message}`);
|
||
continue;
|
||
}
|
||
- await endRun(s.run, "host-lost", null);
|
||
record({ event: "end", instance: s.instance, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}), run: s.run, reason: "host-lost", exitCode: null, signal: null });
|
||
log(`run ${s.run} (${s.instance}) from an earlier host ended: host-lost`);
|
||
}
|
||
diff --git a/packages/cli/src/model-proxy.mjs b/packages/cli/src/model-proxy.mjs
|
||
index ab3882a41..079d2aa02 100644
|
||
--- a/packages/cli/src/model-proxy.mjs
|
||
+++ b/packages/cli/src/model-proxy.mjs
|
||
@@ -1,6 +1,7 @@
|
||
import { CliError } from './errors.mjs';
|
||
import { refuseInsideAgent } from './transport.mjs';
|
||
import { fetchPinned } from '../../model-proxy/src/fetch.mjs';
|
||
+import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
|
||
const failure = (code, exitCode = 2) => Object.assign(new CliError(code, exitCode), { code });
|
||
// A raw terminal suppresses echo; input is never argv, env, readline history or stdout.
|
||
export function promptProxyKey(io, label) {
|
||
@@ -31,14 +32,14 @@ export function promptProxyKey(io, label) {
|
||
export async function proxyCommand(args, io, { system, callFor, prompt = promptProxyKey, fetch = fetchPinned }) {
|
||
const [verb, ...rest] = args;
|
||
if (args.includes('--tester')) throw failure('human-required', 3);
|
||
- if (verb === 'init') {
|
||
+ if (verb === 'init' || verb === 'fetch') {
|
||
try { refuseInsideAgent(io.env); } catch { throw failure('human-required', 3); }
|
||
}
|
||
if (!['fetch', 'init'].includes(verb)) throw failure('proxy-usage', 4);
|
||
const parsed = { flags: new Set(), values: {} }; let port;
|
||
for (let i = 0; i < rest.length; i++) {
|
||
const flag = rest[i];
|
||
- if (verb !== 'init' || !['--business', '--port'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
|
||
+ if (verb !== 'init' || !['--business', '--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
|
||
parsed.values[flag] = rest[++i];
|
||
}
|
||
if (parsed.values['--port'] !== undefined) {
|
||
@@ -46,6 +47,10 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
|
||
port = Number(parsed.values['--port']);
|
||
if (port < 1024 || port > 65535 || port === 8317) throw failure('proxy-config-invalid');
|
||
}
|
||
+ const timing = {};
|
||
+ for (const [flag, field] of [['--rotate-grace-minutes', 'rotateGraceMinutes'], ['--first-output-seconds', 'firstOutputSeconds']])
|
||
+ if (parsed.values[flag] !== undefined) timing[field] = /^[0-9]+$/.test(parsed.values[flag]) ? Number(parsed.values[flag]) : NaN;
|
||
+ try { proxyTiming(timing); } catch (error) { throw failure(error.code); }
|
||
try {
|
||
const config = typeof system === 'function' ? system() : system;
|
||
if (verb === 'fetch') { const result = await fetch({ dataRoot: config.dataRoot }); io.stdout.write(JSON.stringify({ binary: result.binary, panel: result.panel }) + '\n'); return; }
|
||
@@ -54,8 +59,8 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
|
||
const call = callFor(parsed);
|
||
const first = await prompt(io, 'Management key: '), second = await prompt(io, 'Repeat management key: ');
|
||
if (first !== second) throw failure('proxy-key-mismatch');
|
||
- if (!first.trim() || first.length > 4096 || /[\x00-\x1f\x7f]/.test(first)) throw failure('proxy-key-invalid');
|
||
- const result = await call('proxy.init', { key: first, ...(port === undefined ? {} : { port }) });
|
||
+ if (!validManagementKey(first)) throw failure('proxy-key-invalid');
|
||
+ const result = await call('proxy.init', { key: first, ...(port === undefined ? {} : { port }), ...timing });
|
||
io.stdout.write(JSON.stringify(result) + '\n');
|
||
} catch (e) {
|
||
if (e instanceof CliError) throw e;
|
||
diff --git a/packages/cli/src/model-stall.mjs b/packages/cli/src/model-stall.mjs
|
||
new file mode 100644
|
||
index 000000000..2e57cbb9b
|
||
--- /dev/null
|
||
+++ b/packages/cli/src/model-stall.mjs
|
||
@@ -0,0 +1,45 @@
|
||
+import { existsSync, openSync, fstatSync, readSync, closeSync, constants } from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+// A first-turn timestamp is protocol metadata only, never output or a credential.
|
||
+function firstTurn(runDir) {
|
||
+ let fd;
|
||
+ try {
|
||
+ fd = openSync(join(runDir, 'proxy-first-turn'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||
+ const stat = fstatSync(fd);
|
||
+ if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || stat.uid !== process.getuid() || stat.size > 64) return 0n;
|
||
+ const bytes = Buffer.alloc(64), size = readSync(fd, bytes, 0, bytes.length, 0);
|
||
+ if (bytes.subarray(0, size).some(byte => byte > 127)) return 0n;
|
||
+ const match = /^turn ([1-9][0-9]{0,23})\n$/.exec(bytes.subarray(0, size).toString('ascii'));
|
||
+ if (!match || match[0].length !== size || size !== stat.size) return 0n;
|
||
+ const stamp = BigInt(match[1]);
|
||
+ return stamp > 0n && stamp <= process.hrtime.bigint() ? stamp : 0n;
|
||
+ } catch (error) { return error.code === 'ENOENT' ? null : 0n; }
|
||
+ finally { if (fd !== undefined) { try { closeSync(fd); } catch { return 0n; } } }
|
||
+}
|
||
+// Legacy utility callers retain their explicit construction-time budget. Managed
|
||
+// proxy sessions opt into first-actual-turn arming in the launcher.
|
||
+export function watchModelOutput({ runDir, seconds = 120, keyId, stop, receipt, armAtFirstTurn = false }) {
|
||
+ let timer, settled = false, finish;
|
||
+ const done = new Promise(resolve => { finish = resolve; });
|
||
+ const cancel = () => { if (settled) return; settled = true; clearTimeout(timer); finish(); };
|
||
+ const budget = BigInt(Math.ceil(seconds * 1e9));
|
||
+ let deadline = armAtFirstTurn ? null : process.hrtime.bigint() + budget;
|
||
+ const tick = async () => {
|
||
+ if (settled) return;
|
||
+ if (existsSync(join(runDir, 'proxy-first-output'))) { cancel(); return; }
|
||
+ if (deadline === null) {
|
||
+ const first = firstTurn(runDir);
|
||
+ if (first === null) { timer = setTimeout(tick, 1000); return; }
|
||
+ // Invalid protocol metadata fails closed through the same trusted stop;
|
||
+ // it cannot grant an idle session an indefinite or extended budget.
|
||
+ deadline = first === 0n ? 0n : first + budget;
|
||
+ }
|
||
+ const left = Number(deadline - process.hrtime.bigint()) / 1e6;
|
||
+ if (left > 0) { timer = setTimeout(tick, Math.min(1000, left)); return; }
|
||
+ try { await stop(); receipt({ action: 'proxy-stall', keyIds: [keyId] }); }
|
||
+ catch { receipt({ action: 'proxy-stall', code: 'proxy-stop-failed', keyIds: [keyId] }); }
|
||
+ finally { cancel(); }
|
||
+ };
|
||
+ timer = setTimeout(tick, 0);
|
||
+ return { cancel, done };
|
||
+}
|
||
diff --git a/packages/cli/tests/fixtures/model-clock-probe.mjs b/packages/cli/tests/fixtures/model-clock-probe.mjs
|
||
new file mode 100644
|
||
index 000000000..1e191e905
|
||
--- /dev/null
|
||
+++ b/packages/cli/tests/fixtures/model-clock-probe.mjs
|
||
@@ -0,0 +1,13 @@
|
||
+// Isolated owned FIFO probe: parent bounds/kills this exact child if I/O blocks.
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { watchModelOutput } from '../../src/model-stall.mjs';
|
||
+import { getPriority, setPriority } from 'node:os';
|
||
+// Enforce the gate's existing nice19 contract on this owned probe itself.
|
||
+setPriority(0, 19);
|
||
+let stops=0;const receipts=[];
|
||
+const watch=watchModelOutput({runDir:process.argv[2],armAtFirstTurn:true,seconds:60,keyId:'0123456789ab',stop:async()=>{stops++;},receipt:r=>receipts.push(r)});
|
||
+const clock=setTimeout(watch.cancel,100);
|
||
+await watch.done;clearTimeout(clock);
|
||
+const marker=join(process.argv[2],'proxy-first-turn'),openMarkerFds=fs.readdirSync('/proc/self/fd').filter(n=>{try{return fs.readlinkSync('/proc/self/fd/'+n)===marker;}catch{return false;}}).length;
|
||
+process.stdout.write(JSON.stringify({stops,receipts,nice:getPriority(0),openMarkerFds})+'\n');
|
||
diff --git a/packages/cli/tests/model-managed-launch-fixture.mjs b/packages/cli/tests/model-managed-launch-fixture.mjs
|
||
new file mode 100644
|
||
index 000000000..1a63106ba
|
||
--- /dev/null
|
||
+++ b/packages/cli/tests/model-managed-launch-fixture.mjs
|
||
@@ -0,0 +1,94 @@
|
||
+// #653 isolated owned fixture: real launcher/runtime/runner and fake loopback management HTTP.
|
||
+import fs from 'node:fs';
|
||
+import http from 'node:http';
|
||
+import assert from 'node:assert/strict';
|
||
+import { createHash, randomBytes } from 'node:crypto';
|
||
+import { spawnSync } from 'node:child_process';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { fixture, fakeHarnesses } from './helpers.mjs';
|
||
+import { loadSystem, bootConfig } from '../src/config.mjs';
|
||
+import { createLauncher } from '../src/launcher.mjs';
|
||
+import { startBroker } from '../../bus/src/runtime.mjs';
|
||
+import { gitEnv } from '../../seat/src/checkout.mjs';
|
||
+import { readSessions, runsDir, sessionsFile, SESSIONS_VERSION, launchLogFile } from '../../seat/src/session.mjs';
|
||
+export const waitFor = async (read, message, ms=15000) => { const end=Date.now()+ms;while(Date.now()<end){const value=await read();if(value)return value;await new Promise(r=>setTimeout(r,20));}throw Error(message); };
|
||
+export const pause = ms => new Promise(r=>setTimeout(r,ms));
|
||
+const id = value => createHash('sha256').update(value).digest('hex').slice(0,12);
|
||
+export async function managedLaunchFixture(t,{proxy=true,launches=true,refuseProbe=false}={}){
|
||
+ const root=fs.mkdtempSync(join(tmpdir(),'ml653-')),closers=[],history=new Set(['fx653-manage']),proxyReceipts=[],logs=[];
|
||
+ let server,bus,launcher,stderr;const trace={preflightAttempts:0,successfulPreflights:0,enableBeforeSpawn:[],bindings:[],stalls:[]};
|
||
+ const checkHistory=entries=>{const text=JSON.stringify(entries);for(const value of history)if(text.includes(value))throw Error('fixture credential leaked');};
|
||
+ t.after(async()=>{const failures=[];for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');});
|
||
+ const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';return doc;});
|
||
+ const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles};
|
||
+ const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);
|
||
+ const state={keys:[sentinel],calls:[],refuseProbe};
|
||
+ server=http.createServer(async(req,res)=>{try{
|
||
+ const supplied=req.headers.authorization?.slice(7);if(supplied)history.add(supplied);for(const key of state.keys)history.add(key);
|
||
+ if(req.url==='/v8/management/config/access/api-keys'){
|
||
+ state.calls.push(req.method);if(supplied!=='fx653-manage'){res.writeHead(401);res.end('{}');return;}
|
||
+ if(req.method==='GET'){res.end(JSON.stringify(state.keys));return;}
|
||
+ const parts=[];for await(const part of req)parts.push(part);const values=JSON.parse(Buffer.concat(parts));for(const value of values)history.add(value);state.keys=values;res.end('{"status":"ok"}');return;
|
||
+ }
|
||
+ state.calls.push('PROBE');res.writeHead(!state.refuseProbe&&state.keys.includes(supplied)?200:401);res.end('{}');
|
||
+ }catch{if(!res.destroyed){res.writeHead(500);res.end('{}');}}});
|
||
+ await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});const port=server.address().port;assert.notEqual(port,8317);
|
||
+ fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx653-manage',{mode:0o600});
|
||
+ fs.mkdirSync(join(f.dataRoot,'bus-host'),{recursive:true,mode:0o700});fs.writeFileSync(sessionsFile(f.dataRoot),JSON.stringify({sessionsVersion:SESSIONS_VERSION,sessions:[]}),{mode:0o600});
|
||
+ const git=(...args)=>{const result=spawnSync('git',['-c','user.name=test','-c','[email protected]','-C',f.doc.projects.stack.root,...args],{env:gitEnv(env),encoding:'utf8'});assert.equal(result.status,0,'owned local git fixture succeeds');};
|
||
+ git('init','-q','-b','main');fs.writeFileSync(join(f.doc.projects.stack.root,'file.txt'),'one\n');git('add','file.txt');git('commit','-q','-m','one');
|
||
+ const adapter=join(root,'adapter.sh');fs.writeFileSync(adapter,'case "$MOSAIC_REQUEST" in *SILENT*) exec /usr/bin/sleep 30;; *) printf %s fx653-private-output; printf "done\\n" > "$MOSAIC_TURN_MARKER";; esac\n',{mode:0o644});
|
||
+ const system={...loadSystem({env}),...(proxy?{proxy:{port,binary:'/fixture-unused',firstOutputSeconds:1}}:{})};
|
||
+ const {trackers,...boot}=bootConfig({system,businessId:'acme',env});assert.equal(trackers,undefined);
|
||
+ stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){proxyReceipts.push(String(chunk));const callback=args.find(a=>typeof a==='function');callback?.();return true;}return stderr.call(this,chunk,...args);};
|
||
+ bus=await startBroker(boot);closers.push(()=>bus.close());
|
||
+ const hooks=[];let expectedKeyId;
|
||
+ const actualOp=async m=>{
|
||
+ switch(m.op){
|
||
+ case 'modelPreflight':return bus.modelPreflight(m);
|
||
+ case 'modelStall':return bus.modelStall(m);
|
||
+ case 'identity':return bus.identity(m.cap);
|
||
+ case 'authorizeLaunch':return bus.authorizeLaunch(m.cap,m.instance);
|
||
+ case 'refuse':return bus.refuse(m.cap,m.code,m.subject);
|
||
+ case 'endLaunch':return bus.endLaunch(m.record);
|
||
+ case 'recoverLaunch':return bus.recoverLaunch(m.record);
|
||
+ case 'credentialStatus':return bus.credentialStatus(m.business,m.role);
|
||
+ case 'mayLaunch':return bus.mayLaunch(m);
|
||
+ case 'landHolders':return bus.landHolders(m.business);
|
||
+ case 'landHeld':return bus.landHeld(m.record);
|
||
+ case 'landRelease':return bus.landRelease(m.record);
|
||
+ case 'threadsState':return bus.threadsState(m.business);
|
||
+ case 'threadIdle':return bus.threadIdle(m.record);
|
||
+ case 'lifecycleRequests':return bus.lifecycleRequests(m.business);
|
||
+ case 'sessionClears':return bus.sessionClears(m.business);
|
||
+ default:throw Error('fixture unrequested trusted operation');
|
||
+ }
|
||
+ };
|
||
+ const host={business:'acme',path:bus.path,dataRoot:f.dataRoot,beforeClose:fn=>hooks.push(fn),
|
||
+ op:async m=>{
|
||
+ if(m.op==='modelPreflight'){trace.preflightAttempts++;const value=await actualOp(m);if(value){trace.successfulPreflights++;expectedKeyId=id(value.key);}return value;}
|
||
+ if(m.op==='landHolders'&&trace.successfulPreflights){
|
||
+ // Existing trusted host call occurs after enable publication and BEFORE bundle,
|
||
+ // registerRuntime/spawnSession/bind in the production startLocked sequence.
|
||
+ const dirs=fs.readdirSync(runsDir(f.dataRoot,'acme'));assert.equal(dirs.length,1);const file=join(runsDir(f.dataRoot,'acme'),dirs[0],'proxy-first-turn-enabled');
|
||
+ assert.equal(fs.readFileSync(file,'utf8'),'enabled\n','managed caller publishes enable before spawn');assert.equal(fs.statSync(file).mode&0o777,0o600);assert.equal(trace.bindings.length,0);assert.equal(readSessions(f.dataRoot).length,0);trace.enableBeforeSpawn.push(true);
|
||
+ }
|
||
+ if(m.op==='modelStall'){const result=await actualOp(m);trace.stalls.push({run:m.run,keyId:m.keyId,stopped:result.stopped});return result;}
|
||
+ return actualOp(m);
|
||
+ },
|
||
+ bindLaunch:async record=>{const result=bus.bindLaunch(record);trace.bindings.push({run:record.run,pid:record.pid,startTime:record.startTime,modelKeyId:record.modelKeyId});return result;}};
|
||
+ const makeLoader=await fakeHarnesses({after:close=>closers.push(close)},{pi:adapter});
|
||
+ launcher=createLauncher({host,system,env,launches,makeLoader,sessionDefaults:{pollInterval:50},harnessVersion:'0.85.1',log:line=>logs.push(line)});
|
||
+ // The existing trusted host hook owns all launcher-created children. No registry
|
||
+ // PID is signalled by fixture teardown; normal bounded launcher close is used.
|
||
+ closers.push(async()=>{for(const close of hooks)await close(0);});await launcher.listen();
|
||
+ const human=bus.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});
|
||
+ const launchLog=()=>fs.existsSync(launchLogFile(f.dataRoot,'acme'))?fs.readFileSync(launchLogFile(f.dataRoot,'acme'),'utf8').trim().split('\n').filter(Boolean).map(l=>JSON.parse(l)):[];
|
||
+ const valueFree=()=>checkHistory({logs,proxyReceipts,launch:launchLog(),trace});
|
||
+ return {root,f,state,trace,proxyReceipts,logs,launcher,bus,launchLog,expectedKeyId:()=>expectedKeyId,valueFree,
|
||
+ runDirectories:()=>fs.existsSync(runsDir(f.dataRoot,'acme'))?fs.readdirSync(runsDir(f.dataRoot,'acme')).map(name=>join(runsDir(f.dataRoot,'acme'),name)):[],
|
||
+ sessions:()=>readSessions(f.dataRoot),send:body=>bus.broker.request(human,{verb:'message.send',args:{to:'chief',body,class:'REQUEST'}}),
|
||
+ reply:sent=>waitFor(()=>bus.broker.request(human,{verb:'message.receive'}).find(m=>m.in_reply_to===sent.id),'real managed reply observed'),
|
||
+ assertHistoryGuard:()=>{try{checkHistory({probe:sentinel});}catch(e){if(e.message==='fixture credential leaked')return;}throw Error('fixture history guard failed');}};
|
||
+}
|
||
diff --git a/packages/cli/tests/model-managed-launch.test.mjs b/packages/cli/tests/model-managed-launch.test.mjs
|
||
new file mode 100644
|
||
index 000000000..74939c688
|
||
--- /dev/null
|
||
+++ b/packages/cli/tests/model-managed-launch.test.mjs
|
||
@@ -0,0 +1,29 @@
|
||
+// #653 STATIC test-first preparation. No provider/proxy service or manual enable marker.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { sandboxProblem } from '../../seat/src/sandbox.mjs';
|
||
+import { startTimeOf } from '../../seat/src/proc.mjs';
|
||
+import { managedLaunchFixture, pause, waitFor } from './model-managed-launch-fixture.mjs';
|
||
+const skip=sandboxProblem()??false; // Any unavailable confinement is UNVERIFIED, not a green result.
|
||
+async function launched(t,options){const f=await managedLaunchFixture(t,options);let answer;await assert.doesNotReject(async()=>{try{answer=await f.launcher.launchPm();}finally{if(options?.proxy!==false)assert.equal(f.trace.successfulPreflights,1,'own real model preflight succeeded before launch outcome');}},'actual managed proxy launch succeeds after successful preflight');const s=f.sessions().find(s=>s.run===answer.run);assert.ok(s?.live,'real confined runner is registered and live');return {f,s};}
|
||
+test('653 actual managed proxy launch stays idle unarmed then stops first silent turn through trusted runtime',{skip,timeout:30000},async t=>{
|
||
+ const {f,s}=await launched(t);assert.equal(f.trace.successfulPreflights,1);assert.deepEqual(f.trace.enableBeforeSpawn,[true]);assert.equal(f.trace.bindings[0].modelKeyId,f.expectedKeyId());assert.equal(s.modelKeyId,f.expectedKeyId());assert.notEqual(s.pid,s.runnerPid);
|
||
+ await pause(1250);assert.equal(f.trace.stalls.length,0,'idle beyond configured initial budget does not stop');assert.equal(fs.existsSync(join(s.runDir,'proxy-first-turn')),false);assert.equal(startTimeOf(s.runnerPid),s.runnerStartTime);
|
||
+ const before=process.hrtime.bigint();f.send('SILENT');await waitFor(()=>fs.existsSync(join(s.runDir,'proxy-first-turn')),'actual managed adapter turn publishes marker');const stamp=BigInt(fs.readFileSync(join(s.runDir,'proxy-first-turn'),'utf8').trim().split(' ')[1]);assert.ok(stamp>=before&&stamp<=process.hrtime.bigint());assert.equal(fs.existsSync(join(s.runDir,'proxy-first-output')),false);
|
||
+ await waitFor(()=>f.trace.stalls.length===1,'actual caller watcher reaches trusted modelStall');assert.deepEqual(f.trace.stalls,[{run:s.run,keyId:s.modelKeyId,stopped:true}]);await waitFor(()=>f.sessions().length===0,'normal managed exit unregisters session');assert.equal(f.launchLog().some(r=>r.action==='proxy-stall'&&r.keyIds?.includes(s.modelKeyId)&&!r.code),true);f.assertHistoryGuard();f.valueFree();
|
||
+});
|
||
+test('653 actual managed proxy output cancels caller watcher and first timestamp never resets',{skip,timeout:30000},async t=>{
|
||
+ const {f,s}=await launched(t);const first=f.send('OUTPUT');const answer=await f.reply(first);assert.equal(answer.body,'fx653-private-output');const file=join(s.runDir,'proxy-first-turn'),stamp=fs.readFileSync(file,'utf8');assert.equal(fs.statSync(file).mode&0o777,0o600);assert.equal(fs.readFileSync(join(s.runDir,'proxy-first-output'),'utf8'),'output\n');
|
||
+ const second=f.send('OUTPUT');await f.reply(second);assert.equal(fs.readFileSync(file,'utf8'),stamp);await pause(1250);assert.equal(f.trace.stalls.length,0,'actual first output cancels managed watcher');assert.equal(f.sessions()[0].live,true);assert.equal(fs.readdirSync(s.runDir).some(n=>n.startsWith('.proxy-first-turn-')),false);f.valueFree();
|
||
+});
|
||
+test('653 actual nonproxy launch writes output without proxy enable or first-turn protocol',{skip,timeout:30000},async t=>{
|
||
+ const {f,s}=await launched(t,{proxy:false});assert.equal(f.trace.preflightAttempts,0);assert.equal(fs.existsSync(join(s.runDir,'proxy-first-turn-enabled')),false);const sent=f.send('OUTPUT');assert.equal((await f.reply(sent)).body,'fx653-private-output');assert.equal(fs.existsSync(join(s.runDir,'proxy-first-turn')),false);assert.equal(f.trace.stalls.length,0);f.valueFree();
|
||
+});
|
||
+test('653 actual refused proxy preflight never enables binds or spawns a managed session',{skip,timeout:30000},async t=>{
|
||
+ const f=await managedLaunchFixture(t,{refuseProbe:true});await assert.rejects(f.launcher.launchPm(),e=>e.code==='proxy-key-refused');assert.equal(f.trace.successfulPreflights,0);assert.equal(f.trace.preflightAttempts,1);assert.equal(f.trace.bindings.length,0);assert.equal(f.sessions().length,0);assert.equal(f.trace.enableBeforeSpawn.length,0);assert.equal(f.runDirectories().some(dir=>fs.existsSync(join(dir,'proxy-first-turn-enabled'))||fs.existsSync(join(dir,'session.json'))),false,'refused preflight has no enable/session publication');f.valueFree();
|
||
+});
|
||
+test('653 actual no-launch host refuses before proxy preflight and session side effects',{skip,timeout:30000},async t=>{
|
||
+ const f=await managedLaunchFixture(t,{launches:false});await assert.rejects(f.launcher.launchPm(),e=>e.code==='launches-disabled');assert.equal(f.trace.preflightAttempts,0);assert.equal(f.trace.bindings.length,0);assert.equal(f.sessions().length,0);assert.equal(f.runDirectories().length,0,'no-launch never allocates a run');f.valueFree();
|
||
+});
|
||
diff --git a/packages/cli/tests/model-proxy-guards.test.mjs b/packages/cli/tests/model-proxy-guards.test.mjs
|
||
new file mode 100644
|
||
index 000000000..5ee58f3d1
|
||
--- /dev/null
|
||
+++ b/packages/cli/tests/model-proxy-guards.test.mjs
|
||
@@ -0,0 +1,52 @@
|
||
+// P2 RED tests: these catch fetch before eligibility and divergent key predicates.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import { proxyCommand } from '../src/model-proxy.mjs';
|
||
+import { io } from './helpers.mjs';
|
||
+
|
||
+for (const env of [{ CODEX_THREAD_ID: 'fixture' }, { MOSAIC_AGENT_RUN: 'fixture' }]) {
|
||
+ test(`human-required: proxy fetch refuses ${Object.keys(env)[0]} before config or fetch`, async () => {
|
||
+ let touched = false;
|
||
+ const forbidden = () => { touched = true; throw new Error('eligibility must run first'); };
|
||
+ await assert.rejects(proxyCommand(['fetch'], io(env), { system: forbidden, fetch: forbidden }), e => e.code === 'human-required');
|
||
+ assert.equal(touched, false);
|
||
+ });
|
||
+}
|
||
+test('human-required: proxy fetch tester refuses before config or fetch', async () => {
|
||
+ let touched = false;
|
||
+ const forbidden = () => { touched = true; throw new Error('eligibility must run first'); };
|
||
+ await assert.rejects(proxyCommand(['fetch', '--tester'], io(), { system: forbidden, fetch: forbidden }), e => e.code === 'human-required');
|
||
+ assert.equal(touched, false);
|
||
+});
|
||
+for (const key of ['fx331-caf\u00e9', ' fx331-key', 'fx331-key ', 'fx331 key']) {
|
||
+ test(`proxy-key-invalid: CLI rejects non-management-key input (${JSON.stringify(key).length})`, async () => {
|
||
+ const x = io(); x.stdin.isTTY = true; x.stdout.isTTY = true;
|
||
+ let calls = 0;
|
||
+ await assert.rejects(proxyCommand(['init'], x, {
|
||
+ system: { dataRoot: '/unused-fixture' }, prompt: async () => key,
|
||
+ callFor: () => async () => { calls++; return { port: 18432, binary: '/fixture' }; },
|
||
+ }), e => e.code === 'proxy-key-invalid');
|
||
+ assert.equal(calls, 0);
|
||
+ assert.equal(x.out.text.includes(key), false);
|
||
+ assert.equal(x.err.text.includes(key), false);
|
||
+ });
|
||
+}
|
||
+
|
||
+for (const [flag, code, value] of [['--rotate-grace-minutes', 'proxy-rotate-grace-invalid', '1441'], ['--first-output-seconds', 'proxy-first-output-invalid', '3601']]) {
|
||
+ test(`${code}: init flag refuses before config or prompt`, async () => {
|
||
+ let touched = false;
|
||
+ const forbidden = () => { touched = true; throw new Error('must not run'); };
|
||
+ await assert.rejects(proxyCommand(['init', flag, value], io(), { system: forbidden, prompt: forbidden }), e => e.code === code);
|
||
+ assert.equal(touched, false);
|
||
+ });
|
||
+}
|
||
+test('human proxy init passes optional timing flags only through authenticated call', async () => {
|
||
+ const x = io(); x.stdin.isTTY = true; x.stdout.isTTY = true;
|
||
+ const calls = [];
|
||
+ await proxyCommand(['init', '--rotate-grace-minutes', '20', '--first-output-seconds', '10'], x, {
|
||
+ system: { dataRoot: '/unused-fixture' }, prompt: async () => 'fx331-manage',
|
||
+ callFor: () => async (verb, args) => { calls.push({ verb, args }); return { port: 18432, binary: '/fixture' }; },
|
||
+ });
|
||
+ assert.equal(calls.length, 1); assert.equal(calls[0].args.rotateGraceMinutes, 20); assert.equal(calls[0].args.firstOutputSeconds, 10);
|
||
+ assert.equal(x.out.text.includes('fx331-manage'), false);
|
||
+});
|
||
diff --git a/packages/cli/tests/model-stall.test.mjs b/packages/cli/tests/model-stall.test.mjs
|
||
new file mode 100644
|
||
index 000000000..37ae415d4
|
||
--- /dev/null
|
||
+++ b/packages/cli/tests/model-stall.test.mjs
|
||
@@ -0,0 +1,30 @@
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { join } from 'node:path';
|
||
+import { watchModelOutput } from '../src/model-stall.mjs';
|
||
+function fixture(t) { const root = fs.mkdtempSync(join(tmpdir(), 'p2-stall-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); return root; }
|
||
+test('proxy-stall: silent run stops and logs only ids, never output or key values', async t => {
|
||
+ const root = fixture(t), receipts = []; let stops = 0;
|
||
+ const watch = watchModelOutput({ runDir: root, seconds: 0.02, keyId: '0123456789ab', stop: async () => { stops++; }, receipt: r => receipts.push(r) });
|
||
+ t.after(watch.cancel); await watch.done;
|
||
+ assert.equal(stops, 1); assert.deepEqual(receipts, [{ action: 'proxy-stall', keyIds: ['0123456789ab'] }]);
|
||
+});
|
||
+test('first stdout marker cancels stall without reading or logging marker text', async t => {
|
||
+ const root = fixture(t), receipts = []; let stops = 0;
|
||
+ fs.writeFileSync(join(root, 'proxy-first-output'), 'fx331-private-output');
|
||
+ const watch = watchModelOutput({ runDir: root, seconds: 0.02, keyId: '0123456789ab', stop: async () => { stops++; }, receipt: r => receipts.push(r) });
|
||
+ t.after(watch.cancel); await watch.done; assert.equal(stops, 0); assert.deepEqual(receipts, []);
|
||
+});
|
||
+test('proxy-stop-failed: failed stall stop is logged by code and never leaks error text', async t => {
|
||
+ const root = fixture(t), receipts = [];
|
||
+ const watch = watchModelOutput({ runDir: root, seconds: 0.02, keyId: '0123456789ab', stop: async () => { throw Error('fx331-private-output'); }, receipt: r => receipts.push(r) });
|
||
+ t.after(watch.cancel); await watch.done;
|
||
+ assert.deepEqual(receipts, [{ action: 'proxy-stall', code: 'proxy-stop-failed', keyIds: ['0123456789ab'] }]);
|
||
+});
|
||
+test('run exit cancellation settles watcher without stopping the run', async t => {
|
||
+ const root = fixture(t); let stops = 0;
|
||
+ const watch = watchModelOutput({ runDir: root, seconds: 0.02, keyId: '0123456789ab', stop: async () => { stops++; }, receipt: () => {} });
|
||
+ watch.cancel(); await watch.done; assert.equal(stops, 0);
|
||
+});
|
||
diff --git a/packages/cli/tests/model-turn-clock.test.mjs b/packages/cli/tests/model-turn-clock.test.mjs
|
||
new file mode 100644
|
||
index 000000000..59919c8f7
|
||
--- /dev/null
|
||
+++ b/packages/cli/tests/model-turn-clock.test.mjs
|
||
@@ -0,0 +1,15 @@
|
||
+// #650 first actual turn, one initial budget; prepared only, not executed.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { join } from 'node:path';
|
||
+import { watchModelOutput } from '../src/model-stall.mjs';
|
||
+const delay=ms=>new Promise(r=>setTimeout(r,ms));
|
||
+async function doneWithin(watch){let timer;try{await Promise.race([watch.done,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('watcher completion not observed')),5000);})]);}finally{clearTimeout(timer);}}
|
||
+function fixture(t){const runDir=fs.mkdtempSync(join(tmpdir(),'clock650-'));let stops=0;const receipts=[];t.after(()=>fs.rmSync(runDir,{recursive:true,force:true}));const watch=watchModelOutput({runDir,armAtFirstTurn:true,seconds:0.1,keyId:'0123456789ab',stop:async()=>{stops++;},receipt:r=>receipts.push(r)});t.after(watch.cancel);return {runDir,watch,receipts,stops:()=>stops,arm:()=>fs.writeFileSync(join(runDir,'proxy-first-turn'),`turn ${process.hrtime.bigint()}\n`,{mode:0o600})};}
|
||
+test('650 idle longer than initial budget remains unarmed',async t=>{const f=fixture(t);await delay(250);assert.equal(f.stops(),0,'idle time must not spend first-output budget');f.watch.cancel();await f.watch.done;assert.deepEqual(f.receipts,[]);});
|
||
+test('650 first silent turn spends one initial budget then invokes existing stop callback',async t=>{const f=fixture(t);await delay(250);assert.equal(f.stops(),0,'silent turn clock is unarmed before first turn');f.arm();await doneWithin(f.watch);assert.equal(f.stops(),1);assert.deepEqual(f.receipts,[{action:'proxy-stall',keyIds:['0123456789ab']}]);});
|
||
+test('650 first output cancels turn watcher without output text in receipt',async t=>{const f=fixture(t);f.arm();fs.writeFileSync(join(f.runDir,'proxy-first-output'),'output\n',{mode:0o600});await doneWithin(f.watch);assert.equal(f.stops(),0);assert.deepEqual(f.receipts,[]);});
|
||
+test('650 later marker cannot extend already armed initial budget',async t=>{const f=fixture(t);f.arm();await delay(30);fs.writeFileSync(join(f.runDir,'proxy-first-turn'),`turn ${process.hrtime.bigint()+60_000_000_000n}\n`);await doneWithin(f.watch);assert.equal(f.stops(),1);});
|
||
+test('650 idle run exit cancellation stops no process',async t=>{const f=fixture(t);f.watch.cancel();await f.watch.done;assert.equal(f.stops(),0);assert.deepEqual(f.receipts,[]);});
|
||
diff --git a/packages/cli/tests/model-turn-reader.test.mjs b/packages/cli/tests/model-turn-reader.test.mjs
|
||
new file mode 100644
|
||
index 000000000..2b5e371ec
|
||
--- /dev/null
|
||
+++ b/packages/cli/tests/model-turn-reader.test.mjs
|
||
@@ -0,0 +1,39 @@
|
||
+// #650 strict reader negatives, real owned files only; prepared, not executed.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { spawn, execFileSync } from 'node:child_process';
|
||
+import { syncBuiltinESMExports } from 'node:module';
|
||
+import { watchModelOutput } from '../src/model-stall.mjs';
|
||
+const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||
+function scratch(t){const root=fs.mkdtempSync(join(tmpdir(),'reader650-'));t.after(()=>fs.rmSync(root,{recursive:true,force:true}));return root;}
|
||
+const canonical=()=>Buffer.from(`turn ${process.hrtime.bigint()}\n`);
|
||
+async function invalid(t,prepare){const root=scratch(t),file=join(root,'proxy-first-turn');prepare(file,root);let stops=0;const receipts=[];const watch=watchModelOutput({runDir:root,armAtFirstTurn:true,seconds:60,keyId:'0123456789ab',stop:async()=>{stops++;},receipt:r=>receipts.push(r)});t.after(watch.cancel);await delay(30);assert.equal(stops,1,'invalid first-turn metadata triggers single trusted stop');await delay(30);assert.equal(stops,1);assert.deepEqual(receipts,[{action:'proxy-stall',keyIds:['0123456789ab']}]);const targets=fs.readdirSync('/proc/self/fd').flatMap(n=>{try{return [fs.readlinkSync('/proc/self/fd/'+n)];}catch{return [];}});assert.equal(targets.includes(file),false,'invalid marker descriptor closed');return {root,file};}
|
||
+const malformed=[
|
||
+ ['oversized',()=>Buffer.alloc(65,120)],['empty',()=>Buffer.alloc(0)],['wrong prefix',()=>Buffer.from(`turns ${process.hrtime.bigint()}\n`)],
|
||
+ ['zero',()=>Buffer.from('turn 0\n')],['future',()=>Buffer.from(`turn ${process.hrtime.bigint()+60_000_000_000n}\n`)],['leading zero',()=>Buffer.from(`turn 0${process.hrtime.bigint()}\n`)],
|
||
+ ['extra trailing newline',()=>Buffer.concat([canonical(),Buffer.from('\n')])],['extra token',()=>Buffer.from(`turn ${process.hrtime.bigint()} extra\n`)],
|
||
+ ['CRLF',()=>Buffer.from(`turn ${process.hrtime.bigint()}\r\n`)],['malformed digit',()=>Buffer.from('turn abc\n')],
|
||
+ ...[['highbit prefix',0],['highbit digit',5],['highbit newline',-1]].map(([name,index])=>[name,()=>{const b=canonical();b[index<0?b.length-1:index]|=128;return b;}]),
|
||
+];
|
||
+for(const [name,bytes] of malformed)test(`650 reader rejects ${name} without exposing metadata`,async t=>{await invalid(t,file=>fs.writeFileSync(file,bytes(),{mode:0o600}));});
|
||
+test('650 reader rejects wrong mode',async t=>{await invalid(t,file=>fs.writeFileSync(file,canonical(),{mode:0o644}));});
|
||
+test('650 reader never follows owned symlink to a valid long-budget timestamp',async t=>{await invalid(t,(file,root)=>{const target=join(root,'owned-target');fs.writeFileSync(target,canonical(),{mode:0o600});fs.symlinkSync(target,file);});});
|
||
+test('650 reader FIFO cannot block the watcher process',async t=>{
|
||
+ const root=scratch(t);execFileSync('mkfifo',['-m','600',join(root,'proxy-first-turn')]);
|
||
+ const probe=new URL('./fixtures/model-clock-probe.mjs',import.meta.url).pathname;
|
||
+ const child=spawn(process.execPath,[probe,root],{env:{PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:process.env.MOSAIC_CONFIG},stdio:['ignore','pipe','ignore']});
|
||
+ let output='',timedOut=false;child.stdout.on('data',b=>{output+=b;});const closed=new Promise((resolve,reject)=>{child.once('error',reject);child.once('close',(code,signal)=>resolve({code,signal}));});const timer=setTimeout(()=>{timedOut=true;child.kill('SIGKILL');},5000);t.after(async()=>{clearTimeout(timer);if(child.exitCode===null&&child.signalCode===null)child.kill('SIGKILL');await closed;});
|
||
+ const result=await closed;clearTimeout(timer);assert.equal(timedOut,false,'FIFO probe completes without blocking');assert.equal(result.code,0);const answer=JSON.parse(output);assert.equal(answer.stops,1,'FIFO metadata is refused through existing stop');assert.equal(answer.nice,19);assert.equal(answer.openMarkerFds,0,'FIFO descriptor closed before child exit');assert.deepEqual(answer.receipts,[{action:'proxy-stall',keyIds:['0123456789ab']}]);
|
||
+});
|
||
+test('650 reader late detection uses already-spent embedded budget',async t=>{const root=scratch(t);fs.writeFileSync(join(root,'proxy-first-turn'),`turn ${process.hrtime.bigint()-120_000_000_000n}\n`,{mode:0o600});let stops=0;const watch=watchModelOutput({runDir:root,armAtFirstTurn:true,seconds:60,keyId:'0123456789ab',stop:async()=>{stops++;},receipt:()=>{}});t.after(watch.cancel);await delay(30);assert.equal(stops,1,'embedded initial timestamp already spent the budget');});
|
||
+test('650 reader closes owned marker descriptors and never rereads armed timestamp',async t=>{
|
||
+ const root=scratch(t),file=join(root,'proxy-first-turn');fs.writeFileSync(file,canonical(),{mode:0o600});const original=fs.openSync;let reads=0;fs.openSync=function(p,flags,...args){if(p===file&&typeof flags==='number')reads++;return original.call(this,p,flags,...args);};syncBuiltinESMExports();t.after(()=>{fs.openSync=original;syncBuiltinESMExports();});
|
||
+ const watch=watchModelOutput({runDir:root,armAtFirstTurn:true,seconds:0.1,keyId:'0123456789ab',stop:async()=>{},receipt:()=>{}});t.after(watch.cancel);await delay(30);assert.equal(reads,1,'first marker read observed');fs.writeFileSync(file,canonical());await watch.done;assert.equal(reads,1,'armed timestamp never reread');
|
||
+ const targets=fs.readdirSync('/proc/self/fd').flatMap(n=>{try{return [fs.readlinkSync('/proc/self/fd/'+n)];}catch{return [];}});assert.equal(targets.includes(file),false,'all owned marker descriptors closed');
|
||
+});
|
||
+// Wrong-owner and independently unreadable valid-0600 fixtures require changing
|
||
+// another UID/ACL. Not forged here; those branches remain explicitly UNVERIFIED.
|
||
+test('650 first output cancels before malformed metadata can request a stop',async t=>{const root=scratch(t);fs.writeFileSync(join(root,'proxy-first-turn'),'not-a-timestamp',{mode:0o644});fs.writeFileSync(join(root,'proxy-first-output'),'output\n',{mode:0o600});let stops=0;const receipts=[];const watch=watchModelOutput({runDir:root,armAtFirstTurn:true,seconds:60,keyId:'0123456789ab',stop:async()=>{stops++;},receipt:r=>receipts.push(r)});t.after(watch.cancel);await watch.done;assert.equal(stops,0);assert.deepEqual(receipts,[]);});
|
||
diff --git a/packages/cli/tests/session-clear.test.mjs b/packages/cli/tests/session-clear.test.mjs
|
||
index 92a55b5b5..fb6d8c203 100644
|
||
--- a/packages/cli/tests/session-clear.test.mjs
|
||
+++ b/packages/cli/tests/session-clear.test.mjs
|
||
@@ -86,6 +86,7 @@ async function start(t, { f, adapter }, { tickMs = 100, stopTimeoutMs } = {}) {
|
||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||
+ recoverLaunch: (m) => runtime.recoverLaunch(m.record),
|
||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||
threadsState: (m) => runtime.threadsState(m.business),
|
||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||
diff --git a/packages/cli/tests/thread-revoke.test.mjs b/packages/cli/tests/thread-revoke.test.mjs
|
||
index 62a421b4d..41881ac1c 100644
|
||
--- a/packages/cli/tests/thread-revoke.test.mjs
|
||
+++ b/packages/cli/tests/thread-revoke.test.mjs
|
||
@@ -91,6 +91,7 @@ async function start(t, { f, adapter, access }) {
|
||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||
+ recoverLaunch: (m) => runtime.recoverLaunch(m.record),
|
||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||
threadsState: (m) => runtime.threadsState(m.business),
|
||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||
diff --git a/packages/harness/src/first-output.mjs b/packages/harness/src/first-output.mjs
|
||
new file mode 100644
|
||
index 000000000..db3b19cee
|
||
--- /dev/null
|
||
+++ b/packages/harness/src/first-output.mjs
|
||
@@ -0,0 +1,32 @@
|
||
+import { writeFileSync, openSync, closeSync, linkSync, unlinkSync } from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+// The host needs an output-exists signal, never the output or a model credential.
|
||
+export function firstOutputMarker(runDir) {
|
||
+ let seen = false;
|
||
+ return chunk => {
|
||
+ if (seen || !chunk.length) return;
|
||
+ try { writeFileSync(join(runDir, 'proxy-first-output'), 'output\n', { flag: 'wx', mode: 0o600 }); }
|
||
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
|
||
+ seen = true;
|
||
+ };
|
||
+}
|
||
+
|
||
+// Publish only a fixed time record inside the existing writable run directory.
|
||
+export function firstTurnMarker(runDir) {
|
||
+ return () => {
|
||
+ const stamp = process.hrtime.bigint();
|
||
+ const temporary = join(runDir, `.proxy-first-turn-${process.pid}-${stamp}`);
|
||
+ let fd, owned = false, failed = false;
|
||
+ try {
|
||
+ fd = openSync(temporary, 'wx', 0o600); owned = true;
|
||
+ writeFileSync(fd, `turn ${stamp}\n`);
|
||
+ try { linkSync(temporary, join(runDir, 'proxy-first-turn')); }
|
||
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
|
||
+ } catch { failed = true; }
|
||
+ finally {
|
||
+ if (fd !== undefined) { try { closeSync(fd); } catch { failed = true; } }
|
||
+ if (owned) { try { unlinkSync(temporary); } catch { failed = true; } }
|
||
+ }
|
||
+ if (failed) throw new Error('first-turn marker publication failed');
|
||
+ };
|
||
+}
|
||
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
|
||
index 62d4a3407..575ae66a9 100644
|
||
--- a/packages/harness/src/runner.mjs
|
||
+++ b/packages/harness/src/runner.mjs
|
||
@@ -38,6 +38,7 @@ import { createServer, connect } from "node:net";
|
||
import { join } from "node:path";
|
||
import { pathToFileURL } from "node:url";
|
||
import { Client } from "../../bus/src/client.mjs";
|
||
+import { firstOutputMarker, firstTurnMarker } from './first-output.mjs';
|
||
import { GUARD_CODES, FIXED_TEXTS } from "../../library/src/reply-protocol.mjs";
|
||
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
|
||
|
||
@@ -344,12 +345,14 @@ function runTurn({ session, request, n, env, onChild }) {
|
||
closeSync(errFd);
|
||
onChild(child);
|
||
let out = "";
|
||
+ const markOutput = firstOutputMarker(session.runDir);
|
||
child.stdout.setEncoding("utf8");
|
||
child.stdout.on("data", (s) => {
|
||
+ markOutput(s);
|
||
if (out.length < 4 * BODY_MAX) out += s;
|
||
});
|
||
return new Promise((resolve) => {
|
||
- let timedOut = false;
|
||
+ let timedOut = false, markerFailed = false, markerKill;
|
||
const kill = (signal) => {
|
||
try {
|
||
process.kill(-child.pid, signal);
|
||
@@ -360,15 +363,28 @@ function runTurn({ session, request, n, env, onChild }) {
|
||
kill("SIGTERM");
|
||
setTimeout(() => kill("SIGKILL"), 5000).unref();
|
||
}, session.turnTimeout * 1000);
|
||
+ if (session.proxyFirstTurn) child.once("spawn", () => {
|
||
+ try { firstTurnMarker(session.runDir)(); }
|
||
+ catch {
|
||
+ markerFailed = true;
|
||
+ clearTimeout(clock);
|
||
+ kill("SIGTERM");
|
||
+ markerKill = setTimeout(() => kill("SIGKILL"), 5000).unref();
|
||
+ }
|
||
+ });
|
||
child.on("error", (e) => {
|
||
+ if (markerFailed) return; // wait for close/reap, never resolve early
|
||
+
|
||
clearTimeout(clock);
|
||
onChild(null);
|
||
resolve({ ok: false, reason: `adapter didn't start: ${e.code ?? e.message}` });
|
||
});
|
||
child.on("close", (code, signal) => {
|
||
clearTimeout(clock);
|
||
+ clearTimeout(markerKill);
|
||
kill("SIGKILL");
|
||
onChild(null);
|
||
+ if (markerFailed) return resolve({ ok: false, markerFailed: true, reason: "first-turn marker publication failed" });
|
||
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}` });
|
||
// A "marker" harness (row 65: the module's turnEnd()) writes the marker when its turn ends.
|
||
@@ -431,7 +447,8 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||
|
||
let session, cap, policy;
|
||
try {
|
||
- session = { ...DEFAULTS, ...JSON.parse(readFileSync(join(runDir, "session.json"), "utf8")), runDir };
|
||
+ session = { ...DEFAULTS, ...JSON.parse(readFileSync(join(runDir, "session.json"), "utf8")), runDir,
|
||
+ proxyFirstTurn: existsSync(join(runDir, "proxy-first-turn-enabled")) };
|
||
cap = JSON.parse(await readLine(stdin)).cap;
|
||
if (typeof cap !== "string" || !/^[0-9a-f]{64}$/.test(cap)) throw new Error("no capability on stdin");
|
||
policy = JSON.parse(readFileSync(session.bundle.policy, "utf8"));
|
||
@@ -516,7 +533,13 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||
// Row 156 (#1651): the landing turns, before any message. Each reads the state as a message turn
|
||
// does and is bracketed by usage with the decision id as its turn; it sends nothing and marks
|
||
// nothing read. The turn counter is shared with message turns.
|
||
- let gone = false;
|
||
+ let gone = false, markerFailure = false;
|
||
+ const failedMarker = turn => {
|
||
+ if (!turn.markerFailed) return false;
|
||
+ markerFailure = true; code = EXIT.usage;
|
||
+ log("runner: first-turn marker publication failed");
|
||
+ return true;
|
||
+ };
|
||
for (const e of session.landing ?? []) {
|
||
if (stopping) break;
|
||
let block;
|
||
@@ -538,6 +561,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||
unslop.read();
|
||
try { await client.call("usage.end", { turn: e.decision }); }
|
||
catch (err) { log(`runner: usage outcome unavailable: ${err.code ?? "failed"}`); }
|
||
+ if (failedMarker(turn)) { gone = true; break; }
|
||
log(`runner: landing turn ${n} for decision ${e.decision} (${e.task_ref}): ${turn.ok ? "ok" : `failed: ${turn.reason}`}`);
|
||
}
|
||
loop: while (!stopping && !gone) {
|
||
@@ -580,6 +604,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||
// A failed/stopped harness can still have used tokens. Missing data remains unknown.
|
||
try { await client.call("usage.end", { turn: m.id }); }
|
||
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
|
||
+ if (failedMarker(turn)) break loop;
|
||
if (stopping) break loop;
|
||
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
|
||
try {
|
||
@@ -628,6 +653,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||
unslop.read();
|
||
try { await client.call("usage.end", { turn: `${m.id}:retry` }); }
|
||
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
|
||
+ if (failedMarker(retry)) break loop;
|
||
if (stopping) break loop;
|
||
if (!retry.ok) log(`runner: retry turn ${n} for message ${m.id} failed: ${retry.reason}`);
|
||
try { await send(retry); }
|
||
@@ -650,7 +676,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||
}
|
||
server.close();
|
||
rmSync(session.toolSocket, { force: true });
|
||
- if (code === EXIT.stopped && !released) {
|
||
+ if ((code === EXIT.stopped || markerFailure) && !released) {
|
||
try {
|
||
await client.call("role.release");
|
||
} catch (e) {
|
||
diff --git a/packages/harness/tests/first-output.test.mjs b/packages/harness/tests/first-output.test.mjs
|
||
new file mode 100644
|
||
index 000000000..6a8c83103
|
||
--- /dev/null
|
||
+++ b/packages/harness/tests/first-output.test.mjs
|
||
@@ -0,0 +1,16 @@
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { join } from 'node:path';
|
||
+import { firstOutputMarker } from '../src/first-output.mjs';
|
||
+test('stdout marker contains a fixed token only, never output text; emitted once after nonempty output', t => {
|
||
+ const root = fs.mkdtempSync(join(tmpdir(), 'p2-output-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||
+ const observe = firstOutputMarker(root), file = join(root, 'proxy-first-output');
|
||
+ observe(''); assert.equal(fs.existsSync(file), false);
|
||
+ observe('fx331-private-output');
|
||
+ assert.equal(fs.readFileSync(file, 'utf8'), 'output\n'); assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||
+ const before = fs.statSync(file).mtimeMs;
|
||
+ observe('other output'); assert.equal(fs.statSync(file).mtimeMs, before);
|
||
+ assert.equal(fs.readFileSync(file, 'utf8').includes('fx331-private-output'), false);
|
||
+});
|
||
diff --git a/packages/harness/tests/first-turn.test.mjs b/packages/harness/tests/first-turn.test.mjs
|
||
new file mode 100644
|
||
index 000000000..65ad34d4d
|
||
--- /dev/null
|
||
+++ b/packages/harness/tests/first-turn.test.mjs
|
||
@@ -0,0 +1,38 @@
|
||
+// #650 timestamp producer's one-write invariant; prepared only.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { syncBuiltinESMExports } from 'node:module';
|
||
+import * as markers from '../src/first-output.mjs';
|
||
+function root(t){const p=fs.mkdtempSync(join(tmpdir(),'turn650-'));t.after(()=>fs.rmSync(p,{recursive:true,force:true}));return p;}
|
||
+test('650 first-turn producer publishes time metadata only and keeps output content separate',t=>{const p=root(t);assert.equal(typeof markers.firstTurnMarker,'function','first-turn protocol producer exists');const before=process.hrtime.bigint();markers.firstTurnMarker(p)();const value=fs.readFileSync(join(p,'proxy-first-turn'),'utf8');assert.match(value,/^turn [0-9]+\n$/);const n=BigInt(value.trim().split(' ')[1]);assert.ok(n>=before&&n<=process.hrtime.bigint());assert.equal(fs.statSync(join(p,'proxy-first-turn')).mode&0o777,0o600);assert.deepEqual(fs.readdirSync(p),['proxy-first-turn']);markers.firstOutputMarker(p)('fx650-private-output');assert.equal(fs.readFileSync(join(p,'proxy-first-output'),'utf8'),'output\n');assert.equal(value.includes('fx650-private-output'),false);});
|
||
+test('650 subsequent actual turn cannot reset or extend first timestamp',t=>{const p=root(t);assert.equal(typeof markers.firstTurnMarker,'function','first-turn one-shot producer exists');markers.firstTurnMarker(p)();const first=fs.readFileSync(join(p,'proxy-first-turn')),stat=fs.statSync(join(p,'proxy-first-turn'));markers.firstTurnMarker(p)();assert.equal(fs.readFileSync(join(p,'proxy-first-turn')).equals(first),true);assert.equal(fs.statSync(join(p,'proxy-first-turn')).mtimeMs,stat.mtimeMs);assert.deepEqual(fs.readdirSync(p),['proxy-first-turn']);});
|
||
+
|
||
+// Narrow test-only filesystem interception, only this fixture's private prefix.
|
||
+// Product has no injectable seam; every other file operation passes through.
|
||
+for(const fault of ['partial write','exclusive collision','link','close','unlink'])test(`650 producer ${fault} failure keeps ownership and fixed diagnostic`,t=>{
|
||
+ const p=fs.mkdtempSync(join(tmpdir(),'turn650-fault-'));let restore=()=>{};t.after(()=>{restore();fs.rmSync(p,{recursive:true,force:true});});assert.equal(typeof markers.firstTurnMarker,'function','guarded first-turn producer exists');
|
||
+ const original=Object.fromEntries(['openSync','writeFileSync','linkSync','closeSync','unlinkSync'].map(k=>[k,fs[k]]));let fd,temporary,closeCalls=0,unlinkCalls=0,collisionCreated=false;
|
||
+ const own=value=>typeof value==='string'&&value.startsWith(join(p,'.proxy-first-turn-'));
|
||
+ restore=()=>{for(const [k,v] of Object.entries(original))fs[k]=v;syncBuiltinESMExports();};
|
||
+ fs.openSync=function(file,...args){
|
||
+ if(!own(file))return original.openSync.call(this,file,...args);
|
||
+ temporary=file;
|
||
+ if(fault==='exclusive collision'){
|
||
+ const collisionFd=original.openSync.call(this,file,'wx',0o600);
|
||
+ try{original.writeFileSync.call(this,collisionFd,'owned-collision');}
|
||
+ finally{original.closeSync.call(this,collisionFd);}
|
||
+ collisionCreated=true;
|
||
+ }
|
||
+ fd=original.openSync.call(this,file,...args);return fd;
|
||
+ };
|
||
+ fs.writeFileSync=function(file,data,...args){if(file===fd&&fault==='partial write'){original.writeFileSync(file,'t');throw Error('fx650-write-failed');}return original.writeFileSync.call(this,file,data,...args);};
|
||
+ fs.linkSync=function(from,...args){if(own(from)&&fault==='link')throw Error('fx650-link-failed');return original.linkSync.call(this,from,...args);};
|
||
+ fs.closeSync=function(value){if(value===fd){closeCalls++;const result=original.closeSync.call(this,value);if(fault==='close')throw Error('fx650-close-failed');return result;}return original.closeSync.call(this,value);};
|
||
+ fs.unlinkSync=function(file){if(own(file)){unlinkCalls++;if(fault==='unlink')throw Error('fx650-unlink-failed');}return original.unlinkSync.call(this,file);};syncBuiltinESMExports();
|
||
+ assert.throws(()=>markers.firstTurnMarker(p)(),error=>error.message==='first-turn marker publication failed','publication errors expose fixed diagnostic only');
|
||
+ if(fault==='exclusive collision'){assert.equal(collisionCreated,true,'collision fixture created before product open');assert.equal(unlinkCalls,0,'collision does not confer temp ownership');assert.equal(fs.existsSync(temporary),true,'pre-existing collision not removed');assert.equal(closeCalls,0);}
|
||
+ else {assert.equal(closeCalls,1,'owned descriptor close always attempted');assert.equal(unlinkCalls,1,'owned temporary unlink always attempted');if(fault!=='unlink')assert.equal(fs.existsSync(temporary),false);}
|
||
+});
|
||
diff --git a/packages/harness/tests/model-turn-publication.test.mjs b/packages/harness/tests/model-turn-publication.test.mjs
|
||
new file mode 100644
|
||
index 000000000..e37bd3b0e
|
||
--- /dev/null
|
||
+++ b/packages/harness/tests/model-turn-publication.test.mjs
|
||
@@ -0,0 +1,41 @@
|
||
+// #650 controlled producer failure through REAL runner process and real broker.
|
||
+// Unconfined fault-injection tests; direct confined production marker proof is separate.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { spawn } from 'node:child_process';
|
||
+import { citationWorld } from '../../bus/tests/citation-helpers.mjs';
|
||
+import { serve } from '../../bus/src/server.mjs';
|
||
+const RUNNER=new URL('../src/runner.mjs',import.meta.url).pathname;
|
||
+const ADAPTER=new URL('./fixtures/citation-adapter.mjs',import.meta.url).pathname;
|
||
+const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||
+async function setup(t,mode){
|
||
+ const closers=[];let child,closed,server;
|
||
+ t.after(async()=>{const errors=[];if(child){try{if(child.exitCode===null&&child.signalCode===null)child.kill('SIGTERM');let timer;try{await Promise.race([closed,new Promise(resolve=>{timer=setTimeout(()=>{child.kill('SIGKILL');resolve();},5000);})]);await closed;}finally{clearTimeout(timer);}}catch{errors.push('own-runner-close');}}try{await server?.close();}catch{errors.push('server-close');}for(const close of [...closers].reverse()){try{await close();}catch{errors.push('owned-world-close');}}if(errors.length)throw new AggregateError(errors.map(code=>Error(code)),'owned producer fixture cleanup failed');});
|
||
+ const f=citationWorld({after:close=>closers.push(close)},{library:mode==='retry'}),runDir=join(f.root,'run'),bundle=join(runDir,'bundle');fs.mkdirSync(bundle,{recursive:true,mode:0o700});fs.mkdirSync(join(f.root,'ws'));
|
||
+ fs.writeFileSync(f.settings,JSON.stringify({replyLimits:{requireCitation:true}}));const brokerSocket=join(f.root,'broker.sock');server=await serve({broker:f.broker,path:brokerSocket});
|
||
+ fs.writeFileSync(join(runDir,'fixture-steps.json'),JSON.stringify([{text:'uncited'},{text:'still uncited'},{text:'unreachable'}]));
|
||
+ const adapter=join(runDir,'adapter.sh');fs.writeFileSync(adapter,`exec '${process.execPath}' '${ADAPTER}'\n`);fs.writeFileSync(join(bundle,'prompt.md'),'# fixture\n');fs.writeFileSync(join(bundle,'tools.json'),'[]');fs.writeFileSync(join(bundle,'policy.json'),JSON.stringify({needs:[],credentials:[]}));
|
||
+ fs.writeFileSync(join(runDir,'proxy-first-turn-enabled'),'enabled\n',{mode:0o600});
|
||
+ fs.writeFileSync(join(runDir,'session.json'),JSON.stringify({business:'acme',instance:'coder',run:'coder-run',harness:'pi',provider:'fixture',model:'fixture',brokerSocket,workspace:join(f.root,'ws'),sessionDir:join(runDir,'session'),adapter,bundle:{prompt:join(bundle,'prompt.md'),tools:join(bundle,'tools.json'),policy:join(bundle,'policy.json')},toolSocket:join(runDir,'tools.sock'),turnMarker:join(runDir,'turn.done'),turnEnd:'marker',harnessEnv:{},pollInterval:20,turnTimeout:10,...(mode==='landing'?{landing:[{decision:'d1',task_ref:'vikunja:3/7'},{decision:'d2',task_ref:'vikunja:3/8'}]}:{})}));
|
||
+ const trace=join(f.root,'owned-spawn-trace.json'),attempt=join(f.root,'owned-marker-attempts'),preload=join(f.root,'fault-preload.mjs'),targetAttempt=mode==='retry'?2:1;
|
||
+ // Test-only preload: all actual spawn/kill/fs operations pass through. Intercept
|
||
+ // only own adapter spawn, owned returned PID group and own temporary marker path.
|
||
+ fs.writeFileSync(preload,`import fs from 'node:fs';import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';
|
||
+ const runDir=${JSON.stringify(runDir)},adapter=${JSON.stringify(adapter)},trace=${JSON.stringify(trace)},attempt=${JSON.stringify(attempt)},target=${targetAttempt};
|
||
+ const link=fs.linkSync,spawn=cp.spawn,kill=process.kill,write=fs.writeFileSync;let tries=0;const state={groups:[],signals:[],unownedSignals:0};const save=()=>write(trace,JSON.stringify(state));
|
||
+ cp.spawn=function(command,args,...rest){const child=spawn.call(this,command,args,...rest);if(command==='/bin/sh'&&args?.[0]===adapter){state.groups.push(child.pid);save();}return child;};
|
||
+ process.kill=function(pid,signal){if(pid<0){if(!state.groups.includes(-pid)){state.unownedSignals++;save();throw Error('unowned signal refused');}state.signals.push({pid,signal});save();}return kill.call(this,pid,signal);};
|
||
+ fs.linkSync=function(from,to,...rest){if(typeof from==='string'&&from.startsWith(runDir+'/.proxy-first-turn-')&&to===runDir+'/proxy-first-turn'){write(attempt,String(++tries));if(tries===target)throw Error('fx650-marker-fault');}return link.call(this,from,to,...rest);};syncBuiltinESMExports();\n`);
|
||
+ child=spawn(process.execPath,['--import',preload,RUNNER,runDir],{stdio:['pipe','ignore','pipe'],env:{PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:process.env.MOSAIC_CONFIG}});let log='';child.stderr.on('data',chunk=>{log+=chunk;});child.stdin.on('error',()=>{});closed=new Promise((resolve,reject)=>{child.once('error',reject);child.once('close',(code,signal)=>resolve({code,signal}));});child.stdin.end(JSON.stringify({cap:f.agent})+'\n');
|
||
+ if(mode!=='landing'){const ready=Date.now()+5000;while(!fs.existsSync(join(runDir,'tools.sock'))&&Date.now()<ready)await delay(20);assert.equal(fs.existsSync(join(runDir,'tools.sock')),true,'real runner tool server ready');await f.call(f.human,'message.send',{to:'coder',body:'first fixture request',class:'REQUEST'});await f.call(f.human,'message.send',{to:'coder',body:'second fixture request',class:'REQUEST'});}
|
||
+ return {...f,runDir,closed,log:()=>log,attempt,targetAttempt,trace};
|
||
+}
|
||
+for(const mode of ['landing','message','retry'])test(`650 ${mode} publication failure reaps owned adapter then releases and exits2 without later turn`,{timeout:60000},async t=>{
|
||
+ const f=await setup(t,mode),deadline=Date.now()+15000;while(!f.log().includes('runner: first-turn marker publication failed')&&Date.now()<deadline)await delay(20);
|
||
+ assert.equal(f.log().includes('runner: first-turn marker publication failed'),true,'controlled publication failure observed at real caller');
|
||
+ const outcome=await f.closed;assert.equal(outcome.code,2);assert.equal(outcome.signal,null);assert.equal(f.log().includes('fx650-marker-fault'),false,'raw publication error never logged');assert.equal(fs.existsSync(join(f.runDir,'tools.sock')),false);
|
||
+ assert.equal(f.store.get("SELECT op FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").op,'release','existing role release reached');assert.equal(Number(fs.readFileSync(f.attempt,'utf8')),f.targetAttempt,'no later timestamp attempt after failure');
|
||
+ const traced=JSON.parse(fs.readFileSync(f.trace,'utf8'));assert.equal(traced.groups.length,f.targetAttempt,'no later adapter turn after publication failure');assert.ok(traced.signals.some(s=>s.signal==='SIGTERM'),'owned adapter TERM observed');assert.equal(traced.signals.every(s=>traced.groups.includes(-s.pid)),true,'no registry or unrelated PID signalled');assert.equal(traced.unownedSignals,0,'no unowned group signal attempted');
|
||
+});
|
||
diff --git a/packages/model-proxy/README.md b/packages/model-proxy/README.md
|
||
index a173f9db6..ea2c0bd62 100644
|
||
--- a/packages/model-proxy/README.md
|
||
+++ b/packages/model-proxy/README.md
|
||
@@ -1,4 +1,4 @@
|
||
-# Model proxy (P1, #1827)
|
||
+# Model proxy (P1 #1827, P2 #1829)
|
||
|
||
The bus initializes and manages one pinned CLIProxyAPI per instance. PIN records
|
||
release URLs and independently reviewable SHA256s of the archive, executable and
|
||
@@ -20,8 +20,8 @@ contains `proxy.port` and `proxy.binary` only when proxy.json exists.
|
||
|
||
The management client is imported by the bus; it only GETs/PUTs the v8 list on
|
||
127.0.0.1, never follows redirects, and emits receipts with SHA256 key IDs only.
|
||
-Client-key values are held in memory, never returned by a bus verb. P2 adds
|
||
-per-agent lifecycle integration; this row creates only the sentinel.
|
||
+Client-key values are never returned by an agent-facing bus verb. The bus manages
|
||
+per-agent lifecycle below; initialization creates only the sentinel.
|
||
|
||
The unit starts through a hash-checking runner with the pinned panel directory
|
||
and `-local-model`, so catalogs require no network. Render/install is explicit;
|
||
@@ -47,3 +47,34 @@ Then start the bus normally and retry init from the human terminal. If the bus
|
||
or initialization state is uncertain, leave the lock in place. A refused fetch
|
||
may leave empty `opt/cliproxyapi/` parent directories; they are not an installed
|
||
version and need not be removed before retrying.
|
||
+
|
||
+P2 adds an implicit `model` credential for every agent in proxy mode. The bus
|
||
+confirms the v8 key-list write before storing a key at
|
||
+`<configDir>/secrets/agents/<business>/<agent>/model` (0600, directories 0700).
|
||
+The private file holds the key and its lifecycle metadata. Status and receipts
|
||
+contain key IDs, issue time, rotation date and state; they never contain values.
|
||
+No role or business `credentials.model` reference is needed or accepted.
|
||
+
|
||
+Rotation defaults to 30 days. New launches use the replacement key. The old key
|
||
+stays until its runs end or its grace expires; a failed bounded session stop
|
||
+keeps both the old key and its retirement metadata for the next check. Revoke
|
||
+requires a confirming management GET and polling `/v1/models` to 401 before local
|
||
+cleanup. Polling has a five-second total deadline and 25ms intervals. A durable
|
||
+`revoking` state blocks launches before preflight, and boot reconciliation
|
||
+finishes it without restoring that key. Reconciliation preserves foreign keys
|
||
+and refuses launches when this instance's sentinel is absent.
|
||
+
|
||
+For a new instance only, human `proxy init` also accepts
|
||
+`--rotate-grace-minutes N` (1–1440, default 120) and
|
||
+`--first-output-seconds N` (1–3600, default 120). These are optional fields in
|
||
+proxy.json; existing two-field sidecars remain valid. There is no per-agent
|
||
+rotation-period setter in P2. Fetch also refuses agent-marked and tester calls
|
||
+before reading config or touching the network/disk.
|
||
+
|
||
+The trusted launcher requests `modelPreflight` with business/role and receives
|
||
+exactly `{ baseUrl, key }`. That answer never passes through an agent socket or
|
||
+status API. A 401 or other non-success HTTP status refuses `proxy-key-refused`;
|
||
+a failed connection refuses `proxy-down`. No stdout by the configured deadline
|
||
+requests a bounded stop and records `proxy-stall` with key IDs. The runner writes
|
||
+only a fixed token to `<run>/proxy-first-output`, never stdout contents. A stop
|
||
+failure records/refuses `proxy-stop-failed` and retains the key.
|
||
diff --git a/packages/model-proxy/src/client.mjs b/packages/model-proxy/src/client.mjs
|
||
index d0829b789..16433df00 100644
|
||
--- a/packages/model-proxy/src/client.mjs
|
||
+++ b/packages/model-proxy/src/client.mjs
|
||
@@ -62,6 +62,27 @@ export class ManagementClient {
|
||
}
|
||
getKeys() { return this.#call('GET'); }
|
||
putKeys(keys) { if (!validKeys(keys)) refuse('proxy-client-invalid'); return this.#call('PUT', keys); }
|
||
+ // Client-key authentication only. Never send the management key to the model route,
|
||
+ // follow a Location, consume/log the response body or trust an ambient dispatcher.
|
||
+ async probeKey(key, { timeout = this.#timeout } = {}) {
|
||
+ if (!validManagementKey(key) || !Number.isFinite(timeout) || timeout <= 0) refuse('proxy-client-invalid');
|
||
+ let timer, status = 0;
|
||
+ try {
|
||
+ return await new Promise((resolve, reject) => {
|
||
+ const req = http.request(this.#base + '/v1/models', { method: 'GET', headers: { Authorization: `Bearer ${key}` } }, response => {
|
||
+ status = response.statusCode;
|
||
+ response.destroy();
|
||
+ resolve(status);
|
||
+ });
|
||
+ req.on('error', () => reject(new ProxyError('proxy-down')));
|
||
+ timer = setTimeout(() => { req.destroy(); reject(new ProxyError('proxy-down')); }, timeout);
|
||
+ req.end();
|
||
+ });
|
||
+ } finally {
|
||
+ clearTimeout(timer);
|
||
+ this.#receipt({ call: 'GET /v1/models', status, keyIds: [sha256(key).slice(0, 12)] });
|
||
+ }
|
||
+ }
|
||
}
|
||
|
||
// Trusted bus boot only: no CLI/Console/session exports this value or client.
|
||
diff --git a/packages/model-proxy/src/common.mjs b/packages/model-proxy/src/common.mjs
|
||
index ab6d30fb1..1a733d237 100644
|
||
--- a/packages/model-proxy/src/common.mjs
|
||
+++ b/packages/model-proxy/src/common.mjs
|
||
@@ -1,11 +1,8 @@
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
-import { validateHeaderValue } from 'node:http';
|
||
import { createHash } from 'node:crypto';
|
||
-export class ProxyError extends Error {
|
||
- constructor(code) { super(code); this.name = 'ProxyError'; this.code = code; }
|
||
-}
|
||
-export const refuse = code => { throw new ProxyError(code); };
|
||
+import { ProxyError, refuse, validManagementKey, proxyTiming } from './validation.mjs';
|
||
+export { ProxyError, refuse, validManagementKey, proxyTiming };
|
||
export const sha256 = value => createHash('sha256').update(value).digest('hex');
|
||
export const PIN = Object.freeze(JSON.parse(fs.readFileSync(new URL('../PIN', import.meta.url), 'utf8')));
|
||
// Refuse symlink components before handling any state. Callers only create owned new leaves.
|
||
@@ -28,9 +25,3 @@ export function regularBytes(file, code) {
|
||
} catch { refuse(code); }
|
||
finally { if (fd !== undefined) fs.closeSync(fd); }
|
||
}
|
||
-
|
||
-export function validManagementKey(key) {
|
||
- if (typeof key !== 'string' || !/^[\x21-\x7e]{1,4096}$/.test(key)) return false;
|
||
- try { validateHeaderValue('Authorization', `Bearer ${key}`); return true; }
|
||
- catch { return false; }
|
||
-}
|
||
diff --git a/packages/model-proxy/src/settings.mjs b/packages/model-proxy/src/settings.mjs
|
||
index a485b9e15..8dccb1271 100644
|
||
--- a/packages/model-proxy/src/settings.mjs
|
||
+++ b/packages/model-proxy/src/settings.mjs
|
||
@@ -2,23 +2,26 @@ import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import net from 'node:net';
|
||
import { randomBytes } from 'node:crypto';
|
||
-import { PIN, refuse, safePath, regularBytes, sha256, validManagementKey } from './common.mjs';
|
||
+import { PIN, refuse, safePath, regularBytes, sha256, validManagementKey, proxyTiming } from './common.mjs';
|
||
export function installPaths(dataRoot, pin = PIN) {
|
||
const dir = path.join(dataRoot, 'opt', 'cliproxyapi', pin.version);
|
||
return { dir, binary: path.join(dir, 'cli-proxy-api'), panel: path.join(dir, 'static', 'management.html') };
|
||
}
|
||
export function validateProxy(value, dataRoot, pin = PIN) {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)
|
||
- || Object.keys(value).sort().join(',') !== 'binary,port'
|
||
+ || Object.keys(value).some(k => !['binary', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'].includes(k))
|
||
|| !Number.isInteger(value.port) || value.port < 1024 || value.port > 65535 || value.port === 8317
|
||
|| value.binary !== installPaths(dataRoot, pin).binary) refuse('proxy-config-invalid');
|
||
- return { port: value.port, binary: value.binary };
|
||
+ return { port: value.port, binary: value.binary, ...proxyTiming(value) };
|
||
}
|
||
export function loadProxy(configDir, dataRoot) {
|
||
const file = path.join(configDir, 'proxy.json');
|
||
try { fs.lstatSync(file); } catch (e) { if (e.code === 'ENOENT') return undefined; refuse('proxy-config-invalid'); }
|
||
try { return validateProxy(JSON.parse(regularBytes(file, 'proxy-config-invalid')), dataRoot); }
|
||
- catch { refuse('proxy-config-invalid'); }
|
||
+ catch (error) {
|
||
+ if (['proxy-rotate-grace-invalid', 'proxy-first-output-invalid'].includes(error.code)) throw error;
|
||
+ refuse('proxy-config-invalid');
|
||
+ }
|
||
}
|
||
export function verifyInstall(dataRoot, pin = PIN) {
|
||
const paths = installPaths(dataRoot, pin);
|
||
@@ -50,8 +53,9 @@ export function initialConfig({ dataRoot, port, key, sentinel }) {
|
||
};
|
||
}
|
||
// Called only by the bus's path-bound human verb, never by an agent or the tester.
|
||
-export async function initializeProxy({ configDir, dataRoot, key, port, pin = PIN }) {
|
||
+export async function initializeProxy({ configDir, dataRoot, key, port, pin = PIN, ...options }) {
|
||
if (!validManagementKey(key)) refuse('proxy-key-invalid');
|
||
+ const timing = proxyTiming(options);
|
||
safePath(configDir, 'proxy-init-failed'); safePath(dataRoot, 'proxy-init-failed');
|
||
const paths = verifyInstall(dataRoot, pin);
|
||
const settingsFile = path.join(configDir, 'proxy.json'), proxyDir = path.join(dataRoot, 'proxy');
|
||
@@ -77,7 +81,7 @@ export async function initializeProxy({ configDir, dataRoot, key, port, pin = PI
|
||
const tag = sha256(configDir).slice(0, 12);
|
||
const sentinel = `msk-${tag}-sentinel-${randomBytes(32).toString('base64url')}`;
|
||
fs.writeFileSync(path.join(proxyDir, 'config.yaml'), JSON.stringify(initialConfig({ dataRoot, port: reservation.port, key, sentinel }), null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
||
- const settings = { port: reservation.port, binary: paths.binary };
|
||
+ const settings = { port: reservation.port, binary: paths.binary, ...timing };
|
||
const settingsFd = fs.openSync(settingsFile, 'wx', 0o600); ownsSettings = true;
|
||
try { fs.writeFileSync(settingsFd, JSON.stringify(settings, null, 2) + '\n'); } finally { fs.closeSync(settingsFd); }
|
||
return settings;
|
||
diff --git a/packages/model-proxy/src/validation.mjs b/packages/model-proxy/src/validation.mjs
|
||
new file mode 100644
|
||
index 000000000..be83e2954
|
||
--- /dev/null
|
||
+++ b/packages/model-proxy/src/validation.mjs
|
||
@@ -0,0 +1,21 @@
|
||
+import { validateHeaderValue } from 'node:http';
|
||
+export class ProxyError extends Error {
|
||
+ constructor(code) { super(code); this.name = 'ProxyError'; this.code = code; }
|
||
+}
|
||
+export const refuse = code => { throw new ProxyError(code); };
|
||
+
|
||
+export function validManagementKey(key) {
|
||
+ if (typeof key !== 'string' || !/^[\x21-\x7e]{1,4096}$/.test(key)) return false;
|
||
+ try { validateHeaderValue('Authorization', `Bearer ${key}`); return true; }
|
||
+ catch { return false; }
|
||
+}
|
||
+
|
||
+export function proxyTiming(value) {
|
||
+ const result = {};
|
||
+ for (const [field, max, code] of [['rotateGraceMinutes', 1440, 'proxy-rotate-grace-invalid'], ['firstOutputSeconds', 3600, 'proxy-first-output-invalid']]) {
|
||
+ if (!Object.hasOwn(value, field)) continue;
|
||
+ if (!Number.isInteger(value[field]) || value[field] < 1 || value[field] > max) refuse(code);
|
||
+ result[field] = value[field];
|
||
+ }
|
||
+ return result;
|
||
+}
|
||
diff --git a/packages/model-proxy/tests/client-probe.test.mjs b/packages/model-proxy/tests/client-probe.test.mjs
|
||
new file mode 100644
|
||
index 000000000..252df33d9
|
||
--- /dev/null
|
||
+++ b/packages/model-proxy/tests/client-probe.test.mjs
|
||
@@ -0,0 +1,49 @@
|
||
+// P2 RED: catch wrong-key preflight, 401 success, redirect following and unbounded probes.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import http from 'node:http';
|
||
+import { ManagementClient } from '../src/client.mjs';
|
||
+
|
||
+async function fixture(t, handler) {
|
||
+ const server = http.createServer(handler);
|
||
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||
+ t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }));
|
||
+ const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||
+ const receipts = [];
|
||
+ return { server, receipts, client: new ManagementClient({ baseUrl, key: 'fx331-manage', receipt: r => receipts.push(r) }) };
|
||
+}
|
||
+for (const status of [200, 401, 503]) {
|
||
+ test(`probeKey observes ${status} with one agent-authenticated GET /v1/models`, async t => {
|
||
+ const calls = [];
|
||
+ const f = await fixture(t, (req, res) => {
|
||
+ calls.push({ path: req.url, method: req.method, ownKey: req.headers.authorization === 'Bearer fx331-agent' });
|
||
+ res.writeHead(status); res.end('fixture-body');
|
||
+ });
|
||
+ assert.equal(typeof f.client.probeKey, 'function', 'client must support bounded agent-key probe');
|
||
+ assert.equal(await f.client.probeKey('fx331-agent', { timeout: 100 }), status);
|
||
+ assert.deepEqual(calls, [{ path: '/v1/models', method: 'GET', ownKey: true }]);
|
||
+ const output = JSON.stringify(f.receipts);
|
||
+ assert.equal(output.includes('fx331-agent'), false);
|
||
+ assert.equal(output.includes('fx331-manage'), false);
|
||
+ });
|
||
+}
|
||
+test('proxy-down: agent-key probe connection failure is sanitized', async t => {
|
||
+ const f = await fixture(t, (_req, res) => res.end());
|
||
+ await new Promise(resolve => f.server.close(resolve));
|
||
+ assert.equal(typeof f.client.probeKey, 'function');
|
||
+ await assert.rejects(f.client.probeKey('fx331-agent', { timeout: 100 }), e => e.code === 'proxy-down' && !e.message.includes('fx331-agent'));
|
||
+});
|
||
+test('proxy-down: silent listener cannot extend probe request deadline', async t => {
|
||
+ const f = await fixture(t, () => {});
|
||
+ assert.equal(typeof f.client.probeKey, 'function');
|
||
+ const started = performance.now();
|
||
+ await assert.rejects(f.client.probeKey('fx331-agent', { timeout: 50 }), e => e.code === 'proxy-down');
|
||
+ assert.ok(performance.now() - started < 1000, 'bounded HTTP probe must settle');
|
||
+});
|
||
+test('agent-key probe returns redirect status without following Location or exposing key', async t => {
|
||
+ let calls = 0;
|
||
+ const f = await fixture(t, (_req, res) => { calls++; res.writeHead(302, { Location: '/wrong' }); res.end(); });
|
||
+ assert.equal(typeof f.client.probeKey, 'function');
|
||
+ assert.equal(await f.client.probeKey('fx331-agent', { timeout: 100 }), 302);
|
||
+ assert.equal(calls, 1);
|
||
+});
|
||
diff --git a/packages/model-proxy/tests/p2-settings.test.mjs b/packages/model-proxy/tests/p2-settings.test.mjs
|
||
new file mode 100644
|
||
index 000000000..28fd3a8dd
|
||
--- /dev/null
|
||
+++ b/packages/model-proxy/tests/p2-settings.test.mjs
|
||
@@ -0,0 +1,15 @@
|
||
+// P2 named settings refuse independently; legacy sidecars retain their shape.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import { validateProxy, installPaths } from '../src/settings.mjs';
|
||
+const dataRoot = '/fixture-data';
|
||
+const base = { port: 18432, binary: installPaths(dataRoot).binary };
|
||
+test('proxy settings without optional timing fields remain valid', () => assert.deepEqual(validateProxy(base, dataRoot), base));
|
||
+test('proxy settings accept each positive upper bound', () => {
|
||
+ assert.deepEqual(validateProxy({ ...base, rotateGraceMinutes: 1440, firstOutputSeconds: 3600 }, dataRoot), { ...base, rotateGraceMinutes: 1440, firstOutputSeconds: 3600 });
|
||
+});
|
||
+for (const [field, maximum, code] of [['rotateGraceMinutes', 1440, 'proxy-rotate-grace-invalid'], ['firstOutputSeconds', 3600, 'proxy-first-output-invalid']]) {
|
||
+ for (const value of [0, -1, maximum + 1, 1.5, '120', null]) test(`${code}: ${JSON.stringify(value)}`, () => {
|
||
+ assert.throws(() => validateProxy({ ...base, [field]: value }, dataRoot), e => e.code === code);
|
||
+ });
|
||
+}
|
||
diff --git a/packages/model-proxy/tests/validation.test.mjs b/packages/model-proxy/tests/validation.test.mjs
|
||
new file mode 100644
|
||
index 000000000..d104f98ea
|
||
--- /dev/null
|
||
+++ b/packages/model-proxy/tests/validation.test.mjs
|
||
@@ -0,0 +1,35 @@
|
||
+// R5: the sandbox may load parsing helpers, never proxy PIN or host state.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import { readFileSync } from 'node:fs';
|
||
+import * as common from '../src/common.mjs';
|
||
+
|
||
+test('validation has only the exact parsing import and no state or I/O capability', () => {
|
||
+ const source = readFileSync(new URL('../src/validation.mjs', import.meta.url), 'utf8');
|
||
+ assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), ["import { validateHeaderValue } from 'node:http';"]);
|
||
+ assert.equal((source.match(/\bimport\b/g) ?? []).length, 1);
|
||
+ assert.doesNotMatch(source, /\b(?:require|process|globalThis|global|fetch|eval|Function|WebSocket|XMLHttpRequest|Buffer|URL|fs|path|PIN|config|state|env)\b/);
|
||
+ assert.doesNotMatch(source, /\b(?:readFile|writeFile|open|request|createServer|connect|listen|setTimeout|setInterval)\w*\b/);
|
||
+ // No namespace/default import, aliases, reexports or lazy imports can expose node:http I/O.
|
||
+ assert.equal((source.match(/\bvalidateHeaderValue\b/g) ?? []).length, 2);
|
||
+ assert.doesNotMatch(source, /\bexport\s+(?:\*|\{)|\bimport\s*\(/);
|
||
+});
|
||
+
|
||
+test('common reexports the exact validation bindings and errors keep their identity', async () => {
|
||
+ const validation = await import('../src/validation.mjs');
|
||
+ for (const name of ['ProxyError', 'refuse', 'validManagementKey', 'proxyTiming']) assert.equal(common[name], validation[name], name);
|
||
+ assert.throws(() => validation.refuse('fixture-refusal'), e => e instanceof common.ProxyError && e.code === 'fixture-refusal' && e.message === 'fixture-refusal');
|
||
+ assert.throws(() => common.refuse('fixture-refusal'), e => e instanceof validation.ProxyError);
|
||
+});
|
||
+
|
||
+test('validation retains management header parsing and timing bounds', async () => {
|
||
+ const { validManagementKey, proxyTiming, ProxyError } = await import('../src/validation.mjs');
|
||
+ for (const value of ['a', 'fx331-key', '!~']) assert.equal(validManagementKey(value), true);
|
||
+ for (const value of ['', 'with space', 'line\n', 'line\r', '\t', '\u007f', '\u0080', null, 7]) assert.equal(validManagementKey(value), false);
|
||
+ assert.deepEqual(proxyTiming({}), {});
|
||
+ assert.deepEqual(proxyTiming({ rotateGraceMinutes: 1, firstOutputSeconds: 1 }), { rotateGraceMinutes: 1, firstOutputSeconds: 1 });
|
||
+ assert.deepEqual(proxyTiming({ rotateGraceMinutes: 1440, firstOutputSeconds: 3600 }), { rotateGraceMinutes: 1440, firstOutputSeconds: 3600 });
|
||
+ for (const [field, max, code] of [['rotateGraceMinutes', 1440, 'proxy-rotate-grace-invalid'], ['firstOutputSeconds', 3600, 'proxy-first-output-invalid']]) {
|
||
+ for (const value of [0, -1, max + 1, 1.5, '120', null]) assert.throws(() => proxyTiming({ [field]: value }), e => e instanceof ProxyError && e.code === code);
|
||
+ }
|
||
+});
|
||
diff --git a/packages/seat/src/sandbox.mjs b/packages/seat/src/sandbox.mjs
|
||
index b30091e62..ed4550c6e 100644
|
||
--- a/packages/seat/src/sandbox.mjs
|
||
+++ b/packages/seat/src/sandbox.mjs
|
||
@@ -49,6 +49,8 @@ const KILL_WITH_PARENT = ["/usr/bin/setpriv", "--pdeathsig", "KILL", "--"];
|
||
// is missing here.
|
||
export const RUNNER_FILES = Object.freeze([
|
||
"packages/harness/src/runner.mjs",
|
||
+ // Row 331: fixed output-exists marker; writes only within the existing run directory.
|
||
+ "packages/harness/src/first-output.mjs",
|
||
"packages/harness/src/gate.mjs",
|
||
"packages/harness/src/tools.mjs",
|
||
"packages/harness/src/git-tools.mjs",
|
||
@@ -57,6 +59,8 @@ export const RUNNER_FILES = Object.freeze([
|
||
"packages/harness/src/pi-extension.mjs",
|
||
"packages/bus/src/client.mjs",
|
||
"packages/bus/src/broker.mjs",
|
||
+ // Row 331 r5: parsing only; common.mjs and PIN remain outside the sandbox.
|
||
+ "packages/model-proxy/src/validation.mjs",
|
||
"packages/bus/src/address.mjs",
|
||
"packages/bus/src/landing.mjs",
|
||
"packages/business/src/vocabulary.mjs",
|
||
diff --git a/packages/seat/tests/model-markers-confined.test.mjs b/packages/seat/tests/model-markers-confined.test.mjs
|
||
new file mode 100644
|
||
index 000000000..03277e71b
|
||
--- /dev/null
|
||
+++ b/packages/seat/tests/model-markers-confined.test.mjs
|
||
@@ -0,0 +1,45 @@
|
||
+// #650 direct marker proof through the existing confined runner and writable runDir.
|
||
+// No new mounts, fake client configuration or live proxy.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import fs from 'node:fs';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { join } from 'node:path';
|
||
+import { Store } from '../../bus/src/store.mjs';
|
||
+import { Broker } from '../../bus/src/broker.mjs';
|
||
+import { serve } from '../../bus/src/server.mjs';
|
||
+import { newRun, runnerOf, sessionEnv, spawnSession, writeSessionFile } from '../src/session.mjs';
|
||
+import { startTimeOf } from '../src/proc.mjs';
|
||
+import { sandboxProblem } from '../src/sandbox.mjs';
|
||
+import { createLoader } from '../../plugins/src/loader.mjs';
|
||
+const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||
+async function confined(t,{enabled=false}={}){
|
||
+ const root=fs.realpathSync(fs.mkdtempSync(join(tmpdir(),'marker650-')));let store,server,child,exited,runDir;
|
||
+ t.after(async()=>{const failures=[];if(child){try{child.kill('SIGTERM');let timer;try{await Promise.race([exited,new Promise(resolve=>{timer=setTimeout(()=>{child.kill('SIGKILL');resolve();},5000);})]);await exited;}finally{clearTimeout(timer);}}catch{failures.push('own-child-close');}}try{await server?.close();}catch{failures.push('server-close');}try{store?.close();}catch{failures.push('store-close');}try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('root-cleanup');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned marker fixture cleanup failed');});
|
||
+ const dataRoot=join(root,'data');fs.mkdirSync(dataRoot,{mode:0o700});store=new Store(dataRoot);
|
||
+ const broker=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'pm',delivery:'pm'},roles:{pm:{authority:{withinRole:['message.send'],crossRole:[]}},coder:{authority:{withinRole:['message.send'],crossRole:[]}}}}}});
|
||
+ const brokerSocket=join(root,'bus.sock');server=await serve({broker,path:brokerSocket});
|
||
+ const made=newRun(dataRoot,'demo'),run=made.run;runDir=made.runDir;const workspace=join(root,'ws'),bundle=join(runDir,'bundle');fs.mkdirSync(workspace);fs.mkdirSync(bundle,{mode:0o700});
|
||
+ fs.writeFileSync(join(bundle,'prompt.md'),'# test\n');fs.writeFileSync(join(bundle,'policy.json'),JSON.stringify({harness:'pi',workspace,tools:[],typed:[],network:'none',needs:[],credentials:[]}));fs.writeFileSync(join(bundle,'tools.json'),'[]');
|
||
+ const adapter=join(root,'adapter.sh');fs.writeFileSync(adapter,"printf '%s' 'fx650-private-output'\nprintf 'done\\n' > \"$MOSAIC_TURN_MARKER\"\n",{mode:0o644});
|
||
+ if(enabled)fs.writeFileSync(join(runDir,'proxy-first-turn-enabled'),'enabled\n',{mode:0o600,flag:'wx'});
|
||
+ writeSessionFile(runDir,{business:'demo',instance:'coder',run,launchedBy:'pm',harness:'pi',provider:'fake',model:'fake-model',brokerSocket,workspace,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'),turnEnd:'marker',harnessEnv:{},pollInterval:50});
|
||
+ const loader=createLoader({log:()=>{}}),loaded=await loader.load({harness:['pi']});assert.deepEqual(loaded.refused,[]);const harnessMounts=loader.modules('harness')[0].mounts({env:{}});
|
||
+ child=spawnSession({runDir,env:sessionEnv({HOME:process.env.HOME,PATH:process.env.PATH},run),paths:{workspace,adapter,harnessMounts,brokerSocket},business:'demo',role:'coder'});
|
||
+ exited=new Promise((resolve,reject)=>{child.once('error',reject);child.once('close',(code,signal)=>resolve({code,signal}));});await runnerOf(child);
|
||
+ const cap=broker.bindLaunch({business:'demo',role:'coder',run,harness:'pi',pid:child.pid,startTime:startTimeOf(child.pid)}),pm=broker.bindLaunch({business:'demo',role:'pm',run:'marker-pm',harness:'pi'});broker.request(pm,{verb:'role.claim'});child.stdin.end(JSON.stringify({cap})+'\n');
|
||
+ async function ask(body){const sent=broker.request(pm,{verb:'message.send',args:{to:'coder',body,class:'REQUEST'}}),deadline=Date.now()+15000;while(Date.now()<deadline){const reply=broker.request(pm,{verb:'message.receive'}).find(r=>r.in_reply_to===sent.id);if(reply)return reply.body;await delay(25);}throw Error('confined marker response not observed');}
|
||
+ return {runDir,ask,store,exited,send:body=>broker.request(pm,{verb:'message.send',args:{to:'coder',body,class:'REQUEST'}})};
|
||
+}
|
||
+const skip=sandboxProblem()??false; // Any unavailable confinement is UNVERIFIED, never green evidence.
|
||
+test('650 confined actual stdout writes fixed output marker in existing runDir without changing reply',{skip,timeout:60000},async t=>{
|
||
+ const f=await confined(t),file=join(f.runDir,'proxy-first-output');await delay(150);assert.equal(fs.existsSync(file),false,'idle confined runner has no output marker');
|
||
+ assert.equal(await f.ask('first'),'fx650-private-output');assert.equal(fs.readFileSync(file,'utf8'),'output\n');assert.equal(fs.statSync(file).mode&0o777,0o600);
|
||
+ const stat=fs.statSync(file);assert.equal(await f.ask('second'),'fx650-private-output');assert.equal(fs.statSync(file).mtimeMs,stat.mtimeMs);assert.equal(fs.readFileSync(file,'utf8').includes('fx650-private-output'),false);assert.equal(fs.existsSync(join(f.runDir,'proxy-first-turn')),false,'non-proxy actual turns never publish first-turn timestamps');
|
||
+});
|
||
+test('650 confined actual adapter turn publishes first timestamp once and idle stays unarmed',{skip,timeout:60000},async t=>{
|
||
+ const f=await confined(t,{enabled:true}),file=join(f.runDir,'proxy-first-turn');await delay(150);assert.equal(fs.existsSync(file),false,'idle confined runner has no turn marker');
|
||
+ const before=process.hrtime.bigint();assert.equal(await f.ask('first'),'fx650-private-output');assert.equal(fs.existsSync(file),true,'actual confined turn publishes first-turn marker');
|
||
+ const value=fs.readFileSync(file,'utf8');assert.match(value,/^turn [0-9]+\n$/);const stamp=BigInt(value.trim().split(' ')[1]);assert.ok(stamp>=before&&stamp<=process.hrtime.bigint(),'host and confined process share monotonic clock');assert.equal(fs.statSync(file).mode&0o777,0o600);
|
||
+ const stat=fs.statSync(file);assert.equal(await f.ask('second'),'fx650-private-output');assert.equal(fs.readFileSync(file,'utf8'),value);assert.equal(fs.statSync(file).mtimeMs,stat.mtimeMs);assert.equal(fs.readdirSync(f.runDir).some(n=>n.startsWith('.proxy-first-turn-')),false,'atomic publication leaves no temp marker');
|
||
+});
|
||
diff --git a/packages/seat/tests/sandbox.test.mjs b/packages/seat/tests/sandbox.test.mjs
|
||
index b42d81d82..df42bacdd 100644
|
||
--- a/packages/seat/tests/sandbox.test.mjs
|
||
+++ b/packages/seat/tests/sandbox.test.mjs
|
||
@@ -53,6 +53,17 @@ test("RUNNER_FILES is exactly what the runner and the harness sources it starts
|
||
assert.deepEqual([...seen].sort(), [...RUNNER_FILES].sort());
|
||
});
|
||
|
||
+test("row 331 mounts only the parsing validator and fixed marker code, never proxy state", (t) => {
|
||
+ const root = scratch(t);
|
||
+ const list = binds({ runDir: join(root, "run"), workspace: join(root, "ws"), adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") });
|
||
+ for (const relative of ["packages/model-proxy/src/validation.mjs", "packages/harness/src/first-output.mjs"]) {
|
||
+ const src = join(REPO, relative);
|
||
+ assert.deepEqual(list.filter(b => b.src === src), [{ kind: "ro", src, why: "a repository file the runner loads" }]);
|
||
+ }
|
||
+ assert.deepEqual(list.filter(b => b.src?.startsWith(join(REPO, "packages/model-proxy"))).map(b => b.src), [join(REPO, "packages/model-proxy/src/validation.mjs")]);
|
||
+ assert.equal(list.some(b => b.src === REPO || b.src === join(REPO, "packages/harness/src")), false);
|
||
+});
|
||
+
|
||
test("sandboxProblem names a missing bwrap and a bwrap that fails", (t) => {
|
||
const root = scratch(t);
|
||
assert.equal(sandboxProblem({ bwrap: join(root, "nope") }), `${join(root, "nope")} is missing`);
|
||
diff --git a/scripts/mosaic-config.mjs b/scripts/mosaic-config.mjs
|
||
index 76a07fffd..b1866a890 100755
|
||
--- a/scripts/mosaic-config.mjs
|
||
+++ b/scripts/mosaic-config.mjs
|
||
@@ -353,7 +353,7 @@ async function validate(document, file) {
|
||
const { loadProxy } = await import("../packages/model-proxy/src/settings.mjs");
|
||
proxy = loadProxy(path.dirname(file), dataRoot);
|
||
}
|
||
- } catch { fail(2, "proxy-config-invalid"); }
|
||
+ } catch (error) { fail(2, ['proxy-rotate-grace-invalid', 'proxy-first-output-invalid'].includes(error.code) ? error.code : "proxy-config-invalid"); }
|
||
|
||
return {
|
||
configVersion: document.configVersion,
|