Compare commits
2
Commits
org-rr
...
org-redact3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96c900d210 | ||
|
|
307fe928ac |
No files matched your search
@@ -100,9 +100,15 @@ function sessionPairs(pairs) {
|
||||
// Safety net for a removed-outer-bound change. It is fixture cleanup, never
|
||||
// product supervision. The guard reads only fixture records and checks sid
|
||||
// immediately before every kill. Normal outer-bound cases return before it.
|
||||
export function fixtureGuard(t,files,milliseconds=9500) {
|
||||
const program=`const fs=require('node:fs');setTimeout(()=>{for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}},Number(process.argv[2]));`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds)],{stdio:'ignore'});
|
||||
export function fixtureGuard(t,files,milliseconds=9500,startFile) {
|
||||
// P10 already records the instant its observed probe starts. Arm its safety
|
||||
// net there, so unrelated host-fixture setup cannot consume the probe budget.
|
||||
// Callers without an observer retain the original immediate-start behavior.
|
||||
const program=`const fs=require('node:fs');
|
||||
function cleanup(){for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}}
|
||||
function arm(){setTimeout(cleanup,Number(process.argv[2]));}
|
||||
if(!process.argv[3])arm();else{const poll=setInterval(()=>{let text='';try{text=fs.readFileSync(process.argv[3],'utf8')}catch{}if(/^begin [0-9]+[.][0-9]{2}\\n/.test(text)){clearInterval(poll);arm();}},10);}`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds),...(startFile?[startFile]:[])],{stdio:'ignore'});
|
||||
const stop=async()=>{
|
||||
if(child.exitCode!==null||child.signalCode!==null)return;
|
||||
const exited=new Promise(resolve=>child.once('exit',resolve));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fixtureGuard } from './case-sudo-fixture.mjs';
|
||||
|
||||
test('fixture cleanup guard starts its budget at the probe marker', async t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'guard-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const records = join(dir, 'pids'), startFile = join(dir, 'probe');
|
||||
writeFileSync(startFile, '');
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { stdio: 'ignore' });
|
||||
const exited = once(child, 'exit');
|
||||
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); });
|
||||
const raw = readFileSync(`/proc/${child.pid}/stat`, 'utf8');
|
||||
const sid = Number(raw.slice(raw.lastIndexOf(') ') + 2).split(' ')[3]);
|
||||
writeFileSync(records, `${child.pid} ${sid}\n`);
|
||||
const stop = fixtureGuard(t, [records], 100, startFile);
|
||||
// An empty marker is setup, not the start of the observed probe. This wait
|
||||
// exceeds the cleanup budget deliberately; no production bound is changed.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
assert.equal(child.signalCode, null, 'setup must not consume the probe cleanup budget');
|
||||
writeFileSync(startFile, 'begin 100.00\n');
|
||||
const terminal = await Promise.race([exited, new Promise((_, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('armed fixture guard did not clean up')), 3000);
|
||||
timer.unref();
|
||||
})]);
|
||||
assert.deepEqual(terminal, [null, 'SIGKILL']);
|
||||
await stop();
|
||||
});
|
||||
@@ -429,7 +429,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
assert.equal(baseline.status,0,baseline.out+baseline.err);
|
||||
assert.deepEqual(await pollFixtureGone(files),[],'baseline leaves no session');
|
||||
}
|
||||
const capture=cleanupFixture(t,files);const stop=fixtureGuard(t,files,7000);
|
||||
const capture=cleanupFixture(t,files);
|
||||
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
|
||||
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
|
||||
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
|
||||
@@ -440,6 +440,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
|
||||
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
|
||||
}
|
||||
const stop=fixtureGuard(t,files,7000,timingFile);
|
||||
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
|
||||
assert.equal(r.status,0,r.out+r.err);
|
||||
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fixture } from './helpers.mjs';
|
||||
const repo = join(import.meta.dirname, '../../..');
|
||||
test('task file JSON refusal preserves exit 2 and file without parser bytes', t => {
|
||||
const file = write(join(scratch(t), 'task.json'));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.ok(r.stderr.includes(file), r.stderr);
|
||||
assert.match(r.stderr, /task file is not valid JSON/);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|Unexpected token/);
|
||||
});
|
||||
test('task file read refusal retains exit 2, file and filesystem code', t => {
|
||||
const file = write(join(scratch(t), 'task.json'), '{}');
|
||||
// A directory is refused by lstat before the read. Inject a read failure
|
||||
// after that unchanged regular-file gate, including on root-run CI hosts.
|
||||
const preload = `import fs from 'node:fs';
|
||||
const read = fs.readFileSync;
|
||||
fs.readFileSync = function(path, ...args) {
|
||||
if (path === ${JSON.stringify(file)}) throw Object.assign(new Error(${JSON.stringify(secret)}), { code: 'EIO' });
|
||||
return read.call(this, path, ...args);
|
||||
};`;
|
||||
const r = spawnSync(process.execPath, ['--import', 'data:text/javascript,' + encodeURIComponent(preload), join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.ok(r.stderr.includes(file), r.stderr);
|
||||
assert.match(r.stderr, /task file is not valid JSON.*\(EIO\)/);
|
||||
assert.doesNotMatch(r.stderr, /sk-test/);
|
||||
});
|
||||
test('case2 precheck business helper omits parser bytes in its BUSINESS fact', t => {
|
||||
const root = scratch(t), f = fixture(root);
|
||||
const file = write(join(root, 'config/businesses/acme.json'));
|
||||
// Execute the production facts helper, without host or credential checks.
|
||||
const shell = readFileSync(join(repo, 'scripts/case2-precheck.sh'), 'utf8');
|
||||
const start = shell.indexOf('facts="$(cd "$REPO" && node --input-type=module -e \'') + 'facts="$(cd "$REPO" && node --input-type=module -e \''.length;
|
||||
assert.ok(start > 100);
|
||||
const end = shell.indexOf("' \"$REPO\" \"$BUSINESS\"", start);
|
||||
assert.ok(end > start);
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', shell.slice(start, end), repo, 'acme'], { encoding: 'utf8', env: { ...f.env, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, PATH: process.env.PATH } });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.ok(r.stdout.includes(`BUSINESS\tbad\tcannot read ${file}:`), r.stdout);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|Unexpected token/);
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
// exception is <boardDir>/seen.json, which holds Jason's "seen" marks and is
|
||||
// only changed when he clicks; a scan reads it and never rewrites it.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -35,9 +36,9 @@ export function loadConfig(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new ConfigError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) {
|
||||
@@ -227,9 +228,9 @@ export function loadSeen(boardDir) {
|
||||
if (!existsSync(path)) return {};
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path} (${err.message})`);
|
||||
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`);
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { ConfigError, loadConfig, loadSeen } from '../src/scan.mjs';
|
||||
for (const kind of ['config', 'seen']) test(`${kind} JSON refusal names only the file`, t => {
|
||||
const root = scratch(t), file = write(join(root, kind === 'config' ? 'config.json' : 'seen.json'));
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(file, ConfigError));
|
||||
});
|
||||
for (const kind of ['config', 'seen']) test(`${kind} read refusal retains the filesystem code`, t => {
|
||||
const root = scratch(t), file = join(root, kind === 'config' ? 'config.json' : 'seen.json');
|
||||
mkdirSync(file);
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), error => {
|
||||
safe(file, ConfigError)(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
assert.doesNotMatch(error.message, /illegal operation/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
// The per-message budget resets on agent_start, the event pi emits once per
|
||||
// prompt run, so a follow-up message gets a fresh budget.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { Type } from "typebox";
|
||||
import { TOOLS_ENV, TOOL_DESCRIPTIONS, READ_MAX_LINES, loadToolsConfig, createToolSet, enabledToolNames } from "../src/tools.mjs";
|
||||
import { COMMIT_MESSAGE_MAX, COMMIT_PATHS_MAX, VAULT_PREFIXES } from "../src/git.mjs";
|
||||
@@ -141,9 +142,9 @@ export default function (pi) {
|
||||
if (typeof raw !== "string" || raw.length === 0) throw new Error(`${TOOLS_ENV} is not set; the connector sets it from the binding's tools key`);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`${TOOLS_ENV} is not valid JSON: ${err.message}`);
|
||||
parsed = parseJsonFileText(raw, TOOLS_ENV);
|
||||
} catch {
|
||||
throw new Error(`${TOOLS_ENV} is not valid JSON`);
|
||||
}
|
||||
const config = loadToolsConfig(parsed);
|
||||
const tools = createToolSet(config);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
// Ids in this file are private to the binding's data directory (0600),
|
||||
// like every other journal.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
@@ -104,9 +105,9 @@ export function readApprovals(dir) {
|
||||
for (const [i, line] of readFileSync(path, "utf8").split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(JSON.parse(line));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
// Absent means no tools and a launch exactly as before. It is a fixed
|
||||
// key: the extension reads it at pi start.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { loadWebConfig } from "./web.mjs";
|
||||
import { loadSetsparkConfig } from "./setspark.mjs";
|
||||
@@ -59,9 +60,9 @@ export function loadDataRoot(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new DiscordError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
@@ -292,9 +293,9 @@ export function loadBinding(path) {
|
||||
checkPrivateFile(path, "binding");
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`binding is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`binding is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
return validateBinding(raw, `binding ${path}`);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
// supervised form for a service unit's pre-start
|
||||
// Directories are 0700, files 0600. Lines are appended, never rewritten.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import {
|
||||
appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync,
|
||||
unlinkSync, writeFileSync, writeSync,
|
||||
@@ -47,9 +48,9 @@ function readLines(path) {
|
||||
for (const [i, line] of text.split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(JSON.parse(line));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { DiscordError } from '../src/errors.mjs';
|
||||
import { loadDataRoot, loadBinding } from '../src/binding.mjs';
|
||||
import { readApprovals } from '../src/approvals.mjs';
|
||||
import { readInboxIds } from '../src/journal.mjs';
|
||||
import extension from '../extension/tools.mjs';
|
||||
import { TOOLS_ENV } from '../src/tools.mjs';
|
||||
for (const [name, read, relative] of [
|
||||
['config', (file) => loadDataRoot(file), 'config.json'],
|
||||
['binding', (file) => loadBinding(file), 'binding.json'],
|
||||
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl'],
|
||||
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl'],
|
||||
]) test(`${name} JSON refusal omits parser bytes`, t => {
|
||||
const root = scratch(t), ledger = name === 'approvals' || name === 'journal';
|
||||
const file = write(join(root, relative), ledger ? '{}\n' + malformed : malformed);
|
||||
assert.throws(() => read(file, root), safe(ledger ? `${file}:2` : file, DiscordError, { exitCode: 2 }));
|
||||
});
|
||||
test('tools environment JSON refusal names the config key without its bytes', () => {
|
||||
const previous = process.env[TOOLS_ENV];
|
||||
process.env[TOOLS_ENV] = malformed;
|
||||
try { assert.throws(() => extension({}), safe(TOOLS_ENV, Error)); }
|
||||
finally { if (previous === undefined) delete process.env[TOOLS_ENV]; else process.env[TOOLS_ENV] = previous; }
|
||||
});
|
||||
test('config read refusal retains the filesystem code', t => {
|
||||
const file = scratch(t);
|
||||
assert.throws(() => loadDataRoot(file), error => {
|
||||
safe(file, DiscordError, { exitCode: 2 })(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ export function webPlan(resolved, harness, builtinWeb) {
|
||||
throw new BundleError(`instance ${resolved.instance}: a ${resolved.definition} role fetches only through the broker, not search-source "harness-builtin"`, "web-source-not-allowed");
|
||||
}
|
||||
if (granted && source === "harness-builtin" && !builtinWeb) {
|
||||
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on ${harness}`);
|
||||
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on the configured harness`);
|
||||
}
|
||||
return { source, builtin: granted && source === "harness-builtin" };
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ test("search-source harness-builtin: claude-code gets WebSearch and WebFetch and
|
||||
|
||||
test("search-source harness-builtin is refused for a granted pi role, which has no built-in web tool", (t) => {
|
||||
const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "pi", model: "claude-sonnet-5-5" }));
|
||||
assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on pi/.test(e.message));
|
||||
assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on the configured harness/.test(e.message));
|
||||
// Without the grant the source doesn't matter on pi.
|
||||
assert.ok(!build(t, withSource(r, "harness-builtin")).manifest.builtins.some((x) => /web/i.test(x)));
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { BundleError, webPlan } from '../src/bundle.mjs';
|
||||
test('harness web refusal names the harness field without the configured value', () => {
|
||||
assert.throws(() => webPlan({ instance: 'builder', definition: 'coder', webSource: 'harness-builtin', limits: { tools: ['web'] } }, secret, false), safe('harness', BundleError, { code: 'bundle-refused' }));
|
||||
});
|
||||
@@ -65,6 +65,9 @@ function fixture(t) {
|
||||
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
|
||||
// The queue section imports the queue validator and the seat registry.
|
||||
for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, 'src'), { recursive: true });
|
||||
mkdirSync(path.join(root, 'packages/organization/src'), { recursive: true });
|
||||
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
||||
cpSync(path.join(source, '../../organization/src', file), path.join(root, 'packages/organization/src', file));
|
||||
// The fake serves ISSUES for every page, or page N of the ISSUE_PAGES array
|
||||
// (an empty page past its end). API_FAIL fails every call, or only FAIL_PAGE.
|
||||
const api = put('bin/gitea-api.sh', `#!/usr/bin/env node
|
||||
|
||||
@@ -337,6 +337,9 @@ function cliFixture(t, rows = MAP) {
|
||||
const repo = scratchRepo(t, { map: mapText(rows, { retired: [], highWater: Math.max(...rows.map(r => r.id)) }) });
|
||||
genesisCommitted(repo);
|
||||
for (const pkg of ['ledger', 'seat']) cpSync(path.join(PACKAGES, pkg, 'src'), path.join(repo.root, 'packages', pkg, 'src'), { recursive: true });
|
||||
mkdirSync(path.join(repo.root, 'packages/organization/src'), { recursive: true });
|
||||
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
||||
cpSync(path.join(PACKAGES, 'organization/src', file), path.join(repo.root, 'packages/organization/src', file));
|
||||
const dataRoot = path.join(repo.base, 'data');
|
||||
const config = path.join(repo.base, 'config.json');
|
||||
writeFileSync(config, JSON.stringify({ dataRoot }));
|
||||
|
||||
@@ -112,10 +112,10 @@ function checkScope(value, where, check) {
|
||||
|
||||
function checkExpires(value, where) {
|
||||
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z`);
|
||||
}
|
||||
const iso = new Date(value).toISOString();
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time`);
|
||||
}
|
||||
|
||||
function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
@@ -133,7 +133,7 @@ function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
|
||||
});
|
||||
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of the grant's business`);
|
||||
});
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
|
||||
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function rejectUnknownKeys(object, allowed, where) {
|
||||
|
||||
export function requireId(value, where) {
|
||||
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
||||
refuse(`${where} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`);
|
||||
refuse(`${where} must match ${ID_PATTERN}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function requireString(value, where, { max = 200 } = {}) {
|
||||
}
|
||||
|
||||
export function requirePositiveInt(value, where) {
|
||||
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer (got ${JSON.stringify(value)})`);
|
||||
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ export function requireDistinctList(value, where, check, { nonEmpty = false } =
|
||||
|
||||
// Calendar date YYYY-MM-DD that exists (2026-02-30 refuses).
|
||||
export function requireDate(value, where) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD (got ${JSON.stringify(value)})`);
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD`);
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date: ${value}`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date`);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
});
|
||||
|
||||
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of the grant's business/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { BusinessError } from '../src/errors.mjs';
|
||||
import { requireId, requirePositiveInt, requireDate } from '../src/util.mjs';
|
||||
import { checkAccess } from '../src/access.mjs';
|
||||
for (const [field, check, value] of [
|
||||
['id', requireId, secret], ['revision', requirePositiveInt, secret], ['rotateBy', requireDate, secret],
|
||||
// Arbitrary sentinel bytes cannot pass the date-shape check to reach this branch.
|
||||
['rotateBy', requireDate, '2026-02-30'],
|
||||
]) test(`${field} rejects ${check.name} input without its value`, () => {
|
||||
assert.throws(() => check(value, field), error => {
|
||||
safe(field, BusinessError, { exitCode: 2 })(error);
|
||||
assert.ok(!error.message.includes(value), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
function access() { return { accessVersion: 1, revision: 1, users: [{ id: 'bean', home: 'local', subjects: [{ provider: 'test', sub: 'user' }] }], grants: [{ id: 'grant', user: 'bean', business: 'acme', projects: ['*'], instances: ['coder'], level: 'viewer' }] }; }
|
||||
const context = { providers: ['test'], peers: [], businesses: { acme: { human: 'jason', instances: ['coder'] } }, ssoSubjects: [] };
|
||||
for (const [field, value] of [['expires', secret], ['expires', '2026-02-30T00:00:00Z'], ['instances', [secret]]]) test(`grant ${field} refusal omits its value ${JSON.stringify(value)}`, () => {
|
||||
const doc = access(); doc.grants[0][field] = value;
|
||||
assert.throws(() => checkAccess(doc, context), error => {
|
||||
safe(`grants[0].${field}`, BusinessError, { exitCode: 2 })(error);
|
||||
assert.ok(!error.message.includes(Array.isArray(value) ? value[0] : value), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ function jsonProblems(kind, text, relative, problems) {
|
||||
const value = JSON.parse(text);
|
||||
for (const problem of validateRecord(kind, value)) problems.push(`${relative}: ${problem}`);
|
||||
return value;
|
||||
} catch (e) { problems.push(`${relative}: invalid JSON (${e.message})`); return null; }
|
||||
} catch { problems.push(`${relative}: invalid JSON`); return null; }
|
||||
}
|
||||
async function checkLayout(context) {
|
||||
const repo = projectRepo(context), problems = [];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
import { checkProject, initProject } from '../src/index.mjs';
|
||||
test('project record JSON problems name the file without parser bytes', async t => {
|
||||
const root = scratch(t), context = { business: { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code') } } }, project: 'stack', dataRoot: join(root, 'data') };
|
||||
const { repo } = await initProject(context);
|
||||
write(join(repo, 'requirements/REQ-1.json'));
|
||||
const result = await checkProject(context);
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.problems.some(p => p.includes('requirements/REQ-1.json: invalid JSON')), JSON.stringify(result.problems));
|
||||
assert.doesNotMatch(JSON.stringify(result), /sk-test|Unexpected token/);
|
||||
});
|
||||
@@ -1025,7 +1025,7 @@ export function parseMigrationMap(text) {
|
||||
const blocks = [...text.matchAll(/^```json queue-map\n([\s\S]*?)^```$/gm)];
|
||||
if (blocks.length !== 1) throw refuse("the migration map must hold exactly one ```json queue-map block");
|
||||
let map;
|
||||
try { map = JSON.parse(blocks[0][1]); } catch (err) { throw refuse(`the migration map's queue-map block is not JSON: ${err.message}`); }
|
||||
try { map = JSON.parse(blocks[0][1]); } catch { throw refuse(`the migration map's queue-map block is not JSON`); }
|
||||
keysExactly(map, ["rows", "retired", "highWater"], "migration map");
|
||||
if (!Array.isArray(map.rows)) throw refuse("migration map rows must be a list");
|
||||
map.rows.forEach((r) => {
|
||||
@@ -1126,7 +1126,7 @@ export function loadDoc(bytes) {
|
||||
throw refuse("queue.json is not valid UTF-8");
|
||||
}
|
||||
let doc;
|
||||
try { doc = JSON.parse(text); } catch (err) { throw refuse(`queue.json is not JSON: ${err.message}`); }
|
||||
try { doc = JSON.parse(text); } catch { throw refuse(`queue.json is not JSON`); }
|
||||
keysExactly(doc, DOC_KEYS, "queue.json");
|
||||
if (serialize(doc) !== text) throw refuse("queue.json does not re-serialize byte for byte (a hand edit?)");
|
||||
if (doc.version !== VERSION) throw refuse(`queue.json version ${doc.version} is not ${VERSION}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Scratch repositories for the queue tests. Every test works under
|
||||
// os.tmpdir(): a fresh repository holding a copy of packages/queue/src and
|
||||
// the two discord helpers it imports, so the code-under-root check (8.3)
|
||||
// the discord helpers and safe JSON parser they import, so the code-under-root check (8.3)
|
||||
// passes there and nothing touches this checkout's .git.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statfsSync, writeFileSync } from "node:fs";
|
||||
@@ -91,6 +91,9 @@ export function scratchRepo(t, { map = mapText(), commitMap = true, name = "repo
|
||||
cpSync(PKG_SRC, join(root, "packages/queue/src"), { recursive: true });
|
||||
mkdirSync(join(root, "packages/discord/src"), { recursive: true });
|
||||
for (const f of ["journal.mjs", "errors.mjs"]) cpSync(join(DISCORD_SRC, f), join(root, "packages/discord/src", f));
|
||||
mkdirSync(join(root, "packages/organization/src"), { recursive: true });
|
||||
for (const f of ["util.mjs", "errors.mjs", "vocabulary.mjs"])
|
||||
cpSync(join(DISCORD_SRC, "../../organization/src", f), join(root, "packages/organization/src", f));
|
||||
for (const s of ["darkwing", "dewey", "filbert", "rocko", "sage"]) {
|
||||
mkdirSync(join(root, "agents", s, "work"), { recursive: true });
|
||||
writeFileSync(join(root, "agents", s, ".keep"), "");
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { loadDoc, parseMigrationMap } from '../src/queue.mjs';
|
||||
import { QueueError } from '../src/errors.mjs';
|
||||
for (const [label, parse] of [
|
||||
['queue.json', () => loadDoc(Buffer.from(malformed))],
|
||||
["migration map's queue-map block", () => parseMigrationMap('```json queue-map\n' + malformed + '\n```')],
|
||||
]) test(`${label} refusal omits parser bytes`, () => {
|
||||
assert.throws(parse, safe(label, QueueError, { code: 2 }));
|
||||
});
|
||||
@@ -31,8 +31,8 @@ export function readActivePointer(dataRoot) {
|
||||
let pointer;
|
||||
try {
|
||||
pointer = JSON.parse(state.text);
|
||||
} catch (error) {
|
||||
throw new RunsError(`release pointer is not valid JSON (${state.file}): ${error.message}`, 2);
|
||||
} catch {
|
||||
throw new RunsError(`release pointer is not valid JSON (${state.file})`, 2);
|
||||
}
|
||||
const invalid = (why) => new RunsError(`release pointer ${why} (${state.file})`, 2);
|
||||
if (typeof pointer !== "object" || pointer === null || Array.isArray(pointer)) throw invalid("must be a JSON object");
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { RunsError, readActivePointer } from '../src/index.mjs';
|
||||
test('release pointer JSON refusal names only the file', t => {
|
||||
const root = scratch(t), file = write(join(root, 'state/active.json'));
|
||||
assert.throws(() => readActivePointer(root), safe(file, RunsError, { exitCode: 2 }));
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
// Sessions live in <seatDir>/.pi/agent/sessions.
|
||||
// Anything else is layout "unknown" and records nulls; nothing is guessed.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, statSync, realpathSync } from "node:fs";
|
||||
import { join, basename, dirname, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -53,9 +54,9 @@ export function loadDataRoot(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new SeatError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new SeatError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new SeatError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SeatError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new SeatError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
@@ -202,9 +203,9 @@ export function readRegistration(seats, seat, layout) {
|
||||
if (!existsSync(path)) return null;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new SeatError(`registration is not valid JSON: ${path} (${err.message})`);
|
||||
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new SeatError(`registration is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
try {
|
||||
const record = validateRegistration(parsed);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { SeatError, loadDataRoot, readRegistration, registrationPath } from '../src/seat.mjs';
|
||||
for (const kind of ['config', 'registration']) test(`${kind} JSON refusal names only the file`, t => {
|
||||
const root = scratch(t), file = write(kind === 'config' ? join(root, 'config.json') : registrationPath(root, 'builder', 'repo'));
|
||||
assert.throws(() => kind === 'config' ? loadDataRoot(file) : readRegistration(root, 'builder', 'repo'), safe(file, SeatError, { exitCode: 2 }));
|
||||
});
|
||||
for (const kind of ['config', 'registration']) test(`${kind} read refusal retains the filesystem code`, t => {
|
||||
const root = scratch(t), file = kind === 'config' ? join(root, 'config.json') : registrationPath(root, 'builder', 'repo');
|
||||
mkdirSync(file, { recursive: true });
|
||||
assert.throws(() => kind === 'config' ? loadDataRoot(file) : readRegistration(root, 'builder', 'repo'), error => {
|
||||
safe(file, SeatError, { exitCode: 2 })(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -193,9 +193,10 @@ try {
|
||||
readNotifyConfig(system.dataRoot, id);
|
||||
say("NOTIFY", "ok");
|
||||
} catch (e) { say("NOTIFY", "bad", e.message.split("\n")[0]); }
|
||||
const { parseJsonFileText } = await import(`${repo}/packages/organization/src/util.mjs`);
|
||||
const file = businessFilePath(id);
|
||||
let raw;
|
||||
try { raw = JSON.parse(readFileSync(file, "utf8")); } catch (e) { say("BUSINESS", "bad", `cannot read ${file}: ${e.code ?? e.message}`); process.exit(0); }
|
||||
try { raw = parseJsonFileText(readFileSync(file, "utf8"), file); } catch (e) { say("BUSINESS", "bad", `cannot read ${file}: ${e.code ?? "not valid JSON"}`); process.exit(0); }
|
||||
const library = Object.entries(raw?.projects ?? {}).filter(([, p]) => p && p.kind === "library").map(([k]) => k);
|
||||
if (library.length) say("LIBRARY", library.join(","));
|
||||
let b;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
* - No secrets in mission/task data or run records.
|
||||
*/
|
||||
|
||||
import { parseJsonFileText } from "../packages/organization/src/util.mjs";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -74,9 +75,9 @@ function readJsonFile(file, what) {
|
||||
}
|
||||
let document;
|
||||
try {
|
||||
document = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
document = parseJsonFileText(fs.readFileSync(file, "utf8"), file, what);
|
||||
} catch (error) {
|
||||
fail(2, `${what} is not valid JSON (${file}): ${error.message}`);
|
||||
fail(2, `${what} is not valid JSON (${file})${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ for (const agent of ['darkwing', 'dewey', 'filbert', 'researcher', 'sage']) test
|
||||
// fixture is its own data root so no registration reaches a real one.
|
||||
cpSync(join(source, 'scripts/mosaic'), join(root, 'scripts/mosaic'));
|
||||
cpSync(join(source, 'packages/seat/src'), join(root, 'packages/seat/src'), { recursive: true });
|
||||
mkdirSync(join(root, 'packages/organization/src'), { recursive: true });
|
||||
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
||||
cpSync(join(source, 'packages/organization/src', file), join(root, 'packages/organization/src', file));
|
||||
mkdirSync(join(root, '.git'));
|
||||
writeFileSync(join(root, 'config.json'), JSON.stringify({ configVersion: 1, dataRoot: root }));
|
||||
const registration = join(root, `seats/repo/${agent}/registration.json`);
|
||||
@@ -149,40 +152,46 @@ test('default agent entry retains container setup and refuses missing seat/auth'
|
||||
try {
|
||||
for (const dir of ['scripts', 'bin', 'runtime/user']) mkdirSync(join(root, dir), { recursive: true });
|
||||
cpSync(join(source, 'scripts/agent.sh'), join(root, 'scripts/agent.sh'));
|
||||
// All runtime effects remain in this fixture; Docker and release helpers are doubles.
|
||||
// Observe the current processing-control handoff; its Docker behavior has
|
||||
// its own package tests. Every runtime effect stays inside this fixture.
|
||||
writeFileSync(join(root, 'scripts/common.sh'), `
|
||||
load_config() { MOSAIC_DEV_DIR="$PWD/runtime"; MOSAIC_ADAPTER=pi; MOSAIC_MODEL=test; echo config >> trace; }
|
||||
load_release() { echo release >> trace; }
|
||||
bootstrap_runtime_dir() { echo bootstrap >> trace; }
|
||||
processing_guard() { echo processing >> trace; }
|
||||
ensure_release_aligned() { echo alignment >> trace; }
|
||||
`);
|
||||
writeFileSync(join(root, 'runtime/user/USER.md'), 'fixture user\n');
|
||||
writeFileSync(join(root, 'bin/docker'), `#!/usr/bin/env node
|
||||
require('node:fs').writeFileSync('docker.json', JSON.stringify({
|
||||
args: process.argv.slice(2), session: process.env.MOSAIC_SESSION_DIR,
|
||||
workspace: process.env.MOSAIC_WORKSPACE, tools: process.env.MOSAIC_TOOLS,
|
||||
name: process.env.MOSAIC_AGENT_NAME, interactive: process.env.MOSAIC_INTERACTIVE
|
||||
mkdirSync(join(root, 'packages/processing-control/src'), { recursive: true });
|
||||
writeFileSync(join(root, 'packages/processing-control/src/container-cli.mjs'), `
|
||||
import fs from 'node:fs';
|
||||
fs.writeFileSync('container.json', JSON.stringify({
|
||||
args: process.argv.slice(2), session: process.env.MOSAIC_SESSION_DIR ?? null,
|
||||
workspace: process.env.MOSAIC_WORKSPACE ?? null, tools: process.env.MOSAIC_TOOLS,
|
||||
name: process.env.MOSAIC_AGENT_NAME, interactive: process.env.MOSAIC_INTERACTIVE,
|
||||
sessionSource: process.env.MOSAIC_SESSION_SOURCE, workspaceSource: process.env.MOSAIC_WORKSPACE_SOURCE
|
||||
}));
|
||||
`, { mode: 0o755 });
|
||||
`);
|
||||
const env = { ...process.env, PATH: join(root, 'bin') + ':' + process.env.PATH };
|
||||
for (const name of ['MOSAIC_AGENTS_DIR', 'MOSAIC_ROLES_DIR', 'MOSAIC_SKILLS', 'MOSAIC_AGENT_ROLE']) delete env[name];
|
||||
const run = (args, overrides = {}) => spawnSync('bash', [join(root, 'scripts/agent.sh'), ...args],
|
||||
{ cwd: tmpdir(), env: { ...env, ...overrides }, encoding: 'utf8', timeout: 10000 });
|
||||
let result = run(['fixture', '--tools', 'read,bash']);
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.deepEqual(readFileSync(join(root, 'trace'), 'utf8').trim().split('\n'), ['config', 'release', 'bootstrap', 'alignment']);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(root, 'docker.json'))), {
|
||||
args: ['compose', 'run', '--rm', 'mosaic-agent'], session: '/var/lib/mosaic/sessions/agent-fixture',
|
||||
workspace: '/var/lib/mosaic/workspaces/fixture', tools: 'read,bash', name: 'fixture', interactive: '1'
|
||||
assert.deepEqual(readFileSync(join(root, 'trace'), 'utf8').trim().split('\n'), ['config', 'release', 'bootstrap', 'processing', 'alignment']);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(root, 'container.json'))), {
|
||||
args: ['--tty'], session: null,
|
||||
workspace: null, tools: 'read,bash', name: 'fixture', interactive: '1',
|
||||
sessionSource: join(root, 'runtime/sessions/agent-fixture'), workspaceSource: join(root, 'runtime/workspaces/fixture')
|
||||
});
|
||||
const captured = readFileSync(join(root, 'docker.json'), 'utf8');
|
||||
const captured = readFileSync(join(root, 'container.json'), 'utf8');
|
||||
result = run(['fixture'], { MOSAIC_AGENTS_DIR: join(root, 'missing-seats') });
|
||||
assert.equal(result.status, 4);
|
||||
assert.match(result.stderr, /no seat definition/);
|
||||
result = run(['fixture', '--auth', 'missing']);
|
||||
assert.equal(result.status, 4);
|
||||
assert.match(result.stderr, /no mosaic-managed credential/);
|
||||
assert.equal(readFileSync(join(root, 'docker.json'), 'utf8'), captured);
|
||||
assert.equal(readFileSync(join(root, 'container.json'), 'utf8'), captured);
|
||||
assert.equal(existsSync(join(root, '.pi')), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
|
||||
@@ -23,6 +23,9 @@ test('Rocko uses Sonnet, isolated resume IDs, current context and launch refusal
|
||||
// Seat registration (packages/seat): the fixture is its own data root.
|
||||
cpSync(join(source, 'scripts/mosaic'), join(root, 'scripts/mosaic'));
|
||||
cpSync(join(source, 'packages/seat/src'), join(root, 'packages/seat/src'), { recursive: true });
|
||||
mkdirSync(join(root, 'packages/organization/src'), { recursive: true });
|
||||
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
||||
cpSync(join(source, 'packages/organization/src', file), join(root, 'packages/organization/src', file));
|
||||
mkdirSync(join(root, '.git'));
|
||||
writeFileSync(join(root, 'config.json'), JSON.stringify({ configVersion: 1, dataRoot: root }));
|
||||
const registration = join(root, 'seats/repo/rocko/registration.json');
|
||||
|
||||
Reference in new issue
Block a user