Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
215 lines
9.6 KiB
JavaScript
215 lines
9.6 KiB
JavaScript
// Row 60 (#1549): `credentials`, the broker's credential status read. Human and reader
|
|
// sessions read service, account, role, date and state for their own business; a role
|
|
// session can't. No token, file path or variable name reaches the output or the host log.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { fork } from 'node:child_process';
|
|
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { once } from 'node:events';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
import { Credentials } from '../src/credentials.mjs';
|
|
import { Client } from '../src/client.mjs';
|
|
|
|
const day = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
|
const authority = { withinRole: ['message.send'], crossRole: [] };
|
|
const TOKENS = {
|
|
pmGitea: 'fixture_pm_gitea_0123456789',
|
|
pmVikunja: 'fixture_pm_vikunja_0123456789',
|
|
coderGitea: 'fixture_coder_gitea_0123456789',
|
|
coderVikunja: 'fixture_coder_vikunja_0123456789',
|
|
sync: 'fixture_sync_vikunja_0123456789',
|
|
other: 'fixture_other_gitea_0123456789',
|
|
};
|
|
const ENV = 'FIXTURE_ROW60_PM_VIKUNJA';
|
|
|
|
// Two businesses, `demo` and `demox`: an instance prefix without its slash would leak one into the other.
|
|
function seed(root) {
|
|
const secrets = join(root, 'secrets');
|
|
mkdirSync(secrets);
|
|
const file = (name) => {
|
|
const path = join(secrets, name);
|
|
writeFileSync(path, TOKENS[name] + '\n', { mode: 0o600 });
|
|
return path;
|
|
};
|
|
const paths = { pmGitea: file('pmGitea'), coderGitea: file('coderGitea'), coderVikunja: file('coderVikunja') };
|
|
paths.sync = file('sync');
|
|
paths.other = file('other');
|
|
const businesses = {
|
|
demo: {
|
|
id: 'demo',
|
|
human: 'jason',
|
|
// Row 65: runtime launches bind only on a harness the business switches on.
|
|
plugins: { harness: ['pi'] },
|
|
arbiters: { technical: 'cto', delivery: 'pm' },
|
|
roles: {
|
|
pm: {
|
|
authority,
|
|
tracker: { bot: 'bot-pm' },
|
|
credentials: {
|
|
gitea: { file: paths.pmGitea, rotateBy: '2099-01-01' },
|
|
vikunja: { env: ENV, expires: day(3) },
|
|
},
|
|
},
|
|
coder: {
|
|
authority,
|
|
tracker: { bot: 'bot-coder' },
|
|
credentials: {
|
|
gitea: { file: paths.coderGitea, rotateBy: '2000-01-01' },
|
|
vikunja: { file: paths.coderVikunja, expires: '2000-01-01' },
|
|
},
|
|
},
|
|
cto: { authority },
|
|
},
|
|
tracker: { sync: { bot: 'bot-sync', botId: 7, credentials: { vikunja: { file: paths.sync, expires: '2099-01-01' } } } },
|
|
},
|
|
demox: {
|
|
id: 'demox',
|
|
human: 'jason',
|
|
arbiters: { technical: 'cto', delivery: 'cto' },
|
|
roles: { cto: { authority, credentials: { gitea: { file: paths.other, rotateBy: '2099-01-01' } } } },
|
|
},
|
|
};
|
|
return { businesses, paths, secrets, env: { [ENV]: TOKENS.pmVikunja } };
|
|
}
|
|
// What the runtime builds: one reference per business/instance, plus business/@sync.
|
|
function references(businesses) {
|
|
const out = {};
|
|
for (const [business, b] of Object.entries(businesses)) {
|
|
for (const [role, r] of Object.entries(b.roles)) if (r.credentials) out[business + '/' + role] = r.credentials;
|
|
if (b.tracker?.sync?.credentials) out[business + '/@sync'] = b.tracker.sync.credentials;
|
|
}
|
|
return out;
|
|
}
|
|
const DEMO = [
|
|
{ service: 'gitea', account: null, role: 'pm', dateKind: 'rotateBy', date: '2099-01-01', state: 'valid' },
|
|
{ service: 'vikunja', account: 'bot-pm', role: 'pm', dateKind: 'expires', date: day(3), state: 'expiring' },
|
|
{ service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: '2000-01-01', state: 'rotation-due' },
|
|
{ service: 'vikunja', account: 'bot-coder', role: 'coder', dateKind: 'expires', date: '2000-01-01', state: 'expired' },
|
|
{ service: 'vikunja', account: 'bot-sync', role: 'tracker sync', dateKind: 'expires', date: '2099-01-01', state: 'valid' },
|
|
];
|
|
// Nothing that names or holds a credential: the tokens, the token files and their directory, and the env name.
|
|
const forbidden = (secrets, paths) => [...Object.values(TOKENS), ...Object.values(paths), secrets, ENV];
|
|
const clean = (text, words) => words.filter((w) => text.includes(w));
|
|
|
|
function setup(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-cred-status-'));
|
|
const { businesses, paths, secrets, env } = seed(root);
|
|
const credentials = new Credentials({ references: references(businesses), repoRoots: [], dataRoot: '/nonexistent-data', env });
|
|
mkdirSync(join(root, 'data'));
|
|
const store = new Store(join(root, 'data'));
|
|
const b = new Broker({
|
|
store,
|
|
businesses,
|
|
secretCheck: (value) => credentials.assertClean(value),
|
|
credentialStatus: () => credentials.status(),
|
|
});
|
|
t.after(() => {
|
|
store.close();
|
|
credentials.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
return { b, secrets, paths };
|
|
}
|
|
|
|
test('credentials gives human and reader sessions service, account, role, date and state, and nothing else', (t) => {
|
|
const { b, secrets, paths } = setup(t);
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const reader = b.bindReader({ business: 'demo' });
|
|
for (const cap of [human, reader]) {
|
|
const rows = b.request(cap, { verb: 'credentials' });
|
|
assert.deepEqual(rows, DEMO);
|
|
for (const row of rows) assert.deepEqual(Object.keys(row), ['service', 'account', 'role', 'dateKind', 'date', 'state']);
|
|
assert.deepEqual(clean(JSON.stringify(rows), forbidden(secrets, paths)), []);
|
|
}
|
|
assert.deepEqual(b.request(b.bindReader({ business: 'demox' }), { verb: 'credentials' }), [
|
|
{ service: 'gitea', account: null, role: 'cto', dateKind: 'rotateBy', date: '2099-01-01', state: 'valid' },
|
|
]);
|
|
});
|
|
|
|
test('credentials refuses a role session and any argument', (t) => {
|
|
const { b } = setup(t);
|
|
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
|
b.request(coder, { verb: 'role.claim' });
|
|
assert.throws(() => b.request(coder, { verb: 'credentials' }), { code: 'human-required' });
|
|
const reader = b.bindReader({ business: 'demo' });
|
|
assert.throws(() => b.request(reader, { verb: 'credentials', args: { role: 'coder' } }), { code: 'invalid-request' });
|
|
assert.throws(() => b.request(reader, { verb: 'credentials', args: { business: 'demox' } }), { code: 'invalid-request' });
|
|
});
|
|
|
|
test('credentials reads the status on each request, and a broker without one lists none', (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-cred-live-'));
|
|
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'cto' }, roles: { cto: { authority } } } };
|
|
const stores = ['a', 'b'].map((d) => (mkdirSync(join(root, d)), new Store(join(root, d))));
|
|
t.after(() => {
|
|
for (const s of stores) s.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
let state = 'valid';
|
|
const b = new Broker({
|
|
store: stores[0],
|
|
businesses,
|
|
credentialStatus: () => [{ instance: 'demo/cto', service: 'gitea', date: '2099-01-01', state }],
|
|
});
|
|
const reader = b.bindReader({ business: 'demo' });
|
|
assert.equal(b.request(reader, { verb: 'credentials' })[0].state, 'valid');
|
|
state = 'rotation-due';
|
|
assert.equal(b.request(reader, { verb: 'credentials' })[0].state, 'rotation-due');
|
|
const bare = new Broker({ store: stores[1], businesses });
|
|
assert.deepEqual(bare.request(bare.bindReader({ business: 'demo' }), { verb: 'credentials' }), []);
|
|
});
|
|
|
|
// The brief's acceptance: the broker process on a seeded config holding a file token, an env
|
|
// value and their paths and names. The op's output, the host log and the store hold none of them.
|
|
test('the broker process answers credentials without a token, path or variable in its output, log or store', async (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-cred-process-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const { businesses, paths, secrets, env } = seed(root);
|
|
const data = join(root, 'data');
|
|
mkdirSync(data);
|
|
const child = fork(new URL('../src/process.mjs', import.meta.url), [], {
|
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
env: { ...process.env, ...env },
|
|
});
|
|
let logs = '';
|
|
child.stdout.on('data', (b) => (logs += b));
|
|
child.stderr.on('data', (b) => (logs += b));
|
|
t.after(() => {
|
|
if (child.exitCode === null) child.kill('SIGKILL');
|
|
});
|
|
const reply = once(child, 'message');
|
|
child.send({
|
|
op: 'boot',
|
|
config: {
|
|
dataRoot: data,
|
|
businesses,
|
|
launches: [{ business: 'demo', role: 'coder', run: 'r1', harness: 'pi', pid: process.pid, startTime: '1' }],
|
|
readers: ['demo'],
|
|
},
|
|
});
|
|
const ready = (await reply)[0];
|
|
assert.equal(ready.ok, true);
|
|
const reader = new Client({ path: ready.path, cap: ready.readers[0].cap });
|
|
const rows = await reader.call('credentials');
|
|
assert.deepEqual(rows, DEMO);
|
|
const agent = new Client({ path: ready.path, cap: ready.launches[0].cap });
|
|
await agent.call('role.claim');
|
|
await assert.rejects(agent.call('credentials'), /human-required/);
|
|
const exit = once(child, 'exit');
|
|
child.send({ op: 'close' });
|
|
assert.equal((await exit)[0], 0);
|
|
|
|
const words = forbidden(secrets, paths);
|
|
assert.deepEqual(clean(JSON.stringify(rows), words), []);
|
|
assert.deepEqual(clean(logs, words), []);
|
|
const bus = join(data, 'bus');
|
|
const stored = readdirSync(bus)
|
|
.filter((f) => f.startsWith('bus.sqlite'))
|
|
.map((f) => readFileSync(join(bus, f), 'latin1'))
|
|
.join('');
|
|
assert.ok(stored.includes('human-required'), 'the refusal is in the store');
|
|
assert.deepEqual(clean(stored, words), []);
|
|
});
|