Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ Each release of Research-Kit, newest first. The version is `KIT_VERSION` in

## Unreleased

- Machine config saves preserve unpatched raw role and posture values, including typos
that diagnostics must still report. An unreadable config without a usable last-good
snapshot refuses an unrelated save rather than persisting the restrictive fallback;
an explicit valid `--role` can repair it. `install-hooks` names the refusal with exit 2
and the panel key endpoint returns HTTP 409 without echoing the key (#364).

- `path-authority` and `researcher-release` name a value flag with no value and print usage with exit 2 before resolving paths, including when another flag follows (#288).

- `property-replay --all` refuses a missing regression directory with exit 2 and
Expand Down
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion research-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ node research-kit/bin/selftest.mjs # all of it
node research-kit/bin/selftest.mjs gate hook # just these files
```

1838 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
1843 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
the assertions settled (ADR-0021), and each test is raced against a watchdog
(`RESEARCH_KIT_TEST_TIMEOUT`, default 60s).

Expand Down
2 changes: 1 addition & 1 deletion research-kit/bin/install-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ evidencePolicy ${config.evidencePolicy}
`);
process.exit(failed ? 1 : 0);
} catch (err) {
const why = writeFailure(err);
const why = err.code === 'CONFIG-UNREADABLE' ? err.message : writeFailure(err);
if (!why) throw err;
process.stderr.write(`${why}\n`);
process.exit(2);
Expand Down
32 changes: 30 additions & 2 deletions research-kit/lib/machine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -236,12 +236,40 @@ export function loadConfig(env = process.env) {
/** Writing drops retired keys, so the migration happens through normal use. */
export function saveConfig(patch, env = process.env) {
const file = configPath(env);
const current = readMachineConfig(env).settings;
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const rawFrom = (source) => {
const raw = parseJson(readText(source) ?? '');
if (!object(raw)) throw new Error('config must be a JSON object');
return raw;
};
let current = {};
if (exists(file)) {
try { current = rawFrom(file); }
catch {
try { current = rawFrom(lastGoodPath(file)); }
catch {
if (!Object.hasOwn(patch, 'role') || !ROLES.includes(patch.role)) {
throw Object.assign(new Error(`machine config ${file} does not parse as a JSON object; repair it or declare the role with --role collector|builder before saving`), {
code: 'CONFIG-UNREADABLE',
});
}
}
}
}
// Save declarations, not the resolved role/posture. A typo remains available for
// doctor to name until the operator patches that field. Only the existing retired
// spelling is migrated here; it must keep its mode when its old key is dropped.
if (current.editGate?.mode === undefined && EDIT_GATE_MODES.includes(current.claudeGate)) {
current = { ...current, editGate: { ...(object(current.editGate) ? current.editGate : {}), mode: current.claudeGate } };
}
const next = {
...current,
...patch,
editGate: { ...current.editGate, ...(patch.editGate ?? {}) },
};
if (patch.editGate !== undefined) {
next.editGate = { ...(object(current.editGate) ? current.editGate : {}), ...(patch.editGate ?? {}) };
}
for (const retired of Object.keys(RETIRED_CONFIG_KEYS)) delete next[retired];
ensureDir(path.dirname(file));
writeText(file, `${JSON.stringify(next, null, 2)}\n`);
writeText(lastGoodPath(file), `${JSON.stringify(next, null, 2)}\n`);
Expand Down
6 changes: 5 additions & 1 deletion research-kit/lib/panel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,11 @@ export function createPanel({
if (problem) return refuse(res, 400, problem);
const inside = configInsideRepository(env);
if (inside) return refuse(res, 409, `the machine config is inside the repository at ${inside}; a key never goes into a repository (Rule 6). Point RESEARCH_KIT_CONFIG outside it.`);
saveConfig({ [SEARCH_KEY.configKey]: body.key.trim() }, env);
try { saveConfig({ [SEARCH_KEY.configKey]: body.key.trim() }, env); }
catch (err) {
if (err.code === 'CONFIG-UNREADABLE') return refuse(res, 409, err.message);
throw err;
}
return send(res, 200, { saved: true, keys: keyState() });
}

Expand Down
56 changes: 56 additions & 0 deletions research-kit/test/machine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,62 @@ test('saveConfig drops the retired key, so the migration happens through normal
assert.equal(written.role, 'builder');
});

test('an unrelated config save preserves the role the operator actually typed', () => {
const { env, file } = envWith('{"role":"Builder","evidencePolicy":"STRICT"}');
saveConfig({ failOpen: false }, env);
const written = JSON.parse(readText(file));
assert.equal(written.role, 'Builder');
assert.equal(written.evidencePolicy, 'STRICT');
assert.equal(written.failOpen, false);
assert.equal(machineRole(env), 'unknown');
saveConfig({ role: 'collector' }, env);
assert.equal(JSON.parse(readText(file)).role, 'collector');
});

test('a broken config without a usable snapshot refuses an unrelated save and permits a role repair', () => {
for (const snapshot of [null, '[]', 'null']) {
const original = '{"role":"bui';
const { env, file } = envWith(original, { snapshot });
assert.throws(() => saveConfig({ failOpen: true }, env), /does not parse.*--role/);
assert.equal(readText(file), original, 'a refusal must keep the operator\'s only copy');
if (snapshot === null) assert.equal(fs.existsSync(`${file}.last-good`), false);
else assert.equal(readText(`${file}.last-good`), snapshot);
saveConfig({ role: 'collector' }, env);
assert.equal(JSON.parse(readText(file)).role, 'collector');
assert.notEqual(JSON.parse(readText(file)).failOpen, false, 'no fallback-closed setting is persisted');
}
});

test('saving from a last-good snapshot retains its raw role and posture values', () => {
const { env, file } = envWith('{"role":"bui', {
snapshot: '{"role":"Builder","failOpen":"false","editGate":{"mode":"HARD-BLOCK"}}',
});
saveConfig({ serpapiKey: 'unit-test-key' }, env);
const written = JSON.parse(readText(file));
assert.equal(written.role, 'Builder');
assert.equal(written.failOpen, 'false');
assert.equal(written.editGate.mode, 'HARD-BLOCK');
assert.equal(written.serpapiKey, 'unit-test-key');
});

test('install-hooks names an unreadable config save refusal without replacing it', () => {
const original = '{"role":"bui';
const { env, file } = envWith(original);
const isolated = {
...env, RESEARCH_KIT_HOME: KIT_ROOT,
RESEARCH_KIT_INSTALL_STATE: path.join(path.dirname(file), 'install.json'),
GIT_CONFIG_GLOBAL: path.join(path.dirname(file), 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1',
};
const run = spawnSync(process.execPath, [path.join(KIT_ROOT, 'bin', 'install-hooks.mjs'), '--fail-closed', '--git-only'], {
env: isolated, encoding: 'utf8', timeout: 60_000, windowsHide: true,
});
assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
assert.match(run.stderr, /does not parse.*--role/);
assert.doesNotMatch(run.stderr, /\n\s+at /);
assert.equal(readText(file), original);
assert.equal(fs.existsSync(isolated.RESEARCH_KIT_INSTALL_STATE), false);
});

// Found 2026-10-11: the browser transport and its error messages tell the operator to set
// browserPath (ADR-0088), but the config was read without it and rewritten without it, so
// `install-hooks --role builder` erased a browserPath the operator had set by hand.
Expand Down
15 changes: 15 additions & 0 deletions research-kit/test/panel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,21 @@ test('the page never writes server text as HTML', () => {
assert(/button\.type = 'button'/.test(js) && /aria-label/.test(js), 'request output has no keyboard-accessible control');
});

test('saving a key refuses a broken unsnapshotted config without replacing or echoing it', async () => {
const m = machine();
const original = '{"role":"bui';
writeText(m.configFile, original);
const ctx = await started({ machine: m, project: makeProject() });
try {
const res = await api(ctx, '/api/key/search', { key: KEY });
assertEqual(res.status, 409, res.text);
assert(/does not parse.*--role/.test(res.text), res.text);
assert(!res.text.includes(KEY), 'the refusal echoed the key');
assertEqual(readText(m.configFile), original);
assert(!fs.existsSync(`${m.configFile}.last-good`), 'the refusal created a snapshot');
} finally { await ctx.panel.close(); }
});

test('saving the search key writes the machine config and never echoes the key', async () => {
const m = machine();
const ctx = await started({ machine: m, project: makeProject() });
Expand Down
Loading