harness-everything 0.31.2 → 0.31.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -0
- package/bin/cli.js +1 -0
- package/ci/lib/isolated-test-process.js +30 -0
- package/ci/mechanism-2al-codex-hooks-compat.test.js +14 -14
- package/ci/mechanism-2ao-plugin-sync.test.js +19 -2
- package/ci/mechanism-2at-fable-execution-phases.test.js +46 -0
- package/ci/mechanism-2au-codex-runtime-safety.test.js +340 -0
- package/ci/mechanism-2av-test-home-isolation.test.js +48 -0
- package/ci/mechanism-test.js +2 -2
- package/docs/platform-capabilities.md +4 -0
- package/docs/platform-compatibility.json +1 -1
- package/fable-mode/SKILL.md +1 -1
- package/fable-mode/execution-guardrails/SKILL.md +1 -1
- package/fable-mode/fable-haiku/SKILL.md +1 -1
- package/fable-mode/fable-opus/SKILL.md +1 -1
- package/fable-mode/fable-sonnet/SKILL.md +1 -1
- package/fable-mode/references/execution-phases.md +93 -25
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +2 -2
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/references/execution-phases.md +93 -25
- package/plugins/harness-everything/skills/self-evolve/SKILL.md +1 -1
- package/plugins/harness-everything/skills/self-evolve/scripts/self-regression.js +8 -6
- package/sbom.cdx.json +7 -7
- package/scripts/codex-hooks-compat.js +265 -25
- package/self-evolve/SKILL.md +1 -1
- package/self-evolve/scripts/self-regression.js +8 -6
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
const compat = require('../scripts/codex-hooks-compat');
|
|
9
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-323-'));
|
|
10
|
+
let failures = 0;
|
|
11
|
+
function test(name, run) {
|
|
12
|
+
try { run(); console.log('PASS ' + name); }
|
|
13
|
+
catch (error) { failures++; console.error('FAIL ' + name + ': ' + error.message); }
|
|
14
|
+
}
|
|
15
|
+
// Git for Windows rejects the NUL device as a config file; use real empty paths.
|
|
16
|
+
const gitConfig = path.join(root, 'empty.gitconfig');
|
|
17
|
+
const gitHooks = path.join(root, 'no-git-hooks');
|
|
18
|
+
fs.writeFileSync(gitConfig, '');
|
|
19
|
+
fs.mkdirSync(gitHooks);
|
|
20
|
+
function samePathOf(left, right) {
|
|
21
|
+
const normalize = value => path.resolve(value).toLowerCase();
|
|
22
|
+
return Boolean(left && right) && normalize(left) === normalize(right);
|
|
23
|
+
}
|
|
24
|
+
function git(cwd, args) {
|
|
25
|
+
const result = spawnSync('git', ['-c', 'core.hooksPath=' + gitHooks, ...args], {
|
|
26
|
+
cwd, encoding: 'utf8', env: { ...process.env, GIT_CONFIG_GLOBAL: gitConfig, GIT_CONFIG_NOSYSTEM: '1' },
|
|
27
|
+
});
|
|
28
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
29
|
+
return result.stdout.trim();
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
test('A survives B; C collects A; repeat C retains B; uninstall removes retained runtimes', () => {
|
|
33
|
+
const source = path.join(root, 'cache', 'plugin');
|
|
34
|
+
fs.cpSync(compat.SOURCE_PLUGIN_ROOT, source, { recursive: true });
|
|
35
|
+
const codexHome = path.join(root, 'retention-home');
|
|
36
|
+
const install = () => compat.install({ codexHome, sourcePluginRoot: source });
|
|
37
|
+
const a = install();
|
|
38
|
+
fs.writeFileSync(path.join(source, 'revision.txt'), 'B');
|
|
39
|
+
const b = install();
|
|
40
|
+
const oldScript = path.join(a.runtimeDir, 'hooks', 'scripts', 'codex-action-gate-pre.js');
|
|
41
|
+
assert(fs.existsSync(oldScript), 'A hook script must still resolve after B');
|
|
42
|
+
// Resolve the retained script without executing a hook against any user state.
|
|
43
|
+
assert.strictEqual(require.resolve(oldScript), fs.realpathSync(oldScript));
|
|
44
|
+
fs.writeFileSync(path.join(source, 'revision.txt'), 'C');
|
|
45
|
+
const c = install();
|
|
46
|
+
assert(!fs.existsSync(a.runtimeDir), 'third distinct install must collect A');
|
|
47
|
+
assert(fs.existsSync(b.runtimeDir));
|
|
48
|
+
install();
|
|
49
|
+
assert(fs.existsSync(b.runtimeDir), 'same-fingerprint update must retain B');
|
|
50
|
+
assert.deepStrictEqual(fs.readdirSync(path.dirname(c.runtimeDir)).sort(),
|
|
51
|
+
[path.basename(b.runtimeDir), path.basename(c.runtimeDir)].sort());
|
|
52
|
+
compat.uninstall({ codexHome });
|
|
53
|
+
assert(!fs.existsSync(path.dirname(c.runtimeDir)), 'uninstall removes every retained runtime');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('status reports a lost previous runtime; same-fingerprint reinstall resets it', () => {
|
|
57
|
+
const source = path.join(root, 'cache', 'plugin-health');
|
|
58
|
+
fs.cpSync(compat.SOURCE_PLUGIN_ROOT, source, { recursive: true });
|
|
59
|
+
const codexHome = path.join(root, 'health-home');
|
|
60
|
+
const install = () => compat.install({ codexHome, sourcePluginRoot: source });
|
|
61
|
+
const a = install();
|
|
62
|
+
fs.writeFileSync(path.join(source, 'revision.txt'), 'B');
|
|
63
|
+
const b = install();
|
|
64
|
+
let report = compat.status({ codexHome });
|
|
65
|
+
assert.strictEqual(report.healthy, true);
|
|
66
|
+
assert.strictEqual(report.runtimes.previous.status, 'present');
|
|
67
|
+
assert(report.runtimes.previous.runtimeDir === a.runtimeDir);
|
|
68
|
+
fs.writeFileSync(path.join(a.runtimeDir, 'tampered.txt'), 'x');
|
|
69
|
+
report = compat.status({ codexHome });
|
|
70
|
+
assert.strictEqual(report.healthy, false);
|
|
71
|
+
assert.strictEqual(report.runtimes.previous.status, 'modified');
|
|
72
|
+
fs.rmSync(a.runtimeDir, { recursive: true, force: true });
|
|
73
|
+
report = compat.status({ codexHome });
|
|
74
|
+
assert.strictEqual(report.healthy, false, 'missing previous runtime must not report healthy');
|
|
75
|
+
assert.strictEqual(report.runtimes.current.healthy, true);
|
|
76
|
+
assert.strictEqual(report.runtimes.previous.status, 'missing');
|
|
77
|
+
assert.strictEqual(report.observationCapture.status, 'configured-unverified');
|
|
78
|
+
assert.strictEqual(report.observationCapture.reason, 'previous-runtime-missing');
|
|
79
|
+
const cli = spawnSync(process.execPath, [path.resolve(__dirname, '../scripts/codex-hooks-compat.js'),
|
|
80
|
+
'status', '--codex-home', codexHome], { encoding: 'utf8' });
|
|
81
|
+
assert.strictEqual(cli.status, 1, 'CLI status must fail when the previous runtime is lost');
|
|
82
|
+
const again = install();
|
|
83
|
+
assert.strictEqual(again.runtimeDir, b.runtimeDir);
|
|
84
|
+
report = compat.status({ codexHome });
|
|
85
|
+
assert.strictEqual(report.healthy, true);
|
|
86
|
+
assert.strictEqual(report.runtimes.previous.status, 'none');
|
|
87
|
+
compat.uninstall({ codexHome });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('overlapping installs serialize; neither collects the other current runtime', () => {
|
|
91
|
+
const codexHome = path.join(root, 'concurrent-home');
|
|
92
|
+
const base = path.join(root, 'cache', 'plugin-concurrent');
|
|
93
|
+
fs.cpSync(compat.SOURCE_PLUGIN_ROOT, base, { recursive: true });
|
|
94
|
+
const a = compat.install({ codexHome, sourcePluginRoot: base });
|
|
95
|
+
const sources = {};
|
|
96
|
+
for (const id of ['B', 'C']) {
|
|
97
|
+
sources[id] = path.join(root, 'cache', 'plugin-concurrent-' + id);
|
|
98
|
+
fs.cpSync(base, sources[id], { recursive: true });
|
|
99
|
+
fs.writeFileSync(path.join(sources[id], 'revision.txt'), id);
|
|
100
|
+
}
|
|
101
|
+
const barrier = path.join(root, 'barrier');
|
|
102
|
+
fs.mkdirSync(barrier);
|
|
103
|
+
// Each child waits after preparing its runtime until the peer has also prepared one,
|
|
104
|
+
// forcing the reviewer's interleaving. A serialized peer never arrives, so the wait
|
|
105
|
+
// is bounded and the first holder proceeds alone.
|
|
106
|
+
const child = path.join(root, 'concurrent-child.js');
|
|
107
|
+
fs.writeFileSync(child, `
|
|
108
|
+
const fs = require('fs');
|
|
109
|
+
const path = require('path');
|
|
110
|
+
const [compatPath, codexHome, source, barrier, id, peer] = process.argv.slice(2);
|
|
111
|
+
const compat = require(compatPath);
|
|
112
|
+
const result = compat.install({ codexHome, sourcePluginRoot: source, onRuntimeReady() {
|
|
113
|
+
fs.writeFileSync(path.join(barrier, id), '');
|
|
114
|
+
const deadline = Date.now() + 1500;
|
|
115
|
+
while (!fs.existsSync(path.join(barrier, peer)) && Date.now() < deadline) {
|
|
116
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
117
|
+
}
|
|
118
|
+
} });
|
|
119
|
+
process.stdout.write(JSON.stringify(result));
|
|
120
|
+
`);
|
|
121
|
+
const driver = path.join(root, 'concurrent-driver.js');
|
|
122
|
+
fs.writeFileSync(driver, `
|
|
123
|
+
const { spawn } = require('child_process');
|
|
124
|
+
const [child, compatPath, codexHome, barrier, sourceB, sourceC] = process.argv.slice(2);
|
|
125
|
+
const runs = [['B', 'C', sourceB], ['C', 'B', sourceC]].map(([id, peer, source]) => new Promise(resolve => {
|
|
126
|
+
const proc = spawn(process.execPath, [child, compatPath, codexHome, source, barrier, id, peer]);
|
|
127
|
+
let out = '', err = '';
|
|
128
|
+
proc.stdout.on('data', chunk => { out += chunk; });
|
|
129
|
+
proc.stderr.on('data', chunk => { err += chunk; });
|
|
130
|
+
proc.on('close', code => resolve({ id, code, out, err }));
|
|
131
|
+
}));
|
|
132
|
+
Promise.all(runs).then(results => process.stdout.write(JSON.stringify(results)));
|
|
133
|
+
`);
|
|
134
|
+
const compatPath = path.resolve(__dirname, '../scripts/codex-hooks-compat.js');
|
|
135
|
+
const run = spawnSync(process.execPath, [driver, child, compatPath, codexHome, barrier, sources.B, sources.C],
|
|
136
|
+
{ encoding: 'utf8', timeout: 120000 });
|
|
137
|
+
assert.strictEqual(run.status, 0, run.stderr);
|
|
138
|
+
const results = JSON.parse(run.stdout);
|
|
139
|
+
for (const result of results) assert.strictEqual(result.code, 0, result.id + ': ' + result.err);
|
|
140
|
+
const installed = Object.fromEntries(results.map(result => [result.id, JSON.parse(result.out).runtimeDir]));
|
|
141
|
+
|
|
142
|
+
const manifest = JSON.parse(fs.readFileSync(compat.pathsFor(codexHome).manifestFile, 'utf8'));
|
|
143
|
+
assert(fs.existsSync(manifest.runtimeDir), 'final current runtime must exist');
|
|
144
|
+
assert.strictEqual(compat.treeHash(manifest.runtimeDir), manifest.sourcePluginSha256);
|
|
145
|
+
const other = samePathOf(manifest.runtimeDir, installed.B) ? installed.C : installed.B;
|
|
146
|
+
assert(samePathOf(manifest.previousRuntimeDir, other), 'serialized peer becomes the previous runtime');
|
|
147
|
+
assert(fs.existsSync(other), 'no installer may collect the peer runtime it just committed');
|
|
148
|
+
assert(!fs.existsSync(a.runtimeDir), 'only the oldest runtime is collected');
|
|
149
|
+
const hooks = fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8').replace(/\\\\/g, '/');
|
|
150
|
+
const runtime = manifest.runtimeDir.replace(/\\/g, '/');
|
|
151
|
+
const referenced = hooks.match(new RegExp(runtime.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/[^"\\s]+\\.js', 'g')) || [];
|
|
152
|
+
assert(referenced.length > 0, 'hooks.json must reference the current runtime');
|
|
153
|
+
for (const file of referenced) assert(fs.existsSync(file), 'configured command must resolve: ' + file);
|
|
154
|
+
assert.strictEqual(compat.status({ codexHome }).healthy, true);
|
|
155
|
+
assert(!fs.existsSync(compat.pathsFor(codexHome).ownerRoot + '.lock'), 'lock must be released');
|
|
156
|
+
compat.uninstall({ codexHome });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('stale lock from a dead process is broken; a live lock times out before writes', () => {
|
|
160
|
+
const source = path.join(root, 'cache', 'plugin-lock');
|
|
161
|
+
fs.cpSync(compat.SOURCE_PLUGIN_ROOT, source, { recursive: true });
|
|
162
|
+
const codexHome = path.join(root, 'lock-home');
|
|
163
|
+
const lockDir = compat.pathsFor(codexHome).ownerRoot + '.lock';
|
|
164
|
+
const dead = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' });
|
|
165
|
+
for (const dir of [lockDir, lockDir + '.guard']) {
|
|
166
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
167
|
+
fs.writeFileSync(path.join(dir, 'owner.json'),
|
|
168
|
+
JSON.stringify({ pid: Number(dead.stdout), hostname: os.hostname(), token: 'crashed' }));
|
|
169
|
+
}
|
|
170
|
+
compat.install({ codexHome, sourcePluginRoot: source, lockTimeoutMs: 5000 });
|
|
171
|
+
assert(!fs.existsSync(lockDir), 'stale lock must be replaced and released');
|
|
172
|
+
assert(!fs.existsSync(lockDir + '.guard'), 'guard left by a crashed process must be reclaimed');
|
|
173
|
+
|
|
174
|
+
fs.mkdirSync(lockDir);
|
|
175
|
+
fs.writeFileSync(path.join(lockDir, 'owner.json'), JSON.stringify({ pid: process.pid, hostname: os.hostname() }));
|
|
176
|
+
const before = fs.readFileSync(path.join(codexHome, 'hooks.json'));
|
|
177
|
+
fs.writeFileSync(path.join(source, 'revision.txt'), 'locked');
|
|
178
|
+
assert.throws(() => compat.install({ codexHome, sourcePluginRoot: source, lockTimeoutMs: 200 }), /timed out.*lock/i);
|
|
179
|
+
assert.throws(() => compat.uninstall({ codexHome, lockTimeoutMs: 200 }), /timed out.*lock/i);
|
|
180
|
+
assert.deepStrictEqual(fs.readFileSync(path.join(codexHome, 'hooks.json')), before);
|
|
181
|
+
assert.strictEqual(fs.readdirSync(compat.pathsFor(codexHome).runtimeRoot).length, 1, 'no runtime created while locked');
|
|
182
|
+
fs.rmSync(lockDir, { recursive: true });
|
|
183
|
+
compat.uninstall({ codexHome });
|
|
184
|
+
assert(!fs.existsSync(path.join(codexHome, 'harness-everything')), 'uninstall leaves no owner or lock directories');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('three-contender stale-lock recovery admits at most one holder', () => {
|
|
188
|
+
const codexHome = path.join(root, 'stale-race-home');
|
|
189
|
+
const base = path.join(root, 'cache', 'plugin-stale-race');
|
|
190
|
+
fs.cpSync(compat.SOURCE_PLUGIN_ROOT, base, { recursive: true });
|
|
191
|
+
compat.install({ codexHome, sourcePluginRoot: base });
|
|
192
|
+
const sources = {};
|
|
193
|
+
for (const id of ['A', 'B', 'C']) {
|
|
194
|
+
sources[id] = path.join(root, 'cache', 'plugin-stale-race-' + id);
|
|
195
|
+
fs.cpSync(base, sources[id], { recursive: true });
|
|
196
|
+
fs.writeFileSync(path.join(sources[id], 'revision.txt'), id);
|
|
197
|
+
}
|
|
198
|
+
// Leave a lock owned by a dead local process.
|
|
199
|
+
const lockDir = compat.pathsFor(codexHome).ownerRoot + '.lock';
|
|
200
|
+
const dead = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' });
|
|
201
|
+
fs.mkdirSync(lockDir);
|
|
202
|
+
fs.writeFileSync(path.join(lockDir, 'owner.json'),
|
|
203
|
+
JSON.stringify({ pid: Number(dead.stdout), hostname: os.hostname(), token: 'dead' }));
|
|
204
|
+
const barrier = path.join(root, 'stale-barrier');
|
|
205
|
+
fs.mkdirSync(barrier);
|
|
206
|
+
// Reviewer interleaving: A and B both observe the stale owner; A reclaims and holds;
|
|
207
|
+
// B then reclaims late and pauses after its rename, so C can take the vacated path.
|
|
208
|
+
// Each holder claims an exclusive marker for 1.5 s inside its critical section.
|
|
209
|
+
const child = path.join(root, 'stale-race-child.js');
|
|
210
|
+
fs.writeFileSync(child, `
|
|
211
|
+
const fs = require('fs');
|
|
212
|
+
const path = require('path');
|
|
213
|
+
const [compatPath, codexHome, source, barrier, id] = process.argv.slice(2);
|
|
214
|
+
const compat = require(compatPath);
|
|
215
|
+
const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
216
|
+
const waitFor = (names, ms) => {
|
|
217
|
+
const deadline = Date.now() + ms;
|
|
218
|
+
while (!names.every(name => fs.existsSync(path.join(barrier, name))) && Date.now() < deadline) sleep(10);
|
|
219
|
+
};
|
|
220
|
+
if (id === 'C') waitFor(['A-stale', 'B-stale'], 3000);
|
|
221
|
+
let overlap = false;
|
|
222
|
+
const result = compat.install({
|
|
223
|
+
codexHome, sourcePluginRoot: source,
|
|
224
|
+
onLockEvent(event) {
|
|
225
|
+
if (event === 'stale-observed' && id !== 'C') {
|
|
226
|
+
fs.writeFileSync(path.join(barrier, id + '-stale'), '');
|
|
227
|
+
waitFor(['A-stale', 'B-stale'], 1500);
|
|
228
|
+
if (id === 'B') sleep(400);
|
|
229
|
+
}
|
|
230
|
+
if (event === 'reclaim-renamed' && id === 'B') sleep(800);
|
|
231
|
+
},
|
|
232
|
+
onRuntimeReady() {
|
|
233
|
+
const marker = path.join(barrier, 'holder');
|
|
234
|
+
try { fs.writeFileSync(marker, id, { flag: 'wx' }); }
|
|
235
|
+
catch (_) { overlap = true; return; }
|
|
236
|
+
sleep(1500);
|
|
237
|
+
fs.rmSync(marker);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
if (overlap) { process.stderr.write(id + ' entered the critical section concurrently'); process.exit(3); }
|
|
241
|
+
process.stdout.write(JSON.stringify(result));
|
|
242
|
+
`);
|
|
243
|
+
const driver = path.join(root, 'stale-race-driver.js');
|
|
244
|
+
fs.writeFileSync(driver, `
|
|
245
|
+
const { spawn } = require('child_process');
|
|
246
|
+
const [child, compatPath, codexHome, barrier, ...sources] = process.argv.slice(2);
|
|
247
|
+
const runs = ['A', 'B', 'C'].map((id, index) => new Promise(resolve => {
|
|
248
|
+
const proc = spawn(process.execPath, [child, compatPath, codexHome, sources[index], barrier, id]);
|
|
249
|
+
let out = '', err = '';
|
|
250
|
+
proc.stdout.on('data', chunk => { out += chunk; });
|
|
251
|
+
proc.stderr.on('data', chunk => { err += chunk; });
|
|
252
|
+
proc.on('close', code => resolve({ id, code, out, err }));
|
|
253
|
+
}));
|
|
254
|
+
Promise.all(runs).then(results => process.stdout.write(JSON.stringify(results)));
|
|
255
|
+
`);
|
|
256
|
+
const compatPath = path.resolve(__dirname, '../scripts/codex-hooks-compat.js');
|
|
257
|
+
const run = spawnSync(process.execPath,
|
|
258
|
+
[driver, child, compatPath, codexHome, barrier, sources.A, sources.B, sources.C],
|
|
259
|
+
{ encoding: 'utf8', timeout: 180000 });
|
|
260
|
+
assert.strictEqual(run.status, 0, run.stderr);
|
|
261
|
+
const failed = JSON.parse(run.stdout).filter(result => result.code !== 0)
|
|
262
|
+
.map(result => result.id + ' exit ' + result.code + ': ' +
|
|
263
|
+
(result.err.split('\n').find(line => /Error|concurrently/.test(line)) || result.err.trim()));
|
|
264
|
+
assert.deepStrictEqual(failed, [], 'every contender must succeed exclusively');
|
|
265
|
+
const manifest = JSON.parse(fs.readFileSync(compat.pathsFor(codexHome).manifestFile, 'utf8'));
|
|
266
|
+
assert(fs.existsSync(manifest.runtimeDir), 'final current runtime must exist');
|
|
267
|
+
assert.strictEqual(compat.treeHash(manifest.runtimeDir), manifest.sourcePluginSha256);
|
|
268
|
+
assert(manifest.previousRuntimeDir && fs.existsSync(manifest.previousRuntimeDir), 'previous runtime must exist');
|
|
269
|
+
assert.strictEqual(compat.status({ codexHome }).healthy, true);
|
|
270
|
+
assert(!fs.existsSync(lockDir), 'lock must be released');
|
|
271
|
+
const leftovers = fs.readdirSync(path.dirname(lockDir)).filter(name => name !== 'compat-hooks');
|
|
272
|
+
assert.deepStrictEqual(leftovers, [], 'no stale tombs or guards may remain');
|
|
273
|
+
compat.uninstall({ codexHome });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const repo = path.join(root, 'repo');
|
|
277
|
+
const source = path.join(repo, 'plugins', 'harness-everything');
|
|
278
|
+
fs.cpSync(compat.SOURCE_PLUGIN_ROOT, source, { recursive: true });
|
|
279
|
+
git(repo, ['init']);
|
|
280
|
+
git(repo, ['add', '.']);
|
|
281
|
+
git(repo, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'fixture']);
|
|
282
|
+
test('tracked plugin in ordinary checkout allows unrelated untracked files', () => {
|
|
283
|
+
fs.writeFileSync(path.join(repo, 'unrelated.txt'), 'unrelated');
|
|
284
|
+
compat.install({ codexHome: path.join(root, 'unrelated-home'), sourcePluginRoot: source });
|
|
285
|
+
});
|
|
286
|
+
test('packaged source inside unrelated dirty repository is allowed', () => {
|
|
287
|
+
const packaged = path.join(repo, 'node_modules', 'harness', 'plugins', 'harness-everything');
|
|
288
|
+
fs.cpSync(compat.SOURCE_PLUGIN_ROOT, packaged, { recursive: true });
|
|
289
|
+
compat.install({ codexHome: path.join(root, 'packaged-home'), sourcePluginRoot: packaged });
|
|
290
|
+
});
|
|
291
|
+
test('ignored files inside plugin subtree are excluded from the runtime, not refused', () => {
|
|
292
|
+
fs.writeFileSync(path.join(repo, '.gitignore'), '*.ignored\n.DS_Store\n');
|
|
293
|
+
git(repo, ['add', '.gitignore']);
|
|
294
|
+
git(repo, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'ignore']);
|
|
295
|
+
const codexHome = path.join(root, 'ignored-home');
|
|
296
|
+
const baseline = compat.install({ codexHome, sourcePluginRoot: source });
|
|
297
|
+
const ignored = [path.join(source, 'hooks', 'scripts', 'payload.ignored'), path.join(source, '.DS_Store')];
|
|
298
|
+
for (const file of ignored) fs.writeFileSync(file, 'ignored content');
|
|
299
|
+
try {
|
|
300
|
+
const result = compat.install({ codexHome, sourcePluginRoot: source });
|
|
301
|
+
assert.strictEqual(result.runtimeDir, baseline.runtimeDir, 'ignored files must not change the fingerprint');
|
|
302
|
+
assert(!fs.existsSync(path.join(result.runtimeDir, 'hooks', 'scripts', 'payload.ignored')));
|
|
303
|
+
assert(!fs.existsSync(path.join(result.runtimeDir, '.DS_Store')));
|
|
304
|
+
assert.strictEqual(compat.status({ codexHome }).healthy, true);
|
|
305
|
+
} finally {
|
|
306
|
+
for (const file of ignored) fs.rmSync(file);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
test('clean ordinary checkout installs; dirty checkout refuses before writes; explicit opt-in works', () => {
|
|
310
|
+
const codexHome = path.join(root, 'dirty-home');
|
|
311
|
+
compat.install({ codexHome, sourcePluginRoot: source });
|
|
312
|
+
fs.writeFileSync(path.join(source, 'unreleased.txt'), 'dirty');
|
|
313
|
+
const before = fs.readFileSync(path.join(codexHome, 'hooks.json'));
|
|
314
|
+
assert.throws(() => compat.install({ codexHome, sourcePluginRoot: source }), /unreleased|dirty/i);
|
|
315
|
+
assert.deepStrictEqual(fs.readFileSync(path.join(codexHome, 'hooks.json')), before);
|
|
316
|
+
compat.install({ codexHome, sourcePluginRoot: source, allowUnreleasedSource: true });
|
|
317
|
+
fs.rmSync(path.join(source, 'unreleased.txt'));
|
|
318
|
+
});
|
|
319
|
+
test('linked worktree refuses even when clean; explicit opt-in works', () => {
|
|
320
|
+
const linked = path.join(root, 'linked');
|
|
321
|
+
git(repo, ['worktree', 'add', '-b', 'fixture-linked', linked]);
|
|
322
|
+
const codexHome = path.join(root, 'linked-home');
|
|
323
|
+
const sourcePluginRoot = path.join(linked, 'plugins', 'harness-everything');
|
|
324
|
+
assert.throws(() => compat.install({ codexHome, sourcePluginRoot }), /unreleased|worktree/i);
|
|
325
|
+
assert(!fs.existsSync(codexHome), 'source refusal must happen before writes');
|
|
326
|
+
compat.install({ codexHome, sourcePluginRoot, allowUnreleasedSource: true });
|
|
327
|
+
const packaged = path.join(linked, 'cache', 'harness');
|
|
328
|
+
fs.cpSync(compat.SOURCE_PLUGIN_ROOT, packaged, { recursive: true });
|
|
329
|
+
compat.install({ codexHome: path.join(root, 'linked-cache-home'), sourcePluginRoot: packaged });
|
|
330
|
+
});
|
|
331
|
+
test('CLI forwards explicit unreleased-source opt-in', () => {
|
|
332
|
+
const codexHome = path.join(root, 'cli-home');
|
|
333
|
+
const script = path.resolve(__dirname, '../scripts/codex-hooks-compat.js');
|
|
334
|
+
const result = spawnSync(process.execPath, [script, 'install', '--codex-home', codexHome, '--allow-unreleased-source'], { encoding: 'utf8' });
|
|
335
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
336
|
+
});
|
|
337
|
+
} finally {
|
|
338
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
339
|
+
}
|
|
340
|
+
process.exitCode = failures ? 1 : 0;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-runner-isolation-'));
|
|
9
|
+
try {
|
|
10
|
+
const repo = path.join(root, 'fixture');
|
|
11
|
+
const ci = path.join(repo, 'ci');
|
|
12
|
+
fs.mkdirSync(path.join(ci, 'lib'), { recursive: true });
|
|
13
|
+
fs.copyFileSync(path.join(__dirname, 'mechanism-test.js'), path.join(ci, 'mechanism-test.js'));
|
|
14
|
+
const helper = path.join(__dirname, 'lib', 'isolated-test-process.js');
|
|
15
|
+
if (fs.existsSync(helper)) fs.copyFileSync(helper, path.join(ci, 'lib', 'isolated-test-process.js'));
|
|
16
|
+
const report = path.join(root, 'child.json');
|
|
17
|
+
fs.writeFileSync(path.join(ci, 'mechanism-fixture.test.js'), `
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const assert = require('assert');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
assert.notStrictEqual(process.env.CODEX_HOME, process.env.PARENT_CODEX_HOME);
|
|
23
|
+
assert.notStrictEqual(process.env.HOME, process.env.PARENT_HOME);
|
|
24
|
+
assert.strictEqual(process.env.CODEX_HOME, path.join(process.env.HOME, '.codex'));
|
|
25
|
+
assert.strictEqual(os.homedir(), process.env.HOME);
|
|
26
|
+
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
|
27
|
+
fs.writeFileSync(path.join(process.env.CODEX_HOME, 'child-proof'), 'isolated');
|
|
28
|
+
fs.writeFileSync(process.env.ISOLATION_REPORT, JSON.stringify({home: process.env.HOME}));
|
|
29
|
+
`);
|
|
30
|
+
const parentHome = path.join(root, 'parent-home');
|
|
31
|
+
const parentCodex = path.join(parentHome, '.codex');
|
|
32
|
+
const result = spawnSync(process.execPath, [path.join(ci, 'mechanism-test.js')], {
|
|
33
|
+
encoding: 'utf8', env: { ...process.env, HOME: parentHome, USERPROFILE: parentHome,
|
|
34
|
+
CODEX_HOME: parentCodex, PARENT_HOME: parentHome, PARENT_CODEX_HOME: parentCodex,
|
|
35
|
+
ISOLATION_REPORT: report },
|
|
36
|
+
});
|
|
37
|
+
assert.strictEqual(result.status, 0, result.stdout + result.stderr);
|
|
38
|
+
const child = JSON.parse(fs.readFileSync(report, 'utf8'));
|
|
39
|
+
assert(!fs.existsSync(child.home), 'runner must clean its temporary home');
|
|
40
|
+
assert(!fs.existsSync(parentHome), 'runner must not write into inherited home');
|
|
41
|
+
// Both entry points must use the same isolated spawn implementation.
|
|
42
|
+
const regression = fs.readFileSync(path.join(__dirname, '../self-evolve/scripts/self-regression.js'), 'utf8');
|
|
43
|
+
assert(regression.includes('spawnTestNode('));
|
|
44
|
+
assert(!regression.includes("spawnSync('node'"));
|
|
45
|
+
console.log('PASS mechanism child receives a temporary CODEX_HOME/HOME; shared self-regression isolation and cleanup verified');
|
|
46
|
+
} finally {
|
|
47
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
48
|
+
}
|
package/ci/mechanism-test.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* keeps the test code highly decoupled, and simplifies debugging and adding new test suites.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
const {
|
|
10
|
+
const { spawnTestNode } = require('./lib/isolated-test-process');
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
13
|
|
|
@@ -35,7 +35,7 @@ files.forEach(file => {
|
|
|
35
35
|
const filePath = path.join(evalDir, file);
|
|
36
36
|
console.log(`\n▶️ Running test suite: ${file}`);
|
|
37
37
|
|
|
38
|
-
const testRun =
|
|
38
|
+
const testRun = spawnTestNode([filePath], {
|
|
39
39
|
stdio: 'inherit',
|
|
40
40
|
cwd: projectRoot,
|
|
41
41
|
env: { ...process.env, CLAUDE: '1' }
|
|
@@ -116,6 +116,10 @@ harness codex-hooks uninstall
|
|
|
116
116
|
|
|
117
117
|
The installer copies the current packaged runtime into `$CODEX_HOME/harness-everything/compat-hooks/` and merges absolute-path entries into `$CODEX_HOME/hooks.json`. harness codex-hooks status reports `inactive` when the compatibility hooks are absent, or `configured-unverified` when their files are healthy; it cannot read Codex trust state. A successful Codex run of plugin-sync.js installs or refreshes this copy automatically. The installer does **not** modify `config.toml`, trust hashes, approval policy, or hook enablement. Codex must still show/review/trust the resulting non-managed hooks through its normal `/hooks` flow.
|
|
118
118
|
|
|
119
|
+
Updates retain at most two fingerprinted runtimes: the current runtime and the immediately previous distinct runtime. A third distinct install collects the oldest; sessions older than one update must restart. Reinstalling the same fingerprint preserves an intact previous runtime and drops a missing or modified one. `status` reports current and previous runtime file integrity separately and is unhealthy while a recorded previous runtime is missing or modified; this is filesystem health, not Codex hook trust or dispatch. Install, update, and uninstall hold an exclusive `compat-hooks.lock` directory for the whole manifest-read → runtime → hooks/manifest commit → collection transaction, so concurrent installers serialize instead of collecting each other's runtime. Each lock directory is created atomically together with its owner token. Acquisition and stale reclaim run under a short-lived `compat-hooks.lock.guard`, stale ownership is revalidated inside it, and a lock is removed only when it still carries the exact token judged stale; release likewise removes only the releasing holder's token. A lock or guard left by a dead local process is reclaimed, and a live lock times out (60 s) before any write. Uninstall removes all retained Harness-owned runtimes. Command paths remain fingerprinted: updates may require normal Codex hook review again; Harness does not bypass that security decision.
|
|
120
|
+
|
|
121
|
+
Installs of a plugin source tracked by a linked Git worktree refuse before writing. For an ordinary checkout, only modified or untracked files inside the plugin source subtree cause refusal. The runtime is built from the subtree's Git-tracked files, so `.gitignore`-excluded files (for example `.DS_Store` or `__pycache__`) are neither copied nor fingerprinted and do not block installation. The operator can explicitly pass `--allow-unreleased-source`. Packaged copies whose plugin manifest is not tracked by an enclosing repository remain supported, including copies inside an unrelated dirty repository. The mechanism and self-regression runners give child processes temporary HOME/USERPROFILE and CODEX_HOME directories. Tests must pass temporary Codex homes or mock the hook installer; mocking host commands alone does not isolate filesystem writes.
|
|
122
|
+
|
|
119
123
|
Ownership is deliberately strict: update/uninstall removes only exact entries recorded in the Harness compatibility manifest. If an installed Harness entry was edited after installation, the operation fails closed rather than overwriting or deleting that user-modified entry. Unrelated pre-existing and later-added user hooks are preserved.
|
|
120
124
|
|
|
121
125
|
The compatibility path registers hook commands in Codex user configuration and covers the missing plugin-mount route where user hooks dispatch. It does not repair a Codex carrier that fails to dispatch `PreToolUse` at all; those host-level execution-path limitations remain a live compatibility boundary.
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"globalScope": {"status": "Mechanism verified", "paths": {"skills": ["~/.agents/skills"]}},
|
|
115
115
|
"installUninstallSymmetry": {"status": "Mechanism verified", "notes": "General Harness installer ownership is mechanism-tested. The optional codex-hooks compatibility fallback separately has exact-entry update/uninstall ownership tests and preserves unrelated user hooks. Live evidence confirms trusted dispatch for the two observation handlers on one Windows Codex CLI host; native plugin mounting and other scopes remain unverified."},
|
|
116
116
|
"assetsReferences": {"status": "Mechanism verified", "notes": "Packaged skill references are checked and remain inside the package."},
|
|
117
|
-
"updateSync": {"status": "Mechanism verified", "notes": "scripts/plugin-sync.js detects installed state and uses marketplace upgrade for an installed Codex plugin; after successful install/update verification it refreshes the local codex-hooks compatibility runtime and hooks.json entries. Unsupported CLI options are dropped and retried, and a Codex build without codex plugin list/add is reported as an actionable capability-boundary skip. Live marketplace source reporting and plugin-sync refresh remain unverified; the separate compatibility fallback dispatch is live-verified as recorded in the evidence artifact."},
|
|
117
|
+
"updateSync": {"status": "Mechanism verified", "notes": "scripts/plugin-sync.js detects installed state and uses marketplace upgrade for an installed Codex plugin; after successful install/update verification it refreshes the local codex-hooks compatibility runtime and hooks.json entries, retaining current plus previous distinct runtime. Tracked linked-worktree sources or dirty plugin subtrees require explicit unreleased-source opt-in; untracked packaged sources remain supported even inside unrelated dirty repositories. Unsupported CLI options are dropped and retried, and a Codex build without codex plugin list/add is reported as an actionable capability-boundary skip. Live marketplace source reporting and plugin-sync refresh remain unverified; the separate compatibility fallback dispatch is live-verified as recorded in the evidence artifact."},
|
|
118
118
|
"liveHostVerification": {"status": "Partial", "evidence": "benchmarks/results/live-host/codex-2026-10-13/README.md", "notes": "The preserved artifact verifies two consecutive observation captures through the user-hook compatibility fallback on Codex CLI 0.162.1 for Windows, including current-turn labels. Native plugin mounting, marketplace source reporting, other lifecycle events, and other host/version combinations remain unverified."}
|
|
119
119
|
}
|
|
120
120
|
},
|
package/fable-mode/SKILL.md
CHANGED
|
@@ -2,37 +2,105 @@
|
|
|
2
2
|
|
|
3
3
|
```mermaid
|
|
4
4
|
flowchart TD
|
|
5
|
-
Start[Qualifying Tier 3 task] --> Discover[Discover
|
|
6
|
-
Discover -->
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
Review --> Done[Record result or
|
|
5
|
+
Start[Qualifying Tier 3 task] --> Discover[Discover runtime and authorized scope]
|
|
6
|
+
Discover --> Profile[Resolve behavior profile and runtime choice]
|
|
7
|
+
Profile --> Plan[Write dependency-aware stage map and checks]
|
|
8
|
+
Plan --> Register[Write workflow-stages.json in session state]
|
|
9
|
+
Register --> Execute[Start workflow-disposition and execute ready stages]
|
|
10
|
+
Execute --> Check[Run exact check and record correlated evidence]
|
|
11
|
+
Check --> Review[Cold-review when the selected plan requires it]
|
|
12
|
+
Review --> Done[Record result or blocker]
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
## Discovery and planning
|
|
16
16
|
|
|
17
|
-
Read
|
|
18
|
-
authorized write scope
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
Read relevant files and sources before producing an artifact. Record the
|
|
18
|
+
authorized write scope. Give each stage an objective, dependencies, write set,
|
|
19
|
+
expected output, exact check command, and pass condition. Required and optional
|
|
20
|
+
bindings belong on the stage that needs them. Replan counts are advisory; after
|
|
21
|
+
three same-signature failures, use Rule-of-3 `zoom-out`.
|
|
21
22
|
|
|
22
|
-
##
|
|
23
|
+
## Profiles and delegation
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
Resolve a requested behavior profile with Fable's profile selector and record
|
|
26
|
+
it separately from the runtime choice. `fable-orchestrator`,
|
|
27
|
+
`fable-worker-sonnet`, and `fable-worker-haiku` name coordination,
|
|
28
|
+
reasoning/synthesis, and mechanical-work roles. These are behavior-profile
|
|
29
|
+
aliases; they do not switch the runtime model. Preserve an explicit user or
|
|
30
|
+
host runtime choice. Workers do not spawn workers. If a named worker is
|
|
31
|
+
unavailable, use a declared inline fallback or report the gap.
|
|
29
32
|
|
|
30
|
-
##
|
|
33
|
+
## Active workflow
|
|
34
|
+
|
|
35
|
+
For an active mandatory workflow, use the router-displayed session path for
|
|
36
|
+
`workflow-stages.json` and the router-displayed `workflow-disposition.js`
|
|
37
|
+
controller. Do not register active stages in a repository-local contracts
|
|
38
|
+
directory. Every required stage needs `dependsOn`, `writeSet`, `checkCommand`,
|
|
39
|
+
and `passCondition`; an independent plan also needs a read-only `fable-verifier`
|
|
40
|
+
stage that depends on all implementation stages.
|
|
41
|
+
|
|
42
|
+
Start the selected run with the controller path and session id from the router:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
node "<workflow-disposition.js path from the router>" start --session-id "<session-id>"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The controller creates correlated run-scoped contracts. Load only bindings on
|
|
49
|
+
dependency-ready stages. Record each disposition with evidence:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
node "<workflow-disposition.js path from the router>" stage-binding \
|
|
53
|
+
--session-id "<session-id>" --stage-id "<stage-id>" \
|
|
54
|
+
--binding-id "<binding-id>" --disposition loaded \
|
|
55
|
+
--evidence "Read the resolved required reference for this stage."
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Run the exact `checkCommand`; the post-tool `contract-test.js` records the
|
|
59
|
+
session, run, stage, worker identity, numeric exit code, and check evidence. If
|
|
60
|
+
bindings are unresolved, resolve them and rerun the exact check before passing
|
|
61
|
+
the stage. Keep results under the run's `contracts/<stageId>.json` and
|
|
62
|
+
`evidence/<stageId>.json` records. Stage checks, required bindings, and selected
|
|
63
|
+
independent verification remain semantic MUSTs. The controller and lifecycle
|
|
64
|
+
hooks are reminder-only: unresolved evidence stays visible without locking
|
|
65
|
+
ordinary tools or Stop. See [`../CONTRACT-FORMAT.md`](../CONTRACT-FORMAT.md) for
|
|
66
|
+
the schema and correlation rules.
|
|
67
|
+
|
|
68
|
+
## Blocked recovery
|
|
31
69
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
70
|
+
If `start` is called while the workflow is running, it exits 2 with
|
|
71
|
+
`workflow already running; record a blocker before replanning`. From that
|
|
72
|
+
running state, record evidence with `block`:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
node "<workflow-disposition.js path from the router>" block \
|
|
76
|
+
--session-id "<session-id>" \
|
|
77
|
+
--evidence "The current stage map needs a bounded recovery update."
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Update the same session's `workflow-stages.json`, retaining the approved
|
|
81
|
+
requirements, workflow selection, dependencies, and required bindings. Then
|
|
82
|
+
start the revised stage map:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
node "<workflow-disposition.js path from the router>" start --session-id "<session-id>"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This starts a new run and clears the recorded block reason. If the session is
|
|
89
|
+
already `blocked`, update its stage map and run `start` directly. Do not call
|
|
90
|
+
`plan` from `blocked`: planning is accepted only while the workflow is pending
|
|
91
|
+
or active. When an explicitly authorized scope change requires new requirement
|
|
92
|
+
composition, route a new session instead of replacing approved requirements.
|
|
93
|
+
|
|
94
|
+
## Legacy/manual fallback
|
|
95
|
+
|
|
96
|
+
The path `.claude/harness-everything/state/contracts/<stage>.json` belongs only
|
|
97
|
+
to an explicitly selected legacy/manual workflow without an active mandatory
|
|
98
|
+
run. It does not register stages or correlated evidence for the current
|
|
99
|
+
workflow controller. Do not use it for an active Fable workflow.
|
|
100
|
+
|
|
101
|
+
## Verification and handoff
|
|
37
102
|
|
|
38
|
-
|
|
103
|
+
Run each named check and review scope-guard evidence before delivery. When the
|
|
104
|
+
selected plan requires independent verification, the verifier is read-only and
|
|
105
|
+
uses a distinct worker identity after all other stages pass. Record a blocker
|
|
106
|
+
or an evidence-backed result; do not discard unresolved requirements.
|