@haposoft/cafekit 0.18.2 → 0.18.4
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 +1 -1
- package/bin/__tests__/adv-skill.test.js +370 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- package/bin/__tests__/codex-hook-runtime-fixture.cjs +26 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +8 -11
- package/bin/__tests__/codex-hooks.test.js +87 -85
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +2 -4
- package/bin/__tests__/codex-native.test.js +136 -2298
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +2 -8
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/codex-skill-projections.test.js +101 -0
- package/bin/__tests__/develop-contract.test.js +100 -874
- package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +81 -5
- package/bin/__tests__/omp-hooks.test.js +24 -0
- package/bin/__tests__/orca-session.test.js +2 -16
- package/bin/__tests__/package-inventory.test.js +228 -2044
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +184 -0
- package/bin/__tests__/secret-scanner.test.js +73 -0
- package/bin/__tests__/skill-routing-source.test.js +6 -0
- package/bin/__tests__/toaa-skill.test.js +195 -0
- package/bin/__tests__/usage-hook-retired.test.js +24 -6
- package/bin/__tests__/validator-grounding.test.js +0 -198
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +548 -0
- package/bin/__tests__/website-docs.test.js +58 -0
- package/bin/install.js +99 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +12 -3
- package/bin/lib/context.js +5 -0
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +5 -3
- package/bin/phases/codex-hooks.js +38 -0
- package/bin/phases/codex-runtime.js +106 -74
- package/bin/phases/copy-payload.js +49 -1
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +1 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +7 -1
- package/src/claude/hooks/__tests__/privacy-block.test.js +33 -35
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +86 -48
- package/src/claude/hooks/__tests__/state.test.js +21 -0
- package/src/claude/hooks/agent.cjs +5 -2
- package/src/claude/hooks/inspect-block.cjs +3 -1
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
- package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
- package/src/claude/hooks/lib/spec-touch.cjs +14 -1
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/privacy-block.cjs +2 -1
- package/src/claude/hooks/rules.cjs +5 -2
- package/src/claude/hooks/session.cjs +4 -1
- package/src/claude/hooks/spec-state.cjs +5 -4
- package/src/claude/hooks/state.cjs +27 -1
- package/src/claude/migration-manifest.json +7 -1
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
- package/src/claude/scripts/provenance.cjs +40 -16
- package/src/claude/scripts/scan-staged-secrets.cjs +49 -3
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- package/src/claude/skills/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +11 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +4 -2
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/sync/SKILL.md +4 -0
- package/src/claude/skills/toaa/SKILL.md +82 -0
- package/src/claude/skills/toaa/references/autonomous-orchestration.md +54 -0
- package/src/claude/skills/toaa/references/prerequisites-checklist.md +37 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +1 -1
- package/src/codex/hooks/inspect-block.cjs +7 -4
- package/src/codex/hooks/lib/hook-context.cjs +119 -4
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-state.cjs +18 -1
- package/src/codex/hooks/lib/state-store.cjs +31 -6
- package/src/codex/hooks/precompact.cjs +9 -8
- package/src/codex/hooks/privacy-approval.cjs +2 -2
- package/src/codex/hooks/privacy-block.cjs +5 -4
- package/src/codex/hooks/rules.cjs +12 -6
- package/src/codex/hooks/session.cjs +22 -10
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
- package/src/omp/hooks/privacy-block.cjs +2 -1
- package/bin/__tests__/codex-research-projection.test.js +0 -70
- package/bin/__tests__/codex-specs-projection.test.js +0 -76
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -58
- package/src/claude/skills/chrome-devtools/scripts/__tests__/error-handling.test.js +0 -102
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, before, after } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { spawnSync } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
const REPO = path.resolve(__dirname, '../../../..');
|
|
11
|
+
const SRC = path.resolve(__dirname, '../../src');
|
|
12
|
+
const SCRIPT = path.resolve(__dirname, '../../scripts/build-codex-plugin.mjs');
|
|
13
|
+
let lib;
|
|
14
|
+
let claudeLib;
|
|
15
|
+
let work;
|
|
16
|
+
let tree;
|
|
17
|
+
|
|
18
|
+
function walk(root, base = root) {
|
|
19
|
+
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
|
20
|
+
const full = path.join(root, entry.name);
|
|
21
|
+
return entry.isDirectory() ? walk(full, base) : [path.relative(base, full)];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
before(async () => {
|
|
26
|
+
lib = await import(SCRIPT);
|
|
27
|
+
claudeLib = await import(path.resolve(__dirname, '../../scripts/build-claude-plugin.mjs'));
|
|
28
|
+
work = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-plugin-build-'));
|
|
29
|
+
tree = lib.build(path.join(work, 'cf-codex'));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
after(() => fs.rmSync(work, { recursive: true, force: true }));
|
|
33
|
+
|
|
34
|
+
test('inventory-and-policy: the Claude plugin skill list, Codex names, and explicit-only policies', () => {
|
|
35
|
+
const skills = fs.readdirSync(path.join(tree, 'skills')).sort();
|
|
36
|
+
// The Claude plugin's skills plus the Codex-only ones.
|
|
37
|
+
assert.deepEqual(skills, [...claudeLib.inScopeSkills(), ...lib.CODEX_ONLY_SKILLS].sort());
|
|
38
|
+
const { isExplicitOnlySkill, CODEX_POLICY_FILE, CODEX_EXPLICIT_ONLY_POLICY } = require('../phases/copy-payload');
|
|
39
|
+
for (const skill of skills) {
|
|
40
|
+
const text = fs.readFileSync(path.join(tree, 'skills', skill, 'SKILL.md'), 'utf8');
|
|
41
|
+
assert.match(text, new RegExp(`^---\\nname: cf-${skill}\\n`), `${skill} keeps the installer's Codex name`);
|
|
42
|
+
const policy = path.join(tree, 'skills', skill, CODEX_POLICY_FILE);
|
|
43
|
+
const skillSource = lib.CODEX_ONLY_SKILLS.includes(skill) ? path.join(SRC, 'codex/plugin/skills', skill) : path.join(SRC, 'claude/skills', skill);
|
|
44
|
+
if (isExplicitOnlySkill(skillSource)) {
|
|
45
|
+
assert.equal(fs.readFileSync(policy, 'utf8'), CODEX_EXPLICIT_ONLY_POLICY, `${skill} policy`);
|
|
46
|
+
} else {
|
|
47
|
+
assert.ok(!fs.existsSync(policy), `${skill} has an unexpected policy file`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('path-allowlist: Markdown reaches plugin files through {CAFEKIT_ROOT}, never an install path', () => {
|
|
53
|
+
const offenders = [];
|
|
54
|
+
for (const file of walk(tree).filter((f) => f.endsWith('.md'))) {
|
|
55
|
+
const text = fs.readFileSync(path.join(tree, file), 'utf8');
|
|
56
|
+
if (/\.codex[/\\](skills|references|scripts|rules|hooks|output-styles)\b/.test(text)) offenders.push(`${file}: .codex runtime path`);
|
|
57
|
+
if (/\.agents[/\\]skills\b/.test(text)) offenders.push(`${file}: .agents/skills path`);
|
|
58
|
+
if (/(?<!~\/)\.codex\/agents\//.test(text)) offenders.push(`${file}: project .codex/agents path`);
|
|
59
|
+
if (/\.claude[/\\](skills|references|scripts|rules)\b/.test(text)) offenders.push(`${file}: .claude runtime path`);
|
|
60
|
+
if (/\{CAFEKIT_ROOT\}\/scripts\//.test(text)) offenders.push(`${file}: script reached by path instead of {cafekit:<name>}`);
|
|
61
|
+
for (const [, rel] of text.matchAll(/\{CAFEKIT_ROOT\}\/([^\s`'")]+)/g)) {
|
|
62
|
+
const target = rel.replace(/[.,:;]+$/, '');
|
|
63
|
+
if (/[<*]/.test(target)) continue;
|
|
64
|
+
if (!fs.existsSync(path.join(tree, target))) offenders.push(`${file}: {CAFEKIT_ROOT}/${target} does not exist`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
assert.deepEqual(offenders, []);
|
|
68
|
+
const commands = lib.scriptCommands(tree);
|
|
69
|
+
assert.ok(commands.includes('provenance'), 'provenance is not in the command table');
|
|
70
|
+
for (const name of commands) assert.ok(fs.existsSync(path.join(tree, 'scripts', `${name}.cjs`)), `${name} has no script`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('manifest-and-marketplace: plugin.json and the repo marketplace carry the keys Codex reads', () => {
|
|
74
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(tree, '.codex-plugin/plugin.json'), 'utf8'));
|
|
75
|
+
assert.equal(manifest.name, 'cf');
|
|
76
|
+
assert.equal(manifest.version, require('../../package.json').version);
|
|
77
|
+
for (const key of ['description', 'author', 'license', 'interface']) assert.ok(manifest[key], `plugin.json lacks ${key}`);
|
|
78
|
+
const market = lib.marketplace();
|
|
79
|
+
assert.equal(typeof market.name, 'string');
|
|
80
|
+
const entry = market.plugins.find((p) => p.name === 'cf');
|
|
81
|
+
assert.deepEqual(entry.source, { source: 'local', path: './plugins/cf-codex' });
|
|
82
|
+
assert.ok(entry.policy?.installation && entry.policy?.authentication && entry.category, 'entry lacks policy or category');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('marketplace-not-ignored: git can commit .agents/plugins/marketplace.json', () => {
|
|
86
|
+
// --no-index: a tracked file would otherwise always read as not ignored.
|
|
87
|
+
const run = spawnSync('git', ['check-ignore', '-q', '--no-index', '.agents/plugins/marketplace.json'], { cwd: REPO });
|
|
88
|
+
assert.equal(run.status, 1, 'the marketplace file is ignored by git');
|
|
89
|
+
const skills = spawnSync('git', ['check-ignore', '-q', '--no-index', '.agents/skills/specs/SKILL.md'], { cwd: REPO });
|
|
90
|
+
assert.equal(skills.status, 0, 'npx-installed .agents/skills must stay ignored');
|
|
91
|
+
// A reinstall must not append `.agents/` after `!.agents/plugins/` and hide the marketplace again.
|
|
92
|
+
const { hasPattern } = require('../phases/root-config');
|
|
93
|
+
assert.ok(hasPattern(['.agents/*', '!.agents/plugins/'], '.agents/'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('claude-marketplace-guard: the Codex marketplace never points at the Claude plugin tree', () => {
|
|
97
|
+
const entry = lib.marketplace().plugins.find((p) => p.name === 'cf');
|
|
98
|
+
assert.notEqual(path.normalize(entry.source.path), path.normalize('./plugins/cf'));
|
|
99
|
+
assert.ok(!fs.existsSync(path.join(REPO, 'plugins/cf/.codex-plugin')), 'the Claude tree must not carry a Codex manifest');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('check-detects-drift: --check fails after one byte changes in a fresh copy', () => {
|
|
103
|
+
const copy = lib.build(path.join(work, 'copy'));
|
|
104
|
+
const clean = spawnSync('node', [SCRIPT, '--check', copy], { encoding: 'utf8' });
|
|
105
|
+
assert.equal(clean.status, 0, clean.stderr);
|
|
106
|
+
fs.appendFileSync(path.join(copy, 'skills/specs/SKILL.md'), 'x');
|
|
107
|
+
const drift = spawnSync('node', [SCRIPT, '--check', copy], { encoding: 'utf8' });
|
|
108
|
+
assert.equal(drift.status, 1);
|
|
109
|
+
assert.match(drift.stderr, /changed skills\/specs\/SKILL\.md/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── Runtime and hooks.json ─────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const RUNTIME_DIRS = ['hooks', 'rules', 'output-styles'];
|
|
115
|
+
|
|
116
|
+
function npxInstall() {
|
|
117
|
+
const repo = fs.mkdtempSync(path.join(work, 'npx-'));
|
|
118
|
+
spawnSync('git', ['init', '-q'], { cwd: repo });
|
|
119
|
+
const run = spawnSync('node', [path.resolve(__dirname, '../install.js'), '--platform', 'codex', '-y'], {
|
|
120
|
+
cwd: repo, encoding: 'utf8', env: { ...process.env, HOME: fs.mkdtempSync(path.join(work, 'home-')) },
|
|
121
|
+
});
|
|
122
|
+
assert.equal(run.status, 0, run.stderr || run.stdout);
|
|
123
|
+
return repo;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function runtimeFiles(root) {
|
|
127
|
+
return RUNTIME_DIRS.flatMap((dir) => (fs.existsSync(path.join(root, dir)) ? walk(path.join(root, dir)).map((f) => `${dir}/${f}`) : []))
|
|
128
|
+
.concat(fs.existsSync(path.join(root, 'runtime.schema.json')) ? ['runtime.schema.json'] : [])
|
|
129
|
+
.filter((f) => f !== 'hooks/hooks.json' && !f.includes('/.logs/'))
|
|
130
|
+
.sort();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test('runtime-matches-npx: the plugin runtime holds the same files and bytes as an npx Codex install', () => {
|
|
134
|
+
const installed = path.join(npxInstall(), '.codex');
|
|
135
|
+
const expected = runtimeFiles(installed);
|
|
136
|
+
assert.deepEqual(runtimeFiles(tree), expected);
|
|
137
|
+
for (const file of expected) {
|
|
138
|
+
assert.ok(fs.readFileSync(path.join(tree, file)).equals(fs.readFileSync(path.join(installed, file))), `${file} differs from the npx install`);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('requires-resolve: every relative require in the plugin hooks resolves inside the tree', () => {
|
|
143
|
+
const missing = [];
|
|
144
|
+
for (const file of walk(path.join(tree, 'hooks')).filter((f) => f.endsWith('.cjs'))) {
|
|
145
|
+
const full = path.join(tree, 'hooks', file);
|
|
146
|
+
for (const [, spec] of fs.readFileSync(full, 'utf8').matchAll(/require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g)) {
|
|
147
|
+
const target = path.resolve(path.dirname(full), spec);
|
|
148
|
+
if (![target, `${target}.cjs`, `${target}.js`].some((t) => fs.existsSync(t))) missing.push(`hooks/${file} → ${spec}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
assert.deepEqual(missing, []);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function pluginHandlers() {
|
|
155
|
+
const { hooks } = JSON.parse(fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8'));
|
|
156
|
+
return Object.entries(hooks).flatMap(([event, groups]) => groups.flatMap((group) => group.hooks.map((h) => ({ event, ...h }))));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test('hook-commands-fallback: every plugin hook has a POSIX command with a node PATH fallback and a Windows command', () => {
|
|
160
|
+
const template = JSON.parse(fs.readFileSync(path.join(SRC, 'codex/hooks.json'), 'utf8')).hooks;
|
|
161
|
+
const handlers = pluginHandlers();
|
|
162
|
+
// The template's runtime hooks plus the one plugin-only SessionStart hook.
|
|
163
|
+
const pluginOnly = handlers.filter((h) => h.command.includes('/plugin/hooks/'));
|
|
164
|
+
assert.deepEqual(pluginOnly.map((h) => h.event), ['SessionStart']);
|
|
165
|
+
assert.equal(handlers.length - pluginOnly.length, Object.values(template).flat().flatMap((g) => g.hooks).length);
|
|
166
|
+
for (const handler of handlers) {
|
|
167
|
+
const [, dir, script] = handler.command.match(/((?:plugin\/)?hooks)\/([a-z0-9-]+\.cjs)"$/) || [];
|
|
168
|
+
assert.ok(script && fs.existsSync(path.join(tree, dir, script)), `bad command ${handler.command}`);
|
|
169
|
+
assert.match(handler.command, /^PATH="\$PATH":'[^']+'(:'[^']+')* node "\$\{PLUGIN_ROOT\}\/(plugin\/)?hooks\/[a-z0-9-]+\.cjs"$/);
|
|
170
|
+
assert.equal(typeof handler.commandWindows, 'string');
|
|
171
|
+
assert.ok(handler.commandWindows.includes(script) && handler.commandWindows.includes('PLUGIN_ROOT'), handler.commandWindows);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('no-build-machine-paths: hooks.json names no absolute path of the machine that built it', () => {
|
|
176
|
+
const text = fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8');
|
|
177
|
+
for (const local of [REPO, os.homedir(), path.dirname(process.execPath)]) assert.ok(!text.includes(local), `hooks.json contains ${local}`);
|
|
178
|
+
const windows = pluginHandlers()[0].commandWindows;
|
|
179
|
+
const decoded = spawnSync('node', ['-e', `${windows.replace(/^node -e "|"$/g, '').replace(/require\('module'\)\.runMain\(\)/, 'console.log(process.argv[1])')}`], {
|
|
180
|
+
encoding: 'utf8', env: { ...process.env, PLUGIN_ROOT: path.join(work, 'win-root') },
|
|
181
|
+
});
|
|
182
|
+
assert.equal(decoded.stdout.trim(), path.join(work, 'win-root', 'hooks', pluginHandlers()[0].command.match(/hooks\/([a-z0-9-]+\.cjs)"$/)[1]));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('npx-hook-commands-unchanged: the npx install still gets absolute project launchers', () => {
|
|
186
|
+
const repo = npxInstall();
|
|
187
|
+
const { hooks } = JSON.parse(fs.readFileSync(path.join(repo, '.codex/hooks.json'), 'utf8'));
|
|
188
|
+
for (const handler of Object.values(hooks).flat().flatMap((g) => g.hooks)) {
|
|
189
|
+
assert.match(handler.command, /node '[^']+\/\.codex\/hooks\/[a-z0-9-]+\.cjs'$/);
|
|
190
|
+
assert.ok(handler.command.includes(fs.realpathSync(repo)));
|
|
191
|
+
assert.ok(!handler.command.includes('PLUGIN_ROOT'));
|
|
192
|
+
const encoded = handler.commandWindows.split(' ').pop();
|
|
193
|
+
assert.ok(Buffer.from(encoded, 'base64url').toString('utf8').startsWith(path.join(fs.realpathSync(repo), '.codex', 'hooks')), 'npx Windows launcher lost its project path');
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const SAMPLE_PAYLOADS = {
|
|
198
|
+
SessionStart: { source: 'startup' },
|
|
199
|
+
UserPromptSubmit: { prompt: 'hello' },
|
|
200
|
+
PreToolUse: { tool_name: 'Bash', tool_input: { command: 'ls' } },
|
|
201
|
+
PermissionRequest: { tool_name: 'Bash', tool_input: { command: 'ls' } },
|
|
202
|
+
PostToolUse: { tool_name: 'apply_patch', tool_input: { command: '' }, tool_response: {} },
|
|
203
|
+
Stop: { stop_hook_active: false },
|
|
204
|
+
SubagentStart: { agent_type: 'debugger' },
|
|
205
|
+
SubagentStop: { agent_type: 'debugger', stop_hook_active: false },
|
|
206
|
+
PreCompact: { trigger: 'manual' },
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
test('hook-smoke: every generated plugin launcher runs its hook to exit 0 without a crash log', () => {
|
|
210
|
+
// A private root holding the tree, so every crash log a hook writes lands under it.
|
|
211
|
+
const sandbox = fs.mkdtempSync(path.join(work, 'smoke-'));
|
|
212
|
+
const smokeTree = lib.build(path.join(sandbox, 'plugin', 'cf-codex'));
|
|
213
|
+
const repo = path.join(sandbox, 'repo');
|
|
214
|
+
fs.mkdirSync(repo);
|
|
215
|
+
spawnSync('git', ['init', '-q'], { cwd: repo });
|
|
216
|
+
const data = path.join(sandbox, 'data');
|
|
217
|
+
const failures = [];
|
|
218
|
+
for (const handler of pluginHandlers()) {
|
|
219
|
+
const payload = { session_id: 'codex-smoke', hook_event_name: handler.event, cwd: repo, ...(SAMPLE_PAYLOADS[handler.event] || {}) };
|
|
220
|
+
const run = spawnSync('sh', ['-c', handler.command], {
|
|
221
|
+
cwd: repo, input: JSON.stringify(payload), encoding: 'utf8', timeout: 20000,
|
|
222
|
+
env: { ...process.env, HOME: path.join(sandbox, 'home'), PLUGIN_ROOT: smokeTree, PLUGIN_DATA: data, CLAUDE_PLUGIN_ROOT: smokeTree, CLAUDE_PLUGIN_DATA: data },
|
|
223
|
+
});
|
|
224
|
+
if (run.status !== 0) failures.push(`${handler.event} ${handler.command}: exit ${run.status} ${run.stderr.slice(0, 200)}`);
|
|
225
|
+
}
|
|
226
|
+
const crashLogs = walk(sandbox).filter((f) => f.endsWith('hook-log.jsonl'))
|
|
227
|
+
.filter((f) => /"status":"crash"/.test(fs.readFileSync(path.join(sandbox, f), 'utf8')));
|
|
228
|
+
for (const log of crashLogs) failures.push(`crash logged in ${log}: ${fs.readFileSync(path.join(sandbox, log), 'utf8').slice(0, 200)}`);
|
|
229
|
+
assert.deepEqual(failures, []);
|
|
230
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The Codex plugin cannot ship AGENTS.md and Codex substitutes nothing in skill text, so
|
|
4
|
+
// a plugin-only SessionStart hook injects the CafeKit core and defines {CAFEKIT_ROOT}
|
|
5
|
+
// and the {cafekit:<name>} commands. The npx install must never receive that hook.
|
|
6
|
+
|
|
7
|
+
const { test, before, after } = require('node:test');
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { spawnSync } = require('node:child_process');
|
|
13
|
+
|
|
14
|
+
const SCRIPTS = path.resolve(__dirname, '../../scripts');
|
|
15
|
+
const INSTALL = path.resolve(__dirname, '../install.js');
|
|
16
|
+
const ROOT_LENGTH = 120;
|
|
17
|
+
let work;
|
|
18
|
+
let lib;
|
|
19
|
+
let tree;
|
|
20
|
+
|
|
21
|
+
before(async () => {
|
|
22
|
+
work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-context-')));
|
|
23
|
+
lib = await import(path.join(SCRIPTS, 'build-codex-plugin.mjs'));
|
|
24
|
+
// A plugin root of exactly ROOT_LENGTH characters, the size the core budget assumes.
|
|
25
|
+
const pad = ROOT_LENGTH - work.length - '/cf-codex'.length - 1;
|
|
26
|
+
assert.ok(pad > 0, `temp directory too long: ${work}`);
|
|
27
|
+
tree = lib.build(path.join(work, 'p'.repeat(pad), 'cf-codex'));
|
|
28
|
+
assert.equal(tree.length, ROOT_LENGTH);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
after(() => fs.rmSync(work, { recursive: true, force: true }));
|
|
32
|
+
|
|
33
|
+
function hook(root, { cwd = work, env = {} } = {}) {
|
|
34
|
+
const base = { ...process.env, HOME: path.join(work, 'home'), PLUGIN_DATA: path.join(work, 'data'), CLAUDE_PLUGIN_DATA: path.join(work, 'data') };
|
|
35
|
+
delete base.PLUGIN_ROOT; delete base.CLAUDE_PLUGIN_ROOT;
|
|
36
|
+
const fullEnv = { ...base, ...(root ? { PLUGIN_ROOT: root, CLAUDE_PLUGIN_ROOT: root } : {}), ...env };
|
|
37
|
+
const script = path.join(root || tree, 'plugin/hooks/plugin-context.cjs');
|
|
38
|
+
return spawnSync('node', [script], { cwd, encoding: 'utf8', env: fullEnv, input: JSON.stringify({ session_id: 'ctx', hook_event_name: 'SessionStart', source: 'startup', cwd }) });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function context(root = tree, options) {
|
|
42
|
+
const result = hook(root, options);
|
|
43
|
+
assert.equal(result.status, 0, result.stderr);
|
|
44
|
+
return JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test('emits-session-start-json: the hook prints the SessionStart JSON Codex reads, registered for every source', () => {
|
|
48
|
+
const result = hook(tree);
|
|
49
|
+
assert.equal(result.status, 0, result.stderr);
|
|
50
|
+
const output = JSON.parse(result.stdout);
|
|
51
|
+
assert.equal(output.hookSpecificOutput.hookEventName, 'SessionStart');
|
|
52
|
+
assert.equal(typeof output.hookSpecificOutput.additionalContext, 'string');
|
|
53
|
+
const { hooks } = JSON.parse(fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8'));
|
|
54
|
+
const group = hooks.SessionStart.find((g) => g.hooks.some((h) => h.command.includes('/plugin/hooks/plugin-context.cjs')));
|
|
55
|
+
assert.ok(group, 'plugin-context is not registered on SessionStart');
|
|
56
|
+
assert.deepEqual(group.matcher.split('|').sort(), ['clear', 'compact', 'resume', 'startup']);
|
|
57
|
+
assert.match(group.hooks[0].commandWindows, /'plugin','hooks','plugin-context\.cjs'/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('core-under-10000-chars: with a 120-character plugin root the injected text stays within the host cap', () => {
|
|
61
|
+
const text = context();
|
|
62
|
+
assert.ok(text.length <= 10000, `${text.length} characters`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('marker-in-first-2000: the core marker sits in the first 2000 characters', () => {
|
|
66
|
+
const index = context().indexOf('CAFEKIT-CODEX-CORE-LOADED');
|
|
67
|
+
assert.ok(index >= 0 && index < 2000, `marker at ${index}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('command-table-complete: every recorded script has one absolute command that runs', () => {
|
|
71
|
+
const text = context();
|
|
72
|
+
const names = lib.scriptCommands(tree);
|
|
73
|
+
assert.ok(names.length > 0);
|
|
74
|
+
const table = [...text.matchAll(/^ {2}- `\{cafekit:([A-Za-z0-9-]+)\}`: `(.+)`$/gm)];
|
|
75
|
+
assert.deepEqual(table.map(([, name]) => name), names);
|
|
76
|
+
assert.ok(!/\{cafekit:[A-Za-z0-9-]+\}/.test(text.replace(/^ {2}- `\{cafekit:[^\n]*$/gm, '')), 'a command is named outside the table');
|
|
77
|
+
const scratch = fs.mkdtempSync(path.join(work, 'run-'));
|
|
78
|
+
for (const [, name, command] of table) {
|
|
79
|
+
assert.match(command, new RegExp(`^node '${tree.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/scripts/${name}\\.cjs'$`));
|
|
80
|
+
const run = spawnSync('sh', ['-c', `${command} --help`], { cwd: scratch, encoding: 'utf8', input: '' });
|
|
81
|
+
assert.ok([0, 1, 2].includes(run.status), `${name}: exit ${run.status}`);
|
|
82
|
+
assert.doesNotMatch(run.stderr, /Cannot find module|MODULE_NOT_FOUND/, name);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('rule-paths-exist: the root, the rules directory and every rules file named in the core exist', () => {
|
|
87
|
+
const text = context();
|
|
88
|
+
assert.match(text, new RegExp(`means \`${tree.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\``));
|
|
89
|
+
const section = text.slice(text.indexOf('## Detailed rules'));
|
|
90
|
+
const dir = section.match(/`([^`]+\/rules)\/`/)[1];
|
|
91
|
+
assert.equal(dir, path.join(tree, 'rules'));
|
|
92
|
+
const files = [...section.matchAll(/`([a-z-]+\.md)`/g)].map(([, f]) => f);
|
|
93
|
+
assert.ok(files.length >= 9, files.join(', '));
|
|
94
|
+
for (const file of files) assert.ok(fs.existsSync(path.join(dir, file)), file);
|
|
95
|
+
assert.doesNotMatch(text, /\{CAFEKIT_ROOT\}\//, 'an unresolved root token remains');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('dollar-in-root: a plugin root containing `$&` is injected literally', () => {
|
|
99
|
+
const root = path.join(work, 'dollar-$&-root', 'cf-codex');
|
|
100
|
+
fs.cpSync(tree, root, { recursive: true });
|
|
101
|
+
const text = context(root);
|
|
102
|
+
assert.ok(text.includes(`means \`${root}\``));
|
|
103
|
+
assert.ok(text.includes(`node '${root}/scripts/provenance.cjs'`));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('silent-without-plugin-root: without a plugin root, or with one inherited from elsewhere, the hook prints nothing', () => {
|
|
107
|
+
for (const env of [{}, { PLUGIN_ROOT: work, CLAUDE_PLUGIN_ROOT: work }]) {
|
|
108
|
+
const result = hook(null, { env });
|
|
109
|
+
assert.equal(result.status, 0, result.stderr);
|
|
110
|
+
assert.equal(result.stdout, '');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function walk(root) {
|
|
115
|
+
if (!fs.existsSync(root)) return [];
|
|
116
|
+
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
|
117
|
+
const full = path.join(root, entry.name);
|
|
118
|
+
return entry.isDirectory() ? walk(full) : [full];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
test('npx-install-lacks-plugin-hook: an npx Codex install neither ships nor calls the plugin hook, and the plugin stands down beside it', () => {
|
|
123
|
+
const repo = path.join(work, 'npx-repo');
|
|
124
|
+
fs.mkdirSync(repo);
|
|
125
|
+
spawnSync('git', ['init', '-q'], { cwd: repo });
|
|
126
|
+
const install = spawnSync('node', [INSTALL, '--platform', 'codex', '-y'], { cwd: repo, encoding: 'utf8', env: { ...process.env, HOME: path.join(work, 'npx-home') } });
|
|
127
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
128
|
+
const installed = walk(repo).filter((f) => !f.includes(`${path.sep}.git${path.sep}`));
|
|
129
|
+
assert.deepEqual(installed.filter((f) => /plugin-context\.cjs$|script-commands\.json$/.test(f)), []);
|
|
130
|
+
assert.ok(!installed.some((f) => fs.readFileSync(f).includes('CAFEKIT-CODEX-CORE-LOADED')), 'the plugin core reached the npx install');
|
|
131
|
+
assert.doesNotMatch(fs.readFileSync(path.join(repo, '.codex/hooks.json'), 'utf8'), /plugin-context/);
|
|
132
|
+
const beside = hook(tree, { cwd: repo });
|
|
133
|
+
assert.equal(beside.status, 0, beside.stderr);
|
|
134
|
+
assert.equal(beside.stdout, '');
|
|
135
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, before, after } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const crypto = require('node:crypto');
|
|
9
|
+
const { spawnSync } = require('node:child_process');
|
|
10
|
+
|
|
11
|
+
const BUILD = path.resolve(__dirname, '../../scripts/build-codex-plugin.mjs');
|
|
12
|
+
const INSTALL = path.resolve(__dirname, '../install.js');
|
|
13
|
+
const UID = typeof process.getuid === 'function' ? process.getuid() : 'user';
|
|
14
|
+
let lib;
|
|
15
|
+
let work;
|
|
16
|
+
let tree;
|
|
17
|
+
let handlers;
|
|
18
|
+
|
|
19
|
+
function walk(root) {
|
|
20
|
+
if (!fs.existsSync(root)) return [];
|
|
21
|
+
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
|
22
|
+
const full = path.join(root, entry.name);
|
|
23
|
+
return entry.isDirectory() ? [`${full}/`, ...walk(full)] : [full];
|
|
24
|
+
}).sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function git(cwd, ...args) {
|
|
28
|
+
const run = spawnSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd, encoding: 'utf8' });
|
|
29
|
+
assert.equal(run.status, 0, run.stderr);
|
|
30
|
+
return run.stdout.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function repo(name, setup = () => {}) {
|
|
34
|
+
const dir = path.join(work, name);
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
git(dir, 'init', '-q');
|
|
37
|
+
fs.writeFileSync(path.join(dir, 'README.md'), '# demo\n');
|
|
38
|
+
fs.writeFileSync(path.join(dir, '.env'), 'EXAMPLE=placeholder\n');
|
|
39
|
+
setup(dir);
|
|
40
|
+
git(dir, 'add', '-A');
|
|
41
|
+
git(dir, 'commit', '-qm', 'fixture');
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function packet(dir, status) {
|
|
46
|
+
fs.mkdirSync(path.join(dir, 'specs/demo'), { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(dir, 'specs/demo/plan.md'), '# Demo\nSpecs-Contract: process-first-ready-v1\n\n## Tasks\n| # | Task | Status |\n|---|---|---|\n| 01 | Demo | pending |\n');
|
|
48
|
+
fs.writeFileSync(path.join(dir, 'specs/demo/task-01-demo.md'), `# Task 01 — Demo\n\nStatus: ${status}\n\n## Dependencies\n- none\n\n## Verification Plan\n- Command: \`test -f README.md\`\n\n## Receipt\n`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function handler(event, script) {
|
|
52
|
+
const found = handlers.find((h) => h.event === event && h.command.includes(`/hooks/${script}"`));
|
|
53
|
+
assert.ok(found, `no ${event} handler for ${script}`);
|
|
54
|
+
return found.command;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Runs one plugin hook through its generated launcher, the way Codex starts it.
|
|
58
|
+
function run(event, script, cwd, payload = {}, env = {}) {
|
|
59
|
+
const result = spawnSync('sh', ['-c', handler(event, script)], {
|
|
60
|
+
cwd,
|
|
61
|
+
input: JSON.stringify({ session_id: 'plugin-hooks-test', hook_event_name: event, cwd, ...payload }),
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
timeout: 20000,
|
|
64
|
+
env: { ...process.env, HOME: path.join(work, 'home'), PLUGIN_ROOT: tree, CLAUDE_PLUGIN_ROOT: tree, PLUGIN_DATA: path.join(work, 'data'), CLAUDE_PLUGIN_DATA: path.join(work, 'data'), ...env },
|
|
65
|
+
});
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ALL = [
|
|
70
|
+
['SessionStart', 'session.cjs', { source: 'startup' }],
|
|
71
|
+
['UserPromptSubmit', 'rules.cjs', { prompt: 'hello' }],
|
|
72
|
+
['PreToolUse', 'privacy-block.cjs', { tool_name: 'Bash', tool_input: { command: 'ls' } }],
|
|
73
|
+
['PreToolUse', 'inspect-block.cjs', { tool_name: 'Bash', tool_input: { command: 'ls' } }],
|
|
74
|
+
['PostToolUse', 'spec-state.cjs', { tool_name: 'apply_patch', tool_input: { command: '' }, tool_response: {} }],
|
|
75
|
+
['PreCompact', 'precompact.cjs', { trigger: 'manual' }],
|
|
76
|
+
['Stop', 'spec-gate.cjs', { stop_hook_active: false }],
|
|
77
|
+
['Stop', 'state.cjs', { stop_hook_active: false }],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
before(async () => {
|
|
81
|
+
lib = await import(BUILD);
|
|
82
|
+
work = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-plugin-hooks-'));
|
|
83
|
+
tree = lib.build(path.join(work, 'plugin', 'cf-codex'));
|
|
84
|
+
const { hooks } = JSON.parse(fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8'));
|
|
85
|
+
handlers = Object.entries(hooks).flatMap(([event, groups]) => groups.flatMap((g) => g.hooks.map((h) => ({ event, ...h }))));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
after(() => fs.rmSync(work, { recursive: true, force: true }));
|
|
89
|
+
|
|
90
|
+
test('gate-blocks-in-payload-repo: Stop blocks a done task without a receipt in the payload repository', () => {
|
|
91
|
+
const dir = repo('gate', (d) => packet(d, 'done'));
|
|
92
|
+
const result = run('Stop', 'spec-gate.cjs', dir, { stop_hook_active: false });
|
|
93
|
+
assert.equal(result.status, 0, result.stderr);
|
|
94
|
+
assert.match(result.stdout, /"decision":"block"/);
|
|
95
|
+
assert.match(result.stdout, /task-01-demo\.md/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('privacy-uses-payload-repo: reading the payload repository .env is denied and the approval is keyed to it', () => {
|
|
99
|
+
const dir = repo('privacy');
|
|
100
|
+
const data = path.join(work, 'privacy-data');
|
|
101
|
+
const result = run('PreToolUse', 'privacy-block.cjs', dir, { tool_name: 'Bash', tool_input: { command: 'cat .env' } }, { PLUGIN_DATA: data, CLAUDE_PLUGIN_DATA: data });
|
|
102
|
+
assert.match(result.stdout, /"permissionDecision":"deny"/);
|
|
103
|
+
const key = crypto.createHash('sha256').update(fs.realpathSync(dir)).digest('hex').slice(0, 16);
|
|
104
|
+
assert.ok(walk(path.join(data, 'projects', key, 'privacy')).some((p) => !p.endsWith('/')), 'no pending approval stored for this repository');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('nothing-written-outside-data: every hook leaves the repository, the plugin tree and its parent unchanged', () => {
|
|
108
|
+
const dir = repo('clean', (d) => fs.mkdirSync(path.join(d, '.codex')) || fs.writeFileSync(path.join(d, '.codex/runtime.json'), '{}\n'));
|
|
109
|
+
const data = path.join(work, 'clean-data');
|
|
110
|
+
// Git's own .git/ files (an index refresh, a lock left by a timed-out git call) are not
|
|
111
|
+
// CafeKit state, so the working tree is compared and git must report it clean.
|
|
112
|
+
const worktree = () => walk(dir).filter((p) => !p.startsWith(path.join(dir, '.git') + path.sep) && p !== `${path.join(dir, '.git')}/`);
|
|
113
|
+
const before = [worktree(), walk(path.join(work, 'plugin'))];
|
|
114
|
+
for (const [event, script, payload] of ALL) {
|
|
115
|
+
const result = run(event, script, dir, payload, { PLUGIN_DATA: data, CLAUDE_PLUGIN_DATA: data });
|
|
116
|
+
assert.equal(result.status, 0, `${script}: ${result.stderr}`);
|
|
117
|
+
}
|
|
118
|
+
assert.deepEqual([worktree(), walk(path.join(work, 'plugin'))], before);
|
|
119
|
+
assert.equal(git(dir, 'status', '--porcelain'), '', 'the hooks left changes in the repository');
|
|
120
|
+
assert.ok(walk(data).some((p) => !p.endsWith('/')), 'no state under PLUGIN_DATA');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('tmp-root-0700: without PLUGIN_DATA, state goes to a private per-user temp directory', () => {
|
|
124
|
+
const dir = repo('tmp');
|
|
125
|
+
const tmp = fs.mkdtempSync(path.join(work, 'tmp-'));
|
|
126
|
+
for (const [event, script, payload] of ALL) run(event, script, dir, payload, { PLUGIN_DATA: '', CLAUDE_PLUGIN_DATA: '', TMPDIR: tmp });
|
|
127
|
+
const root = path.join(tmp, `cafekit-codex-plugin-${UID}`);
|
|
128
|
+
assert.ok(fs.lstatSync(root).isDirectory());
|
|
129
|
+
assert.equal(fs.statSync(root).mode & 0o777, 0o700);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('foreign-tmp-fail-closed: a temp state root this user does not safely own makes privacy deny', () => {
|
|
133
|
+
const dir = repo('foreign');
|
|
134
|
+
const tmp = fs.mkdtempSync(path.join(work, 'tmp-foreign-'));
|
|
135
|
+
const elsewhere = fs.mkdtempSync(path.join(work, 'elsewhere-'));
|
|
136
|
+
fs.symlinkSync(elsewhere, path.join(tmp, `cafekit-codex-plugin-${UID}`));
|
|
137
|
+
const result = run('PreToolUse', 'privacy-block.cjs', dir, { tool_name: 'Bash', tool_input: { command: 'cat .env' } }, { PLUGIN_DATA: '', CLAUDE_PLUGIN_DATA: '', TMPDIR: tmp });
|
|
138
|
+
assert.match(result.stdout, /"permissionDecision":"deny"/);
|
|
139
|
+
assert.deepEqual(walk(elsewhere), [], 'state was written through the planted symlink');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('no-npx-notice: the plugin session hook prints no npx update notice and keeps the update cache', () => {
|
|
143
|
+
const dir = repo('notice', (d) => {
|
|
144
|
+
fs.mkdirSync(path.join(d, '.codex'));
|
|
145
|
+
fs.writeFileSync(path.join(d, '.codex/cafekit.json'), JSON.stringify({ version: '0.0.1' }));
|
|
146
|
+
});
|
|
147
|
+
const cache = path.join(dir, '.codex/.cafekit-update-cache.json');
|
|
148
|
+
const bytes = JSON.stringify({ ts: Date.now(), latest: '99.0.0' });
|
|
149
|
+
fs.writeFileSync(cache, bytes);
|
|
150
|
+
const result = run('SessionStart', 'session.cjs', dir, { source: 'startup' });
|
|
151
|
+
assert.equal(result.status, 0, result.stderr);
|
|
152
|
+
assert.doesNotMatch(result.stdout, /npx @haposoft\/cafekit/);
|
|
153
|
+
assert.equal(fs.readFileSync(cache, 'utf8'), bytes);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('defaults-without-runtime: without .codex/runtime.json the rules hook still injects the repository paths', () => {
|
|
157
|
+
const dir = repo('defaults');
|
|
158
|
+
const result = run('UserPromptSubmit', 'rules.cjs', dir, { prompt: 'hello', session_id: 'defaults-session' });
|
|
159
|
+
assert.equal(result.status, 0, result.stderr);
|
|
160
|
+
assert.ok(result.stdout.includes(path.join(fs.realpathSync(dir), 'specs')), result.stdout);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function npxRepo(name) {
|
|
164
|
+
const dir = repo(name);
|
|
165
|
+
const install = spawnSync('node', [INSTALL, '--platform', 'codex', '-y'], { cwd: dir, encoding: 'utf8', env: { ...process.env, HOME: path.join(work, `home-${name}`) } });
|
|
166
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
167
|
+
return dir;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test('silent-beside-npx: with the npx Codex hooks active in the repository, plugin hooks stand down and say so once', () => {
|
|
171
|
+
const dir = npxRepo('beside');
|
|
172
|
+
const session = run('SessionStart', 'session.cjs', dir, { source: 'startup' });
|
|
173
|
+
assert.equal(session.status, 0, session.stderr);
|
|
174
|
+
assert.match(session.stdout, /CafeKit plugin is idle/);
|
|
175
|
+
assert.match(session.stdout, /\$cf-/, 'the notice names the duplicate skills');
|
|
176
|
+
assert.match(session.stdout, /trusted in \/hooks/, 'the notice says the gates depend on trusting the npx hooks');
|
|
177
|
+
assert.ok(session.stdout.includes('npx @haposoft/cafekit@latest migrate'), 'the notice names the migrate command with @latest');
|
|
178
|
+
const privacy = run('PreToolUse', 'privacy-block.cjs', dir, { tool_name: 'Bash', tool_input: { command: 'cat .env' } });
|
|
179
|
+
assert.equal(privacy.status, 0);
|
|
180
|
+
assert.equal(privacy.stdout, '', 'the plugin hook acted beside the npx hooks');
|
|
181
|
+
const rules = run('UserPromptSubmit', 'rules.cjs', dir, { prompt: 'hi', session_id: 'beside-rules' });
|
|
182
|
+
assert.equal(rules.stdout, '');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('planted-hooks-json-keeps-gates: a hooks.json without real npx hooks behind it does not switch the plugin off', () => {
|
|
186
|
+
const cases = {
|
|
187
|
+
planted: { description: 'x /.codex/hooks/ x', hooks: {} },
|
|
188
|
+
foreignMachine: { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: "node '/Users/someone-else/project/.codex/hooks/privacy-block.cjs'" }] }] } },
|
|
189
|
+
relativeScript: { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: "node './.codex/hooks/noop.cjs'" }] }] } },
|
|
190
|
+
};
|
|
191
|
+
// Absolute paths that still reach the repository's own hooks only through `..` or a
|
|
192
|
+
// symlinked alias; on Linux /proc/self/cwd does the same through the hook's cwd.
|
|
193
|
+
const alias = path.join(work, 'planted-alias-link');
|
|
194
|
+
const command = (script) => ({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: `node '${script}'` }] }] } });
|
|
195
|
+
cases.dotDot = (d) => command(`${fs.realpathSync(d)}/.codex/hooks/../hooks/noop.cjs`);
|
|
196
|
+
cases.symlinkAlias = (d) => { fs.symlinkSync(fs.realpathSync(d), alias); return command(`${alias}/.codex/hooks/noop.cjs`); };
|
|
197
|
+
for (const [name, entry] of Object.entries(cases)) {
|
|
198
|
+
const dir = repo(`planted-${name}`, (d) => {
|
|
199
|
+
const config = typeof entry === 'function' ? entry(d) : entry;
|
|
200
|
+
fs.mkdirSync(path.join(d, '.codex/hooks'), { recursive: true });
|
|
201
|
+
fs.writeFileSync(path.join(d, '.codex/hooks/noop.cjs'), '\n');
|
|
202
|
+
fs.writeFileSync(path.join(d, '.codex/hooks.json'), JSON.stringify(config));
|
|
203
|
+
});
|
|
204
|
+
const result = run('PreToolUse', 'privacy-block.cjs', dir, { tool_name: 'Bash', tool_input: { command: 'cat .env' } });
|
|
205
|
+
assert.match(result.stdout, /"permissionDecision":"deny"/, `${name}: the plugin stood down`);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('npx-installed-tree: npx-installed hooks keep their behavior, even with a plugin root inherited from the parent', () => {
|
|
210
|
+
const dir = npxRepo('npx');
|
|
211
|
+
for (const inherited of [false, true]) {
|
|
212
|
+
const env = { ...process.env, HOME: path.join(work, 'home-npx') };
|
|
213
|
+
delete env.PLUGIN_ROOT; delete env.CLAUDE_PLUGIN_ROOT; delete env.PLUGIN_DATA; delete env.CLAUDE_PLUGIN_DATA;
|
|
214
|
+
if (inherited) Object.assign(env, { PLUGIN_ROOT: tree, CLAUDE_PLUGIN_ROOT: tree });
|
|
215
|
+
const payload = { session_id: `npx-${inherited}`, hook_event_name: 'PreToolUse', cwd: dir, tool_name: 'Bash', tool_input: { command: 'cat .env' } };
|
|
216
|
+
const result = spawnSync('node', [path.join(dir, '.codex/hooks/privacy-block.cjs')], { cwd: dir, input: JSON.stringify(payload), encoding: 'utf8', env });
|
|
217
|
+
assert.match(result.stdout, /"permissionDecision":"deny"/, `inherited=${inherited}`);
|
|
218
|
+
assert.ok(fs.existsSync(path.join(dir, '.codex/hooks/.privacy')), 'npx privacy state left its project location');
|
|
219
|
+
}
|
|
220
|
+
});
|
|
@@ -7,19 +7,13 @@ const os = require('node:os');
|
|
|
7
7
|
const path = require('node:path');
|
|
8
8
|
const { spawnSync } = require('node:child_process');
|
|
9
9
|
const { test } = require('node:test');
|
|
10
|
+
const { installCodexHookRuntime } = require('./codex-hook-runtime-fixture.cjs');
|
|
10
11
|
const SOURCE = path.resolve(__dirname, '../../src');
|
|
11
12
|
|
|
12
13
|
function fixture({ specs = 'specs', packets = ['auth-flow'] } = {}) {
|
|
13
14
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-quiet-'));
|
|
14
15
|
const hooks = path.join(root, '.codex/hooks');
|
|
15
|
-
|
|
16
|
-
for (const name of ['privacy-paths.cjs', 'privacy-command-analysis.cjs']) {
|
|
17
|
-
fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib', name), path.join(hooks, 'lib', name));
|
|
18
|
-
}
|
|
19
|
-
fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
|
|
20
|
-
fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
|
|
21
|
-
fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
|
|
22
|
-
fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/runtime-path-safety.cjs'), path.join(hooks, 'lib/runtime-path-safety.cjs'));
|
|
16
|
+
installCodexHookRuntime(root);
|
|
23
17
|
fs.cpSync(path.join(SOURCE, 'claude/scripts'), path.join(root, '.codex/scripts'), { recursive: true });
|
|
24
18
|
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs } }));
|
|
25
19
|
fs.writeFileSync(path.join(root, '.gitignore'), '.codex/\n');
|