@haposoft/cafekit 0.18.1 → 0.18.2
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/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +774 -6
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +192 -59
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -52
- package/bin/phases/copy-payload.js +1 -3
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +43 -59
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +8 -1
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -5,9 +5,84 @@ const crypto = require('node:crypto');
|
|
|
5
5
|
const fs = require('node:fs');
|
|
6
6
|
const os = require('node:os');
|
|
7
7
|
const path = require('node:path');
|
|
8
|
-
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { spawnSync, spawn } = require('node:child_process');
|
|
9
9
|
const { test } = require('node:test');
|
|
10
10
|
|
|
11
|
+
const OLD_INSPECTION_MATCHER = 'Bash|exec_command|apply_patch|Edit|Write|Read|Glob|Grep|mcp__.*|shell';
|
|
12
|
+
const NEW_INSPECTION_MATCHER = 'Bash|exec_command|shell|apply_patch|mcp__.*|collaborationspawn_agent|collaborationwait_agent';
|
|
13
|
+
function matcherFixture(run) {
|
|
14
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-matcher-'));
|
|
15
|
+
const previous = process.cwd();
|
|
16
|
+
const config = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../src/codex/hooks.json'), 'utf8'));
|
|
17
|
+
const messages = [], warnings = [];
|
|
18
|
+
const ctx = { dryRun: false, results: { updated: 0, unchanged: 0, errors: 0 },
|
|
19
|
+
manifest: require('../../src/claude/migration-manifest.json'),
|
|
20
|
+
ui: { detail: value => messages.push(value), warn: value => warnings.push(value) } };
|
|
21
|
+
const file = path.join(root, '.codex/hooks.json');
|
|
22
|
+
try {
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true }); process.chdir(root);
|
|
24
|
+
const merge = require('../phases/codex-hooks.js').mergeCodexHooks;
|
|
25
|
+
run({ root, config, ctx, file, messages, warnings, merge: () => merge(ctx, 'codex', root),
|
|
26
|
+
save: () => fs.writeFileSync(file, JSON.stringify(config)), read: () => JSON.parse(fs.readFileSync(file, 'utf8')) });
|
|
27
|
+
} finally { process.chdir(previous); fs.rmSync(root, { recursive: true, force: true }); }
|
|
28
|
+
}
|
|
29
|
+
test('upgrade refreshes a former managed inspection matcher and reports it', () => matcherFixture(f => {
|
|
30
|
+
f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER; f.save();
|
|
31
|
+
const before = fs.readFileSync(f.file, 'utf8');
|
|
32
|
+
f.ctx.dryRun = true; f.merge();
|
|
33
|
+
assert.equal(fs.readFileSync(f.file, 'utf8'), before);
|
|
34
|
+
assert.ok(f.messages.some(line => /↻.*matcher updated/.test(line)));
|
|
35
|
+
f.ctx.dryRun = false; f.messages.length = 0; f.merge();
|
|
36
|
+
assert.equal(f.read().hooks.PreToolUse[0].matcher, NEW_INSPECTION_MATCHER);
|
|
37
|
+
assert.ok(f.messages.some(line => /↻.*matcher updated/.test(line)));
|
|
38
|
+
assert.ok(!f.messages.some(line => /already registered/.test(line)));
|
|
39
|
+
}));
|
|
40
|
+
test('upgrade preserves a mixed user group and warns', () => matcherFixture(f => {
|
|
41
|
+
f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER;
|
|
42
|
+
f.config.hooks.PreToolUse[0].hooks.push({ type: 'command', command: 'echo user-owned' }); f.save(); f.merge();
|
|
43
|
+
assert.equal(f.read().hooks.PreToolUse[0].matcher, OLD_INSPECTION_MATCHER);
|
|
44
|
+
assert.ok(f.read().hooks.PreToolUse[0].hooks.some(h => h.command === 'echo user-owned'));
|
|
45
|
+
assert.ok(f.warnings.some(line => /matcher.*preserv/i.test(line)));
|
|
46
|
+
}));
|
|
47
|
+
test('reinstall keeps purely user groups unchanged without matcher warnings', () => matcherFixture(f => {
|
|
48
|
+
const userPre = { matcher: 'Bash', hooks: [{ command: 'my-linter' }] };
|
|
49
|
+
const userPost = { hooks: [{ command: 'my-logger' }] };
|
|
50
|
+
f.config.hooks = { PreToolUse: [userPre], PostToolUse: [userPost] }; f.save();
|
|
51
|
+
const installs = Array.from({ length: 2 }, () => {
|
|
52
|
+
const result = spawnSync(process.execPath, [path.resolve(__dirname, '../install.js'), '--platform', 'codex', '-y'], {
|
|
53
|
+
cwd: f.root, encoding: 'utf8',
|
|
54
|
+
});
|
|
55
|
+
return { result, config: f.read() };
|
|
56
|
+
});
|
|
57
|
+
for (const { result, config } of installs) {
|
|
58
|
+
assert.equal(result.status, 0, result.stderr);
|
|
59
|
+
for (const [event, original] of [['PreToolUse', userPre], ['PostToolUse', userPost]]) {
|
|
60
|
+
const group = config.hooks[event].find(g => g.hooks.some(h => h.command === original.hooks[0].command));
|
|
61
|
+
assert.equal(JSON.stringify(group), JSON.stringify(original));
|
|
62
|
+
}
|
|
63
|
+
assert.doesNotMatch(result.stdout + result.stderr, /matcher preserved/);
|
|
64
|
+
}
|
|
65
|
+
}));
|
|
66
|
+
test('fresh hook registration uses every template matcher', () => matcherFixture(f => {
|
|
67
|
+
f.merge();
|
|
68
|
+
for (const [event, groups] of Object.entries(f.config.hooks)) {
|
|
69
|
+
assert.deepEqual(f.read().hooks[event].map(g => g.matcher), groups.map(g => g.matcher));
|
|
70
|
+
}
|
|
71
|
+
}));
|
|
72
|
+
test('upgrade preserves a user-selected managed matcher and warns', () => matcherFixture(f => {
|
|
73
|
+
f.config.hooks.PreToolUse[0].matcher = 'Read|Grep'; f.save(); f.merge();
|
|
74
|
+
assert.equal(f.read().hooks.PreToolUse[0].matcher, 'Read|Grep');
|
|
75
|
+
assert.ok(f.warnings.some(line => /matcher.*preserv/i.test(line)));
|
|
76
|
+
}));
|
|
77
|
+
test('upgrade refreshes the surviving group after retired hooks are pruned', () => matcherFixture(f => {
|
|
78
|
+
f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER;
|
|
79
|
+
f.config.hooks.PreToolUse[0].hooks.push({ type: 'command', command: 'node ".codex/hooks/task-scaffold-guard.cjs"' });
|
|
80
|
+
f.save(); f.merge();
|
|
81
|
+
assert.equal(f.read().hooks.PreToolUse[0].matcher, NEW_INSPECTION_MATCHER);
|
|
82
|
+
assert.ok(!JSON.stringify(f.read()).includes('task-scaffold-guard'));
|
|
83
|
+
assert.ok(f.messages.some(line => /matcher updated/.test(line)));
|
|
84
|
+
}));
|
|
85
|
+
|
|
11
86
|
const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
12
87
|
const CODEX_HOOKS = path.join(PACKAGE_ROOT, 'src/codex/hooks');
|
|
13
88
|
const PROVENANCE = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'));
|
|
@@ -68,12 +143,24 @@ function writeProcessTask(featureDir, status, receiptLines = null, command = 'pn
|
|
|
68
143
|
function inHookFixture(run, options = {}) {
|
|
69
144
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
|
|
70
145
|
const hooks = path.join(root, '.codex', 'hooks');
|
|
146
|
+
let deferred = false;
|
|
71
147
|
try {
|
|
72
148
|
fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
|
|
149
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
|
|
150
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
|
|
151
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
|
|
152
|
+
fs.copyFileSync(
|
|
153
|
+
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/inspect-paths.cjs'),
|
|
154
|
+
path.join(hooks, 'lib/inspect-paths.cjs'),
|
|
155
|
+
);
|
|
73
156
|
fs.copyFileSync(
|
|
74
157
|
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-command-analysis.cjs'),
|
|
75
158
|
path.join(hooks, 'lib/privacy-command-analysis.cjs'),
|
|
76
159
|
);
|
|
160
|
+
fs.copyFileSync(
|
|
161
|
+
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-paths.cjs'),
|
|
162
|
+
path.join(hooks, 'lib/privacy-paths.cjs'),
|
|
163
|
+
);
|
|
77
164
|
fs.copyFileSync(
|
|
78
165
|
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
|
|
79
166
|
path.join(hooks, 'lib/runtime-path-safety.cjs'),
|
|
@@ -106,9 +193,14 @@ function inHookFixture(run, options = {}) {
|
|
|
106
193
|
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
107
194
|
assert.equal(result.status, 0, result.stderr);
|
|
108
195
|
}
|
|
109
|
-
|
|
196
|
+
const result = run(root, hooks);
|
|
197
|
+
if (result && typeof result.then === 'function') {
|
|
198
|
+
deferred = true;
|
|
199
|
+
return result.finally(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
110
202
|
} finally {
|
|
111
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
203
|
+
if (!deferred) fs.rmSync(root, { recursive: true, force: true });
|
|
112
204
|
}
|
|
113
205
|
}
|
|
114
206
|
|
|
@@ -120,6 +212,116 @@ function processTaskContent(title, status, dependency = 'none') {
|
|
|
120
212
|
].join('\n');
|
|
121
213
|
}
|
|
122
214
|
|
|
215
|
+
function inNoCommitCodexFixture(run) {
|
|
216
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-no-commit-'));
|
|
217
|
+
const hooks = path.join(root, '.codex/hooks');
|
|
218
|
+
try {
|
|
219
|
+
fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
|
|
220
|
+
for (const name of ['privacy-paths.cjs', 'privacy-command-analysis.cjs']) {
|
|
221
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib', name), path.join(hooks, 'lib', name));
|
|
222
|
+
}
|
|
223
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
|
|
224
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
|
|
225
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
|
|
226
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
|
|
227
|
+
path.join(hooks, 'lib/runtime-path-safety.cjs'));
|
|
228
|
+
const scripts = path.join(root, '.codex/scripts');
|
|
229
|
+
fs.mkdirSync(scripts, { recursive: true });
|
|
230
|
+
for (const name of ['workflow-policy.cjs', 'provenance.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs']) {
|
|
231
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts', name), path.join(scripts, name));
|
|
232
|
+
}
|
|
233
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test']]) {
|
|
234
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
235
|
+
assert.equal(result.status, 0, result.stderr);
|
|
236
|
+
}
|
|
237
|
+
const writePacket = (feature, status) => {
|
|
238
|
+
const dir = path.join(root, 'specs', feature);
|
|
239
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
240
|
+
fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
|
|
241
|
+
fs.writeFileSync(path.join(dir, 'task-01-work.md'), processTaskContent('Task 01: work', status)
|
|
242
|
+
+ (status === 'done' ? '\n## Receipt\n\nVerification: PASS\n' : ''));
|
|
243
|
+
};
|
|
244
|
+
const invoke = (hook, event) => runHook(path.join(hooks, `${hook}.cjs`), root, {
|
|
245
|
+
cwd: root, session_id: 'no-commit-codex', hook_event_name: event, prompt: 'Continue specs/auth',
|
|
246
|
+
});
|
|
247
|
+
run({ writePacket, gate: () => invoke('spec-gate', 'Stop'), state: () => invoke('spec-state', 'UserPromptSubmit') });
|
|
248
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
test('Codex collaborationspawn_agent appears as the latest tracked tool on resume', () => {
|
|
252
|
+
inHookFixture((root, hooks) => {
|
|
253
|
+
const state = path.join(hooks, 'state.cjs');
|
|
254
|
+
const session = { cwd: root, session_id: 'spawn-tracking' };
|
|
255
|
+
const tracked = runHook(state, root, {
|
|
256
|
+
...session, hook_event_name: 'PostToolUse', tool_name: 'collaborationspawn_agent',
|
|
257
|
+
tool_input: { message: 'Inspect the parser behavior' },
|
|
258
|
+
});
|
|
259
|
+
assert.equal(tracked.status, 0, tracked.stderr);
|
|
260
|
+
const resumed = runHook(state, root, { ...session, hook_event_name: 'SessionStart', source: 'resume' });
|
|
261
|
+
assert.equal(resumed.status, 0, resumed.stderr);
|
|
262
|
+
assert.match(resumed.stdout, /Latest Tracked Tool/);
|
|
263
|
+
assert.match(resumed.stdout, /collaborationspawn_agent/);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('Codex collaborationspawn_agent input does not create a pending todo', () => {
|
|
268
|
+
inHookFixture((root, hooks) => {
|
|
269
|
+
const state = path.join(hooks, 'state.cjs');
|
|
270
|
+
const session = { cwd: root, session_id: 'spawn-no-todo' };
|
|
271
|
+
const title = 'Inspect parser without creating a todo';
|
|
272
|
+
const tracked = runHook(state, root, {
|
|
273
|
+
...session, hook_event_name: 'PostToolUse', tool_name: 'collaborationspawn_agent',
|
|
274
|
+
tool_input: { id: 'spawn-child', title },
|
|
275
|
+
});
|
|
276
|
+
assert.equal(tracked.status, 0, tracked.stderr);
|
|
277
|
+
const resumed = runHook(state, root, { ...session, hook_event_name: 'SessionStart', source: 'resume' });
|
|
278
|
+
assert.equal(resumed.status, 0, resumed.stderr);
|
|
279
|
+
assert.match(resumed.stdout, /Latest Tracked Tool/);
|
|
280
|
+
assert.match(resumed.stdout, /collaborationspawn_agent/);
|
|
281
|
+
const pending = resumed.stdout.match(/## What's Left\n([\s\S]*?)(?=\n## |$)/);
|
|
282
|
+
assert.ok(pending, 'the pending task section must be present');
|
|
283
|
+
assert.ok(!pending[1].includes(title), 'spawn metadata must not become a pending task');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('Codex no commits allows Stop without done tasks and prints one reminder', () => {
|
|
288
|
+
inNoCommitCodexFixture(({ writePacket, gate, state }) => {
|
|
289
|
+
writePacket('auth', 'in_progress');
|
|
290
|
+
const stopped = gate();
|
|
291
|
+
assert.equal(stopped.status, 0, stopped.stderr);
|
|
292
|
+
assert.equal(stopped.stdout.trim(), '');
|
|
293
|
+
const prompted = state();
|
|
294
|
+
assert.equal(prompted.status, 0, prompted.stderr);
|
|
295
|
+
assert.match(prompted.stdout, /no commits/);
|
|
296
|
+
assert.equal(prompted.stdout.trim().split('\n').length, 1);
|
|
297
|
+
assert.doesNotMatch(prompted.stdout, /Spec tollgate unavailable/);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('Codex no commits blocks a done task with first commit guidance', () => {
|
|
302
|
+
inNoCommitCodexFixture(({ writePacket, gate }) => {
|
|
303
|
+
writePacket('auth', 'done');
|
|
304
|
+
const result = gate();
|
|
305
|
+
assert.equal(result.status, 0, result.stderr);
|
|
306
|
+
const body = JSON.parse(result.stdout);
|
|
307
|
+
assert.equal(body.decision, 'block');
|
|
308
|
+
assert.match(body.reason, /first commit/);
|
|
309
|
+
assert.doesNotMatch(body.reason, /controlled failure/);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('Codex no commits blocks two completed packets with first commit guidance', () => {
|
|
314
|
+
inNoCommitCodexFixture(({ writePacket, gate }) => {
|
|
315
|
+
for (const feature of ['auth', 'billing']) writePacket(feature, 'done');
|
|
316
|
+
const result = gate();
|
|
317
|
+
assert.equal(result.status, 0, result.stderr);
|
|
318
|
+
const body = JSON.parse(result.stdout);
|
|
319
|
+
assert.equal(body.decision, 'block');
|
|
320
|
+
assert.match(body.reason, /first commit/);
|
|
321
|
+
assert.doesNotMatch(body.reason, /controlled failure/);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
123
325
|
function canonicalProcessTask(root, title, dependency = 'none') {
|
|
124
326
|
const context = workflowRuntimeContext(root, 'auth', 'session-auth-state');
|
|
125
327
|
return [
|
|
@@ -300,6 +502,57 @@ test('Codex Stop still blocks a recorded missing packet and reads a plan.md + sp
|
|
|
300
502
|
});
|
|
301
503
|
});
|
|
302
504
|
|
|
505
|
+
test('Codex installed spec-state uses one snapshot for done task receipts', (t) => {
|
|
506
|
+
inInstalledProcessSpecStateFixture(({ feature, hooks, root, writeTask }) => {
|
|
507
|
+
const trace = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-snapshot-count-'));
|
|
508
|
+
try {
|
|
509
|
+
const tasks = ['task-01-work.md', 'task-02-work.md'];
|
|
510
|
+
for (const name of tasks) writeTask(name, name, 'done');
|
|
511
|
+
writeTask('task-03-work.md', 'Task 03: work', 'in_progress');
|
|
512
|
+
const scripts = path.join(root, '.codex/scripts');
|
|
513
|
+
const provenance = require(path.join(scripts, 'provenance.cjs'));
|
|
514
|
+
const policy = require(path.join(scripts, 'workflow-policy.cjs'));
|
|
515
|
+
const receipt = require(path.join(scripts, 'spec-receipt.cjs'));
|
|
516
|
+
const context = provenance.deriveRuntimeContext({ projectRoot: root,
|
|
517
|
+
specsRoot: path.join(root, 'specs'), specFile: path.join(feature, 'plan.md'),
|
|
518
|
+
featureName: 'auth', runtimeSession: 'one-snapshot' });
|
|
519
|
+
for (const name of tasks) {
|
|
520
|
+
fs.appendFileSync(path.join(feature, name), [
|
|
521
|
+
'\n## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
|
|
522
|
+
`Base: ${context.base}`, `Head: ${context.head}`, '```text', '1 test passed', '```', '',
|
|
523
|
+
].join('\n'));
|
|
524
|
+
assert.deepEqual(receipt.checkWorkflowTaskReceipt(feature, name, context, policy).failures, []);
|
|
525
|
+
}
|
|
526
|
+
const countFile = path.join(trace, 'git-status.txt');
|
|
527
|
+
const preload = path.join(trace, 'count.cjs');
|
|
528
|
+
fs.writeFileSync(preload, `
|
|
529
|
+
const cp = require('node:child_process');
|
|
530
|
+
const fs = require('node:fs');
|
|
531
|
+
const path = require('node:path');
|
|
532
|
+
for (const method of ['spawnSync', 'execFileSync']) {
|
|
533
|
+
const original = cp[method];
|
|
534
|
+
cp[method] = function(command, args, ...rest) {
|
|
535
|
+
if (path.basename(String(command)) === 'git' && Array.isArray(args) && args.includes('status'))
|
|
536
|
+
fs.appendFileSync(${JSON.stringify(countFile)}, 'status\\n');
|
|
537
|
+
return original.call(this, command, args, ...rest);
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
`);
|
|
541
|
+
const result = spawnSync(process.execPath, [path.join(hooks, 'spec-state.cjs')], {
|
|
542
|
+
cwd: root, encoding: 'utf8',
|
|
543
|
+
env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --require ${JSON.stringify(preload)}` },
|
|
544
|
+
input: JSON.stringify({ cwd: root, featureName: 'auth', hook_event_name: 'UserPromptSubmit',
|
|
545
|
+
session_id: 'one-snapshot', prompt: 'Continue specs/auth' }),
|
|
546
|
+
});
|
|
547
|
+
assert.equal(result.status, 0, result.stderr);
|
|
548
|
+
assert.match(result.stdout, /Spec state changed/);
|
|
549
|
+
const count = fs.readFileSync(countFile, 'utf8').trim().split('\n').length;
|
|
550
|
+
t.diagnostic(`Codex git status calls: ${count}`);
|
|
551
|
+
assert.ok(count > 0 && count <= 2, `git status calls: ${count}; expected at most 2`);
|
|
552
|
+
} finally { fs.rmSync(trace, { recursive: true, force: true }); }
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
303
556
|
test('Codex installed spec-state re-evaluates a standalone task when blocked becomes pending', () => {
|
|
304
557
|
inInstalledProcessSpecStateFixture(({ findCacheFile, hooks, runState, writeTask }) => {
|
|
305
558
|
writeTask('task-01-auth.md', 'Task 01: auth', 'blocked');
|
|
@@ -456,6 +709,503 @@ test('Codex resolver accepts unique legacy task numbers, rejects ambiguity, and
|
|
|
456
709
|
});
|
|
457
710
|
});
|
|
458
711
|
|
|
712
|
+
test('Codex inspect still denies blocked reads when the configured specs path escapes', () => {
|
|
713
|
+
inHookFixture((root, hooks) => {
|
|
714
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
|
|
715
|
+
const result = runHook(path.join(hooks, 'inspect-block.cjs'), root, {
|
|
716
|
+
cwd: root, hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: 'dist/index.html' },
|
|
717
|
+
});
|
|
718
|
+
assert.equal(result.status, 2);
|
|
719
|
+
assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test('Codex rules still inject when the configured specs path escapes', () => {
|
|
724
|
+
inHookFixture((root, hooks) => {
|
|
725
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
|
|
726
|
+
const result = runHook(path.join(hooks, 'rules.cjs'), root, {
|
|
727
|
+
cwd: root, hook_event_name: 'UserPromptSubmit', session_id: 'invalid-specs-rules',
|
|
728
|
+
});
|
|
729
|
+
assert.equal(result.status, 0);
|
|
730
|
+
assert.match(result.stdout, /## Rules/);
|
|
731
|
+
assert.ok(result.stdout.includes(`${fs.realpathSync(root)}/specs/`));
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test('Codex rules wait for runtime before reserving the same session', () => {
|
|
736
|
+
inHookFixture((root, hooks) => {
|
|
737
|
+
const file = path.join(root, '.codex/runtime.json');
|
|
738
|
+
const payload = { cwd: root, hook_event_name: 'UserPromptSubmit', session_id: 'waiting-for-runtime' };
|
|
739
|
+
const hook = path.join(hooks, 'rules.cjs');
|
|
740
|
+
assert.equal(fs.existsSync(file), false);
|
|
741
|
+
const absent = runHook(hook, root, payload);
|
|
742
|
+
assert.equal(absent.status, 0);
|
|
743
|
+
assert.equal(absent.stdout, '');
|
|
744
|
+
fs.writeFileSync(file, JSON.stringify({ paths: { specs: 'specs' } }));
|
|
745
|
+
const present = runHook(hook, root, payload);
|
|
746
|
+
assert.equal(present.status, 0);
|
|
747
|
+
assert.match(present.stdout, /## Rules/);
|
|
748
|
+
assert.equal(runHook(hook, root, payload).stdout, '');
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test('Codex secret guardrail runs from source with its shared keyword shim', () => {
|
|
753
|
+
const env = { ...process.env };
|
|
754
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
755
|
+
delete env.PROJECT_ROOT;
|
|
756
|
+
const result = spawnSync(process.execPath, [path.join(CODEX_HOOKS, 'secret-output-guardrail.cjs')], {
|
|
757
|
+
cwd: PACKAGE_ROOT, env, encoding: 'utf8', input: JSON.stringify({ prompt: 'Explain secret handling.' }),
|
|
758
|
+
});
|
|
759
|
+
assert.equal(result.status, 0);
|
|
760
|
+
assert.equal(result.stderr, '');
|
|
761
|
+
assert.match(result.stdout, /## Secret handling/);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('Codex secret guardrail fails open without a keyword library or a stack', () => {
|
|
765
|
+
inHookFixture((root, hooks) => {
|
|
766
|
+
fs.unlinkSync(path.join(hooks, 'lib/secret-keywords.cjs'));
|
|
767
|
+
const result = runHook(path.join(hooks, 'secret-output-guardrail.cjs'), root, { prompt: 'Explain secret handling.' });
|
|
768
|
+
assert.equal(result.status, 0);
|
|
769
|
+
assert.equal(result.stdout, '');
|
|
770
|
+
assert.equal(result.stderr, '');
|
|
771
|
+
const rows = fs.readFileSync(path.join(hooks, '.logs/hook-log.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
|
772
|
+
assert.equal(rows.length, 1);
|
|
773
|
+
assert.equal(rows[0].hook, 'secret-output-guardrail');
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
for (const invalidSpecs of [false, true]) {
|
|
778
|
+
test(`Codex compact warns about pending confirmations${invalidSpecs ? ' with an invalid specs path' : ''}`, () => {
|
|
779
|
+
inHookFixture((root, hooks) => {
|
|
780
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: invalidSpecs ? '../outside' : 'specs' } }));
|
|
781
|
+
const result = runHook(path.join(hooks, 'session.cjs'), root, {
|
|
782
|
+
cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session',
|
|
783
|
+
});
|
|
784
|
+
assert.equal(result.status, 0);
|
|
785
|
+
assert.match(result.stdout, /Pending.*(?:confirmation|approval)/i);
|
|
786
|
+
assert.match(result.stdout, /(?:request|ask).*?(?:fresh|again|user)/i);
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
test('Codex compact clears its pending privacy request even with an invalid specs path', () => {
|
|
792
|
+
inHookFixture((root, hooks) => {
|
|
793
|
+
const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
|
|
794
|
+
privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'compact-session', filePaths: ['.env'] });
|
|
795
|
+
const dir = path.join(hooks, '.privacy');
|
|
796
|
+
const pending = () => fs.readdirSync(dir).filter(name => name.startsWith('pending-'));
|
|
797
|
+
assert.equal(pending().length, 1);
|
|
798
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
|
|
799
|
+
const result = runHook(path.join(hooks, 'session.cjs'), root, {
|
|
800
|
+
cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session',
|
|
801
|
+
});
|
|
802
|
+
assert.equal(result.status, 0);
|
|
803
|
+
assert.equal(pending().length, 0);
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test('Codex compact releases only its own rules reservation', () => {
|
|
808
|
+
inHookFixture((root, hooks) => {
|
|
809
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), '{}');
|
|
810
|
+
const prompt = session_id => runHook(path.join(hooks, 'rules.cjs'), root, { cwd: root, hook_event_name: 'UserPromptSubmit', session_id });
|
|
811
|
+
for (const id of ['session-s', 'session-t']) {
|
|
812
|
+
assert.match(prompt(id).stdout, /## Rules/);
|
|
813
|
+
assert.equal(prompt(id).stdout, '');
|
|
814
|
+
}
|
|
815
|
+
const compact = runHook(path.join(hooks, 'session.cjs'), root, {
|
|
816
|
+
cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-s',
|
|
817
|
+
});
|
|
818
|
+
assert.equal(compact.status, 0);
|
|
819
|
+
assert.match(prompt('session-s').stdout, /## Rules/);
|
|
820
|
+
assert.equal(prompt('session-t').stdout, '');
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test('Codex precompact ignores empty stdin without recording a crash', () => {
|
|
825
|
+
inHookFixture((root, hooks) => {
|
|
826
|
+
const result = spawnSync(process.execPath, [path.join(hooks, 'precompact.cjs')], {
|
|
827
|
+
cwd: root, input: '', encoding: 'utf8',
|
|
828
|
+
});
|
|
829
|
+
assert.equal(result.status, 0);
|
|
830
|
+
assert.equal(result.stderr, '');
|
|
831
|
+
const file = path.join(hooks, '.logs/hook-log.jsonl');
|
|
832
|
+
const rows = fs.existsSync(file)
|
|
833
|
+
? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
|
|
834
|
+
: [];
|
|
835
|
+
assert.equal(rows.filter(row => row.hook === 'precompact' && row.status === 'crash').length, 0);
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test('Codex precompact anchors are restored only to the matching session', () => {
|
|
840
|
+
inHookFixture((root, hooks) => {
|
|
841
|
+
const head = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim();
|
|
842
|
+
const status = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
|
|
843
|
+
assert.equal(status.status, 0);
|
|
844
|
+
const dirty = status.stdout.trim().split('\n').filter(Boolean).length;
|
|
845
|
+
const capture = runHook(path.join(hooks, 'precompact.cjs'), root, {
|
|
846
|
+
cwd: root, hook_event_name: 'PreCompact', trigger: 'auto', session_id: 'session-s',
|
|
847
|
+
});
|
|
848
|
+
assert.equal(capture.status, 0, capture.stderr);
|
|
849
|
+
const restart = session_id => runHook(path.join(hooks, 'session.cjs'), root, {
|
|
850
|
+
cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id,
|
|
851
|
+
});
|
|
852
|
+
const own = restart('session-s');
|
|
853
|
+
assert.equal(own.status, 0);
|
|
854
|
+
assert.match(own.stdout, /State before compaction/);
|
|
855
|
+
assert.ok(own.stdout.includes(`HEAD ${head}`));
|
|
856
|
+
assert.ok(own.stdout.includes(`${dirty} uncommitted path(s)`));
|
|
857
|
+
const other = restart('session-t');
|
|
858
|
+
assert.equal(other.status, 0);
|
|
859
|
+
assert.match(other.stdout, /Pending/i);
|
|
860
|
+
assert.doesNotMatch(other.stdout, /State before compaction/);
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
function sessionWithoutUpdateNetwork(root, hooks, session_id) {
|
|
865
|
+
const marker = path.join(root, 'update-network-attempted');
|
|
866
|
+
const preload = path.join(root, 'block-update-network.cjs');
|
|
867
|
+
fs.writeFileSync(preload, `require('https').get = () => {
|
|
868
|
+
require('fs').writeFileSync(${JSON.stringify(marker)}, 'attempted');
|
|
869
|
+
throw new Error('network disabled in update test');
|
|
870
|
+
};\n`);
|
|
871
|
+
const result = spawnSync(process.execPath, ['--require', preload, path.join(hooks, 'session.cjs')], {
|
|
872
|
+
cwd: root, encoding: 'utf8', input: JSON.stringify({ cwd: root, hook_event_name: 'SessionStart', source: 'startup', session_id }),
|
|
873
|
+
});
|
|
874
|
+
assert.equal(fs.existsSync(marker), false, 'the cached update check must not attempt a network request');
|
|
875
|
+
return result;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
test('Codex SessionStart prints a newer cached CafeKit version', () => {
|
|
879
|
+
inHookFixture((root, hooks) => {
|
|
880
|
+
fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
|
|
881
|
+
fs.writeFileSync(path.join(root, '.codex/.cafekit-update-cache.json'), JSON.stringify({ ts: Date.now(), latest: '1.3.0' }));
|
|
882
|
+
const result = sessionWithoutUpdateNetwork(root, hooks, 'cached-update');
|
|
883
|
+
assert.equal(result.status, 0);
|
|
884
|
+
assert.ok(result.stdout.includes('\n⚡ CafeKit update available: 1.2.3 → 1.3.0\n Run: npx @haposoft/cafekit\n'));
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test('Codex SessionStart stays silent about an equal cached CafeKit version', () => {
|
|
889
|
+
inHookFixture((root, hooks) => {
|
|
890
|
+
fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
|
|
891
|
+
fs.writeFileSync(path.join(root, '.codex/.cafekit-update-cache.json'), JSON.stringify({ ts: Date.now(), latest: '1.2.3' }));
|
|
892
|
+
const result = sessionWithoutUpdateNetwork(root, hooks, 'equal-update');
|
|
893
|
+
assert.equal(result.status, 0);
|
|
894
|
+
assert.doesNotMatch(result.stdout, /CafeKit update available/);
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test('Codex update failure still clears pending privacy for the session', () => {
|
|
899
|
+
inHookFixture((root, hooks) => {
|
|
900
|
+
const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
|
|
901
|
+
privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'failed-update', filePaths: ['.env'] });
|
|
902
|
+
fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
|
|
903
|
+
fs.writeFileSync(path.join(hooks, 'lib/update-check.cjs'), "module.exports = { checkCafeKitUpdate() { throw new Error('synthetic update failure'); } };\n");
|
|
904
|
+
const result = sessionWithoutUpdateNetwork(root, hooks, 'failed-update');
|
|
905
|
+
assert.equal(result.status, 0);
|
|
906
|
+
assert.equal(result.stderr, '');
|
|
907
|
+
const pending = fs.readdirSync(path.join(hooks, '.privacy')).filter(name => name.startsWith('pending-'));
|
|
908
|
+
assert.equal(pending.length, 0);
|
|
909
|
+
});
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
test('Codex Stop describes an escaping specs root as an invalid workflow packet', () => {
|
|
913
|
+
inHookFixture((root, hooks) => {
|
|
914
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
|
|
915
|
+
const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
|
|
916
|
+
cwd: root, session_id: 'invalid-root-message', hook_event_name: 'Stop', stop_hook_active: false,
|
|
917
|
+
});
|
|
918
|
+
assert.equal(result.status, 0);
|
|
919
|
+
const output = JSON.parse(result.stdout);
|
|
920
|
+
assert.equal(output.decision, 'block');
|
|
921
|
+
assert.match(output.reason, /^Completion gate: invalid workflow packet detected \(<specs>\):/);
|
|
922
|
+
assert.doesNotMatch(output.reason, /controlled failure/);
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test('Codex spec-state shows detailed task status counts on its Phase line', () => {
|
|
927
|
+
inInstalledProcessSpecStateFixture(({ writeTask, runState }) => {
|
|
928
|
+
for (const [name, status] of [['task-01-active.md', 'in_progress'], ['task-02-next.md', 'pending'],
|
|
929
|
+
['task-03-blocked.md', 'blocked'], ['task-04-later.md', 'pending']]) {
|
|
930
|
+
writeTask(name, name, status);
|
|
931
|
+
}
|
|
932
|
+
const result = runState();
|
|
933
|
+
assert.equal(result.status, 0, result.stderr);
|
|
934
|
+
assert.match(result.stdout, /^- Phase: .* \| Tasks: 0 done \/ 4 total \(1 in_progress, 2 pending, 1 blocked\)$/m);
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
function shortPrivacyFixture(check) {
|
|
939
|
+
return inHookFixture((root, hooks) => {
|
|
940
|
+
fs.writeFileSync(path.join(root, '.env'), 'fixture\n');
|
|
941
|
+
fs.writeFileSync(path.join(root, 'credentials.json'), '{}\n');
|
|
942
|
+
const dir = path.join(hooks, '.privacy');
|
|
943
|
+
const session = 'short-approval-session';
|
|
944
|
+
const payload = (file = '.env', session_id = session) => ({ cwd: root, session_id,
|
|
945
|
+
hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: `cat ${file}` } });
|
|
946
|
+
const block = (file, id) => runHook(path.join(hooks, 'privacy-block.cjs'), root, payload(file, id));
|
|
947
|
+
const submit = (prompt, session_id = session, env = process.env) => spawnSync(process.execPath,
|
|
948
|
+
[path.join(hooks, 'privacy-approval.cjs')], { cwd: root, encoding: 'utf8', env,
|
|
949
|
+
input: JSON.stringify({ cwd: root, session_id, hook_event_name: 'UserPromptSubmit', prompt }) });
|
|
950
|
+
const files = prefix => fs.existsSync(dir) ? fs.readdirSync(dir).filter(name => name.startsWith(prefix)) : [];
|
|
951
|
+
const id = result => {
|
|
952
|
+
assert.equal(result.status, 0, result.stderr);
|
|
953
|
+
const reason = JSON.parse(result.stdout).hookSpecificOutput.permissionDecisionReason;
|
|
954
|
+
const match = reason.match(/APPROVE CAFEKIT PRIVACY ([a-f0-9]{24})/);
|
|
955
|
+
assert.ok(match, reason);
|
|
956
|
+
return match[1];
|
|
957
|
+
};
|
|
958
|
+
return check({ root, hooks, dir, session, payload, block, submit, files, id });
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function asyncPrivacyBlock(fixture, file) {
|
|
963
|
+
return new Promise((resolve, reject) => {
|
|
964
|
+
const child = spawn(process.execPath, [path.join(fixture.hooks, 'privacy-block.cjs')], { cwd: fixture.root });
|
|
965
|
+
let stdout = '', stderr = '';
|
|
966
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
967
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
968
|
+
child.on('error', reject);
|
|
969
|
+
child.on('close', status => resolve({ status, stdout, stderr }));
|
|
970
|
+
child.stdin.end(JSON.stringify(fixture.payload(file)));
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
for (const [label, prompt] of [['y', 'y'], ['yes', 'yes'], ['title-case Vietnamese', 'Có'],
|
|
975
|
+
['padded y', ' y '], ['decomposed Vietnamese', 'co\u0301'], ['uppercase Vietnamese', 'CÓ'], ['terminal newline', 'Y\r\n']]) {
|
|
976
|
+
test(`Codex short privacy approval accepts ${label} and reports granted paths`, () => shortPrivacyFixture(f => {
|
|
977
|
+
const denied = f.block();
|
|
978
|
+
const requestId = f.id(denied);
|
|
979
|
+
const sessionHash = crypto.createHash('sha256').update(f.session).digest('hex');
|
|
980
|
+
assert.deepEqual(f.files('short-'), [`short-${sessionHash}-${requestId}.json`]);
|
|
981
|
+
assert.match(denied.stdout, /y \(or yes \/ có\)/);
|
|
982
|
+
const result = f.submit(prompt);
|
|
983
|
+
assert.equal(result.status, 0, result.stderr);
|
|
984
|
+
assert.match(result.stdout, /User approved/);
|
|
985
|
+
assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
|
|
986
|
+
assert.equal(f.files('token-').length, 1);
|
|
987
|
+
assert.equal(f.files('short-').length, 0);
|
|
988
|
+
assert.equal(f.block().stdout, '');
|
|
989
|
+
assert.equal(f.block().stdout, '', 'the one-hour grant remains reusable');
|
|
990
|
+
}));
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
test('Codex short privacy approval is cancelled by an intervening prompt', () => shortPrivacyFixture(f => {
|
|
994
|
+
f.id(f.block());
|
|
995
|
+
assert.equal(f.submit('ok làm đi').status, 0);
|
|
996
|
+
const result = f.submit('y');
|
|
997
|
+
assert.equal(result.status, 0, result.stderr);
|
|
998
|
+
assert.doesNotMatch(result.stdout, /User approved/);
|
|
999
|
+
assert.equal(f.files('token-').length, 0);
|
|
1000
|
+
assert.equal(f.files('short-').length, 0);
|
|
1001
|
+
assert.match(f.block().stdout, /"permissionDecision":"deny"/);
|
|
1002
|
+
}));
|
|
1003
|
+
|
|
1004
|
+
test('Codex short privacy approval stays in its session and requires pending state', () => shortPrivacyFixture(f => {
|
|
1005
|
+
assert.equal(f.submit('y').status, 0);
|
|
1006
|
+
assert.equal(f.files('token-').length, 0);
|
|
1007
|
+
f.id(f.block());
|
|
1008
|
+
assert.doesNotMatch(f.submit('y', 'another-session').stdout, /User approved/);
|
|
1009
|
+
assert.equal(f.files('token-').length, 0);
|
|
1010
|
+
assert.match(f.submit('y').stdout, /User approved/);
|
|
1011
|
+
}));
|
|
1012
|
+
|
|
1013
|
+
for (const [label, prompt] of [['extra Vietnamese text', 'y nhé'], ['extra English text', 'yes please'],
|
|
1014
|
+
['repeated letter', 'yy'], ['fullwidth letter', 'y'], ['zero-width suffix', 'y\u200b']]) {
|
|
1015
|
+
test(`Codex short privacy approval rejects ${label}`, () => shortPrivacyFixture(f => {
|
|
1016
|
+
f.id(f.block());
|
|
1017
|
+
const result = f.submit(prompt);
|
|
1018
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1019
|
+
assert.doesNotMatch(result.stdout, /User approved/);
|
|
1020
|
+
assert.equal(f.files('token-').length, 0);
|
|
1021
|
+
assert.equal(f.files('short-').length, 0);
|
|
1022
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1023
|
+
}));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
test('Codex short privacy approval refuses two requests while the long phrase selects one', () => shortPrivacyFixture(f => {
|
|
1027
|
+
const first = f.id(f.block());
|
|
1028
|
+
f.id(f.block('credentials.json'));
|
|
1029
|
+
const result = f.submit('y');
|
|
1030
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1031
|
+
assert.doesNotMatch(result.stdout, /User approved/);
|
|
1032
|
+
assert.equal(f.files('token-').length, 0);
|
|
1033
|
+
assert.equal(f.files('short-').length, 0);
|
|
1034
|
+
const exact = f.submit(`APPROVE CAFEKIT PRIVACY ${first}`);
|
|
1035
|
+
assert.match(exact.stdout, /User approved/);
|
|
1036
|
+
assert.match(JSON.parse(exact.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
|
|
1037
|
+
assert.equal(f.block().stdout, '');
|
|
1038
|
+
assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
|
|
1039
|
+
}));
|
|
1040
|
+
|
|
1041
|
+
test('parallel Codex privacy blocks preserve both markers and cannot be approved by y', () => shortPrivacyFixture(async f => {
|
|
1042
|
+
const results = await Promise.all([asyncPrivacyBlock(f, '.env'), asyncPrivacyBlock(f, 'credentials.json')]);
|
|
1043
|
+
assert.notEqual(f.id(results[0]), f.id(results[1]));
|
|
1044
|
+
assert.equal(f.files('short-').length, 2);
|
|
1045
|
+
assert.equal(f.submit('y').status, 0);
|
|
1046
|
+
assert.equal(f.files('token-').length, 0);
|
|
1047
|
+
assert.equal(f.files('short-').length, 0);
|
|
1048
|
+
assert.match(f.block().stdout, /"permissionDecision":"deny"/);
|
|
1049
|
+
assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
|
|
1050
|
+
}));
|
|
1051
|
+
|
|
1052
|
+
test('repeated Codex blocks for the same path set reuse the request and short marker', () => shortPrivacyFixture(f => {
|
|
1053
|
+
const first = f.id(f.block('.env credentials.json'));
|
|
1054
|
+
assert.equal(f.id(f.block('credentials.json .env')), first);
|
|
1055
|
+
assert.equal(f.files('pending-').length, 1);
|
|
1056
|
+
assert.equal(f.files('short-').length, 1);
|
|
1057
|
+
assert.match(f.submit('y').stdout, /User approved/);
|
|
1058
|
+
assert.equal(f.block('.env credentials.json').stdout, '');
|
|
1059
|
+
assert.match(f.block().stdout, /"permissionDecision":"deny"/);
|
|
1060
|
+
}));
|
|
1061
|
+
|
|
1062
|
+
test('a later Codex block restores the short marker for the same pending request', () => shortPrivacyFixture(f => {
|
|
1063
|
+
const requestId = f.id(f.block());
|
|
1064
|
+
assert.equal(f.submit('ok làm đi').status, 0);
|
|
1065
|
+
assert.equal(f.files('short-').length, 0);
|
|
1066
|
+
assert.equal(f.id(f.block()), requestId);
|
|
1067
|
+
assert.equal(f.files('pending-').length, 1);
|
|
1068
|
+
assert.equal(f.files('short-').length, 1);
|
|
1069
|
+
assert.match(f.submit('y').stdout, /User approved/);
|
|
1070
|
+
assert.equal(f.block().stdout, '');
|
|
1071
|
+
assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
|
|
1072
|
+
}));
|
|
1073
|
+
|
|
1074
|
+
test('an immediate repeated Codex block keeps one short marker without an exclusive-write error', () => shortPrivacyFixture(f => {
|
|
1075
|
+
const requestId = f.id(f.block());
|
|
1076
|
+
assert.equal(f.id(f.block()), requestId);
|
|
1077
|
+
assert.equal(f.files('pending-').length, 1);
|
|
1078
|
+
assert.equal(f.files('short-').length, 1);
|
|
1079
|
+
assert.match(f.submit('y').stdout, /User approved/);
|
|
1080
|
+
assert.equal(f.block().stdout, '');
|
|
1081
|
+
}));
|
|
1082
|
+
|
|
1083
|
+
test('a Codex prompt error after claiming markers cancels the short approval', () => shortPrivacyFixture(f => {
|
|
1084
|
+
f.id(f.block());
|
|
1085
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-fault-'));
|
|
1086
|
+
try {
|
|
1087
|
+
const preload = path.join(outside, 'fault.cjs');
|
|
1088
|
+
fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.readdirSync; let calls = 0;\nfs.readdirSync = function(dir, ...args) { if (String(dir) === ${JSON.stringify(fs.realpathSync(f.dir))} && ++calls === 2) throw new Error('injected state read error'); return original.call(this, dir, ...args); };\n`);
|
|
1089
|
+
const failed = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
|
|
1090
|
+
assert.equal(failed.status, 0, failed.stderr);
|
|
1091
|
+
assert.match(failed.stdout, /failed safely/);
|
|
1092
|
+
assert.equal(f.files('short-').length, 0);
|
|
1093
|
+
assert.equal(f.files('token-').length, 0);
|
|
1094
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1095
|
+
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
|
|
1096
|
+
}));
|
|
1097
|
+
|
|
1098
|
+
test('a Codex post-claim error cancels a marker created during prompt processing', () => shortPrivacyFixture(f => {
|
|
1099
|
+
f.id(f.block());
|
|
1100
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-post-claim-fault-'));
|
|
1101
|
+
try {
|
|
1102
|
+
const preload = path.join(outside, 'fault.cjs');
|
|
1103
|
+
fs.writeFileSync(preload, `const assert = require('node:assert/strict');
|
|
1104
|
+
const state = require(${JSON.stringify(path.join(f.hooks, 'lib/privacy-state.cjs'))});
|
|
1105
|
+
const claim = state.claimShortMarkers;
|
|
1106
|
+
let claimed = false;
|
|
1107
|
+
state.claimShortMarkers = (...args) => {
|
|
1108
|
+
const result = claim(...args);
|
|
1109
|
+
if (!claimed) { assert.equal(result.ok, true); assert.equal(result.requestIds.length, 1); claimed = true; }
|
|
1110
|
+
return result;
|
|
1111
|
+
};
|
|
1112
|
+
state.cleanup = () => {
|
|
1113
|
+
assert.equal(claimed, true);
|
|
1114
|
+
state.createPending(${JSON.stringify({ projectRoot: f.root, sessionCwd: f.root, sessionId: f.session, filePath: 'credentials.json' })});
|
|
1115
|
+
throw new Error('injected processing error after marker claim');
|
|
1116
|
+
};\n`);
|
|
1117
|
+
const failed = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
|
|
1118
|
+
assert.equal(failed.status, 0, failed.stderr);
|
|
1119
|
+
assert.match(failed.stdout, /failed safely/);
|
|
1120
|
+
assert.equal(f.files('pending-').length, 2);
|
|
1121
|
+
assert.equal(f.files('short-').length, 0);
|
|
1122
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1123
|
+
assert.equal(f.files('token-').length, 0);
|
|
1124
|
+
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
|
|
1125
|
+
}));
|
|
1126
|
+
|
|
1127
|
+
test('a corrupt Codex short marker cannot grant permission even with valid pending state', () => shortPrivacyFixture(f => {
|
|
1128
|
+
f.id(f.block());
|
|
1129
|
+
const markers = f.files('short-');
|
|
1130
|
+
assert.equal(markers.length, 1);
|
|
1131
|
+
fs.writeFileSync(path.join(f.dir, markers[0]), '{');
|
|
1132
|
+
const result = f.submit('y');
|
|
1133
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1134
|
+
assert.doesNotMatch(result.stdout, /User approved/);
|
|
1135
|
+
assert.equal(f.files('token-').length, 0);
|
|
1136
|
+
assert.equal(f.files('short-').length, 0);
|
|
1137
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1138
|
+
}));
|
|
1139
|
+
|
|
1140
|
+
for (const unlinkFailure of [false, true]) {
|
|
1141
|
+
test(`an unclaimable Codex short marker is cancelled after ${unlinkFailure ? 'rename and unlink errors' : 'a rename error'}`, () => shortPrivacyFixture(f => {
|
|
1142
|
+
f.id(f.block());
|
|
1143
|
+
assert.equal(f.files('short-').length, 1);
|
|
1144
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-rename-fault-'));
|
|
1145
|
+
try {
|
|
1146
|
+
const preload = path.join(outside, 'fault.cjs');
|
|
1147
|
+
const prefix = JSON.stringify(path.join(fs.realpathSync(f.dir), 'short-'));
|
|
1148
|
+
fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.renameSync;\nfs.renameSync = function(from, to) { if (String(from).startsWith(${prefix})) throw new Error('injected rename failure'); return original.call(this, from, to); };\n`
|
|
1149
|
+
+ (unlinkFailure ? `const unlink = fs.unlinkSync; fs.unlinkSync = function(file) { if (String(file).startsWith(${prefix})) throw new Error('injected unlink failure'); return unlink.call(this, file); };\n` : ''));
|
|
1150
|
+
const result = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
|
|
1151
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1152
|
+
assert.doesNotMatch(result.stdout, /User approved/);
|
|
1153
|
+
assert.equal(f.files('token-').length, 0);
|
|
1154
|
+
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
|
|
1155
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1156
|
+
assert.equal(f.files('token-').length, 0);
|
|
1157
|
+
assert.equal(f.files('short-').length, 0);
|
|
1158
|
+
}));
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
test('Codex compact clears pending requests and short markers only for its session', () => shortPrivacyFixture(f => {
|
|
1162
|
+
f.id(f.block());
|
|
1163
|
+
f.id(f.block('credentials.json', 'other-session'));
|
|
1164
|
+
const result = runHook(path.join(f.hooks, 'session.cjs'), f.root,
|
|
1165
|
+
{ source: 'compact', session_id: f.session, cwd: f.root, hook_event_name: 'SessionStart' });
|
|
1166
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1167
|
+
const prefix = `short-${crypto.createHash('sha256').update(f.session).digest('hex')}-`;
|
|
1168
|
+
assert.equal(f.files(prefix).length, 0);
|
|
1169
|
+
assert.equal(f.files('short-').length, 1);
|
|
1170
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1171
|
+
assert.equal(f.files('token-').length, 0);
|
|
1172
|
+
assert.match(f.submit('y', 'other-session').stdout, /User approved/);
|
|
1173
|
+
}));
|
|
1174
|
+
|
|
1175
|
+
test('Codex short approval cannot grant an expired pending request', () => shortPrivacyFixture(f => {
|
|
1176
|
+
const id = f.id(f.block());
|
|
1177
|
+
const file = path.join(f.dir, `pending-${id}.json`);
|
|
1178
|
+
const pending = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1179
|
+
pending.expiresAt = Date.now() - 1;
|
|
1180
|
+
fs.writeFileSync(file, JSON.stringify(pending));
|
|
1181
|
+
assert.equal(f.submit('y').status, 0);
|
|
1182
|
+
assert.equal(f.files('token-').length, 0);
|
|
1183
|
+
assert.equal(f.files('short-').length, 0);
|
|
1184
|
+
}));
|
|
1185
|
+
|
|
1186
|
+
test('Codex cleanup removes expired short markers', () => shortPrivacyFixture(f => {
|
|
1187
|
+
f.id(f.block());
|
|
1188
|
+
const markers = f.files('short-');
|
|
1189
|
+
assert.equal(markers.length, 1);
|
|
1190
|
+
const file = path.join(f.dir, markers[0]);
|
|
1191
|
+
const marker = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1192
|
+
marker.expiresAt = Date.now() - 1;
|
|
1193
|
+
fs.writeFileSync(file, JSON.stringify(marker));
|
|
1194
|
+
require(path.join(f.hooks, 'lib/privacy-state.cjs')).cleanup(f.root);
|
|
1195
|
+
assert.equal(f.files('short-').length, 0);
|
|
1196
|
+
}));
|
|
1197
|
+
|
|
1198
|
+
test('Codex long privacy approval still works after an intervening prompt and reports paths', () => shortPrivacyFixture(f => {
|
|
1199
|
+
const id = f.id(f.block());
|
|
1200
|
+
assert.equal(f.submit('ok làm đi').status, 0);
|
|
1201
|
+
assert.equal(f.files('short-').length, 0);
|
|
1202
|
+
const result = f.submit(`APPROVE CAFEKIT PRIVACY ${id}`);
|
|
1203
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1204
|
+
assert.match(result.stdout, /User approved/);
|
|
1205
|
+
assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
|
|
1206
|
+
assert.equal(f.block().stdout, '');
|
|
1207
|
+
}));
|
|
1208
|
+
|
|
459
1209
|
test('Codex privacy approval is exact, session-bound, and reusable within its window', () => {
|
|
460
1210
|
inHookFixture((root, hooks) => {
|
|
461
1211
|
const nested = path.join(root, 'packages', 'app');
|
|
@@ -647,15 +1397,17 @@ test('Codex inspect hook scans native shell commands and structured paths', () =
|
|
|
647
1397
|
for (const command of [
|
|
648
1398
|
'rg secret node_modules',
|
|
649
1399
|
"sed -n '1,20p' dist/bundle.js",
|
|
650
|
-
"rg --glob '**/*' symbol
|
|
1400
|
+
"rg --glob '**/*' symbol ."
|
|
651
1401
|
]) {
|
|
652
1402
|
const result = runHook(inspect, root, payload(command));
|
|
653
1403
|
assert.equal(result.status, 2, command);
|
|
654
1404
|
assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
|
|
655
1405
|
}
|
|
656
1406
|
|
|
657
|
-
const
|
|
658
|
-
|
|
1407
|
+
for (const command of ['rg symbol src packages', "rg --glob '**/*' symbol src"]) {
|
|
1408
|
+
const normal = runHook(inspect, root, payload(command));
|
|
1409
|
+
assert.equal(normal.status, 0);
|
|
1410
|
+
}
|
|
659
1411
|
|
|
660
1412
|
const structured = runHook(inspect, root, {
|
|
661
1413
|
...payload(''),
|
|
@@ -929,6 +1681,22 @@ test('empty or forged Codex hook authority fails closed', () => {
|
|
|
929
1681
|
});
|
|
930
1682
|
});
|
|
931
1683
|
|
|
1684
|
+
test('Codex Stop omits secret-related assistant messages from saved state', () => {
|
|
1685
|
+
inHookFixture((root, hooks) => {
|
|
1686
|
+
const message = 'API key handling was discussed in this synthetic test.';
|
|
1687
|
+
const result = runHook(path.join(hooks, 'state.cjs'), root, {
|
|
1688
|
+
hook_event_name: 'Stop', session_id: 'secret-summary', last_assistant_message: message,
|
|
1689
|
+
});
|
|
1690
|
+
assert.equal(result.status, 0);
|
|
1691
|
+
const dir = codexStateDir(root, 'secret-summary');
|
|
1692
|
+
for (const name of ['data.json', 'latest.md']) {
|
|
1693
|
+
const content = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
1694
|
+
assert.ok(content.includes('[omitted: possible secret]'));
|
|
1695
|
+
assert.ok(!content.includes(message));
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
});
|
|
1699
|
+
|
|
932
1700
|
test('Codex state hook persists direct native event fields at project root', () => {
|
|
933
1701
|
inHookFixture((root, hooks) => {
|
|
934
1702
|
const nested = path.join(root, 'packages', 'app');
|