diff --git a/packages/cli/tests/refusal-redaction.test.mjs b/packages/cli/tests/refusal-redaction.test.mjs index 4c1e9f87f..4f363c579 100644 --- a/packages/cli/tests/refusal-redaction.test.mjs +++ b/packages/cli/tests/refusal-redaction.test.mjs @@ -28,6 +28,22 @@ test('task file JSON refusal preserves exit 2 and file without parser bytes', t 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')); diff --git a/packages/control-board/src/scan.mjs b/packages/control-board/src/scan.mjs index b50cf6390..9854812b2 100644 --- a/packages/control-board/src/scan.mjs +++ b/packages/control-board/src/scan.mjs @@ -37,8 +37,8 @@ export function loadConfig(path = defaultConfigPath()) { let raw; try { raw = parseJsonFileText(readFileSync(path, "utf8"), path); - } catch { - throw new ConfigError(`config is not valid JSON: ${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)) { @@ -229,8 +229,8 @@ export function loadSeen(boardDir) { let parsed; try { parsed = parseJsonFileText(readFileSync(path, "utf8"), path); - } catch { - throw new ConfigError(`seen marks file is not valid JSON: ${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)) { diff --git a/packages/control-board/tests/refusal-redaction.test.mjs b/packages/control-board/tests/refusal-redaction.test.mjs index e7f9b50e7..171af624b 100644 --- a/packages/control-board/tests/refusal-redaction.test.mjs +++ b/packages/control-board/tests/refusal-redaction.test.mjs @@ -32,3 +32,13 @@ for (const kind of ['config', 'seen']) test(`${kind} JSON refusal names only the 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; + }); +}); diff --git a/packages/discord/src/binding.mjs b/packages/discord/src/binding.mjs index c620997c8..fce460a15 100644 --- a/packages/discord/src/binding.mjs +++ b/packages/discord/src/binding.mjs @@ -61,8 +61,8 @@ export function loadDataRoot(path = defaultConfigPath()) { let raw; try { raw = parseJsonFileText(readFileSync(path, "utf8"), path); - } catch { - throw new DiscordError(`config is not valid JSON: ${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}`); @@ -294,8 +294,8 @@ export function loadBinding(path) { let raw; try { raw = parseJsonFileText(readFileSync(path, "utf8"), path); - } catch { - throw new DiscordError(`binding is not valid JSON: ${path}`); + } catch (error) { + throw new DiscordError(`binding is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`); } return validateBinding(raw, `binding ${path}`); } diff --git a/packages/discord/tests/refusal-redaction.test.mjs b/packages/discord/tests/refusal-redaction.test.mjs index 8638c0f59..12ad349be 100644 --- a/packages/discord/tests/refusal-redaction.test.mjs +++ b/packages/discord/tests/refusal-redaction.test.mjs @@ -49,3 +49,11 @@ test('tools environment JSON refusal names the config key without its bytes', () 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; + }); +}); diff --git a/packages/seat/src/seat.mjs b/packages/seat/src/seat.mjs index 37d7c4650..6549daeaf 100644 --- a/packages/seat/src/seat.mjs +++ b/packages/seat/src/seat.mjs @@ -55,8 +55,8 @@ export function loadDataRoot(path = defaultConfigPath()) { let raw; try { raw = parseJsonFileText(readFileSync(path, "utf8"), path); - } catch { - throw new SeatError(`config is not valid JSON: ${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}`); @@ -204,8 +204,8 @@ export function readRegistration(seats, seat, layout) { let parsed; try { parsed = parseJsonFileText(readFileSync(path, "utf8"), path); - } catch { - throw new SeatError(`registration is not valid JSON: ${path}`); + } catch (error) { + throw new SeatError(`registration is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`); } try { const record = validateRegistration(parsed); diff --git a/packages/seat/tests/refusal-redaction.test.mjs b/packages/seat/tests/refusal-redaction.test.mjs index 33b922648..74f9d96f7 100644 --- a/packages/seat/tests/refusal-redaction.test.mjs +++ b/packages/seat/tests/refusal-redaction.test.mjs @@ -32,3 +32,12 @@ for (const kind of ['config', 'registration']) test(`${kind} JSON refusal names 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; + }); +}); diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs index d3659a200..4d251efe2 100755 --- a/scripts/mosaic-task.mjs +++ b/scripts/mosaic-task.mjs @@ -76,8 +76,8 @@ function readJsonFile(file, what) { let document; try { document = parseJsonFileText(fs.readFileSync(file, "utf8"), file, what); - } catch { - fail(2, `${what} is not valid JSON (${file})`); + } catch (error) { + fail(2, `${what} is not valid JSON (${file})${error.code ? ` (${error.code})` : ""}`); } return document; } diff --git a/scripts/test-darkwing-launch.mjs b/scripts/test-darkwing-launch.mjs index 883cdb1f0..c398c783b 100644 --- a/scripts/test-darkwing-launch.mjs +++ b/scripts/test-darkwing-launch.mjs @@ -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 }); diff --git a/scripts/test-rocko-launch.mjs b/scripts/test-rocko-launch.mjs index d591bcb0d..f84fa9710 100644 --- a/scripts/test-rocko-launch.mjs +++ b/scripts/test-rocko-launch.mjs @@ -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');