feat: integrate reviewed proxy page and managed model keys
Source-candidate: poe d9a564e6d20f5f0fd75ce45b12cbbf8bb4c9d568\nSource-candidate: magica38ba5762b06716db1dee00d0afec162656488f5c\nValidated321whole-file phase plus whole bus1349/model-proxy100/webui490; final gate tooling checks pending, not landed.
This commit is contained in:
1 parent
acd2f4abfa
commit
362893d043
87 files changed
+4766
-99
No files matched your search
@@ -216,7 +216,10 @@ delists the agent, and no session starts on a key the proxy refuses.
|
|||||||
how the code relies on that.
|
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
|
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
|
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
|
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.
|
models. If it can't, say so in BUILD.md. The bundle's model choice stays the guard.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
`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
|
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
|
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
|
revoked by the stack, which stops that session first (D14). Idle before the first actual adapter turn
|
||||||
`proxy.firstOutputSeconds` (default 120) is stopped and logged as `proxy-stall`.
|
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
|
## 7. Usage and limits
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
decide on purpose to allow or block that egress and record it in P7's receipt. Tracked as Vikunja 633
|
||||||
(umbrella 237).
|
(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.
|
||||||
+66
-27
@@ -1,4 +1,6 @@
|
|||||||
|
import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
|
||||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||||
|
import { validManagementKey, proxyTiming } from '../../model-proxy/src/validation.mjs';
|
||||||
import { parseAddress } from './address.mjs';
|
import { parseAddress } from './address.mjs';
|
||||||
import { isAbsolute, dirname } from 'node:path';
|
import { isAbsolute, dirname } from 'node:path';
|
||||||
import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';
|
import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';
|
||||||
@@ -419,6 +421,7 @@ export class Broker {
|
|||||||
#organization;
|
#organization;
|
||||||
#credentialVerbs;
|
#credentialVerbs;
|
||||||
#proxyInit;
|
#proxyInit;
|
||||||
|
#proxyPage;
|
||||||
#project;
|
#project;
|
||||||
#library;
|
#library;
|
||||||
// C2-06 (design 5.2, B7): cap -> the session's kept web.fetch pages, in a library business only.
|
// C2-06 (design 5.2, B7): cap -> the session's kept web.fetch pages, in a library business only.
|
||||||
@@ -477,7 +480,7 @@ export class Broker {
|
|||||||
#clock = 0;
|
#clock = 0;
|
||||||
#processing;
|
#processing;
|
||||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
|
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
|
||||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||||
@@ -502,6 +505,7 @@ export class Broker {
|
|||||||
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
||||||
this.#credentialVerbs = credentialVerbs;
|
this.#credentialVerbs = credentialVerbs;
|
||||||
this.#proxyInit = proxyInit;
|
this.#proxyInit = proxyInit;
|
||||||
|
this.#proxyPage = proxyPage;
|
||||||
this.#hostGrant = hostGrant;
|
this.#hostGrant = hostGrant;
|
||||||
this.#hostKeys = hostKeys;
|
this.#hostKeys = hostKeys;
|
||||||
this.#giteaGrant = giteaGrant;
|
this.#giteaGrant = giteaGrant;
|
||||||
@@ -1365,7 +1369,7 @@ export class Broker {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// Trusted launcher API, deliberately NOT a socket verb. S6 supplies immutable launch records.
|
// Trusted launcher API, deliberately NOT a socket verb. S6 supplies immutable launch records.
|
||||||
bindLaunch(record) {
|
#launchProof(record) {
|
||||||
keys(
|
keys(
|
||||||
record,
|
record,
|
||||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript'],
|
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript'],
|
||||||
@@ -1414,22 +1418,28 @@ export class Broker {
|
|||||||
// A record without a session keeps the body it had before row 264a.
|
// A record without a session keeps the body it had before row 264a.
|
||||||
...(record.session === undefined ? {} : { session: record.session }),
|
...(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) 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) {
|
||||||
if (old.actor_role !== record.role || old.body !== JSON.stringify(body)) fail('launch-record-mismatch');
|
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');
|
||||||
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 };
|
||||||
} else this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
|
}
|
||||||
|
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');
|
||||||
|
if (!old && record.thread !== undefined && this.#threadLost(record.business, record.thread)) fail('thread-revoked');
|
||||||
|
if (!old) this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
|
||||||
this.#runs.set(key, session);
|
this.#runs.set(key, session);
|
||||||
return this.#cap(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
|
// 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
|
// 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.
|
// released if it still holds one, and every capability of the run stops working.
|
||||||
@@ -1438,6 +1448,10 @@ export class Broker {
|
|||||||
this.#business(identifier(record.business));
|
this.#business(identifier(record.business));
|
||||||
const key = record.business + ':' + identifier(record.run);
|
const key = record.business + ':' + identifier(record.run);
|
||||||
const session = this.#runs.get(key) ?? fail('unknown-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');
|
if (this.#ended.has(key)) fail('run-ended');
|
||||||
identifier(record.reason);
|
identifier(record.reason);
|
||||||
const exitCode = record.exitCode ?? null;
|
const exitCode = record.exitCode ?? null;
|
||||||
@@ -2587,7 +2601,7 @@ export class Broker {
|
|||||||
}
|
}
|
||||||
let step = 'B';
|
let step = 'B';
|
||||||
try {
|
try {
|
||||||
const result = write('instance.fire');
|
const result = await write('instance.fire');
|
||||||
step = 'C';
|
step = 'C';
|
||||||
this.#store.transaction(() => {
|
this.#store.transaction(() => {
|
||||||
this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
|
this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||||||
@@ -4593,7 +4607,8 @@ export class Broker {
|
|||||||
this.#organizationFail(e, 'instances.set');
|
this.#organizationFail(e, 'instances.set');
|
||||||
}
|
}
|
||||||
// sage-1610 (b): a deleted agent's Vikunja bot is named, so the human removes it in Vikunja.
|
// 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
|
// 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
|
// that stopped it. A delete also names the agent's Vikunja bot (sage-1610 b). A fire's end
|
||||||
@@ -4803,6 +4818,17 @@ export class Broker {
|
|||||||
try {
|
try {
|
||||||
keys(request, ['verb', 'args'], ['verb']);
|
keys(request, ['verb', 'args'], ['verb']);
|
||||||
string(request.verb, 64);
|
string(request.verb, 64);
|
||||||
|
// P4: this boundary precedes the generic user/tester gates so every
|
||||||
|
// nonhuman caller gets human-required. Never secret-check, clone or trail
|
||||||
|
// a write-only API key or a callback URL as request content.
|
||||||
|
if (PROXY_PAGE_VERBS.includes(request.verb)) {
|
||||||
|
this.#human(s);
|
||||||
|
if (!this.#proxyPage) fail('not-configured');
|
||||||
|
const args = request.args ?? {};
|
||||||
|
if (!object(args)) fail('invalid-request');
|
||||||
|
return Promise.resolve().then(() => this.#proxyPage(request.verb, args, { business: s.business, human: s.human }))
|
||||||
|
.catch(error => { throw this.#refused(s, new BusError(PROXY_PAGE_CODES.includes(error.code) ? error.code : 'proxy-down'), { verb: request.verb }); });
|
||||||
|
}
|
||||||
// FED R3 (design 3.5, 3.7 trigger 1): a user reaches the collaborator list only, and each
|
// FED R3 (design 3.5, 3.7 trigger 1): a user reaches the collaborator list only, and each
|
||||||
// request reads the access file again, so a removed or expired grant refuses the next one.
|
// request reads the access file again, so a removed or expired grant refuses the next one.
|
||||||
let grants = null;
|
let grants = null;
|
||||||
@@ -4823,14 +4849,16 @@ export class Broker {
|
|||||||
if (request.verb === 'proxy.init') {
|
if (request.verb === 'proxy.init') {
|
||||||
this.#human(s);
|
this.#human(s);
|
||||||
if (!this.#proxyInit) fail('not-configured');
|
if (!this.#proxyInit) fail('not-configured');
|
||||||
keys(args, ['key', 'port'], ['key']);
|
keys(args, ['key', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'], ['key']);
|
||||||
if (typeof args.key !== 'string' || !args.key.trim() || args.key.length > 4096 || /[\x00-\x1f\x7f]/.test(args.key)) fail('proxy-key-invalid');
|
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');
|
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.
|
// Async initialization is outside the store transaction. Refusals retain codes only.
|
||||||
return Promise.resolve().then(() => this.#proxyInit(args)).then(result => {
|
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
|
|| !Number.isInteger(result.port) || result.port < 1024 || result.port > 65535 || result.port === 8317
|
||||||
|| typeof result.binary !== 'string' || !result.binary.startsWith('/')) fail('proxy-init-failed');
|
|| 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));
|
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.init', port: result.port }, null));
|
||||||
return result;
|
return result;
|
||||||
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
|
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
|
||||||
@@ -4882,7 +4910,10 @@ export class Broker {
|
|||||||
}
|
}
|
||||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||||
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
|
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')
|
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
|
||||||
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
|
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)) {
|
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 +5114,8 @@ export class Broker {
|
|||||||
const { reason: _, ...written } = args;
|
const { reason: _, ...written } = args;
|
||||||
const write = (op) => {
|
const write = (op) => {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
this.#organizationFail(e, verb);
|
this.#organizationFail(e, verb);
|
||||||
}
|
}
|
||||||
@@ -5091,8 +5123,11 @@ export class Broker {
|
|||||||
if (hire) {
|
if (hire) {
|
||||||
this.#hireable(s, args.definition);
|
this.#hireable(s, args.definition);
|
||||||
const result = write('instance.hire');
|
const result = write('instance.hire');
|
||||||
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
|
const finish = result => {
|
||||||
return 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 (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
|
||||||
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
|
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
|
||||||
@@ -5142,11 +5177,14 @@ export class Broker {
|
|||||||
// Step B: the delist. An instance already delisted counts as done.
|
// Step B: the delist. An instance already delisted counts as done.
|
||||||
const result = write('instance.fire');
|
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.
|
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
|
||||||
this.#store.transaction(() => {
|
const finish = result => {
|
||||||
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
|
this.#store.transaction(() => {
|
||||||
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
|
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, ...stopped };
|
||||||
|
};
|
||||||
|
return result?.then ? result.then(finish) : finish(result);
|
||||||
}
|
}
|
||||||
// The shape of hire and fire arguments.
|
// The shape of hire and fire arguments.
|
||||||
#hireFireArgs(action, args) {
|
#hireFireArgs(action, args) {
|
||||||
@@ -5179,6 +5217,7 @@ export class Broker {
|
|||||||
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
|
// 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.
|
// outcome-unknown: the write may have committed.
|
||||||
#organizationFail(e, verb) {
|
#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
|
// 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.
|
// 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");
|
if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {
|
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,
|
renameSync, unlinkSync, rmdirSync, readdirSync,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import { BusError } from './broker.mjs';
|
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) => {
|
const deny = (code) => {
|
||||||
throw new BusError(code);
|
throw new BusError(code);
|
||||||
};
|
};
|
||||||
@@ -111,6 +112,8 @@ export const privateDir = (path, { missing = false } = {}) => {
|
|||||||
};
|
};
|
||||||
export class Credentials {
|
export class Credentials {
|
||||||
#tokens = new Map();
|
#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.
|
// Row 199: every reference the bus loaded, so issue, rotate, revoke and status work per entry.
|
||||||
#refs = new Map();
|
#refs = new Map();
|
||||||
#revoked = new Set();
|
#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
|
// 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
|
// 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).
|
// 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.#storeRoot = storeRoot;
|
||||||
this.#repoRoots = repoRoots;
|
this.#repoRoots = repoRoots;
|
||||||
this.#dataRoot = dataRoot;
|
this.#dataRoot = dataRoot;
|
||||||
@@ -139,6 +142,7 @@ export class Credentials {
|
|||||||
const value = this.#read(ref, env, service);
|
const value = this.#read(ref, env, service);
|
||||||
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
|
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||||
}
|
}
|
||||||
|
for (const instance of modelAgents) this.registerModel(instance);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.close();
|
this.close();
|
||||||
throw e;
|
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.
|
// One reference's checks, for the constructor and register (row 235, r6 X4). Returns its date.
|
||||||
#checkRef(instance, service, ref) {
|
#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;
|
const date = service === 'vikunja' ? ref.expires : ref.rotateBy;
|
||||||
if (!day(date)) deny('credential-date');
|
if (!day(date)) deny('credential-date');
|
||||||
if (
|
if (
|
||||||
@@ -167,6 +171,7 @@ export class Credentials {
|
|||||||
const k = instance + ':' + service;
|
const k = instance + ':' + service;
|
||||||
if (this.#tokens.has(k)) deny('credential-reference');
|
if (this.#tokens.has(k)) deny('credential-reference');
|
||||||
const date = this.#checkRef(instance, service, ref);
|
const date = this.#checkRef(instance, service, ref);
|
||||||
|
this.#invalidateModelProof(instance, service);
|
||||||
this.#refs.set(k, { ref, date });
|
this.#refs.set(k, { ref, date });
|
||||||
this.#revoked.delete(k);
|
this.#revoked.delete(k);
|
||||||
}
|
}
|
||||||
@@ -174,14 +179,159 @@ export class Credentials {
|
|||||||
unregister(instance, service) {
|
unregister(instance, service) {
|
||||||
const k = instance + ':' + service;
|
const k = instance + ':' + service;
|
||||||
if (this.#tokens.has(k)) deny('credential-reference');
|
if (this.#tokens.has(k)) deny('credential-reference');
|
||||||
|
this.#invalidateModelProof(instance, service);
|
||||||
this.#refs.delete(k);
|
this.#refs.delete(k);
|
||||||
this.#revoked.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
|
// 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.
|
// and revoke's abort() reaches the executor. A slot already held refuses host-busy. Returns the signal.
|
||||||
claim(instance, service) {
|
claim(instance, service) {
|
||||||
const k = instance + ':' + service;
|
const k = instance + ':' + service;
|
||||||
if (this.#inflight.has(k)) deny('host-busy');
|
if (this.#inflight.has(k)) deny('host-busy');
|
||||||
|
this.#invalidateModelProof(instance, service);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.#inflight.set(k, controller);
|
this.#inflight.set(k, controller);
|
||||||
return controller.signal;
|
return controller.signal;
|
||||||
@@ -271,6 +421,20 @@ export class Credentials {
|
|||||||
}
|
}
|
||||||
// { token } for gitea and vikunja, { token, key } for host.<id> (design D2).
|
// { token } for gitea and vikunja, { token, key } for host.<id> (design D2).
|
||||||
static #format(service, token) {
|
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)) {
|
if (isHostKind(service)) {
|
||||||
const key = parseHostKey(token);
|
const key = parseHostKey(token);
|
||||||
return { token: key.text, key };
|
return { token: key.text, key };
|
||||||
@@ -292,6 +456,7 @@ export class Credentials {
|
|||||||
this.#known(instance, service);
|
this.#known(instance, service);
|
||||||
if (!day(date)) deny('credential-date');
|
if (!day(date)) deny('credential-date');
|
||||||
const value = Credentials.#format(service, token);
|
const value = Credentials.#format(service, token);
|
||||||
|
this.#invalidateModelProof(instance, service);
|
||||||
const file = this.#storePath(instance, service);
|
const file = this.#storePath(instance, service);
|
||||||
const agentDir = dirname(file);
|
const agentDir = dirname(file);
|
||||||
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
|
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
|
||||||
@@ -309,7 +474,7 @@ export class Credentials {
|
|||||||
let fd;
|
let fd;
|
||||||
try {
|
try {
|
||||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
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);
|
fsyncSync(fd);
|
||||||
} finally {
|
} finally {
|
||||||
if (fd !== undefined) closeSync(fd);
|
if (fd !== undefined) closeSync(fd);
|
||||||
@@ -335,6 +500,7 @@ export class Credentials {
|
|||||||
const { ref } = this.#known(instance, service);
|
const { ref } = this.#known(instance, service);
|
||||||
if (this.#held.has(k) || !ref.store) deny('credential-reference');
|
if (this.#held.has(k) || !ref.store) deny('credential-reference');
|
||||||
if (!day(date)) deny('credential-date');
|
if (!day(date)) deny('credential-date');
|
||||||
|
this.#invalidateModelProof(instance, service);
|
||||||
const value = this.#read(ref, {}, service);
|
const value = this.#read(ref, {}, service);
|
||||||
if (value === null) deny('credential-file');
|
if (value === null) deny('credential-file');
|
||||||
this.#refs.set(k, { ref: { ...ref, rotateBy: date }, date });
|
this.#refs.set(k, { ref: { ...ref, rotateBy: date }, date });
|
||||||
@@ -347,6 +513,7 @@ export class Credentials {
|
|||||||
hold(instance, service) {
|
hold(instance, service) {
|
||||||
const k = instance + ':' + service;
|
const k = instance + ':' + service;
|
||||||
this.#known(instance, service);
|
this.#known(instance, service);
|
||||||
|
this.#invalidateModelProof(instance, service);
|
||||||
this.#tokens.delete(k);
|
this.#tokens.delete(k);
|
||||||
this.#held.add(k);
|
this.#held.add(k);
|
||||||
this.#inflight.get(k)?.abort();
|
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
|
// 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.
|
// 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);
|
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.#tokens.delete(instance + ':' + service);
|
||||||
this.#revoked.add(instance + ':' + service);
|
this.#revoked.add(instance + ':' + service);
|
||||||
this.#held.delete(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.
|
// revoke for every service, then the agent's dir. A file other than a token or its tmp refuses.
|
||||||
remove(instance) {
|
remove(instance) {
|
||||||
const services = [...this.#refs.keys()].filter((k) => k.startsWith(instance + ':')).map((k) => k.slice(instance.length + 1));
|
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'));
|
const agentDir = dirname(this.#storePath(instance, 'gitea'));
|
||||||
let names;
|
let names;
|
||||||
try {
|
try {
|
||||||
@@ -426,7 +600,8 @@ export class Credentials {
|
|||||||
} catch {
|
} catch {
|
||||||
deny('invalid-request');
|
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.
|
// Row 210 (design D5): whether a host.run for this agent and host is in flight.
|
||||||
busy(instance, service) {
|
busy(instance, service) {
|
||||||
@@ -438,6 +613,7 @@ export class Credentials {
|
|||||||
const k = instance + ':' + service;
|
const k = instance + ':' + service;
|
||||||
const entry = this.#tokens.get(k);
|
const entry = this.#tokens.get(k);
|
||||||
if (!entry) deny('credential-unavailable');
|
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'))
|
if (service === 'vikunja' && Date.now() >= Date.parse(entry.date + 'T00:00:00Z'))
|
||||||
deny('credential-expired');
|
deny('credential-expired');
|
||||||
if (!entry.key) {
|
if (!entry.key) {
|
||||||
@@ -474,6 +650,7 @@ export class Credentials {
|
|||||||
status() {
|
status() {
|
||||||
return [...this.#refs.entries()].map(([key, { date }]) => {
|
return [...this.#refs.entries()].map(([key, { date }]) => {
|
||||||
const hostKey = this.#tokens.get(key)?.key;
|
const hostKey = this.#tokens.get(key)?.key;
|
||||||
|
const model = this.#tokens.get(key)?.model;
|
||||||
const i = key.lastIndexOf(':'),
|
const i = key.lastIndexOf(':'),
|
||||||
service = key.slice(i + 1),
|
service = key.slice(i + 1),
|
||||||
left = Date.parse(date + 'T00:00:00Z') - Date.now();
|
left = Date.parse(date + 'T00:00:00Z') - Date.now();
|
||||||
@@ -482,13 +659,16 @@ export class Credentials {
|
|||||||
service,
|
service,
|
||||||
date,
|
date,
|
||||||
...(hostKey ? { fingerprint: hostKey.fingerprint, publicKey: hostKey.publicLine } : {}),
|
...(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)
|
state: !this.#tokens.has(key)
|
||||||
? this.#held.has(key)
|
? this.#held.has(key)
|
||||||
? 'held'
|
? 'held'
|
||||||
: this.#revoked.has(key)
|
: this.#revoked.has(key)
|
||||||
? 'revoked'
|
? 'revoked'
|
||||||
: 'not-issued'
|
: 'not-issued'
|
||||||
: service !== 'vikunja'
|
: model?.state === 'revoking' ? 'revoking' : service !== 'vikunja'
|
||||||
? left <= 0
|
? left <= 0
|
||||||
? 'rotation-due'
|
? 'rotation-due'
|
||||||
: 'valid'
|
: 'valid'
|
||||||
@@ -501,6 +681,7 @@ export class Credentials {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
close() {
|
close() {
|
||||||
|
this.#modelProofs.clear(); this.#modelTokens = new WeakMap();
|
||||||
this.#tokens.clear();
|
this.#tokens.clear();
|
||||||
this.#refs.clear();
|
this.#refs.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@ const LAUNCH_OPS = new Set([
|
|||||||
'authorizeLaunch',
|
'authorizeLaunch',
|
||||||
'refuse',
|
'refuse',
|
||||||
'endLaunch',
|
'endLaunch',
|
||||||
|
'recoverLaunch',
|
||||||
'credentialStatus',
|
'credentialStatus',
|
||||||
|
'modelPreflight',
|
||||||
|
'modelStall',
|
||||||
'checkPush',
|
'checkPush',
|
||||||
'recordPush',
|
'recordPush',
|
||||||
'reviewFetch',
|
'reviewFetch',
|
||||||
@@ -40,6 +43,7 @@ function launchOp(m) {
|
|||||||
if (m.op === 'authorizeLaunch') return runtime.authorizeLaunch(m.cap, m.instance);
|
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 === 'refuse') return runtime.refuse(m.cap, m.code, m.subject);
|
||||||
if (m.op === 'endLaunch') return runtime.endLaunch(m.record);
|
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.
|
// Row 93 (#1587): the git socket's ops.
|
||||||
if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref);
|
if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref);
|
||||||
if (m.op === 'recordPush') return runtime.recordPush(m.cap, m.push);
|
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 === 'sessionClearRetire') return runtime.sessionClearRetire(m.record);
|
||||||
if (m.op === 'sessionClearStart') return runtime.sessionClearStart(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 === '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,
|
// 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).
|
// whose [] for an unknown role read as "no holders" for landHolders (row 156 mutant P02).
|
||||||
throw new BusError('launch-op-refused');
|
throw new BusError('launch-op-refused');
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Pure broker/runner parsing vocabulary. No host management or state imports.
|
||||||
|
export const PROXY_PAGE_VERBS = Object.freeze(['proxy.accounts', 'proxy.login.start', 'proxy.login.submit',
|
||||||
|
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.keys']);
|
||||||
|
export const PROXY_PAGE_CODES = Object.freeze(['not-configured', 'human-required', 'provider-not-allowed',
|
||||||
|
'login-state-unknown', 'callback-url-invalid', 'apikey-invalid', 'priority-invalid', 'priority-unconfirmed',
|
||||||
|
'priority-write-refused', 'proxy-down', 'invalid-request']);
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// P4: bus-only native Console operations. No upstream credential is persisted here.
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { sha256, refuse } from '../../model-proxy/src/common.mjs';
|
||||||
|
import { PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
|
||||||
|
export { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
|
||||||
|
const object = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||||
|
function fields(args, names) {
|
||||||
|
if (!object(args) || Object.keys(args).length !== names.length || names.some(k => !Object.hasOwn(args, k))) refuse('invalid-request');
|
||||||
|
}
|
||||||
|
const priorityOk = n => Number.isInteger(n) && n >= -1000 && n <= 1000;
|
||||||
|
const identifier = x => typeof x === 'string' && x.length > 0 && x.length <= 512;
|
||||||
|
const stateOk = x => typeof x === 'string' && /^[a-zA-Z0-9_-]{1,512}$/.test(x);
|
||||||
|
const keyId = value => sha256(value).slice(0, 12);
|
||||||
|
const ZAI_URL = 'https://api.z.ai/api/anthropic';
|
||||||
|
const providerName = provider => ({ claude: 'Claude', codex: 'Codex', 'z.ai': 'z.ai', other: 'Other' })[provider];
|
||||||
|
// Credential labels/emails and group names are arbitrary upstream strings. A stable
|
||||||
|
// generated display name provides identification without reflecting a misplaced token.
|
||||||
|
function view(id, provider, status, priority) {
|
||||||
|
return { accountId: keyId(id), provider, displayName: `${providerName(provider)} account ${keyId(id)}`,
|
||||||
|
status, priority: priorityOk(priority) ? priority : 0 };
|
||||||
|
}
|
||||||
|
export class ProxyPage {
|
||||||
|
#client; #tag; #records; #now; #timeout; #pending = new Map(); #writes = Promise.resolve();
|
||||||
|
constructor({ client, instanceTag, keyRecords = () => [], now = Date.now, loginTimeoutSeconds = 300 }) {
|
||||||
|
if (!client || !/^[a-zA-Z0-9_-]{1,64}$/.test(instanceTag) || typeof keyRecords !== 'function' || typeof now !== 'function'
|
||||||
|
|| !Number.isSafeInteger(loginTimeoutSeconds) || loginTimeoutSeconds <= 0) throw new TypeError('invalid proxy page settings');
|
||||||
|
this.#client = client; this.#tag = instanceTag; this.#records = keyRecords; this.#now = now; this.#timeout = loginTimeoutSeconds * 1000;
|
||||||
|
}
|
||||||
|
// Serializes read-modify-write provider config within the bus. P2 owns the
|
||||||
|
// independent client-key list. Store's lock admits only one bus per data root.
|
||||||
|
#write(operation) {
|
||||||
|
const next = this.#writes.then(operation); this.#writes = next.catch(() => {}); return next;
|
||||||
|
}
|
||||||
|
async #accounts() {
|
||||||
|
const response = await this.#client.getCredentials();
|
||||||
|
const groups = await this.#client.getUpstreamGroups();
|
||||||
|
if (!Array.isArray(response?.files) || !Array.isArray(groups)) refuse('proxy-down');
|
||||||
|
const rows = [];
|
||||||
|
for (const row of response.files) {
|
||||||
|
if (!identifier(row?.id) || !identifier(row?.name)) continue;
|
||||||
|
const provider = ['claude', 'codex'].includes(row.provider) ? row.provider : 'other';
|
||||||
|
const status = row.disabled === true ? 'disabled' : row.unavailable === true ? 'unavailable'
|
||||||
|
: ['ready', 'active', 'error', 'disabled', 'unavailable'].includes(row.status) ? row.status : 'unknown';
|
||||||
|
rows.push({ ...view(row.id, provider, status, row.priority), source: 'oauth', name: row.name });
|
||||||
|
}
|
||||||
|
for (const [groupIndex, group] of groups.entries()) {
|
||||||
|
if (!Array.isArray(group?.keys)) continue;
|
||||||
|
for (const [keyIndex, key] of group.keys.entries()) {
|
||||||
|
// auth_index is generated by the proxy. Never fall back to hashing api-key.
|
||||||
|
if (!identifier(key?.auth_index)) continue;
|
||||||
|
const provider = group['base-url'] === ZAI_URL ? 'z.ai' : 'other';
|
||||||
|
const status = key.disabled === true || group.disabled === true ? 'disabled' : 'active';
|
||||||
|
rows.push({ ...view(key.auth_index, provider, status, key.priority ?? group.priority), source: 'apikey', groupIndex, keyIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rows, groups };
|
||||||
|
}
|
||||||
|
#public(row) {
|
||||||
|
const { accountId, provider, displayName, status, priority } = row;
|
||||||
|
return { accountId, provider, displayName, status, priority };
|
||||||
|
}
|
||||||
|
#session(state, scope, { reportTimeout = false } = {}) {
|
||||||
|
const entry = stateOk(state) ? this.#pending.get(state) : null;
|
||||||
|
if (!entry || entry.business !== scope.business || entry.human !== scope.human) refuse('login-state-unknown');
|
||||||
|
if (entry.expires <= this.#now()) {
|
||||||
|
this.#pending.delete(state);
|
||||||
|
if (reportTimeout) return null;
|
||||||
|
refuse('login-state-unknown');
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
async request(verb, args, scope) {
|
||||||
|
try { return await this.#request(verb, args, scope); }
|
||||||
|
catch (error) {
|
||||||
|
// A client or fixture may put request values in its errors. Only locally
|
||||||
|
// enumerated refusal codes cross the broker, never a raw exception/body.
|
||||||
|
refuse(PROXY_PAGE_CODES.includes(error.code) ? error.code : 'proxy-down');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async #request(verb, args, scope) {
|
||||||
|
if (verb === 'proxy.accounts') {
|
||||||
|
fields(args, []);
|
||||||
|
return { accounts: (await this.#accounts()).rows.map(row => this.#public(row)) };
|
||||||
|
}
|
||||||
|
if (verb === 'proxy.login.start') {
|
||||||
|
fields(args, ['provider']);
|
||||||
|
if (!['claude', 'codex'].includes(args.provider)) refuse('provider-not-allowed');
|
||||||
|
for (const [state, value] of this.#pending) if (value.expires <= this.#now()) this.#pending.delete(state);
|
||||||
|
if (this.#pending.size >= 128) refuse('proxy-down');
|
||||||
|
const answer = await this.#client.oauthStart(args.provider);
|
||||||
|
let url; try { url = new URL(answer.url); } catch { refuse('proxy-down'); }
|
||||||
|
const host = args.provider === 'claude' ? 'claude.ai' : 'auth.openai.com';
|
||||||
|
if (url.protocol !== 'https:' || url.hostname !== host || url.username || url.password || url.port
|
||||||
|
|| url.href.length > 8192 || !stateOk(answer.state) || url.searchParams.getAll('state').length !== 1
|
||||||
|
|| url.searchParams.get('state') !== answer.state || this.#pending.has(answer.state)) refuse('proxy-down');
|
||||||
|
this.#pending.set(answer.state, { business: scope.business, human: scope.human, expires: this.#now() + this.#timeout });
|
||||||
|
return { url: url.href, state: answer.state };
|
||||||
|
}
|
||||||
|
if (verb === 'proxy.login.submit') {
|
||||||
|
fields(args, ['redirect_url']);
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
if (typeof args.redirect_url !== 'string' || args.redirect_url.length > 8192) throw Error();
|
||||||
|
url = new URL(args.redirect_url);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || url.searchParams.getAll('state').length !== 1) throw Error();
|
||||||
|
} catch { refuse('callback-url-invalid'); }
|
||||||
|
const state = url.searchParams.get('state'); this.#session(state, scope);
|
||||||
|
await this.#client.oauthCallback(args.redirect_url);
|
||||||
|
// Callback acceptance is explicitly not proof of credential persistence.
|
||||||
|
return { status: 'wait' };
|
||||||
|
}
|
||||||
|
if (verb === 'proxy.login.status') {
|
||||||
|
fields(args, ['state']);
|
||||||
|
if (!this.#session(args.state, scope, { reportTimeout: true })) return { status: 'error', reason: 'login-timeout' };
|
||||||
|
const answer = await this.#client.oauthStatus(args.state);
|
||||||
|
if (answer?.status === 'wait') return { status: 'wait' };
|
||||||
|
if (!['ok', 'error'].includes(answer?.status)) refuse('proxy-down');
|
||||||
|
this.#pending.delete(args.state);
|
||||||
|
return answer.status === 'ok' ? { status: 'ok' } : { status: 'error', reason: 'login-failed' };
|
||||||
|
}
|
||||||
|
if (verb === 'proxy.login.cancel') {
|
||||||
|
fields(args, ['state']); this.#session(args.state, scope);
|
||||||
|
await this.#client.oauthCancel(args.state); this.#pending.delete(args.state);
|
||||||
|
return { status: 'cancelled' };
|
||||||
|
}
|
||||||
|
if (verb === 'proxy.apikey.add') {
|
||||||
|
fields(args, ['provider', 'key']);
|
||||||
|
if (args.provider !== 'z.ai') refuse('provider-not-allowed');
|
||||||
|
if (typeof args.key !== 'string' || !/^[\x21-\x7e]{1,4096}$/.test(args.key)) refuse('apikey-invalid');
|
||||||
|
return this.#write(async () => {
|
||||||
|
const groups = await this.#client.getUpstreamGroups();
|
||||||
|
if (!Array.isArray(groups)) refuse('proxy-down');
|
||||||
|
const group = { name: `mosaic-zai-${randomBytes(6).toString('hex')}`, 'base-url': ZAI_URL,
|
||||||
|
priority: 0, keys: [{ 'api-key': args.key }] };
|
||||||
|
await this.#client.putUpstreamGroups([...groups, group]);
|
||||||
|
return { added: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (verb === 'proxy.priority.set') {
|
||||||
|
fields(args, ['accountId', 'priority']);
|
||||||
|
if (typeof args.accountId !== 'string' || !/^[a-f0-9]{12}$/.test(args.accountId) || !priorityOk(args.priority)) refuse('priority-invalid');
|
||||||
|
return this.#write(async () => {
|
||||||
|
const { rows, groups } = await this.#accounts();
|
||||||
|
const matches = rows.filter(row => row.accountId === args.accountId);
|
||||||
|
if (matches.length !== 1) refuse('priority-invalid');
|
||||||
|
const row = matches[0];
|
||||||
|
try {
|
||||||
|
if (row.source === 'oauth') await this.#client.patchCredential(row.name, args.priority);
|
||||||
|
else {
|
||||||
|
groups[row.groupIndex].keys[row.keyIndex].priority = args.priority;
|
||||||
|
await this.#client.putUpstreamGroups(groups);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
refuse(error.code === 'proxy-client-refused' || error.code === 'proxy-client-redirect' ? 'priority-write-refused' : 'proxy-down');
|
||||||
|
}
|
||||||
|
const after = (await this.#accounts()).rows.find(r => r.accountId === args.accountId);
|
||||||
|
if (!after || after.priority !== args.priority) refuse('priority-unconfirmed');
|
||||||
|
return { accountId: row.accountId, priority: args.priority };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (verb === 'proxy.keys') {
|
||||||
|
fields(args, []);
|
||||||
|
const values = await this.#client.getKeys();
|
||||||
|
if (!Array.isArray(values) || values.some(k => typeof k !== 'string')) refuse('proxy-down');
|
||||||
|
const records = await this.#records(scope.business);
|
||||||
|
const prefix = `msk-${this.#tag}-`;
|
||||||
|
return { keys: values.map(value => {
|
||||||
|
const id = keyId(value), sentinel = value.startsWith(prefix + 'sentinel-');
|
||||||
|
const record = records.find(r => r.keyId === id);
|
||||||
|
return { keyId: id, management: sentinel ? 'sentinel' : value.startsWith(prefix) ? 'stack-managed' : 'not stack-managed',
|
||||||
|
agent: record?.agent ?? null, issuedAt: record?.issuedAt ?? null, state: record?.state ?? null };
|
||||||
|
}) };
|
||||||
|
}
|
||||||
|
refuse('invalid-request');
|
||||||
|
}
|
||||||
|
}
|
||||||
+186
-10
@@ -1,4 +1,4 @@
|
|||||||
import { lstatSync, readdirSync } from 'node:fs';
|
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { Store } from './store.mjs';
|
import { Store } from './store.mjs';
|
||||||
@@ -8,13 +8,16 @@ import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
|
|||||||
import { Credentials } from './credentials.mjs';
|
import { Credentials } from './credentials.mjs';
|
||||||
import { serve } from './server.mjs';
|
import { serve } from './server.mjs';
|
||||||
import { launchSettings } from '../../business/src/launch-settings.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 { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
|
||||||
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
|
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
|
||||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||||
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
|
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
|
||||||
|
import { ProxyPage } from './proxy-page.mjs';
|
||||||
|
import { sha256 } from '../../model-proxy/src/common.mjs';
|
||||||
import { initializeProxy } from '../../model-proxy/src/settings.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 { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
|
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
|
||||||
import { credentialVerbs } from './credential-verbs.mjs';
|
import { credentialVerbs } from './credential-verbs.mjs';
|
||||||
@@ -121,6 +124,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({
|
export async function startBroker({
|
||||||
dataRoot,
|
dataRoot,
|
||||||
businesses,
|
businesses,
|
||||||
@@ -158,7 +265,9 @@ export async function startBroker({
|
|||||||
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|
||||||
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
|
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
|
||||||
throw new BusError('invalid-host-request');
|
throw new BusError('invalid-host-request');
|
||||||
let modelProxyClient = null;
|
let modelProxyClient = null, proxyPage = null;
|
||||||
|
let modelKeys = null, modelTimer = null, modelSweep = null;
|
||||||
|
const modelRuns = new Map();
|
||||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
|
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
|
||||||
try {
|
try {
|
||||||
const proxyReceipt = entry => process.stderr.write(`model-proxy: ${JSON.stringify(entry)}\n`);
|
const proxyReceipt = entry => process.stderr.write(`model-proxy: ${JSON.stringify(entry)}\n`);
|
||||||
@@ -198,7 +307,34 @@ export async function startBroker({
|
|||||||
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
|
// 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.
|
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
|
||||||
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
|
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 }); }
|
||||||
|
}
|
||||||
|
const configureProxyPage = () => {
|
||||||
|
proxyPage = new ProxyPage({ client: modelProxyClient, instanceTag: sha256(organization.dir).slice(0, 12),
|
||||||
|
// P2 supplies the model metadata; only the matching business's record
|
||||||
|
// participates in this owner view. No credential value is read here.
|
||||||
|
keyRecords: business => credentials.status().filter(r => r.service === 'model' && r.instance?.startsWith(business + '/'))
|
||||||
|
.map(r => ({ keyId: r.keyId, agent: r.instance.slice(business.length + 1), issuedAt: r.issuedAt, state: r.state })),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (modelProxyClient) configureProxyPage();
|
||||||
|
|
||||||
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
// 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.
|
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
|
||||||
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
||||||
@@ -240,7 +376,9 @@ export async function startBroker({
|
|||||||
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
|
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
|
||||||
}
|
}
|
||||||
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
|
// 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,
|
const usage = await createUsage({ store, dataRoot, businesses,
|
||||||
log: entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`) });
|
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
|
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
|
||||||
@@ -393,7 +531,7 @@ export async function startBroker({
|
|||||||
},
|
},
|
||||||
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
|
// 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.
|
// 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==='limits')return withTokens(id,settings.read(id));
|
||||||
if(verb==='plugins')return plugins.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.
|
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
|
||||||
@@ -406,6 +544,8 @@ export async function startBroker({
|
|||||||
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
|
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.
|
// 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);
|
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;
|
let result;
|
||||||
if(verb==='limits.set')result=withTokens(id,settings.set(id,args,actor));
|
if(verb==='limits.set')result=withTokens(id,settings.set(id,args,actor));
|
||||||
else if(verb==='instances.set')result=instances.set(id,args,actor);
|
else if(verb==='instances.set')result=instances.set(id,args,actor);
|
||||||
@@ -428,16 +568,21 @@ export async function startBroker({
|
|||||||
// took, so a refused one (credentials, roots, ...) shows the restart line for a keep or a
|
// 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.
|
// hire too. The other writers' answers carry no restart field.
|
||||||
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
|
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.
|
// Row 194 (D2.3): the role files as they stand, for the hire checks.
|
||||||
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
|
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
|
||||||
secretCheck: (value) => credentials.assertClean(value),
|
secretCheck: (value) => credentials.assertClean(value),
|
||||||
credentialStatus: () => credentials.status(),
|
credentialStatus: () => credentials.status(),
|
||||||
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete, live with no restart; each
|
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete, live with no restart; each
|
||||||
// rewrites or records through the organization writer, so changes.jsonl holds the receipt.
|
// rewrites or records through the organization writer, so changes.jsonl holds the receipt.
|
||||||
|
proxyPage: async (verb, args, scope) => {
|
||||||
|
if (!proxyPage) throw new BusError('not-configured');
|
||||||
|
return proxyPage.request(verb, args, scope);
|
||||||
|
},
|
||||||
proxyInit: organization ? async args => {
|
proxyInit: organization ? async args => {
|
||||||
const settings = await initializeProxy({ configDir: organization.dir, dataRoot, ...args });
|
const settings = await initializeProxy({ configDir: organization.dir, dataRoot, ...args });
|
||||||
modelProxyClient = await loadManagementClient({ configDir: organization.dir, proxy: settings, receipt: proxyReceipt });
|
modelProxyClient = await loadManagementClient({ configDir: organization.dir, proxy: settings, receipt: proxyReceipt });
|
||||||
|
configureProxyPage();
|
||||||
return settings;
|
return settings;
|
||||||
} : null,
|
} : null,
|
||||||
credentialVerbs: organization
|
credentialVerbs: organization
|
||||||
@@ -487,8 +632,15 @@ export async function startBroker({
|
|||||||
!/^\d+$/.test(r.startTime)
|
!/^\d+$/.test(r.startTime)
|
||||||
)
|
)
|
||||||
throw new BusError('invalid-launch-process');
|
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 });
|
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 };
|
return { business: r.business, run: r.run, cap };
|
||||||
}
|
}
|
||||||
const bound = launches.map(bindLaunch);
|
const bound = launches.map(bindLaunch);
|
||||||
@@ -649,7 +801,7 @@ export async function startBroker({
|
|||||||
return credentials
|
return credentials
|
||||||
.status()
|
.status()
|
||||||
.filter((c) => c.instance === business + '/' + role)
|
.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
|
// 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.
|
// still running skips the tick. Only codes are logged, never git's output.
|
||||||
@@ -694,6 +846,13 @@ export async function startBroker({
|
|||||||
}, access.intervalMs ?? ACCESS_SWEEP_MS);
|
}, access.intervalMs ?? ACCESS_SWEEP_MS);
|
||||||
accessTimer.unref();
|
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 {
|
return {
|
||||||
broker,
|
broker,
|
||||||
credentials,
|
credentials,
|
||||||
@@ -701,7 +860,21 @@ export async function startBroker({
|
|||||||
get modelProxy() { return modelProxyClient; },
|
get modelProxy() { return modelProxyClient; },
|
||||||
path,
|
path,
|
||||||
bindLaunch,
|
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),
|
threadsState: (business) => broker.threadsState(business),
|
||||||
threadIdle: (r) => broker.threadIdle(r),
|
threadIdle: (r) => broker.threadIdle(r),
|
||||||
// Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch.
|
// Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch.
|
||||||
@@ -743,6 +916,8 @@ export async function startBroker({
|
|||||||
launches: bound,
|
launches: bound,
|
||||||
readers: readCaps,
|
readers: readCaps,
|
||||||
async close() {
|
async close() {
|
||||||
|
clearInterval(modelTimer);
|
||||||
|
await modelSweep;
|
||||||
clearInterval(accessTimer);
|
clearInterval(accessTimer);
|
||||||
await stopSync();
|
await stopSync();
|
||||||
await server.close();
|
await server.close();
|
||||||
@@ -753,6 +928,7 @@ export async function startBroker({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
clearInterval(modelTimer);
|
||||||
clearInterval(accessTimer);
|
clearInterval(accessTimer);
|
||||||
await server?.close();
|
await server?.close();
|
||||||
await adapter?.close?.();
|
await adapter?.close?.();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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');}};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;}};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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');}};
|
||||||
|
}
|
||||||
@@ -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('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('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);});
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 }) };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();}
|
||||||
@@ -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');}};
|
||||||
|
}
|
||||||
@@ -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' }]);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -12,8 +12,12 @@ import { fixture } from './spec-fixtures.mjs';
|
|||||||
test('Broker keeps the sandbox client import closure: only the listed imports, no lazy project import', () => {
|
test('Broker keeps the sandbox client import closure: only the listed imports, no lazy project import', () => {
|
||||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||||
|
// Rows330/331: pure verb/refusal vocabulary, never the host management implementation.
|
||||||
|
"import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';",
|
||||||
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
|
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
|
||||||
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
|
"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.
|
// Row 265 (#1770): pure room address parsing, mounted in RUNNER_FILES beside the broker.
|
||||||
"import { parseAddress } from './address.mjs';",
|
"import { parseAddress } from './address.mjs';",
|
||||||
// Row 65 (#1558): bindLaunch checks a run's transcript path is absolute.
|
// Row 65 (#1558): bindLaunch checks a run's transcript path is absolute.
|
||||||
@@ -38,6 +42,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
|||||||
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';",
|
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';",
|
||||||
]);
|
]);
|
||||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||||
|
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||||
assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||||
assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {revocationFixture} from './model-revocation-fixture.mjs';
|
||||||
|
|
||||||
|
// Join control: the real runtime supplies P2's cached metadata to P4. This
|
||||||
|
// fixture's seeded record is read evidence only, not issuance/ownership proof.
|
||||||
|
test('Console key view uses the same runtime model record without exposing its value', {timeout:30000}, async t=>{
|
||||||
|
const f=await revocationFixture(t,{runtime:true});
|
||||||
|
const record=f.seed({valid:true,matchingTmp:false});
|
||||||
|
const human=f.bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||||
|
const result=await f.bus.broker.request(human,{verb:'proxy.keys',args:{}});
|
||||||
|
assert.deepEqual(result.keys.find(row=>row.keyId===record.keyId),{
|
||||||
|
keyId:record.keyId,management:'stack-managed',agent:'aide',issuedAt:record.issuedAt,state:'valid',
|
||||||
|
});
|
||||||
|
assert.equal(JSON.stringify(result).includes(record.key),false,'no model value enters the Console result');
|
||||||
|
f.valueFree();
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// P4 named mutant: returning any unfiltered credential field. Every projection
|
||||||
|
// and write hits the real management client against a local stand-in server.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { fakeManagement } from './proxy-page-fixture.mjs';
|
||||||
|
const id = value => createHash('sha256').update(value).digest('hex').slice(0, 12);
|
||||||
|
const oauth = extra => ({ id: 'oauth-one.json', name: 'oauth-one.json', provider: 'claude', label: 'p4-private-label',
|
||||||
|
email: 'p4-private-email', token: 'p4-private-token', status: 'ready', priority: 3, ...extra });
|
||||||
|
test('accounts have exactly the allowlisted public fields, id from credential identifier', async t => {
|
||||||
|
const s = await fakeManagement(t, { files: [oauth()] });
|
||||||
|
const result = await s.call('proxy.accounts');
|
||||||
|
assert.deepEqual(result, { accounts: [{ accountId: id('oauth-one.json'), provider: 'claude',
|
||||||
|
displayName: `Claude account ${id('oauth-one.json')}`, status: 'ready', priority: 3 }] });
|
||||||
|
for (const value of ['p4-private-label', 'p4-private-email', 'p4-private-token']) {
|
||||||
|
assert.equal(JSON.stringify(result).includes(value), false);
|
||||||
|
assert.notEqual(result.accounts[0].accountId, id(value));
|
||||||
|
assert.equal(JSON.stringify(s.state.receipts).includes(value), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('token-like data in every upstream field never reaches public accounts', async t => {
|
||||||
|
const toxic = 'p4-private-token';
|
||||||
|
const s = await fakeManagement(t, { files: [oauth({ label: toxic, email: toxic, token: toxic, status: toxic,
|
||||||
|
provider: toxic, account: toxic, account_type: toxic, auth_index: toxic, path: toxic, size: toxic, priority: toxic })] });
|
||||||
|
const result = await s.call('proxy.accounts');
|
||||||
|
assert.equal(JSON.stringify(result).includes(toxic), false);
|
||||||
|
assert.equal(JSON.stringify(s.state.receipts).includes(toxic), false);
|
||||||
|
assert.deepEqual(Object.keys(result.accounts[0]).sort(), ['accountId', 'displayName', 'priority', 'provider', 'status']);
|
||||||
|
});
|
||||||
|
test('API key account is derived from proxy auth_index, never api-key, and can set priority', async t => {
|
||||||
|
const s = await fakeManagement(t);
|
||||||
|
await s.call('proxy.apikey.add', { provider: 'z.ai', key: 'p4-canary-key' });
|
||||||
|
const first = (await s.call('proxy.accounts')).accounts[0];
|
||||||
|
assert.equal(first.accountId, id('upstream-1')); assert.notEqual(first.accountId, id('p4-canary-key'));
|
||||||
|
assert.equal(first.provider, 'z.ai'); assert.equal(first.priority, 0);
|
||||||
|
// Fake preserves derived identifiers across writes, just as the pinned proxy does.
|
||||||
|
// The next write is expected to strip derived auth_index before sending config.
|
||||||
|
await s.call('proxy.priority.set', { accountId: first.accountId, priority: 7 });
|
||||||
|
assert.equal((await s.call('proxy.accounts')).accounts[0].priority, 7);
|
||||||
|
assert.equal(JSON.stringify(s.state.receipts).includes('p4-canary-key'), false);
|
||||||
|
});
|
||||||
|
test('OAuth priority uses top-level name/priority and confirms it through list', async t => {
|
||||||
|
const s = await fakeManagement(t, { files: [oauth()] });
|
||||||
|
assert.deepEqual(await s.call('proxy.priority.set', { accountId: id('oauth-one.json'), priority: 8 }),
|
||||||
|
{ accountId: id('oauth-one.json'), priority: 8 });
|
||||||
|
assert.equal(s.state.files[0].priority, 8);
|
||||||
|
assert.equal(s.state.calls.filter(c => c === 'PATCH /v8/management/credentials/fields').length, 1);
|
||||||
|
});
|
||||||
|
test('priority-unconfirmed: accepted API-key write with unchanged read-back refuses', async t => {
|
||||||
|
const s = await fakeManagement(t, { priorityNoop: true,
|
||||||
|
groups: [{ name: 'z.ai', 'base-url': 'https://api.z.ai/api/anthropic', priority: 0,
|
||||||
|
keys: [{ 'api-key': 'p4-upstream', auth_index: 'upstream-1' }] }] });
|
||||||
|
await assert.rejects(s.call('proxy.priority.set', { accountId: id('upstream-1'), priority: 8 }), { code: 'priority-unconfirmed' });
|
||||||
|
});
|
||||||
|
for (const status of [201, 400]) test(`priority-write-refused: OAuth PATCH status ${status}`, async t => {
|
||||||
|
const s = await fakeManagement(t, { files: [oauth()], patchStatus: status });
|
||||||
|
await assert.rejects(s.call('proxy.priority.set', { accountId: id('oauth-one.json'), priority: 8 }), { code: 'priority-write-refused' });
|
||||||
|
assert.equal(s.state.calls.filter(c => c.startsWith('PATCH ')).length, 1);
|
||||||
|
});
|
||||||
|
test('priority-invalid: unknown account refuses before any write', async t => {
|
||||||
|
const s = await fakeManagement(t);
|
||||||
|
await assert.rejects(s.call('proxy.priority.set', { accountId: 'a'.repeat(12), priority: 8 }), { code: 'priority-invalid' });
|
||||||
|
assert.equal(s.state.calls.some(c => c.startsWith('PUT ') || c.startsWith('PATCH ')), false);
|
||||||
|
});
|
||||||
|
test('proxy-down: a closed management listener refuses once', async t => {
|
||||||
|
const s = await fakeManagement(t);
|
||||||
|
await new Promise(r => s.server.close(r));
|
||||||
|
await assert.rejects(s.call('proxy.accounts'), { code: 'proxy-down' });
|
||||||
|
assert.equal(s.state.receipts.length, 1);
|
||||||
|
});
|
||||||
|
test('client keys return ids, sentinel/foreign labels and only scoped record metadata', async t => {
|
||||||
|
const s = await fakeManagement(t, { keys: ['msk-fixture-sentinel-x', 'msk-fixture-agent', 'foreign-key'],
|
||||||
|
records: [{ keyId: id('msk-fixture-agent'), agent: 'coder', issuedAt: '2026-10-24T00:00:00Z', state: 'valid' }] });
|
||||||
|
const result = await s.call('proxy.keys');
|
||||||
|
assert.deepEqual(result, { keys: [
|
||||||
|
{ keyId: id('msk-fixture-sentinel-x'), management: 'sentinel', agent: null, issuedAt: null, state: null },
|
||||||
|
{ keyId: id('msk-fixture-agent'), management: 'stack-managed', agent: 'coder', issuedAt: '2026-10-24T00:00:00Z', state: 'valid' },
|
||||||
|
{ keyId: id('foreign-key'), management: 'not stack-managed', agent: null, issuedAt: null, state: null },
|
||||||
|
] });
|
||||||
|
for (const value of s.state.keys) assert.equal(JSON.stringify(result).includes(value), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// P4 real client/page regression: fresh pinned proxy404 must allow the first key.
|
||||||
|
// Malformed successful bodies still fail closed with the existing refusal code.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fakeManagement } from './proxy-page-fixture.mjs';
|
||||||
|
const route = '/v8/management/config/api-keys/claude';
|
||||||
|
test('fresh404 yields no accounts, then adds the first key once with no value leakage', async t => {
|
||||||
|
const canary = 'p4-first-key';
|
||||||
|
const s = await fakeManagement(t, { emptyProviderResponse: { status: 404, body: { error: 'p4-body-canary' } } });
|
||||||
|
const empty = await s.call('proxy.accounts');
|
||||||
|
assert.equal(Array.isArray(empty.accounts), true); assert.equal(empty.accounts.length, 0);
|
||||||
|
const added = await s.call('proxy.apikey.add', { provider: 'z.ai', key: canary });
|
||||||
|
assert.equal(added.added, true);
|
||||||
|
const populated = await s.call('proxy.accounts');
|
||||||
|
assert.equal(populated.accounts.length, 1); assert.equal(populated.accounts[0].provider, 'z.ai');
|
||||||
|
assert.equal(s.state.receives, 1, 'one PUT, no retry');
|
||||||
|
assert.equal(s.state.groups[0].keys[0]['api-key'] === canary, true);
|
||||||
|
assert.deepEqual(s.state.calls, ['GET /v8/management/credentials', `GET ${route}`, `GET ${route}`, `PUT ${route}`,
|
||||||
|
'GET /v8/management/credentials', `GET ${route}`]);
|
||||||
|
assert.deepEqual(s.state.receipts.filter(r => r.call === `GET ${route}`).map(r => r.status), [404, 404, 200]);
|
||||||
|
for (const value of [canary, 'p4-body-canary', 'p4-management']) {
|
||||||
|
assert.equal(JSON.stringify([empty, added, populated]).includes(value), false);
|
||||||
|
assert.equal(JSON.stringify(s.state.receipts).includes(value), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const [name, body] of [['null', null], ['object', { groups: [] }]])
|
||||||
|
test(`malformed200 ${name} remains proxy-down, no retry or write`, async t => {
|
||||||
|
const s = await fakeManagement(t, { emptyProviderResponse: { status: 200, body } });
|
||||||
|
await assert.rejects(s.call('proxy.accounts'), { code: 'proxy-down' });
|
||||||
|
assert.deepEqual(s.state.calls, ['GET /v8/management/credentials', `GET ${route}`]);
|
||||||
|
assert.equal(s.state.receives, 0);
|
||||||
|
assert.deepEqual(s.state.receipts.map(r => r.status), [200, 200]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Local management stand-in. All credentials made up, kept only in this test's
|
||||||
|
// memory; logs collect endpoint names, never query strings, headers or bodies.
|
||||||
|
import http from 'node:http';
|
||||||
|
import { ManagementClient } from '../../model-proxy/src/client.mjs';
|
||||||
|
import { ProxyPage } from '../src/proxy-page.mjs';
|
||||||
|
export async function fakeManagement(t, options = {}) {
|
||||||
|
const state = { files: [], groups: [], keys: ['msk-fixture-sentinel-x', 'foreign-key'], receives: 0,
|
||||||
|
calls: [], receipts: [], priorityNoop: false, patchStatus: 200, ...options };
|
||||||
|
let nextId = 1;
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://localhost');
|
||||||
|
const chunks = []; for await (const c of req) chunks.push(c);
|
||||||
|
const value = chunks.length ? JSON.parse(Buffer.concat(chunks)) : null;
|
||||||
|
state.calls.push(req.method + ' ' + u.pathname);
|
||||||
|
const reply = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
|
||||||
|
if (req.headers.authorization !== 'Bearer p4-management') return reply(401, { error: 'refused' });
|
||||||
|
if (u.pathname === '/v8/management/credentials') return reply(200, { files: state.files, observed_at: '2026-10-24T00:00:00Z' });
|
||||||
|
if (u.pathname === '/v8/management/config/api-keys/claude') {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
if (state.groups.length === 0 && state.emptyProviderResponse)
|
||||||
|
return reply(state.emptyProviderResponse.status, state.emptyProviderResponse.body);
|
||||||
|
return reply(200, state.groups);
|
||||||
|
}
|
||||||
|
// Stand-in treats auth_index as derived/read-only metadata.
|
||||||
|
if (value.some(g => (g.keys ?? []).some(k => Object.hasOwn(k, 'auth_index')))) return reply(422, { error: 'derived field' });
|
||||||
|
state.receives++;
|
||||||
|
if (!state.priorityNoop) state.groups = value.map(g => ({ ...g, keys: g.keys.map(k => ({ ...k, auth_index: state.groups.flatMap(old => old.keys ?? []).find(old => old['api-key'] === k['api-key'])?.auth_index ?? `upstream-${nextId++}` })) }));
|
||||||
|
return reply(200, { status: 'ok' });
|
||||||
|
}
|
||||||
|
if (u.pathname === '/v8/management/credentials/fields') {
|
||||||
|
if (state.patchStatus !== 200) return reply(state.patchStatus, { error: 'p4-private-detail' });
|
||||||
|
const row = state.files.find(f => f.name === value.name);
|
||||||
|
if (!row) return reply(404, { error: 'not found' });
|
||||||
|
if (!state.priorityNoop) row.priority = value.priority;
|
||||||
|
return reply(200, { status: 'ok' });
|
||||||
|
}
|
||||||
|
if (u.pathname === '/v8/management/config/access/api-keys') return reply(200, state.keys);
|
||||||
|
if (u.pathname === '/v8/management/oauth/auth-url') return reply(200, { status: 'ok', state: 'p4-state', url: 'https://claude.ai/oauth/authorize?state=p4-state' });
|
||||||
|
if (u.pathname === '/v8/management/oauth/status') return reply(200, { status: 'wait' });
|
||||||
|
if (u.pathname === '/v8/management/oauth/callback' || u.pathname === '/v8/management/oauth/session') return reply(200, { status: 'ok' });
|
||||||
|
return reply(404, {});
|
||||||
|
});
|
||||||
|
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||||
|
t.after(() => new Promise(r => server.close(r)));
|
||||||
|
const client = new ManagementClient({ baseUrl: `http://127.0.0.1:${server.address().port}`, key: 'p4-management', receipt: entry => state.receipts.push(entry) });
|
||||||
|
const page = new ProxyPage({ client, instanceTag: 'fixture', keyRecords: () => options.records ?? [] });
|
||||||
|
const call = (verb, args = {}) => page.request(verb, args, { business: 'demo', human: 'owner' });
|
||||||
|
return { state, page, call, client, server };
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// P4: callback must match an unfinished, unexpired login owned by this human/business.
|
||||||
|
// Named mutant: skip pending-state check. Canary errors must never reflect upstream input.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
const moduleURL = new URL('../src/proxy-page.mjs', import.meta.url);
|
||||||
|
const owner = { business: 'demo', human: 'owner' };
|
||||||
|
async function setup(options = {}) {
|
||||||
|
const { ProxyPage } = await import(moduleURL);
|
||||||
|
let now = 1000;
|
||||||
|
const calls = [];
|
||||||
|
const client = Object.fromEntries(['getCredentials', 'getKeys', 'oauthStart', 'oauthCallback', 'oauthStatus', 'oauthCancel',
|
||||||
|
'getUpstreamGroups', 'putUpstreamGroups', 'patchCredential'].map(name => [name, async (...args) => {
|
||||||
|
calls.push([name, ...args]);
|
||||||
|
if (name === 'oauthStart') return { status: 'ok', state: 'p4-state', url: 'https://claude.ai/oauth/authorize?state=p4-state' };
|
||||||
|
if (name === 'oauthStatus') return { status: 'wait' };
|
||||||
|
if (name === 'getCredentials') return { files: [] };
|
||||||
|
if (name === 'getUpstreamGroups') return [];
|
||||||
|
if (name === 'getKeys') return [];
|
||||||
|
return { status: 'ok' };
|
||||||
|
}]));
|
||||||
|
const p = new ProxyPage({ client, instanceTag: 'fixture', now: () => now, ...options });
|
||||||
|
return { client, calls, p, time: value => { now = value; }, call: (verb, args = {}, who = owner) => p.request(verb, args, who) };
|
||||||
|
}
|
||||||
|
for (const [verb, args] of [
|
||||||
|
['proxy.login.start', { provider: 'other' }], ['proxy.apikey.add', { provider: 'other', key: 'fixture-key' }],
|
||||||
|
]) test(`${verb}: provider-not-allowed`, async () => {
|
||||||
|
const s = await setup(); await assert.rejects(s.call(verb, args), { code: 'provider-not-allowed' }); assert.equal(s.calls.length, 0);
|
||||||
|
});
|
||||||
|
for (const [name, key] of [['empty', ''], ['too long', 'x'.repeat(4097)], ['charset', 'bad\nkey']])
|
||||||
|
test(`apikey-invalid ${name}`, async () => {
|
||||||
|
const s = await setup(); await assert.rejects(s.call('proxy.apikey.add', { provider: 'z.ai', key }), { code: 'apikey-invalid' }); assert.equal(s.calls.length, 0);
|
||||||
|
});
|
||||||
|
for (const [name, url] of [['not URL', 'not a url'], ['oversize', 'http://localhost/?' + 'a'.repeat(8193)],
|
||||||
|
['repeated state', 'http://localhost/?state=p4-state&state=p4-state']])
|
||||||
|
test(`callback-url-invalid ${name}`, async () => {
|
||||||
|
const s = await setup(); await s.call('proxy.login.start', { provider: 'claude' });
|
||||||
|
await assert.rejects(s.call('proxy.login.submit', { redirect_url: url }), { code: 'callback-url-invalid' });
|
||||||
|
assert.equal(s.calls.filter(c => c[0] === 'oauthCallback').length, 0);
|
||||||
|
});
|
||||||
|
test('login-state-unknown callback never reaches proxy', async () => {
|
||||||
|
const s = await setup();
|
||||||
|
await assert.rejects(s.call('proxy.login.submit', { redirect_url: 'http://localhost/?state=unknown&code=p4-code' }), { code: 'login-state-unknown' });
|
||||||
|
assert.equal(s.calls.length, 0);
|
||||||
|
});
|
||||||
|
test('login state is scoped to the human and business', async () => {
|
||||||
|
const s = await setup(); await s.call('proxy.login.start', { provider: 'claude' });
|
||||||
|
for (const who of [{ business: 'other', human: 'owner' }, { business: 'demo', human: 'other' }])
|
||||||
|
await assert.rejects(s.call('proxy.login.status', { state: 'p4-state' }, who), { code: 'login-state-unknown' });
|
||||||
|
assert.equal(s.calls.length, 1);
|
||||||
|
});
|
||||||
|
test('accepted callback wait is pending; only a status ok completes and retires state', async () => {
|
||||||
|
const s = await setup(); await s.call('proxy.login.start', { provider: 'claude' });
|
||||||
|
assert.deepEqual(await s.call('proxy.login.submit', { redirect_url: 'http://localhost/?state=p4-state&code=p4-code' }), { status: 'wait' });
|
||||||
|
assert.deepEqual(await s.call('proxy.login.status', { state: 'p4-state' }), { status: 'wait' });
|
||||||
|
s.client.oauthStatus = async () => ({ status: 'ok', token: 'never-return' });
|
||||||
|
assert.deepEqual(await s.call('proxy.login.status', { state: 'p4-state' }), { status: 'ok' });
|
||||||
|
await assert.rejects(s.call('proxy.login.status', { state: 'p4-state' }), { code: 'login-state-unknown' });
|
||||||
|
await assert.rejects(s.call('proxy.login.submit', { redirect_url: 'http://localhost/?state=p4-state&code=p4-code' }), { code: 'login-state-unknown' });
|
||||||
|
});
|
||||||
|
test('expiry reports login-timeout once with no management call and retires state', async () => {
|
||||||
|
const s = await setup(); await s.call('proxy.login.start', { provider: 'claude' }); s.time(301000);
|
||||||
|
assert.deepEqual(await s.call('proxy.login.status', { state: 'p4-state' }), { status: 'error', reason: 'login-timeout' });
|
||||||
|
assert.equal(s.calls.length, 1);
|
||||||
|
await assert.rejects(s.call('proxy.login.status', { state: 'p4-state' }), { code: 'login-state-unknown' });
|
||||||
|
});
|
||||||
|
test('cancel calls DELETE and retires state', async () => {
|
||||||
|
const s = await setup(); await s.call('proxy.login.start', { provider: 'claude' });
|
||||||
|
assert.deepEqual(await s.call('proxy.login.cancel', { state: 'p4-state' }), { status: 'cancelled' });
|
||||||
|
assert.deepEqual(s.calls.at(-1), ['oauthCancel', 'p4-state']);
|
||||||
|
await assert.rejects(s.call('proxy.login.status', { state: 'p4-state' }), { code: 'login-state-unknown' });
|
||||||
|
});
|
||||||
|
test('upstream OAuth error body never crosses bus response', async () => {
|
||||||
|
const s = await setup(); await s.call('proxy.login.start', { provider: 'claude' });
|
||||||
|
s.client.oauthStatus = async () => ({ status: 'error', error: 'p4-private-code', state: 'p4-state' });
|
||||||
|
assert.deepEqual(await s.call('proxy.login.status', { state: 'p4-state' }), { status: 'error', reason: 'login-failed' });
|
||||||
|
});
|
||||||
|
for (const args of [{ accountId: 'bad', priority: 1 }, { accountId: 'a'.repeat(12), priority: 1.5 },
|
||||||
|
{ accountId: 'a'.repeat(12), priority: 1001 }]) test('priority-invalid ' + JSON.stringify(args), async () => {
|
||||||
|
const s = await setup(); await assert.rejects(s.call('proxy.priority.set', args), { code: 'priority-invalid' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// P4: broker security boundary. Mutants: granted user accepted; secret in error/log.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Broker } from '../src/broker.mjs';
|
||||||
|
import { Store } from '../src/store.mjs';
|
||||||
|
|
||||||
|
const verbs = ['proxy.accounts', 'proxy.login.start', 'proxy.login.submit', 'proxy.login.status',
|
||||||
|
'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.keys'];
|
||||||
|
function setup(t, proxyPage = async () => ({ ok: true })) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'p4-bus-'));
|
||||||
|
const store = new Store(root);
|
||||||
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||||
|
const calls = [];
|
||||||
|
const broker = new Broker({ store, proxyPage: proxyPage && (async (...args) => { calls.push(args); return proxyPage(...args); }),
|
||||||
|
access: () => ({ accessVersion: 1, revision: 1, users: [], grants: [
|
||||||
|
{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['coder'], level: 'collaborator' },
|
||||||
|
] }),
|
||||||
|
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||||
|
vars: { 'bus.tester': true }, roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
|
||||||
|
});
|
||||||
|
const caps = {
|
||||||
|
human: broker.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }),
|
||||||
|
tester: broker.bindTester({ business: 'demo' }),
|
||||||
|
agent: broker.bindLaunch({ business: 'demo', role: 'coder', run: 'p4-run', harness: 'pi' }),
|
||||||
|
user: broker.bindUser({ business: 'demo', user: 'alice' }),
|
||||||
|
};
|
||||||
|
return { calls, store, call: (who, verb, args = {}) => broker.request(caps[who], { verb, args }) };
|
||||||
|
}
|
||||||
|
for (const verb of verbs) {
|
||||||
|
test(`${verb}: owner calls page once with business/human scope`, async t => {
|
||||||
|
const s = setup(t);
|
||||||
|
assert.deepEqual(await s.call('human', verb), { ok: true });
|
||||||
|
assert.deepEqual(s.calls, [[verb, {}, { business: 'demo', human: 'owner' }]]);
|
||||||
|
});
|
||||||
|
for (const who of ['tester', 'agent', 'user']) test(`${verb}: ${who} human-required`, async t => {
|
||||||
|
const s = setup(t);
|
||||||
|
await assert.rejects(async () => s.call(who, verb), { code: 'human-required' });
|
||||||
|
assert.equal(s.calls.length, 0);
|
||||||
|
});
|
||||||
|
test(`${verb}: not-configured`, async t => {
|
||||||
|
const s = setup(t, null);
|
||||||
|
await assert.rejects(async () => s.call('human', verb), { code: 'not-configured' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('API-key canary never enters broker events or unknown error output', async t => {
|
||||||
|
const canary = 'p4-canary-key';
|
||||||
|
const s = setup(t, async () => { throw new Error(canary); });
|
||||||
|
await assert.rejects(async () => s.call('human', 'proxy.apikey.add', { provider: 'z.ai', key: canary }),
|
||||||
|
e => e.code === 'proxy-down' && !e.message.includes(canary));
|
||||||
|
assert.equal(s.calls.length, 1);
|
||||||
|
assert.equal(JSON.stringify(s.store.all('SELECT body FROM events')).includes(canary), false);
|
||||||
|
});
|
||||||
@@ -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
|
// 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
|
// (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.
|
// 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);
|
const { document: old, revision } = writer.read(business);
|
||||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||||
const { name, definition } = args;
|
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.
|
// mos-1585: duplicates refuse before definition eligibility, with no file or credential effect.
|
||||||
definitionFor(rolesDir, definition);
|
definitionFor(rolesDir, definition);
|
||||||
next.launch.instances.push(name);
|
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 });
|
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 { ...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 };
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ export const USAGE = `usage:
|
|||||||
${LIMITS_USAGE}
|
${LIMITS_USAGE}
|
||||||
${PLUGINS_USAGE}
|
${PLUGINS_USAGE}
|
||||||
${CREDENTIALS_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}
|
${HOSTS_USAGE}
|
||||||
${GRANTS_USAGE}
|
${GRANTS_USAGE}
|
||||||
${INSTANCES_USAGE}
|
${INSTANCES_USAGE}
|
||||||
|
|||||||
@@ -139,7 +139,9 @@
|
|||||||
// transaction; a later delete retries it.
|
// transaction; a later delete retries it.
|
||||||
|
|
||||||
import { createServer } from "node:net";
|
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 { dirname, join, relative } from "node:path";
|
||||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||||
import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.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}));
|
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,session,human}));
|
||||||
admission(admissionGeneration);
|
admission(admissionGeneration);
|
||||||
const { run, runDir } = newRun(dataRoot, b.id);
|
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");
|
const sessionDir = store ? store.session : join(runDir, "session");
|
||||||
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
||||||
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
|
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 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 land = thread ? await host.op({ op: "landHeld", record: { business: b.id, role: instance, thread } }) : holding.length > 0;
|
||||||
const bundle = buildBundle({
|
const bundle = buildBundle({
|
||||||
|
...(proxy ? { proxy } : {}),
|
||||||
dir: join(runDir, "bundle"),
|
dir: join(runDir, "bundle"),
|
||||||
resolved,
|
resolved,
|
||||||
models: b.launch.models,
|
models: b.launch.models,
|
||||||
@@ -611,12 +624,13 @@ export function createLauncher({
|
|||||||
admission(admissionGeneration);
|
admission(admissionGeneration);
|
||||||
if (!outerStart) throw new Error("the session process exited at once");
|
if (!outerStart) throw new Error("the session process exited at once");
|
||||||
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
|
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;
|
bound = true;
|
||||||
admission(admissionGeneration);
|
admission(admissionGeneration);
|
||||||
entry = {
|
entry = {
|
||||||
business: b.id, instance, ...(thread ? { thread, session, workspace } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
|
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(),
|
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
|
||||||
|
...(proxy ? { modelKeyId } : {}),
|
||||||
};
|
};
|
||||||
registry.add(entry);
|
registry.add(entry);
|
||||||
// Row 156: one start per decision, whichever launch wrote it into landing.
|
// Row 156: one start per decision, whichever launch wrote it into landing.
|
||||||
@@ -630,10 +644,14 @@ export function createLauncher({
|
|||||||
spawnedAt.delete(run);
|
spawnedAt.delete(run);
|
||||||
unregisterRuntime(dataRoot, managed.id);
|
unregisterRuntime(dataRoot, managed.id);
|
||||||
if (e instanceof Refusal) throw e;
|
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);
|
throw new Refusal("launch-failed", e.message);
|
||||||
}
|
}
|
||||||
children.set(run, exited);
|
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 }) => {
|
exited.then(async ({ code, signal }) => {
|
||||||
|
outputWatch?.cancel();
|
||||||
let receipt;
|
let receipt;
|
||||||
try { receipt = runtime(dataRoot, managed.id); }
|
try { receipt = runtime(dataRoot, managed.id); }
|
||||||
catch { log(`run ${run}: processing receipt unavailable at exit`); }
|
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`);
|
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 {
|
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) {
|
} catch (e) {
|
||||||
// A host that died after ending the run but before updating sessions.json.
|
// 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`);
|
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;
|
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 });
|
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`);
|
log(`run ${s.run} (${s.instance}) from an earlier host ended: host-lost`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CliError } from './errors.mjs';
|
import { CliError } from './errors.mjs';
|
||||||
import { refuseInsideAgent } from './transport.mjs';
|
import { refuseInsideAgent } from './transport.mjs';
|
||||||
import { fetchPinned } from '../../model-proxy/src/fetch.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 });
|
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.
|
// A raw terminal suppresses echo; input is never argv, env, readline history or stdout.
|
||||||
export function promptProxyKey(io, label) {
|
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 }) {
|
export async function proxyCommand(args, io, { system, callFor, prompt = promptProxyKey, fetch = fetchPinned }) {
|
||||||
const [verb, ...rest] = args;
|
const [verb, ...rest] = args;
|
||||||
if (args.includes('--tester')) throw failure('human-required', 3);
|
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); }
|
try { refuseInsideAgent(io.env); } catch { throw failure('human-required', 3); }
|
||||||
}
|
}
|
||||||
if (!['fetch', 'init'].includes(verb)) throw failure('proxy-usage', 4);
|
if (!['fetch', 'init'].includes(verb)) throw failure('proxy-usage', 4);
|
||||||
const parsed = { flags: new Set(), values: {} }; let port;
|
const parsed = { flags: new Set(), values: {} }; let port;
|
||||||
for (let i = 0; i < rest.length; i++) {
|
for (let i = 0; i < rest.length; i++) {
|
||||||
const flag = rest[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];
|
parsed.values[flag] = rest[++i];
|
||||||
}
|
}
|
||||||
if (parsed.values['--port'] !== undefined) {
|
if (parsed.values['--port'] !== undefined) {
|
||||||
@@ -46,6 +47,10 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
|
|||||||
port = Number(parsed.values['--port']);
|
port = Number(parsed.values['--port']);
|
||||||
if (port < 1024 || port > 65535 || port === 8317) throw failure('proxy-config-invalid');
|
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 {
|
try {
|
||||||
const config = typeof system === 'function' ? system() : system;
|
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; }
|
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 call = callFor(parsed);
|
||||||
const first = await prompt(io, 'Management key: '), second = await prompt(io, 'Repeat management key: ');
|
const first = await prompt(io, 'Management key: '), second = await prompt(io, 'Repeat management key: ');
|
||||||
if (first !== second) throw failure('proxy-key-mismatch');
|
if (first !== second) throw failure('proxy-key-mismatch');
|
||||||
if (!first.trim() || first.length > 4096 || /[\x00-\x1f\x7f]/.test(first)) throw failure('proxy-key-invalid');
|
if (!validManagementKey(first)) throw failure('proxy-key-invalid');
|
||||||
const result = await call('proxy.init', { key: first, ...(port === undefined ? {} : { port }) });
|
const result = await call('proxy.init', { key: first, ...(port === undefined ? {} : { port }), ...timing });
|
||||||
io.stdout.write(JSON.stringify(result) + '\n');
|
io.stdout.write(JSON.stringify(result) + '\n');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof CliError) throw e;
|
if (e instanceof CliError) throw e;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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');}};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,[]);});
|
||||||
@@ -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,[]);});
|
||||||
@@ -86,6 +86,7 @@ async function start(t, { f, adapter }, { tickMs = 100, stopTimeoutMs } = {}) {
|
|||||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||||
|
recoverLaunch: (m) => runtime.recoverLaunch(m.record),
|
||||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||||
threadsState: (m) => runtime.threadsState(m.business),
|
threadsState: (m) => runtime.threadsState(m.business),
|
||||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ async function start(t, { f, adapter, access }) {
|
|||||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||||
|
recoverLaunch: (m) => runtime.recoverLaunch(m.record),
|
||||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||||
threadsState: (m) => runtime.threadsState(m.business),
|
threadsState: (m) => runtime.threadsState(m.business),
|
||||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import { createServer, connect } from "node:net";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { Client } from "../../bus/src/client.mjs";
|
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 { GUARD_CODES, FIXED_TEXTS } from "../../library/src/reply-protocol.mjs";
|
||||||
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
|
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
|
||||||
|
|
||||||
@@ -344,12 +345,14 @@ function runTurn({ session, request, n, env, onChild }) {
|
|||||||
closeSync(errFd);
|
closeSync(errFd);
|
||||||
onChild(child);
|
onChild(child);
|
||||||
let out = "";
|
let out = "";
|
||||||
|
const markOutput = firstOutputMarker(session.runDir);
|
||||||
child.stdout.setEncoding("utf8");
|
child.stdout.setEncoding("utf8");
|
||||||
child.stdout.on("data", (s) => {
|
child.stdout.on("data", (s) => {
|
||||||
|
markOutput(s);
|
||||||
if (out.length < 4 * BODY_MAX) out += s;
|
if (out.length < 4 * BODY_MAX) out += s;
|
||||||
});
|
});
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let timedOut = false;
|
let timedOut = false, markerFailed = false, markerKill;
|
||||||
const kill = (signal) => {
|
const kill = (signal) => {
|
||||||
try {
|
try {
|
||||||
process.kill(-child.pid, signal);
|
process.kill(-child.pid, signal);
|
||||||
@@ -360,15 +363,28 @@ function runTurn({ session, request, n, env, onChild }) {
|
|||||||
kill("SIGTERM");
|
kill("SIGTERM");
|
||||||
setTimeout(() => kill("SIGKILL"), 5000).unref();
|
setTimeout(() => kill("SIGKILL"), 5000).unref();
|
||||||
}, session.turnTimeout * 1000);
|
}, 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) => {
|
child.on("error", (e) => {
|
||||||
|
if (markerFailed) return; // wait for close/reap, never resolve early
|
||||||
|
|
||||||
clearTimeout(clock);
|
clearTimeout(clock);
|
||||||
onChild(null);
|
onChild(null);
|
||||||
resolve({ ok: false, reason: `adapter didn't start: ${e.code ?? e.message}` });
|
resolve({ ok: false, reason: `adapter didn't start: ${e.code ?? e.message}` });
|
||||||
});
|
});
|
||||||
child.on("close", (code, signal) => {
|
child.on("close", (code, signal) => {
|
||||||
clearTimeout(clock);
|
clearTimeout(clock);
|
||||||
|
clearTimeout(markerKill);
|
||||||
kill("SIGKILL");
|
kill("SIGKILL");
|
||||||
onChild(null);
|
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 (timedOut) return resolve({ ok: false, reason: `turn passed its ${session.turnTimeout} s wall clock` });
|
||||||
if (code !== 0) return resolve({ ok: false, reason: signal ? `adapter killed by ${signal}` : `adapter exited ${code}` });
|
if (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.
|
// 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;
|
let session, cap, policy;
|
||||||
try {
|
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;
|
cap = JSON.parse(await readLine(stdin)).cap;
|
||||||
if (typeof cap !== "string" || !/^[0-9a-f]{64}$/.test(cap)) throw new Error("no capability on stdin");
|
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"));
|
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
|
// 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
|
// 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.
|
// 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 ?? []) {
|
for (const e of session.landing ?? []) {
|
||||||
if (stopping) break;
|
if (stopping) break;
|
||||||
let block;
|
let block;
|
||||||
@@ -538,6 +561,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
|||||||
unslop.read();
|
unslop.read();
|
||||||
try { await client.call("usage.end", { turn: e.decision }); }
|
try { await client.call("usage.end", { turn: e.decision }); }
|
||||||
catch (err) { log(`runner: usage outcome unavailable: ${err.code ?? "failed"}`); }
|
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}`}`);
|
log(`runner: landing turn ${n} for decision ${e.decision} (${e.task_ref}): ${turn.ok ? "ok" : `failed: ${turn.reason}`}`);
|
||||||
}
|
}
|
||||||
loop: while (!stopping && !gone) {
|
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.
|
// A failed/stopped harness can still have used tokens. Missing data remains unknown.
|
||||||
try { await client.call("usage.end", { turn: m.id }); }
|
try { await client.call("usage.end", { turn: m.id }); }
|
||||||
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
|
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
|
||||||
|
if (failedMarker(turn)) break loop;
|
||||||
if (stopping) break loop;
|
if (stopping) break loop;
|
||||||
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
|
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
|
||||||
try {
|
try {
|
||||||
@@ -628,6 +653,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
|||||||
unslop.read();
|
unslop.read();
|
||||||
try { await client.call("usage.end", { turn: `${m.id}:retry` }); }
|
try { await client.call("usage.end", { turn: `${m.id}:retry` }); }
|
||||||
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
|
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
|
||||||
|
if (failedMarker(retry)) break loop;
|
||||||
if (stopping) break loop;
|
if (stopping) break loop;
|
||||||
if (!retry.ok) log(`runner: retry turn ${n} for message ${m.id} failed: ${retry.reason}`);
|
if (!retry.ok) log(`runner: retry turn ${n} for message ${m.id} failed: ${retry.reason}`);
|
||||||
try { await send(retry); }
|
try { await send(retry); }
|
||||||
@@ -650,7 +676,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
|||||||
}
|
}
|
||||||
server.close();
|
server.close();
|
||||||
rmSync(session.toolSocket, { force: true });
|
rmSync(session.toolSocket, { force: true });
|
||||||
if (code === EXIT.stopped && !released) {
|
if ((code === EXIT.stopped || markerFailure) && !released) {
|
||||||
try {
|
try {
|
||||||
await client.call("role.release");
|
await client.call("role.release");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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
|
The bus initializes and manages one pinned CLIProxyAPI per instance. PIN records
|
||||||
release URLs and independently reviewable SHA256s of the archive, executable and
|
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
|
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.
|
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
|
Client-key values are never returned by an agent-facing bus verb. The bus manages
|
||||||
per-agent lifecycle integration; this row creates only the sentinel.
|
per-agent lifecycle below; initialization creates only the sentinel.
|
||||||
|
|
||||||
The unit starts through a hash-checking runner with the pinned panel directory
|
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;
|
and `-local-model`, so catalogs require no network. Render/install is explicit;
|
||||||
@@ -47,3 +47,51 @@ 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
|
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
|
may leave empty `opt/cliproxyapi/` parent directories; they are not an installed
|
||||||
version and need not be removed before retrying.
|
version and need not be removed before retrying.
|
||||||
|
|
||||||
|
PIN upgrades must carry the native Console page's offline upgrade receipt.
|
||||||
|
Run `bash agents/poe/work/queue-330/acceptance.sh <verified-fetched-executable>`
|
||||||
|
under the test-slot rules, and include its output for the new PIN version in
|
||||||
|
that upgrade row's BUILD.md. Sage's landing gate checks the receipt. The script
|
||||||
|
uses a new temp root and private network namespace, with made-up credentials,
|
||||||
|
and calls the page's bus verbs. If the page cannot pass against the new PIN,
|
||||||
|
the upgrade stops; Sage takes Q46's fallback to Jason through Mos.
|
||||||
|
|
||||||
|
The native `/proxy` page uses human-only bus verbs; only the bus holds the
|
||||||
|
management client. On PIN 8.0.42, OAuth credentials are listed under
|
||||||
|
`/v8/management/credentials` and priority is patched as `{name, priority}`.
|
||||||
|
API-key groups are absent from that list: z.ai keys use the Claude-compatible
|
||||||
|
provider configuration at `/v8/management/config/api-keys/claude`, whose keys
|
||||||
|
carry proxy-generated `auth_index` identifiers. Their priority updates use
|
||||||
|
provider configuration and a read-back check. No upstream API-key value is
|
||||||
|
returned by the bus.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -15,14 +15,14 @@ export class ManagementClient {
|
|||||||
|| typeof receipt !== 'function' || !Number.isSafeInteger(timeout) || timeout <= 0) refuse('proxy-client-invalid');
|
|| typeof receipt !== 'function' || !Number.isSafeInteger(timeout) || timeout <= 0) refuse('proxy-client-invalid');
|
||||||
this.#base = u.origin; this.#key = key; this.#receipt = receipt; this.#timeout = timeout;
|
this.#base = u.origin; this.#key = key; this.#receipt = receipt; this.#timeout = timeout;
|
||||||
}
|
}
|
||||||
#request(method, keys, onStatus) {
|
#request(method, endpoint, payload, onStatus) {
|
||||||
// node:http addresses the fixed numeric loopback directly. An ambient fetch
|
// node:http addresses the fixed numeric loopback directly. An ambient fetch
|
||||||
// dispatcher or HTTP_PROXY cannot send the Authorization header elsewhere.
|
// dispatcher or HTTP_PROXY cannot send the Authorization header elsewhere.
|
||||||
let deadline;
|
let deadline;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let received = false;
|
let received = false;
|
||||||
const req = http.request(this.#base + KEY_PATH, {
|
const req = http.request(this.#base + endpoint, {
|
||||||
method, headers: { Authorization: `Bearer ${this.#key}`, ...(method === 'PUT' ? { 'Content-Type': 'application/json' } : {}) },
|
method, headers: { Authorization: `Bearer ${this.#key}`, ...(payload !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
||||||
}, response => {
|
}, response => {
|
||||||
received = true; onStatus(response.statusCode);
|
received = true; onStatus(response.statusCode);
|
||||||
const error = response.statusCode >= 300 && response.statusCode < 400 ? 'proxy-client-redirect'
|
const error = response.statusCode >= 300 && response.statusCode < 400 ? 'proxy-client-redirect'
|
||||||
@@ -43,13 +43,13 @@ export class ManagementClient {
|
|||||||
});
|
});
|
||||||
req.on('error', () => reject(new ProxyError(received ? 'proxy-client-response' : 'proxy-client-down')));
|
req.on('error', () => reject(new ProxyError(received ? 'proxy-client-response' : 'proxy-client-down')));
|
||||||
deadline = setTimeout(() => { req.destroy(); reject(new ProxyError('proxy-client-down')); }, this.#timeout);
|
deadline = setTimeout(() => { req.destroy(); reject(new ProxyError('proxy-client-down')); }, this.#timeout);
|
||||||
req.end(method === 'PUT' ? JSON.stringify(keys) : undefined);
|
req.end(payload === undefined ? undefined : JSON.stringify(payload));
|
||||||
}).finally(() => clearTimeout(deadline));
|
}).finally(() => clearTimeout(deadline));
|
||||||
}
|
}
|
||||||
async #call(method, keys) {
|
async #call(method, keys) {
|
||||||
let status = 0, ids = [];
|
let status = 0, ids = [];
|
||||||
try {
|
try {
|
||||||
const body = await this.#request(method, keys, value => { status = value; });
|
const body = await this.#request(method, KEY_PATH, keys, value => { status = value; });
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
if (!validKeys(body)) refuse('proxy-client-response');
|
if (!validKeys(body)) refuse('proxy-client-response');
|
||||||
ids = body.map(sha256).map(h => h.slice(0, 12));
|
ids = body.map(sha256).map(h => h.slice(0, 12));
|
||||||
@@ -62,6 +62,56 @@ export class ManagementClient {
|
|||||||
}
|
}
|
||||||
getKeys() { return this.#call('GET'); }
|
getKeys() { return this.#call('GET'); }
|
||||||
putKeys(keys) { if (!validKeys(keys)) refuse('proxy-client-invalid'); return this.#call('PUT', keys); }
|
putKeys(keys) { if (!validKeys(keys)) refuse('proxy-client-invalid'); return this.#call('PUT', keys); }
|
||||||
|
// Fixed management operations for the bus's native page. Queries contain login
|
||||||
|
// states; neither queries nor request/response bodies belong in a receipt.
|
||||||
|
async #page(method, endpoint, payload, expectedStatus = null) {
|
||||||
|
let status = 0;
|
||||||
|
try {
|
||||||
|
const answer = await this.#request(method, endpoint, payload, value => { status = value; });
|
||||||
|
if (expectedStatus !== null && status !== expectedStatus) refuse('proxy-client-refused');
|
||||||
|
return answer;
|
||||||
|
} catch (error) {
|
||||||
|
if (method === 'GET' && endpoint === '/v8/management/config/api-keys/claude' && status === 404) return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
finally { this.#receipt({ call: `${method} ${endpoint.split('?')[0]}`, status, keyIds: [] }); }
|
||||||
|
}
|
||||||
|
getCredentials() { return this.#page('GET', '/v8/management/credentials'); }
|
||||||
|
getUpstreamGroups() { return this.#page('GET', '/v8/management/config/api-keys/claude'); }
|
||||||
|
putUpstreamGroups(groups) {
|
||||||
|
// auth_index is derived response metadata, not writable provider config.
|
||||||
|
const payload = groups.map(group => ({ ...group, keys: group.keys.map(({ auth_index, ...key }) => key) }));
|
||||||
|
return this.#page('PUT', '/v8/management/config/api-keys/claude', payload, 200);
|
||||||
|
}
|
||||||
|
patchCredential(name, priority) { return this.#page('PATCH', '/v8/management/credentials/fields', { name, priority }, 200); }
|
||||||
|
oauthStart(provider) {
|
||||||
|
if (!['claude', 'codex'].includes(provider)) refuse('proxy-client-invalid');
|
||||||
|
return this.#page('GET', `/v8/management/oauth/auth-url?provider=${provider}`);
|
||||||
|
}
|
||||||
|
oauthCallback(redirect_url) { return this.#page('POST', '/v8/management/oauth/callback', { redirect_url }); }
|
||||||
|
oauthStatus(state) { return this.#page('GET', `/v8/management/oauth/status?state=${encodeURIComponent(state)}`); }
|
||||||
|
oauthCancel(state) { return this.#page('DELETE', `/v8/management/oauth/session?state=${encodeURIComponent(state)}`); }
|
||||||
|
// 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.
|
// Trusted bus boot only: no CLI/Console/session exports this value or client.
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { validateHeaderValue } from 'node:http';
|
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
export class ProxyError extends Error {
|
import { ProxyError, refuse, validManagementKey, proxyTiming } from './validation.mjs';
|
||||||
constructor(code) { super(code); this.name = 'ProxyError'; this.code = code; }
|
export { ProxyError, refuse, validManagementKey, proxyTiming };
|
||||||
}
|
|
||||||
export const refuse = code => { throw new ProxyError(code); };
|
|
||||||
export const sha256 = value => createHash('sha256').update(value).digest('hex');
|
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')));
|
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.
|
// 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); }
|
} catch { refuse(code); }
|
||||||
finally { if (fd !== undefined) fs.closeSync(fd); }
|
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; }
|
|
||||||
}
|
|
||||||
@@ -2,23 +2,26 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { randomBytes } from 'node:crypto';
|
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) {
|
export function installPaths(dataRoot, pin = PIN) {
|
||||||
const dir = path.join(dataRoot, 'opt', 'cliproxyapi', pin.version);
|
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') };
|
return { dir, binary: path.join(dir, 'cli-proxy-api'), panel: path.join(dir, 'static', 'management.html') };
|
||||||
}
|
}
|
||||||
export function validateProxy(value, dataRoot, pin = PIN) {
|
export function validateProxy(value, dataRoot, pin = PIN) {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)
|
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
|
|| !Number.isInteger(value.port) || value.port < 1024 || value.port > 65535 || value.port === 8317
|
||||||
|| value.binary !== installPaths(dataRoot, pin).binary) refuse('proxy-config-invalid');
|
|| 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) {
|
export function loadProxy(configDir, dataRoot) {
|
||||||
const file = path.join(configDir, 'proxy.json');
|
const file = path.join(configDir, 'proxy.json');
|
||||||
try { fs.lstatSync(file); } catch (e) { if (e.code === 'ENOENT') return undefined; refuse('proxy-config-invalid'); }
|
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); }
|
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) {
|
export function verifyInstall(dataRoot, pin = PIN) {
|
||||||
const paths = installPaths(dataRoot, 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.
|
// 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');
|
if (!validManagementKey(key)) refuse('proxy-key-invalid');
|
||||||
|
const timing = proxyTiming(options);
|
||||||
safePath(configDir, 'proxy-init-failed'); safePath(dataRoot, 'proxy-init-failed');
|
safePath(configDir, 'proxy-init-failed'); safePath(dataRoot, 'proxy-init-failed');
|
||||||
const paths = verifyInstall(dataRoot, pin);
|
const paths = verifyInstall(dataRoot, pin);
|
||||||
const settingsFile = path.join(configDir, 'proxy.json'), proxyDir = path.join(dataRoot, 'proxy');
|
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 tag = sha256(configDir).slice(0, 12);
|
||||||
const sentinel = `msk-${tag}-sentinel-${randomBytes(32).toString('base64url')}`;
|
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 });
|
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;
|
const settingsFd = fs.openSync(settingsFile, 'wx', 0o600); ownsSettings = true;
|
||||||
try { fs.writeFileSync(settingsFd, JSON.stringify(settings, null, 2) + '\n'); } finally { fs.closeSync(settingsFd); }
|
try { fs.writeFileSync(settingsFd, JSON.stringify(settings, null, 2) + '\n'); } finally { fs.closeSync(settingsFd); }
|
||||||
return settings;
|
return settings;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// P4 pinned8.0.42 regression: absent Claude provider config is HTTP404.
|
||||||
|
// Normalize only that fixed GET; all other methods/endpoints/statuses refuse.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { ManagementClient } from '../src/client.mjs';
|
||||||
|
const endpoint = '/v8/management/config/api-keys/claude';
|
||||||
|
const bodyCanary = 'p4-body-canary';
|
||||||
|
async function fixture(t, status, body = { error: bodyCanary, 'api-key': bodyCanary }) {
|
||||||
|
const requests = [], receipts = [];
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const chunks = []; for await (const chunk of req) chunks.push(chunk);
|
||||||
|
requests.push({ method: req.method, path: req.url, body: Buffer.concat(chunks).toString('utf8') });
|
||||||
|
res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body));
|
||||||
|
});
|
||||||
|
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||||
|
t.after(() => new Promise(r => server.close(r)));
|
||||||
|
const client = new ManagementClient({ baseUrl: `http://127.0.0.1:${server.address().port}`, key: 'p4-management', receipt: r => receipts.push(r) });
|
||||||
|
return { client, requests, receipts };
|
||||||
|
}
|
||||||
|
function evidence(s, method, path, status) {
|
||||||
|
assert.equal(s.requests.length, 1, 'one call, no retry');
|
||||||
|
assert.deepEqual(s.requests.map(r => [r.method, r.path]), [[method, path]]);
|
||||||
|
assert.deepEqual(s.receipts, [{ call: `${method} ${path}`, status, keyIds: [] }]);
|
||||||
|
assert.equal(JSON.stringify(s.receipts).includes(bodyCanary), false);
|
||||||
|
assert.equal(JSON.stringify(s.receipts).includes('p4-management'), false);
|
||||||
|
}
|
||||||
|
async function refused(operation) {
|
||||||
|
let failure;
|
||||||
|
try { await operation(); } catch (error) { failure = error; }
|
||||||
|
assert.equal(failure?.code, 'proxy-client-refused');
|
||||||
|
assert.equal(failure.message.includes(bodyCanary), false, 'no error-body value escapes');
|
||||||
|
assert.equal(JSON.stringify(failure).includes(bodyCanary), false);
|
||||||
|
}
|
||||||
|
test('empty provider GET404 yields empty array, discards body and receipts actual404 once', async t => {
|
||||||
|
const s = await fixture(t, 404);
|
||||||
|
const result = await s.client.getUpstreamGroups();
|
||||||
|
assert.equal(Array.isArray(result), true); assert.equal(result.length, 0);
|
||||||
|
assert.equal(JSON.stringify(result).includes(bodyCanary), false);
|
||||||
|
evidence(s, 'GET', endpoint, 404);
|
||||||
|
});
|
||||||
|
for (const status of [401, 403, 500]) test(`provider GET${status} remains proxy-client-refused, no retry`, async t => {
|
||||||
|
const s = await fixture(t, status);
|
||||||
|
await refused(() => s.client.getUpstreamGroups());
|
||||||
|
evidence(s, 'GET', endpoint, status);
|
||||||
|
});
|
||||||
|
for (const [method, path] of [['getCredentials', '/v8/management/credentials'], ['getKeys', '/v8/management/config/access/api-keys']])
|
||||||
|
test(`${method}404 remains proxy-client-refused, no retry`, async t => {
|
||||||
|
const s = await fixture(t, 404);
|
||||||
|
await refused(() => s.client[method]());
|
||||||
|
evidence(s, 'GET', path, 404);
|
||||||
|
});
|
||||||
|
test('provider PUT404 remains proxy-client-refused, never treated as empty GET', async t => {
|
||||||
|
const s = await fixture(t, 404);
|
||||||
|
await refused(() => s.client.putUpstreamGroups([{ name: 'fixture', keys: [{ 'api-key': 'p4-first-key' }] }]));
|
||||||
|
evidence(s, 'PUT', endpoint, 404);
|
||||||
|
assert.equal(s.requests[0].body.includes('p4-first-key'), true);
|
||||||
|
assert.equal(JSON.stringify(s.receipts).includes('p4-first-key'), false);
|
||||||
|
});
|
||||||
|
test('provider GET200 array passes through, with receipt and no retry', async t => {
|
||||||
|
const s = await fixture(t, 200, [{ name: 'fixture', keys: [] }]);
|
||||||
|
const result = await s.client.getUpstreamGroups();
|
||||||
|
assert.equal(Array.isArray(result), true); assert.equal(result.length, 1);
|
||||||
|
assert.equal(result[0].name === 'fixture' && Array.isArray(result[0].keys), true);
|
||||||
|
evidence(s, 'GET', endpoint, 200);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// P4 client contract; endpoint/payload cases are probe-pending on PIN8.0.42.
|
||||||
|
// Catches management key/body/query values leaking into receipts, redirects/retries.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { ManagementClient } from '../src/client.mjs';
|
||||||
|
async function setup(t, handler) {
|
||||||
|
const calls = [], receipts = [];
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
let body = ''; for await (const c of req) body += c;
|
||||||
|
calls.push({ method: req.method, path: req.url, auth: req.headers.authorization, body });
|
||||||
|
const answer = handler?.(req, body) ?? { status: 200, body: { status: 'ok' } };
|
||||||
|
res.writeHead(answer.status, { 'content-type': 'application/json', ...answer.headers });
|
||||||
|
res.end(JSON.stringify(answer.body));
|
||||||
|
});
|
||||||
|
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||||
|
t.after(() => new Promise(r => server.close(r)));
|
||||||
|
const client = new ManagementClient({ baseUrl: `http://127.0.0.1:${server.address().port}`, key: 'p4-management', receipt: r => receipts.push(r) });
|
||||||
|
return { client, calls, receipts };
|
||||||
|
}
|
||||||
|
test('OAuth management calls keep URL and state out of receipts', async t => {
|
||||||
|
const s = await setup(t);
|
||||||
|
assert.equal(typeof s.client.oauthStart, 'function');
|
||||||
|
await s.client.oauthStart('claude');
|
||||||
|
await s.client.oauthCallback('http://localhost/callback?state=p4-state&code=p4-code');
|
||||||
|
await s.client.oauthStatus('p4-state');
|
||||||
|
await s.client.oauthCancel('p4-state');
|
||||||
|
assert.deepEqual(s.calls.map(({ method, path }) => [method, path]), [
|
||||||
|
['GET', '/v8/management/oauth/auth-url?provider=claude'], ['POST', '/v8/management/oauth/callback'],
|
||||||
|
['GET', '/v8/management/oauth/status?state=p4-state'], ['DELETE', '/v8/management/oauth/session?state=p4-state'],
|
||||||
|
]);
|
||||||
|
assert.ok(s.calls.every(c => c.auth === 'Bearer p4-management'));
|
||||||
|
for (const value of ['p4-state', 'p4-code', 'localhost', 'p4-management']) assert.equal(JSON.stringify(s.receipts).includes(value), false);
|
||||||
|
});
|
||||||
|
test('upstream key goes only in request body and upstream refusal body is discarded', async t => {
|
||||||
|
const s = await setup(t, () => ({ status: 400, body: { error: 'p4-canary-key' } }));
|
||||||
|
assert.equal(typeof s.client.putUpstreamGroups, 'function');
|
||||||
|
await assert.rejects(s.client.putUpstreamGroups([{ name: 'z.ai', keys: [{ 'api-key': 'p4-canary-key' }] }]),
|
||||||
|
e => !e.message.includes('p4-canary-key'));
|
||||||
|
assert.equal(s.calls.length, 1);
|
||||||
|
assert.ok(s.calls[0].body.includes('p4-canary-key'));
|
||||||
|
assert.equal(JSON.stringify(s.receipts).includes('p4-canary-key'), false);
|
||||||
|
});
|
||||||
|
test('redirected management calls are not followed or retried', async t => {
|
||||||
|
const s = await setup(t, () => ({ status: 302, headers: { location: 'http://127.0.0.1:8317/' }, body: {} }));
|
||||||
|
assert.equal(typeof s.client.getCredentials, 'function');
|
||||||
|
await assert.rejects(s.client.getCredentials(), { code: 'proxy-client-redirect' });
|
||||||
|
assert.equal(s.calls.length, 1);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -49,6 +49,8 @@ const KILL_WITH_PARENT = ["/usr/bin/setpriv", "--pdeathsig", "KILL", "--"];
|
|||||||
// is missing here.
|
// is missing here.
|
||||||
export const RUNNER_FILES = Object.freeze([
|
export const RUNNER_FILES = Object.freeze([
|
||||||
"packages/harness/src/runner.mjs",
|
"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/gate.mjs",
|
||||||
"packages/harness/src/tools.mjs",
|
"packages/harness/src/tools.mjs",
|
||||||
"packages/harness/src/git-tools.mjs",
|
"packages/harness/src/git-tools.mjs",
|
||||||
@@ -57,6 +59,9 @@ export const RUNNER_FILES = Object.freeze([
|
|||||||
"packages/harness/src/pi-extension.mjs",
|
"packages/harness/src/pi-extension.mjs",
|
||||||
"packages/bus/src/client.mjs",
|
"packages/bus/src/client.mjs",
|
||||||
"packages/bus/src/broker.mjs",
|
"packages/bus/src/broker.mjs",
|
||||||
|
"packages/bus/src/proxy-page-vocabulary.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/address.mjs",
|
||||||
"packages/bus/src/landing.mjs",
|
"packages/bus/src/landing.mjs",
|
||||||
"packages/business/src/vocabulary.mjs",
|
"packages/business/src/vocabulary.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');
|
||||||
|
});
|
||||||
@@ -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());
|
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) => {
|
test("sandboxProblem names a missing bwrap and a bwrap that fails", (t) => {
|
||||||
const root = scratch(t);
|
const root = scratch(t);
|
||||||
assert.equal(sandboxProblem({ bwrap: join(root, "nope") }), `${join(root, "nope")} is missing`);
|
assert.equal(sandboxProblem({ bwrap: join(root, "nope") }), `${join(root, "nope")} is missing`);
|
||||||
|
|||||||
@@ -1104,3 +1104,23 @@ apply separately to each project and instance; a collaborator grant elsewhere
|
|||||||
does not enable writes here. Grant hints refresh on each user chat render and
|
does not enable writes here. Grant hints refresh on each user chat render and
|
||||||
background refresh, while the bus decides every operation from live grants.
|
background refresh, while the bus decides every operation from live grants.
|
||||||
The existing user notice names the governance records visible to the owner.
|
The existing user notice names the governance records visible to the owner.
|
||||||
|
|
||||||
|
## Model proxy page (P4, #1828)
|
||||||
|
|
||||||
|
The owner's `/proxy` page signs in Claude/Codex accounts, accepts write-only
|
||||||
|
z.ai API keys, sets account priority, and lists client-key IDs. It uses only
|
||||||
|
`/api/proxy/` routes over the owner's human bus transport. A missing instance
|
||||||
|
proxy refuses `not-configured`; it does not ask for a management key. That key
|
||||||
|
stays in the bus. No panel HTML or proxy-port forward is served.
|
||||||
|
|
||||||
|
The page and routes refuse granted users, tester listeners and loopback Hosts.
|
||||||
|
Writes also require `--send`, the Console's own Origin and same-origin Fetch
|
||||||
|
Metadata. Account display names use a provider label and stable hashed id;
|
||||||
|
arbitrary upstream labels and emails are not reflected. Key inputs and pasted
|
||||||
|
callback URLs are cleared when submitted and never saved in browser storage.
|
||||||
|
Only an `ok` status completes OAuth; accepted callbacks remain pending. At the
|
||||||
|
bus's default300-second timeout, status returns `error/login-timeout` once and
|
||||||
|
retires the state. Refreshing the page does not restore a pending login.
|
||||||
|
|
||||||
|
Usage is reserved on this page for P5; P4 adds no usage source. PIN changes
|
||||||
|
require the offline upgrade acceptance documented in packages/model-proxy.
|
||||||
@@ -68,14 +68,14 @@ try {
|
|||||||
// the system config, Business can't resolve authority and Settings has
|
// the system config, Business can't resolve authority and Settings has
|
||||||
// no notifier. A refusal printed here names no path (redactor).
|
// no notifier. A refusal printed here names no path (redactor).
|
||||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, librarySave = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
|
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, librarySave = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, decisionWrite = null, busLine, system = null;
|
||||||
let processing = null, ownerCall = null;
|
let processing = null, ownerCall = null;
|
||||||
try {
|
try {
|
||||||
system = loadSystem();
|
system = loadSystem();
|
||||||
const { dataRoot } = system;
|
const { dataRoot } = system;
|
||||||
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||||
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
|
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
|
||||||
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, librarySave, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
|
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, librarySave, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall } = consoleBus(call, { send: sendOn }));
|
||||||
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
|
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
|
||||||
// --sso and --send.
|
// --sso and --send.
|
||||||
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||||
@@ -107,7 +107,7 @@ try {
|
|||||||
}
|
}
|
||||||
let server;
|
let server;
|
||||||
try {
|
try {
|
||||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
|
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
|
||||||
} catch (err) { sso?.close(); throw err; }
|
} catch (err) { sso?.close(); throw err; }
|
||||||
let testerServer = null;
|
let testerServer = null;
|
||||||
if (testerSocket) {
|
if (testerSocket) {
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { views } from '../../bus/src/views.mjs';
|
|||||||
// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
|
// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
|
||||||
// agentWrite reaches only these.
|
// agentWrite reaches only these.
|
||||||
export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request'];
|
export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request'];
|
||||||
|
// P4 owner transport only; the HTTP boundary and broker both refuse users/testers.
|
||||||
|
export const PROXY_VERBS = Object.freeze(['proxy.accounts', 'proxy.login.start', 'proxy.login.submit',
|
||||||
|
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.keys']);
|
||||||
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
|
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
|
||||||
export const agentCaller = (call, verbs) => async (verb, args) => {
|
export const agentCaller = (call, verbs) => async (verb, args) => {
|
||||||
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
|
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
|
||||||
@@ -18,6 +21,7 @@ export const agentCaller = (call, verbs) => async (verb, args) => {
|
|||||||
// a granted user's options; it goes to createChat, which sends it only the `plugins` read.
|
// a granted user's options; it goes to createChat, which sends it only the `plugins` read.
|
||||||
export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
|
export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
|
||||||
const o = {
|
const o = {
|
||||||
|
proxyCall: agentCaller(call, PROXY_VERBS),
|
||||||
processing: { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) },
|
processing: { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) },
|
||||||
chat: createChat({ call, owner }),
|
chat: createChat({ call, owner }),
|
||||||
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165
|
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Native Console routes: fixed path -> one owner human-transport call. Never
|
||||||
|
// imports a management client, reads proxy settings, or opens a proxy connection.
|
||||||
|
const routes = Object.freeze({
|
||||||
|
'/api/proxy/accounts': ['GET', 'proxy.accounts', []],
|
||||||
|
'/api/proxy/keys': ['GET', 'proxy.keys', []],
|
||||||
|
'/api/proxy/login/start': ['POST', 'proxy.login.start', ['provider']],
|
||||||
|
'/api/proxy/login/submit': ['POST', 'proxy.login.submit', ['redirect_url']],
|
||||||
|
'/api/proxy/login/status': ['POST', 'proxy.login.status', ['state']],
|
||||||
|
'/api/proxy/login/cancel': ['POST', 'proxy.login.cancel', ['state']],
|
||||||
|
'/api/proxy/apikey/add': ['POST', 'proxy.apikey.add', ['provider', 'key']],
|
||||||
|
'/api/proxy/priority/set': ['POST', 'proxy.priority.set', ['accountId', 'priority']],
|
||||||
|
});
|
||||||
|
const codes = ['not-configured', 'human-required', 'provider-not-allowed', 'login-state-unknown',
|
||||||
|
'callback-url-invalid', 'apikey-invalid', 'priority-invalid', 'priority-unconfirmed', 'priority-write-refused', 'proxy-down', 'invalid-request'];
|
||||||
|
const json = (res, status, value) => {
|
||||||
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value));
|
||||||
|
};
|
||||||
|
export async function proxyHTTP(req, res, { path, call, named, tester, granted, send, origin, user, log }) {
|
||||||
|
const route = routes[path];
|
||||||
|
const done = (status, value) => {
|
||||||
|
// No request body, query, login state, authorization URL or exception text.
|
||||||
|
log(`${new Date().toISOString()} ${route?.[0] ?? 'GET'} ${route ? path : '/api/proxy/unknown'} ${status} user=${JSON.stringify(user ?? '')}`);
|
||||||
|
return json(res, status, value);
|
||||||
|
};
|
||||||
|
if (tester || granted || !named) return done(403, { error: 'human-required' });
|
||||||
|
if (!route) return done(404, { error: 'not found' });
|
||||||
|
if (req.method !== route[0]) return done(405, { error: 'method not allowed' });
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
if (!send) return done(403, { error: 'read-only' });
|
||||||
|
if (req.headersDistinct.origin?.length !== 1 || req.headers.origin !== origin) return done(403, { error: 'cross-origin request refused' });
|
||||||
|
if (req.headersDistinct['sec-fetch-site']?.length !== 1 || req.headers['sec-fetch-site'] !== 'same-origin') return done(403, { error: 'cross-site request refused' });
|
||||||
|
}
|
||||||
|
if (!call) return done(503, { error: 'not-configured' });
|
||||||
|
let args = {};
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
if ((req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') throw Error();
|
||||||
|
const chunks = []; let size = 0;
|
||||||
|
for await (const chunk of req) {
|
||||||
|
size += chunk.length; if (size > 16384) throw Error(); chunks.push(chunk);
|
||||||
|
}
|
||||||
|
args = JSON.parse(Buffer.concat(chunks));
|
||||||
|
if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).length !== route[2].length
|
||||||
|
|| route[2].some(k => !Object.hasOwn(args, k))) throw Error();
|
||||||
|
} catch { return done(400, { error: 'invalid-request' }); }
|
||||||
|
}
|
||||||
|
try { return done(200, await call(route[1], args)); }
|
||||||
|
catch (error) {
|
||||||
|
const code = codes.includes(error.code) ? error.code : 'outcome-unknown';
|
||||||
|
return done(code === 'human-required' ? 403 : ['not-configured', 'proxy-down', 'outcome-unknown'].includes(code) ? 503 : 400, { error: code });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<button id="processing-resume" type="button" class="btn" disabled hidden>Resume processing</button>
|
<button id="processing-resume" type="button" class="btn" disabled hidden>Resume processing</button>
|
||||||
<p id="processing-unavailable" class="processing-note small muted">Processing controls are unavailable here. Use mosaic halt or mosaic resume from the instance CLI.</p>
|
<p id="processing-unavailable" class="processing-note small muted">Processing controls are unavailable here. Use mosaic halt or mosaic resume from the instance CLI.</p>
|
||||||
<p id="processing-receipt" class="processing-receipt small" aria-live="polite"></p></div>
|
<p id="processing-receipt" class="processing-receipt small" aria-live="polite"></p></div>
|
||||||
<h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
<p><a href="/proxy">Model proxy</a></p><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||||
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
||||||
<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
|
<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
|
||||||
<div id="error" class="banner err" role="alert" hidden></div>
|
<div id="error" class="banner err" role="alert" hidden></div>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-design="console" data-palette="harbor">
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Model proxy · Mosaic Console</title>
|
||||||
|
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/shell.css">
|
||||||
|
<script src="/proxy.js" defer></script></head>
|
||||||
|
<body><a class="skip" href="#main">Skip to content</a>
|
||||||
|
<header class="cmdbar"><a class="wordmark" href="/">Mosaic<small>Console</small></a><a href="/">Back to Console</a></header>
|
||||||
|
<main id="main" class="content" tabindex="-1">
|
||||||
|
<div class="page-head"><div><h1>Model proxy</h1><p>Accounts used by this instance, their priority, and the client keys assigned to agents.</p></div><button id="proxy-refresh" type="button">Refresh</button></div>
|
||||||
|
<p id="proxy-message" role="status" aria-live="polite">Reading accounts…</p>
|
||||||
|
<p id="proxy-readonly" class="muted" hidden>This Console is read-only. Sign-in and account changes are unavailable.</p>
|
||||||
|
<section class="panel" aria-labelledby="proxy-accounts-title"><h2 id="proxy-accounts-title">Accounts</h2>
|
||||||
|
<p>Higher priority is used first. Account names are stable identifiers; credential values are never displayed.</p>
|
||||||
|
<div class="table-wrap" tabindex="0" role="region" aria-label="Accounts"><table><thead><tr><th>Account</th><th>Provider</th><th>Status</th><th>Priority</th></tr></thead><tbody id="proxy-accounts"></tbody></table></div></section>
|
||||||
|
<section class="panel" aria-labelledby="proxy-signin-title"><h2 id="proxy-signin-title">Sign in to an account</h2>
|
||||||
|
<form id="proxy-login-start"><fieldset data-write disabled><legend>Subscription account</legend><label>Provider <select name="provider"><option value="claude">Claude</option><option value="codex">Codex</option></select></label><button type="submit">Start sign-in</button></fieldset></form>
|
||||||
|
<p id="proxy-login-message" role="status" aria-live="polite"></p>
|
||||||
|
<p><a id="proxy-authorize" target="_blank" rel="noopener noreferrer" hidden>Open account sign-in in a new tab</a></p>
|
||||||
|
<form id="proxy-login-submit"><fieldset data-write disabled><legend>Finish sign-in</legend><p>If the sign-in tab does not return, copy its full address and paste it here.</p><label>Callback address <input name="redirect_url" type="url" autocomplete="off" spellcheck="false" maxlength="8192" required></label><button type="submit">Submit address</button><button id="proxy-login-cancel" type="button">Cancel sign-in</button></fieldset></form></section>
|
||||||
|
<section class="panel" aria-labelledby="proxy-apikey-title"><h2 id="proxy-apikey-title">Add an API key</h2>
|
||||||
|
<form id="proxy-apikey"><fieldset data-write disabled><legend>z.ai</legend><label>API key <input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="4096" required></label><p>The key is sent once and cleared from this field. It will never be shown again.</p><button type="submit">Add key</button></fieldset></form></section>
|
||||||
|
<section class="panel" aria-labelledby="proxy-keys-title"><h2 id="proxy-keys-title">Client keys</h2><p>Only key IDs are shown. Hire and fire manage agent keys.</p>
|
||||||
|
<div class="table-wrap" tabindex="0" role="region" aria-label="Client keys"><table><thead><tr><th>Key ID</th><th>Managed by</th><th>Agent</th><th>Issued</th><th>State</th></tr></thead><tbody id="proxy-keys"></tbody></table></div></section>
|
||||||
|
<section class="panel" id="proxy-usage" aria-labelledby="proxy-usage-title"><h2 id="proxy-usage-title">Usage</h2><p id="proxy-usage-status">Usage reporting will appear here when its view is available.</p></section>
|
||||||
|
</main></body></html>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// P4 page: credentials and OAuth state exist only during the current operation.
|
||||||
|
// No browser storage, request/body logging, key reveal, or automatic write retry.
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
let canWrite = false, login = null, poll = null, busy = false;
|
||||||
|
const message = (text, loginOnly = false) => { $(loginOnly ? 'proxy-login-message' : 'proxy-message').textContent = text; };
|
||||||
|
const cell = value => { const td = document.createElement('td'); td.textContent = value ?? '—'; return td; };
|
||||||
|
async function call(path, args) {
|
||||||
|
const response = await fetch('/api/proxy/' + path, { cache: 'no-store', credentials: 'same-origin',
|
||||||
|
method: args === undefined ? 'GET' : 'POST',
|
||||||
|
...(args === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args) }),
|
||||||
|
});
|
||||||
|
const value = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) throw new Error('Sign in to the Console again.');
|
||||||
|
if (value.error === 'not-configured') throw new Error('The model proxy is not configured for this instance.');
|
||||||
|
if (value.error === 'login-state-unknown') throw new Error('This sign-in has ended. Start a new sign-in.');
|
||||||
|
if (value.error === 'apikey-invalid') throw new Error('Enter a nonempty API key, with no spaces or line breaks.');
|
||||||
|
throw new Error('The operation could not be confirmed. Nothing was retried. Refresh before trying again.');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function endLogin() {
|
||||||
|
clearTimeout(poll); poll = null; login = null;
|
||||||
|
$('proxy-authorize').removeAttribute('href'); $('proxy-authorize').hidden = true;
|
||||||
|
$('proxy-login-submit').elements.redirect_url.value = '';
|
||||||
|
}
|
||||||
|
async function status() {
|
||||||
|
if (!login) return;
|
||||||
|
const active = login;
|
||||||
|
try {
|
||||||
|
const result = await call('login/status', { state: active.state });
|
||||||
|
if (active !== login) return;
|
||||||
|
if (result.status === 'wait') { message('Waiting for account sign-in…', true); poll = setTimeout(status, 2000); return; }
|
||||||
|
endLogin();
|
||||||
|
if (result.status === 'ok') { message('Account signed in.', true); await refresh(); }
|
||||||
|
else message(result.reason === 'login-timeout' ? 'Sign-in timed out. Start again.' : 'Account sign-in failed. Start again.', true);
|
||||||
|
} catch (error) { if (active === login) { endLogin(); message(error.message, true); } }
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
const [accounts, keys] = await Promise.all([call('accounts'), call('keys')]);
|
||||||
|
const rows = accounts.accounts.map(account => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.append(cell(account.displayName), cell(account.provider), cell(account.status));
|
||||||
|
const td = document.createElement('td'), form = document.createElement('form');
|
||||||
|
const input = document.createElement('input'); input.type = 'number'; input.min = '-1000'; input.max = '1000'; input.step = '1'; input.value = account.priority;
|
||||||
|
input.setAttribute('aria-label', `Priority for ${account.displayName}`); input.disabled = !canWrite;
|
||||||
|
const save = document.createElement('button'); save.type = 'submit'; save.textContent = 'Set priority'; save.disabled = !canWrite;
|
||||||
|
form.append(input, save); form.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault(); if (!canWrite || busy || !input.checkValidity()) return;
|
||||||
|
busy = true; save.disabled = true;
|
||||||
|
try { await call('priority/set', { accountId: account.accountId, priority: Number(input.value) }); message('Priority saved.'); await refresh(); }
|
||||||
|
catch (error) { message(error.message); }
|
||||||
|
finally { busy = false; save.disabled = !canWrite; }
|
||||||
|
});
|
||||||
|
td.append(form); row.append(td); return row;
|
||||||
|
});
|
||||||
|
$('proxy-accounts').replaceChildren(...rows);
|
||||||
|
if (!rows.length) { const row = document.createElement('tr'); const td = cell('No accounts configured.'); td.colSpan = 4; row.append(td); $('proxy-accounts').append(row); }
|
||||||
|
$('proxy-keys').replaceChildren(...keys.keys.map(key => {
|
||||||
|
const row = document.createElement('tr'); row.append(cell(key.keyId), cell(key.management), cell(key.agent), cell(key.issuedAt), cell(key.state)); return row;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
$('proxy-refresh').addEventListener('click', () => refresh().then(() => message('Accounts and client keys refreshed.')).catch(e => message(e.message)));
|
||||||
|
$('proxy-login-start').addEventListener('submit', async event => {
|
||||||
|
event.preventDefault(); if (!canWrite || busy || login) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
login = await call('login/start', { provider: event.currentTarget.elements.provider.value });
|
||||||
|
$('proxy-authorize').href = login.url; $('proxy-authorize').hidden = false;
|
||||||
|
window.open(login.url, '_blank', 'noopener,noreferrer');
|
||||||
|
message('Complete account sign-in in the new tab, or use the sign-in link.', true);
|
||||||
|
poll = setTimeout(status, 2000);
|
||||||
|
} catch (error) { message(error.message, true); }
|
||||||
|
finally { busy = false; }
|
||||||
|
});
|
||||||
|
$('proxy-login-submit').addEventListener('submit', async event => {
|
||||||
|
event.preventDefault(); if (!canWrite || busy || !login) return;
|
||||||
|
const input = event.currentTarget.elements.redirect_url, redirect_url = input.value; input.value = '';
|
||||||
|
busy = true;
|
||||||
|
try { await call('login/submit', { redirect_url }); message('Address submitted. Waiting for sign-in confirmation…', true); }
|
||||||
|
catch (error) { message(error.message, true); }
|
||||||
|
finally { busy = false; }
|
||||||
|
});
|
||||||
|
$('proxy-login-cancel').addEventListener('click', async () => {
|
||||||
|
if (!canWrite || busy || !login) return;
|
||||||
|
busy = true; clearTimeout(poll);
|
||||||
|
try { await call('login/cancel', { state: login.state }); endLogin(); message('Sign-in cancelled.', true); }
|
||||||
|
catch (error) { endLogin(); message(error.message, true); }
|
||||||
|
finally { busy = false; }
|
||||||
|
});
|
||||||
|
$('proxy-apikey').addEventListener('submit', async event => {
|
||||||
|
event.preventDefault(); if (!canWrite || busy) return;
|
||||||
|
const input = event.currentTarget.elements.key, key = input.value; input.value = '';
|
||||||
|
busy = true;
|
||||||
|
try { await call('apikey/add', { provider: 'z.ai', key }); message('API key added.'); await refresh(); }
|
||||||
|
catch (error) { message(error.message); }
|
||||||
|
finally { busy = false; }
|
||||||
|
});
|
||||||
|
window.addEventListener('pagehide', () => { endLogin(); $('proxy-apikey').elements.key.value = ''; });
|
||||||
|
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
|
||||||
|
canWrite = config.send === true && config.tester !== true && config.user !== true;
|
||||||
|
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
|
||||||
|
$('proxy-readonly').hidden = canWrite;
|
||||||
|
return refresh();
|
||||||
|
}).then(() => message('Accounts and client keys loaded.')).catch(error => message(error.message));
|
||||||
|
})();
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { proxyHTTP } from './proxy-http.mjs';
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
|
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
|
||||||
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
||||||
@@ -29,6 +30,8 @@ export function boardURL(value) {
|
|||||||
const root = resolve(import.meta.dirname, 'public');
|
const root = resolve(import.meta.dirname, 'public');
|
||||||
const files = new Map([
|
const files = new Map([
|
||||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||||
|
['/proxy', ['proxy.html', 'text/html; charset=utf-8']],
|
||||||
|
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
|
||||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||||
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
|
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
|
||||||
@@ -650,7 +653,7 @@ async function libraryStatusRead(res, read, search) {
|
|||||||
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||||
if (host === 'localhost') host = '127.0.0.1';
|
if (host === 'localhost') host = '127.0.0.1';
|
||||||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||||||
@@ -668,7 +671,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
|||||||
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
||||||
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
||||||
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
|
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
|
||||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
|
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, upstream, files };
|
||||||
const hub = federationView(federation);
|
const hub = federationView(federation);
|
||||||
// An object with none of the three is refused too: asking for the preview never serves without it.
|
// An object with none of the three is refused too: asking for the preview never serves without it.
|
||||||
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
|
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
|
||||||
@@ -678,7 +681,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
|||||||
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
// A granted user's request rebinds these below; every other request keeps the owner's.
|
// A granted user's request rebinds these below; every other request keeps the owner's.
|
||||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false, chatGrants = [];
|
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, upstream, files } = owner, granted = false, chatGrants = [];
|
||||||
res.setHeader('x-content-type-options', 'nosniff');
|
res.setHeader('x-content-type-options', 'nosniff');
|
||||||
res.setHeader('referrer-policy', 'no-referrer');
|
res.setHeader('referrer-policy', 'no-referrer');
|
||||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||||
@@ -745,7 +748,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
|||||||
if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||||
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||||||
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||||||
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true;
|
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null;
|
||||||
chatGrants = sso.chatGrants?.(session) ?? [];
|
chatGrants = sso.chatGrants?.(session) ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -755,6 +758,15 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
|||||||
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||||
}
|
}
|
||||||
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
|
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
|
||||||
|
// P4 page and verbs are owner-only, including reads and static page assets.
|
||||||
|
// This runs after owner sign-in and the granted-user fence, before generic
|
||||||
|
// tester writes or static-file serving. No localhost preview grants access.
|
||||||
|
if (path === '/proxy' || path === '/proxy.js' || path.startsWith('/api/proxy/')) {
|
||||||
|
if (tester || granted || !named) return json(res, 403, { error: 'human-required' });
|
||||||
|
if (path.startsWith('/api/proxy/')) return proxyHTTP(req, res, { path, call: proxyCall, named, tester, granted,
|
||||||
|
send, origin: sso ? sso.publicOrigin : `https://${preview.host}`, user: signer?.user, log });
|
||||||
|
}
|
||||||
|
|
||||||
// Row 252: Approve and Deny. The tester listener never offers them. Anywhere else without a signed-in
|
// Row 252: Approve and Deny. The tester listener never offers them. Anywhere else without a signed-in
|
||||||
// session on the public name under --sso and --send, they are read-only.
|
// session on the public name under --sso and --send, they are read-only.
|
||||||
if (decisionPath) {
|
if (decisionPath) {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Import-parser architecture guard: adding the management client to the Console
|
||||||
|
// would move its key/connection boundary out of the bus, and must fail this test.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { build } from '../node_modules/esbuild/lib/main.js';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
test('Console production module graph cannot import the model-proxy client', async () => {
|
||||||
|
const result = await build({ entryPoints: [new URL('../src/cli.mjs', import.meta.url), new URL('../src/serve.mjs', import.meta.url),
|
||||||
|
new URL('../src/console-bus.mjs', import.meta.url)].map(fileURLToPath), bundle: true, packages: 'external',
|
||||||
|
platform: 'node', format: 'esm', splitting: true, outdir: '/unused-proxy-architecture', write: false, metafile: true, logLevel: 'silent' });
|
||||||
|
assert.equal(Object.keys(result.metafile.inputs).some(file => file.endsWith('/model-proxy/src/client.mjs')), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Native page behavior. HTTP authorization is covered by proxy-page.test.mjs;
|
||||||
|
// this stand-in serves the real page/script and records only route names/counts.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { browser } from './browser.mjs';
|
||||||
|
async function setup(t, readonly = false) {
|
||||||
|
const state = { routes: [], loginStatus: 'wait', receivedKey: false, release: null, priority: 0 };
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const send = value => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(value)); };
|
||||||
|
if (req.url === '/api/config') return send({ send: !readonly });
|
||||||
|
if (req.url.startsWith('/api/proxy/')) {
|
||||||
|
state.routes.push(req.url);
|
||||||
|
const chunks = []; for await (const c of req) chunks.push(c);
|
||||||
|
const args = chunks.length ? JSON.parse(Buffer.concat(chunks)) : {};
|
||||||
|
if (req.url.endsWith('/accounts')) return send({ accounts: [{ accountId: 'a'.repeat(12), provider: 'claude', displayName: '<img src=x onerror="window.injected=true">', status: 'ready', priority: state.priority }] });
|
||||||
|
if (req.url.endsWith('/keys')) return send({ keys: [{ keyId: 'b'.repeat(12), management: 'sentinel', agent: null, issuedAt: null, state: null }] });
|
||||||
|
if (req.url.endsWith('/apikey/add')) {
|
||||||
|
state.receivedKey = args.key === 'p4-browser-key';
|
||||||
|
await new Promise(r => { state.release = r; }); return send({ added: true });
|
||||||
|
}
|
||||||
|
if (req.url.endsWith('/login/start')) return send({ url: 'https://claude.ai/oauth/authorize?state=p4-state', state: 'p4-state' });
|
||||||
|
if (req.url.endsWith('/login/status')) return send({ status: state.loginStatus });
|
||||||
|
if (req.url.endsWith('/login/submit')) return send({ status: 'wait' });
|
||||||
|
if (req.url.endsWith('/login/cancel')) return send({ status: 'cancelled' });
|
||||||
|
if (req.url.endsWith('/priority/set')) { state.priority = args.priority; return send({ priority: args.priority }); }
|
||||||
|
}
|
||||||
|
if (req.url === '/proxy') { res.setHeader('content-type', 'text/html'); return res.end(readFileSync(new URL('../src/public/proxy.html', import.meta.url))); }
|
||||||
|
if (req.url === '/proxy.js') { res.setHeader('content-type', 'text/javascript'); return res.end(readFileSync(new URL('../src/public/proxy.js', import.meta.url))); }
|
||||||
|
res.statusCode = 404; res.end();
|
||||||
|
});
|
||||||
|
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||||
|
t.after(() => { state.release?.(); return new Promise(r => server.close(r)); });
|
||||||
|
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||||
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: "window.openFeatures=null;window.open=(url,target,features)=>{window.openFeatures=features;return null;};" });
|
||||||
|
await b.navigate(`http://127.0.0.1:${server.address().port}/proxy`);
|
||||||
|
const wait = expression => b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expression})return true;await new Promise(r=>setTimeout(r,25));}throw Error('proxy UI condition timed out');})()`);
|
||||||
|
await wait('document.querySelector("#proxy-message").textContent.includes("loaded")');
|
||||||
|
return { b, state, wait };
|
||||||
|
}
|
||||||
|
test('proxy browser clears write-only key immediately, sends once and renders data as text', { timeout: 60000 }, async t => {
|
||||||
|
const { b, state, wait } = await setup(t);
|
||||||
|
assert.equal(await b.evaluate('window.injected===true'), false);
|
||||||
|
assert.equal(await b.evaluate('document.querySelectorAll("#proxy-accounts img").length'), 0);
|
||||||
|
assert.equal(await b.evaluate('document.querySelector("#proxy-apikey input").type'), 'password');
|
||||||
|
assert.equal(await b.evaluate('document.querySelector("#proxy-apikey input").autocomplete'), 'off');
|
||||||
|
await b.evaluate('document.querySelector("#proxy-apikey input").value="p4-browser-key";document.querySelector("#proxy-apikey").requestSubmit();true');
|
||||||
|
await wait('document.querySelector("#proxy-apikey input").value===""');
|
||||||
|
for (let n = 0; n < 100 && !state.release; n++) await new Promise(r => setTimeout(r, 20));
|
||||||
|
assert.equal(state.receivedKey, true);
|
||||||
|
state.release(); await wait('document.querySelector("#proxy-message").textContent.includes("added")');
|
||||||
|
assert.equal(state.routes.filter(r => r.endsWith('/apikey/add')).length, 1);
|
||||||
|
assert.equal(await b.evaluate('localStorage.length+sessionStorage.length'), 0);
|
||||||
|
assert.equal(await b.evaluate('document.body.textContent.includes("p4-browser-key")'), false);
|
||||||
|
});
|
||||||
|
test('proxy browser OAuth opens safely, callback wait stays pending, status ok completes', { timeout: 60000 }, async t => {
|
||||||
|
const { b, state, wait } = await setup(t);
|
||||||
|
await b.evaluate('document.querySelector("#proxy-login-start").requestSubmit();true');
|
||||||
|
await wait('!document.querySelector("#proxy-authorize").hidden');
|
||||||
|
assert.equal(await b.evaluate('window.openFeatures'), 'noopener,noreferrer');
|
||||||
|
assert.equal(await b.evaluate('document.querySelector("#proxy-authorize").rel'), 'noopener noreferrer');
|
||||||
|
await b.evaluate('document.querySelector("#proxy-login-submit input").value="http://localhost/callback?state=p4-state&code=p4-code";document.querySelector("#proxy-login-submit").requestSubmit();true');
|
||||||
|
await wait('document.querySelector("#proxy-login-message").textContent.includes("Waiting")');
|
||||||
|
assert.equal(await b.evaluate('document.querySelector("#proxy-login-message").textContent.includes("signed in")'), false);
|
||||||
|
assert.equal(await b.evaluate('document.querySelector("#proxy-login-submit input").value===""'), true);
|
||||||
|
state.loginStatus = 'ok';
|
||||||
|
await wait('document.querySelector("#proxy-login-message").textContent==="Account signed in."');
|
||||||
|
assert.equal(await b.evaluate('document.querySelector("#proxy-authorize").hasAttribute("href")'), false);
|
||||||
|
assert.equal(await b.evaluate('localStorage.length+sessionStorage.length'), 0);
|
||||||
|
});
|
||||||
|
test('proxy browser read-only disables account changes', { timeout: 60000 }, async t => {
|
||||||
|
const { b, state } = await setup(t, true);
|
||||||
|
assert.equal(await b.evaluate('[...document.querySelectorAll("[data-write]")].every(e=>e.disabled)'), true);
|
||||||
|
assert.equal(await b.evaluate('document.querySelector("#proxy-accounts input").disabled'), true);
|
||||||
|
assert.equal(state.routes.some(r => r.endsWith('/apikey/add') || r.endsWith('/login/start')), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// P4 named mutant: API key echoed in a response, error, log or durable record.
|
||||||
|
// Real HTTP -> human capability -> broker -> page -> management client -> fake.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { request } from 'node:http';
|
||||||
|
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fakeManagement } from '../../bus/tests/proxy-page-fixture.mjs';
|
||||||
|
import { Store } from '../../bus/src/store.mjs';
|
||||||
|
import { Broker } from '../../bus/src/broker.mjs';
|
||||||
|
import { startServer } from '../src/serve.mjs';
|
||||||
|
const scan = root => readdirSync(root, { withFileTypes: true }).flatMap(e => e.isDirectory() ? scan(join(root, e.name)) : [readFileSync(join(root, e.name))]);
|
||||||
|
test('API-key canary reaches fake once and zero responses/logs/receipts/store files', async t => {
|
||||||
|
const canary = 'p4-canary-key';
|
||||||
|
const fake = await fakeManagement(t);
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'p4-canary-'));
|
||||||
|
const store = new Store(root);
|
||||||
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||||
|
const b = new Broker({ store, proxyPage: (...args) => fake.page.request(...args),
|
||||||
|
businesses: { demo: { id: 'demo', human: 'owner', roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } } });
|
||||||
|
const cap = b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true });
|
||||||
|
const logs = [], output = [];
|
||||||
|
const server = await startServer({ port: 0, preview: { host: 'console.example', header: 'x-user', user: 'owner' },
|
||||||
|
send: async () => ({}), proxyCall: (verb, args) => b.request(cap, { verb, args }), log: line => logs.push(line) });
|
||||||
|
t.after(() => new Promise(r => server.close(r)));
|
||||||
|
const call = (path, data) => new Promise((resolve, reject) => {
|
||||||
|
const req = request({ hostname: '127.0.0.1', port: server.address().port, path, method: data ? 'POST' : 'GET',
|
||||||
|
headers: { host: 'console.example', 'x-user': 'owner', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, res => {
|
||||||
|
let text = ''; res.on('data', c => text += c); res.on('end', () => { output.push(text); resolve(res.statusCode); });
|
||||||
|
}); req.on('error', reject); req.end(data ? JSON.stringify(data) : undefined);
|
||||||
|
});
|
||||||
|
// Capture process output during the operation as well as structured log
|
||||||
|
// callbacks. Even a deliberate log-leak mutant must never print its canary.
|
||||||
|
const stdout = process.stdout.write, stderr = process.stderr.write;
|
||||||
|
const capture = (chunk, ...rest) => { output.push(Buffer.from(chunk)); const cb = rest.at(-1); if (typeof cb === 'function') cb(); return true; };
|
||||||
|
let added, accounts;
|
||||||
|
try {
|
||||||
|
process.stdout.write = capture; process.stderr.write = capture;
|
||||||
|
added = await call('/api/proxy/apikey/add', { provider: 'z.ai', key: canary });
|
||||||
|
accounts = await call('/api/proxy/accounts');
|
||||||
|
} finally { process.stdout.write = stdout; process.stderr.write = stderr; }
|
||||||
|
assert.equal(added, 200); assert.equal(accounts, 200);
|
||||||
|
assert.equal(fake.state.receives, 1);
|
||||||
|
assert.equal(fake.state.groups[0].keys[0]['api-key'] === canary, true);
|
||||||
|
for (const bytes of [...output, ...logs, JSON.stringify(fake.state.receipts), ...scan(root)])
|
||||||
|
assert.equal(Buffer.from(bytes).includes(Buffer.from(canary)), false, 'credential value escaped');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth URLs, state and code stay out of broker records and management receipts', async t => {
|
||||||
|
const fake = await fakeManagement(t);
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'p4-oauth-'));
|
||||||
|
const store = new Store(root);
|
||||||
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||||
|
const b = new Broker({ store, proxyPage: (...args) => fake.page.request(...args),
|
||||||
|
businesses: { demo: { id: 'demo', human: 'owner', roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } } });
|
||||||
|
const cap = b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true });
|
||||||
|
const call = (verb, args = {}) => b.request(cap, { verb, args });
|
||||||
|
const login = await call('proxy.login.start', { provider: 'claude' });
|
||||||
|
const callback = 'http://localhost/callback?state=' + login.state + '&code=p4-private-code';
|
||||||
|
assert.deepEqual(await call('proxy.login.submit', { redirect_url: callback }), { status: 'wait' });
|
||||||
|
assert.deepEqual(await call('proxy.login.status', { state: login.state }), { status: 'wait' });
|
||||||
|
for (const value of [login.url, login.state, callback, 'p4-private-code']) {
|
||||||
|
assert.equal(JSON.stringify(fake.state.receipts).includes(value), false);
|
||||||
|
for (const bytes of scan(root)) assert.equal(bytes.includes(Buffer.from(value)), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// P4: real HTTP boundary; mutant: proxy route moved ahead of granted-user fence.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { request } from 'node:http';
|
||||||
|
import { startServer } from '../src/serve.mjs';
|
||||||
|
import { consoleBus } from '../src/console-bus.mjs';
|
||||||
|
const preview = { host: 'console.example', header: 'x-user', user: 'owner' };
|
||||||
|
const headers = { host: preview.host, 'x-user': 'owner', origin: 'https://console.example',
|
||||||
|
'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||||
|
const routes = [
|
||||||
|
['accounts', 'GET', 'proxy.accounts', {}], ['keys', 'GET', 'proxy.keys', {}],
|
||||||
|
['login/start', 'POST', 'proxy.login.start', { provider: 'claude' }],
|
||||||
|
['login/submit', 'POST', 'proxy.login.submit', { redirect_url: 'http://localhost/callback?state=fixture&code=fake' }],
|
||||||
|
['login/status', 'POST', 'proxy.login.status', { state: 'fixture' }],
|
||||||
|
['login/cancel', 'POST', 'proxy.login.cancel', { state: 'fixture' }],
|
||||||
|
['apikey/add', 'POST', 'proxy.apikey.add', { provider: 'z.ai', key: 'p4-canary-key' }],
|
||||||
|
['priority/set', 'POST', 'proxy.priority.set', { accountId: 'a'.repeat(12), priority: 5 }],
|
||||||
|
];
|
||||||
|
const call = (s, path, method = 'GET', value, hs = headers) => new Promise((resolve, reject) => {
|
||||||
|
const req = request({ hostname: '127.0.0.1', port: s.address().port, path, method, headers: hs }, res => {
|
||||||
|
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, text }));
|
||||||
|
});
|
||||||
|
req.on('error', reject); req.end(value === undefined ? undefined : JSON.stringify(value));
|
||||||
|
});
|
||||||
|
async function setup(t, options = {}) {
|
||||||
|
const calls = [], logs = [];
|
||||||
|
const s = await startServer({ port: 0, preview, send: async () => ({}),
|
||||||
|
proxyCall: async (verb, args) => { calls.push([verb, args]); return { ok: true }; },
|
||||||
|
log: l => logs.push(l), ...options });
|
||||||
|
t.after(() => new Promise(r => s.close(r)));
|
||||||
|
return { s, calls, logs };
|
||||||
|
}
|
||||||
|
test('owner proxy routes each make exactly one allowlisted call', async t => {
|
||||||
|
const { s, calls, logs } = await setup(t);
|
||||||
|
for (const [path, method, verb, args] of routes) {
|
||||||
|
const r = await call(s, '/api/proxy/' + path, method, method === 'POST' ? args : undefined);
|
||||||
|
assert.equal(r.status, 200, path);
|
||||||
|
assert.deepEqual(calls.at(-1), [verb, args]);
|
||||||
|
}
|
||||||
|
assert.equal(calls.length, routes.length);
|
||||||
|
for (const value of ['p4-canary-key', 'fixture', 'redirect_url']) assert.equal(logs.join('\n').includes(value), false);
|
||||||
|
});
|
||||||
|
for (const [name, hs, code] of [
|
||||||
|
['signed-out', { ...headers, 'x-user': 'other' }, 401],
|
||||||
|
['loopback', { ...headers, host: '127.0.0.1' }, 403],
|
||||||
|
['cross-origin', { ...headers, origin: 'https://other.example' }, 403],
|
||||||
|
['missing Fetch Metadata', Object.fromEntries(Object.entries(headers).filter(([k]) => k !== 'sec-fetch-site')), 403],
|
||||||
|
]) test(`proxy write refuses ${name}`, async t => {
|
||||||
|
const { s, calls } = await setup(t);
|
||||||
|
assert.equal((await call(s, '/api/proxy/apikey/add', 'POST', { provider: 'z.ai', key: 'fixture-key' }, hs)).status, code);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
test('all proxy writes are read-only without --send', async t => {
|
||||||
|
const { s, calls } = await setup(t, { send: null });
|
||||||
|
for (const [path, method, , args] of routes.filter(r => r[1] === 'POST'))
|
||||||
|
assert.equal((await call(s, '/api/proxy/' + path, method, args)).status, 403, path);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
test('granted user never reaches owner page or any proxy route', async t => {
|
||||||
|
const sso = { publicHost: preview.host, publicOrigin: headers.origin, formTargets: () => [],
|
||||||
|
session: () => ({ name: 'Alice', user: 'alice' }) };
|
||||||
|
const { s, calls } = await setup(t, { preview: null, sso, userOptions: () => { throw Error('must not bind'); } });
|
||||||
|
assert.equal((await call(s, '/proxy')).status, 403);
|
||||||
|
for (const [path, method, , args] of routes)
|
||||||
|
assert.equal((await call(s, '/api/proxy/' + path, method, method === 'POST' ? args : undefined)).status, 403, path);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
test('native page offers write-only key input and no panel script', async t => {
|
||||||
|
const { s } = await setup(t);
|
||||||
|
const r = await call(s, '/proxy');
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.match(r.text, /type="password"/);
|
||||||
|
assert.match(r.text, /autocomplete="off"/);
|
||||||
|
assert.doesNotMatch(r.text, /management\.html|iframe/);
|
||||||
|
});
|
||||||
|
test('Console caller rejects every non-proxy verb', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const o = consoleBus(async (...a) => { calls.push(a); return {}; }, { send: true });
|
||||||
|
assert.equal(typeof o.proxyCall, 'function');
|
||||||
|
await assert.rejects(o.proxyCall('message.send', {}));
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tester socket refuses proxy page, reads and writes before any owner call', async t => {
|
||||||
|
const { mkdtempSync, rmSync } = await import('node:fs');
|
||||||
|
const { tmpdir } = await import('node:os');
|
||||||
|
const { join } = await import('node:path');
|
||||||
|
const { startTesterSocket } = await import('../src/tester-socket.mjs');
|
||||||
|
const { testerRequest } = await import('../../cli/src/tester-http.mjs');
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'p4-tester-'));
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
let calls = 0;
|
||||||
|
const s = await startTesterSocket({ dataRoot: root, proxyCall: async () => { calls++; return {}; }, log: () => {} });
|
||||||
|
t.after(() => new Promise(r => s.close(r)));
|
||||||
|
const page = await testerRequest({ dataRoot: root, path: '/proxy' });
|
||||||
|
assert.equal(page.status, 403);
|
||||||
|
for (const [path, method, , args] of routes) {
|
||||||
|
const r = await testerRequest({ dataRoot: root, path: '/api/proxy/' + path, method, body: method === 'POST' ? args : undefined });
|
||||||
|
assert.equal(r.status, 403, path);
|
||||||
|
}
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed API-key JSON never appears in errors or logs', async t => {
|
||||||
|
const { s, calls, logs } = await setup(t);
|
||||||
|
const canary = 'p4-canary-key';
|
||||||
|
const r = await new Promise((resolve, reject) => {
|
||||||
|
const req = request({ hostname: '127.0.0.1', port: s.address().port, path: '/api/proxy/apikey/add', method: 'POST', headers }, res => {
|
||||||
|
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, text }));
|
||||||
|
}); req.on('error', reject); req.end('{"key":"' + canary);
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(r.text.includes(canary), false); assert.equal(logs.join('\n').includes(canary), false);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
@@ -353,7 +353,7 @@ async function validate(document, file) {
|
|||||||
const { loadProxy } = await import("../packages/model-proxy/src/settings.mjs");
|
const { loadProxy } = await import("../packages/model-proxy/src/settings.mjs");
|
||||||
proxy = loadProxy(path.dirname(file), dataRoot);
|
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 {
|
return {
|
||||||
configVersion: document.configVersion,
|
configVersion: document.configVersion,
|
||||||
|
|||||||
Reference in new issue
Block a user