diff --git a/packages/cli/tests/case-sudo-fixture.mjs b/packages/cli/tests/case-sudo-fixture.mjs index 866c6c0d2..445bb4fa9 100644 --- a/packages/cli/tests/case-sudo-fixture.mjs +++ b/packages/cli/tests/case-sudo-fixture.mjs @@ -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)); diff --git a/packages/cli/tests/case-sudo-fixture.test.mjs b/packages/cli/tests/case-sudo-fixture.test.mjs new file mode 100644 index 000000000..52a499079 --- /dev/null +++ b/packages/cli/tests/case-sudo-fixture.test.mjs @@ -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(); +}); diff --git a/packages/cli/tests/case1-accept.test.mjs b/packages/cli/tests/case1-accept.test.mjs index cc1b582fe..5f0c9592a 100644 --- a/packages/cli/tests/case1-accept.test.mjs +++ b/packages/cli/tests/case1-accept.test.mjs @@ -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'; diff --git a/packages/cli/tests/refusal-redaction.test.mjs b/packages/cli/tests/refusal-redaction.test.mjs new file mode 100644 index 000000000..4c1e9f87f --- /dev/null +++ b/packages/cli/tests/refusal-redaction.test.mjs @@ -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; +} +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('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/); +}); diff --git a/packages/control-board/src/scan.mjs b/packages/control-board/src/scan.mjs index e89c25e99..b50cf6390 100644 --- a/packages/control-board/src/scan.mjs +++ b/packages/control-board/src/scan.mjs @@ -13,6 +13,7 @@ // exception is /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 { + throw new ConfigError(`config is not valid JSON: ${path}`); } 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 { + throw new ConfigError(`seen marks file is not valid JSON: ${path}`); } 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)) { diff --git a/packages/control-board/tests/refusal-redaction.test.mjs b/packages/control-board/tests/refusal-redaction.test.mjs new file mode 100644 index 000000000..e7f9b50e7 --- /dev/null +++ b/packages/control-board/tests/refusal-redaction.test.mjs @@ -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 { 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)); +}); diff --git a/packages/discord/extension/tools.mjs b/packages/discord/extension/tools.mjs index 41e1f2b7b..774a8658b 100644 --- a/packages/discord/extension/tools.mjs +++ b/packages/discord/extension/tools.mjs @@ -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); diff --git a/packages/discord/src/approvals.mjs b/packages/discord/src/approvals.mjs index cc33b1a88..516af3d65 100644 --- a/packages/discord/src/approvals.mjs +++ b/packages/discord/src/approvals.mjs @@ -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; diff --git a/packages/discord/src/binding.mjs b/packages/discord/src/binding.mjs index 7dac0f56d..c620997c8 100644 --- a/packages/discord/src/binding.mjs +++ b/packages/discord/src/binding.mjs @@ -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 { + throw new DiscordError(`config is not valid JSON: ${path}`); } 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 { + throw new DiscordError(`binding is not valid JSON: ${path}`); } return validateBinding(raw, `binding ${path}`); } diff --git a/packages/discord/src/journal.mjs b/packages/discord/src/journal.mjs index f5d8f3e62..61ddea826 100644 --- a/packages/discord/src/journal.mjs +++ b/packages/discord/src/journal.mjs @@ -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; diff --git a/packages/discord/tests/refusal-redaction.test.mjs b/packages/discord/tests/refusal-redaction.test.mjs new file mode 100644 index 000000000..8638c0f59 --- /dev/null +++ b/packages/discord/tests/refusal-redaction.test.mjs @@ -0,0 +1,51 @@ +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; } +}); diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs index 9aa943c7d..f3ad798b3 100644 --- a/packages/harness/src/bundle.mjs +++ b/packages/harness/src/bundle.mjs @@ -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" }; } diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs index fc9769689..43a9831c5 100644 --- a/packages/harness/tests/bundle.test.mjs +++ b/packages/harness/tests/bundle.test.mjs @@ -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))); }); diff --git a/packages/harness/tests/refusal-redaction.test.mjs b/packages/harness/tests/refusal-redaction.test.mjs new file mode 100644 index 000000000..5d74b2f4e --- /dev/null +++ b/packages/harness/tests/refusal-redaction.test.mjs @@ -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' })); +}); diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs index ebd3f2b84..4dad3ff39 100644 --- a/packages/ledger/tests/ledger.test.mjs +++ b/packages/ledger/tests/ledger.test.mjs @@ -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 diff --git a/packages/ledger/tests/queue-checks.test.mjs b/packages/ledger/tests/queue-checks.test.mjs index 86a58ae61..49b8f5378 100644 --- a/packages/ledger/tests/queue-checks.test.mjs +++ b/packages/ledger/tests/queue-checks.test.mjs @@ -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 })); diff --git a/packages/organization/src/access.mjs b/packages/organization/src/access.mjs index 07be8b55e..7d6a77421 100644 --- a/packages/organization/src/access.mjs +++ b/packages/organization/src/access.mjs @@ -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`); diff --git a/packages/organization/src/util.mjs b/packages/organization/src/util.mjs index e248652fa..c50867510 100644 --- a/packages/organization/src/util.mjs +++ b/packages/organization/src/util.mjs @@ -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; } diff --git a/packages/organization/tests/access.test.mjs b/packages/organization/tests/access.test.mjs index e2213e10b..b6813bd47 100644 --- a/packages/organization/tests/access.test.mjs +++ b/packages/organization/tests/access.test.mjs @@ -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/); diff --git a/packages/organization/tests/refusal-redaction.test.mjs b/packages/organization/tests/refusal-redaction.test.mjs new file mode 100644 index 000000000..0a02f4706 --- /dev/null +++ b/packages/organization/tests/refusal-redaction.test.mjs @@ -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; + }); +}); diff --git a/packages/project/src/index.mjs b/packages/project/src/index.mjs index 1f2c3664d..b0eda1e9d 100644 --- a/packages/project/src/index.mjs +++ b/packages/project/src/index.mjs @@ -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 = []; diff --git a/packages/project/tests/refusal-redaction.test.mjs b/packages/project/tests/refusal-redaction.test.mjs new file mode 100644 index 000000000..b56b730c9 --- /dev/null +++ b/packages/project/tests/refusal-redaction.test.mjs @@ -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/); +}); diff --git a/packages/queue/src/queue.mjs b/packages/queue/src/queue.mjs index a29626ecb..3d17d1877 100644 --- a/packages/queue/src/queue.mjs +++ b/packages/queue/src/queue.mjs @@ -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}`); diff --git a/packages/queue/tests/helpers.mjs b/packages/queue/tests/helpers.mjs index 14f127b33..0611c4610 100644 --- a/packages/queue/tests/helpers.mjs +++ b/packages/queue/tests/helpers.mjs @@ -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"), ""); diff --git a/packages/queue/tests/refusal-redaction.test.mjs b/packages/queue/tests/refusal-redaction.test.mjs new file mode 100644 index 000000000..062532b04 --- /dev/null +++ b/packages/queue/tests/refusal-redaction.test.mjs @@ -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 })); +}); diff --git a/packages/runs/src/state.mjs b/packages/runs/src/state.mjs index af5cc9272..e226e0cb8 100644 --- a/packages/runs/src/state.mjs +++ b/packages/runs/src/state.mjs @@ -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"); diff --git a/packages/runs/tests/refusal-redaction.test.mjs b/packages/runs/tests/refusal-redaction.test.mjs new file mode 100644 index 000000000..93feb3b3d --- /dev/null +++ b/packages/runs/tests/refusal-redaction.test.mjs @@ -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 })); +}); diff --git a/packages/seat/src/seat.mjs b/packages/seat/src/seat.mjs index 9a8f76476..37d7c4650 100644 --- a/packages/seat/src/seat.mjs +++ b/packages/seat/src/seat.mjs @@ -15,6 +15,7 @@ // Sessions live in /.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 { + throw new SeatError(`config is not valid JSON: ${path}`); } 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 { + throw new SeatError(`registration is not valid JSON: ${path}`); } try { const record = validateRegistration(parsed); diff --git a/packages/seat/tests/refusal-redaction.test.mjs b/packages/seat/tests/refusal-redaction.test.mjs new file mode 100644 index 000000000..33b922648 --- /dev/null +++ b/packages/seat/tests/refusal-redaction.test.mjs @@ -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 { 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 })); +}); diff --git a/scripts/case2-precheck.sh b/scripts/case2-precheck.sh index cb2693dcc..0c1d06a3b 100755 --- a/scripts/case2-precheck.sh +++ b/scripts/case2-precheck.sh @@ -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; diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs index d6d35a87d..d3659a200 100755 --- a/scripts/mosaic-task.mjs +++ b/scripts/mosaic-task.mjs @@ -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")); - } catch (error) { - fail(2, `${what} is not valid JSON (${file}): ${error.message}`); + document = parseJsonFileText(fs.readFileSync(file, "utf8"), file, what); + } catch { + fail(2, `${what} is not valid JSON (${file})`); } return document; }