Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d0e9f89be | ||
|
|
d5aa963893 | ||
|
|
75f6d5533a | ||
|
|
f0e11cb342 |
No files matched your search
@@ -1347,6 +1347,23 @@ input; error on stderr, never a clean verdict).
|
||||
problem. Scripts print diagnostics on stderr; model responses (and only
|
||||
model responses) on stdout.
|
||||
|
||||
## Model proxy setup (`scripts/mosaic proxy`, #1873)
|
||||
|
||||
Human-shell verbs that match the Console's Proxy setup panel; each refuses
|
||||
`human-required` in an agent shell or with `--tester`, and prints no key or
|
||||
absolute path. `proxy fetch` (existing output unchanged) now prints the
|
||||
page's progress steps on stderr and writes the same fetch receipt to
|
||||
`<dataRoot>/state/proxy-admin.jsonl`. `proxy status [--json]` shows release, init, unit, fence, init
|
||||
lock, own proxy and the next step. `proxy init --generate-key [--port N]
|
||||
[--rotate-grace-minutes N] [--first-output-seconds N] --business ID` has the
|
||||
bus generate the management key (no prompt, no TTY). `proxy unit
|
||||
render|status|install|start|stop|restart` manages the proxy user unit.
|
||||
`proxy fence clear --restart --business ID` restarts the unit, requires a
|
||||
confirmed new process listening on the port, then clears the fence with that
|
||||
evidence (`proxy-restart-unconfirmed` otherwise). `proxy init-lock
|
||||
status|clear` clears a stale init lock only when every bus is stopped and no
|
||||
proxy state exists. Details: `packages/model-proxy/README.md`.
|
||||
|
||||
## Agent limits (`scripts/mosaic limits`)
|
||||
|
||||
View: `scripts/mosaic limits --business ID --json`. Set: `scripts/mosaic limits
|
||||
|
||||
@@ -606,6 +606,7 @@ export class Broker {
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
#proxyFenceClear;
|
||||
#proxyPage;
|
||||
#project;
|
||||
#library;
|
||||
@@ -641,6 +642,7 @@ export class Broker {
|
||||
// Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of
|
||||
// section 5's restart rows and of revokes a restart cut short, for reconcileGitea.
|
||||
#giteaGrant;
|
||||
#copyGrant;
|
||||
#giteaKeys;
|
||||
#giteaUndo = [];
|
||||
// Row 281 (#1786, d6 section 3b): each tracked Gitea promise and the business/agent it acts for, or
|
||||
@@ -666,7 +668,7 @@ export class Broker {
|
||||
#processing;
|
||||
#taskTracker;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, autonomy = 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, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, autonomy = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyFenceClear = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, copyGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
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.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
@@ -695,10 +697,12 @@ export class Broker {
|
||||
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
||||
this.#credentialVerbs = credentialVerbs;
|
||||
this.#proxyInit = proxyInit;
|
||||
this.#proxyFenceClear = proxyFenceClear;
|
||||
this.#proxyPage = proxyPage;
|
||||
this.#hostGrant = hostGrant;
|
||||
this.#hostKeys = hostKeys;
|
||||
this.#giteaGrant = giteaGrant;
|
||||
this.#copyGrant = copyGrant;
|
||||
this.#giteaKeys = giteaKeys;
|
||||
this.#access = access;
|
||||
this.#accessStamp = accessStamp;
|
||||
@@ -5971,6 +5975,19 @@ export class Broker {
|
||||
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 }); });
|
||||
}
|
||||
// #1873: the fence clear takes restart evidence, which the runtime re-proves on the host
|
||||
// before the lock clears. Owner only, before every other gate, like the proxy page verbs.
|
||||
if (request.verb === 'proxy.fence.clear') {
|
||||
this.#human(s);
|
||||
if (!this.#proxyFenceClear) fail('not-configured');
|
||||
const args = request.args ?? {};
|
||||
keys(args, ['evidence'], ['evidence']);
|
||||
return Promise.resolve().then(() => this.#proxyFenceClear(args)).then(result => {
|
||||
if (!object(result) || typeof result.cleared !== 'boolean') fail('proxy-down');
|
||||
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.fence.clear', cleared: result.cleared, pid: result.evidence?.after?.pid ?? null }, null));
|
||||
return result;
|
||||
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(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
|
||||
// request reads the access file again, so a removed or expired grant refuses the next one.
|
||||
let grants = null;
|
||||
@@ -5991,19 +6008,24 @@ export class Broker {
|
||||
if (request.verb === 'proxy.init') {
|
||||
this.#human(s);
|
||||
if (!this.#proxyInit) fail('not-configured');
|
||||
keys(args, ['key', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'], ['key']);
|
||||
if (!validManagementKey(args.key)) fail('proxy-key-invalid');
|
||||
keys(args, ['key', 'generate', 'port', 'rotateGraceMinutes', 'firstOutputSeconds']);
|
||||
// #1873: exactly one of a key or generate:true. A generated key is made here, stored by
|
||||
// init where a typed key goes, and never returned, logged or put in an event.
|
||||
if (Object.hasOwn(args, 'generate') ? args.generate !== true || Object.hasOwn(args, 'key') : !Object.hasOwn(args, 'key')) fail('invalid-request');
|
||||
if (!Object.hasOwn(args, 'generate') && !validManagementKey(args.key)) fail('proxy-key-invalid');
|
||||
if (Object.hasOwn(args, 'port') && (!Number.isInteger(args.port) || args.port < 1024 || args.port > 65535 || args.port === 8317)) fail('proxy-config-invalid');
|
||||
try { proxyTiming(args); } catch (error) { fail(error.code); }
|
||||
// Async initialization is outside the store transaction. Refusals retain codes only.
|
||||
return Promise.resolve().then(() => this.#proxyInit(args)).then(result => {
|
||||
const { generate, ...given } = args;
|
||||
const initArgs = generate ? { ...given, key: randomBytes(48).toString('base64url') } : given;
|
||||
return Promise.resolve().then(() => this.#proxyInit(initArgs)).then(result => {
|
||||
if (!object(result) || Object.keys(result).some(k => !['binary', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'].includes(k))
|
||||
|| !Number.isInteger(result.port) || result.port < 1024 || result.port > 65535 || result.port === 8317
|
||||
|| typeof result.binary !== 'string' || !result.binary.startsWith('/')) fail('proxy-init-failed');
|
||||
try { proxyTiming(result); } catch (error) { fail(error.code); }
|
||||
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.init', port: result.port }, null));
|
||||
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.init', port: result.port, ...(generate ? { generated: true } : {}) }, null));
|
||||
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'), { verb: request.verb }); });
|
||||
}
|
||||
this.#secretCheck(args);
|
||||
if (request.verb === 'plugin.tool') {
|
||||
@@ -6349,6 +6371,11 @@ export class Broker {
|
||||
if (a.token !== undefined) string(a.token, 16384);
|
||||
if (a.date !== undefined) string(a.date, 10);
|
||||
if (a.reason !== undefined) string(a.reason, 500);
|
||||
// #1873 CB2: an issue for a copy this bus booted with goes to the copy verifier (copy-grant.mjs).
|
||||
// The boot's copies view leaves out a name the file's own roles hold; a copy that needs no
|
||||
// credential joins the roles but is still a copy. Any other unknown name answers credential-unknown.
|
||||
const booted = this.#business(s.business);
|
||||
if (verb === 'credentials.issue' && this.#copyGrant && booted.copies && Object.hasOwn(booted.copies, a.agent)) return this.#issueCopyGrant(s, a);
|
||||
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
|
||||
// A running agent keeps its tokens until its runs end: delete waits for every claim to end.
|
||||
if (verb === 'credentials.delete' && (this.#holder(s.business, a.agent)?.op === 'claim' || this.#threadHolders(s.business, a.agent).length))
|
||||
@@ -6379,6 +6406,28 @@ export class Broker {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// #1873 CB2: owner only, as every binding decision (design Q1): not the tester, a reader or an
|
||||
// org.hire member. The recheck runs after the remote work and again under the writer's locks.
|
||||
#copyOwner(s) {
|
||||
if (s.tester || s.reader || !s.human) fail(s.tester ? 'tester-refused' : 'human-required');
|
||||
if (s.hireMembership ? s.hireMembership.owner !== true : s.human !== this.#business(s.business).human) fail('human-required');
|
||||
}
|
||||
#issueCopyGrant(s, a) {
|
||||
this.#copyOwner(s);
|
||||
const passed = (e) => e instanceof BusError && (e.code.startsWith('credential-') || ['copy-unavailable', 'human-required',
|
||||
'definition-holds-credential', 'definition-scan-unreadable'].includes(e.code));
|
||||
return this.#copyGrant(s.business, a, { actor: s.human, owner: true, recheck: () => this.#copyOwner(s) })
|
||||
.then((result) => {
|
||||
this.#secretCheck(result);
|
||||
if (JSON.stringify(result).includes(a.token)) fail('credential-leak');
|
||||
return result;
|
||||
}, (e) => {
|
||||
if (passed(e)) throw e;
|
||||
if (['revision-conflict', 'decision-stale'].includes(e?.code) || e?.code?.startsWith('organization-')) fail(e.code);
|
||||
if (e?.name === 'BusinessError') fail('invalid-request');
|
||||
throw new BusError('outcome-unknown');
|
||||
});
|
||||
}
|
||||
async #autonomyConsole(s,verb,a) {
|
||||
if(verb==='autonomy.get.console') {
|
||||
keys(a,[]);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// #1873 CB2 (design section 5 steps 4 to 6): credentials.issue for an agent copy's bound service.
|
||||
// Gitea is the only service with a verifier in this slice. A copy token is accepted only after the
|
||||
// Gitea server it belongs to proves the whole grant live: the token's scopes, the account it belongs
|
||||
// to (the copy's own dedicated account, never an administrator or another copy's or agent's), every
|
||||
// repository that account can reach and the token's expiry, against the owner-approved binding. A
|
||||
// Vikunja binding refuses credential-scope-unverified: that is the shipped behaviour.
|
||||
//
|
||||
// Order (no lock is held across the remote work):
|
||||
// 1. checks that need nothing written: owner, service, date, token form, definition scan, the
|
||||
// record and the boot snapshot, the bound projects' remotes and the approved scopes;
|
||||
// 2. stage: the writer deactivates the record (pending, the service's grant fields gone), then the
|
||||
// token goes to the copy's slot in the self-only store;
|
||||
// 3. the slot is read back by the identity the stage wrote, and Gitea is asked;
|
||||
// 4. commit: the writer rechecks registry incarnation, document revision, binding generation,
|
||||
// copyId, decision and definition pin under its locks; its beforeWrite reads the slot back by
|
||||
// identity again and runs the caller's session recheck. Only then do the grant metadata and the
|
||||
// receipt land.
|
||||
// Any refusal after the stage removes the slot it wrote and leaves the record pending. A lost reply
|
||||
// or a crash leaves the record pending too; nothing resolves a pending record, and recovery is a
|
||||
// new verified issue. A token a crash left staged goes at the next start (sweepCopySlots). Nothing here logs, and no result, receipt or refusal holds the token, the
|
||||
// slot's path, a request or anything derived from the token.
|
||||
import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { Credentials, assertNotHeld, day, privateDir } from './credentials.mjs';
|
||||
import { send, versionAtLeast, REPO_PAGE, REPO_PAGES } from './gitea-grant.mjs';
|
||||
import { scanOrganizations } from './definition-scan.mjs';
|
||||
import { parseGiteaUrl, GITEA_SCOPE_CATEGORIES, ID_PATTERN, SERVICES } from '../../organization/src/vocabulary.mjs';
|
||||
import { copyBotLogin, scopesWithin } from '../../organization/src/copy-bindings.mjs';
|
||||
|
||||
const deny = (code, reason) => {
|
||||
throw new BusError(code, reason);
|
||||
};
|
||||
const unverified = (reason) => deny('credential-scope-unverified', reason);
|
||||
const DAY_MS = 86400000;
|
||||
// A copy credential's rotation date is after today and at most this many days ahead.
|
||||
export const MAX_ROTATE_DAYS = 366;
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
const positive = (n) => Number.isSafeInteger(n) && n > 0;
|
||||
const lower = (s) => (typeof s === 'string' ? s.toLowerCase() : null);
|
||||
|
||||
// The scopes a Gitea token reports, as {category: level}. Gitea writes write:X as implying read:X and
|
||||
// may list both; the higher level wins. "all", any admin category, a category Gitea added that this
|
||||
// bus doesn't know, or anything that doesn't parse returns null: such a token is broader than any
|
||||
// binding can approve.
|
||||
export function tokenLevels(scopes) {
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) return null;
|
||||
const out = {};
|
||||
for (const scope of scopes) {
|
||||
const m = typeof scope === 'string' ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) return null;
|
||||
if (out[m[2]] !== 'write') out[m[2]] = m[1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const asList = (levels) => Object.entries(levels).map(([c, l]) => `${l}:${c}`).sort();
|
||||
|
||||
// The approved Gitea repositories of a copy's binding: one https origin, lower-cased "owner/repo"
|
||||
// names, from each bound project's remote. A project with no Gitea remote, two remotes that disagree
|
||||
// or two servers cannot be proved.
|
||||
export function boundRepositories(doc, projects) {
|
||||
let origin = null;
|
||||
const repos = new Set();
|
||||
for (const project of projects ?? []) {
|
||||
const entry = doc.projects?.[project];
|
||||
if (!object(entry)) unverified('remote');
|
||||
const remotes = [entry.remote, entry.git?.mode === 'remote' ? entry.git.remote : undefined].filter((r) => r !== undefined);
|
||||
if (remotes.length === 0 || remotes.some((r) => r !== remotes[0])) unverified('remote');
|
||||
const parsed = parseGiteaUrl(remotes[0], { repo: true });
|
||||
if (!parsed.url) unverified('remote');
|
||||
if (origin !== null && parsed.url.origin !== origin) unverified('remote');
|
||||
origin = parsed.url.origin;
|
||||
repos.add(`${parsed.owner}/${parsed.repo}`.toLowerCase());
|
||||
}
|
||||
if (origin === null) unverified('remote');
|
||||
return { origin, repos };
|
||||
}
|
||||
|
||||
// Live introspection of one Gitea token against an approved binding. Returns { userId, tokenId,
|
||||
// login } or throws the refusal. `approved` is the binding's Gitea scopes; `repos` the lower-cased
|
||||
// names it may reach; `login` the account this copy's grant must belong to; `rotateBy` the date the
|
||||
// owner gave. Every call is GET with the token; a missing or ambiguous answer is unverified.
|
||||
export async function verifyGiteaToken({ transport = send, url, ca, timeoutMs, token, approved, repos, login, rotateBy }) {
|
||||
const call = (path) => transport({ url, method: 'GET', path, auth: { token }, ca, timeoutMs });
|
||||
const version = await call('/api/v1/version');
|
||||
if (version.status !== 200 || !object(version.json) || !versionAtLeast(version.json.version)) unverified('version');
|
||||
const info = await call('/api/v1/token');
|
||||
if (info.status === 401 || info.status === 403 || info.status === 404) unverified('token');
|
||||
if (info.status !== 200 || !object(info.json) || !positive(info.json.id) || !object(info.json.user) || !positive(info.json.user.id)
|
||||
|| typeof info.json.user.login !== 'string') unverified('token');
|
||||
if (lower(info.json.user.login) !== lower(login)) deny('credential-identity-refused', login);
|
||||
const levels = tokenLevels(info.json.scopes);
|
||||
if (!levels || !scopesWithin('gitea', asList(levels), approved)) deny('credential-scope-exceeded');
|
||||
if (!levels.user) unverified('scopes');
|
||||
// An expiry Gitea reports must not come before the owner's rotation date ends.
|
||||
if (info.json.expires_at !== undefined && info.json.expires_at !== null) {
|
||||
const at = typeof info.json.expires_at === 'string' ? Date.parse(info.json.expires_at) : NaN;
|
||||
if (Number.isNaN(at)) unverified('token');
|
||||
if (at < Date.parse(rotateBy + 'T00:00:00Z') + DAY_MS) deny('credential-date');
|
||||
}
|
||||
const user = await call('/api/v1/user');
|
||||
if (user.status !== 200 || !object(user.json) || typeof user.json.is_admin !== 'boolean') unverified('user');
|
||||
if (user.json.id !== info.json.user.id || lower(user.json.login) !== lower(login) || user.json.is_admin !== false)
|
||||
deny('credential-identity-refused', login);
|
||||
// Every repository the account can reach: owned, collaborator and team access. One unrelated
|
||||
// repository, an admin permission, or push where the binding approved read refuses. Push is the
|
||||
// account's right, so it is held to what the binding approved, not to the token's own level.
|
||||
const write = tokenLevels(approved)?.repository === 'write';
|
||||
const seen = new Map();
|
||||
let total = null;
|
||||
for (let page = 1; ; page++) {
|
||||
if (page > REPO_PAGES) unverified('repositories');
|
||||
const r = await call(`/api/v1/user/repos?limit=${REPO_PAGE}&page=${page}`);
|
||||
if (r.status !== 200 || !Array.isArray(r.json) || r.total === null || (total !== null && r.total !== total)) unverified('repositories');
|
||||
total = r.total;
|
||||
if (total > REPO_PAGE * REPO_PAGES) unverified('repositories');
|
||||
for (const repo of r.json) {
|
||||
if (!object(repo) || typeof repo.full_name !== 'string' || !object(repo.permissions)) unverified('repositories');
|
||||
const name = repo.full_name.toLowerCase();
|
||||
if (!repos.has(name)) deny('credential-scope-exceeded');
|
||||
const p = repo.permissions;
|
||||
if (p.admin !== false || typeof p.push !== 'boolean' || p.pull !== true || (p.push && !write)) deny('credential-scope-exceeded');
|
||||
seen.set(name, true);
|
||||
}
|
||||
if (seen.size >= total || r.json.length === 0) break;
|
||||
}
|
||||
if (seen.size !== total) unverified('repositories');
|
||||
for (const name of repos) if (!seen.has(name)) unverified('repositories');
|
||||
// An organization scope reaches organizations' own settings: the account must belong to none.
|
||||
if (levels.organization) {
|
||||
const orgs = await call(`/api/v1/user/orgs?limit=${REPO_PAGE}&page=1`);
|
||||
if (orgs.status !== 200 || !Array.isArray(orgs.json)) unverified('organizations');
|
||||
if (orgs.json.length) deny('credential-scope-exceeded');
|
||||
}
|
||||
return { userId: info.json.user.id, tokenId: info.json.id, login };
|
||||
}
|
||||
|
||||
// The issue for one copy's service. `copies` is the bus's boot snapshot ({ org, copies,
|
||||
// definitions }); `writer` the organization writer; `credentials` the bus's store. `hooks` are the
|
||||
// tests' crash points (afterStage, afterRemote, beforeCommit), never set at runtime.
|
||||
export function copyGrant({ credentials, writer, copies = null, organizations = null, transport = send, ca = undefined, timeoutMs = undefined, now = () => new Date(), hooks = {} }) {
|
||||
return async (business, args, { actor, owner = false, recheck = () => {} } = {}) => {
|
||||
const { agent: instance, service, token, date } = args;
|
||||
if (owner !== true || actor === 'tester') deny('human-required');
|
||||
if (service === 'vikunja') unverified('service');
|
||||
if (service !== 'gitea') deny('credential-unknown');
|
||||
const today = now().toISOString().slice(0, 10);
|
||||
if (!day(date) || date <= today || Date.parse(date) - Date.parse(today) > MAX_ROTATE_DAYS * DAY_MS) deny('credential-date');
|
||||
const value = Credentials.checkFormat(service, token);
|
||||
const { document: doc, revision } = writer.read(business);
|
||||
scanOrganizations({ organizations: organizations?.() ?? [business], own: { id: business, document: doc }, readRaw: (b) => writer.readRaw(b),
|
||||
boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null }, (v) => assertNotHeld(v, [value]));
|
||||
const record = doc.copyBindings?.[instance];
|
||||
if (!object(record) || record.state === 'unbound' || !object(record.services?.[service])) deny('credential-unbound');
|
||||
const copy = copies?.org === business && Object.hasOwn(copies.copies ?? {}, instance) ? copies.copies[instance] : null;
|
||||
if (!copy || copy.state === 'refused' || Object.hasOwn(doc.roles ?? {}, instance)) deny('copy-unavailable');
|
||||
if (copy.level !== record.source?.level || copy.name !== record.source?.name || (copy.project ?? null) !== (record.source?.project ?? null)
|
||||
|| copy.definition !== record.definition?.ref) deny('credential-binding-changed');
|
||||
const approved = record.services[service];
|
||||
if (!Array.isArray(approved.scopes) || !approved.scopes.some((s) => /^(read|write):user$/.test(s))) unverified('scopes');
|
||||
const { origin, repos } = boundRepositories(doc, approved.resources?.projects);
|
||||
const login = copyBotLogin(record.orgIncarnation, record.copyId, service);
|
||||
if (!login) deny('credential-binding-changed');
|
||||
const op = { instance, service, copyId: record.copyId, generation: record.generation, decision: record.decision, incarnation: record.orgIncarnation };
|
||||
const definitions = copies?.org === business ? copies.definitions ?? {} : {};
|
||||
const staged = writer.stageCopyGrant(business, op, { revision, actor, owner, definitions });
|
||||
const slot = business + '/' + instance;
|
||||
let identity = null;
|
||||
try {
|
||||
identity = credentials.stageCopy(slot, service, value.token);
|
||||
await hooks.afterStage?.();
|
||||
// An inode number can be reused after an unlink, so the read-back must also be the staged token.
|
||||
const read = credentials.readCopy(slot, service, identity);
|
||||
if (read.token !== value.token) deny('credential-file');
|
||||
const grant = await verifyGiteaToken({ transport, url: origin, ca, timeoutMs, token: read.token, approved: approved.scopes, repos, login, rotateBy: date });
|
||||
await hooks.afterRemote?.();
|
||||
await recheck();
|
||||
const grantId = `gitea:${grant.userId}:${grant.tokenId}`;
|
||||
const after = { ...op, generation: staged.generation };
|
||||
const committed = writer.commitCopyGrant(business, after, {
|
||||
grantId, rotateBy: date, revision: staged.revision, actor, owner, definitions,
|
||||
beforeWrite: () => {
|
||||
hooks.beforeCommit?.();
|
||||
const again = credentials.readCopy(slot, service, identity);
|
||||
if (again.token !== read.token) deny('credential-file');
|
||||
recheck();
|
||||
},
|
||||
});
|
||||
return { agent: instance, service, verb: 'issue', date, state: committed.state, account: login, generation: committed.generation };
|
||||
} catch (e) {
|
||||
if (identity) {
|
||||
try {
|
||||
credentials.unlinkCopy(slot, service, identity);
|
||||
} catch {}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Security review P2: at start, before Credentials loads and before any issue can run, each staged
|
||||
// file of a copy's service whose binding holds no grant for that service goes, and so does any
|
||||
// interrupted write's temp file. An issue killed after its stage leaves the record pending and the
|
||||
// token on disk; nothing resolves a pending record and no reference loads a copy slot, so the file
|
||||
// only keeps a secret nothing will use. `documents` is [business, validated organization document]
|
||||
// for each loaded business (null when unreadable: nothing of it is touched). Only names the document
|
||||
// binds as copies are looked at, never a name its own roles hold or a slot a loaded reference names.
|
||||
// A store dir that isn't private refuses credential-file, as Credentials would. Returns
|
||||
// [{ instance, service }], never a path.
|
||||
export function sweepCopySlots(storeRoot, documents, references = {}) {
|
||||
const removed = [];
|
||||
if (!storeRoot || !privateDir(storeRoot, { missing: true })) return removed;
|
||||
for (const [business, doc] of documents) {
|
||||
if (!object(doc) || !object(doc.copyBindings) || !ID_PATTERN.test(business)) continue;
|
||||
const dir = join(storeRoot, business);
|
||||
if (!privateDir(dir, { missing: true })) continue;
|
||||
for (const [copy, record] of Object.entries(doc.copyBindings)) {
|
||||
if (!ID_PATTERN.test(copy) || Object.hasOwn(doc.roles ?? {}, copy)) continue;
|
||||
const copyDir = join(dir, copy);
|
||||
if (!privateDir(copyDir, { missing: true })) continue;
|
||||
const instance = business + '/' + copy;
|
||||
for (const name of readdirSync(copyDir)) {
|
||||
const service = name.endsWith('.tmp') ? name.slice(0, -4) : name;
|
||||
if (!SERVICES.includes(service) || references[instance]?.[service] !== undefined) continue;
|
||||
if (name === service && typeof record?.services?.[service]?.grantId === 'string') continue;
|
||||
if (lstatSync(join(copyDir, name)).isDirectory()) continue;
|
||||
unlinkSync(join(copyDir, name));
|
||||
removed.push({ instance, service });
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { credentialKind, isHostKind, ID_PATTERN } from '../../organization/src/vocabulary.mjs';
|
||||
import { credentialKind, isHostKind, ID_PATTERN, SERVICES } from '../../organization/src/vocabulary.mjs';
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
@@ -90,7 +90,7 @@ export function parseHostKey(text) {
|
||||
// Credentials.assertClean's test: credential-leak when the value's JSON text holds an entry's token
|
||||
// or one of its retiring model keys. org-defscan (#1873): issue's check runs it on the value it is
|
||||
// about to hold.
|
||||
function assertNotHeld(value, entries) {
|
||||
export function assertNotHeld(value, entries) {
|
||||
let encoded;
|
||||
try {
|
||||
encoded = JSON.stringify(value);
|
||||
@@ -479,6 +479,18 @@ export class Credentials {
|
||||
const value = Credentials.#format(service, token);
|
||||
check((v) => assertNotHeld(v, [value]));
|
||||
this.#invalidateModelProof(instance, service);
|
||||
this.#writeSlot(instance, service, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
|
||||
const file = this.#storePath(instance, service);
|
||||
commit();
|
||||
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
|
||||
this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
this.#revoked.delete(instance + ':' + service);
|
||||
this.#held.delete(instance + ':' + service);
|
||||
return this.status().find((e) => e.instance === instance && e.service === service);
|
||||
}
|
||||
// The store slot write issue and stageCopy share: tmp (O_EXCL, O_NOFOLLOW, 0600), fsync, rename
|
||||
// over the slot, fsync the dir. Returns the identity of the file it wrote.
|
||||
#writeSlot(instance, service, text, { undo = false } = {}) {
|
||||
const file = this.#storePath(instance, service);
|
||||
const agentDir = dirname(file);
|
||||
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
|
||||
@@ -493,27 +505,101 @@ export class Credentials {
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
let fd;
|
||||
// A write that fails leaves no token behind: the temp file this call made goes, and with `undo`
|
||||
// (stageCopy) so does the slot it renamed into place.
|
||||
let fd, identity;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeSync(fd, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
|
||||
fsyncSync(fd);
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
const st = fstatSync(fd);
|
||||
identity = { dev: st.dev, ino: st.ino };
|
||||
writeSync(fd, text);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
} catch (e) {
|
||||
if (identity) {
|
||||
try {
|
||||
const st = lstatSync(tmp);
|
||||
if (st.isFile() && st.dev === identity.dev && st.ino === identity.ino) unlinkSync(tmp);
|
||||
} catch {}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
const dirFd = openSync(agentDir, constants.O_RDONLY);
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
} catch (e) {
|
||||
if (undo) {
|
||||
try {
|
||||
this.unlinkCopy(instance, service, identity);
|
||||
} catch {}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
// #1873 CB2 (design section 5 step 4): a copy's service slot, <store>/<business>/<copy>/<service>.
|
||||
// The bus stages it while the copy's binding is inactive, and afterwards reads back or removes only
|
||||
// the file with the identity (dev, ino) it wrote. Memory and references never hold a copy's token
|
||||
// in this slice; launch admission (CB3) reads the slot. A name a loaded reference uses refuses.
|
||||
static checkFormat(service, token) {
|
||||
return Credentials.#format(service, token);
|
||||
}
|
||||
stageCopy(instance, service, token) {
|
||||
if (this.#refs.has(instance + ':' + service) || !SERVICES.includes(service)) deny('credential-reference');
|
||||
const value = Credentials.#format(service, token);
|
||||
return this.#writeSlot(instance, service, value.token + '\n', { undo: true });
|
||||
}
|
||||
// The staged token, read back with the store's checks plus one link only and the exact identity the
|
||||
// stage wrote: a symlink, a hard link, a replaced or a stale file refuses credential-file.
|
||||
readCopy(instance, service, identity) {
|
||||
const file = this.#storePath(instance, service);
|
||||
privateDir(this.#storeRoot);
|
||||
privateDir(dirname(dirname(file)));
|
||||
privateDir(dirname(file));
|
||||
const same = (st) => st.dev === identity?.dev && st.ino === identity?.ino;
|
||||
let original;
|
||||
try {
|
||||
original = lstatSync(file);
|
||||
} catch {
|
||||
deny('credential-file');
|
||||
}
|
||||
if (original.isSymbolicLink() || !original.isFile() || original.nlink !== 1 || !same(original)) deny('credential-file');
|
||||
let fd, text;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.size > 16384 || st.nlink !== 1 || !same(st))
|
||||
deny('credential-file');
|
||||
text = readFileSync(fd, 'utf8');
|
||||
} catch (e) {
|
||||
if (e instanceof BusError) throw e;
|
||||
deny('credential-file');
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
const dirFd = openSync(agentDir, constants.O_RDONLY);
|
||||
return Credentials.#format(service, text);
|
||||
}
|
||||
// Removes the slot only while it is still the file the stage wrote; another file is left alone.
|
||||
unlinkCopy(instance, service, identity) {
|
||||
const file = this.#storePath(instance, service);
|
||||
let st;
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
st = lstatSync(file);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return false;
|
||||
deny('credential-file');
|
||||
}
|
||||
commit();
|
||||
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
|
||||
this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
this.#revoked.delete(instance + ':' + service);
|
||||
this.#held.delete(instance + ':' + service);
|
||||
return this.status().find((e) => e.instance === instance && e.service === service);
|
||||
if (!st.isFile() || st.dev !== identity?.dev || st.ino !== identity?.ino) return false;
|
||||
unlinkSync(file);
|
||||
return true;
|
||||
}
|
||||
// Row 236 (#436b section 5, settle forward): memory takes the store file as it is now, with the
|
||||
// constructor's checks, and `date` becomes the entry's date. A held entry refuses.
|
||||
|
||||
@@ -98,16 +98,19 @@ export function createProxyLock({ stateDir, identity, waitMs = 5000 }) {
|
||||
fs.renameSync(temp, generationFile); syncDir(stateDir);
|
||||
fs.unlinkSync(fenceFile); syncDir(stateDir);
|
||||
}
|
||||
async function clear({ restarted, read }) {
|
||||
// `verify(previous)` (optional) re-proves a restart against the fence it would clear, inside
|
||||
// the lock, and returns the evidence the receipt records.
|
||||
async function clear({ restarted, read, verify }) {
|
||||
if (restarted !== true) fail('proxy-restart-required');
|
||||
return run(async () => {
|
||||
const previous = status();
|
||||
const evidence = verify ? await verify(previous) : undefined;
|
||||
await read();
|
||||
const fd = fs.openSync(receiptFile, 'a', 0o600);
|
||||
try { fs.writeFileSync(fd, JSON.stringify({ op: 'fence.clear', time: new Date().toISOString(), previous }) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
||||
try { fs.writeFileSync(fd, JSON.stringify({ op: 'fence.clear', time: new Date().toISOString(), previous, ...(evidence === undefined ? {} : { evidence }) }) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
||||
syncDir(stateDir);
|
||||
if (previous) confirm();
|
||||
return { cleared: Boolean(previous) };
|
||||
return { cleared: Boolean(previous), ...(evidence === undefined ? {} : { evidence }) };
|
||||
}, { allowUncertain: true });
|
||||
}
|
||||
async function run(operation, { allowUncertain = false } = {}) {
|
||||
|
||||
@@ -22,6 +22,10 @@ group("organization-bindings-protected", "R1/R8: copy bindings change only throu
|
||||
group("credential-binding-changed credential-role-changed credential-definition-unpinned", "R1/R8: binding approval pinned to the current copy, definition and generation", "Read the copy's current binding and definition, then ask the chief to raise a fresh binding decision for the owner; an old approval never carries over.");
|
||||
group("credential-scope-exceeded", "R1/R8: effective access is the role intersected with the approval", "Narrow the requested scopes and projects to what the copy's role and admitted projects allow, or change the role through its own decision first.");
|
||||
group("credential-unbound", "R1/R8: launch needs an approved, verified binding", "Ask the chief to raise a binding decision for the missing service; the owner approves it. A pending binding is not usable until verification exists.");
|
||||
// #1873 CB2: a copy's service credential is accepted only after live verification at the service.
|
||||
group("credential-scope-unverified", "R1/R8: a copy credential is accepted only when the service proves its whole grant", "Gitea: approve read:user in the copy's binding (its role must declare it) so the account's repositories can be listed, give every bound project a Gitea remote on one server, and retry when the server answers. Vikunja has no verifier yet; its copy bindings stay unusable.");
|
||||
group("credential-identity-refused", "R1/R8: one dedicated service account per organization, copy and service", "Create the account the refusal names on the copy's Gitea server, not an administrator, with access to the bound projects' repositories only, and issue a token from that account. Never reuse another copy's or agent's account or token.");
|
||||
group("credential-date credential-format", "R1/R8: well-formed credentials with a future rotation date", "Supply the token on standard input alone, and a rotation date after today in YYYY-MM-DD form, no more than a year ahead for a copy credential.");
|
||||
group("copy-unavailable copy-refused", "R3/R8: one active, unambiguous copy identity", "Read the organization's copies. Ask the owner to resolve the definition collision or restore the copy's source before raising a binding decision.");
|
||||
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
|
||||
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
|
||||
@@ -57,6 +61,18 @@ group(
|
||||
"R1/R3: owner-managed model and provider configuration",
|
||||
"Ask the owner to inspect the provider configuration through its supported controls; never put keys or callback contents in messages."
|
||||
);
|
||||
// #1873: proxy setup from the Console page and `mosaic proxy`. Each code has one owner next step.
|
||||
group("proxy-install-missing proxy-fetch-url proxy-fetch-failed proxy-fetch-redirect", "R1: pinned model proxy release", "Fetch the pinned release from the Proxy page or with mosaic proxy fetch. A download or redirect failure is retried as it stands; the release URL is fixed by the pin.");
|
||||
group("proxy-install-invalid proxy-fetch-hash proxy-fetch-archive proxy-fetch-existing", "R1: pinned release hashes must match", "A file does not match the pinned hash. Do not run it. Ask the owner to remove the pinned release directory named in the README and fetch again.");
|
||||
group("proxy-already-initialized proxy-port-in-use proxy-management-key-invalid", "R1/R4: proxy init is exclusive creation", "Init never rewrites proxy.json. Read the Proxy page status; pick a free port, or keep the existing setup and continue from the status's next step.");
|
||||
group("proxy-init-busy proxy-init-lock-held proxy-init-lock-absent", "R4: init lock clears only when provably stale", "Wait for a running init to finish. A stale lock clears from the Proxy page or with mosaic proxy init-lock clear only when the bus is stopped and no proxy.json, key file or proxy dir exists; the refusal lists what is still there.");
|
||||
group("proxy-key-transport", "R1/R6: a pasted key travels only over loopback or HTTPS", "Use the generated key, which never leaves the host, or open the Console over HTTPS or on this machine to paste one.");
|
||||
group("proxy-restart-required proxy-restart-unconfirmed proxy-lock-uncertain", "R4/R6: fence clears only after a confirmed proxy restart", "Restart the proxy from the Proxy page or with mosaic proxy fence clear --restart, so the new process is confirmed on the proxy port, then clear the fence. A fence file with no readable time cannot be proven against: restart the proxy service yourself and run mosaic proxy fence clear --proxy-restarted.");
|
||||
group("proxy-admin-busy", "R4: one proxy setup action at a time", "Wait for the running fetch, init or unit action to finish (the Proxy page status shows it), then retry.");
|
||||
group("proxy-key-mismatch", "R1: a pasted management key is confirmed before init", "Type the same key in both fields, or leave them empty to have the bus generate the key.");
|
||||
group("proxy-lock-busy proxy-lock-invalid", "R4: one proxy key write at a time", "Wait for the running proxy write to finish and retry. An invalid lock file needs the owner to inspect the proxy state directory.");
|
||||
group("proxy-not-configured proxy-unit-usage proxy-unit-exists proxy-unit-dropin proxy-unit-copy proxy-unit-path proxy-unit-missing proxy-unit-unhealthy proxy-unit-failed", "R1/R4: proxy user unit is explicit creation", "Initialize the proxy first, then install the unit from the Proxy page or with mosaic proxy unit install. An existing unit or drop-in is never replaced; remove it by hand. An unhealthy unit: read its journal by unit name and restart it.");
|
||||
group("proxy-client-invalid proxy-client-refused proxy-client-response", "R1: proxy management answers", "Check the proxy unit is running and its port answers on the Proxy page, then retry. Upstream bodies are never shown.");
|
||||
group(
|
||||
"block-recipient block-reply-required block-sender message-recipient-refused message-sender-refused tester-reply-required tester-thread-human user-reply-required user-thread-human",
|
||||
"Role messaging: permitted sender, recipient and reply context",
|
||||
|
||||
@@ -35,6 +35,7 @@ import { createModelCatalogue } from './model-catalogue.mjs';
|
||||
import { sha256 } from '../../model-proxy/src/common.mjs';
|
||||
import { initializeProxy } from '../../model-proxy/src/settings.mjs';
|
||||
import { createProxyLock } from './proxy-lock.mjs';
|
||||
import { verifyRestart } from '../../model-proxy/src/admin.mjs';
|
||||
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
|
||||
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
|
||||
@@ -45,6 +46,7 @@ import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { copyGrant, sweepCopySlots } from './copy-grant.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
@@ -334,7 +336,7 @@ export async function startBroker({
|
||||
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|
||||
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
|
||||
throw new BusError('invalid-host-request');
|
||||
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null, proxyLock = null;
|
||||
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null, proxyLock = null, proxyPort = null;
|
||||
let modelKeys = null, modelTimer = null, modelSweep = null, modelCatalogues = null;
|
||||
const modelRuns = new Map();
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null, requestTimer = null;
|
||||
@@ -383,6 +385,7 @@ export async function startBroker({
|
||||
proxyLock = createProxyLock({ stateDir: join(instanceDataRoot, 'proxy'), identity: `${instanceConfigDir}:${proxy.port}` });
|
||||
}
|
||||
modelProxyClient = await loadManagementClient({ configDir: org ? instanceConfigDir : organization.dir, proxy, receipt: proxyReceipt });
|
||||
proxyPort = proxy.port;
|
||||
}
|
||||
|
||||
// Row 234 (#1740, r6 X4 third bullet): each host grant record gives its agent a store reference to
|
||||
@@ -401,6 +404,20 @@ export async function startBroker({
|
||||
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
|
||||
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
|
||||
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
|
||||
// #1873 CB2 (security review P2): a copy slot an issue staged before a crash, whose binding holds
|
||||
// no grant for it, goes before Credentials loads and before any issue can run.
|
||||
if (storeRoot) {
|
||||
const reader = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot });
|
||||
const documents = Object.keys(businesses).map((id) => {
|
||||
try {
|
||||
return [id, reader.read(id).document];
|
||||
} catch {
|
||||
return [id, null];
|
||||
}
|
||||
});
|
||||
for (const entry of sweepCopySlots(storeRoot, documents, references))
|
||||
process.stderr.write(`copies: ${JSON.stringify({ event: 'copy-slot-swept', ...entry })}\n`);
|
||||
}
|
||||
// #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials
|
||||
// refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when
|
||||
// any reference names the store. A root that exists with loose modes or as a symlink still refuses.
|
||||
@@ -790,6 +807,11 @@ export async function startBroker({
|
||||
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
|
||||
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
|
||||
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
|
||||
// #1873 CB2: credentials.issue for an agent copy's bound Gitea service, verified live at the server.
|
||||
copyGrant: organization && storeRoot
|
||||
? copyGrant({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }),
|
||||
copies, organizations: () => Object.keys(businesses) })
|
||||
: null,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
@@ -929,9 +951,18 @@ export async function startBroker({
|
||||
proxyInit: organization ? async args => {
|
||||
const settings = await initializeProxy({ configDir: organization.dir, dataRoot: instanceRoot ?? dataRoot, ...args });
|
||||
modelProxyClient = await loadManagementClient({ configDir: organization.dir, proxy: settings, receipt: proxyReceipt });
|
||||
proxyPort = settings.port;
|
||||
// #1873: an org bus that initializes the shared proxy also holds its write lock from now on.
|
||||
if (org && !proxyLock) proxyLock = createProxyLock({ stateDir: join(instanceDataRoot, 'proxy'), identity: `${instanceConfigDir}:${settings.port}` });
|
||||
configureProxyPage();
|
||||
return settings;
|
||||
} : null,
|
||||
// #1873: clear the uncertain-write fence only on restart evidence this host re-proves: the
|
||||
// process on the proxy port is new, alive and started after the fence was set.
|
||||
proxyFenceClear: organization ? async ({ evidence }) => {
|
||||
if (!proxyLock || !modelProxyClient) throw new BusError('not-configured');
|
||||
return proxyLock.clear({ restarted: true, read: () => modelProxyClient.getKeys(), verify: previous => verifyRestart(evidence, { fence: previous, port: proxyPort }) });
|
||||
} : null,
|
||||
credentialVerbs: organization
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }),
|
||||
organizations: () => Object.keys(businesses), boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null })
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// #1873 CB2 live acceptance: credentials.issue for an agent copy's Gitea service against a scratch
|
||||
// gitea/gitea:1.27.1, a throwaway container on 127.0.0.1 over TLS with a scratch CA, removed after.
|
||||
// The test never pulls an image; without Docker or the image it skips. Every token here is a scratch
|
||||
// token this container made; none is printed, and nothing here reaches git.mosaicstack.dev or any
|
||||
// real service.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:https';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { copyGrant } from '../src/copy-grant.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
||||
import { materializeCopies, copiesView } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
||||
|
||||
const IMAGE = 'gitea/gitea:1.27.1';
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
||||
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
|
||||
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
|
||||
|
||||
const freePort = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once('error', reject);
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const { port } = s.address();
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
|
||||
// One HTTPS call to the scratch container, trusting only the scratch CA.
|
||||
function call(url, ca, method, path, { auth = null, body = null } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
|
||||
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('end', () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
resolve({ status: res.statusCode, json });
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.on('error', () => resolve({ status: 0, json: null }));
|
||||
req.end(data ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('CB2-D: on scratch 1.27.1 an owner-issued copy token is proved live: equal and narrower bind; broader, all, admin, foreign, unrelated and opaque refuse', { skip, timeout: 300000 }, async (t) => {
|
||||
// Capture the child's complete TAP stdout/stderr before any of it reaches this runner, so the parent
|
||||
// can search for the actual minted values without putting one in an assertion.
|
||||
if (!process.env.CB2_COPY_CAPTURE) {
|
||||
const capture = mkdtempSync(join(tmpdir(), 'cb2cap-'));
|
||||
t.after(() => rmSync(capture, { recursive: true, force: true }));
|
||||
const file = join(capture, 'minted');
|
||||
const childEnv = { ...process.env, CB2_COPY_CAPTURE: file };
|
||||
delete childEnv.NODE_TEST_CONTEXT;
|
||||
const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^CB2-D:', fileURLToPath(import.meta.url)], {
|
||||
encoding: 'utf8', timeout: 280000, env: childEnv,
|
||||
});
|
||||
const values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
|
||||
const output = (child.stdout ?? '') + (child.stderr ?? '');
|
||||
assert.ok(values.every((value) => !output.includes(value) && !output.includes(sha(value))), 'complete TAP stdout/stderr contain no minted token');
|
||||
t.diagnostic(output);
|
||||
t.diagnostic(`minted ${values.length}, hits 0 (complete TAP stdout/stderr)`);
|
||||
assert.equal(child.status, 0, 'Docker child passed');
|
||||
assert.equal(values.length, 6, 'all six scratch tokens were minted: the acceptance actually ran');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cb2d-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const tlsDir = join(dir, 'tls'), tls = scratchCa(tlsDir);
|
||||
// The container's git user reads the scratch key; it is a throwaway key for this run only.
|
||||
chmodSync(tlsDir, 0o755);
|
||||
chmodSync(join(tlsDir, 'server.key'), 0o644);
|
||||
const ca = readFileSync(tls.ca, 'utf8'), port = await freePort();
|
||||
const url = `https://127.0.0.1:${port}`, name = 'cb2-' + randomBytes(6).toString('hex');
|
||||
t.after(() => docker('rm', '-f', name));
|
||||
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
|
||||
'-e', 'GITEA__security__INSTALL_LOCK=true', '-e', 'GITEA__database__DB_TYPE=sqlite3',
|
||||
'-e', 'GITEA__service__DISABLE_REGISTRATION=true', '-e', 'GITEA__server__PROTOCOL=https',
|
||||
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem', '-e', 'GITEA__server__KEY_FILE=/certs/server.key',
|
||||
'-e', `GITEA__server__ROOT_URL=${url}/`, IMAGE);
|
||||
assert.equal(started.status, 0, 'scratch container starts');
|
||||
const end = Date.now() + 120000;
|
||||
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
|
||||
assert.ok(Date.now() < end, 'scratch Gitea answers in 120 seconds');
|
||||
await sleep(500);
|
||||
}
|
||||
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
|
||||
const minted = [];
|
||||
const createUser = (user, admin = false) => {
|
||||
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`,
|
||||
'--password', randomBytes(16).toString('base64url'), '--must-change-password=false', ...(admin ? ['--admin'] : []));
|
||||
assert.equal(r.status, 0, `create scratch ${user}`);
|
||||
};
|
||||
const mint = (user, scopes) => {
|
||||
const r = gitea('admin', 'user', 'generate-access-token', '--username', user, '--token-name', 'cb2-' + randomBytes(4).toString('hex'),
|
||||
'--scopes', scopes.join(','), '--raw');
|
||||
assert.equal(r.status, 0, `mint scratch ${user}`);
|
||||
const value = r.stdout.trim();
|
||||
assert.ok(/^[0-9a-f]{40}$/.test(value), 'minted token shape');
|
||||
minted.push(value);
|
||||
writeFileSync(process.env.CB2_COPY_CAPTURE, JSON.stringify(minted), { mode: 0o600 });
|
||||
return value;
|
||||
};
|
||||
createUser('scratch-admin', true);
|
||||
createUser('owner');
|
||||
const admin = 'token ' + mint('scratch-admin', ['write:admin', 'write:repository', 'write:user']);
|
||||
for (const repo of ['stack', 'other']) {
|
||||
const made = await call(url, ca, 'POST', '/api/v1/admin/users/owner/repos', { auth: admin, body: { name: repo, auto_init: true, private: true } });
|
||||
assert.equal(made.status, 201, `create owner/${repo}`);
|
||||
}
|
||||
|
||||
// The organization: one root agent "rev" whose copy binding approves write:repository and
|
||||
// read:user on project stack, whose remote is the scratch owner/stack.
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb2o-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
doc.projects.stack.remote = `${url}/owner/stack`;
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const dataRoot = join(root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
||||
await reserveOrg(ctx, 'demo');
|
||||
await activateOrg(ctx, 'demo');
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
|
||||
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
|
||||
t.after(() => store.close());
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => (snapshot.org === id ? snapshot : null) });
|
||||
const loaded = () => {
|
||||
const d = loadBusiness('demo', { dir: root, rolesDir });
|
||||
// The boot's copies view, as the runtime's loadWithCopies gives the broker.
|
||||
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])), copies: copiesView(snapshot.copies) };
|
||||
};
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
const issue = copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca, timeoutMs: 10000 });
|
||||
const b = new Broker({
|
||||
store, businesses: { demo: loaded() },
|
||||
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } },
|
||||
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a),
|
||||
});
|
||||
const consoleCap = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const request_ = (cap, verb, args) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
await request_(consoleCap, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy',
|
||||
binding: { services: { gitea: { scopes: ['write:repository', 'read:user'], projects: ['stack'] } } } });
|
||||
const rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings.rev;
|
||||
assert.equal(rec().state, 'pending');
|
||||
const slot = join(storeRoot, 'demo', 'rev', 'gitea');
|
||||
const issueToken = (token) => request_(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token });
|
||||
|
||||
// The copy's dedicated account: a read collaborator on owner/stack only.
|
||||
const login = copyBotLogin(rec().orgIncarnation, rec().copyId, 'gitea');
|
||||
createUser(login);
|
||||
const collaborate = (repo, permission) => call(url, ca, 'PUT', `/api/v1/repos/owner/${repo}/collaborators/${login}`, { auth: admin, body: { permission } });
|
||||
assert.equal((await collaborate('stack', 'read')).status, 204, 'bot is a read collaborator on owner/stack');
|
||||
|
||||
const refused = async (token, code, label) => {
|
||||
await assert.rejects(issueToken(token), { code }, label);
|
||||
assert.equal(rec().state, 'pending', label);
|
||||
assert.equal(rec().services.gitea.grantId, undefined, label);
|
||||
assert.ok(!existsSync(slot), `${label}: a refused token leaves no slot`);
|
||||
};
|
||||
|
||||
// Equal: the approved scopes exactly.
|
||||
const equal = mint(login, ['write:repository', 'read:user']);
|
||||
const self = await call(url, ca, 'GET', '/api/v1/token', { auth: 'token ' + equal });
|
||||
assert.equal(self.status, 200);
|
||||
assert.equal(self.json.user.login, login, 'Gitea 1.27.1 names the token owner on /api/v1/token');
|
||||
const one = await issueToken(equal);
|
||||
assert.equal(one.state, 'bound');
|
||||
assert.equal(one.account, login);
|
||||
assert.deepEqual([rec().state, rec().services.gitea.grantId, rec().services.gitea.grantGeneration], ['bound', `gitea:${self.json.user.id}:${self.json.id}`, 1]);
|
||||
assert.equal(statSync(slot).mode & 0o777, 0o600);
|
||||
assert.equal(readFileSync(slot, 'utf8'), equal + '\n');
|
||||
|
||||
// Narrower: read within the approved write.
|
||||
const narrow = mint(login, ['read:repository', 'read:user']);
|
||||
await issueToken(narrow);
|
||||
assert.deepEqual([rec().state, rec().services.gitea.grantGeneration], ['bound', 2]);
|
||||
assert.equal(readFileSync(slot, 'utf8'), narrow + '\n');
|
||||
|
||||
// Broader in one category, and "all": exceeded; a failed re-issue deactivates the binding.
|
||||
await refused(mint(login, ['write:repository', 'write:user']), 'credential-scope-exceeded', 'write:user beyond read:user');
|
||||
await refused(mint(login, ['all']), 'credential-scope-exceeded', 'all');
|
||||
// The administrator's own token, inside the approved scopes: not the copy's account.
|
||||
await refused(mint('scratch-admin', ['read:repository', 'read:user']), 'credential-identity-refused', 'admin account');
|
||||
// A token Gitea never issued.
|
||||
await refused(randomBytes(20).toString('hex'), 'credential-scope-unverified', 'opaque token');
|
||||
// An unrelated repository the account can also reach.
|
||||
assert.equal((await collaborate('other', 'read')).status, 204);
|
||||
await refused(narrow, 'credential-scope-exceeded', 'unrelated repository');
|
||||
|
||||
// Nothing kept names a token, its digest, the slot path or a request.
|
||||
const kept = [file, join(root, 'businesses/.history/demo/changes.jsonl')].filter(existsSync).map((p) => readFileSync(p, 'utf8')).join('\n')
|
||||
+ ['events', 'decisions'].map((tb) => JSON.stringify(store.all(`SELECT * FROM ${tb}`))).join('\n');
|
||||
for (const value of minted) assert.ok(!kept.includes(value) && !kept.includes(sha(value)) && !kept.includes(sha(value + '\n')), 'kept text holds no token');
|
||||
for (const bad of [slot, storeRoot, '/api/v1', 'authorization']) assert.ok(!kept.includes(bad), 'kept text holds no path or request');
|
||||
t.diagnostic(`minted ${minted.length}`);
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
// #1873 CB2 (design section 5 steps 4 to 6, section 8 row CB2): credentials.issue for an agent copy's
|
||||
// bound Gitea service, verified live against a fake Gitea before any grant metadata lands. The
|
||||
// organization writer's half is packages/organization/tests/copy-grant-writer.test.mjs; the real
|
||||
// server acceptance is copy-grant-docker.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { copyGrant, tokenLevels, verifyGiteaToken, boundRepositories, MAX_ROTATE_DAYS, sweepCopySlots } from '../src/copy-grant.mjs';
|
||||
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
||||
import { materializeCopies, copiesView } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||
const present = (p) => { try { lstatSync(p); return true; } catch { return false; } };
|
||||
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
async function fixture(t, { services = READ, edit = (d) => d } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb2-')), rolesDir = rolesCopy(root);
|
||||
const api = await fakeGiteaApi(t, root);
|
||||
const base = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
base.projects.stack.agents = { checker: { definition: 'reviewer' } };
|
||||
base.projects.stack.remote = `${api.url}/owner/stack`;
|
||||
const doc = edit(base);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
||||
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
|
||||
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => snapshot.org === id ? snapshot : null });
|
||||
const loaded = () => { const d = loadBusiness('demo', { dir: root, rolesDir }); return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])), copies: copiesView((opts.copies ?? snapshot).copies) }; };
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700);
|
||||
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
const hooks = {};
|
||||
const opts = { timeoutMs: 3000 };
|
||||
const issue = (...a) => copyGrant({ credentials, writer, copies: opts.copies ?? snapshot, organizations: () => ['demo'], ca: api.ca, timeoutMs: opts.timeoutMs, hooks })(...a);
|
||||
const direct = (args, ctx) => issue('demo', args, ctx);
|
||||
const b = new Broker({ store, businesses: { demo: loaded() }, hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } },
|
||||
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a) });
|
||||
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
const bind = (name = 'rev', svc = services, extra = {}) => call(owner, 'hire.start.console', { op: 'change', level: 'root', name, reason: 'Bind the copy', binding: { services: svc }, ...extra });
|
||||
await bind();
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const rec = (name = 'rev') => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.[name];
|
||||
const repo = api.addRepo('owner', 'stack');
|
||||
// The copy's dedicated account, a read collaborator on owner/stack, and one token from it.
|
||||
const bot = (name = 'rev', { perm = 'read', admin = false } = {}) => {
|
||||
const r = rec(name);
|
||||
const login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea');
|
||||
const u = api.users.get(login) ?? api.addUser(login, { is_admin: admin }).user;
|
||||
repo.collaborators.push(login); repo.permissions[login] = perm;
|
||||
return u;
|
||||
};
|
||||
const token = (u, scopes = ['read:repository', 'read:user'], o) => api.addToken(u.login, `t${api.issued.length}`, scopes, o);
|
||||
const slot = (name = 'rev') => join(storeRoot, 'demo', name, 'gitea');
|
||||
const issueAs = (cap, args) => call(cap, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), ...args });
|
||||
return { root, file, api, b, store, writer, credentials, hooks, opts, direct, owner, human, chief, call, bind, rec, bot, token, slot, repo, storeRoot, issueAs, loaded, snapshot };
|
||||
}
|
||||
|
||||
// Everything the bus or the organization keeps after an issue: the document, the receipts, the store.
|
||||
function kept(f) {
|
||||
const files = [f.file, join(f.root, 'businesses/.history/demo/changes.jsonl')].filter(existsSync).map((p) => readFileSync(p, 'utf8'));
|
||||
const rows = ['events', 'decisions'].map((tb) => JSON.stringify(f.store.all(`SELECT * FROM ${tb}`)));
|
||||
return files.join('\n') + rows.join('\n');
|
||||
}
|
||||
|
||||
test('CB2 an owner-issued copy token is verified live, staged and bound; nothing kept names the token or its path', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal(f.rec().state, 'pending');
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
const date = ahead(30);
|
||||
const result = await f.issueAs(f.human, { token: tok.sha1, date });
|
||||
assert.deepEqual(result, { agent: 'rev', service: 'gitea', verb: 'issue', date, state: 'bound', account: u.login, generation: result.generation });
|
||||
const r = f.rec();
|
||||
assert.equal(r.state, 'bound');
|
||||
assert.deepEqual(r.services.gitea, { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] }, rotateBy: date, grantId: `gitea:${u.id}:${tok.id}`, grantGeneration: 1 });
|
||||
assert.equal(statSync(f.slot()).mode & 0o777, 0o600);
|
||||
assert.equal(readFileSync(f.slot(), 'utf8'), tok.sha1 + '\n');
|
||||
// Only GETs went to Gitea, every one as the copy's account.
|
||||
assert.ok(f.api.requests.length > 0);
|
||||
for (const req of f.api.requests) {
|
||||
assert.equal(req.method, 'GET');
|
||||
if (req.path !== '/api/v1/version') assert.equal(req.as, u.login);
|
||||
}
|
||||
const text = kept(f) + JSON.stringify(result);
|
||||
for (const bad of [tok.sha1, sha(tok.sha1), sha(tok.sha1 + '\n'), f.slot(), f.storeRoot, '/api/v1', 'authorization']) assert.ok(!text.includes(bad), 'kept text holds a secret, path or request');
|
||||
// Copies still cannot launch in CB2: the snapshot does not resolve a gitea-only copy (CB3 owns launch).
|
||||
});
|
||||
|
||||
test('CB2 narrower and equal scopes are accepted; any broader token is exceeded and leaves the record inactive', async (t) => {
|
||||
const f = await fixture(t, { services: { gitea: { scopes: ['write:repository', 'read:user'], projects: ['stack'] } } });
|
||||
const u = f.bot('rev', { perm: 'write' });
|
||||
for (const scopes of [['write:repository', 'write:user'], ['all'], ['write:admin', 'read:user'], ['read:repository', 'read:user', 'read:issue'], ['read:user', 'read:misc']]) {
|
||||
const tok = f.token(u, scopes);
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' }, scopes.join());
|
||||
assert.equal(f.rec().state, 'pending', scopes.join());
|
||||
assert.equal(f.rec().services.gitea.grantId, undefined);
|
||||
assert.ok(!existsSync(f.slot()), 'a refused token leaves no slot');
|
||||
}
|
||||
// Equal.
|
||||
await f.issueAs(f.human, { token: f.token(u, ['write:repository', 'read:user']).sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
// Narrower (read within the approved write) re-issues; the grant generation counts up.
|
||||
f.repo.permissions[u.login] = 'read';
|
||||
const narrow = f.token(u, ['read:repository', 'read:user']);
|
||||
await f.issueAs(f.human, { token: narrow.sha1 });
|
||||
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration, f.rec().services.gitea.grantId], ['bound', 2, `gitea:${u.id}:${narrow.id}`]);
|
||||
// A failed re-issue after bound deactivates the binding and removes its slot.
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['all']).sha1 }), { code: 'credential-scope-exceeded' });
|
||||
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false]);
|
||||
});
|
||||
|
||||
test('CB2 read is narrower than write within a category; categories are independent; push needs write', async (t) => {
|
||||
assert.deepEqual(tokenLevels(['read:repository', 'write:repository', 'read:user']), { repository: 'write', user: 'read' });
|
||||
assert.deepEqual(tokenLevels(['write:issue', 'read:issue']), { issue: 'write' });
|
||||
for (const bad of [['all'], ['write:admin'], ['read:admin'], ['read:sudo'], ['repository'], [], null, [7]]) assert.equal(tokenLevels(bad), null, JSON.stringify(bad));
|
||||
const f = await fixture(t);
|
||||
// The binding approved read:repository; the account can push, so the token reaches more than read.
|
||||
const u = f.bot('rev', { perm: 'write' });
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-scope-exceeded' });
|
||||
// write:issue is another category: read:repository never approves it.
|
||||
f.repo.permissions[u.login] = 'read';
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['read:repository', 'read:user', 'write:issue']).sha1 }), { code: 'credential-scope-exceeded' });
|
||||
await f.issueAs(f.human, { token: f.token(u).sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 push is held to the binding, not the token: a narrower token binds for a push account on a write binding', async () => {
|
||||
const push = { '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: true, pull: true } }], total: 1 } };
|
||||
await assert.rejects(V(scripted(push).transport), { code: 'credential-scope-exceeded' });
|
||||
const write = { approved: ['write:repository', 'read:user'] };
|
||||
assert.deepEqual(await V(scripted(push).transport, write), { userId: 5, tokenId: 9, login: 'bot' });
|
||||
const writeToken = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['write:repository', 'read:user'], user: { id: 5, login: 'bot' } } } };
|
||||
assert.deepEqual(await V(scripted({ ...push, ...writeToken }).transport, write), { userId: 5, tokenId: 9, login: 'bot' });
|
||||
await assert.rejects(V(scripted({ ...push, ...writeToken }).transport), { code: 'credential-scope-exceeded' });
|
||||
});
|
||||
|
||||
test('CB2 the boot copy must still match the record: a missing, refused or changed copy refuses before anything is staged or sent', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const tok = f.token(f.bot());
|
||||
const rev = f.snapshot.copies.rev;
|
||||
const cases = [
|
||||
[{ ...f.snapshot, copies: {} }, 'copy-unavailable'],
|
||||
[{ ...f.snapshot, org: 'other' }, 'copy-unavailable'],
|
||||
[{ ...f.snapshot, copies: { rev: { ...rev, state: 'refused' } } }, 'copy-unavailable'],
|
||||
[{ ...f.snapshot, copies: { rev: { ...rev, level: 'project', project: 'stack' } } }, 'credential-binding-changed'],
|
||||
[{ ...f.snapshot, copies: { rev: { ...rev, name: 'other' } } }, 'credential-binding-changed'],
|
||||
[{ ...f.snapshot, copies: { rev: { ...rev, definition: 'coder' } } }, 'credential-binding-changed'],
|
||||
];
|
||||
const before = kept(f), sent = f.api.requests.length;
|
||||
for (const [copies, code] of cases) {
|
||||
f.opts.copies = copies;
|
||||
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code }, JSON.stringify(copies.copies?.rev ?? copies.org));
|
||||
}
|
||||
f.opts.copies = null;
|
||||
// No record for the name, or a name the organization's own roles hold.
|
||||
await assert.rejects(f.direct({ agent: 'nobody', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'credential-unbound' });
|
||||
f.opts.copies = { ...f.snapshot, copies: { ...f.snapshot.copies, chief: { ...rev, name: 'chief' } } };
|
||||
const { document } = f.writer.read('demo');
|
||||
const shadow = { ...f.writer, read: () => ({ ...f.writer.read('demo'), document: { ...document, copyBindings: { ...document.copyBindings, chief: document.copyBindings.rev } } }) };
|
||||
await assert.rejects(copyGrant({ credentials: f.credentials, writer: shadow, copies: f.opts.copies, organizations: () => ['demo'] })('demo', { agent: 'chief', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'copy-unavailable' });
|
||||
f.opts.copies = null;
|
||||
assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo'))], [true, sent, false]);
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 stageCopy refuses a name a loaded reference holds and an unknown service, leaving the file alone', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb2-ref-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const storeRoot = join(root, 'agents');
|
||||
mkdirSync(join(storeRoot, 'demo', 'rev'), { recursive: true, mode: 0o700 }); chmodSync(storeRoot, 0o700); chmodSync(join(storeRoot, 'demo'), 0o700);
|
||||
const file = join(storeRoot, 'demo', 'rev', 'gitea');
|
||||
writeFileSync(file, 'r'.repeat(40) + '\n', { mode: 0o600 });
|
||||
const credentials = new Credentials({ references: { 'demo/rev': { gitea: { service: 'gitea', store: true, file, rotateBy: ahead(30) } } }, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
assert.throws(() => credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)), { code: 'credential-reference' });
|
||||
assert.equal(readFileSync(file, 'utf8'), 'r'.repeat(40) + '\n');
|
||||
assert.throws(() => credentials.stageCopy('demo/other', 'model', 'a'.repeat(40)), { code: 'credential-reference' });
|
||||
assert.equal(present(join(storeRoot, 'demo', 'other')), false);
|
||||
});
|
||||
|
||||
test('CB2 a Vikunja binding refuses credential-scope-unverified before anything is staged or sent', async (t) => {
|
||||
const vik = { tasks: ['read_one'] };
|
||||
const f = await fixture(t, { services: { gitea: READ.gitea, vikunja: { scopes: vik, projects: ['stack'] } } });
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
await assert.rejects(f.issueAs(f.human, { service: 'vikunja', token: 'v'.repeat(40) }), { code: 'credential-scope-unverified' });
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.equal(f.api.requests.length, 0);
|
||||
assert.ok(!existsSync(join(f.storeRoot, 'demo', 'rev')));
|
||||
// With a Gitea grant the record still waits on Vikunja: pending, never bound.
|
||||
await f.issueAs(f.human, { token: f.token(f.bot()).sha1 });
|
||||
assert.equal(f.rec().state, 'pending');
|
||||
assert.ok(f.rec().services.gitea.grantId);
|
||||
assert.ok(REFUSALS['credential-scope-unverified'].nextStep.includes('Vikunja'));
|
||||
});
|
||||
|
||||
test('CB2 unrelated repositories, admin rights or a missing bound repository refuse', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
const other = f.api.addRepo('someone', 'private', { collaborators: [u.login] });
|
||||
other.permissions[u.login] = 'read';
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' });
|
||||
other.collaborators.length = 0;
|
||||
const own = f.api.addRepo(u.login, 'scratch');
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' });
|
||||
f.api.repos.splice(f.api.repos.indexOf(own), 1);
|
||||
f.repo.collaborators.length = 0;
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
|
||||
assert.equal(f.rec().state, 'pending');
|
||||
f.repo.collaborators.push(u.login);
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
// A scripted Gitea for the verifier's own branches: paging, organizations and malformed answers.
|
||||
function scripted(over = {}) {
|
||||
const base = {
|
||||
'/api/v1/version': { status: 200, json: { version: '1.27.1' } },
|
||||
'/api/v1/token': { status: 200, json: { id: 9, scopes: ['read:repository', 'read:user'], user: { id: 5, login: 'bot' } } },
|
||||
'/api/v1/user': { status: 200, json: { id: 5, login: 'bot', is_admin: false } },
|
||||
'/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: false, pull: true } }], total: 1 },
|
||||
'/api/v1/user/orgs': { status: 200, json: [], total: 0 },
|
||||
...over,
|
||||
};
|
||||
const seen = [];
|
||||
const transport = async ({ path, method }) => {
|
||||
seen.push(`${method} ${path}`);
|
||||
const key = path.split('?')[0];
|
||||
const a = base[key];
|
||||
return typeof a === 'function' ? a(path) : { total: null, ...a };
|
||||
};
|
||||
return { transport, seen };
|
||||
}
|
||||
const V = (transport, extra = {}) => verifyGiteaToken({ transport, url: 'https://127.0.0.1:1', token: 'x'.repeat(40), approved: ['read:repository', 'read:user', 'read:organization'], repos: new Set(['owner/stack']), login: 'bot', rotateBy: ahead(30), ...extra });
|
||||
|
||||
test('CB2 verifier: paging must agree, organizations are refused with an organization scope', async () => {
|
||||
assert.deepEqual(await V(scripted().transport), { userId: 5, tokenId: 9, login: 'bot' });
|
||||
const pages = (p1, p2) => (path) => (path.includes('page=1') ? p1 : p2);
|
||||
const repo = (n) => ({ full_name: `owner/${n}`, permissions: { admin: false, push: false, pull: true } });
|
||||
const two = new Set(['owner/stack', 'owner/b']);
|
||||
const many = Array.from({ length: 50 }, (_, i) => repo(`r${i}`));
|
||||
const fifty = new Set(many.map((r) => r.full_name.toLowerCase()));
|
||||
// A total that changes between pages, a page that comes back short, and no total at all.
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': pages({ status: 200, json: many, total: 51 }, { status: 200, json: [repo('x')], total: 52 }) }).transport, { repos: fifty }), { code: 'credential-scope-unverified' });
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': pages({ status: 200, json: many, total: 52 }, { status: 200, json: [], total: 52 }) }).transport, { repos: fifty }), { code: 'credential-scope-unverified' });
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack')], total: null } }).transport), { code: 'credential-scope-unverified' });
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack'), repo('b')], total: 2 } }).transport), { code: 'credential-scope-exceeded' });
|
||||
assert.ok(await V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack'), repo('b')], total: 2 } }).transport, { repos: two }));
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { push: false, pull: true } }], total: 1 } }).transport), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack' }], total: 1 } }).transport), { code: 'credential-scope-unverified' });
|
||||
// Organizations: only asked when the token has an organization level.
|
||||
const plain = scripted({ '/api/v1/user/orgs': { status: 200, json: [{ id: 1, username: 'acme' }], total: 1 } });
|
||||
assert.ok(await V(plain.transport));
|
||||
assert.ok(!plain.seen.some((s) => s.includes('/user/orgs')));
|
||||
const orgTok = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['read:organization', 'read:repository', 'read:user'], user: { id: 5, login: 'bot' } } } };
|
||||
assert.ok(await V(scripted(orgTok).transport));
|
||||
await assert.rejects(V(scripted({ ...orgTok, '/api/v1/user/orgs': { status: 200, json: [{ id: 1, username: 'acme' }], total: 1 } }).transport), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(V(scripted({ ...orgTok, '/api/v1/user/orgs': { status: 500, json: null } }).transport), { code: 'credential-scope-unverified' });
|
||||
// The bound projects' remotes: one https Gitea origin, agreeing remotes.
|
||||
const doc = (projects) => ({ projects });
|
||||
assert.deepEqual([...boundRepositories(doc({ a: { remote: 'https://127.0.0.1:9/Owner/Stack' } }), ['a']).repos], ['owner/stack']);
|
||||
for (const [d, ps] of [[doc({ a: { remote: 'http://127.0.0.1:9/o/r' } }), ['a']], [doc({ a: {} }), ['a']], [doc({}), ['a']], [doc({ a: { remote: 'https://127.0.0.1:9/o/r' }, b: { remote: 'https://127.0.0.2:9/o/r' } }), ['a', 'b']], [doc({}), []],
|
||||
[doc({ a: { remote: 'https://127.0.0.1:9/o/r', git: { mode: 'remote', remote: 'https://127.0.0.1:9/o/s' } } }), ['a']]])
|
||||
assert.throws(() => boundRepositories(d, ps), { code: 'credential-scope-unverified' });
|
||||
});
|
||||
|
||||
test('CB2 a foreign account, an administrator or another copy\'s token is identity-refused', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const foreign = f.api.addUser('someone-else').user;
|
||||
f.repo.collaborators.push('someone-else');
|
||||
for (const tok of [f.token(foreign), { sha1: f.api.adminToken }]) {
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-identity-refused' });
|
||||
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
||||
}
|
||||
// The copy's own login, made an administrator.
|
||||
u.is_admin = true;
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-identity-refused' });
|
||||
u.is_admin = false;
|
||||
// /user names another account than /token did.
|
||||
const undo = f.api.rule((e) => e.path === '/api/v1/user', { status: 200, json: { id: u.id + 1000, login: u.login, is_admin: false } });
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-identity-refused' });
|
||||
undo();
|
||||
// Another copy's account and token: checker.stack is bound in the same organization.
|
||||
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: READ } });
|
||||
const checker = f.bot('checker.stack');
|
||||
const theirs = f.token(checker);
|
||||
await assert.rejects(f.issueAs(f.human, { token: theirs.sha1 }), { code: 'credential-identity-refused' });
|
||||
await f.issueAs(f.human, { agent: 'checker.stack', token: theirs.sha1 });
|
||||
assert.equal(f.rec('checker.stack').state, 'bound');
|
||||
assert.equal(f.rec().state, 'pending');
|
||||
assert.notEqual(checker.login, u.login);
|
||||
});
|
||||
|
||||
test('CB2 an opaque or unverifiable token refuses unverified; a malformed one refuses credential-format', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
// A malformed token refuses before anything is staged or sent (build review 2): the bound record is
|
||||
// unchanged, nothing reached Gitea and no slot exists.
|
||||
const before = kept(f), sent = f.api.requests.length;
|
||||
await assert.rejects(f.issueAs(f.human, { token: 'short' }), { code: 'credential-format' });
|
||||
await assert.rejects(f.issueAs(f.human, { token: 'has space ' + 'x'.repeat(30) }), { code: 'credential-format' });
|
||||
assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo', 'rev'))], [true, sent, false]);
|
||||
await assert.rejects(f.issueAs(f.human, { token: 'f'.repeat(40) }), { code: 'credential-scope-unverified' });
|
||||
const tok = f.token(u);
|
||||
for (const answer of [{ status: 200, json: { id: tok.id, scopes: tok.scopes } }, { status: 200, json: { id: tok.id, scopes: tok.scopes, user: { login: u.login } } }, { status: 500, json: null }, { drop: true }]) {
|
||||
const undo = f.api.rule((e) => e.path === '/api/v1/token', answer);
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' }, JSON.stringify(answer));
|
||||
undo();
|
||||
}
|
||||
f.opts.timeoutMs = 300;
|
||||
const hang = f.api.rule((e) => e.path.startsWith('/api/v1/user/repos'), { hang: true });
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
|
||||
hang();
|
||||
f.opts.timeoutMs = 3000;
|
||||
f.api.state.version = '1.26.0';
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
|
||||
f.api.state.version = '1.27.1';
|
||||
// The token lacks read:user, which the binding approved: its repositories cannot be listed.
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['read:repository']).sha1 }), { code: 'credential-scope-unverified' });
|
||||
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 a token any definition already holds refuses before anything is staged or sent', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const tok = f.token(f.bot());
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
// The boot copy snapshot's definitions are live text for the bus's lifetime.
|
||||
f.opts.copies = { ...f.snapshot, definitions: { ...f.snapshot.definitions, leaky: { note: `see ${tok.sha1}` } } };
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'definition-holds-credential' });
|
||||
assert.deepEqual([readFileSync(f.file, 'utf8') === before, existsSync(f.slot()), f.api.requests.length], [true, false, 0]);
|
||||
f.opts.copies = null;
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 a binding without read:user is unverifiable before anything is staged', async (t) => {
|
||||
const f = await fixture(t, { services: { gitea: { scopes: ['read:repository'], projects: ['stack'] } } });
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(f.bot()).sha1 }), { code: 'credential-scope-unverified' });
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.equal(f.api.requests.length, 0);
|
||||
});
|
||||
|
||||
test('CB2 dates: the rotation date is after today, at most a year ahead, and not after the token expires', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
for (const date of [ahead(0), ahead(-1), ahead(MAX_ROTATE_DAYS + 1), '2099-02-30', '2099-1-01', 'tomorrow'])
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1, date }), { code: 'credential-date' }, date);
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.equal(f.api.requests.length, 0);
|
||||
const soon = f.token(u, undefined, { expires: `${ahead(10)}T00:00:00Z` });
|
||||
await assert.rejects(f.issueAs(f.human, { token: soon.sha1, date: ahead(10) }), { code: 'credential-date' });
|
||||
await assert.rejects(f.issueAs(f.human, { token: soon.sha1, date: ahead(30) }), { code: 'credential-date' });
|
||||
const odd = f.token(u, undefined, { expires: 'not-a-date' });
|
||||
await assert.rejects(f.issueAs(f.human, { token: odd.sha1 }), { code: 'credential-scope-unverified' });
|
||||
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
||||
await f.issueAs(f.human, { token: soon.sha1, date: ahead(9) });
|
||||
assert.equal(f.rec().services.gitea.rotateBy, ahead(9));
|
||||
await f.issueAs(f.human, { token: f.token(u, undefined, { expires: `${ahead(400)}T00:00:00Z` }).sha1, date: ahead(MAX_ROTATE_DAYS) });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 the slot is read back by identity: symlinks, hard links, stale and replaced files refuse credential-file', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
const other = f.token(u);
|
||||
const elsewhere = join(f.root, 'elsewhere');
|
||||
const cases = {
|
||||
symlink: ['afterStage', () => { writeFileSync(elsewhere, tok.sha1 + '\n', { mode: 0o600 }); unlinkSync(f.slot()); symlinkSync(elsewhere, f.slot()); }],
|
||||
hardlink: ['afterStage', () => { rmSync(elsewhere, { force: true }); linkSync(f.slot(), elsewhere); }],
|
||||
stale: ['afterStage', () => { renameSync(f.slot(), elsewhere); writeFileSync(f.slot(), tok.sha1 + '\n', { mode: 0o600 }); }],
|
||||
mode: ['afterStage', () => chmodSync(f.slot(), 0o644)],
|
||||
// A writer replaces a file by renaming a new one over it, as the store does.
|
||||
replacedAfterRemote: ['afterRemote', () => { writeFileSync(elsewhere, other.sha1 + '\n', { mode: 0o600 }); renameSync(elsewhere, f.slot()); }],
|
||||
replacedBeforeCommit: ['beforeCommit', () => { writeFileSync(elsewhere, other.sha1 + '\n', { mode: 0o600 }); renameSync(elsewhere, f.slot()); }],
|
||||
// Unlinked and written again: the inode number may be reused, the content still differs.
|
||||
reusedInode: ['afterStage', () => { unlinkSync(f.slot()); writeFileSync(f.slot(), other.sha1 + '\n', { mode: 0o600 }); }],
|
||||
rewrittenInPlace: ['beforeCommit', () => writeFileSync(f.slot(), other.sha1 + '\n')],
|
||||
};
|
||||
for (const [name, [hook, act]] of Object.entries(cases)) {
|
||||
rmSync(elsewhere, { force: true });
|
||||
f.hooks[hook] = act;
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-file' }, name);
|
||||
delete f.hooks[hook];
|
||||
assert.equal(f.rec().state, 'pending', name);
|
||||
assert.equal(f.rec().services.gitea.grantId, undefined, name);
|
||||
// The bus removes only the file it wrote; a file someone else put there stays.
|
||||
if (['symlink', 'stale', 'replacedAfterRemote', 'replacedBeforeCommit'].includes(name)) assert.ok(present(f.slot()), name);
|
||||
if (['hardlink', 'mode', 'rewrittenInPlace'].includes(name)) assert.ok(!present(f.slot()), name);
|
||||
rmSync(f.slot(), { force: true });
|
||||
}
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 crash points leave the record pending and the slot removed; a new verified issue recovers', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
for (const hook of ['afterStage', 'afterRemote', 'beforeCommit']) {
|
||||
f.hooks[hook] = () => { throw new Error('crash'); };
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'outcome-unknown' }, hook);
|
||||
delete f.hooks[hook];
|
||||
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false], hook);
|
||||
}
|
||||
// A hard crash after the stage (security review P2): the record is pending and the staged token is
|
||||
// on disk. The next start's sweep removes it before anything loads; the record stays pending and
|
||||
// only a fresh verified issue binds.
|
||||
const r = f.rec();
|
||||
const { revision } = f.writer.read('demo');
|
||||
f.writer.stageCopyGrant('demo', { instance: 'rev', service: 'gitea', copyId: r.copyId, generation: r.generation, decision: r.decision, incarnation: r.orgIncarnation }, { revision, actor: 'jason', owner: true });
|
||||
f.credentials.stageCopy('demo/rev', 'gitea', tok.sha1);
|
||||
assert.equal(f.rec().state, 'pending');
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', f.writer.read('demo').document]], {}), [{ instance: 'demo/rev', service: 'gitea' }]);
|
||||
assert.deepEqual([f.rec().state, present(f.slot())], ['pending', false]);
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 1]);
|
||||
// A lost reply: the commit landed but the caller never heard. Issuing again re-verifies.
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 2]);
|
||||
});
|
||||
|
||||
test('CB2 the start sweep removes only ungranted copy slots and staged temp files', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
const doc = () => [['demo', f.writer.read('demo').document]];
|
||||
const copyDir = join(f.storeRoot, 'demo', 'rev');
|
||||
// A granted slot stays; an interrupted write's temp file goes; a name that is no service stays.
|
||||
writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 });
|
||||
writeFileSync(join(copyDir, 'notes'), 'x', { mode: 0o600 });
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'gitea' }]);
|
||||
assert.deepEqual([present(f.slot()), present(join(copyDir, 'gitea.tmp')), present(join(copyDir, 'notes'))], [true, false, true]);
|
||||
// An ungranted service of a bound copy goes; a slot a loaded reference names stays.
|
||||
writeFileSync(join(copyDir, 'vikunja'), 'x', { mode: 0o600 });
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), { 'demo/rev': { vikunja: { service: 'vikunja' } } }), []);
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'vikunja' }]);
|
||||
// A role's own slots and an unreadable organization are never touched.
|
||||
const roleDir = join(f.storeRoot, 'demo', 'chief');
|
||||
mkdirSync(roleDir, { mode: 0o700 }); writeFileSync(join(roleDir, 'gitea'), 'x', { mode: 0o600 });
|
||||
const edited = { ...f.writer.read('demo').document };
|
||||
edited.copyBindings = { ...edited.copyBindings, chief: { ...edited.copyBindings.rev, services: {} } };
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', edited]], {}), []);
|
||||
assert.equal(present(join(roleDir, 'gitea')), true);
|
||||
writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 });
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', null]], {}), []);
|
||||
assert.equal(present(join(copyDir, 'gitea.tmp')), true);
|
||||
// No store, a missing org dir: nothing. A store dir that isn't private refuses as Credentials would.
|
||||
assert.deepEqual(sweepCopySlots(null, doc(), {}), []);
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, [['other', f.writer.read('demo').document]], {}), []);
|
||||
chmodSync(copyDir, 0o755);
|
||||
assert.throws(() => sweepCopySlots(f.storeRoot, doc(), {}), /credential-file/);
|
||||
chmodSync(copyDir, 0o700);
|
||||
});
|
||||
|
||||
test('CB2 a stage write that fails leaves no staged token behind', async (t) => {
|
||||
const f = await fixture(t);
|
||||
// The slot path is a non-empty directory: the rename fails after the temp file holds the token.
|
||||
mkdirSync(join(f.slot(), 'x'), { recursive: true, mode: 0o700 });
|
||||
assert.throws(() => f.credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)));
|
||||
assert.deepEqual(readdirSync(join(f.storeRoot, 'demo', 'rev')), ['gitea']);
|
||||
});
|
||||
|
||||
test('CB2 a rebind, a failed owner recheck or a concurrent issue during the remote work refuses the commit', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
// The owner approves a new binding while Gitea is being asked.
|
||||
f.hooks.afterRemote = () => f.bind('rev', { gitea: { scopes: ['read:user', 'read:repository'], projects: ['stack'] } });
|
||||
// The document moved under the commit: its revision check refuses before the binding fence.
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'revision-conflict' });
|
||||
delete f.hooks.afterRemote;
|
||||
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
||||
// The caller's owner recheck fails after the remote work (the broker's #copyOwner), and again
|
||||
// when it fails only under the writer's locks.
|
||||
for (const at of [1, 2]) {
|
||||
let n = 0;
|
||||
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true, recheck: () => { if (++n >= at) throw new BusError('human-required'); } }), { code: 'human-required' });
|
||||
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
||||
}
|
||||
// Not the owner, or the tester's name, never reaches the writer.
|
||||
for (const ctx of [{ actor: 'jason', owner: false }, { actor: 'tester', owner: true }])
|
||||
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, ctx), { code: 'human-required' });
|
||||
// Two issues: the first is held after its remote work while the second binds.
|
||||
const second = f.token(u);
|
||||
let release, first = true;
|
||||
const held = new Promise((r) => { release = r; });
|
||||
f.hooks.afterRemote = () => { if (first) { first = false; return held; } };
|
||||
const a = f.issueAs(f.human, { token: tok.sha1 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
while (f.hooks.afterRemote && first) await new Promise((r) => setTimeout(r, 10));
|
||||
await f.issueAs(f.human, { token: second.sha1 });
|
||||
release();
|
||||
await assert.rejects(a, (e) => ['revision-conflict', 'credential-binding-changed', 'credential-file'].includes(e.code));
|
||||
delete f.hooks.afterRemote;
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
assert.equal(f.rec().services.gitea.grantId, `gitea:${u.id}:${second.id}`);
|
||||
assert.equal(readFileSync(f.slot(), 'utf8'), second.sha1 + '\n', 'the second issue\'s slot stays');
|
||||
});
|
||||
|
||||
test('CB2 only the organization owner issues: the chief, the tester, a reader and an org.hire member are refused before staging', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const tok = f.token(f.bot());
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
const member = (owner) => f.b.bindHireConsole({ business: 'demo', actor: 'member', subject: { ...SUBJECT, sub: 'member' }, rights: ['org.hire', 'decide'], owner, instanceOwner: true });
|
||||
const got = [];
|
||||
for (const cap of [f.chief, f.b.bindTester({ business: 'demo' }), f.b.bindReader({ business: 'demo' }), member(false), member(true), f.owner]) {
|
||||
await assert.rejects(f.issueAs(cap, { token: tok.sha1 }), (e) => { got.push(e.code); return !String(e.message).includes(tok.sha1); });
|
||||
}
|
||||
// The Console caps, the owner's included, never reach credentials.issue: the token comes from the
|
||||
// owner's CLI on standard input.
|
||||
assert.deepEqual(got, ['human-required', 'tester-refused', 'human-required', 'human-required', 'human-required', 'human-required']);
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.equal(f.api.requests.length, 0);
|
||||
assert.ok(!existsSync(join(f.storeRoot, 'demo')));
|
||||
// Only a name the boot's copies view holds goes to the copy verifier; any other unknown name
|
||||
// answers credential-unknown as before, and nothing is staged or sent.
|
||||
await assert.rejects(f.issueAs(f.human, { agent: 'nobody', token: tok.sha1 }), { code: 'credential-unknown' });
|
||||
assert.deepEqual([readFileSync(f.file, 'utf8') === before, f.api.requests.length, existsSync(join(f.storeRoot, 'demo'))], [true, 0, false]);
|
||||
});
|
||||
|
||||
test('CB2 refusal table: the new codes have rails and next steps that name no path', () => {
|
||||
for (const code of ['credential-scope-unverified', 'credential-identity-refused', 'credential-date', 'credential-format', 'credential-file', 'copy-unavailable']) {
|
||||
const g = REFUSALS[code];
|
||||
assert.ok(g && g.rail.length && g.nextStep.length, code);
|
||||
assert.doesNotMatch(g.nextStep, /\/|~|\.json\b|secrets/, code);
|
||||
}
|
||||
});
|
||||
@@ -4,13 +4,15 @@
|
||||
// scratch root only.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { definitionPin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { readTemplate } from '../../organization/src/definitions.mjs';
|
||||
|
||||
const TOKEN = 'fake_issued_token_0123456789';
|
||||
const ROTATED = 'fake_rotated_token_9876543210';
|
||||
@@ -18,16 +20,18 @@ const OTHER = 'fake_other_token_5555555555';
|
||||
const DATE = '2099-01-01';
|
||||
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
|
||||
async function setup(t, { storeRoot = true } = {}) {
|
||||
async function setup(t, { storeRoot = true, before = () => {}, edit = () => {} } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
edit(doc, rolesDir);
|
||||
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
|
||||
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
|
||||
before(store);
|
||||
// The bus's resolved business carries the same references; a store one names its derived file.
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
@@ -187,3 +191,26 @@ test('#1886: a store root that exists with loose modes still refuses start', asy
|
||||
};
|
||||
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
|
||||
});
|
||||
|
||||
test('CB2 security review P2: the start sweep removes a copy slot a crashed issue left staged', async (t) => {
|
||||
// A copy binding left pending by an issue killed after its stage, its token and a temp file on disk.
|
||||
const pending = (doc, rolesDir) => {
|
||||
doc.copyBindings = { rev: {
|
||||
copyId: 'c'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'root', name: 'rev', project: null },
|
||||
definition: definitionPin('reviewer', { definitions: {}, rolesDir, readTemplate }), generation: 2, state: 'pending',
|
||||
services: { gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] } } },
|
||||
decision: '11111111-1111-4111-8111-111111111111' } };
|
||||
};
|
||||
const staged = (store) => {
|
||||
mkdirSync(join(store('rev', 'gitea'), '..'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(store('rev', 'gitea'), '..', '..'), 0o700);
|
||||
for (const name of ['gitea', 'gitea.tmp']) writeFileSync(store('rev', name), TOKEN + '\n', { mode: 0o600 });
|
||||
mkdirSync(join(store('coder', 'gitea'), '..'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(store('coder', 'gitea'), TOKEN + '\n', { mode: 0o600 });
|
||||
};
|
||||
const c = await setup(t, { edit: pending, before: staged });
|
||||
assert.equal(c.file().copyBindings.rev.state, 'pending');
|
||||
assert.deepEqual(readdirSync(join(c.store('rev', 'gitea'), '..')), []);
|
||||
// A role's own store slot is the store's, not the sweep's.
|
||||
assert.equal(existsSync(c.store('coder', 'gitea')), true);
|
||||
});
|
||||
@@ -38,13 +38,14 @@ export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic
|
||||
issued: [],
|
||||
// A user and, with `scopes`, one token named `tokenName`. Returns the user and that token or null.
|
||||
addUser(login, { is_admin = false, prohibit_login = false, full_name = login, email = `${login}@example.test`, scopes = null, tokenName = 'test' } = {}) {
|
||||
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [] };
|
||||
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [], orgs: [] };
|
||||
users.set(lower(login), u);
|
||||
return { user: u, token: scopes ? api.addToken(login, tokenName, scopes) : null };
|
||||
},
|
||||
addToken(login, name, scopes) {
|
||||
// #1873 CB2: `expires` (an ISO instant) is reported as the token's expires_at.
|
||||
addToken(login, name, scopes, { expires = null } = {}) {
|
||||
const u = users.get(lower(login));
|
||||
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort() };
|
||||
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort(), expires };
|
||||
u.tokens.push(tok);
|
||||
api.issued.push(tok.sha1);
|
||||
return tok;
|
||||
@@ -96,7 +97,10 @@ export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic
|
||||
if (!who) return [401, { message: 'unauthorized' }];
|
||||
const me = who.user;
|
||||
if (method === 'GET' && path === '/api/v1/user') return [200, userJson(me)];
|
||||
if (method === 'GET' && path === '/api/v1/token') return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes }];
|
||||
// Gitea 1.27 names the token's owner; #1873 CB2's verifier needs it.
|
||||
if (method === 'GET' && path === '/api/v1/token')
|
||||
return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes, user: { id: me.id, login: me.login }, ...(who.token.expires ? { expires_at: who.token.expires } : {}) }];
|
||||
if (method === 'GET' && path === '/api/v1/user/orgs') return [200, (me.orgs ?? []).map((name, i) => ({ id: 1000 + i, username: name })), (me.orgs ?? []).length];
|
||||
if (method === 'GET' && path === '/api/v1/user/repos') {
|
||||
const all = reachable(me);
|
||||
const limit = Number(query.get('limit') ?? 30);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// #1873 bus side of the Console proxy setup: proxy.init with a bus-generated key, the owner-only
|
||||
// proxy.fence.clear verb, and a lock clear that re-proves restart evidence before it clears.
|
||||
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 { Broker } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { createProxyLock } from '../src/proxy-lock.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { validManagementKey } from '../../model-proxy/src/common.mjs';
|
||||
|
||||
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const code = name => error => error.code === name;
|
||||
function fixture(t, { proxyInit = null, proxyFenceClear = null } = {}) {
|
||||
const store = new Store(fs.mkdtempSync(join(tmpdir(), 'ps-bus-')));
|
||||
t.after(() => store.close());
|
||||
const b = new Broker({ store, proxyInit, proxyFenceClear, businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
|
||||
return {
|
||||
store, b,
|
||||
human: b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }),
|
||||
tester: b.bindTester({ business: 'demo' }), reader: b.bindReader({ business: 'demo' }),
|
||||
agent: b.bindLaunch({ business: 'demo', role: 'coder', run: 'ps-run', harness: 'pi' }),
|
||||
};
|
||||
}
|
||||
const events = store => JSON.stringify(store.all('SELECT body FROM events'));
|
||||
const bodies = store => store.all('SELECT body FROM events').map(r => JSON.parse(r.body));
|
||||
|
||||
test('proxy.init generate:true makes a valid key on the bus, hands it only to init, and never returns or records it', async t => {
|
||||
const seen = [];
|
||||
const f = fixture(t, { proxyInit: async args => { seen.push(args); return { port: args.port ?? 18432, binary: '/fixture' }; } });
|
||||
const result = await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true, port: 18440, rotateGraceMinutes: 5 } });
|
||||
assert.deepEqual(result, { port: 18440, binary: '/fixture' });
|
||||
assert.equal(seen.length, 1);
|
||||
assert.deepEqual(Object.keys(seen[0]).sort(), ['key', 'port', 'rotateGraceMinutes']);
|
||||
assert.equal(validManagementKey(seen[0].key), true);
|
||||
assert.match(seen[0].key, /^[A-Za-z0-9_-]{64}$/, '48 CSPRNG bytes as base64url');
|
||||
assert.equal(JSON.stringify(result).includes(seen[0].key), false);
|
||||
assert.equal(events(f.store).includes(seen[0].key), false);
|
||||
assert.deepEqual(bodies(f.store).filter(b => b.call === 'proxy.init'), [{ call: 'proxy.init', port: 18440, generated: true }]);
|
||||
await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true } });
|
||||
assert.notEqual(seen[1].key, seen[0].key, 'each init generates a fresh key');
|
||||
});
|
||||
|
||||
test('proxy.init generate refuses agents and testers, and refuses a key with generate or generate other than true', async t => {
|
||||
let calls = 0;
|
||||
const f = fixture(t, { proxyInit: async () => { calls++; return { port: 18432, binary: '/fixture' }; } });
|
||||
for (const cap of [f.agent, f.tester]) assert.throws(() => f.b.request(cap, { verb: 'proxy.init', args: { generate: true } }), code('human-required'));
|
||||
assert.throws(() => f.b.request(f.reader, { verb: 'proxy.init', args: { generate: true } }), code('read-only'));
|
||||
for (const args of [{ generate: true, key: 'ps-sentinel-key-0001' }, { generate: false }, { generate: 'yes' }, {}])
|
||||
assert.throws(() => f.b.request(f.human, { verb: 'proxy.init', args }), code('invalid-request'), JSON.stringify(args));
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(events(f.store).includes('ps-sentinel-key-0001'), false);
|
||||
// The same verb the refusals guard does run for the owner.
|
||||
await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true } });
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('proxy.fence.clear is owner-only, passes the evidence through, and records only the pid', async t => {
|
||||
const seen = [];
|
||||
const evidence = { unit: 'mosaic-proxy.service', confirmed: true, before: { pid: 1 }, after: { pid: 2 }, observedAt: new Date(0).toISOString() };
|
||||
const f = fixture(t, { proxyFenceClear: async args => { seen.push(args); return { cleared: true, evidence: { after: { pid: 2 } } }; } });
|
||||
for (const cap of [f.agent, f.tester]) assert.throws(() => f.b.request(cap, { verb: 'proxy.fence.clear', args: { evidence } }), code('human-required'));
|
||||
assert.throws(() => f.b.request(f.reader, { verb: 'proxy.fence.clear', args: { evidence } }), code('read-only'));
|
||||
assert.throws(() => f.b.request(f.human, { verb: 'proxy.fence.clear', args: {} }), code('invalid-request'));
|
||||
assert.throws(() => f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence, restarted: true } }), code('invalid-request'));
|
||||
assert.equal(seen.length, 0);
|
||||
assert.deepEqual(await f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence } }), { cleared: true, evidence: { after: { pid: 2 } } });
|
||||
assert.deepEqual(seen, [{ evidence }]);
|
||||
assert.deepEqual(bodies(f.store).filter(b => b.call === 'proxy.fence.clear'), [{ call: 'proxy.fence.clear', cleared: true, pid: 2 }]);
|
||||
});
|
||||
|
||||
test('proxy.fence.clear maps a refusal to its proxy code and a missing runtime to not-configured', async t => {
|
||||
const f = fixture(t, { proxyFenceClear: async () => { throw Object.assign(Error('/home/x detail'), { code: 'proxy-restart-unconfirmed' }); } });
|
||||
await assert.rejects(f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence: {} } }), e => e.code === 'proxy-restart-unconfirmed' && !e.message.includes('/home/x'));
|
||||
const g = fixture(t);
|
||||
assert.throws(() => g.b.request(g.human, { verb: 'proxy.fence.clear', args: { evidence: {} } }), code('not-configured'));
|
||||
});
|
||||
|
||||
test('a lock clear with verify: the fence an uncertain write set clears only on proof, and the receipt holds the evidence', async t => {
|
||||
const dir = fs.mkdtempSync(join(tmpdir(), 'ps-lock-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const lock = createProxyLock({ stateDir: dir, identity: 'instance-one' });
|
||||
await assert.rejects(lock.run(async () => { lock.fence({ org: 'acme', op: 'apikey.add' }); throw Error('outcome unknown'); }));
|
||||
assert.throws(() => lock.assertCertain(), code('proxy-uncertain'));
|
||||
let read = 0;
|
||||
await assert.rejects(lock.clear({ restarted: true, read: async () => { read++; }, verify: () => { throw Object.assign(Error('no'), { code: 'proxy-restart-unconfirmed' }); } }), code('proxy-restart-unconfirmed'));
|
||||
assert.equal(read, 0, 'an unproved restart never reads or clears');
|
||||
assert.throws(() => lock.assertCertain(), code('proxy-uncertain'));
|
||||
const seenFence = [];
|
||||
const result = await lock.clear({ restarted: true, read: async () => { read++; }, verify: previous => { seenFence.push(previous); return { after: { pid: 42 } }; } });
|
||||
assert.deepEqual(result, { cleared: true, evidence: { after: { pid: 42 } } });
|
||||
assert.equal(seenFence[0].org, 'acme'); assert.equal(seenFence[0].op, 'apikey.add');
|
||||
lock.assertCertain();
|
||||
const receipt = fs.readFileSync(join(dir, 'mosaic-keys.receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
|
||||
assert.deepEqual(receipt.evidence, { after: { pid: 42 } });
|
||||
assert.equal(receipt.previous.org, 'acme');
|
||||
});
|
||||
|
||||
test('every #1873 refusal code has a refusal-table entry with a next step', () => {
|
||||
for (const c of ['proxy-admin-busy', 'proxy-key-transport', 'proxy-key-mismatch', 'proxy-restart-required', 'proxy-restart-unconfirmed', 'proxy-init-lock-held', 'proxy-init-lock-absent', 'proxy-unit-missing', 'proxy-unit-unhealthy', 'proxy-unit-failed', 'proxy-unit-exists', 'proxy-unit-path', 'proxy-unit-usage', 'proxy-install-missing', 'proxy-install-invalid', 'proxy-fetch-hash']) {
|
||||
const entry = REFUSALS[c];
|
||||
assert.ok(entry, c);
|
||||
assert.equal(typeof entry.nextStep, 'string', c);
|
||||
assert.ok(entry.nextStep.length > 20, c);
|
||||
assert.equal(/\/(home|mnt|srv|tmp)\//.test(entry.nextStep), false, `${c} names no absolute path`);
|
||||
}
|
||||
});
|
||||
@@ -63,7 +63,8 @@ export const USAGE = `usage:
|
||||
${LIMITS_USAGE}
|
||||
${PLUGINS_USAGE}
|
||||
${CREDENTIALS_USAGE}
|
||||
mosaic proxy fetch | fence status [--json] | fence clear --proxy-restarted | init [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
|
||||
mosaic proxy fetch | status [--json] | fence status [--json] | fence clear --proxy-restarted | fence clear --restart [--business ID] | init [--generate-key] [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
|
||||
mosaic proxy unit render|install|start|stop|restart|status [--json] | init-lock status|clear
|
||||
${HOSTS_USAGE}
|
||||
${GRANTS_USAGE}
|
||||
${ORG_INSTANCES_USAGE}
|
||||
@@ -795,7 +796,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
}
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 199 (#1697): issue and rotate read the token on stdin.
|
||||
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey });
|
||||
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey, ...(deps.proxyAdmin ? { admin: deps.proxyAdmin } : {}) });
|
||||
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 234 (#1740, r6 E4): the host records.
|
||||
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
|
||||
@@ -7,6 +7,9 @@ import { orgSelector } from './org-selector.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
import { fetchPinned } from '../../model-proxy/src/fetch.mjs';
|
||||
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
|
||||
import { createProxyAdmin } from '../../model-proxy/src/admin-control.mjs';
|
||||
import { listOrgs } from '../../organization/src/org-registry.mjs';
|
||||
import { busesStopped, instanceFromConfig } from './proxy-admin-host.mjs';
|
||||
const failure = (code, exitCode = 2) => Object.assign(new CliError(code, exitCode), { code });
|
||||
// A raw terminal suppresses echo; input is never argv, env, readline history or stdout.
|
||||
export function promptProxyKey(io, label) {
|
||||
@@ -34,12 +37,90 @@ export function promptProxyKey(io, label) {
|
||||
io.stdin.setRawMode(true); io.stdin.on('data', data); io.stdin.once('end', end); io.stdin.once('error', failed); io.stdin.resume();
|
||||
});
|
||||
}
|
||||
export async function proxyCommand(args, io, { system, callFor, prompt = promptProxyKey, fetch = fetchPinned }) {
|
||||
// #1873: the setup controller the Console's Proxy page uses, for this instance. `admin` is a
|
||||
// test seam; the default runs proxy-service.sh, systemctl and the pinned fetch for real.
|
||||
function adminFor(io, system, deps) {
|
||||
if (deps.admin) return deps.admin;
|
||||
const config = typeof system === 'function' ? system() : system;
|
||||
const dir = configDir(io.env);
|
||||
return createProxyAdmin({
|
||||
dataRoot: config.dataRoot, configDir: dir, instance: instanceFromConfig(io.env), env: io.env, fetch: deps.fetch ?? fetchPinned,
|
||||
unitDir: deps.unitDir ?? null,
|
||||
buses: () => busesStopped({ dataRoot: config.dataRoot, configDir: dir }),
|
||||
orgs: async () => (await listOrgs({ configDir: dir, dataRoot: config.dataRoot })).filter(org => org.state === 'active').map(org => ({ id: org.id, proxy: 'shared', catalogue: null })),
|
||||
});
|
||||
}
|
||||
const print = (io, value) => io.stdout.write(JSON.stringify(value) + '\n');
|
||||
const hostCode = (error, fallback) => (/^proxy-[a-z-]+$/.test(error?.code ?? '') ? error.code : fallback);
|
||||
|
||||
// `mosaic proxy status | unit ... | init-lock ...`: host facts and actions, labels only.
|
||||
async function setupCommand(verb, rest, io, { system, deps }) {
|
||||
const admin = adminFor(io, system, deps);
|
||||
try {
|
||||
if (verb === 'status') {
|
||||
if (!(rest.length === 0 || (rest.length === 1 && rest[0] === '--json'))) throw failure('proxy-usage', 4);
|
||||
const status = await admin.status();
|
||||
if (rest[0] === '--json') return print(io, status);
|
||||
const { release, init, unit, fence, initLock } = status;
|
||||
io.stdout.write([
|
||||
`release: ${release.fetched ? `${release.version} (panel ${release.panelVersion}) ${release.verified ? 'hashes verified' : `not verified: ${release.problem}`}` : `not fetched (pinned ${release.version})`}`,
|
||||
`initialized: ${init.initialized ? `yes, port ${init.settings?.port}` : init.partial ? `partly (${init.files.filter(f => f.present).map(f => f.label).join(', ')})` : 'no'}`,
|
||||
`unit: ${unit.unit} ${unit.installed ? 'installed' : 'not installed'}, ${unit.active}${unit.pid ? ` pid ${unit.pid}` : ''}${unit.port ? `, port ${unit.port} ${unit.listening ? 'answering' : 'not answering'}` : ''}`,
|
||||
`fence: ${fence.set ? `set${fence.op ? ` after ${fence.op}` : ''}${fence.time ? ` at ${fence.time}` : ''}` : 'clear'}`,
|
||||
`init lock: ${initLock.present ? (initLock.clearable ? 'stale, clearable' : `held: ${initLock.blockers.join('; ')}`) : 'none'}`,
|
||||
`own proxy: ${status.ownProxy.state}`,
|
||||
`next: ${status.next.join(', ')}`,
|
||||
].join('\n') + '\n');
|
||||
return;
|
||||
}
|
||||
if (verb === 'unit') {
|
||||
const [op, ...flags] = rest;
|
||||
if (op === 'render' && flags.length === 0) { io.stdout.write((await admin.render()).text); return; }
|
||||
if (op === 'status' && (flags.length === 0 || (flags.length === 1 && flags[0] === '--json'))) { print(io, (await admin.status()).unit); return; }
|
||||
if (['install', 'start', 'stop', 'restart'].includes(op) && flags.length === 0) { print(io, await admin.unitAction(op)); return; }
|
||||
throw failure('proxy-usage', 4);
|
||||
}
|
||||
if (verb === 'init-lock') {
|
||||
const [op, ...flags] = rest;
|
||||
if (flags.length !== 0) throw failure('proxy-usage', 4);
|
||||
if (op === 'status') { print(io, (await admin.status()).initLock); return; }
|
||||
if (op === 'clear') { print(io, await admin.initLockClear()); return; }
|
||||
throw failure('proxy-usage', 4);
|
||||
}
|
||||
} catch (error) { if (error instanceof CliError) throw error; throw failure(hostCode(error, 'proxy-unit-failed')); }
|
||||
throw failure('proxy-usage', 4);
|
||||
}
|
||||
|
||||
export async function proxyCommand(args, io, { system, callFor, prompt = promptProxyKey, fetch = fetchPinned, ...deps }) {
|
||||
const [verb, ...rest] = args;
|
||||
if (args.includes('--tester')) throw failure('human-required', 3);
|
||||
if (verb === 'init' || verb === 'fetch' || verb === 'fence') {
|
||||
// #1873: every proxy verb, read or write, runs only from a human shell.
|
||||
if (['init', 'fetch', 'fence', 'status', 'unit', 'init-lock'].includes(verb)) {
|
||||
try { refuseInsideAgent(io.env); } catch { throw failure('human-required', 3); }
|
||||
}
|
||||
if (['status', 'unit', 'init-lock'].includes(verb)) return setupCommand(verb, rest, io, { system, deps: { ...deps, fetch } });
|
||||
if (verb === 'fence' && rest[0] === 'clear' && rest.includes('--restart')) {
|
||||
// #1873: restart the proxy unit, then send the restart evidence to the bus, which re-proves it
|
||||
// on the host before it clears the fence. --business/--org names the bus.
|
||||
const parsed = { flags: new Set(), values: {} };
|
||||
const selector = orgSelector(() => failure('proxy-usage', 4));
|
||||
const flags = rest.slice(1);
|
||||
let restart = false;
|
||||
for (let i = 0; i < flags.length; i++) {
|
||||
if (flags[i] === '--restart' && !restart) { restart = true; continue; }
|
||||
if (selector.owns(flags[i])) { selector.take(flags[i], flags[i + 1]); i++; continue; }
|
||||
throw failure('proxy-usage', 4);
|
||||
}
|
||||
if (selector.given) parsed.values['--business'] = selector.value;
|
||||
const call = callFor(parsed);
|
||||
const admin = adminFor(io, system, { ...deps, fetch });
|
||||
try {
|
||||
const restarted = await admin.unitAction('restart');
|
||||
if (restarted.evidence?.confirmed !== true) throw failure('proxy-restart-unconfirmed');
|
||||
print(io, { restart: restarted.receipt, ...(await admin.fenceClear(call)) });
|
||||
return;
|
||||
} catch (error) { if (error instanceof CliError) throw error; throw failure(hostCode(error, 'proxy-down')); }
|
||||
}
|
||||
if (verb === 'fence') {
|
||||
const [action, ...flags] = rest;
|
||||
if (action === 'clear' && flags.length === 0) throw failure('proxy-restart-required');
|
||||
@@ -66,6 +147,7 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const flag = rest[i];
|
||||
if (verb === 'init' && selector.owns(flag)) { selector.take(flag, rest[i + 1]); i++; continue; }
|
||||
if (verb === 'init' && flag === '--generate-key' && !parsed.flags.has(flag)) { parsed.flags.add(flag); continue; }
|
||||
if (verb !== 'init' || !['--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];
|
||||
}
|
||||
@@ -81,7 +163,23 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
|
||||
try { proxyTiming(timing); } catch (error) { throw failure(error.code); }
|
||||
try {
|
||||
const config = typeof system === 'function' ? system() : system;
|
||||
if (verb === 'fetch') { const result = await fetch({ dataRoot: config.dataRoot }); io.stdout.write(JSON.stringify({ binary: result.binary, panel: result.panel }) + '\n'); return; }
|
||||
if (verb === 'fetch') {
|
||||
// #1873: the page's fetch, with its progress on stderr and its receipt in proxy-admin.jsonl;
|
||||
// stdout keeps its existing line.
|
||||
const { result, receipt } = await adminFor(io, system, { ...deps, fetch }).fetchRun({ progress: step => io.stderr.write(`fetch: ${step}\n`) });
|
||||
io.stderr.write(`receipt: ${JSON.stringify(receipt)}\n`);
|
||||
io.stdout.write(JSON.stringify({ binary: result.binary, panel: result.panel }) + '\n'); return;
|
||||
}
|
||||
// #1873: the bus generates the key and stores it where init stores a typed key; no prompt,
|
||||
// no terminal needed, and the key is never printed or returned.
|
||||
if (parsed.flags.has('--generate-key')) {
|
||||
// Through the page's controller, so the same init receipt lands in proxy-admin.jsonl.
|
||||
const call = callFor(parsed);
|
||||
const { settings, receipt } = await adminFor(io, system, deps).init(call, { generate: true, ...(port === undefined ? {} : { port }), ...timing });
|
||||
io.stderr.write(`receipt: ${JSON.stringify(receipt)}\n`);
|
||||
print(io, { ...settings, generated: true });
|
||||
return;
|
||||
}
|
||||
if (!io.stdin.isTTY || !io.stdout.isTTY) throw failure('proxy-terminal-required', 3);
|
||||
// Establish that the CLI is eligible before reading sensitive input. The bus verifies the proof.
|
||||
const call = callFor(parsed);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// #1873 proxy setup: host facts that `mosaic proxy` and the Console page share. Never reads a
|
||||
// key, proxy setting or bus record; labels only, never an absolute path.
|
||||
import net from 'node:net';
|
||||
import { hostStatus } from './host.mjs';
|
||||
import { socketPath, orgPaths } from './config.mjs';
|
||||
import { devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { listOrgs } from '../../organization/src/org-registry.mjs';
|
||||
|
||||
// A socket that accepts a connection, or fails other than "absent"/"refused", counts as running.
|
||||
function socketLive(file, timeout = 1000) {
|
||||
return new Promise(resolve => {
|
||||
const socket = net.connect(file);
|
||||
const done = value => { socket.destroy(); resolve(value); };
|
||||
socket.setTimeout(timeout, () => done(true));
|
||||
socket.once('connect', () => done(true));
|
||||
socket.once('error', error => done(!['ENOENT', 'ECONNREFUSED'].includes(error.code)));
|
||||
});
|
||||
}
|
||||
|
||||
async function rootRunning(root, socket) {
|
||||
try {
|
||||
const status = hostStatus(root);
|
||||
if (status.host?.live || status.writerLock?.live) return true;
|
||||
} catch { return true; }
|
||||
return socketLive(socket);
|
||||
}
|
||||
|
||||
// The init-lock README condition "the bus is stopped", proved for the instance's own bus and
|
||||
// every registered organization's bus. Anything that cannot be examined counts as running.
|
||||
// `listOrgsFn` and `running` are for tests.
|
||||
export async function busesStopped({ dataRoot, configDir, listOrgsFn = listOrgs, running = rootRunning }) {
|
||||
const out = [];
|
||||
if (await running(dataRoot, socketPath(dataRoot))) out.push('the instance bus');
|
||||
let orgs;
|
||||
try { orgs = await listOrgsFn({ configDir }); }
|
||||
catch { return { stopped: false, running: [...out, 'organization registry unreadable'] }; }
|
||||
for (const org of orgs) {
|
||||
const label = `organization ${typeof org?.id === 'string' ? org.id : '?'}`;
|
||||
let paths;
|
||||
try { paths = orgPaths(org.root); } catch { out.push(label); continue; }
|
||||
if (await running(org.root, paths.socket)) out.push(label);
|
||||
}
|
||||
return { stopped: out.length === 0, running: out };
|
||||
}
|
||||
|
||||
// The named instance MOSAIC_CONFIG selects, or null for the default (dev) instance.
|
||||
export function instanceFromConfig(env = process.env) {
|
||||
try { return devOwnerInstance(env); } catch { return null; }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// #1873 CLI parity for the Console's Proxy page: status, unit, init-lock, init --generate-key and
|
||||
// fence clear --restart. Every verb runs only from a human shell; nothing prints a key or a path.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { proxyCommand } from '../src/model-proxy.mjs';
|
||||
import { io } from './helpers.mjs';
|
||||
|
||||
const code = c => e => e.code === c;
|
||||
const fakeSystem = () => ({ dataRoot: join(mkdtempSync(join(tmpdir(), 'ps-cli-')), 'data') });
|
||||
const STATUS = {
|
||||
release: { fetched: true, version: '8.0.42', panelVersion: '1.26.7', verified: true },
|
||||
init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 77, port: 18432, listening: true },
|
||||
fence: { set: false }, initLock: { present: false, clearable: false, blockers: [] },
|
||||
ownProxy: { available: false, state: 'not built' }, next: ['accounts'],
|
||||
};
|
||||
function fakeAdmin({ restart = { confirmed: true } } = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
status: async () => { calls.push(['status']); return STATUS; },
|
||||
render: async () => { calls.push(['render']); return { text: '[Unit]\nDescription=fixture\n' }; },
|
||||
unitAction: async op => { calls.push(['unit', op]); return op === 'restart' ? { receipt: { op: 'unit.restart' }, evidence: restart } : { op, ok: true }; },
|
||||
initLockClear: async () => { calls.push(['init-lock.clear']); return { cleared: true, receipt: { op: 'init-lock.clear' } }; },
|
||||
fenceClear: async call => { calls.push(['fence.clear']); return call('proxy.fence.clear', { evidence: restart }); },
|
||||
};
|
||||
}
|
||||
const human = () => io();
|
||||
|
||||
test('every new setup verb refuses inside an agent shell and with --tester, before the controller runs', async () => {
|
||||
const admin = fakeAdmin();
|
||||
for (const args of [['proxy', 'status'], ['proxy', 'status', '--json'], ['proxy', 'unit', 'render'], ['proxy', 'unit', 'start'], ['proxy', 'unit', 'restart'],
|
||||
['proxy', 'init-lock', 'status'], ['proxy', 'init-lock', 'clear'], ['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], ['proxy', 'init', '--generate-key', '--business', 'demo']]) {
|
||||
await assert.rejects(main(args, io({ CODEX_THREAD_ID: 'fixture' }), { system: fakeSystem(), proxyAdmin: admin, transport: () => () => { throw Error('must not call'); } }), code('human-required'), args.join(' '));
|
||||
await assert.rejects(main([...args, '--tester'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('human-required'), `${args.join(' ')} --tester`);
|
||||
}
|
||||
assert.deepEqual(admin.calls, []);
|
||||
});
|
||||
|
||||
test('proxy status prints the same facts the page shows, and --json the status object', async () => {
|
||||
const admin = fakeAdmin();
|
||||
const x = human();
|
||||
await main(['proxy', 'status'], x, { system: fakeSystem(), proxyAdmin: admin });
|
||||
assert.match(x.out.text, /^release: 8\.0\.42 \(panel 1\.26\.7\) hashes verified$/m);
|
||||
assert.match(x.out.text, /^unit: mosaic-proxy\.service installed, active pid 77, port 18432 answering$/m);
|
||||
assert.match(x.out.text, /^own proxy: not built$/m);
|
||||
assert.match(x.out.text, /^next: accounts$/m);
|
||||
const y = human();
|
||||
await main(['proxy', 'status', '--json'], y, { system: fakeSystem(), proxyAdmin: admin });
|
||||
assert.deepEqual(JSON.parse(y.out.text), STATUS);
|
||||
await assert.rejects(main(['proxy', 'status', '--verbose'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'));
|
||||
});
|
||||
|
||||
test('proxy unit and init-lock verbs map one to one onto the controller; bad usage refuses', async () => {
|
||||
const admin = fakeAdmin();
|
||||
const x = human();
|
||||
await main(['proxy', 'unit', 'render'], x, { system: fakeSystem(), proxyAdmin: admin });
|
||||
assert.equal(x.out.text, '[Unit]\nDescription=fixture\n');
|
||||
for (const op of ['install', 'start', 'stop']) await main(['proxy', 'unit', op], human(), { system: fakeSystem(), proxyAdmin: admin });
|
||||
await main(['proxy', 'unit', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin });
|
||||
await main(['proxy', 'init-lock', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin });
|
||||
const y = human();
|
||||
await main(['proxy', 'init-lock', 'clear'], y, { system: fakeSystem(), proxyAdmin: admin });
|
||||
assert.equal(JSON.parse(y.out.text).cleared, true);
|
||||
assert.deepEqual(admin.calls, [['render'], ['unit', 'install'], ['unit', 'start'], ['unit', 'stop'], ['status'], ['status'], ['init-lock.clear']]);
|
||||
for (const args of [['proxy', 'unit'], ['proxy', 'unit', 'reload'], ['proxy', 'unit', 'start', 'now'], ['proxy', 'init-lock'], ['proxy', 'init-lock', 'clear', '--force']])
|
||||
await assert.rejects(main(args, human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'), args.join(' '));
|
||||
});
|
||||
|
||||
test('a controller refusal reaches the shell as its proxy code only, with no path or detail', async () => {
|
||||
const admin = fakeAdmin();
|
||||
admin.unitAction = async () => { throw Object.assign(Error('/home/owner/.config/mosaic-dev/proxy.json pa-cli-sentinel'), { code: 'proxy-unit-missing' }); };
|
||||
admin.status = async () => { throw Error('pa-cli-sentinel'); };
|
||||
await assert.rejects(main(['proxy', 'unit', 'start'], human(), { system: fakeSystem(), proxyAdmin: admin }), e => e.code === 'proxy-unit-missing' && !e.message.includes('pa-cli-sentinel') && !e.message.includes('/home/'));
|
||||
await assert.rejects(main(['proxy', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin }), e => e.code === 'proxy-unit-failed' && !e.message.includes('pa-cli-sentinel'));
|
||||
});
|
||||
|
||||
test('proxy init --generate-key needs no terminal, never prompts, and sends generate to the bus', async () => {
|
||||
const x = human();
|
||||
assert.notEqual(x.stdin.isTTY, true);
|
||||
const calls = [], system = fakeSystem();
|
||||
await main(['proxy', 'init', '--generate-key', '--business', 'demo', '--port', '18440', '--rotate-grace-minutes', '5'], x, {
|
||||
system, promptProxyKey: () => { throw Error('must not prompt'); },
|
||||
transport: b => async (verb, args) => { calls.push({ b, verb, args }); return { port: 18440, rotateGraceMinutes: 5, binary: '/fixture' }; } });
|
||||
assert.deepEqual(calls, [{ b: 'demo', verb: 'proxy.init', args: { generate: true, port: 18440, rotateGraceMinutes: 5 } }]);
|
||||
assert.deepEqual(JSON.parse(x.out.text), { port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null, generated: true });
|
||||
// The same init receipt the page writes, settings only.
|
||||
const [receipt] = readFileSync(join(system.dataRoot, 'state', 'proxy-admin.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.deepEqual({ ...receipt, time: 'T' }, { time: 'T', op: 'init', generated: true, port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null });
|
||||
assert.match(x.err.text, /^receipt: \{"time":/m);
|
||||
await assert.rejects(main(['proxy', 'init', '--generate-key', '--generate-key', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-usage'));
|
||||
await assert.rejects(main(['proxy', 'init', '--generate-key', '--port', '8317', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-config-invalid'));
|
||||
});
|
||||
|
||||
test('proxy fence clear --restart restarts first and clears only on a confirmed restart', async () => {
|
||||
const admin = fakeAdmin();
|
||||
const calls = [];
|
||||
const x = human();
|
||||
await main(['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], x, { system: fakeSystem(), proxyAdmin: admin,
|
||||
transport: b => async (verb, args) => { calls.push({ b, verb, args }); return { cleared: true, evidence: { after: { pid: 2 } } }; } });
|
||||
assert.deepEqual(admin.calls, [['unit', 'restart'], ['fence.clear']]);
|
||||
assert.deepEqual(calls, [{ b: 'demo', verb: 'proxy.fence.clear', args: { evidence: { confirmed: true } } }]);
|
||||
assert.deepEqual(JSON.parse(x.out.text), { restart: { op: 'unit.restart' }, cleared: true, evidence: { after: { pid: 2 } } });
|
||||
const unconfirmed = fakeAdmin({ restart: { confirmed: false } });
|
||||
await assert.rejects(main(['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], human(), { system: fakeSystem(), proxyAdmin: unconfirmed,
|
||||
transport: () => () => { throw Error('must not call'); } }), code('proxy-restart-unconfirmed'));
|
||||
assert.deepEqual(unconfirmed.calls, [['unit', 'restart']]);
|
||||
await assert.rejects(main(['proxy', 'fence', 'clear', '--restart', '--restart'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'));
|
||||
});
|
||||
|
||||
test('proxy fetch shows the page\'s progress steps and writes the same fetch receipt; stdout keeps its line', async () => {
|
||||
const system = fakeSystem();
|
||||
const x = human();
|
||||
await proxyCommand(['fetch'], x, { system, fetch: async ({ progress }) => { for (const step of ['download-archive', 'verify-archive', 'install']) progress(step); return { binary: 'fixture-binary', panel: 'fixture-panel' }; } });
|
||||
assert.deepEqual(JSON.parse(x.out.text), { binary: 'fixture-binary', panel: 'fixture-panel' });
|
||||
assert.match(x.err.text, /^fetch: download-archive\nfetch: verify-archive\nfetch: install\nreceipt: /);
|
||||
const receipts = readFileSync(join(system.dataRoot, 'state', 'proxy-admin.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(receipts.length, 1);
|
||||
assert.equal(receipts[0].op, 'fetch');
|
||||
assert.equal(receipts[0].verified, true);
|
||||
assert.match(receipts[0].archiveSha256, /^[0-9a-f]{64}$/);
|
||||
// A failed hash check writes no receipt and keeps its code.
|
||||
const failed = fakeSystem();
|
||||
await assert.rejects(proxyCommand(['fetch'], human(), { system: failed, fetch: async () => { throw Object.assign(Error('mismatch'), { code: 'proxy-fetch-hash' }); } }), code('proxy-fetch-hash'));
|
||||
assert.throws(() => readFileSync(join(failed.dataRoot, 'state', 'proxy-admin.jsonl')), { code: 'ENOENT' });
|
||||
});
|
||||
@@ -125,3 +125,54 @@ buses, the proxy's own panel, other holders of the management key, and the
|
||||
human `proxy fetch` and `proxy init` tools do not participate. Such writers can
|
||||
still race a whole-list write or alter proxy state. A server-side fence is a
|
||||
follow-up; do not run those writers concurrently with organization buses.
|
||||
|
||||
### Setup from the Console (#1873)
|
||||
|
||||
Settings > Model proxy has a Proxy setup panel for the instance owner: the
|
||||
`--dev-local-owner` owner on a dev Console, or the root owner under `--sso`.
|
||||
Agents, testers and grant holders get `human-required` on every setup route,
|
||||
read or write. The panel shows the release (pinned version, hashes verified),
|
||||
initialization, the unit (installed, active, pid, port), the fence, the init
|
||||
lock, each organization's proxy (shared) and catalogue, and own proxy. Each
|
||||
state offers its next step as one button. CLI parity:
|
||||
|
||||
mosaic proxy status [--json]
|
||||
mosaic proxy fetch
|
||||
mosaic proxy init --generate-key [--port N] [--rotate-grace-minutes N] [--first-output-seconds N] --business ID
|
||||
mosaic proxy unit render|status|install|start|stop|restart
|
||||
mosaic proxy fence clear --restart --business ID
|
||||
mosaic proxy init-lock status|clear
|
||||
|
||||
- Fetch runs the same PIN and hash checks as `mosaic proxy fetch`; the page
|
||||
shows each step as it runs.
|
||||
- Init asks the bus to generate the management key (`proxy.init` with
|
||||
`generate: true`, 48 CSPRNG bytes). The key goes to the key file only; no
|
||||
response, event or receipt carries it. A pasted key with its confirmation is
|
||||
accepted only when the page is on HTTPS or on loopback from a loopback
|
||||
peer; a plain-HTTP LAN page gets the generated key only
|
||||
(`proxy-key-transport`). Init keeps its exclusive-create rules and never
|
||||
rewrites `config.json`. Restart the other buses afterwards.
|
||||
- The unit verbs install the same user unit as `scripts/proxy-service.sh`.
|
||||
Health is the unit's main process tree listening on the configured port.
|
||||
- `fence clear --restart` (and the page's fence step) restarts the unit,
|
||||
confirms a new process that started after the fence and listens on the port,
|
||||
then clears the fence with that evidence under the lock. The receipt holds the
|
||||
before and after pids. Without a confirmed restart it refuses
|
||||
(`proxy-restart-unconfirmed`). A fence file with no readable time also refuses,
|
||||
because a restart cannot be shown to come after it; restart the service
|
||||
yourself and use `fence clear --proxy-restarted`, which is unchanged.
|
||||
- `proxy-admin-busy` (one setup action at a time) is per process: the Console
|
||||
and a `mosaic proxy` command each hold their own. The writes that matter are
|
||||
still serialized across processes: init by the bus's init lock and
|
||||
exclusive create, key writes by the instance-wide proxy lock, and unit
|
||||
actions by systemd.
|
||||
- The page and `status --json` carry the last ten receipts from
|
||||
`proxy-admin.jsonl`, so they survive a reload. CLI `init --generate-key`
|
||||
writes the same init receipt as the page.
|
||||
- The init lock clears only when every bus on the instance is stopped and no
|
||||
`proxy.json`, key file or proxy state directory exists. Otherwise the panel
|
||||
and `init-lock status` list why not.
|
||||
- Own proxy per organization is not built: the configuration has one proxy
|
||||
per instance (ruling 8, deferred).
|
||||
|
||||
Receipts: `<dataRoot>/state/proxy-admin.jsonl` (labels, pids and port only).
|
||||
@@ -0,0 +1,140 @@
|
||||
// #1873 proxy setup controller shared by the Console page (webui proxy-admin.mjs) and
|
||||
// `mosaic proxy status|unit|init-lock|fence clear --restart`. Host actions run here; init and
|
||||
// the fence clear go to the bus, which decides them. Results and receipts carry labels, key IDs
|
||||
// and process ids only: never a key, token, absolute path or upstream text.
|
||||
import { homedir } from 'node:os';
|
||||
import { fetchPinned } from './fetch.mjs';
|
||||
import { PIN } from './common.mjs';
|
||||
import { releaseState, initState, fenceState, initLockStatus, clearInitLock, appendReceipt, recentReceipts, createUnitControl, FILE_LABELS } from './admin.mjs';
|
||||
|
||||
export const proxyCode = (error, fallback) => (/^proxy-[a-z-]+$/.test(error?.code ?? '') ? error.code : fallback);
|
||||
const failure = code => Object.assign(new Error(code), { code });
|
||||
|
||||
// `buses()` proves the init-lock condition "bus stopped" ({stopped, running:[label]}).
|
||||
// `orgs()` lists the organizations this proxy serves: [{id, catalogue}]. `unit` and `fetch` are
|
||||
// (and `pin`) seams for tests; the defaults run the real proxy-service.sh, systemctl and pinned fetch.
|
||||
export function createProxyAdmin({ dataRoot, configDir, instance = null, unitDir = null, unit = null, fetch = fetchPinned, buses, orgs = async () => [], now = () => new Date(), env = process.env, pin = PIN }) {
|
||||
const port = () => { try { return initState(configDir, dataRoot).settings?.port ?? null; } catch { return null; } };
|
||||
const control = unit ?? createUnitControl({ instance, unitDir, port, env });
|
||||
let job = { state: 'idle', step: null, receipt: null, error: null };
|
||||
// The last confirmed restart this page made. One fence clear may use it.
|
||||
let restarted = null;
|
||||
let busy = null;
|
||||
const receipt = record => appendReceipt(dataRoot, { time: now().toISOString(), ...record });
|
||||
const fetchRecord = () => ({ op: 'fetch', version: pin.version, panelVersion: pin.panel.version, archiveSha256: pin.archive.sha256, panelSha256: pin.panel.sha256, binarySha256: pin.binary.sha256, verified: true });
|
||||
async function exclusive(name, action) {
|
||||
if (busy) throw failure('proxy-admin-busy');
|
||||
busy = name;
|
||||
try { return await action(); } finally { busy = null; }
|
||||
}
|
||||
async function unitStatus() {
|
||||
try { return await control.status(); } catch (error) { return { unit: control.unit, installed: false, active: 'unknown', healthy: false, error: proxyCode(error, 'proxy-unit-failed') }; }
|
||||
}
|
||||
function nextSteps({ release, init, unitState, fence, lock }) {
|
||||
if (!release.fetched || release.problem) return ['fetch'];
|
||||
if (lock.present) return lock.clearable ? ['init-lock-clear'] : ['init-lock-wait'];
|
||||
if (init.partial || init.problem) return ['init-repair'];
|
||||
if (!init.initialized) return ['init'];
|
||||
if (!unitState.installed) return ['unit-install'];
|
||||
if (unitState.active !== 'active') return ['unit-start'];
|
||||
if (!unitState.healthy) return ['unit-restart'];
|
||||
if (fence) return restarted ? ['fence-clear'] : ['unit-restart'];
|
||||
return ['accounts'];
|
||||
}
|
||||
return {
|
||||
unit: control.unit,
|
||||
async status() {
|
||||
// The job is read before the release: a fetch that ends during the awaits below then reads
|
||||
// "running" (and the page polls again), never "done" next to a release read before it ended.
|
||||
const fetchJob = { ...job };
|
||||
const release = releaseState(dataRoot, pin);
|
||||
const init = initState(configDir, dataRoot);
|
||||
const unitState = await unitStatus();
|
||||
const fence = fenceState(dataRoot);
|
||||
const lock = init.lock ? initLockStatus({ configDir, dataRoot, buses: await buses() }) : { present: false, clearable: false, blockers: [] };
|
||||
let organizations = null;
|
||||
try { organizations = await orgs(); } catch { organizations = null; }
|
||||
return {
|
||||
release: { version: release.version, panelVersion: release.panelVersion, fetched: release.fetched, verified: release.verified, problem: release.problem },
|
||||
init: {
|
||||
initialized: init.initialized, partial: init.partial, problem: init.problem, settings: init.settings,
|
||||
files: Object.entries(init.files).map(([name, found]) => ({ label: FILE_LABELS[name], present: found })),
|
||||
},
|
||||
unit: unitState,
|
||||
fence: fence ? { set: true, org: fence.org ?? null, op: fence.op ?? null, time: fence.time ?? null, invalid: fence.invalid === true } : { set: false },
|
||||
initLock: lock,
|
||||
fetch: fetchJob,
|
||||
restart: restarted ? { confirmed: true, observedAt: restarted.observedAt, pid: restarted.after.pid } : null,
|
||||
busy,
|
||||
receipts: recentReceipts(dataRoot),
|
||||
organizations,
|
||||
// Ruling 8: the system config holds one proxy per instance; an organization's own proxy
|
||||
// needs a config format that does not exist yet.
|
||||
ownProxy: { available: false, state: 'not built', reason: 'This instance has one shared proxy. A separate proxy per organization is not built yet.' },
|
||||
next: nextSteps({ release, init, unitState, fence, lock }),
|
||||
};
|
||||
},
|
||||
// Runs in the background; the page polls status for its step and receipt.
|
||||
fetchStart() {
|
||||
if (job.state === 'running' || busy) throw failure('proxy-admin-busy');
|
||||
job = { state: 'running', step: 'start', receipt: null, error: null };
|
||||
busy = 'fetch';
|
||||
Promise.resolve()
|
||||
.then(() => fetch({ dataRoot, pin, progress: step => { job.step = step; } }))
|
||||
.then(() => {
|
||||
job = { state: 'done', step: 'done', error: null, receipt: receipt(fetchRecord()) };
|
||||
})
|
||||
.catch(error => { job = { state: 'failed', step: job.step, receipt: null, error: proxyCode(error, 'proxy-fetch-failed') }; })
|
||||
.finally(() => { busy = null; });
|
||||
return { state: 'running' };
|
||||
},
|
||||
// The CLI's fetch: the same pinned fetch and receipt, in the foreground. Returns the fetch
|
||||
// result (for the CLI's existing output) and the receipt.
|
||||
async fetchRun({ progress = () => {} } = {}) {
|
||||
if (job.state === 'running') throw failure('proxy-admin-busy');
|
||||
return exclusive('fetch', async () => {
|
||||
const result = await fetch({ dataRoot, pin, progress });
|
||||
return { result, receipt: receipt(fetchRecord()) };
|
||||
});
|
||||
},
|
||||
async init(call, args) {
|
||||
return exclusive('init', async () => {
|
||||
const result = await call('proxy.init', args);
|
||||
const settings = { port: result.port, rotateGraceMinutes: result.rotateGraceMinutes ?? null, firstOutputSeconds: result.firstOutputSeconds ?? null };
|
||||
return { settings, receipt: receipt({ op: 'init', generated: args.generate === true, ...settings }) };
|
||||
});
|
||||
},
|
||||
async render() {
|
||||
const text = await control.render();
|
||||
const home = homedir();
|
||||
return { unit: control.unit, text: home && home !== '/' ? text.split(home).join('~') : text };
|
||||
},
|
||||
async unitAction(op) {
|
||||
return exclusive(`unit-${op}`, async () => {
|
||||
if (op === 'restart') {
|
||||
const { state, evidence } = await control.restart();
|
||||
restarted = evidence.confirmed ? evidence : null;
|
||||
return { state, evidence, receipt: receipt({ op: 'unit.restart', unit: control.unit, confirmed: evidence.confirmed, before: { pid: evidence.before.pid }, after: { pid: evidence.after.pid, listening: evidence.after.listening }, observedAt: evidence.observedAt }) };
|
||||
}
|
||||
const state = await control[op]();
|
||||
// Any other unit action ends the restart this page confirmed.
|
||||
restarted = null;
|
||||
return { state, receipt: receipt({ op: `unit.${op}`, unit: control.unit, active: state.active, pid: state.pid ?? null, healthy: state.healthy === true }) };
|
||||
});
|
||||
},
|
||||
// Only after this page restarted the proxy and saw the new process. The bus re-proves it.
|
||||
async fenceClear(call) {
|
||||
return exclusive('fence-clear', async () => {
|
||||
if (!restarted) throw failure('proxy-restart-required');
|
||||
const evidence = restarted;
|
||||
// Kept until the bus answers: a bus that is down or busy leaves the confirmed restart for a retry.
|
||||
const result = await call('proxy.fence.clear', { evidence });
|
||||
if (restarted === evidence) restarted = null;
|
||||
return { cleared: result.cleared === true, receipt: receipt({ op: 'fence.clear', cleared: result.cleared === true, evidence: result.evidence ?? null }) };
|
||||
});
|
||||
},
|
||||
async initLockClear() {
|
||||
return exclusive('init-lock-clear', async () => ({ receipt: clearInitLock({ configDir, dataRoot, buses: await buses(), now }) }));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// #1873 proxy setup state and host actions shared by `mosaic proxy` and the Console page.
|
||||
// Never imports the management client and never reads the management key file: state is
|
||||
// file presence, the pinned install hashes, the fence file and the proxy unit's systemd state.
|
||||
// Results and receipts name files by label, never by absolute path, and carry no key.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PIN, refuse } from './common.mjs';
|
||||
import { installPaths, loadProxy, verifyInstall } from './settings.mjs';
|
||||
|
||||
const REPO = fileURLToPath(new URL('../../../', import.meta.url)).replace(/\/$/, '');
|
||||
export const INSTANCE_NAME = /^[a-z][a-z0-9]{1,15}$/;
|
||||
|
||||
|
||||
export const FILE_LABELS = Object.freeze({ settings: 'proxy.json', key: 'the management key file', proxyDir: 'the proxy state directory', lock: 'the init lock' });
|
||||
export function initFiles(configDir, dataRoot) {
|
||||
return {
|
||||
settings: path.join(configDir, 'proxy.json'),
|
||||
key: path.join(configDir, 'secrets', 'proxy-management.key'),
|
||||
proxyDir: path.join(dataRoot, 'proxy'),
|
||||
lock: path.join(configDir, '.proxy-init.lock'),
|
||||
};
|
||||
}
|
||||
// Presence only. A path that cannot be examined counts as present, so nothing clears on doubt.
|
||||
function present(file) { try { fs.lstatSync(file); return true; } catch (error) { return error.code !== 'ENOENT'; } }
|
||||
|
||||
const verified = new Map();
|
||||
// The pinned release: hashes are checked once per file identity (dev, inode, size, mtime, ctime, mode),
|
||||
// in nanoseconds. mtime can be set back by the file's owner; ctime cannot, so an in-place rewrite re-hashes.
|
||||
export function releaseState(dataRoot, pin = PIN) {
|
||||
const paths = installPaths(dataRoot, pin);
|
||||
const out = { version: pin.version, panelVersion: pin.panel.version, fetched: false, verified: false, problem: null };
|
||||
let stats;
|
||||
try { stats = [paths.binary, paths.panel].map(file => fs.lstatSync(file, { bigint: true })); }
|
||||
catch (error) { out.problem = error.code === 'ENOENT' ? 'proxy-install-missing' : 'proxy-install-invalid'; return out; }
|
||||
out.fetched = true;
|
||||
const identity = stats.map(s => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.mode}`).join('|');
|
||||
if (verified.get(paths.dir) === identity) { out.verified = true; return out; }
|
||||
try { verifyInstall(dataRoot, pin); verified.set(paths.dir, identity); out.verified = true; }
|
||||
catch (error) { out.problem = /^proxy-install-(missing|invalid)$/.test(error.code ?? '') ? error.code : 'proxy-install-invalid'; }
|
||||
return out;
|
||||
}
|
||||
|
||||
export function initState(configDir, dataRoot) {
|
||||
const files = initFiles(configDir, dataRoot);
|
||||
const found = Object.fromEntries(Object.entries(files).map(([name, file]) => [name, present(file)]));
|
||||
let settings = null, problem = null;
|
||||
if (found.settings) {
|
||||
try {
|
||||
const proxy = loadProxy(configDir, dataRoot);
|
||||
settings = { port: proxy.port, rotateGraceMinutes: proxy.rotateGraceMinutes ?? null, firstOutputSeconds: proxy.firstOutputSeconds ?? null };
|
||||
} catch (error) { problem = /^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-config-invalid'; }
|
||||
}
|
||||
const initialized = found.settings && found.key && found.proxyDir && !problem;
|
||||
const partial = !initialized && (found.settings || found.key || found.proxyDir);
|
||||
return { initialized, partial, problem, settings, files: found, lock: found.lock };
|
||||
}
|
||||
|
||||
// Read-only view of the uncertain-write fence. Never constructs a proxy lock, which writes.
|
||||
export function fenceState(dataRoot) {
|
||||
const file = path.join(dataRoot, 'proxy', 'mosaic-keys.uncertain');
|
||||
let stat;
|
||||
try { stat = fs.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; return { invalid: true }; }
|
||||
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) || stat.size > 4096) return { invalid: true };
|
||||
let entry;
|
||||
try { entry = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { invalid: true }; }
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { invalid: true };
|
||||
return {
|
||||
org: typeof entry.org === 'string' ? entry.org : null,
|
||||
op: typeof entry.op === 'string' ? entry.op : null,
|
||||
time: typeof entry.time === 'string' ? entry.time : null,
|
||||
pid: Number.isSafeInteger(entry.pid) ? entry.pid : null,
|
||||
};
|
||||
}
|
||||
|
||||
// README conditions for a stale init lock: the bus is stopped (so no init is running), and
|
||||
// none of proxy.json, the key file or the proxy dir exists. `buses` is the caller's proof:
|
||||
// { stopped: boolean, running: [label] }.
|
||||
export function initLockStatus({ configDir, dataRoot, buses }) {
|
||||
const state = initState(configDir, dataRoot);
|
||||
const blockers = [];
|
||||
if (!buses || buses.stopped !== true) blockers.push(`bus running: ${(buses?.running ?? ['unknown']).join(', ')}`);
|
||||
for (const name of ['settings', 'key', 'proxyDir']) if (state.files[name]) blockers.push(`${FILE_LABELS[name]} exists`);
|
||||
return { present: state.lock, clearable: state.lock && blockers.length === 0, blockers: state.lock ? blockers : [] };
|
||||
}
|
||||
|
||||
export function clearInitLock({ configDir, dataRoot, buses, now = () => new Date() }) {
|
||||
const status = initLockStatus({ configDir, dataRoot, buses });
|
||||
if (!status.present) refuse('proxy-init-lock-absent');
|
||||
if (!status.clearable) refuse('proxy-init-lock-held');
|
||||
const lock = initFiles(configDir, dataRoot).lock;
|
||||
const stat = fs.lstatSync(lock);
|
||||
if (!stat.isFile() || stat.uid !== process.getuid()) refuse('proxy-init-lock-held');
|
||||
fs.unlinkSync(lock);
|
||||
return appendReceipt(dataRoot, { op: 'init-lock.clear', time: now().toISOString(), conditions: ['bus stopped', 'no proxy.json', 'no management key file', 'no proxy state directory'] });
|
||||
}
|
||||
|
||||
// Append-only admin receipts beside the other state logs. Labels and key ids only.
|
||||
export function receiptFile(dataRoot) { return path.join(dataRoot, 'state', 'proxy-admin.jsonl'); }
|
||||
export function appendReceipt(dataRoot, record) {
|
||||
const dir = path.join(dataRoot, 'state');
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const fd = fs.openSync(receiptFile(dataRoot), 'a', 0o600);
|
||||
try { fs.writeFileSync(fd, JSON.stringify(record) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
||||
return record;
|
||||
}
|
||||
|
||||
// The last few receipts, oldest first, for the page after a reload. Reads only the file's tail and
|
||||
// skips a line that does not parse (a torn last write). Receipts never hold a key or a body.
|
||||
export function recentReceipts(dataRoot, limit = 10) {
|
||||
let fd;
|
||||
try { fd = fs.openSync(receiptFile(dataRoot), 'r'); } catch { return []; }
|
||||
try {
|
||||
const size = fs.fstatSync(fd).size, length = Math.min(size, 65536), buffer = Buffer.alloc(length);
|
||||
fs.readSync(fd, buffer, 0, length, size - length);
|
||||
const lines = buffer.toString('utf8').split('\n').slice(size > length ? 1 : 0).filter(Boolean);
|
||||
const out = [];
|
||||
for (const line of lines) { try { const value = JSON.parse(line); if (value && typeof value === 'object' && typeof value.op === 'string') out.push(value); } catch { /* torn line */ } }
|
||||
return out.slice(-limit);
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
|
||||
export function unitName(instance) {
|
||||
if (instance !== null && instance !== undefined && instance !== 'dev' && !INSTANCE_NAME.test(instance)) refuse('proxy-unit-usage');
|
||||
return instance && instance !== 'dev' ? `mosaic-proxy-${instance}.service` : 'mosaic-proxy.service';
|
||||
}
|
||||
|
||||
export function defaultRun(command, args, { env, timeout = 60000 } = {}) {
|
||||
return new Promise(resolve => {
|
||||
execFile(command, args, { env, timeout, maxBuffer: 1024 * 1024, killSignal: 'SIGKILL' }, (error, stdout, stderr) => {
|
||||
resolve({ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function probePort(port, { host = '127.0.0.1', timeout = 1000 } = {}) {
|
||||
return new Promise(resolve => {
|
||||
if (!Number.isInteger(port)) return resolve(false);
|
||||
const socket = net.connect({ host, port });
|
||||
const done = value => { socket.destroy(); resolve(value); };
|
||||
socket.setTimeout(timeout, () => done(false));
|
||||
socket.once('connect', () => done(true));
|
||||
socket.once('error', () => done(false));
|
||||
});
|
||||
}
|
||||
|
||||
const UNIT_CODES = ['proxy-unit-usage', 'proxy-unit-exists', 'proxy-unit-dropin', 'proxy-unit-copy', 'proxy-unit-path', 'proxy-not-configured'];
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// The proxy user unit for one instance. `port` is a function so a fresh init is seen.
|
||||
// `unitDir` is passed to proxy-service.sh --dir; null keeps that script's default.
|
||||
export function createUnitControl({ instance = null, port = () => null, unitDir = null, env = process.env, run = defaultRun, repo = REPO, probe = probePort, waitMs = 30000, pollMs = 250, now = () => new Date() } = {}) {
|
||||
const unit = unitName(instance);
|
||||
if (unitDir !== null && (typeof unitDir !== 'string' || !path.isAbsolute(unitDir))) refuse('proxy-unit-path');
|
||||
const script = path.join(repo, 'scripts', 'proxy-service.sh');
|
||||
const instanceArgs = instance && instance !== 'dev' ? ['--instance', instance] : [];
|
||||
async function service(op) {
|
||||
const result = await run('bash', [script, ...instanceArgs, op, ...(unitDir && op === 'install' ? ['--dir', unitDir] : [])], { env });
|
||||
if (result.code === 0) return result;
|
||||
const code = /^proxy-service: (proxy-[a-z-]+)$/m.exec(result.stderr)?.[1];
|
||||
refuse(UNIT_CODES.includes(code) ? code : 'proxy-unit-failed');
|
||||
}
|
||||
async function status() {
|
||||
const result = await run('systemctl', ['--user', 'show', unit, '--property=LoadState,ActiveState,SubState,MainPID,ExecMainStartTimestampMonotonic,NRestarts'], { env, timeout: 15000 });
|
||||
if (result.code !== 0) refuse('proxy-unit-failed');
|
||||
const fields = Object.fromEntries(result.stdout.split('\n').filter(Boolean).map(line => { const i = line.indexOf('='); return [line.slice(0, i), line.slice(i + 1)]; }));
|
||||
const pid = /^[1-9][0-9]*$/.test(fields.MainPID ?? '') ? Number(fields.MainPID) : null;
|
||||
const since = /^[1-9][0-9]*$/.test(fields.ExecMainStartTimestampMonotonic ?? '') ? Number(fields.ExecMainStartTimestampMonotonic) : null;
|
||||
const p = port();
|
||||
const listening = fields.ActiveState === 'active' && Number.isInteger(p) ? await probe(p) : false;
|
||||
return {
|
||||
unit,
|
||||
installed: fields.LoadState === 'loaded',
|
||||
load: fields.LoadState ?? 'unknown', active: fields.ActiveState ?? 'unknown', sub: fields.SubState ?? 'unknown',
|
||||
pid, since, restarts: /^[0-9]+$/.test(fields.NRestarts ?? '') ? Number(fields.NRestarts) : null,
|
||||
port: Number.isInteger(p) ? p : null, listening,
|
||||
healthy: fields.ActiveState === 'active' && pid !== null && listening,
|
||||
};
|
||||
}
|
||||
async function waitFor(test) {
|
||||
const deadline = Date.now() + waitMs;
|
||||
for (;;) {
|
||||
const state = await status();
|
||||
if (test(state)) return state;
|
||||
if (Date.now() >= deadline) return state;
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
async function systemctl(verb) {
|
||||
const result = await run('systemctl', ['--user', verb, unit], { env, timeout: 90000 });
|
||||
if (result.code !== 0) refuse('proxy-unit-failed');
|
||||
}
|
||||
return {
|
||||
unit,
|
||||
status,
|
||||
async render() { return (await service('render')).stdout; },
|
||||
async install() { await service('install'); return status(); },
|
||||
async start() {
|
||||
const before = await status();
|
||||
if (!before.installed) refuse('proxy-unit-missing');
|
||||
await systemctl('start');
|
||||
const after = await waitFor(s => s.healthy);
|
||||
if (!after.healthy) refuse('proxy-unit-unhealthy');
|
||||
return after;
|
||||
},
|
||||
async stop() {
|
||||
const before = await status();
|
||||
if (!before.installed) refuse('proxy-unit-missing');
|
||||
await systemctl('stop');
|
||||
return waitFor(s => s.active === 'inactive' || s.active === 'failed');
|
||||
},
|
||||
// Restart evidence is what a fence clear needs: a new main process, started after the old
|
||||
// one, active and answering on the proxy port. Anything less is not a confirmed restart.
|
||||
async restart() {
|
||||
const before = await status();
|
||||
if (!before.installed) refuse('proxy-unit-missing');
|
||||
await systemctl('restart');
|
||||
const changed = s => s.healthy && s.pid !== null && s.pid !== before.pid && s.since !== null && (before.since === null || s.since > before.since);
|
||||
const after = await waitFor(changed);
|
||||
const confirmed = changed(after);
|
||||
return {
|
||||
state: after,
|
||||
evidence: {
|
||||
unit, confirmed,
|
||||
before: { pid: before.pid, since: before.since, active: before.active },
|
||||
after: { pid: after.pid, since: after.since, active: after.active, listening: after.listening },
|
||||
observedAt: now().toISOString(),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Process start time as wall-clock milliseconds, from /proc (start ticks since boot + btime).
|
||||
export function processStartMs(pid, { proc = '/proc' } = {}) {
|
||||
try {
|
||||
const stat = fs.readFileSync(path.join(proc, String(pid), 'stat'), 'utf8');
|
||||
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
||||
const ticks = Number(fields[19]);
|
||||
const btime = Number(/^btime ([0-9]+)$/m.exec(fs.readFileSync(path.join(proc, 'stat'), 'utf8'))?.[1]);
|
||||
if (!Number.isFinite(ticks) || !Number.isFinite(btime)) return null;
|
||||
return btime * 1000 + Math.floor(ticks * 10);
|
||||
} catch { return null; }
|
||||
}
|
||||
// `pid` and every process descended from it, from each process's parent field in /proc.
|
||||
export function processTree(pid, { proc = '/proc' } = {}) {
|
||||
const parents = new Map();
|
||||
let names; try { names = fs.readdirSync(proc); } catch { return [pid]; }
|
||||
for (const name of names) {
|
||||
if (!/^[1-9][0-9]*$/.test(name)) continue;
|
||||
try {
|
||||
const stat = fs.readFileSync(path.join(proc, name, 'stat'), 'utf8');
|
||||
const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]);
|
||||
if (!parents.has(ppid)) parents.set(ppid, []);
|
||||
parents.get(ppid).push(Number(name));
|
||||
} catch { /* gone */ }
|
||||
}
|
||||
const out = [pid];
|
||||
for (let i = 0; i < out.length && out.length < 256; i++) out.push(...(parents.get(out[i]) ?? []));
|
||||
return out;
|
||||
}
|
||||
// True when `pid` or one of its descendants holds a listening TCP socket on `port` (IPv4 or
|
||||
// IPv6), read from /proc. The unit's main process is the node runner; the pinned binary it
|
||||
// spawns is the listener.
|
||||
export function pidListensOn(pid, port, { proc = '/proc' } = {}) {
|
||||
const hex = port.toString(16).toUpperCase().padStart(4, '0');
|
||||
const inodes = new Set();
|
||||
for (const table of ['tcp', 'tcp6']) {
|
||||
let text; try { text = fs.readFileSync(path.join(proc, 'net', table), 'utf8'); } catch { continue; }
|
||||
for (const line of text.split('\n').slice(1)) {
|
||||
const cols = line.trim().split(/\s+/);
|
||||
if (cols.length > 9 && cols[1].endsWith(`:${hex}`) && cols[3] === '0A') inodes.add(cols[9]);
|
||||
}
|
||||
}
|
||||
if (inodes.size === 0) return false;
|
||||
for (const member of processTree(pid, { proc })) {
|
||||
let fds; try { fds = fs.readdirSync(path.join(proc, String(member), 'fd')); } catch { continue; }
|
||||
for (const fd of fds) {
|
||||
let link; try { link = fs.readlinkSync(path.join(proc, String(member), 'fd', fd)); } catch { continue; }
|
||||
const m = /^socket:\[([0-9]+)\]$/.exec(link);
|
||||
if (m && inodes.has(m[1])) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A fence clear accepts restart evidence only after re-proving it here, on the host: the
|
||||
// evidence names a confirmed restart to a new process, and that process is alive now, started
|
||||
// after the fence was set, and is the one listening on the proxy port. Caller-supplied fields
|
||||
// are never trusted on their own.
|
||||
export function verifyRestart(evidence, { fence, port, startMs = processStartMs, listens = pidListensOn, now = () => Date.now() } = {}) {
|
||||
const int = v => Number.isSafeInteger(v) && v > 0;
|
||||
const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
if (!plain(evidence) || Object.keys(evidence).sort().join(',') !== 'after,before,confirmed,observedAt,unit'
|
||||
|| typeof evidence.unit !== 'string' || !/^mosaic-proxy(-[a-z][a-z0-9]{1,15})?\.service$/.test(evidence.unit)
|
||||
|| typeof evidence.observedAt !== 'string' || Number.isNaN(Date.parse(evidence.observedAt))
|
||||
|| !plain(evidence.before) || !plain(evidence.after)) refuse('proxy-restart-unconfirmed');
|
||||
const { before, after } = evidence;
|
||||
if (evidence.confirmed !== true || !int(after.pid) || !int(after.since) || after.pid === before.pid) refuse('proxy-restart-unconfirmed');
|
||||
const observed = Date.parse(evidence.observedAt);
|
||||
if (observed > now() + 60000 || now() - observed > 10 * 60 * 1000) refuse('proxy-restart-unconfirmed');
|
||||
const started = startMs(after.pid);
|
||||
if (started === null) refuse('proxy-restart-unconfirmed');
|
||||
const fenceTime = fence && typeof fence.time === 'string' ? Date.parse(fence.time) : NaN;
|
||||
if (fence && !(Number.isFinite(fenceTime) && started > fenceTime)) refuse('proxy-restart-unconfirmed');
|
||||
if (!Number.isInteger(port) || !listens(after.pid, port)) refuse('proxy-restart-unconfirmed');
|
||||
return { unit: evidence.unit, before: { pid: int(before.pid) ? before.pid : null }, after: { pid: after.pid, startedAt: new Date(started).toISOString() }, observedAt: evidence.observedAt, listeningPort: port };
|
||||
}
|
||||
@@ -29,15 +29,21 @@ export async function downloadRelease(url, { testOrigin } = {}) {
|
||||
}
|
||||
refuse('proxy-fetch-redirect');
|
||||
}
|
||||
export async function fetchPinned({ dataRoot, pin = PIN, download = downloadRelease }) {
|
||||
// `progress(step)` names each stage for a caller that shows it (#1873 Console page); never a path.
|
||||
export async function fetchPinned({ dataRoot, pin = PIN, download = downloadRelease, progress = () => {} }) {
|
||||
safePath(dataRoot, 'proxy-fetch-existing');
|
||||
const paths = installPaths(dataRoot, pin);
|
||||
safePath(paths.dir, 'proxy-fetch-existing');
|
||||
// Fetch and verify both assets before any install filesystem mutation.
|
||||
progress('download-archive');
|
||||
const archive = await download(pin.archive.url);
|
||||
progress('verify-archive');
|
||||
if (sha256(archive) !== pin.archive.sha256) refuse('proxy-fetch-hash');
|
||||
progress('download-panel');
|
||||
const panel = await download(pin.panel.url);
|
||||
progress('verify-panel');
|
||||
if (sha256(panel) !== pin.panel.sha256) refuse('proxy-fetch-hash');
|
||||
progress('extract-binary');
|
||||
let binary;
|
||||
try {
|
||||
const listing = execFileSync('tar', ['-tzf', '-'], { input: archive, maxBuffer: 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }).toString().split('\n').filter(Boolean);
|
||||
@@ -45,7 +51,9 @@ export async function fetchPinned({ dataRoot, pin = PIN, download = downloadRele
|
||||
// Only the root executable is read to stdout. No archive path is extracted to disk.
|
||||
binary = execFileSync('tar', ['-xOzf', '-', 'cli-proxy-api'], { input: archive, maxBuffer: 256 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { refuse('proxy-fetch-archive'); }
|
||||
progress('verify-binary');
|
||||
if (sha256(binary) !== pin.binary.sha256) refuse('proxy-fetch-hash');
|
||||
progress('install');
|
||||
try {
|
||||
fs.lstatSync(paths.dir);
|
||||
if (fs.readdirSync(paths.dir).sort().join(',') !== 'cli-proxy-api,static'
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// #1873 the setup controller the Console page and `mosaic proxy` share: next steps from state,
|
||||
// a fetch receipt with every pinned hash, init through the bus call, and the fence clear that
|
||||
// needs a restart this controller confirmed.
|
||||
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 { createHash } from 'node:crypto';
|
||||
import { createProxyAdmin } from '../src/admin-control.mjs';
|
||||
import { initFiles, receiptFile } from '../src/admin.mjs';
|
||||
import { PIN } from '../src/common.mjs';
|
||||
|
||||
const code = name => error => error.code === name;
|
||||
const SENTINEL = 'pa-sentinel-management-key-0123456789';
|
||||
const stopped = async () => ({ stopped: true, running: [] });
|
||||
|
||||
function fixture(t, { unitState = { installed: false, active: 'inactive', healthy: false }, restart = null, fetch = null, pin = PIN, statusHook = null } = {}) {
|
||||
const root = fs.mkdtempSync(join(tmpdir(), 'pc-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
|
||||
fs.mkdirSync(configDir, { mode: 0o700 }); fs.mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ops = [];
|
||||
const unit = {
|
||||
unit: 'mosaic-proxy-zz.service',
|
||||
status: async () => { await statusHook?.(); return { unit: 'mosaic-proxy-zz.service', ...unitState }; },
|
||||
render: async () => `ExecStart=/usr/bin/node ${process.env.HOME ?? '/nohome'}/repo/run.mjs\n`,
|
||||
install: async () => { ops.push('install'); return { ...unitState, installed: true }; },
|
||||
start: async () => { ops.push('start'); return { ...unitState, active: 'active', pid: 11, healthy: true }; },
|
||||
stop: async () => { ops.push('stop'); return { ...unitState, active: 'inactive', pid: null }; },
|
||||
restart: async () => { ops.push('restart'); return restart ?? { state: unitState, evidence: { unit: 'mosaic-proxy-zz.service', confirmed: true, before: { pid: 10, since: 1, active: 'active' }, after: { pid: 11, since: 2, active: 'active', listening: true }, observedAt: new Date(0).toISOString() } }; },
|
||||
};
|
||||
const admin = createProxyAdmin({ dataRoot, configDir, unit, buses: stopped, fetch: fetch ?? (async () => ({})), now: () => new Date(0), orgs: async () => [{ id: 'acme', catalogue: 'ready' }], pin });
|
||||
return { root, configDir, dataRoot, admin, ops, files: initFiles(configDir, dataRoot) };
|
||||
}
|
||||
const receipts = dataRoot => { try { return fs.readFileSync(receiptFile(dataRoot), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); } catch { return []; } };
|
||||
|
||||
test('a fresh instance says fetch first; status names files by label and offers no own proxy', async t => {
|
||||
const { root, admin } = fixture(t);
|
||||
const status = await admin.status();
|
||||
assert.deepEqual(status.next, ['fetch']);
|
||||
assert.equal(status.release.version, PIN.version);
|
||||
assert.equal(status.release.fetched, false);
|
||||
assert.deepEqual(status.init.files.map(f => f.label), ['proxy.json', 'the management key file', 'the proxy state directory', 'the init lock']);
|
||||
assert.deepEqual(status.ownProxy, { available: false, state: 'not built', reason: 'This instance has one shared proxy. A separate proxy per organization is not built yet.' });
|
||||
assert.deepEqual(status.organizations, [{ id: 'acme', catalogue: 'ready' }]);
|
||||
assert.equal(JSON.stringify(status).includes(root), false);
|
||||
});
|
||||
|
||||
test('fetch runs in the background, reports each step and writes a receipt with every pinned hash', async t => {
|
||||
const steps = [];
|
||||
let release;
|
||||
const gate = new Promise(r => { release = r; });
|
||||
const { dataRoot, admin } = fixture(t, { fetch: async ({ pin, progress }) => { assert.equal(pin, PIN); progress('verify-archive'); steps.push('called'); await gate; } });
|
||||
assert.deepEqual(admin.fetchStart(), { state: 'running' });
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.throws(() => admin.fetchStart(), code('proxy-admin-busy'));
|
||||
await assert.rejects(admin.unitAction('start'), code('proxy-admin-busy'));
|
||||
const running = await admin.status();
|
||||
assert.equal(running.fetch.state, 'running'); assert.equal(running.fetch.step, 'verify-archive'); assert.equal(running.busy, 'fetch');
|
||||
release();
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
const done = await admin.status();
|
||||
assert.equal(done.fetch.state, 'done');
|
||||
assert.deepEqual(done.fetch.receipt, { time: new Date(0).toISOString(), op: 'fetch', version: PIN.version, panelVersion: PIN.panel.version, archiveSha256: PIN.archive.sha256, panelSha256: PIN.panel.sha256, binarySha256: PIN.binary.sha256, verified: true });
|
||||
assert.deepEqual(receipts(dataRoot), [done.fetch.receipt]);
|
||||
});
|
||||
|
||||
test('a fetch that ends while status is being read is never reported done with the release still not fetched', async t => {
|
||||
// E2E finding: status read the release before its awaits and the job after them, so the page
|
||||
// saw "done" next to "Not fetched", stopped polling and kept offering Fetch.
|
||||
const sha = text => createHash('sha256').update(text).digest('hex');
|
||||
const pin = { ...PIN, archive: { ...PIN.archive, sha256: sha('a') }, binary: { sha256: sha('b') }, panel: { ...PIN.panel, sha256: sha('p') } };
|
||||
let release, during = null;
|
||||
const gate = new Promise(r => { release = r; });
|
||||
const f = fixture(t, { pin, statusHook: async () => { const d = during; during = null; await d?.(); }, fetch: async ({ dataRoot }) => {
|
||||
await gate;
|
||||
const dir = join(dataRoot, 'opt', 'cliproxyapi', PIN.version);
|
||||
fs.mkdirSync(join(dir, 'static'), { recursive: true });
|
||||
fs.writeFileSync(join(dir, 'cli-proxy-api'), 'b'); fs.writeFileSync(join(dir, 'static', 'management.html'), 'p');
|
||||
} });
|
||||
f.admin.fetchStart();
|
||||
await new Promise(r => setImmediate(r));
|
||||
during = async () => { release(); await new Promise(r => setTimeout(r, 10)); };
|
||||
const racing = await f.admin.status();
|
||||
assert.equal(during, null, 'the fetch ended inside this status read');
|
||||
assert.equal(racing.fetch.state === 'done' && !racing.release.fetched, false, `fetch ${racing.fetch.state} with release fetched=${racing.release.fetched}`);
|
||||
const after = await f.admin.status();
|
||||
assert.equal(after.fetch.state, 'done'); assert.equal(after.release.verified, true); assert.equal(after.next[0], 'init');
|
||||
});
|
||||
|
||||
test('a refused fetch keeps its code and writes no receipt', async t => {
|
||||
const { dataRoot, admin } = fixture(t, { fetch: async () => { throw Object.assign(Error('/home/x upstream body'), { code: 'proxy-fetch-hash' }); } });
|
||||
admin.fetchStart();
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
const status = await admin.status();
|
||||
assert.equal(status.fetch.state, 'failed'); assert.equal(status.fetch.error, 'proxy-fetch-hash');
|
||||
assert.equal(JSON.stringify(status).includes('upstream body'), false);
|
||||
assert.deepEqual(receipts(dataRoot), []);
|
||||
});
|
||||
|
||||
test('init sends the arguments to proxy.init and its receipt carries settings only', async t => {
|
||||
const { dataRoot, admin } = fixture(t);
|
||||
const calls = [];
|
||||
const result = await admin.init(async (verb, args) => { calls.push([verb, args]); return { port: 18432, binary: '/fixture', rotateGraceMinutes: 5, firstOutputSeconds: 90 }; }, { generate: true, port: 18432 });
|
||||
assert.deepEqual(calls, [['proxy.init', { generate: true, port: 18432 }]]);
|
||||
assert.deepEqual(result.settings, { port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 90 });
|
||||
assert.deepEqual(receipts(dataRoot), [{ time: new Date(0).toISOString(), op: 'init', generated: true, port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 90 }]);
|
||||
await admin.init(async () => ({ port: 18433, binary: '/fixture' }), { key: SENTINEL });
|
||||
assert.equal(fs.readFileSync(receiptFile(dataRoot), 'utf8').includes(SENTINEL), false);
|
||||
assert.equal(JSON.stringify(receipts(dataRoot)).includes('/fixture'), false);
|
||||
});
|
||||
|
||||
test('fence clear refuses without a restart, and after an unconfirmed one, before calling the bus', async t => {
|
||||
const { admin, ops } = fixture(t, { restart: { state: {}, evidence: { unit: 'mosaic-proxy-zz.service', confirmed: false, before: { pid: 10 }, after: { pid: 10, listening: true }, observedAt: new Date(0).toISOString() } } });
|
||||
const calls = [];
|
||||
const call = async (verb, args) => { calls.push([verb, args]); return { cleared: true }; };
|
||||
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'));
|
||||
const restart = await admin.unitAction('restart');
|
||||
assert.equal(restart.evidence.confirmed, false);
|
||||
assert.equal(restart.receipt.confirmed, false);
|
||||
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'));
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(ops, ['restart']);
|
||||
});
|
||||
|
||||
test('fence clear after a confirmed restart sends that evidence once; another unit action ends it', async t => {
|
||||
const { dataRoot, admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
|
||||
const calls = [];
|
||||
const call = async (verb, args) => { calls.push([verb, args]); return { cleared: true, evidence: { after: { pid: 11 } } }; };
|
||||
const restart = await admin.unitAction('restart');
|
||||
assert.equal(restart.evidence.confirmed, true);
|
||||
assert.deepEqual((await admin.status()).restart, { confirmed: true, observedAt: new Date(0).toISOString(), pid: 11 });
|
||||
const cleared = await admin.fenceClear(call);
|
||||
assert.deepEqual(calls, [['proxy.fence.clear', { evidence: restart.evidence }]]);
|
||||
assert.equal(cleared.cleared, true);
|
||||
assert.deepEqual(cleared.receipt.evidence, { after: { pid: 11 } });
|
||||
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'), 'one restart, one clear');
|
||||
await admin.unitAction('restart');
|
||||
await admin.unitAction('stop');
|
||||
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'), 'a stop ends the confirmed restart');
|
||||
assert.deepEqual(receipts(dataRoot).map(r => r.op), ['unit.restart', 'fence.clear', 'unit.restart', 'unit.stop']);
|
||||
});
|
||||
|
||||
test('a fence clear the bus did not answer keeps the confirmed restart for a retry', async t => {
|
||||
const { admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
|
||||
let fail = true;
|
||||
const call = async () => { if (fail) { fail = false; throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); } return { cleared: true }; };
|
||||
await admin.unitAction('restart');
|
||||
await assert.rejects(admin.fenceClear(call), /bus down/);
|
||||
assert.equal((await admin.status()).restart?.confirmed, true, 'the restart is still confirmed');
|
||||
assert.equal((await admin.fenceClear(call)).cleared, true);
|
||||
assert.equal((await admin.status()).restart, null);
|
||||
});
|
||||
|
||||
test('next step walks fetch, init lock, repair, init, unit install, start, restart, fence and accounts', async t => {
|
||||
const sha = text => createHash('sha256').update(text).digest('hex');
|
||||
const pin = { ...PIN, archive: { ...PIN.archive, sha256: sha('a') }, binary: { sha256: sha('b') }, panel: { ...PIN.panel, sha256: sha('p') } };
|
||||
const state = { installed: false, active: 'inactive', healthy: false, pid: null };
|
||||
const f = fixture(t, { unitState: state, pin });
|
||||
const next = async () => (await f.admin.status()).next[0];
|
||||
assert.equal(await next(), 'fetch');
|
||||
const binDir = join(f.dataRoot, 'opt', 'cliproxyapi', PIN.version);
|
||||
fs.mkdirSync(join(binDir, 'static'), { recursive: true });
|
||||
fs.writeFileSync(join(binDir, 'cli-proxy-api'), 'b'); fs.writeFileSync(join(binDir, 'static', 'management.html'), 'wrong');
|
||||
assert.equal(await next(), 'fetch', 'a hash mismatch is not a fetched release');
|
||||
fs.rmSync(join(binDir, 'static', 'management.html')); fs.writeFileSync(join(binDir, 'static', 'management.html'), 'p');
|
||||
fs.mkdirSync(join(f.configDir, 'secrets'), { mode: 0o700 });
|
||||
fs.writeFileSync(f.files.key, 'fixture', { mode: 0o600 });
|
||||
fs.writeFileSync(f.files.lock, '', { mode: 0o600 });
|
||||
assert.equal(await next(), 'init-lock-wait', 'a key file blocks the lock clear');
|
||||
fs.rmSync(f.files.key);
|
||||
assert.equal(await next(), 'init-lock-clear');
|
||||
fs.rmSync(f.files.lock);
|
||||
fs.writeFileSync(f.files.key, 'fixture', { mode: 0o600 });
|
||||
assert.equal(await next(), 'init-repair');
|
||||
fs.rmSync(f.files.key);
|
||||
assert.equal(await next(), 'init');
|
||||
fs.writeFileSync(f.files.key, 'fixture', { mode: 0o600 });
|
||||
fs.mkdirSync(f.files.proxyDir, { mode: 0o700 });
|
||||
fs.writeFileSync(f.files.settings, JSON.stringify({ binary: join(binDir, 'cli-proxy-api'), port: 18432 }), { mode: 0o600 });
|
||||
const initialized = await f.admin.status();
|
||||
assert.equal(initialized.init.initialized, true);
|
||||
assert.deepEqual(initialized.init.settings, { port: 18432, rotateGraceMinutes: null, firstOutputSeconds: null });
|
||||
assert.equal(initialized.next[0], 'unit-install');
|
||||
state.installed = true;
|
||||
assert.equal(await next(), 'unit-start');
|
||||
state.active = 'active';
|
||||
assert.equal(await next(), 'unit-restart', 'active but not answering');
|
||||
state.healthy = true;
|
||||
assert.equal(await next(), 'accounts');
|
||||
fs.writeFileSync(join(f.files.proxyDir, 'mosaic-keys.uncertain'), JSON.stringify({ org: 'acme', op: 'write', time: new Date(0).toISOString(), pid: 1 }), { mode: 0o600 });
|
||||
const fenced = await f.admin.status();
|
||||
assert.deepEqual(fenced.fence, { set: true, org: 'acme', op: 'write', time: new Date(0).toISOString(), invalid: false });
|
||||
assert.equal(fenced.next[0], 'unit-restart', 'a fence needs a restart first');
|
||||
await f.admin.unitAction('restart');
|
||||
assert.equal(await next(), 'fence-clear');
|
||||
});
|
||||
|
||||
test('the init lock clears through the controller only on the README conditions', async t => {
|
||||
const { dataRoot, admin, files } = fixture(t);
|
||||
await assert.rejects(admin.initLockClear(), code('proxy-init-lock-absent'));
|
||||
fs.writeFileSync(files.lock, '', { mode: 0o600 });
|
||||
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
|
||||
await assert.rejects(admin.initLockClear(), code('proxy-init-lock-held'));
|
||||
assert.deepEqual((await admin.status()).initLock, { present: true, clearable: false, blockers: ['the proxy state directory exists'] });
|
||||
fs.rmdirSync(files.proxyDir);
|
||||
const result = await admin.initLockClear();
|
||||
assert.equal(result.receipt.op, 'init-lock.clear');
|
||||
assert.equal(fs.existsSync(files.lock), false);
|
||||
assert.deepEqual(receipts(dataRoot).map(r => r.op), ['init-lock.clear']);
|
||||
});
|
||||
|
||||
test('render replaces the home directory with ~', async t => {
|
||||
const { admin } = fixture(t);
|
||||
const { unit, text } = await admin.render();
|
||||
assert.equal(unit, 'mosaic-proxy-zz.service');
|
||||
if (process.env.HOME && process.env.HOME !== '/') assert.equal(text.includes(process.env.HOME), false);
|
||||
});
|
||||
|
||||
test('status carries the last receipts from proxy-admin.jsonl, so the page shows them after a reload', async t => {
|
||||
const { dataRoot, admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
|
||||
assert.deepEqual((await admin.status()).receipts, []);
|
||||
for (let i = 0; i < 12; i++) await admin.unitAction('stop');
|
||||
fs.appendFileSync(receiptFile(dataRoot), '{"op":"torn');
|
||||
const { receipts } = await admin.status();
|
||||
assert.equal(receipts.length, 10);
|
||||
assert.ok(receipts.every(r => r.op === 'unit.stop'));
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// #1873 proxy setup host helpers: restart proof from a fake /proc, the stale init-lock rules,
|
||||
// unit control over a fake systemctl, and receipts that carry no path or key.
|
||||
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 { createHash } from 'node:crypto';
|
||||
import { PIN } from '../src/common.mjs';
|
||||
import { installPaths } from '../src/settings.mjs';
|
||||
import {
|
||||
processTree, pidListensOn, processStartMs, verifyRestart, initLockStatus, clearInitLock, initFiles, initState,
|
||||
fenceState, createUnitControl, releaseState, receiptFile, unitName,
|
||||
} from '../src/admin.mjs';
|
||||
|
||||
const code = name => error => error.code === name;
|
||||
const scratch = t => { const dir = fs.mkdtempSync(join(tmpdir(), 'pa-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); return dir; };
|
||||
|
||||
// A fake /proc: runner pid 100 (start tick 500), its child 101 listens on `port` via socket inode 5555.
|
||||
function fakeProc(t, { port = 8080, listener = 101, start = 500 } = {}) {
|
||||
const proc = join(scratch(t), 'proc');
|
||||
const stat = (pid, ppid, ticks) => `${pid} (node) S ${ppid} 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 ${ticks} 0 0\n`;
|
||||
fs.mkdirSync(join(proc, 'net'), { recursive: true });
|
||||
fs.writeFileSync(join(proc, 'stat'), 'cpu 0\nbtime 1000\n');
|
||||
for (const [pid, ppid, ticks] of [[1, 0, 1], [100, 1, start], [101, 100, start + 1], [200, 1, 1]]) {
|
||||
fs.mkdirSync(join(proc, String(pid), 'fd'), { recursive: true });
|
||||
fs.writeFileSync(join(proc, String(pid), 'stat'), stat(pid, ppid, ticks));
|
||||
}
|
||||
fs.symlinkSync('socket:[5555]', join(proc, String(listener), 'fd', '3'));
|
||||
fs.symlinkSync('/dev/null', join(proc, '100', 'fd', '0'));
|
||||
const hex = port.toString(16).toUpperCase().padStart(4, '0');
|
||||
fs.writeFileSync(join(proc, 'net', 'tcp'), ' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n'
|
||||
+ ` 0: 0100007F:${hex} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5555 1 0 100 0 0 10 0\n`
|
||||
+ ` 1: 0100007F:${hex} 0100007F:9999 01 00000000:00000000 00:00000000 00000000 1000 0 7777 1 0 100 0 0 10 0\n`);
|
||||
return proc;
|
||||
}
|
||||
|
||||
test('processTree follows parent links; pidListensOn finds a listener in a child of the unit main pid', t => {
|
||||
const proc = fakeProc(t);
|
||||
assert.deepEqual(processTree(100, { proc }).sort(), [100, 101]);
|
||||
assert.equal(pidListensOn(100, 8080, { proc }), true);
|
||||
assert.equal(pidListensOn(200, 8080, { proc }), false, 'an unrelated process does not count');
|
||||
assert.equal(pidListensOn(100, 8081, { proc }), false, 'another port does not count');
|
||||
assert.equal(processStartMs(100, { proc }), 1000 * 1000 + 5000);
|
||||
assert.equal(processStartMs(999, { proc }), null);
|
||||
});
|
||||
|
||||
const evidence = (over = {}) => ({
|
||||
unit: 'mosaic-proxy.service', confirmed: true,
|
||||
before: { pid: 50, since: 10, active: 'active' }, after: { pid: 100, since: 20, active: 'active', listening: true },
|
||||
observedAt: new Date(2000).toISOString(), ...over,
|
||||
});
|
||||
const proof = { fence: { time: new Date(1000).toISOString() }, port: 8080, startMs: () => 1500, listens: (pid, port) => pid === 100 && port === 8080, now: () => 2000 };
|
||||
|
||||
test('verifyRestart accepts a confirmed restart to a new live listener started after the fence', () => {
|
||||
const result = verifyRestart(evidence(), proof);
|
||||
assert.deepEqual(result, { unit: 'mosaic-proxy.service', before: { pid: 50 }, after: { pid: 100, startedAt: new Date(1500).toISOString() }, observedAt: new Date(2000).toISOString(), listeningPort: 8080 });
|
||||
});
|
||||
|
||||
test('verifyRestart refuses every unproved restart with proxy-restart-unconfirmed', () => {
|
||||
const cases = [
|
||||
['not confirmed', evidence({ confirmed: false }), proof],
|
||||
['same pid', evidence({ before: { pid: 100, since: 10, active: 'active' } }), proof],
|
||||
['extra field', { ...evidence(), forged: true }, proof],
|
||||
['bad unit name', evidence({ unit: '../x.service' }), proof],
|
||||
['stale observation', evidence({ observedAt: new Date(2000).toISOString() }), { ...proof, now: () => 2000 + 11 * 60 * 1000 }],
|
||||
['process gone', evidence(), { ...proof, startMs: () => null }],
|
||||
['started before the fence', evidence(), { ...proof, startMs: () => 900 }],
|
||||
['fence without time', evidence(), { ...proof, fence: { invalid: true } }],
|
||||
['not listening', evidence(), { ...proof, listens: () => false }],
|
||||
['no port', evidence(), { ...proof, port: null }],
|
||||
['not an object', 'restarted', proof],
|
||||
];
|
||||
for (const [name, value, options] of cases) assert.throws(() => verifyRestart(value, options), code('proxy-restart-unconfirmed'), name);
|
||||
});
|
||||
|
||||
function dirs(t) {
|
||||
const root = scratch(t);
|
||||
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
|
||||
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); fs.mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
|
||||
return { root, configDir, dataRoot, files: initFiles(configDir, dataRoot) };
|
||||
}
|
||||
|
||||
test('init lock: absent refuses, held names each README blocker, clear needs every condition and writes a receipt', t => {
|
||||
const { root, configDir, dataRoot, files } = dirs(t);
|
||||
const stopped = { stopped: true, running: [] };
|
||||
assert.deepEqual(initLockStatus({ configDir, dataRoot, buses: stopped }), { present: false, clearable: false, blockers: [] });
|
||||
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: stopped }), code('proxy-init-lock-absent'));
|
||||
fs.writeFileSync(files.lock, '', { mode: 0o600 });
|
||||
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
|
||||
const held = initLockStatus({ configDir, dataRoot, buses: { stopped: false, running: ['organization acme'] } });
|
||||
assert.equal(held.clearable, false);
|
||||
assert.deepEqual(held.blockers, ['bus running: organization acme', 'the proxy state directory exists']);
|
||||
assert.equal(JSON.stringify(held).includes(root), false, 'blockers name labels, never paths');
|
||||
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: { stopped: false, running: ['the instance bus'] } }), code('proxy-init-lock-held'));
|
||||
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: stopped }), code('proxy-init-lock-held'));
|
||||
assert.equal(fs.existsSync(files.lock), true);
|
||||
fs.rmdirSync(files.proxyDir);
|
||||
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: undefined }), code('proxy-init-lock-held'), 'no bus proof counts as running');
|
||||
const receipt = clearInitLock({ configDir, dataRoot, buses: stopped, now: () => new Date(0) });
|
||||
assert.equal(fs.existsSync(files.lock), false);
|
||||
assert.deepEqual(receipt, { op: 'init-lock.clear', time: new Date(0).toISOString(), conditions: ['bus stopped', 'no proxy.json', 'no management key file', 'no proxy state directory'] });
|
||||
assert.deepEqual(fs.readFileSync(receiptFile(dataRoot), 'utf8').trim().split('\n').map(JSON.parse), [receipt]);
|
||||
assert.equal((fs.statSync(receiptFile(dataRoot)).mode & 0o777), 0o600);
|
||||
});
|
||||
|
||||
test('initState reports partial state from file presence and fenceState reads the fence without writing', t => {
|
||||
const { configDir, dataRoot, files } = dirs(t);
|
||||
assert.deepEqual(initState(configDir, dataRoot), { initialized: false, partial: false, problem: null, settings: null, files: { settings: false, key: false, proxyDir: false, lock: false }, lock: false });
|
||||
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
|
||||
assert.equal(initState(configDir, dataRoot).partial, true);
|
||||
assert.equal(fenceState(dataRoot), null);
|
||||
fs.writeFileSync(join(files.proxyDir, 'mosaic-keys.uncertain'), JSON.stringify({ org: 'acme', op: 'apikey.add', time: '2026-11-11T00:00:00.000Z', pid: 7 }), { mode: 0o600 });
|
||||
assert.deepEqual(fenceState(dataRoot), { org: 'acme', op: 'apikey.add', time: '2026-11-11T00:00:00.000Z', pid: 7 });
|
||||
fs.chmodSync(join(files.proxyDir, 'mosaic-keys.uncertain'), 0o644);
|
||||
assert.deepEqual(fenceState(dataRoot), { invalid: true });
|
||||
assert.deepEqual(fs.readdirSync(files.proxyDir), ['mosaic-keys.uncertain']);
|
||||
});
|
||||
|
||||
test('releaseState: nothing fetched names the next step without a path', t => {
|
||||
const { root, dataRoot } = dirs(t);
|
||||
const state = releaseState(dataRoot);
|
||||
assert.equal(state.fetched, false); assert.equal(state.verified, false); assert.equal(state.problem, 'proxy-install-missing');
|
||||
assert.equal(JSON.stringify(state).includes(root), false);
|
||||
});
|
||||
|
||||
test('releaseState re-hashes a release file rewritten in place with its mtime set back', async t => {
|
||||
const { dataRoot } = dirs(t);
|
||||
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
const binary = Buffer.from('made-up proxy binary 0001'), panel = Buffer.from('<html>made-up panel</html>');
|
||||
const pin = { ...PIN, version: '0.0.0-test', binary: { ...PIN.binary, sha256: sha(binary) }, panel: { ...PIN.panel, sha256: sha(panel) } };
|
||||
const paths = installPaths(dataRoot, pin);
|
||||
fs.mkdirSync(join(paths.dir, 'static'), { recursive: true });
|
||||
fs.writeFileSync(paths.binary, binary, { mode: 0o755 }); fs.writeFileSync(paths.panel, panel);
|
||||
const when = 1700000000;
|
||||
for (const file of [paths.binary, paths.panel]) fs.utimesSync(file, when, when);
|
||||
assert.equal(releaseState(dataRoot, pin).verified, true);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const before = fs.statSync(paths.binary, { bigint: true });
|
||||
fs.writeFileSync(paths.binary, Buffer.from('made-up proxy binary 0002'), { flag: 'r+' });
|
||||
fs.utimesSync(paths.binary, when, when);
|
||||
const after = fs.statSync(paths.binary, { bigint: true });
|
||||
assert.deepEqual([after.ino, after.size, after.mtimeNs, after.mode], [before.ino, before.size, before.mtimeNs, before.mode], 'only ctime tells the files apart');
|
||||
const state = releaseState(dataRoot, pin);
|
||||
assert.equal(state.verified, false); assert.equal(state.problem, 'proxy-install-invalid');
|
||||
});
|
||||
|
||||
test('unit names follow the instance and refuse a bad instance', () => {
|
||||
assert.equal(unitName(null), 'mosaic-proxy.service');
|
||||
assert.equal(unitName('dev'), 'mosaic-proxy.service');
|
||||
assert.equal(unitName('uiorg'), 'mosaic-proxy-uiorg.service');
|
||||
assert.throws(() => unitName('../x'), code('proxy-unit-usage'));
|
||||
});
|
||||
|
||||
// A fake systemctl / proxy-service.sh. `states` is the sequence `systemctl show` reports.
|
||||
function fakeUnit(states, { install = { code: 0, stdout: '', stderr: '' }, verbs = [] } = {}) {
|
||||
let i = 0;
|
||||
const run = async (command, args) => {
|
||||
if (command === 'systemctl' && args[1] === 'show') {
|
||||
const s = states[Math.min(i++, states.length - 1)];
|
||||
return { code: 0, stdout: `LoadState=${s.load ?? 'loaded'}\nActiveState=${s.active}\nSubState=running\nMainPID=${s.pid ?? 0}\nExecMainStartTimestampMonotonic=${s.since ?? 0}\nNRestarts=0\n`, stderr: '' };
|
||||
}
|
||||
if (command === 'systemctl') { verbs.push(args[1]); return { code: 0, stdout: '', stderr: '' }; }
|
||||
verbs.push(args.filter(a => !a.endsWith('proxy-service.sh')).join(' '));
|
||||
return install;
|
||||
};
|
||||
return createUnitControl({ instance: 'zz', port: () => 18432, run, probe: async () => true, waitMs: 0, pollMs: 0, now: () => new Date(0) });
|
||||
}
|
||||
|
||||
test('unit restart is confirmed only when a new main process is active and listening', async () => {
|
||||
const verbs = [];
|
||||
const control = fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'active', pid: 11, since: 6 }], { verbs });
|
||||
const { evidence: e } = await control.restart();
|
||||
assert.equal(e.confirmed, true);
|
||||
assert.deepEqual(e.before, { pid: 10, since: 5, active: 'active' });
|
||||
assert.deepEqual(e.after, { pid: 11, since: 6, active: 'active', listening: true });
|
||||
assert.equal(e.unit, 'mosaic-proxy-zz.service');
|
||||
assert.deepEqual(verbs, ['restart']);
|
||||
const same = await fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'active', pid: 10, since: 5 }]).restart();
|
||||
assert.equal(same.evidence.confirmed, false, 'the same process is not a restart');
|
||||
const down = await fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'failed', pid: 0, since: 0 }]).restart();
|
||||
assert.equal(down.evidence.confirmed, false, 'a failed unit is not a restart');
|
||||
});
|
||||
|
||||
test('unit actions map proxy-service refusals by code and refuse a missing unit', async () => {
|
||||
await assert.rejects(fakeUnit([{ load: 'not-found', active: 'inactive' }]).start(), code('proxy-unit-missing'));
|
||||
await assert.rejects(fakeUnit([{ load: 'not-found', active: 'inactive' }]).restart(), code('proxy-unit-missing'));
|
||||
await assert.rejects(fakeUnit([{ active: 'inactive' }], { install: { code: 3, stdout: '', stderr: 'proxy-service: proxy-unit-exists\n' } }).install(), code('proxy-unit-exists'));
|
||||
await assert.rejects(fakeUnit([{ active: 'inactive' }], { install: { code: 3, stdout: '', stderr: 'proxy-service: /home/x secret detail\n' } }).install(), code('proxy-unit-failed'));
|
||||
await assert.rejects(fakeUnit([{ active: 'active', pid: 0 }, { active: 'failed', pid: 0 }]).start(), code('proxy-unit-unhealthy'));
|
||||
const verbs = [];
|
||||
const started = await fakeUnit([{ active: 'inactive' }, { active: 'active', pid: 9, since: 1 }], { verbs }).start();
|
||||
assert.equal(started.healthy, true); assert.deepEqual(verbs, ['start']);
|
||||
assert.throws(() => createUnitControl({ unitDir: 'relative' }), code('proxy-unit-path'));
|
||||
});
|
||||
@@ -7,8 +7,8 @@
|
||||
// never a token, a token fragment or digest, a path or an environment name.
|
||||
//
|
||||
// { copyId, orgIncarnation, source: { level, name, project }, definition: { ref, rev, sha256 },
|
||||
// generation, state, services: { gitea?: { slot, scopes, resources }, vikunja?: {...} },
|
||||
// decision, tracker? }
|
||||
// generation, state, services: { gitea?: { slot, scopes, resources, rotateBy?, grantId?,
|
||||
// grantGeneration? }, vikunja?: {...} }, decision, tracker? }
|
||||
//
|
||||
// copyId is 128 random bits minted on the first approval for that copy; it is kept, with the
|
||||
// generation counting up, through every later change and the unbound tombstone. orgIncarnation is
|
||||
@@ -19,9 +19,15 @@
|
||||
// (composition, instructions and personality), a role template by the hash readTemplate takes over
|
||||
// its file and contract.
|
||||
//
|
||||
// States: `pending` is approved and not verified. Only a verifier (slice CB2, not built) may turn a
|
||||
// record `bound`, and only `bound` resolves. `unbound` is the tombstone an unbind leaves. Nothing
|
||||
// in CB1 writes `bound`, so no copy binding resolves and no copy launches with one.
|
||||
// States: `pending` is approved and not verified. Only a verifier (slice CB2) may turn a record
|
||||
// `bound`, and only `bound` resolves. `unbound` is the tombstone an unbind leaves.
|
||||
//
|
||||
// CB2 (grant metadata): a service the verifier proved carries `rotateBy` (the owner's rotation day),
|
||||
// `grantId` (the service's opaque grant id, `gitea:<user id>:<token id>`: ids the service assigned,
|
||||
// never token bytes) and `grantGeneration` (counts every verified grant of that service; it stays
|
||||
// when a new issue strips the other two). The verifier's commit writes `bound` only when every
|
||||
// service holds a grant, and no grant id appears twice across the organization's copies. Launch
|
||||
// admission, which re-proves each grant, is CB3.
|
||||
//
|
||||
// Everything here is pure: no file is read, no secret, no slot.
|
||||
|
||||
@@ -39,6 +45,11 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
|
||||
const RECORD_KEYS = ["copyId", "orgIncarnation", "source", "definition", "generation", "state", "services", "decision", "tracker"];
|
||||
const SERVICE_KEYS = ["slot", "scopes", "resources"];
|
||||
const GRANT_KEYS = ["rotateBy", "grantId", "grantGeneration"];
|
||||
const GRANT_ID = /^(gitea|vikunja):[1-9][0-9]{0,15}:[1-9][0-9]{0,15}$/;
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const realDay = (value) => typeof value === "string" && DAY.test(value) && !Number.isNaN(Date.parse(value))
|
||||
&& new Date(value).toISOString().slice(0, 10) === value;
|
||||
export const BINDING_STATES = Object.freeze(["pending", "bound", "unbound"]);
|
||||
// CB1 binds the core services (vocabulary SERVICES) only. A plugin service has no verifier design yet.
|
||||
export const BINDABLE_SERVICES = SERVICES;
|
||||
@@ -46,6 +57,18 @@ export const MAX_BINDINGS = 256;
|
||||
|
||||
export const newCopyId = () => randomBytes(16).toString("hex");
|
||||
|
||||
// CB2: the one service account a copy's binding may use, derived from the registry incarnation, the
|
||||
// copyId and the service, so another organization, another copy and another service never share
|
||||
// it. Gitea limits a login to 40 characters; this is 36. Not a secret and not token-derived.
|
||||
export function copyBotLogin(incarnation, copyId, service) {
|
||||
if (typeof incarnation !== "string" || !INCARNATION.test(incarnation) || typeof copyId !== "string" || !COPY_ID.test(copyId)
|
||||
|| !BINDABLE_SERVICES.includes(service)) return null;
|
||||
return `mosaic-copy-${sha256(`copy-bot\0${incarnation}\0${copyId}\0${service}`).slice(0, 24)}`;
|
||||
}
|
||||
// Whether every service of a binding record holds a verified grant (CB2).
|
||||
export const allGranted = (services) => isPlainObject(services) && Object.keys(services).length > 0
|
||||
&& Object.values(services).every((v) => isPlainObject(v) && typeof v.grantId === "string");
|
||||
|
||||
// Fixed text for every binding refusal. `service` is a registered service label or null, never a
|
||||
// submitted value.
|
||||
const TEXT = {
|
||||
@@ -53,6 +76,8 @@ const TEXT = {
|
||||
"credential-unbound": (s) => `credentials.${s} needs an owner-approved binding.`,
|
||||
"credential-scope-exceeded": (s) => `credentials.${s}.scopes exceeds the approved role.`,
|
||||
"credential-scope-unverified": (s) => `credentials.${s} permissions could not be verified.`,
|
||||
"credential-identity-refused": (s) => `credentials.${s} is not this copy's own service account.`,
|
||||
"credential-date": (s) => `credentials.${s} rotation date is invalid.`,
|
||||
"credential-binding-changed": (s) => `credentials.${s} changed; read and approve again.`,
|
||||
"credential-role-changed": (s) => `credentials.${s} no longer matches the approved definition; the owner must review the binding.`,
|
||||
"copy-unavailable": () => "copy is no longer active in this organization.",
|
||||
@@ -195,7 +220,7 @@ export function validateCopyBindings(value, { trackers = [] } = {}) {
|
||||
if (!isPlainObject(value)) refuse("copyBindings field is invalid");
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > MAX_BINDINGS) refuse("copyBindings field is invalid");
|
||||
const bots = new Set(trackers.map((t) => t.bot)), botIds = new Set(trackers.map((t) => t.botId)), copyIds = new Set();
|
||||
const bots = new Set(trackers.map((t) => t.bot)), botIds = new Set(trackers.map((t) => t.botId)), copyIds = new Set(), grants = new Set();
|
||||
keys.forEach((instance, n) => {
|
||||
const i = n + 1, r = value[instance];
|
||||
if (!ID_PATTERN.test(instance)) refuse(`copyBindings entry ${i} name is invalid`);
|
||||
@@ -225,8 +250,18 @@ export function validateCopyBindings(value, { trackers = [] } = {}) {
|
||||
if ((r.state === "unbound") !== (services.length === 0)) refuse(at(i, "services"));
|
||||
for (const service of services) {
|
||||
const v = r.services[service];
|
||||
if (!BINDABLE_SERVICES.includes(service) || !isPlainObject(v) || Object.keys(v).sort().join(",") !== SERVICE_KEYS.slice().sort().join(",")
|
||||
|| v.slot !== "self" || !validScopes(service, v.scopes)) refuse(at(i, "services"));
|
||||
const extra = isPlainObject(v) ? Object.keys(v).filter((k) => !SERVICE_KEYS.includes(k)) : [];
|
||||
if (!BINDABLE_SERVICES.includes(service) || !isPlainObject(v) || SERVICE_KEYS.some((k) => !Object.hasOwn(v, k))
|
||||
|| extra.some((k) => !GRANT_KEYS.includes(k)) || v.slot !== "self" || !validScopes(service, v.scopes)) refuse(at(i, "services"));
|
||||
// CB2: rotateBy and grantId come together; a grant id needs its generation; one grant id per organization.
|
||||
if ((v.rotateBy === undefined) !== (v.grantId === undefined) || (v.grantId !== undefined && v.grantGeneration === undefined)
|
||||
|| (v.rotateBy !== undefined && !realDay(v.rotateBy))
|
||||
|| (v.grantId !== undefined && (typeof v.grantId !== "string" || !GRANT_ID.test(v.grantId) || v.grantId.split(":")[0] !== service))
|
||||
|| (v.grantGeneration !== undefined && (!Number.isSafeInteger(v.grantGeneration) || v.grantGeneration < 1))) refuse(at(i, "services"));
|
||||
if (v.grantId !== undefined) {
|
||||
if (grants.has(v.grantId)) refuse(`copyBindings entry ${i} services field uses a grant another binding uses`);
|
||||
grants.add(v.grantId);
|
||||
}
|
||||
const p = v.resources;
|
||||
if (!isPlainObject(p) || Object.keys(p).join(",") !== "projects" || !Array.isArray(p.projects) || p.projects.length === 0
|
||||
|| p.projects.some((x) => typeof x !== "string" || !ID_PATTERN.test(x)) || new Set(p.projects).size !== p.projects.length
|
||||
|
||||
@@ -13,7 +13,7 @@ import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { orgIncarnation, withRegistryWriter } from "./org-registry.mjs";
|
||||
import { bindingText, definitionPin, samePin } from "./copy-bindings.mjs";
|
||||
import { allGranted, bindingText, definitionPin, samePin } from "./copy-bindings.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
import { checkDefinition, checkDefinitionName, instancesNaming, readTemplate } from "./definitions.mjs";
|
||||
|
||||
@@ -594,6 +594,86 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
});
|
||||
}
|
||||
|
||||
// #1873 CB2 (design section 5 steps 4 to 6): a copy's service credential is issued in two writes
|
||||
// around the remote work; no lock is held across it. stageCopyGrant deactivates first: the record
|
||||
// goes pending, the service loses its grantId and rotateBy (grantGeneration stays) and the binding
|
||||
// generation counts up. Only then does the bus write the slot and ask the service. commitCopyGrant
|
||||
// rechecks under the locks everything the stage pinned (registry incarnation, document revision,
|
||||
// generation, copyId, decision, source and definition pin), runs `beforeWrite` (the bus's slot and
|
||||
// session recheck), then writes the grant: grantGeneration counts up, and the record is `bound` once
|
||||
// every service holds a grant. A refusal or a crash between the two leaves the record pending and
|
||||
// nothing resolves it; recovery is a new verified issue. Owner only, as a binding decision.
|
||||
// `op` is { instance, service, copyId, generation, decision, incarnation } as the bus read it.
|
||||
function copyGrantFence(old, op, { actor, owner, definitions }) {
|
||||
const { instance, service } = op;
|
||||
const agents = [
|
||||
...Object.entries(old.roles ?? {}).flatMap(([n, e]) => [n, `${e?.definition}.${n}`]),
|
||||
...Object.entries(old.projects ?? {}).flatMap(([p, v]) => Object.entries(v?.agents ?? {}).flatMap(([n, e]) => [n, `${n}.${p}`, `${e?.definition}.${n}.${p}`])),
|
||||
...Object.keys(old.copyBindings ?? {}), ...Object.keys(definitions ?? {}), ...(old.retired ?? []),
|
||||
];
|
||||
if (owner !== true || actor === "tester" || agents.includes(actor)) fail("human-required", bindingText("human-required"));
|
||||
const current = old.copyBindings?.[instance];
|
||||
if (!current || current.copyId !== op.copyId || current.generation !== op.generation || current.orgIncarnation !== op.incarnation
|
||||
|| current.decision !== op.decision || current.state === "unbound" || !Object.hasOwn(current.services ?? {}, service)
|
||||
|| Object.hasOwn(old.roles ?? {}, instance)) fail("credential-binding-changed", bindingText("credential-binding-changed", service));
|
||||
const pin = definitionPin(current.definition.ref, { definitions: { ...(definitions ?? {}), ...(old.definitions ?? {}) }, rolesDir, readTemplate });
|
||||
if (!samePin(pin, current.definition)) fail("credential-role-changed", bindingText("credential-role-changed", service));
|
||||
return current;
|
||||
}
|
||||
const grantOp = (op) => {
|
||||
if (!op || typeof op.instance !== "string" || typeof op.service !== "string" || !Number.isSafeInteger(op.generation)
|
||||
|| typeof op.copyId !== "string" || typeof op.decision !== "string" || typeof op.incarnation !== "string") fail("invalid-request");
|
||||
};
|
||||
function stageCopyGrant(id, op, { revision, actor, owner = false, definitions = {} }) {
|
||||
grantOp(op);
|
||||
if (owner !== true || actor === "tester") fail("human-required", bindingText("human-required"));
|
||||
const build = old => {
|
||||
const current = copyGrantFence(old, op, { actor, owner, definitions });
|
||||
const services = structuredClone(current.services);
|
||||
delete services[op.service].grantId;
|
||||
delete services[op.service].rotateBy;
|
||||
const record = { ...structuredClone(current), generation: current.generation + 1, state: "pending", services };
|
||||
return { ...structuredClone(old), copyBindings: { ...old.copyBindings, [op.instance]: record } };
|
||||
};
|
||||
const result = commit(id, build, {
|
||||
revision, actor, pathsOnly: true, capability: BINDING_WRITE, incarnation: op.incarnation,
|
||||
guard: (old, next) => {
|
||||
if (canonicalJson(next) !== canonicalJson(build(old))) fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
|
||||
projectsProtected(old, next);
|
||||
definitionsProtected(old, next);
|
||||
},
|
||||
detail: { verb: "binding.stage", agent: op.instance, kind: [op.service], generation: op.generation + 1 },
|
||||
});
|
||||
return { ...result, generation: op.generation + 1 };
|
||||
}
|
||||
function commitCopyGrant(id, op, { grantId, rotateBy, revision, actor, owner = false, definitions = {}, beforeWrite = null }) {
|
||||
grantOp(op);
|
||||
if (owner !== true || actor === "tester") fail("human-required", bindingText("human-required"));
|
||||
const build = old => {
|
||||
const current = copyGrantFence(old, op, { actor, owner, definitions });
|
||||
if (current.state !== "pending" || current.services[op.service].grantId !== undefined) fail("credential-binding-changed", bindingText("credential-binding-changed", op.service));
|
||||
// One grant per organization: another copy's or another service's grant id never moves here.
|
||||
for (const [name, r] of Object.entries(old.copyBindings ?? {}))
|
||||
for (const [service, v] of Object.entries(r?.services ?? {}))
|
||||
if (v?.grantId === grantId && !(name === op.instance && service === op.service)) fail("credential-identity-refused", bindingText("credential-identity-refused", op.service));
|
||||
const services = structuredClone(current.services);
|
||||
services[op.service] = { ...services[op.service], rotateBy, grantId, grantGeneration: (services[op.service].grantGeneration ?? 0) + 1 };
|
||||
const record = { ...structuredClone(current), generation: current.generation + 1, state: allGranted(services) ? "bound" : "pending", services };
|
||||
return { ...structuredClone(old), copyBindings: { ...old.copyBindings, [op.instance]: record } };
|
||||
};
|
||||
let state = null;
|
||||
const result = commit(id, (old) => { const next = build(old); state = next.copyBindings[op.instance].state; return next; }, {
|
||||
revision, actor, beforeWrite, pathsOnly: true, capability: BINDING_WRITE, incarnation: op.incarnation,
|
||||
guard: (old, next) => {
|
||||
if (canonicalJson(next) !== canonicalJson(build(old))) fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
|
||||
projectsProtected(old, next);
|
||||
definitionsProtected(old, next);
|
||||
},
|
||||
detail: { verb: "binding.grant", agent: op.instance, kind: [op.service], generation: op.generation + 1 },
|
||||
});
|
||||
return { ...result, generation: op.generation + 1, state };
|
||||
}
|
||||
|
||||
// AU inverse, from an immutable private proposal. It touches only the named instance and
|
||||
// definition; writer validation, fresh revision and owner fence still apply. Bindings never restore.
|
||||
function reverseHireDecision(id,p,{actor,beforeWrite,beforePublish}) {
|
||||
@@ -699,5 +779,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, stageCopyGrant, commitCopyGrant, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// #1873 CB2 (design section 5 steps 4 to 6, section 8 row CB2): the grant metadata a verified copy
|
||||
// credential leaves on its binding record, and the writer's two writes around the remote work. The
|
||||
// bus side (verifier, slot, broker) is packages/bus/tests/copy-grant.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { hireDecisions } from '../src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../src/organization.mjs';
|
||||
import { checkRootAgents } from '../src/agent-levels.mjs';
|
||||
import { materializeCopies } from '../src/agent-copies.mjs';
|
||||
import { readTemplate } from '../src/definitions.mjs';
|
||||
import { activateOrg, reserveOrg } from '../src/org-registry.mjs';
|
||||
import * as bindings from '../src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from './helpers.mjs';
|
||||
|
||||
const { definitionPin, validateCopyBindings } = bindings;
|
||||
const OWNER = { actor: 'jason', agent: false, rights: ['org.hire'], owner: true, instanceOwner: true, subject: { provider: 'test', iss: 'https://test', sub: 'owner' } };
|
||||
const FUTURE = '2099-01-01T00:00:00.000Z';
|
||||
const ID1 = '11111111-1111-4111-8111-111111111111', ID2 = '22222222-2222-4222-8222-222222222222', ID3 = '33333333-3333-4333-8333-333333333333';
|
||||
const GITEA = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const repoRoles = join(new URL('../../..', import.meta.url).pathname, 'roles');
|
||||
const INC = 'a'.repeat(32);
|
||||
|
||||
const granted = (over = {}) => ({
|
||||
copyId: 'c'.repeat(32), orgIncarnation: INC, source: { level: 'root', name: 'rev', project: null },
|
||||
definition: definitionPin('reviewer', { rolesDir: repoRoles, readTemplate }), generation: 3, state: 'bound',
|
||||
services: {
|
||||
gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['stack'] }, rotateBy: '2099-01-01', grantId: 'gitea:5:7', grantGeneration: 1 },
|
||||
vikunja: { slot: 'self', scopes: { tasks: ['read_one'] }, resources: { projects: ['stack'] }, rotateBy: '2099-01-01', grantId: 'vikunja:2:9', grantGeneration: 2 },
|
||||
},
|
||||
decision: ID1, ...over,
|
||||
});
|
||||
const withGitea = (patch, over = {}) => { const r = granted(over); r.services.gitea = { ...r.services.gitea, ...patch }; for (const k of Object.keys(patch)) if (patch[k] === undefined) delete r.services.gitea[k]; return r; };
|
||||
|
||||
test('CB2 schema: grant fields come together, name their own service and are ids only', () => {
|
||||
assert.doesNotThrow(() => validateCopyBindings({ rev: granted() }));
|
||||
// After a stage: pending, the grant gone, its generation kept.
|
||||
assert.doesNotThrow(() => validateCopyBindings({ rev: withGitea({ rotateBy: undefined, grantId: undefined }, { state: 'pending' }) }));
|
||||
const refused = [
|
||||
withGitea({ grantId: undefined }),
|
||||
withGitea({ rotateBy: undefined }),
|
||||
withGitea({ grantGeneration: undefined }),
|
||||
withGitea({ grantId: 'vikunja:5:7' }),
|
||||
withGitea({ grantId: 'gitea:5' }),
|
||||
withGitea({ grantId: 'gitea:0:7' }),
|
||||
withGitea({ grantId: `gitea:${SECRET}` }),
|
||||
withGitea({ grantId: 'gitea:5:7:' + 'f'.repeat(40) }),
|
||||
withGitea({ grantGeneration: 0 }),
|
||||
withGitea({ grantGeneration: 1.5 }),
|
||||
withGitea({ rotateBy: '2099-02-30' }),
|
||||
withGitea({ rotateBy: '2099-1-01' }),
|
||||
withGitea({ token: SECRET }),
|
||||
withGitea({ account: 'mosaic-copy-x' }),
|
||||
withGitea({ sha256: 'f'.repeat(64) }),
|
||||
];
|
||||
for (const r of refused) assert.throws(() => validateCopyBindings({ rev: r }), e => e.name === 'BusinessError' && !e.message.includes(SECRET) && !e.message.includes('/'), JSON.stringify(r.services.gitea));
|
||||
});
|
||||
|
||||
test('CB2 schema: grant fields are optional per service; one grant id per organization', () => {
|
||||
// The schema keeps CB0's shape: a service without a grant is valid in any state. Only the
|
||||
// verifier's commit writes bound, and only when every service holds a grant (bus copy-grant tests).
|
||||
assert.doesNotThrow(() => validateCopyBindings({ rev: withGitea({ rotateBy: undefined, grantId: undefined }) }));
|
||||
assert.throws(() => validateCopyBindings({ rev: granted({ services: {} }) }));
|
||||
const other = { ...granted({ copyId: 'd'.repeat(32), source: { level: 'root', name: 'other', project: null } }) };
|
||||
other.services = structuredClone(other.services);
|
||||
other.services.vikunja.grantId = 'vikunja:3:3';
|
||||
assert.throws(() => validateCopyBindings({ rev: granted(), other }), /grant another binding uses/);
|
||||
other.services.gitea.grantId = 'gitea:5:8';
|
||||
assert.doesNotThrow(() => validateCopyBindings({ rev: granted(), other }));
|
||||
});
|
||||
|
||||
test('CB2 the login a copy grant must belong to is per organization, copy and service', () => {
|
||||
const { copyBotLogin } = bindings;
|
||||
assert.equal(typeof copyBotLogin, 'function');
|
||||
const a = copyBotLogin(INC, 'c'.repeat(32), 'gitea');
|
||||
assert.match(a, /^mosaic-copy-[0-9a-f]{24}$/);
|
||||
assert.ok(a.length <= 40);
|
||||
assert.equal(copyBotLogin(INC, 'c'.repeat(32), 'gitea'), a);
|
||||
for (const other of [copyBotLogin('b'.repeat(32), 'c'.repeat(32), 'gitea'), copyBotLogin(INC, 'd'.repeat(32), 'gitea'), copyBotLogin(INC, 'c'.repeat(32), 'vikunja')])
|
||||
assert.notEqual(other, a);
|
||||
for (const bad of [[null, 'c'.repeat(32), 'gitea'], [INC, 'x', 'gitea'], [INC, 'c'.repeat(32), 'host.ops'], [INC, 'c'.repeat(32), 'model']])
|
||||
assert.equal(copyBotLogin(...bad), null);
|
||||
});
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb2o-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const roles = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
doc.projects.stack.agents = { checker: { definition: 'reviewer' } };
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
||||
const ready = (async () => { await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo'); })();
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir: roles }) }, rolesDir: roles });
|
||||
const writer = organizationWriter({ dir: root, rolesDir: roles });
|
||||
const service = hireDecisions({ writer, rolesDir: roles, configDir: root, dataRoot, assertClean: () => {}, copies: id => snapshot.org === id ? snapshot : null });
|
||||
const bind = async (input, id) => {
|
||||
await ready;
|
||||
const p = await service.prepare('demo', { op: 'change', reason: 'Bind', binding: { services: GITEA }, ...input }, { id, expiresAt: FUTURE });
|
||||
await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
};
|
||||
const rec = name => writer.read('demo').document.copyBindings[name];
|
||||
const opFor = (name, service = 'gitea') => { const r = rec(name); return { instance: name, service, copyId: r.copyId, generation: r.generation, decision: r.decision, incarnation: r.orgIncarnation }; };
|
||||
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8');
|
||||
return { root, roles, file, writer, bind, rec, opFor, log, revision: () => writer.read('demo').revision };
|
||||
}
|
||||
const AS = { actor: 'jason', owner: true };
|
||||
|
||||
test('CB2 writer: the stage deactivates, the commit binds; the receipts name the verbs, never a grant', async t => {
|
||||
const f = setup(t);
|
||||
await f.bind({ level: 'root', name: 'rev' }, ID1);
|
||||
const op = f.opFor('rev');
|
||||
assert.equal(f.rec('rev').state, 'pending');
|
||||
const staged = f.writer.stageCopyGrant('demo', op, { revision: f.revision(), ...AS });
|
||||
assert.equal(staged.generation, op.generation + 1);
|
||||
assert.deepEqual({ state: f.rec('rev').state, generation: f.rec('rev').generation }, { state: 'pending', generation: op.generation + 1 });
|
||||
const done = f.writer.commitCopyGrant('demo', { ...op, generation: staged.generation }, { grantId: 'gitea:5:9', rotateBy: '2099-01-01', revision: staged.revision, ...AS });
|
||||
assert.equal(done.state, 'bound');
|
||||
const r = f.rec('rev');
|
||||
assert.deepEqual({ state: r.state, generation: r.generation, gitea: r.services.gitea }, {
|
||||
state: 'bound', generation: op.generation + 2,
|
||||
gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] }, rotateBy: '2099-01-01', grantId: 'gitea:5:9', grantGeneration: 1 },
|
||||
});
|
||||
const log = f.log();
|
||||
assert.ok(log.includes('binding.stage') && log.includes('binding.grant'));
|
||||
assert.ok(!log.includes('gitea:5:9') && !log.includes('2099-01-01'), 'the receipts are paths only');
|
||||
// A new issue stages again: use closes before any remote work, the grant generation stays.
|
||||
f.writer.stageCopyGrant('demo', f.opFor('rev'), { revision: f.revision(), ...AS });
|
||||
assert.deepEqual({ state: f.rec('rev').state, gitea: f.rec('rev').services.gitea }, {
|
||||
state: 'pending', gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] }, grantGeneration: 1 },
|
||||
});
|
||||
const again = f.writer.commitCopyGrant('demo', f.opFor('rev'), { grantId: 'gitea:5:10', rotateBy: '2099-02-01', revision: f.revision(), ...AS });
|
||||
assert.equal(again.state, 'bound');
|
||||
assert.equal(f.rec('rev').services.gitea.grantGeneration, 2);
|
||||
});
|
||||
|
||||
test('CB2 writer: a stale generation, revision, decision, copy or incarnation, and a second commit, write nothing', async t => {
|
||||
const f = setup(t);
|
||||
await f.bind({ level: 'root', name: 'rev' }, ID1);
|
||||
const op = f.opFor('rev');
|
||||
const rev0 = f.revision();
|
||||
const staged = f.writer.stageCopyGrant('demo', op, { revision: rev0, ...AS });
|
||||
const after = { ...op, generation: staged.generation };
|
||||
const grant = { grantId: 'gitea:5:9', rotateBy: '2099-01-01', ...AS };
|
||||
const before = readFileSync(f.file);
|
||||
for (const [bad, code] of [
|
||||
[{ ...after, generation: op.generation }, 'credential-binding-changed'],
|
||||
[{ ...after, copyId: 'e'.repeat(32) }, 'credential-binding-changed'],
|
||||
[{ ...after, decision: ID3 }, 'credential-binding-changed'],
|
||||
[{ ...after, instance: 'checker.stack' }, 'credential-binding-changed'],
|
||||
[{ ...after, service: 'vikunja' }, 'credential-binding-changed'],
|
||||
[{ ...after, incarnation: 'b'.repeat(32) }, 'decision-stale'],
|
||||
]) assert.throws(() => f.writer.commitCopyGrant('demo', bad, { ...grant, revision: staged.revision }), { code }, JSON.stringify(bad));
|
||||
assert.throws(() => f.writer.commitCopyGrant('demo', after, { ...grant, revision: rev0 }), { code: 'revision-conflict' });
|
||||
assert.deepEqual(readFileSync(f.file), before);
|
||||
f.writer.commitCopyGrant('demo', after, { ...grant, revision: staged.revision });
|
||||
const bound = readFileSync(f.file);
|
||||
assert.throws(() => f.writer.commitCopyGrant('demo', after, { ...grant, revision: f.revision() }), { code: 'credential-binding-changed' });
|
||||
assert.throws(() => f.writer.commitCopyGrant('demo', { ...after, generation: after.generation + 1 }, { ...grant, grantId: 'gitea:5:10', revision: f.revision() }), { code: 'credential-binding-changed' });
|
||||
assert.deepEqual(readFileSync(f.file), bound);
|
||||
// A new binding decision between the stage and the commit wins: the commit refuses.
|
||||
f.writer.stageCopyGrant('demo', f.opFor('rev'), { revision: f.revision(), ...AS });
|
||||
const pinned = f.opFor('rev');
|
||||
await f.bind({ level: 'root', name: 'rev', binding: { services: { gitea: { scopes: ['read:user'], projects: ['stack'] } } } }, ID2);
|
||||
assert.throws(() => f.writer.commitCopyGrant('demo', pinned, { ...grant, grantId: 'gitea:5:11', revision: f.revision() }), { code: 'credential-binding-changed' });
|
||||
assert.deepEqual(f.rec('rev').services.gitea, { slot: 'self', scopes: ['read:user'], resources: { projects: ['stack'] } });
|
||||
assert.equal(f.rec('rev').state, 'pending');
|
||||
});
|
||||
|
||||
test('CB2 writer: only the owner stages or commits; no agent, tester or member identity', async t => {
|
||||
const f = setup(t);
|
||||
await f.bind({ level: 'root', name: 'rev' }, ID1);
|
||||
const op = f.opFor('rev');
|
||||
const before = readFileSync(f.file);
|
||||
for (const who of [{ actor: 'jason', owner: false }, { actor: 'tester', owner: true }, { actor: 'rev', owner: true }, { actor: 'chief', owner: true }, { actor: 'checker.stack', owner: true }, { actor: 'reviewer.checker.stack', owner: true }]) {
|
||||
assert.throws(() => f.writer.stageCopyGrant('demo', op, { revision: f.revision(), ...who }), { code: 'human-required' }, who.actor);
|
||||
assert.throws(() => f.writer.commitCopyGrant('demo', op, { grantId: 'gitea:5:9', rotateBy: '2099-01-01', revision: f.revision(), ...who }), { code: 'human-required' }, who.actor);
|
||||
}
|
||||
assert.throws(() => f.writer.stageCopyGrant('demo', { ...op, generation: 'x' }, { revision: f.revision(), ...AS }), { code: 'invalid-request' });
|
||||
assert.deepEqual(readFileSync(f.file), before);
|
||||
});
|
||||
|
||||
test('CB2 writer: another copy\'s grant id never moves here; a definition change refuses the commit', async t => {
|
||||
const f = setup(t);
|
||||
await f.bind({ level: 'root', name: 'rev' }, ID1);
|
||||
await f.bind({ level: 'project', project: 'stack', name: 'checker' }, ID2);
|
||||
const a = f.writer.stageCopyGrant('demo', f.opFor('rev'), { revision: f.revision(), ...AS });
|
||||
f.writer.commitCopyGrant('demo', { ...f.opFor('rev') }, { grantId: 'gitea:5:9', rotateBy: '2099-01-01', revision: f.revision(), ...AS });
|
||||
assert.ok(a);
|
||||
f.writer.stageCopyGrant('demo', f.opFor('checker.stack'), { revision: f.revision(), ...AS });
|
||||
const before = readFileSync(f.file);
|
||||
assert.throws(() => f.writer.commitCopyGrant('demo', f.opFor('checker.stack'), { grantId: 'gitea:5:9', rotateBy: '2099-01-01', revision: f.revision(), ...AS }), { code: 'credential-identity-refused' });
|
||||
assert.deepEqual(readFileSync(f.file), before);
|
||||
// The reviewer template changes between the stage and the commit: its pin no longer matches.
|
||||
const template = join(f.roles, 'reviewer.json');
|
||||
const role = JSON.parse(readFileSync(template, 'utf8'));
|
||||
writeFileSync(template, JSON.stringify({ ...role, title: role.title + ' changed' }, null, 2));
|
||||
assert.throws(() => f.writer.commitCopyGrant('demo', f.opFor('checker.stack'), { grantId: 'gitea:6:1', rotateBy: '2099-01-01', revision: f.revision(), ...AS }), { code: 'credential-role-changed' });
|
||||
assert.deepEqual(readFileSync(f.file), before);
|
||||
});
|
||||
|
||||
test('CB2 writer: a generic write never adds or changes grant fields', async t => {
|
||||
const f = setup(t);
|
||||
await f.bind({ level: 'root', name: 'rev' }, ID1);
|
||||
const { document, revision } = f.writer.read('demo');
|
||||
const next = structuredClone(document);
|
||||
next.copyBindings.rev.services.gitea = { ...next.copyBindings.rev.services.gitea, rotateBy: '2099-01-01', grantId: 'gitea:5:9', grantGeneration: 1 };
|
||||
next.copyBindings.rev.state = 'bound';
|
||||
assert.throws(() => f.writer.write('demo', next, { revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
|
||||
assert.equal(f.rec('rev').state, 'pending');
|
||||
});
|
||||
@@ -30,6 +30,8 @@ import { fileURLToPath } from 'node:url';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { actionTrail } from './action-trail.mjs';
|
||||
import { reviveBuses } from './bus-revive.mjs';
|
||||
import { createProxyAdmin, orgCatalogue } from './proxy-admin.mjs';
|
||||
import { busesStopped } from '../../cli/src/proxy-admin-host.mjs';
|
||||
import { agentLevelsWriteRoutes } from './agent-levels-write.mjs';
|
||||
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
|
||||
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
|
||||
@@ -49,7 +51,7 @@ if (process.platform === 'linux') {
|
||||
}
|
||||
}
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--proxy-unit-dir PATH] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
@@ -100,6 +102,11 @@ try {
|
||||
if (devBind !== null) throw new Error('--dev-insecure-bind takes one address');
|
||||
devBind = value;
|
||||
} else if (flag === '--dev-allow-host') devNames.push(value);
|
||||
// #1873: where the Proxy page installs the proxy user unit; proxy-service.sh's default otherwise.
|
||||
else if (flag === '--proxy-unit-dir') {
|
||||
if (!isAbsolute(value)) throw new Error('--proxy-unit-dir must be an absolute path');
|
||||
options.proxyUnitDir = value;
|
||||
}
|
||||
else throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
const stamp = assertBuildStart({ serve: true, sso: ssoOn, devOwner, devBind, devNames });
|
||||
@@ -194,6 +201,23 @@ try {
|
||||
}) : null;
|
||||
// #1873 A1: the root agent level, read fresh on each request; the Agents grid's only new read.
|
||||
const agentLevels = organizations ? { loadRoot: () => loadRootAgents(configDir(), { rolesDir: rolesDir() }), rolesDir: rolesDir(), ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance } } : null;
|
||||
// #1873: proxy setup for the instance owner (dev local owner, or the root owner under --sso).
|
||||
// Instance-wide: one pinned release, one proxy.json, one unit. Each organization uses it (shared).
|
||||
const proxyAdmin = system && (devOwner || ssoOn) ? createProxyAdmin({
|
||||
dataRoot: system.dataRoot, configDir: configDir(), instance, unitDir: options.proxyUnitDir ?? null,
|
||||
buses: () => busesStopped({ dataRoot: system.dataRoot, configDir: configDir() }),
|
||||
orgs: async () => {
|
||||
const catalogue = orgCatalogue;
|
||||
if (!organizations) return [{ id: options.business ?? null, proxy: 'shared', catalogue: await catalogue(ownerCall ?? (async () => { throw new Error(); })) }];
|
||||
const out = [];
|
||||
for (const org of await listOrgs({ configDir: configDir(), dataRoot: system.dataRoot, instance })) {
|
||||
if (org.state !== 'active') continue;
|
||||
let paths; try { paths = orgPaths(org.root); } catch { out.push({ id: org.id, proxy: 'shared', catalogue: { status: 'bus-unavailable', models: null } }); continue; }
|
||||
out.push({ id: org.id, proxy: 'shared', catalogue: await catalogue(busCaller({ dataRoot: org.root, socket: paths.socket, business: org.id, timeoutMs: 5000 })) });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
}) : null;
|
||||
let server, testerServer = null;
|
||||
// F3: the restart route closes every listener and connection, then exits RESTART_EXIT for the
|
||||
// supervisor to relaunch. Without --supervised the route answers not-supervised.
|
||||
@@ -207,7 +231,7 @@ try {
|
||||
// #1873 A2: Settings > Agents writes for the root and project levels, beside the levels read.
|
||||
const agentLevelRoutes = organizations ? [agentLevelsWriteRoutes({ configDir: configDir(), rolesDir: rolesDir(), trail })] : [];
|
||||
try {
|
||||
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), 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, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, routes: agentLevelRoutes, preselect: options.business ?? null });
|
||||
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), 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, proxyAdmin, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, routes: agentLevelRoutes, preselect: options.business ?? null });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
if (testerSocket) {
|
||||
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
||||
|
||||
@@ -10,7 +10,9 @@ import { views } from '../../bus/src/views.mjs';
|
||||
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.group.models.set', 'proxy.keys', 'plugins', 'plugins.set']);
|
||||
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.group.models.set', 'proxy.keys', 'plugins', 'plugins.set',
|
||||
// #1873: proxy setup from the page. The broker gates both to the human owner.
|
||||
'proxy.init', 'proxy.fence.clear']);
|
||||
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
|
||||
// U4b (#1873): the task board's writes, the person's built-in store verbs (broker tasks.*). The
|
||||
// caller refuses any other, so the board's taskWrite never reaches unarchive or delete.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// #1873 model proxy setup from the Console: the owner-only HTTP routes. Same checks as
|
||||
// proxy-http.mjs. The controller (packages/model-proxy/src/admin-control.mjs) runs host
|
||||
// actions; init and the fence clear go to the bus, which decides them. No key, token, path or
|
||||
// upstream text reaches a response, receipt or log line.
|
||||
import { isIP } from 'node:net';
|
||||
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
|
||||
import { createProxyAdmin, proxyCode } from '../../model-proxy/src/admin-control.mjs';
|
||||
|
||||
export { createProxyAdmin };
|
||||
|
||||
// #1873: one organization's catalogue row for the setup page. A count is shown only when the bus
|
||||
// answered with a model list; otherwise models is null and the page shows a dash, never a made-up 0.
|
||||
export async function orgCatalogue(call) {
|
||||
try {
|
||||
const value = await call('routes');
|
||||
return { status: value?.catalogue?.status ?? 'unknown', models: Array.isArray(value?.catalogue?.models) ? value.catalogue.models.length : null };
|
||||
} catch { return { status: 'bus-unavailable', models: null }; }
|
||||
}
|
||||
|
||||
const ROUTES = Object.freeze({
|
||||
'/api/proxy/admin/status': ['GET', []],
|
||||
'/api/proxy/admin/unit/render': ['GET', []],
|
||||
'/api/proxy/admin/fetch': ['POST', []],
|
||||
'/api/proxy/admin/init': ['POST', null],
|
||||
'/api/proxy/admin/unit/install': ['POST', []],
|
||||
'/api/proxy/admin/unit/start': ['POST', []],
|
||||
'/api/proxy/admin/unit/stop': ['POST', []],
|
||||
'/api/proxy/admin/unit/restart': ['POST', []],
|
||||
'/api/proxy/admin/fence/clear': ['POST', []],
|
||||
'/api/proxy/admin/init-lock/clear': ['POST', []],
|
||||
});
|
||||
export const PROXY_ADMIN_PATHS = Object.freeze(Object.keys(ROUTES));
|
||||
const INIT_KEYS = ['generate', 'key', 'confirm', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'];
|
||||
const json = (res, status, value) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value));
|
||||
};
|
||||
const loopbackName = name => name === 'localhost' || name === '[::1]' || name === '::1' || (isIP(name) === 4 && name.startsWith('127.'));
|
||||
const loopbackAddress = address => typeof address === 'string' && (address === '::1' || /^(::ffff:)?127\./.test(address));
|
||||
// A pasted management key crosses only HTTPS or loopback. A plain-HTTP LAN page gets the generated key.
|
||||
export function keyTransportOk(origin, remoteAddress) {
|
||||
let url; try { url = new URL(origin); } catch { return false; }
|
||||
if (url.protocol === 'https:') return true;
|
||||
return url.protocol === 'http:' && loopbackName(url.hostname) && loopbackAddress(remoteAddress);
|
||||
}
|
||||
const STATUS = { 'human-required': 403, 'proxy-key-transport': 403, 'not-configured': 503, 'proxy-down': 503, 'outcome-unknown': 503, 'proxy-admin-busy': 409 };
|
||||
|
||||
// `owner` is true only for the instance owner's session (the dev local owner, or the root owner
|
||||
// signed in under --sso). `call` is the owner's transport to the selected bus, or null.
|
||||
export async function proxyAdminHTTP(req, res, { path, admin, call, named, tester, granted, owner, send, origin, user, log }) {
|
||||
const route = ROUTES[path];
|
||||
const done = (status, value) => {
|
||||
// Path, method and status only: never a body, key, evidence or exception text.
|
||||
log(`${new Date().toISOString()} ${route?.[0] ?? req.method} ${route ? path : '/api/proxy/admin/unknown'} ${status} user=${JSON.stringify(user ?? '')}`);
|
||||
return json(res, status, value);
|
||||
};
|
||||
const refuse = code => done(STATUS[code] ?? 400, { error: code });
|
||||
if (tester || granted || !named || owner !== true) return refuse('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 (!admin) return refuse('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)) throw Error();
|
||||
const allowed = route[1] ?? INIT_KEYS;
|
||||
if (Object.keys(args).some(k => !allowed.includes(k))) throw Error();
|
||||
} catch { return refuse('invalid-request'); }
|
||||
}
|
||||
try {
|
||||
switch (path) {
|
||||
case '/api/proxy/admin/status': return done(200, await admin.status());
|
||||
case '/api/proxy/admin/unit/render': return done(200, await admin.render());
|
||||
case '/api/proxy/admin/fetch': return done(202, admin.fetchStart());
|
||||
case '/api/proxy/admin/init': {
|
||||
const timing = {};
|
||||
for (const field of ['port', 'rotateGraceMinutes', 'firstOutputSeconds']) if (Object.hasOwn(args, field)) timing[field] = args[field];
|
||||
if (Object.hasOwn(timing, 'port') && (!Number.isInteger(timing.port) || timing.port < 1024 || timing.port > 65535 || timing.port === 8317)) return refuse('proxy-config-invalid');
|
||||
try { proxyTiming(timing); } catch (error) { return refuse(proxyCode(error, 'proxy-config-invalid')); }
|
||||
let initArgs;
|
||||
if (args.generate === true && !Object.hasOwn(args, 'key') && !Object.hasOwn(args, 'confirm')) initArgs = { generate: true, ...timing };
|
||||
else if (!Object.hasOwn(args, 'generate') && Object.hasOwn(args, 'key')) {
|
||||
// The transport check comes before the key is examined, so a refused key is never judged.
|
||||
if (!keyTransportOk(origin, req.socket?.remoteAddress)) return refuse('proxy-key-transport');
|
||||
if (typeof args.key !== 'string' || args.key !== args.confirm) return refuse('proxy-key-mismatch');
|
||||
if (!validManagementKey(args.key)) return refuse('proxy-key-invalid');
|
||||
initArgs = { key: args.key, ...timing };
|
||||
} else return refuse('invalid-request');
|
||||
if (!call) return refuse('not-configured');
|
||||
return done(200, await admin.init(call, initArgs));
|
||||
}
|
||||
case '/api/proxy/admin/unit/install': return done(200, await admin.unitAction('install'));
|
||||
case '/api/proxy/admin/unit/start': return done(200, await admin.unitAction('start'));
|
||||
case '/api/proxy/admin/unit/stop': return done(200, await admin.unitAction('stop'));
|
||||
case '/api/proxy/admin/unit/restart': return done(200, await admin.unitAction('restart'));
|
||||
case '/api/proxy/admin/fence/clear':
|
||||
if (!call) return refuse('not-configured');
|
||||
return done(200, await admin.fenceClear(call));
|
||||
case '/api/proxy/admin/init-lock/clear': return done(200, await admin.initLockClear());
|
||||
}
|
||||
} catch (error) {
|
||||
const code = ['human-required', 'not-configured', 'invalid-request'].includes(error?.code) ? error.code : proxyCode(error, 'outcome-unknown');
|
||||
return refuse(code);
|
||||
}
|
||||
return done(404, { error: 'not found' });
|
||||
}
|
||||
@@ -120,7 +120,31 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
// No browser storage, request/body logging, key reveal, or automatic write retry.
|
||||
(() => {
|
||||
'use strict';
|
||||
const markup = '<button id="proxy-refresh" type="button">Refresh</button><p id="proxy-message" role="status" aria-live="polite">Reading accounts…</p>\n<p id="proxy-readonly" class="muted" hidden>This Console is read-only. Sign-in and account changes are unavailable.</p>\n<section class="panel" aria-labelledby="proxy-accounts-title"><h2 id="proxy-accounts-title">Accounts</h2>\n<p>Higher priority is used first. Account names are stable identifiers; credential values are never displayed.</p>\n<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><th>Models</th></tr></thead><tbody id="proxy-accounts"></tbody></table></div></section>\n<section class="panel" aria-labelledby="proxy-signin-title"><h2 id="proxy-signin-title">Sign in to an account</h2>\n<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>\n<p id="proxy-login-message" role="status" aria-live="polite"></p>\n<p><a id="proxy-authorize" target="_blank" rel="noopener noreferrer" hidden>Open account sign-in in a new tab</a></p>\n<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>\n<section class="panel" aria-labelledby="proxy-apikey-title"><h2 id="proxy-apikey-title">Add an API key</h2>\n<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><p id="proxy-apikey-message" role="status"></p></form></section>\n<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>\n<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>\n';
|
||||
// #1873: setup from nothing to a working proxy. Shown only to the instance owner (config.proxyAdmin).
|
||||
const setupMarkup = '<section id="proxy-setup" class="panel" aria-labelledby="proxy-setup-title" hidden><h2 id="proxy-setup-title">Proxy setup</h2>'
|
||||
+ '<p>Each step is checked on the host. Files are named by label; keys are never shown.</p>'
|
||||
+ '<dl id="proxy-setup-state" class="proxy-setup-state"></dl>'
|
||||
+ '<div id="proxy-setup-next" class="proxy-setup-next" role="status" aria-live="polite"></div>'
|
||||
+ '<p id="proxy-setup-message" role="status" aria-live="polite"></p>'
|
||||
+ '<p id="proxy-setup-bus"></p>'
|
||||
+ '<details id="proxy-setup-init-box"><summary>Initialize the proxy</summary><form id="proxy-setup-init"><fieldset data-setup-write disabled><legend>Management key</legend>'
|
||||
+ '<label><input type="radio" name="keySource" value="generate" checked> Generate a key on the host (recommended). It is stored where init stores keys and never shown.</label>'
|
||||
+ '<label id="proxy-setup-paste-choice" hidden><input type="radio" name="keySource" value="paste"> Use my own key</label>'
|
||||
+ '<p id="proxy-setup-paste-note" class="muted" hidden>Your own key can be pasted only over HTTPS or on this machine (loopback). This page uses the generated key.</p>'
|
||||
+ '<div id="proxy-setup-paste" hidden><label>Management key <input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="4096"></label>'
|
||||
+ '<label>Confirm key <input name="confirm" type="password" autocomplete="off" spellcheck="false" maxlength="4096"></label></div></fieldset>'
|
||||
+ '<fieldset data-setup-write disabled><legend>Settings (leave empty for the defaults)</legend><div class="filters">'
|
||||
+ '<label>Port <input name="port" type="number" min="1024" max="65535" step="1" inputmode="numeric"></label>'
|
||||
+ '<label>Key rotation grace (minutes) <input name="rotateGraceMinutes" type="number" min="1" max="1440" step="1" inputmode="numeric"></label>'
|
||||
+ '<label>First output timeout (seconds) <input name="firstOutputSeconds" type="number" min="1" max="3600" step="1" inputmode="numeric"></label>'
|
||||
+ '</div><button type="submit">Initialize</button></fieldset></form></details>'
|
||||
+ '<div class="proxy-setup-unit"><h3>Proxy service</h3><p class="muted">Install renders the unit the same way as <code>mosaic proxy unit install</code>.</p>'
|
||||
+ '<div class="proxy-setup-buttons"><button type="button" data-unit="install" data-setup-write disabled>Install</button><button type="button" data-unit="start" data-setup-write disabled>Start</button><button type="button" data-unit="restart" data-setup-write disabled>Restart</button><button type="button" data-unit="stop" data-setup-write disabled>Stop</button><button type="button" id="proxy-setup-render">Show unit file</button></div>'
|
||||
+ '<pre id="proxy-setup-unit-file" hidden></pre></div>'
|
||||
+ '<div class="proxy-setup-orgs"><h3>Organizations</h3><div class="table-wrap" tabindex="0" role="region" aria-label="Organizations using the proxy"><table><thead><tr><th>Organization</th><th>Proxy</th><th>Catalogue</th><th>Models</th></tr></thead><tbody id="proxy-setup-orgs"></tbody></table></div>'
|
||||
+ '<p id="proxy-setup-own" class="muted"></p></div>'
|
||||
+ '<div class="proxy-setup-receipts"><h3>Receipts</h3><ol id="proxy-setup-receipts"><li class="muted">No setup actions on this page yet.</li></ol></div></section>\n';
|
||||
const markup = setupMarkup + '<button id="proxy-refresh" type="button">Refresh</button><p id="proxy-message" role="status" aria-live="polite">Reading accounts…</p>\n<p id="proxy-readonly" class="muted" hidden>This Console is read-only. Sign-in and account changes are unavailable.</p>\n<section class="panel" aria-labelledby="proxy-accounts-title"><h2 id="proxy-accounts-title">Accounts</h2>\n<p>Higher priority is used first. Account names are stable identifiers; credential values are never displayed.</p>\n<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><th>Models</th></tr></thead><tbody id="proxy-accounts"></tbody></table></div></section>\n<section class="panel" aria-labelledby="proxy-signin-title"><h2 id="proxy-signin-title">Sign in to an account</h2>\n<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>\n<p id="proxy-login-message" role="status" aria-live="polite"></p>\n<p><a id="proxy-authorize" target="_blank" rel="noopener noreferrer" hidden>Open account sign-in in a new tab</a></p>\n<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>\n<section class="panel" aria-labelledby="proxy-apikey-title"><h2 id="proxy-apikey-title">Add an API key</h2>\n<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><p id="proxy-apikey-message" role="status"></p></form></section>\n<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>\n<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>\n';
|
||||
function mount() {
|
||||
const $ = id => document.getElementById(id);
|
||||
let canWrite = false, login = null, poll = null;
|
||||
@@ -138,6 +162,7 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
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 === 'proxy-down') throw Object.assign(new Error('The model proxy is not answering.'), { code: 'proxy-down' });
|
||||
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.');
|
||||
@@ -239,9 +264,187 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
catch (error) { message(error.message);receipt('proxy-apikey-message',error.message,'error'); }
|
||||
finally { busy.delete('apikey');formBusy(form,false); }
|
||||
});
|
||||
const cleanup = () => { clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
|
||||
// #1873 setup controller. Every action is a POST the server checks again (owner, Origin,
|
||||
// Fetch Metadata); this code only offers the next step. Pasted keys stay in their fields
|
||||
// until submit, which clears them before the request is answered.
|
||||
const setup = (() => {
|
||||
const box = $('proxy-setup'), form = $('proxy-setup-init');
|
||||
let state = null, timer = null, enabled = false, sendOk = false, seenFetch = null, loading = false;
|
||||
const pasteAllowed = location.protocol === 'https:' || ['localhost', '[::1]'].includes(location.hostname) || /^127\./.test(location.hostname);
|
||||
const STEPS = { start: 'Starting', 'download-archive': 'Downloading the release archive', 'verify-archive': 'Checking the archive hash', 'download-panel': 'Downloading the panel', 'verify-panel': 'Checking the panel hash', 'extract-binary': 'Extracting the executable', 'verify-binary': 'Checking the executable hash', install: 'Installing', done: 'Done' };
|
||||
const REASONS = {
|
||||
'human-required': 'Only the instance owner can set up the proxy.',
|
||||
'read-only': 'This Console is read-only.',
|
||||
'proxy-admin-busy': 'Another setup action is running. Wait for it to finish.',
|
||||
'proxy-key-transport': 'A pasted key travels only over HTTPS or loopback. Use the generated key.',
|
||||
'proxy-key-mismatch': 'The two key fields differ. Type the same key in both.',
|
||||
'proxy-key-invalid': 'The key must be 1 to 4096 printable characters with no spaces.',
|
||||
'proxy-config-invalid': 'The port must be 1024 to 65535 and not 8317.',
|
||||
'proxy-rotate-grace-invalid': 'Key rotation grace must be 1 to 1440 minutes.',
|
||||
'proxy-first-output-invalid': 'First output timeout must be 1 to 3600 seconds.',
|
||||
'proxy-already-initialized': 'The proxy is already initialized. Init never overwrites it.',
|
||||
'proxy-port-in-use': 'That port is in use. Choose another or leave it empty.',
|
||||
'proxy-init-busy': 'An init lock is held. See Init lock above.',
|
||||
'proxy-install-missing': 'The pinned release is not fetched yet.',
|
||||
'proxy-install-invalid': 'The fetched release failed its hash check. Fetch again.',
|
||||
'proxy-restart-required': 'Restart the proxy from this page first.',
|
||||
'proxy-restart-unconfirmed': 'The restart could not be confirmed on the host. Restart again.',
|
||||
'proxy-init-lock-held': 'The init lock cannot be cleared yet.',
|
||||
'proxy-init-lock-absent': 'There is no init lock.',
|
||||
'proxy-unit-exists': 'A different proxy unit file is installed. Check it with mosaic proxy unit render.',
|
||||
'proxy-unit-missing': 'The proxy service is not installed.',
|
||||
'proxy-unit-unhealthy': 'The proxy service started but does not answer on its port.',
|
||||
'proxy-unit-failed': 'systemd refused the action.',
|
||||
'not-configured': 'Proxy setup is not available on this Console.',
|
||||
'outcome-unknown': 'The outcome is unknown. Check the status before retrying.',
|
||||
};
|
||||
const explain = code => `${REASONS[code] ?? 'The action was refused.'} (${code})`;
|
||||
async function admin(path, args) {
|
||||
const response = await fetch('/api/proxy/admin/' + path, { cache: 'no-store', credentials: 'same-origin', redirect: 'manual',
|
||||
method: args === undefined ? 'GET' : 'POST',
|
||||
...(args === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args) }) });
|
||||
if (response.type === 'opaqueredirect' || response.status === 401) throw new Error('Sign in to the Console again.');
|
||||
let value = null; try { value = await response.json(); } catch { /* refused below */ }
|
||||
if (!response.ok) { const code = typeof value?.error === 'string' && /^[a-z -]+$/.test(value.error) ? value.error : 'outcome-unknown'; const error = new Error(explain(code)); error.code = code; throw error; }
|
||||
return value;
|
||||
}
|
||||
const say = (text, kind) => receipt('proxy-setup-message', text, kind);
|
||||
const human = value => value === null || value === undefined ? '—' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
function addReceipt(record) {
|
||||
if (!record || typeof record !== 'object') return;
|
||||
const list = $('proxy-setup-receipts'); list.querySelector('li.muted')?.remove();
|
||||
const li = document.createElement('li'), op = document.createElement('strong'); op.textContent = record.op ?? 'action';
|
||||
const details = Object.entries(record).filter(([k]) => k !== 'op' && k !== 'time').map(([k, v]) => `${k}: ${human(v)}`).join(', ');
|
||||
li.append(op, ` ${window.MosaicLocalTime(record.time)}${details ? ' — ' + details : ''}`);
|
||||
list.prepend(li);
|
||||
}
|
||||
// #1873: receipts survive a reload: status carries the last few from proxy-admin.jsonl.
|
||||
function paintReceipts() {
|
||||
if (!Array.isArray(state.receipts) || !state.receipts.length) return;
|
||||
$('proxy-setup-receipts').replaceChildren(); state.receipts.forEach(addReceipt);
|
||||
}
|
||||
function row(dl, term, text) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = term; dd.textContent = text; dl.append(dt, dd); }
|
||||
function paintState() {
|
||||
const { release, init, unit, fence, initLock } = state, dl = $('proxy-setup-state'); dl.replaceChildren();
|
||||
row(dl, 'Release', !release.fetched ? `Not fetched (pinned ${release.version}, panel ${release.panelVersion})` : release.verified ? `Fetched ${release.version}, panel ${release.panelVersion}, hashes verified` : `Fetched ${release.version}, hashes not verified (${release.problem})`);
|
||||
const files = init.files.filter(f => f.present).map(f => f.label).join(', ') || 'none';
|
||||
row(dl, 'Initialized', init.initialized ? `Yes, port ${init.settings?.port ?? '—'}` : init.partial ? `Partly (${init.problem ?? 'incomplete'}). Present: ${files}` : init.problem ? `No (${init.problem})` : 'No');
|
||||
row(dl, 'Service', `${unit.unit}: ${unit.installed ? 'installed' : 'not installed'}, ${unit.active}${unit.pid ? `, pid ${unit.pid}` : ''}${unit.port ? `, port ${unit.port} ${unit.listening ? 'answering' : 'not answering'}` : ''}${unit.error ? ` (${unit.error})` : ''}`);
|
||||
row(dl, 'Fence', !fence.set ? 'Clear' : fence.invalid ? 'Set (unreadable fence file)' : `Set after ${fence.op ?? 'a key write'}${fence.org ? ` for ${fence.org}` : ''}${fence.time ? ` at ${window.MosaicLocalTime(fence.time)}` : ''}`);
|
||||
row(dl, 'Init lock', !initLock.present ? 'None' : initLock.clearable ? 'Stale: the bus is stopped and no proxy files exist' : `Held: ${initLock.blockers.join('; ')}`);
|
||||
if (state.restart?.confirmed) row(dl, 'Last restart', `Confirmed at ${window.MosaicLocalTime(state.restart.observedAt)}, new pid ${state.restart.pid}`);
|
||||
}
|
||||
function paintNext() {
|
||||
const next = state.next?.[0], box = $('proxy-setup-next');
|
||||
const steps = {
|
||||
fetch: ['Fetch the pinned release', `Next: fetch CLIProxyAPI ${state.release.version}. Every file is checked against its pinned SHA-256 before it is installed.`, fetchRelease],
|
||||
'init-lock-clear': ['Clear the stale init lock', 'Next: an init lock is left from an init that did not finish. The bus is stopped and no proxy files exist, so it can be cleared.', clearLock],
|
||||
'init-lock-wait': ['Check again', `An init lock is held and cannot be cleared: ${state.initLock.blockers.join('; ')}. Stop the bus and remove partial state as the model-proxy README says, then check again.`, load],
|
||||
'init-repair': ['Check again', 'Setup is partly done. Init never overwrites existing proxy files; resolve them as the model-proxy README says, then check again.', load],
|
||||
init: ['Initialize the proxy', 'Next: initialize. The bus generates the management key unless you paste your own.', () => { $('proxy-setup-init-box').open = true; form.elements.port.focus(); }],
|
||||
'unit-install': ['Install the proxy service', `Next: install ${state.unit.unit}.`, () => unitAction('install')],
|
||||
'unit-start': ['Start the proxy service', `Next: start ${state.unit.unit}.`, () => unitAction('start')],
|
||||
'unit-restart': ['Restart the proxy service', state.fence.set ? (fence => fence.invalid || !fence.time ? 'A key write had an uncertain outcome, so key writes are fenced. The fence file has no readable time, so a restart from this page cannot be proven against it: restart the proxy service, confirm it answers, then run mosaic proxy fence clear --proxy-restarted.' : 'A key write had an uncertain outcome, so key writes are fenced. Restart the proxy; after this page confirms the new process, clear the fence.')(state.fence) : 'The service is running but does not answer on its port. Restart it.', () => unitAction('restart')],
|
||||
'fence-clear': ['Clear the fence', 'The proxy was restarted from this page and the new process answers on its port. Clear the fence with that evidence.', clearFence],
|
||||
accounts: ['Add an account', 'The proxy is running. Sign in to an account or add an API key below.', () => $('proxy-signin-title')?.scrollIntoView({ block: 'start' })],
|
||||
};
|
||||
const [label, text, action] = steps[next] ?? ['Check again', 'Status read.', load];
|
||||
const p = document.createElement('p'); p.textContent = text;
|
||||
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn primary'; button.id = 'proxy-setup-go'; button.dataset.next = next ?? '';
|
||||
button.textContent = state.busy ? 'Working…' : label;
|
||||
const write = !['init-lock-wait', 'init-repair', 'accounts', 'init'].includes(next);
|
||||
button.disabled = Boolean(state.busy) || (write && !sendOk);
|
||||
button.addEventListener('click', () => { if (!button.disabled) action(); });
|
||||
box.replaceChildren(p, button);
|
||||
}
|
||||
function paintControls() {
|
||||
const { release, init, unit, initLock, busy } = state;
|
||||
const canInit = sendOk && !busy && release.fetched && release.verified && !init.initialized && !init.partial && !initLock.present;
|
||||
form.querySelectorAll('[data-setup-write]').forEach(el => { el.disabled = !canInit; });
|
||||
$('proxy-setup-paste-choice').hidden = !pasteAllowed; $('proxy-setup-paste-note').hidden = pasteAllowed;
|
||||
const allow = { install: !unit.installed, start: unit.installed && unit.active !== 'active', restart: unit.installed && unit.active === 'active', stop: unit.installed && unit.active === 'active' };
|
||||
box.querySelectorAll('[data-unit]').forEach(button => { button.disabled = !sendOk || Boolean(busy) || !init.initialized || !allow[button.dataset.unit]; });
|
||||
const orgs = $('proxy-setup-orgs');
|
||||
if (!Array.isArray(state.organizations)) orgs.replaceChildren(Object.assign(document.createElement('tr'), { innerHTML: '<td colspan="4">Organizations could not be read.</td>' }));
|
||||
else if (!state.organizations.length) orgs.replaceChildren(Object.assign(document.createElement('tr'), { innerHTML: '<td colspan="4">No organizations.</td>' }));
|
||||
else orgs.replaceChildren(...state.organizations.map(org => { const tr = document.createElement('tr'); tr.append(cell(org.id), cell(org.proxy === 'shared' ? 'Shared (this instance)' : org.proxy), cell(org.catalogue?.status ?? org.error ?? 'unknown'), cell(Number.isInteger(org.catalogue?.models) ? String(org.catalogue.models) : '—')); return tr; }));
|
||||
$('proxy-setup-own').textContent = state.ownProxy?.available ? 'An organization can run its own proxy.' : `Own proxy per organization: ${state.ownProxy?.state ?? 'not built'}. ${state.ownProxy?.reason ?? ''}`;
|
||||
}
|
||||
function paint() {
|
||||
paintState(); paintNext(); paintControls(); paintReceipts();
|
||||
// An initialized proxy keeps the init form folded after a reload too.
|
||||
if (state.init.initialized) $('proxy-setup-init-box').open = false;
|
||||
const job = state.fetch;
|
||||
if (job.state === 'running') say(`Fetching: ${STEPS[job.step] ?? job.step ?? 'starting'}…`);
|
||||
else if (job.state === 'failed' && seenFetch !== 'failed') { say(`Fetch failed. ${explain(job.error)}`, 'error'); seenFetch = 'failed'; }
|
||||
else if (job.state === 'done' && job.receipt && seenFetch !== job.receipt.time) { seenFetch = job.receipt.time; if (!state.receipts?.some(r => r.op === 'fetch' && r.time === job.receipt.time)) addReceipt(job.receipt); say(`Fetched ${job.receipt.version}; archive, panel and executable hashes verified.`, 'success'); }
|
||||
clearTimeout(timer); timer = null;
|
||||
if (enabled && (job.state === 'running' || state.busy)) timer = setTimeout(load, 1000);
|
||||
}
|
||||
async function load() {
|
||||
if (!enabled || loading) return; loading = true;
|
||||
try { state = await admin('status'); paint(); if (state.init.initialized) await accountsReady(); }
|
||||
catch (error) { say(error.message, 'error'); }
|
||||
finally { loading = false; }
|
||||
}
|
||||
async function act(label, action) {
|
||||
if (!sendOk || state?.busy) return;
|
||||
state = { ...state, busy: label }; paintNext(); paintControls(); say('Working…');
|
||||
try { await action(); } catch (error) { say(error.message, 'error'); } finally { if (state) state = { ...state, busy: null }; await load(); }
|
||||
}
|
||||
const fetchRelease = () => act('fetch', async () => { await admin('fetch', {}); seenFetch = null; say('Fetch started.'); });
|
||||
const clearLock = () => act('init-lock-clear', async () => { const result = await admin('init-lock/clear', {}); addReceipt(result.receipt); say('Stale init lock cleared.', 'success'); });
|
||||
const unitAction = op => act(`unit-${op}`, async () => {
|
||||
const result = await admin(`unit/${op}`, {}); addReceipt(result.receipt);
|
||||
if (op === 'restart' && result.evidence?.confirmed !== true) say('The service restarted but the new process could not be confirmed. The fence stays set.', 'error');
|
||||
else say({ install: 'Proxy service installed.', start: 'Proxy service started and answering.', stop: 'Proxy service stopped.', restart: 'Proxy service restarted; the new process answers.' }[op], 'success');
|
||||
});
|
||||
const clearFence = () => act('fence-clear', async () => { const result = await admin('fence/clear', {}); addReceipt(result.receipt); say(result.cleared ? 'Fence cleared.' : 'The fence was not set.', 'success'); });
|
||||
form.elements.keySource.forEach?.(radio => radio.addEventListener('change', () => { $('proxy-setup-paste').hidden = form.elements.keySource.value !== 'paste'; }));
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const f = form.elements, args = {};
|
||||
const pasted = pasteAllowed && f.keySource.value === 'paste', key = f.key.value, confirm = f.confirm.value;
|
||||
f.key.value = ''; f.confirm.value = '';
|
||||
if (!sendOk || state?.busy) return;
|
||||
for (const name of ['port', 'rotateGraceMinutes', 'firstOutputSeconds']) if (f[name].value !== '') args[name] = Number(f[name].value);
|
||||
if (pasted) {
|
||||
if (!key) return say('Type the key in both fields, or choose the generated key.', 'error');
|
||||
if (key !== confirm) return say(explain('proxy-key-mismatch'), 'error');
|
||||
args.key = key; args.confirm = confirm;
|
||||
} else args.generate = true;
|
||||
act('init', async () => {
|
||||
const result = await admin('init', args); addReceipt(result.receipt);
|
||||
$('proxy-setup-init-box').open = false;
|
||||
say(`Initialized on port ${result.settings.port}${args.generate ? ' with a generated key' : ''}. Install and start the service next. Other organizations' buses read the proxy settings when they start: restart a bus before its agents use the proxy.`, 'success');
|
||||
window.MosaicConsole?.attachBus?.($('proxy-setup-bus'), 'Restart this organization\'s bus');
|
||||
});
|
||||
});
|
||||
$('proxy-setup-render').addEventListener('click', async () => {
|
||||
const pre = $('proxy-setup-unit-file');
|
||||
if (!pre.hidden) { pre.hidden = true; return; }
|
||||
try { const result = await admin('unit/render'); pre.textContent = result.text; pre.hidden = false; } catch (error) { say(error.message, 'error'); }
|
||||
});
|
||||
box.querySelectorAll('[data-unit]').forEach(button => button.addEventListener('click', () => { if (!button.disabled) unitAction(button.dataset.unit); }));
|
||||
return {
|
||||
start(config) { enabled = true; sendOk = config.proxyAdminSend === true; box.hidden = false; return load(); },
|
||||
stop() { enabled = false; clearTimeout(timer); timer = null; const f = form.elements; f.key.value = ''; f.confirm.value = ''; },
|
||||
};
|
||||
})();
|
||||
// Panels the not-configured state hides come back once the proxy answers.
|
||||
let accountsShown = true;
|
||||
async function accountsReady() {
|
||||
if (accountsShown) return;
|
||||
try { await refresh(); } catch { return; }
|
||||
accountsShown = true; $('proxy-message').classList.remove('empty');
|
||||
document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el => { el.hidden = false; });
|
||||
if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el => { el.hidden = true; });
|
||||
$('proxy-readonly').hidden = canWrite;
|
||||
message('Accounts and client keys loaded.');
|
||||
}
|
||||
const cleanup = () => { setup.stop(); clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
|
||||
window.addEventListener('pagehide', cleanup, {once:true});
|
||||
let devOwner = false;
|
||||
let devOwner = false, proxyAdmin = false;
|
||||
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
|
||||
// F3: an organization's settings writes go with settingsSend (the org Console has no global send).
|
||||
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
|
||||
@@ -249,8 +452,18 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
|
||||
$('proxy-readonly').hidden = canWrite;
|
||||
if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el=>el.hidden=true);
|
||||
proxyAdmin = config.proxyAdmin === true;
|
||||
if (proxyAdmin) setup.start(config);
|
||||
return refresh();
|
||||
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true);
|
||||
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message);
|
||||
// #1873: on the setup page a stopped proxy hides the account panels like an unconfigured one,
|
||||
// and the setup load brings them back once the service answers.
|
||||
// Any failed read hides them, not only proxy-down: a proxy that is initialized but not yet answering
|
||||
// can fail with another code, and a stale error over an empty table would stay after Start.
|
||||
if (proxyAdmin && error.message !== 'The model proxy is not configured for this instance.') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true); message(error.code === 'proxy-down' ? 'The model proxy is not running yet. Follow the next step under Proxy setup.' : `Accounts could not be read${error.code ? ` (${error.code})` : ''}. Follow the next step under Proxy setup; the account panels return once the proxy answers.`); return; }
|
||||
if(error.message==='The model proxy is not configured for this instance.') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true);
|
||||
// #1873: the owner sets the proxy up on this page (Proxy setup above); no terminal note.
|
||||
if (proxyAdmin) { message('The model proxy is not set up yet. Follow the next step under Proxy setup.'); return; }
|
||||
// F3: on a dev instance, say plainly why this page cannot set the proxy up and what does.
|
||||
// Setup takes a management key typed twice at a terminal and writes the proxy's credentials;
|
||||
// this page never handles that key. Account sign-in (a provider's own OAuth) then runs here.
|
||||
|
||||
@@ -103,6 +103,13 @@ table.s1-table td code{font-size:12.5px;white-space:nowrap}
|
||||
.agent-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}
|
||||
.agent-restart [role=status]:empty{display:none}
|
||||
#proxy-dev-setup pre{overflow-x:auto;max-width:100%}
|
||||
/* #1873 proxy setup */
|
||||
.proxy-setup-state{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 16px;margin:0 0 12px}.proxy-setup-state dt{color:var(--muted);font-weight:600}.proxy-setup-state dd{margin:0;overflow-wrap:anywhere}
|
||||
.proxy-setup-next{display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px;margin:0 0 12px}.proxy-setup-next p{margin:0;flex:1 1 280px}
|
||||
.proxy-setup-buttons{display:flex;flex-wrap:wrap;gap:8px}.proxy-setup-buttons button{min-height:44px}
|
||||
#proxy-setup pre{overflow-x:auto;max-width:100%;font-size:12px}#proxy-setup h3{margin:16px 0 4px}#proxy-setup details{margin:0 0 12px}#proxy-setup summary{min-height:44px;display:flex;align-items:center;cursor:pointer}
|
||||
#proxy-setup-receipts{padding-left:20px;overflow-wrap:anywhere;font-size:13px}
|
||||
@media(max-width:479px){.proxy-setup-state{grid-template-columns:1fr}.proxy-setup-state dd{margin-bottom:6px}}
|
||||
/* Stream S4: which organization this is, in words. Colour (the theme) is never the only cue; the strip appears whenever the theme is not the built-in one. */
|
||||
.org-cue{margin:0 0 12px;padding:6px 10px;border:1px solid var(--line);border-left:4px solid var(--action);border-radius:var(--r-table);background:var(--raised);color:var(--text);font-size:13px;overflow-wrap:anywhere}.skel-rows{display:grid;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
|
||||
.skel{display:block;height:12px;border-radius:4px;background:var(--raised);animation:skel 1.4s ease-in-out infinite}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AUTONOMY_ROUTE, autonomyHTTP } from './autonomy-http.mjs';
|
||||
import { proxyHTTP } from './proxy-http.mjs';
|
||||
import { proxyAdminHTTP } from './proxy-admin.mjs';
|
||||
import { createServer } from 'node:http';
|
||||
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
|
||||
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
||||
@@ -795,7 +796,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
|
||||
// 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.
|
||||
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, requestRead = null, requestWrite = null, systemTypesWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) {
|
||||
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, proxyAdmin = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) {
|
||||
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
|
||||
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
|
||||
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
|
||||
@@ -957,6 +958,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = upstream = null;
|
||||
}
|
||||
if (session?.user !== undefined && !session.rootOwner && !instanceAction) {
|
||||
// #1873: proxy setup answers a grant holder the way the bus answers an agent.
|
||||
if (path.startsWith('/api/proxy/admin/')) return json(res, 403, { error: 'human-required' });
|
||||
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||||
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||||
@@ -1022,6 +1025,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
res.writeHead(303, { location: '/#/settings/model-proxy', 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
}
|
||||
// #1873: proxy setup. Instance-wide host actions (fetch, unit) and the init and fence verbs on
|
||||
// the selected bus; only the instance owner's own session reaches them, never an org owner.
|
||||
if (path.startsWith('/api/proxy/admin/')) return proxyAdminHTTP(req, res, { path, admin: proxyAdmin, call: proxyCall, named, tester, granted,
|
||||
owner: !tester && !granted && named && sso !== null && currentSession?.rootOwner === true, send: owner.send !== null,
|
||||
origin: sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`, user: signer?.user, log });
|
||||
if (path.startsWith('/api/proxy/')) return proxyHTTP(req, res, { path, call: proxyCall, named, tester, granted,
|
||||
send, origin: sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`, user: signer?.user, log });
|
||||
}
|
||||
@@ -1211,7 +1219,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// #1873 Proxy setup routes over the real HTTP boundary: only the instance owner reaches them, a
|
||||
// pasted key crosses only loopback or HTTPS, and no key reaches a response or a log line.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { PROXY_ADMIN_PATHS, keyTransportOk, orgCatalogue } from '../src/proxy-admin.mjs';
|
||||
import { consoleAccess } from '../src/access.mjs';
|
||||
import { createSsoSession } from './helpers/sso-session.mjs';
|
||||
import { REFUSALS } from '../../bus/src/refusals.mjs';
|
||||
|
||||
const SENTINEL = 'pa-http-sentinel-key-0123456789abcdef';
|
||||
const ROUTES = [
|
||||
['status', 'GET'], ['unit/render', 'GET'], ['fetch', 'POST', {}], ['init', 'POST', { generate: true }],
|
||||
['unit/install', 'POST', {}], ['unit/start', 'POST', {}], ['unit/stop', 'POST', {}], ['unit/restart', 'POST', {}],
|
||||
['fence/clear', 'POST', {}], ['init-lock/clear', 'POST', {}],
|
||||
];
|
||||
|
||||
function fakeAdmin() {
|
||||
const calls = [];
|
||||
const note = name => async (...args) => { calls.push([name, ...args.filter(a => typeof a !== 'function')]); return { ok: name }; };
|
||||
return {
|
||||
calls,
|
||||
unit: 'mosaic-proxy.service',
|
||||
status: note('status'), render: note('render'), unitAction: note('unitAction'), initLockClear: note('initLockClear'),
|
||||
fetchStart: () => { calls.push(['fetchStart']); return { state: 'running' }; },
|
||||
init: async (call, args) => { calls.push(['init', args]); await call('proxy.init', args); return { settings: { port: 18432 }, receipt: { op: 'init' } }; },
|
||||
fenceClear: async call => { calls.push(['fenceClear']); return call('proxy.fence.clear', { evidence: {} }); },
|
||||
};
|
||||
}
|
||||
const send = (port, path, { method = 'GET', headers = {}, body } = {}) => new Promise((resolve, reject) => {
|
||||
const req = request({ host: '127.0.0.1', port, path, method, headers }, res => {
|
||||
let text = ''; res.on('data', c => { text += c; }); res.on('end', () => resolve({ status: res.statusCode, text, json: (() => { try { return JSON.parse(text); } catch { return null; } })() }));
|
||||
});
|
||||
req.on('error', reject); req.end(body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body));
|
||||
});
|
||||
|
||||
async function devServer(t, options = {}) {
|
||||
const admin = fakeAdmin(), busCalls = [], logs = [];
|
||||
const s = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), proxyAdmin: admin,
|
||||
proxyCall: async (verb, args) => { busCalls.push([verb, args]); return { cleared: true }; }, log: line => logs.push(line), ...options });
|
||||
t.after(() => new Promise(r => s.close(r)));
|
||||
const port = s.address().port;
|
||||
const hostFor = name => `${name}:${port}`;
|
||||
const headers = (name = '127.0.0.1') => ({ host: hostFor(name), origin: `http://${hostFor(name)}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
||||
return { s, port, admin, busCalls, logs, headers };
|
||||
}
|
||||
const hit = (f, route, extra = {}) => send(f.port, `/api/proxy/admin/${route[0]}`, { method: route[1], headers: { ...f.headers(), ...extra }, body: route[2] });
|
||||
|
||||
test('the route table is exactly the brief: status, render, fetch, init, four unit verbs, fence clear, lock clear', () => {
|
||||
assert.deepEqual(PROXY_ADMIN_PATHS, ROUTES.map(([p]) => `/api/proxy/admin/${p}`));
|
||||
});
|
||||
|
||||
test('the dev local owner reaches every route; init and fence clear go to the bus; the page config offers setup', async t => {
|
||||
const f = await devServer(t);
|
||||
for (const route of ROUTES) {
|
||||
const r = await hit(f, route);
|
||||
assert.equal(r.status, route[0] === 'fetch' ? 202 : 200, route[0]);
|
||||
}
|
||||
assert.deepEqual(f.admin.calls.map(c => c[0]), ['status', 'render', 'fetchStart', 'init', 'unitAction', 'unitAction', 'unitAction', 'unitAction', 'fenceClear', 'initLockClear']);
|
||||
assert.deepEqual(f.admin.calls.filter(c => c[0] === 'unitAction').map(c => c[1]), ['install', 'start', 'stop', 'restart']);
|
||||
assert.deepEqual(f.busCalls.map(c => c[0]), ['proxy.init', 'proxy.fence.clear']);
|
||||
assert.deepEqual(f.busCalls[0][1], { generate: true });
|
||||
const config = await send(f.port, '/api/config', { headers: { host: `127.0.0.1:${f.port}` } });
|
||||
assert.equal(config.json.proxyAdmin, true);
|
||||
assert.equal(config.json.proxyAdminSend, true);
|
||||
for (const line of f.logs.filter(l => l.includes('/api/proxy/admin/'))) assert.match(line, /^\S+ (GET|POST) \/api\/proxy\/admin\/[a-z/-]+ \d{3} user=/);
|
||||
});
|
||||
|
||||
test('without a proxy admin the config offers no setup and the routes answer not-configured', async t => {
|
||||
const f = await devServer(t, { proxyAdmin: null });
|
||||
const config = await send(f.port, '/api/config', { headers: { host: `127.0.0.1:${f.port}` } });
|
||||
assert.equal(Object.hasOwn(config.json, 'proxyAdmin'), false);
|
||||
assert.deepEqual((await hit(f, ROUTES[0])).json, { error: 'not-configured' });
|
||||
});
|
||||
|
||||
test('writes refuse a foreign Origin, missing Fetch Metadata, a read-only Console and a wrong method', async t => {
|
||||
const f = await devServer(t);
|
||||
for (const route of ROUTES.filter(r => r[1] === 'POST')) {
|
||||
assert.equal((await hit(f, route, { origin: 'http://evil.example' })).status, 403, `${route[0]} cross-origin`);
|
||||
assert.equal((await hit(f, route, { 'sec-fetch-site': 'cross-site' })).status, 403, `${route[0]} cross-site`);
|
||||
const noMeta = { ...f.headers() }; delete noMeta['sec-fetch-site'];
|
||||
assert.equal((await send(f.port, `/api/proxy/admin/${route[0]}`, { method: 'POST', headers: noMeta, body: route[2] })).status, 403, `${route[0]} no fetch metadata`);
|
||||
assert.equal((await send(f.port, `/api/proxy/admin/${route[0]}`, { method: 'GET', headers: f.headers() })).status, 405, `${route[0]} GET`);
|
||||
}
|
||||
assert.deepEqual(f.admin.calls, []);
|
||||
const readOnly = await devServer(t, { send: null });
|
||||
for (const route of ROUTES.filter(r => r[1] === 'POST')) assert.deepEqual((await hit(readOnly, route)).json, { error: 'read-only' }, route[0]);
|
||||
assert.deepEqual(readOnly.admin.calls, []);
|
||||
assert.equal((await hit(f, ['nope', 'GET'])).status, 404);
|
||||
});
|
||||
|
||||
test('a pasted key: refused over plain-HTTP LAN before it is judged, accepted on loopback; mismatch and bad keys refuse', async t => {
|
||||
const f = await devServer(t, { devAllowHosts: ['lan.example'] });
|
||||
const lan = { ...f.headers('lan.example') }; delete lan['sec-fetch-site'];
|
||||
const paste = { key: SENTINEL, confirm: SENTINEL };
|
||||
const refused = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: lan, body: paste });
|
||||
assert.equal(refused.status, 403); assert.deepEqual(refused.json, { error: 'proxy-key-transport' });
|
||||
const refusedMismatch = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: lan, body: { key: SENTINEL, confirm: 'other' } });
|
||||
assert.deepEqual(refusedMismatch.json, { error: 'proxy-key-transport' }, 'transport is checked before the key');
|
||||
const generated = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: lan, body: { generate: true } });
|
||||
assert.equal(generated.status, 200, 'a LAN page still gets the generated key');
|
||||
assert.deepEqual(f.busCalls.map(c => c[1]), [{ generate: true }]);
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', { key: SENTINEL, confirm: 'different' }])).json, { error: 'proxy-key-mismatch' });
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', { key: 'a\nb', confirm: 'a\nb' }])).json, { error: 'proxy-key-invalid' });
|
||||
for (const body of [{ key: SENTINEL, confirm: SENTINEL, generate: true }, { generate: true, extra: 1 }, { generate: false }, {}])
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', body])).json, { error: 'invalid-request' }, JSON.stringify(body));
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', { generate: true, port: 8317 }])).json, { error: 'proxy-config-invalid' });
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', { generate: true, rotateGraceMinutes: 0 }])).json, { error: 'proxy-rotate-grace-invalid' });
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', { generate: true, firstOutputSeconds: 99999 }])).json, { error: 'proxy-first-output-invalid' });
|
||||
const ok = await hit(f, ['init', 'POST', { ...paste, port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 60 }]);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(f.busCalls.at(-1), ['proxy.init', { key: SENTINEL, port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 60 }]);
|
||||
assert.equal(f.logs.join('\n').includes(SENTINEL), false, 'no key in a log line');
|
||||
assert.equal(JSON.stringify(f.admin.calls.map(c => c[0])).includes(SENTINEL), false);
|
||||
});
|
||||
|
||||
test('keyTransportOk: HTTPS anywhere, HTTP only on a loopback name from a loopback peer', () => {
|
||||
assert.equal(keyTransportOk('https://console.example', '10.0.0.5'), true);
|
||||
assert.equal(keyTransportOk('http://127.0.0.1:7330', '127.0.0.1'), true);
|
||||
assert.equal(keyTransportOk('http://localhost:7330', '::ffff:127.0.0.1'), true);
|
||||
assert.equal(keyTransportOk('http://127.0.0.1:7330', '10.0.0.5'), false, 'a forwarded peer');
|
||||
assert.equal(keyTransportOk('http://lan.example:7330', '127.0.0.1'), false);
|
||||
assert.equal(keyTransportOk('http://10.0.0.2:7330', '10.0.0.5'), false);
|
||||
assert.equal(keyTransportOk('not a url', '127.0.0.1'), false);
|
||||
});
|
||||
|
||||
test('a refusal from the controller or bus reaches the page as its code only', async t => {
|
||||
const f = await devServer(t);
|
||||
const leak = Object.assign(new Error(`${SENTINEL} /home/owner/.config/mosaic-dev/proxy.json upstream body`), { code: 'proxy-unit-failed' });
|
||||
f.admin.unitAction = async () => { throw leak; };
|
||||
f.admin.status = async () => { throw new Error(SENTINEL); };
|
||||
const unit = await hit(f, ['unit/start', 'POST', {}]);
|
||||
assert.deepEqual(unit.json, { error: 'proxy-unit-failed' });
|
||||
const status = await hit(f, ROUTES[0]);
|
||||
assert.deepEqual(status.json, { error: 'outcome-unknown' });
|
||||
for (const text of [unit.text, status.text, f.logs.join('\n')]) { assert.equal(text.includes(SENTINEL), false); assert.equal(text.includes('/home/'), false); }
|
||||
const big = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: f.headers(), body: `{"key":"${SENTINEL}${'x'.repeat(17000)}"}` });
|
||||
assert.deepEqual(big.json, { error: 'invalid-request' });
|
||||
const broken = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: f.headers(), body: `{"key":"${SENTINEL}` });
|
||||
assert.deepEqual(broken.json, { error: 'invalid-request' });
|
||||
assert.equal(f.logs.join('\n').includes(SENTINEL), false);
|
||||
});
|
||||
|
||||
test('an SSO root owner reaches the routes and may paste over HTTPS; a granted user gets human-required on every route', async t => {
|
||||
const access = { accessVersion: 1, revision: 1,
|
||||
users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
|
||||
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] };
|
||||
const auth = await createSsoSession(t, { host: 'console.example', users: consoleAccess({ load: () => access, business: () => 'demo' }) });
|
||||
const admin = fakeAdmin(), busCalls = [];
|
||||
let bound = 0;
|
||||
const s = await startServer({ port: 0, sso: auth.sso, send: async () => ({}), proxyAdmin: admin, proxyCall: async (verb, args) => { busCalls.push([verb, args]); return {}; },
|
||||
userOptions: () => { bound++; return {}; }, log: () => {} });
|
||||
t.after(() => new Promise(r => s.close(r)));
|
||||
const port = s.address().port;
|
||||
const owner = auth.writeHeaders(await auth.signIn(port));
|
||||
assert.equal((await send(port, '/api/proxy/admin/status', { headers: owner })).status, 200);
|
||||
assert.equal((await send(port, '/api/config', { headers: owner })).json.proxyAdmin, true);
|
||||
const pasted = await send(port, '/api/proxy/admin/init', { method: 'POST', headers: owner, body: { key: SENTINEL, confirm: SENTINEL } });
|
||||
assert.equal(pasted.status, 200, 'HTTPS origin carries a pasted key');
|
||||
assert.equal(pasted.text.includes(SENTINEL), false);
|
||||
const before = admin.calls.length;
|
||||
const alice = auth.writeHeaders(await auth.signIn(port, { sub: 'alice-sub' }));
|
||||
for (const [route, method, body] of ROUTES) {
|
||||
const r = await send(port, `/api/proxy/admin/${route}`, { method, headers: alice, body });
|
||||
assert.equal(r.status, 403, route);
|
||||
assert.deepEqual(r.json, { error: 'human-required' }, route);
|
||||
}
|
||||
assert.equal(bound, 0, 'refused before a user transport is bound');
|
||||
assert.equal(Object.hasOwn((await send(port, '/api/config', { headers: alice })).json, 'proxyAdmin'), false);
|
||||
assert.equal(admin.calls.length, before);
|
||||
});
|
||||
|
||||
test('the tester listener gets human-required on every route, read or write', async t => {
|
||||
const { startTesterSocket } = await import('../src/tester-socket.mjs');
|
||||
const { testerRequest } = await import('../../cli/src/tester-http.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'pa-tester-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const admin = fakeAdmin();
|
||||
let calls = 0;
|
||||
const s = await startTesterSocket({ dataRoot: root, proxyAdmin: admin, proxyCall: async () => { calls++; return {}; }, log: () => {} });
|
||||
t.after(() => new Promise(r => s.close(r)));
|
||||
for (const [route, method, body] of ROUTES) {
|
||||
const r = await testerRequest({ dataRoot: root, path: `/api/proxy/admin/${route}`, method, body });
|
||||
assert.equal(r.status, 403, route);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(admin.calls, []);
|
||||
});
|
||||
|
||||
test('every refusal code a setup route can answer has a refusal-table entry with a next step', () => {
|
||||
for (const code of ['human-required', 'proxy-key-transport', 'proxy-key-mismatch', 'proxy-key-invalid', 'proxy-config-invalid', 'proxy-rotate-grace-invalid', 'proxy-first-output-invalid', 'proxy-admin-busy', 'proxy-restart-required', 'proxy-restart-unconfirmed', 'proxy-init-lock-held', 'proxy-init-lock-absent', 'proxy-unit-missing', 'proxy-unit-unhealthy', 'proxy-unit-failed', 'not-configured', 'outcome-unknown', 'invalid-request'])
|
||||
assert.equal(typeof REFUSALS[code]?.nextStep, 'string', code);
|
||||
});
|
||||
|
||||
test('an organization whose bus does not answer shows no model count, not a made-up 0', async () => {
|
||||
assert.deepEqual(await orgCatalogue(async () => { throw new Error('down'); }), { status: 'bus-unavailable', models: null });
|
||||
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'disabled' } })), { status: 'disabled', models: null });
|
||||
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'enabled', models: [{ id: 'a' }, { id: 'b' }] } })), { status: 'enabled', models: 2 });
|
||||
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'enabled', models: [] } })), { status: 'enabled', models: 0 });
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// #1873 the Proxy setup panel in a real browser: the next step is one button, init sends a
|
||||
// generated-key request unless the owner pastes a key on loopback, and pasted fields are cleared.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { w15Fixture } from './w15-fixture.mjs';
|
||||
|
||||
const T = '2026-11-11T12:00:00.000Z';
|
||||
const base = {
|
||||
release: { fetched: false, verified: false, version: '8.0.42', panelVersion: '1.26.7' },
|
||||
init: { initialized: false, partial: false, files: [{ label: 'proxy settings', present: false }, { label: 'management key', present: false }] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: false, active: 'inactive' },
|
||||
fence: { set: false }, initLock: { present: false, clearable: false, blockers: [] },
|
||||
fetch: { state: 'idle' }, restart: null, busy: null,
|
||||
organizations: [{ id: 'acme', proxy: 'shared', catalogue: null }],
|
||||
ownProxy: { available: false, state: 'not built', reason: 'The configuration has one proxy per instance.' },
|
||||
next: ['fetch'],
|
||||
};
|
||||
const fetched = { ...base, release: { ...base.release, fetched: true, verified: true }, next: ['init'] };
|
||||
const fenced = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true },
|
||||
fence: { set: true, op: 'apikey.add', org: 'acme', time: T }, restart: { confirmed: true, pid: 4242, observedAt: T }, next: ['fence-clear'] };
|
||||
const unconfigured = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'not-configured' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'not-configured' } } };
|
||||
const scenario = (status, config = {}) => ({ '/api/config': { send: true, devOwner: true, proxyAdmin: true, proxyAdminSend: true, ...config }, '/api/proxy/admin/status': status, ...unconfigured });
|
||||
const writes = b => b.evaluate('window.__writes.filter(w=>w.path.startsWith("/api/proxy/admin/")).map(w=>({path:w.path,body:JSON.parse(w.body)}))');
|
||||
|
||||
test('a fresh instance shows the fetch step as one button, the not-built own proxy, and no terminal note', { timeout: 90000 }, async t => {
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(base), 'POST /api/proxy/admin/fetch': { status: 202, body: { state: 'running' } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="fetch"');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup").hidden'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-go").textContent'), 'Fetch the pinned release');
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-next p").textContent'), /CLIProxyAPI 8\.0\.42/);
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-own").textContent'), /not built/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-orgs td").textContent'), 'acme');
|
||||
await wait('document.querySelector("#proxy-message")?.textContent.includes("Follow the next step under Proxy setup")');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#proxy-dev-setup")'), false, 'the page replaces the terminal note');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#proxy-setup-init [data-setup-write]:not([disabled])").length'), 0, 'init waits for a verified release');
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/fetch")');
|
||||
assert.deepEqual(await writes(b), [{ path: '/api/proxy/admin/fetch', body: {} }]);
|
||||
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
|
||||
});
|
||||
|
||||
test('without send the next-step button is disabled and nothing is posted', { timeout: 90000 }, async t => {
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: scenario(base, { send: false, proxyAdminSend: false }) });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="fetch"');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-go").disabled'), true);
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
assert.deepEqual(await writes(b), []);
|
||||
});
|
||||
|
||||
test('init sends generate by default; a pasted key on loopback is sent with its confirmation and the fields are cleared', { timeout: 90000 }, async t => {
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(fetched), 'POST /api/proxy/admin/init': { body: { settings: { port: 18432 }, receipt: { op: 'init', time: T, port: 18432, generated: true } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="init"');
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('document.querySelector("#proxy-setup-init-box").open===true');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-paste-choice").hidden'), false, 'loopback page offers the paste choice');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-paste").hidden'), true);
|
||||
await b.evaluate('(()=>{const f=document.querySelector("#proxy-setup-init");f.elements.port.value="18432";f.requestSubmit();})()');
|
||||
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/init")');
|
||||
await wait('document.querySelector("#proxy-setup-message").textContent.includes("with a generated key")');
|
||||
assert.deepEqual(await writes(b), [{ path: '/api/proxy/admin/init', body: { port: 18432, generate: true } }]);
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-receipts").textContent'), /init/);
|
||||
await wait('!document.querySelector("#proxy-setup-init [data-setup-write]").disabled');
|
||||
await b.evaluate('(()=>{const f=document.querySelector("#proxy-setup-init");f.elements.keySource.value="paste";f.querySelector("[value=paste]").dispatchEvent(new Event("change"));f.elements.port.value="";f.elements.key.value="pa-browser-sentinel-0001";f.elements.confirm.value="pa-browser-sentinel-0002";f.requestSubmit();})()');
|
||||
await wait('document.querySelector("#proxy-setup-message").textContent.includes("proxy-key-mismatch")');
|
||||
assert.equal((await writes(b)).length, 1, 'a mismatch is refused in the page');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-init").elements.key.value'), '', 'the key field is cleared on submit');
|
||||
await b.evaluate('(()=>{const f=document.querySelector("#proxy-setup-init");f.elements.key.value="pa-browser-sentinel-0001";f.elements.confirm.value="pa-browser-sentinel-0001";f.requestSubmit();})()');
|
||||
await wait('window.__writes.filter(w=>w.path==="/api/proxy/admin/init").length===2');
|
||||
assert.deepEqual((await writes(b))[1], { path: '/api/proxy/admin/init', body: { key: 'pa-browser-sentinel-0001', confirm: 'pa-browser-sentinel-0001' } });
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#proxy-setup-init input[type=password]")].every(i=>i.value==="")'), true);
|
||||
assert.equal(await b.evaluate('document.body.innerText.includes("pa-browser-sentinel")'), false);
|
||||
});
|
||||
|
||||
test('after a confirmed restart the next step clears the fence and shows the receipt', { timeout: 90000 }, async t => {
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(fenced), 'POST /api/proxy/admin/fence/clear': { body: { cleared: true, receipt: { op: 'fence.clear', time: T, pid: 4242 } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="fence-clear"');
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-state").textContent'), /new pid 4242/);
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('document.querySelector("#proxy-setup-message").textContent.includes("Fence cleared")');
|
||||
assert.deepEqual(await writes(b), [{ path: '/api/proxy/admin/fence/clear', body: {} }]);
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-receipts").textContent'), /fence\.clear.*pid: 4242/);
|
||||
});
|
||||
|
||||
test('a stopped proxy hides the account panels with a setup note, and starting the service brings them back', { timeout: 90000 }, async t => {
|
||||
const installed = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'inactive' }, next: ['unit-start'] };
|
||||
const down = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'proxy-down' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'proxy-down' } } };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(installed), ...down, 'POST /api/proxy/admin/unit/start': { body: { receipt: { op: 'unit.start', time: T, pid: 4242 } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-start"');
|
||||
await wait('document.querySelector("#proxy-message").textContent.includes("not running yet")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-apikey-title]").hidden'), true);
|
||||
await b.evaluate(`Object.assign(window.__scenario, {
|
||||
'GET /api/proxy/accounts': { body: { accounts: [] } }, 'GET /api/proxy/keys': { body: { keys: [] } },
|
||||
'/api/proxy/admin/status': ${JSON.stringify({ ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true }, next: ['accounts'] })} })`);
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/unit/start")');
|
||||
await wait('document.querySelector("#proxy-message").textContent.includes("Accounts and client keys loaded")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-apikey-title]").hidden'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), false);
|
||||
});
|
||||
|
||||
test('an initialized proxy whose accounts read fails with another code also hides the panels until it answers', { timeout: 90000 }, async t => {
|
||||
const installed = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'inactive' }, next: ['unit-start'] };
|
||||
const failing = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'outcome-unknown' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'outcome-unknown' } } };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(installed), ...failing, 'POST /api/proxy/admin/unit/start': { body: { receipt: { op: 'unit.start', time: T, pid: 4242 } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-start"');
|
||||
await wait('document.querySelector("#proxy-message").textContent.includes("the account panels return once the proxy answers")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), true);
|
||||
await b.evaluate(`Object.assign(window.__scenario, {
|
||||
'GET /api/proxy/accounts': { body: { accounts: [] } }, 'GET /api/proxy/keys': { body: { keys: [] } },
|
||||
'/api/proxy/admin/status': ${JSON.stringify({ ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true }, next: ['accounts'] })} })`);
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('document.querySelector("#proxy-message").textContent.includes("Accounts and client keys loaded")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), false);
|
||||
});
|
||||
|
||||
test('a fence with no readable time names the --proxy-restarted path instead of a page restart', { timeout: 90000 }, async t => {
|
||||
const timeless = { ...fenced, fence: { set: true, invalid: true }, restart: null, next: ['unit-restart'] };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: scenario(timeless) });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-restart"');
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-next p").textContent'), /no readable time.*mosaic proxy fence clear --proxy-restarted/);
|
||||
});
|
||||
|
||||
test('after a reload the receipts come from status, and an initialized proxy folds the init form', { timeout: 90000 }, async t => {
|
||||
const initialized = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
receipts: [{ op: 'fetch', time: T, version: '8.0.42' }, { op: 'init', time: T, generated: true, port: 18432 }], next: ['unit-install'] };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(initialized), 'POST /api/proxy/admin/unit/install': { body: { receipt: { op: 'unit.install', time: T } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-install"');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#proxy-setup-receipts li strong")].map(s=>s.textContent)'), ['init', 'fetch'], 'newest first, from proxy-admin.jsonl');
|
||||
await b.evaluate('document.querySelector("#proxy-setup-init-box").open = true');
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/unit/install")');
|
||||
await wait('document.querySelector("#proxy-setup-init-box").open===false');
|
||||
});
|
||||
Reference in new issue
Block a user