Add fixture-only execution materialization and refresh (#1500)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Registry metadata validation
|
||||
|
||||
Read-only metadata CLI. No materialization, live credential handling or refresh service.
|
||||
Read-only metadata CLI plus fixture-only resolution, generation and refresh APIs. No live credential handling or refresh service.
|
||||
|
||||
```
|
||||
node packages/mosaic/src/cli/main.mjs validate --registry-root /absolute/fixture-root
|
||||
@@ -12,7 +12,7 @@ node --test packages/mosaic/tests/
|
||||
|
||||
The reader currently supports Linux with procfs and descriptor-relative paths. Other platforms refuse explicitly rather than falling back to unsafe traversal. Root and all traversed registry directories must belong to the invoking user and have mode 0700. Metadata files must be regular, owned by that user and mode 0600. Symlinks are rejected in the root path, directories and metadata files. Required directories are auth/providers, auth/accounts, auth/settings and harnesses. A missing or empty registry is invalid, not an empty successful listing.
|
||||
|
||||
Metadata is limited to 1 MiB per file. Directory handles anchor child access while paths are being checked, preventing a renamed ancestor from redirecting later opens. Files are opened without following symlinks and checked against their prior inode/device and permissions. This is not a claim of transactionally consistent multi-record snapshots under concurrent writers; registry mutation and materialization are outside this package slice.
|
||||
Metadata is limited to 1 MiB per file. Directory handles anchor child access while paths are being checked, preventing a renamed ancestor from redirecting later opens. Files are opened without following symlinks and checked against their prior inode/device and permissions. This is not a claim of transactionally consistent multi-record snapshots under concurrent writers; registry mutation and production materialization remain outside this package slice.
|
||||
|
||||
Account metadata lives at auth/accounts/<provider>/<account>/account.json. The credential.json sibling is never opened or inspected. No real credentials are needed for tests. Public Git fixtures do not preserve private modes: the tests copy them into temporary directories and set 0700/0600 before invoking the reader. Do not run the CLI against your real credential stores for a smoke test.
|
||||
|
||||
@@ -22,4 +22,18 @@ Record version fields are numeric 1. Unknown versions, top-level/nested unknown
|
||||
|
||||
Profiles may only default to an enrolled account. Account types must be supported by their providers, and references must resolve. Failed validation returns no partial entries or CLI listing. Diagnostics use fixed paths/codes, never input values, unknown keys or JSON parser excerpts. Invalid input exits 1; command usage errors exit 2.
|
||||
|
||||
The safety regression suite covers independently reproduced findings D1-D10 from issue #1500. Passing it establishes those tested properties, not a production security certification or authorization to build materialization on unreviewed code.
|
||||
The safety regression suite covers independently reproduced findings D1-D10 from issue #1500. Passing it establishes those tested properties, not a production security certification.
|
||||
|
||||
## Fixture-only execution and refresh
|
||||
|
||||
`resolveFixtureExecution`, `createFixtureCredentialStore` and `createFixtureWorkspace` are exported from src/index.mjs. Supply a validated registry snapshot, explicit scope and marker-only synthetic credentials. Scope is fixture input, not proof of actual project membership. Fork pins and enrollment constrain selection; native model ceilings that cannot be enforced are refused.
|
||||
|
||||
`workspace.generate(registry, request, store, options)` creates distinct private execution generations in a dedicated /tmp root. It accepts only the branded in-memory store. No production backend or output directory can be injected. Call `workspace.close()` when done. Published files are never rewritten by this API. Exclusive claims survive failures and prohibit retrying the same execution ID.
|
||||
|
||||
Expired or near-expiry OAuth fixtures automatically run the fixed fake refresh program under the store transaction. Each invocation has isolated PI_CODING_AGENT_DIR, HOME and cwd, with no inherited environment. No real Pi executable, shell, print command or network client is used. Both credential fields rotate together; successful generation commits the in-memory draft. Child output is discarded, the child is killed on timeout, and cleanup waits for child closure. Output size, type, mode and fixture markers are validated before use.
|
||||
|
||||
Tests can supply `refresh: { mode, timeoutMs }`. Modes are rotate, unchanged, failure, timeout and malformed. Timeout is 10–10000 ms, default 2000. Supplying refresh forces the fake check even for currently valid credentials. Generation fault hooks are after-auth, before-publish and after-publish. No arbitrary executable, environment or callback can be supplied.
|
||||
|
||||
Limits: store locks are in-process, not a production distributed lock. Credential-store commit and filesystem publication are not crash-atomic together. A post-publication failure records uncertainty, retains the generation and rolls back the in-memory draft; this simulator has no external token issuer to reconcile. A real refresh backend would need a separate reconciliation protocol before reuse. Filesystem ownership does not defend against arbitrary same-UID tampering or establish hardlink provenance.
|
||||
|
||||
Exact Pi 0.85.1 isolation is static-source evidence only. Its model-catalog network flag does NOT suppress OAuth refresh networking. Never substitute real Pi in these tests, even with expired synthetic OAuth tokens. Final whole-increment review is required before publication.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Fixture-only execution resolution. Not a membership/registration authority.
|
||||
import { validId, validateProvider, validateAccount, validateSettingsProfile,
|
||||
validateHarnessManifest } from './records.mjs';
|
||||
export class FixtureError extends Error {
|
||||
constructor(code) { super(code); this.name = 'FixtureError'; this.code = code; }
|
||||
}
|
||||
export const refuse = code => { throw new FixtureError(code); };
|
||||
const obj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
export function exact(v, required, optional = []) {
|
||||
if (!obj(v) || required.some(k => !Object.hasOwn(v, k)) ||
|
||||
Object.keys(v).some(k => ![...required, ...optional].includes(k))) refuse('invalid-fixture-input');
|
||||
}
|
||||
function checkRegistry(result) {
|
||||
if (!obj(result) || !Array.isArray(result.errors) || result.errors.length || !obj(result.entries)) refuse('invalid-registry');
|
||||
const entries = result.entries;
|
||||
for (const [name, validate] of [['providers', validateProvider], ['accounts', validateAccount],
|
||||
['profiles', validateSettingsProfile], ['harnesses', validateHarnessManifest]]) {
|
||||
if (!obj(entries[name])) refuse('invalid-registry');
|
||||
for (const [key, record] of Object.entries(entries[name])) {
|
||||
if (validate(record).length) refuse('invalid-registry');
|
||||
if (key !== (name === 'accounts' ? `${record.provider}/${record.id}` : record.id)) refuse('invalid-registry');
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
export function resolveFixtureExecution(result, request) {
|
||||
// Clone immediately: later caller mutation cannot change this resolution.
|
||||
let r, registry;
|
||||
try { r = structuredClone(request); registry = structuredClone(result); }
|
||||
catch { refuse('invalid-fixture-input'); }
|
||||
const entries = checkRegistry(registry);
|
||||
exact(r, ['fixtureOnly', 'agentId', 'projectId', 'workspaceId', 'sessionId', 'executionId', 'profile'], ['accounts', 'fork']);
|
||||
if (r.fixtureOnly !== true) refuse('fixture-only');
|
||||
for (const key of ['agentId', 'projectId', 'workspaceId', 'sessionId', 'executionId', 'profile'])
|
||||
if (!validId(r[key])) refuse('invalid-scope');
|
||||
if (!Object.hasOwn(entries.profiles, r.profile)) refuse('missing-profile');
|
||||
const profile = entries.profiles[r.profile];
|
||||
const overrides = r.accounts ?? {};
|
||||
if (!obj(overrides)) refuse('invalid-selection');
|
||||
const providers = new Set([...(profile.providers ?? []), ...profile.allowedAccounts.map(ref => ref.split('/')[0])]);
|
||||
for (const key of Object.keys(overrides)) if (!providers.has(key)) refuse('provider-not-enrolled');
|
||||
if (Object.hasOwn(r, 'fork')) {
|
||||
exact(r.fork, ['sourceSessionId', 'accounts']);
|
||||
if (!validId(r.fork.sourceSessionId) || r.fork.sourceSessionId === r.sessionId || !obj(r.fork.accounts)) refuse('invalid-fork-pin');
|
||||
for (const id of Object.keys(r.fork.accounts)) if (!providers.has(id)) refuse('revoked-fork-pin');
|
||||
}
|
||||
const accounts = Object.create(null), models = { providers: Object.create(null) }, types = Object.create(null);
|
||||
for (const id of [...providers].sort()) {
|
||||
if (!Object.hasOwn(entries.providers, id)) refuse('missing-provider');
|
||||
const provider = entries.providers[id], cfg = provider.harnesses.pi;
|
||||
if (!cfg || !entries.harnesses.pi) refuse('unsupported-harness');
|
||||
let ref;
|
||||
if (r.fork) {
|
||||
ref = r.fork.accounts[id];
|
||||
if (Object.hasOwn(overrides, id) && overrides[id] !== ref) refuse('fork-account-change');
|
||||
} else ref = Object.hasOwn(overrides, id) ? overrides[id] : profile.defaultAccounts?.[id];
|
||||
if (ref === undefined) {
|
||||
if (!provider.credentialTypes.includes('none')) refuse(r.fork ? 'missing-fork-pin' : 'missing-account-selection');
|
||||
} else {
|
||||
if (typeof ref !== 'string' || !profile.allowedAccounts.includes(ref) || ref.split('/')[0] !== id)
|
||||
refuse('account-not-enrolled');
|
||||
if (!Object.hasOwn(entries.accounts, ref)) refuse('missing-account');
|
||||
const account = entries.accounts[ref];
|
||||
if (account.provider !== id || !provider.credentialTypes.includes(account.type)) refuse('credential-type-not-supported');
|
||||
accounts[id] = ref; types[id] = account.type;
|
||||
}
|
||||
if (provider.kind === 'native') {
|
||||
// No aliasing to another provider's credential slot.
|
||||
if (cfg.providerId !== id) refuse('provider-alias-unsupported');
|
||||
if (profile.models?.[id]) refuse('native-model-enforcement-unavailable');
|
||||
} else {
|
||||
if (!['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai'].includes(cfg.api)) refuse('unsupported-model-api');
|
||||
const selected = profile.models?.[id] ?? cfg.models;
|
||||
if (!selected.length || selected.some(m => !cfg.models.includes(m))) refuse('model-not-enrolled');
|
||||
models.providers[id] = { api: cfg.api, baseUrl: cfg.baseUrl, models: selected.map(model => ({ id: model })) };
|
||||
if (!ref || types[id] === 'none') models.providers[id].apiKey = 'FIXTURE_NONE';
|
||||
}
|
||||
}
|
||||
const scope = Object.fromEntries(['agentId', 'projectId', 'workspaceId', 'sessionId', 'executionId'].map(k => [k, r[k]]));
|
||||
return { fixtureOnly: true, scope, profile: r.profile, accounts, types, models,
|
||||
fork: r.fork ? { sourceSessionId: r.fork.sourceSessionId, accounts: { ...accounts } } : null };
|
||||
}
|
||||
// Real keys/tokens are deliberately outside this slice's accepted input language.
|
||||
const marker = v => typeof v === 'string' && /^FIXTURE_[A-Z0-9_-]{1,128}$/.test(v);
|
||||
export function validateFixtureCredential(value, type) {
|
||||
if (type === 'none') { if (value !== null) refuse('invalid-fixture-credential'); return null; }
|
||||
if (type === 'api_key') {
|
||||
exact(value, ['type', 'key']);
|
||||
if (value.type !== type || !marker(value.key)) refuse('invalid-fixture-credential');
|
||||
} else if (type === 'oauth') {
|
||||
exact(value, ['type', 'access', 'refresh', 'expires']);
|
||||
if (value.type !== type || !marker(value.access) || !marker(value.refresh) ||
|
||||
!Number.isSafeInteger(value.expires) || value.expires < 0) refuse('invalid-fixture-credential');
|
||||
} else refuse('unsupported-fixture-credential');
|
||||
return structuredClone(value);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// In-memory synthetic store only; no filesystem or production backend.
|
||||
import { refuse, validateFixtureCredential } from './execution.mjs';
|
||||
const stores = new WeakSet();
|
||||
export const isFixtureStore = store => stores.has(store);
|
||||
export function createFixtureCredentialStore(initial) {
|
||||
const records = new Map(), locks = new Set();
|
||||
for (const [ref, value] of Object.entries(initial)) {
|
||||
if (!/^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/.test(ref)) refuse('invalid-account-ref');
|
||||
records.set(ref, validateFixtureCredential(value, value?.type ?? 'none'));
|
||||
}
|
||||
const store = Object.freeze({
|
||||
fixtureOnly: true,
|
||||
async transaction(refs, callback) {
|
||||
const keys = [...new Set(refs)].sort();
|
||||
for (const key of keys) {
|
||||
if (!records.has(key)) refuse('missing-fixture-credential');
|
||||
if (locks.has(key)) refuse('credential-busy');
|
||||
}
|
||||
keys.forEach(key => locks.add(key));
|
||||
try {
|
||||
const draft = new Map(keys.map(key => [key, structuredClone(records.get(key))]));
|
||||
const result = await callback(draft);
|
||||
for (const key of keys) validateFixtureCredential(draft.get(key), records.get(key)?.type ?? 'none');
|
||||
keys.forEach(key => records.set(key, structuredClone(draft.get(key))));
|
||||
return result;
|
||||
} finally { keys.forEach(key => locks.delete(key)); }
|
||||
},
|
||||
});
|
||||
stores.add(store);
|
||||
return store;
|
||||
}
|
||||
@@ -2,3 +2,6 @@ import { validateProvider, validateAccount, validateSettingsProfile, validateSea
|
||||
import { loadRegistry } from "./registry.mjs";
|
||||
|
||||
export { validateProvider, validateAccount, validateSettingsProfile, validateSeatSelection, validateHarnessManifest, ValidationError, loadRegistry };
|
||||
export { resolveFixtureExecution, FixtureError } from './execution.mjs';
|
||||
export { createFixtureCredentialStore } from './fixture-store.mjs';
|
||||
export { createFixtureWorkspace } from './materialize-fixture.mjs';
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Synthetic-only publication simulator. No caller-selected filesystem root,
|
||||
// production credential adapter, real Pi runner, service or live activation.
|
||||
import { mkdtemp, mkdir, open, rename, rm, lstat } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { FixtureError, exact, refuse, resolveFixtureExecution, validateFixtureCredential } from './execution.mjs';
|
||||
import { isFixtureStore } from './fixture-store.mjs';
|
||||
import { refreshFixtureCredential, validateRefreshOptions } from './refresh-fixture.mjs';
|
||||
const anchor = h => `/proc/self/fd/${h.fd}`;
|
||||
async function openDir(path) { return open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); }
|
||||
async function verifyJson(dir, name, value) {
|
||||
const expected = Buffer.from(JSON.stringify(value, null, 2) + '\n');
|
||||
const h = await open(`${anchor(dir)}/${name}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try {
|
||||
const s = await h.stat();
|
||||
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size !== expected.length) refuse('generation-validation-failed');
|
||||
const actual = Buffer.alloc(expected.length + 1);
|
||||
let size = 0;
|
||||
while (size < actual.length) {
|
||||
const { bytesRead } = await h.read(actual, size, actual.length - size, null);
|
||||
if (!bytesRead) break;
|
||||
size += bytesRead;
|
||||
}
|
||||
if (size !== expected.length || !actual.subarray(0, size).equals(expected)) refuse('generation-validation-failed');
|
||||
} finally { await h.close(); }
|
||||
}
|
||||
async function writeJson(dir, name, value) {
|
||||
const content = JSON.stringify(value, null, 2) + '\n';
|
||||
if (Buffer.byteLength(content) > 1024 * 1024) refuse('generation-too-large');
|
||||
const h = await open(`${anchor(dir)}/${name}`, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
try { await h.writeFile(content); await h.sync(); }
|
||||
finally { await h.close(); }
|
||||
}
|
||||
export async function createFixtureWorkspace() {
|
||||
if (process.platform !== 'linux') refuse('unsupported-platform');
|
||||
const path = await mkdtemp('/tmp/mosaic-materialization-fixture-');
|
||||
const root = await openDir(path), identity = await root.stat();
|
||||
let closed = false, active = 0;
|
||||
const handles = {};
|
||||
try {
|
||||
for (const name of ['claims', 'pending', 'generations']) {
|
||||
await mkdir(`${anchor(root)}/${name}`, { mode: 0o700 });
|
||||
handles[name] = await openDir(`${anchor(root)}/${name}`);
|
||||
}
|
||||
} catch (e) {
|
||||
for (const h of Object.values(handles)) await h.close();
|
||||
await root.close(); await rm(path, { recursive: true, force: true }); throw e;
|
||||
}
|
||||
return Object.freeze({
|
||||
fixtureOnly: true, path,
|
||||
async generate(registry, request, store, options = {}) {
|
||||
if (closed) refuse('workspace-closed');
|
||||
if (!isFixtureStore(store)) refuse('fixture-store-required');
|
||||
exact(options, [], ['fault', 'refresh']);
|
||||
if (options.fault !== undefined && !['after-auth', 'before-publish', 'after-publish'].includes(options.fault)) refuse('invalid-fixture-option');
|
||||
options = { fault: options.fault, refresh: options.refresh === undefined ? undefined : validateRefreshOptions(options.refresh) };
|
||||
const plan = resolveFixtureExecution(registry, request), id = plan.scope.executionId;
|
||||
const pending = `${anchor(handles.pending)}/${id}`, claimPath = `${anchor(handles.claims)}/${id}`;
|
||||
let claim, generation, claimed = false, published = false;
|
||||
active++;
|
||||
try {
|
||||
try { await mkdir(claimPath, { mode: 0o700 }); claimed = true; }
|
||||
catch { refuse('execution-already-claimed'); }
|
||||
claim = await openDir(claimPath);
|
||||
await writeJson(claim, 'started.json', { fixtureOnly: true, executionId: id, state: 'started' });
|
||||
const result = await store.transaction(Object.values(plan.accounts), async draft => {
|
||||
const auth = Object.create(null);
|
||||
for (const [provider, ref] of Object.entries(plan.accounts)) {
|
||||
let credential = validateFixtureCredential(draft.get(ref), plan.types[provider]);
|
||||
if (credential !== null && (options.refresh !== undefined ||
|
||||
(credential.type === 'oauth' && credential.expires <= Date.now() + 300000))) {
|
||||
credential = await refreshFixtureCredential(provider, credential, options.refresh ?? {});
|
||||
draft.set(ref, credential);
|
||||
}
|
||||
if (credential !== null) auth[provider] = credential;
|
||||
}
|
||||
await mkdir(pending, { mode: 0o700 }); generation = await openDir(pending);
|
||||
await writeJson(generation, 'auth.json', auth);
|
||||
if (options.fault === 'after-auth') refuse('injected-generation-failure');
|
||||
await writeJson(generation, 'models.json', plan.models);
|
||||
const manifest = { manifestVersion: 1, fixtureOnly: true, manifestId: randomUUID(),
|
||||
scope: plan.scope, profile: plan.profile, accounts: plan.accounts, fork: plan.fork,
|
||||
artifacts: ['auth.json', 'models.json'], state: 'ready' };
|
||||
// Manifest contains no token, expiry or credential-content digest.
|
||||
await writeJson(generation, 'manifest.json', manifest);
|
||||
if (options.fault === 'before-publish') refuse('injected-generation-failure');
|
||||
await verifyJson(generation, 'auth.json', auth);
|
||||
await verifyJson(generation, 'models.json', plan.models);
|
||||
await verifyJson(generation, 'manifest.json', manifest);
|
||||
await generation.sync(); await generation.close(); generation = undefined;
|
||||
const target = `${anchor(handles.generations)}/${id}`;
|
||||
try { await lstat(target); refuse('generation-exists'); }
|
||||
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
// Retained exclusive claim serializes this execution ID, including
|
||||
// failed attempts. Published generations are never replaced by API.
|
||||
await rename(pending, target); published = true;
|
||||
if (options.fault === 'after-publish') refuse('injected-generation-failure');
|
||||
await handles.generations.sync();
|
||||
await writeJson(claim, 'result.json', { fixtureOnly: true, state: 'published', manifestId: manifest.manifestId });
|
||||
return { fixtureOnly: true, manifest, generationPath: `${path}/generations/${id}` };
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (generation) { await generation.close(); generation = undefined; }
|
||||
if (claimed && !published) await rm(pending, { recursive: true, force: true });
|
||||
const code = published ? 'publication-uncertain' : e instanceof FixtureError ? e.code : 'generation-failed';
|
||||
if (claim) {
|
||||
try { await writeJson(claim, 'failure.json', { fixtureOnly: true, state: published ? 'uncertain' : 'failed', code }); }
|
||||
catch { throw new FixtureError('recording-failed'); }
|
||||
}
|
||||
throw new FixtureError(code);
|
||||
} finally {
|
||||
if (claim) await claim.close(); active--;
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
if (active) refuse('workspace-busy');
|
||||
closed = true;
|
||||
for (const h of Object.values(handles)) await h.close();
|
||||
await root.close();
|
||||
const current = await lstat(path);
|
||||
if (current.dev !== identity.dev || current.ino !== identity.ino || current.isSymbolicLink()) refuse('workspace-path-changed');
|
||||
await rm(path, { recursive: true, force: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Deliberately executes a fixed fake program, never Pi or caller-supplied code.
|
||||
// PI_CODING_AGENT_DIR matches the statically verified 0.85.1 auth boundary.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, writeFile, open, rm } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { validId } from './records.mjs';
|
||||
import { exact, refuse, validateFixtureCredential } from './execution.mjs';
|
||||
const fake = `
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
const [command, subcommand, flag, provider] = process.argv.slice(1);
|
||||
if (command !== 'auth' || subcommand !== 'check' || flag !== '--provider') process.exit(2);
|
||||
if (Object.keys(process.env).some(k => !['HOME','PI_CODING_AGENT_DIR','FIXTURE_MODE'].includes(k))) process.exit(2);
|
||||
const path = join(process.env.PI_CODING_AGENT_DIR, 'auth.json');
|
||||
const mode = process.env.FIXTURE_MODE;
|
||||
if (mode === 'timeout') await new Promise(() => setInterval(() => {}, 1000));
|
||||
if (mode === 'failure') { process.stderr.write('FIXTURE_PRIVATE_DIAGNOSTIC'); process.exit(1); }
|
||||
if (mode === 'malformed') { await writeFile(path, '{', { mode: 0o600 }); process.exit(0); }
|
||||
const auth = JSON.parse(await readFile(path, 'utf8'));
|
||||
const c = auth[provider];
|
||||
if (mode === 'rotate' && c.type === 'oauth') {
|
||||
c.access = 'FIXTURE_ROTATED_ACCESS'; c.refresh = 'FIXTURE_ROTATED_REFRESH';
|
||||
c.expires = Date.now() + 3600000;
|
||||
}
|
||||
await writeFile(path, JSON.stringify(auth), { mode: 0o600 });
|
||||
process.stdout.write('ready');
|
||||
`;
|
||||
export function validateRefreshOptions(options = {}) {
|
||||
exact(options, [], ['mode', 'timeoutMs']);
|
||||
const mode = options.mode ?? 'rotate', timeoutMs = options.timeoutMs ?? 2000;
|
||||
if (!['rotate', 'unchanged', 'failure', 'timeout', 'malformed'].includes(mode) ||
|
||||
!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 10000) refuse('invalid-refresh-option');
|
||||
return { mode, timeoutMs };
|
||||
}
|
||||
export async function refreshFixtureCredential(provider, credential, options = {}) {
|
||||
if (!validId(provider)) refuse('invalid-provider');
|
||||
const { mode, timeoutMs } = validateRefreshOptions(options);
|
||||
const input = validateFixtureCredential(credential, credential?.type);
|
||||
const root = await mkdtemp('/tmp/mosaic-refresh-fixture-');
|
||||
try {
|
||||
const agent = `${root}/agent`, home = `${root}/home`, cwd = `${root}/cwd`;
|
||||
for (const dir of [agent, home, cwd]) await mkdir(dir, { mode: 0o700 });
|
||||
const file = `${agent}/auth.json`;
|
||||
await writeFile(file, JSON.stringify({ [provider]: input }), { mode: 0o600, flag: 'wx' });
|
||||
const outcome = await new Promise(resolve => {
|
||||
let timedOut = false, spawnFailed = false;
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', fake, 'auth', 'check', '--provider', provider], {
|
||||
cwd, env: { HOME: home, PI_CODING_AGENT_DIR: agent, FIXTURE_MODE: mode },
|
||||
// Child output is discarded, never buffered, parsed, logged or returned.
|
||||
stdio: 'ignore', shell: false,
|
||||
});
|
||||
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
|
||||
child.on('error', () => { spawnFailed = true; });
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer); resolve({ code, signal, timedOut, spawnFailed });
|
||||
});
|
||||
});
|
||||
if (outcome.timedOut) refuse('refresh-timeout');
|
||||
if (outcome.spawnFailed || outcome.code !== 0 || outcome.signal) refuse('refresh-failed');
|
||||
const h = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
let result;
|
||||
try {
|
||||
const s = await h.stat();
|
||||
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 4096) refuse('invalid-refresh-output');
|
||||
const buf = Buffer.alloc(4097); let size = 0;
|
||||
while (size < buf.length) {
|
||||
const { bytesRead } = await h.read(buf, size, buf.length - size, null);
|
||||
if (!bytesRead) break;
|
||||
size += bytesRead;
|
||||
}
|
||||
if (size > 4096) refuse('invalid-refresh-output');
|
||||
try { result = JSON.parse(buf.subarray(0, size).toString('utf8')); }
|
||||
catch { refuse('invalid-refresh-output'); }
|
||||
} finally { await h.close(); }
|
||||
exact(result, [provider]);
|
||||
const output = validateFixtureCredential(result[provider], input.type);
|
||||
if (output.type === 'oauth' && output.expires <= Date.now() + 300000) refuse('refresh-not-ready');
|
||||
return output;
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile, readdir, stat, lstat, writeFile, mkdir, symlink } from 'node:fs/promises';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { resolveFixtureExecution } from '../src/execution.mjs';
|
||||
import { createFixtureCredentialStore } from '../src/fixture-store.mjs';
|
||||
import { createFixtureWorkspace } from '../src/materialize-fixture.mjs';
|
||||
const base = resolve(import.meta.dirname, 'fixtures/valid');
|
||||
const json = name => JSON.parse(readFileSync(`${base}/${name}`, 'utf8'));
|
||||
function registry() {
|
||||
const account = json('auth/accounts/openai-codex/homelab-openai/account.json');
|
||||
const profile = json('auth/settings/research-default.json');
|
||||
profile.allowedAccounts.push('openai-codex/personal');
|
||||
return { errors: [], entries: {
|
||||
providers: { 'openai-codex': json('auth/providers/openai-codex.json'), 'ollama-remote': json('auth/providers/ollama-remote.json') },
|
||||
profiles: { 'research-default': profile }, harnesses: { pi: json('harnesses/pi.json') }, selections: {},
|
||||
accounts: { 'openai-codex/homelab-openai': account, 'openai-codex/personal': { ...account, id: 'personal', name: 'Personal' } },
|
||||
} };
|
||||
}
|
||||
const credential = suffix => ({ type: 'oauth', access: `FIXTURE_ACCESS_${suffix}`, refresh: `FIXTURE_REFRESH_${suffix}`, expires: 9000000000000 });
|
||||
const store = () => createFixtureCredentialStore({ 'openai-codex/homelab-openai': credential('A'), 'openai-codex/personal': credential('B') });
|
||||
const request = (executionId = 'exec-a') => ({ fixtureOnly: true, agentId: 'rocko', projectId: 'project-a', workspaceId: 'workspace-a', sessionId: 'session-a', executionId, profile: 'research-default' });
|
||||
const read = async p => JSON.parse(await readFile(p, 'utf8'));
|
||||
async function workspace(t) { const w = await createFixtureWorkspace(); t.after(() => w.close()); return w; }
|
||||
|
||||
test('pure resolution selects current default or explicit enrolled account', () => {
|
||||
const r = registry(), req = request();
|
||||
const p = resolveFixtureExecution(r, req);
|
||||
assert.equal(p.accounts['openai-codex'], 'openai-codex/homelab-openai');
|
||||
assert.equal(p.models.providers['ollama-remote'].models[0].id, 'qwen2.5-coder:7b');
|
||||
r.entries.profiles['research-default'].defaultAccounts['openai-codex'] = 'openai-codex/personal';
|
||||
assert.equal(resolveFixtureExecution(r, req).accounts['openai-codex'], 'openai-codex/personal');
|
||||
assert.equal(p.accounts['openai-codex'], 'openai-codex/homelab-openai');
|
||||
});
|
||||
test('scope is explicit, bounded and never inferred', () => {
|
||||
for (const key of ['agentId', 'projectId', 'workspaceId', 'sessionId', 'executionId']) {
|
||||
const r = request(); delete r[key]; assert.throws(() => resolveFixtureExecution(registry(), r), /invalid-fixture-input/);
|
||||
r[key] = '../outside'; assert.throws(() => resolveFixtureExecution(registry(), r), /invalid-scope/);
|
||||
}
|
||||
assert.throws(() => resolveFixtureExecution(registry(), { ...request(), fixtureOnly: false }), /fixture-only/);
|
||||
});
|
||||
test('fork pin is preserved against default change, override, missing account and revocation', () => {
|
||||
const r = registry(), q = { ...request(), fork: { sourceSessionId: 'source', accounts: { 'openai-codex': 'openai-codex/homelab-openai' } } };
|
||||
r.entries.profiles['research-default'].defaultAccounts['openai-codex'] = 'openai-codex/personal';
|
||||
assert.equal(resolveFixtureExecution(r, q).accounts['openai-codex'], 'openai-codex/homelab-openai');
|
||||
assert.throws(() => resolveFixtureExecution(r, { ...q, accounts: { 'openai-codex': 'openai-codex/personal' } }), /fork-account-change/);
|
||||
assert.throws(() => resolveFixtureExecution(r, { ...q, fork: { sourceSessionId: 'source', accounts: {} } }), /missing-fork-pin/);
|
||||
delete r.entries.accounts['openai-codex/homelab-openai'];
|
||||
assert.throws(() => resolveFixtureExecution(r, q), /missing-account/);
|
||||
r.entries.profiles['research-default'].allowedAccounts = ['openai-codex/personal'];
|
||||
assert.throws(() => resolveFixtureExecution(r, q), /account-not-enrolled/);
|
||||
});
|
||||
test('unenrolled account/provider, missing harness, model expansion and native model ceiling refuse', () => {
|
||||
assert.throws(() => resolveFixtureExecution(registry(), { ...request(), accounts: { unknown: 'unknown/a' } }), /provider-not-enrolled/);
|
||||
assert.throws(() => resolveFixtureExecution(registry(), { ...request(), accounts: { 'openai-codex': 'other/a' } }), /account-not-enrolled/);
|
||||
const r = registry(); delete r.entries.harnesses.pi;
|
||||
assert.throws(() => resolveFixtureExecution(r, request()), /unsupported-harness/);
|
||||
const s = registry(); s.entries.profiles['research-default'].models['ollama-remote'] = ['not-allowed'];
|
||||
assert.throws(() => resolveFixtureExecution(s, request()), /model-not-enrolled/);
|
||||
s.entries.profiles['research-default'].models = { 'openai-codex': ['not-enforceable'] };
|
||||
assert.throws(() => resolveFixtureExecution(s, request()), /native-model-enforcement-unavailable/);
|
||||
});
|
||||
test('only explicit synthetic credential forms and internal fixture stores admitted', async t => {
|
||||
assert.throws(() => createFixtureCredentialStore({ 'openai-codex/homelab-openai': { type: 'oauth', access: 'not-fixture', refresh: 'not-fixture', expires: 3 } }), /invalid-fixture-credential/);
|
||||
const w = await workspace(t);
|
||||
await assert.rejects(w.generate(registry(), request(), { fixtureOnly: true, transaction() { throw Error('must not execute'); } }), /fixture-store-required/);
|
||||
});
|
||||
test('two concurrent workspaces of the same agent publish distinct complete private generations', async t => {
|
||||
const w = await workspace(t), s = store();
|
||||
const a = request('one'), b = { ...request('two'), sessionId: 'session-b', workspaceId: 'workspace-b', accounts: { 'openai-codex': 'openai-codex/personal' } };
|
||||
const [x, y] = await Promise.all([w.generate(registry(), a, s), w.generate(registry(), b, s)]);
|
||||
assert.notEqual(x.generationPath, y.generationPath);
|
||||
assert.equal((await read(`${x.generationPath}/auth.json`))['openai-codex'].access, 'FIXTURE_ACCESS_A');
|
||||
assert.equal((await read(`${y.generationPath}/auth.json`))['openai-codex'].access, 'FIXTURE_ACCESS_B');
|
||||
for (const g of [x, y]) {
|
||||
assert.deepEqual((await readdir(g.generationPath)).sort(), ['auth.json', 'manifest.json', 'models.json']);
|
||||
assert.equal((await stat(g.generationPath)).mode & 0o777, 0o700);
|
||||
for (const file of ['auth.json', 'manifest.json', 'models.json']) assert.equal((await stat(`${g.generationPath}/${file}`)).mode & 0o777, 0o600);
|
||||
const manifest = await readFile(`${g.generationPath}/manifest.json`, 'utf8');
|
||||
assert.ok(!/FIXTURE_ACCESS|FIXTURE_REFRESH|expires|sha256|credentialHash/.test(manifest));
|
||||
assert.equal(g.manifest.state, 'ready');
|
||||
}
|
||||
assert.deepEqual(await readdir(`${w.path}/pending`), []);
|
||||
});
|
||||
test('same execution ID is exclusively claimed and cannot overwrite a published generation', async t => {
|
||||
const w = await workspace(t), s = store();
|
||||
const attempts = await Promise.allSettled([w.generate(registry(), request(), s), w.generate(registry(), request(), s)]);
|
||||
assert.equal(attempts.filter(a => a.status === 'fulfilled').length, 1);
|
||||
const g = attempts.find(a => a.status === 'fulfilled').value;
|
||||
const before = await readFile(`${g.generationPath}/auth.json`);
|
||||
await assert.rejects(w.generate(registry(), request(), s), /execution-already-claimed/);
|
||||
assert.deepEqual(await readFile(`${g.generationPath}/auth.json`), before);
|
||||
});
|
||||
for (const fault of ['after-auth', 'before-publish']) test(`failed generation ${fault} preserves prior files, records failure and refuses blind same-ID retry`, async t => {
|
||||
const w = await workspace(t), s = store(); const first = await w.generate(registry(), request('good'), s);
|
||||
const before = await readFile(`${first.generationPath}/auth.json`);
|
||||
await assert.rejects(w.generate(registry(), request('bad'), s, { fault }), /injected-generation-failure/);
|
||||
assert.deepEqual(await readdir(`${w.path}/generations`), ['good']);
|
||||
assert.deepEqual(await readdir(`${w.path}/pending`), []);
|
||||
assert.deepEqual(await readFile(`${first.generationPath}/auth.json`), before);
|
||||
assert.equal((await read(`${w.path}/claims/bad/failure.json`)).state, 'failed');
|
||||
await assert.rejects(w.generate(registry(), request('bad'), s), /execution-already-claimed/);
|
||||
});
|
||||
test('credential lock contention refuses without duplicate side effects', async t => {
|
||||
const w = await workspace(t), s = store(); let release, entered;
|
||||
const ready = new Promise(r => { entered = r; });
|
||||
const held = s.transaction(['openai-codex/homelab-openai'], async () => { entered(); await new Promise(r => { release = r; }); });
|
||||
await ready;
|
||||
try { await assert.rejects(w.generate(registry(), request(), s), /credential-busy/); }
|
||||
finally { release(); await held; }
|
||||
assert.deepEqual(await readdir(`${w.path}/generations`), []);
|
||||
});
|
||||
test('symlinked pre-existing final target is refused and never followed', async t => {
|
||||
const w = await workspace(t); await mkdir(`${w.path}/outside`, { mode: 0o700 });
|
||||
await writeFile(`${w.path}/outside/sentinel`, 'unchanged');
|
||||
await symlink(`${w.path}/outside`, `${w.path}/generations/exec-a`);
|
||||
await assert.rejects(w.generate(registry(), request(), store()), /generation-exists/);
|
||||
assert.equal(await readFile(`${w.path}/outside/sentinel`, 'utf8'), 'unchanged');
|
||||
assert.ok((await lstat(`${w.path}/generations/exec-a`)).isSymbolicLink());
|
||||
});
|
||||
test('invalid registry cannot resolve; no fallback to supplied partial entries', () => {
|
||||
const r = registry(); r.errors = [{ code: 'refused' }];
|
||||
assert.throws(() => resolveFixtureExecution(r, request()), /invalid-registry/);
|
||||
});
|
||||
|
||||
|
||||
test('post-publication failure records uncertainty, preserves complete generation and prevents replay', async t => {
|
||||
const w = await workspace(t), s = store();
|
||||
await assert.rejects(w.generate(registry(), request('uncertain'), s, { fault: 'after-publish' }), /publication-uncertain/);
|
||||
const dir = `${w.path}/generations/uncertain`;
|
||||
assert.deepEqual((await readdir(dir)).sort(), ['auth.json', 'manifest.json', 'models.json']);
|
||||
const before = await readFile(`${dir}/auth.json`);
|
||||
assert.equal((await read(`${w.path}/claims/uncertain/failure.json`)).state, 'uncertain');
|
||||
await assert.rejects(w.generate(registry(), request('uncertain'), s), /execution-already-claimed/);
|
||||
assert.deepEqual(await readFile(`${dir}/auth.json`), before);
|
||||
assert.deepEqual(await readdir(`${w.path}/pending`), []);
|
||||
});
|
||||
|
||||
|
||||
test('expired credentials refresh under transaction and subsequent generation reuses rotation', async t => {
|
||||
const w = await workspace(t), s = createFixtureCredentialStore({
|
||||
'openai-codex/homelab-openai': { ...credential('OLD'), expires: 1 },
|
||||
});
|
||||
const first = await w.generate(registry(), request('rotate'), s);
|
||||
const auth = await read(`${first.generationPath}/auth.json`);
|
||||
assert.equal(auth['openai-codex'].access, 'FIXTURE_ROTATED_ACCESS');
|
||||
assert.equal(auth['openai-codex'].refresh, 'FIXTURE_ROTATED_REFRESH');
|
||||
const second = await w.generate(registry(), request('reuse'), s);
|
||||
assert.deepEqual(await read(`${second.generationPath}/auth.json`), auth);
|
||||
await s.transaction(['openai-codex/homelab-openai'], draft => {
|
||||
assert.equal(draft.get('openai-codex/homelab-openai').refresh, 'FIXTURE_ROTATED_REFRESH');
|
||||
});
|
||||
});
|
||||
for (const mode of ['failure', 'timeout', 'malformed']) test(`refresh ${mode} retains prior generation and store state`, async t => {
|
||||
const w = await workspace(t), s = store();
|
||||
const prior = await w.generate(registry(), request('prior'), s);
|
||||
const bytes = await readFile(`${prior.generationPath}/auth.json`);
|
||||
await assert.rejects(w.generate(registry(), request('failed-refresh'), s,
|
||||
{ refresh: { mode, timeoutMs: mode === 'timeout' ? 100 : 2000 } }));
|
||||
assert.deepEqual(await readdir(`${w.path}/generations`), ['prior']);
|
||||
assert.deepEqual(await readFile(`${prior.generationPath}/auth.json`), bytes);
|
||||
await s.transaction(['openai-codex/homelab-openai'], draft => {
|
||||
assert.equal(draft.get('openai-codex/homelab-openai').access, 'FIXTURE_ACCESS_A');
|
||||
});
|
||||
await assert.rejects(w.generate(registry(), request('failed-refresh'), s), /execution-already-claimed/);
|
||||
});
|
||||
test('concurrent refresh on same account refuses contention while unrelated account proceeds', async t => {
|
||||
const w = await workspace(t), s = store();
|
||||
const results = await Promise.allSettled([
|
||||
w.generate(registry(), request('busy-a'), s, { refresh: { mode: 'timeout', timeoutMs: 200 } }),
|
||||
w.generate(registry(), request('busy-b'), s, { refresh: { mode: 'timeout', timeoutMs: 200 } }),
|
||||
w.generate(registry(), { ...request('independent'), accounts: { 'openai-codex': 'openai-codex/personal' } }, s),
|
||||
]);
|
||||
const codes = results.slice(0, 2).map(r => r.reason?.code).sort();
|
||||
assert.deepEqual(codes, ['credential-busy', 'refresh-timeout']);
|
||||
assert.equal(results[2].status, 'fulfilled');
|
||||
assert.deepEqual(await readdir(`${w.path}/generations`), ['independent']);
|
||||
});
|
||||
test('invalid refresh options refuse before burning claim', async t => {
|
||||
const w = await workspace(t), s = store();
|
||||
await assert.rejects(w.generate(registry(), request(), s, { refresh: { executable: '/bin/false' } }), /invalid-fixture-input/);
|
||||
await w.generate(registry(), request(), s);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { refreshFixtureCredential } from '../src/refresh-fixture.mjs';
|
||||
const expired = () => ({ type: 'oauth', access: 'FIXTURE_OLD_ACCESS', refresh: 'FIXTURE_OLD_REFRESH', expires: 1 });
|
||||
test('fixed fake process rotates both OAuth fields without mutating caller input', async () => {
|
||||
const input = expired(), result = await refreshFixtureCredential('openai-codex', input);
|
||||
assert.ok(result.access !== input.access && result.refresh !== input.refresh);
|
||||
assert.ok(result.expires > Date.now() + 300000);
|
||||
assert.equal(input.expires, 1);
|
||||
});
|
||||
test('concurrent isolated processes preserve separate provider credentials', async () => {
|
||||
const a = { type: 'api_key', key: 'FIXTURE_ACCOUNT_A' }, b = { type: 'api_key', key: 'FIXTURE_ACCOUNT_B' };
|
||||
const results = await Promise.all([refreshFixtureCredential('provider-a', a), refreshFixtureCredential('provider-b', b)]);
|
||||
assert.deepEqual(results, [a, b]);
|
||||
});
|
||||
for (const [mode, code] of [['failure', 'refresh-failed'], ['malformed', 'invalid-refresh-output'], ['timeout', 'refresh-timeout'], ['unchanged', 'refresh-not-ready']]) {
|
||||
test(`fake ${mode} is refused with fixed diagnostics`, async () => {
|
||||
await assert.rejects(refreshFixtureCredential('openai-codex', expired(), { mode, timeoutMs: mode === 'timeout' ? 100 : 2000 }), e => e.message === code);
|
||||
});
|
||||
}
|
||||
test('caller executable/environment injection is rejected before spawning', async () => {
|
||||
await assert.rejects(refreshFixtureCredential('openai-codex', expired(), { executable: '/bin/false' }), /invalid-fixture-input/);
|
||||
await assert.rejects(refreshFixtureCredential('openai-codex', expired(), { env: {} }), /invalid-fixture-input/);
|
||||
});
|
||||
Reference in New Issue
Block a user