@haposoft/cafekit 0.16.0 → 0.16.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 +5 -3
- package/bin/__tests__/codex-hooks.test.js +125 -0
- package/bin/__tests__/codex-native.test.js +95 -0
- package/bin/__tests__/develop-contract.test.js +43 -1
- package/bin/__tests__/grok-envelope.test.js +271 -0
- package/bin/__tests__/grok-platform.test.js +133 -0
- package/bin/__tests__/hook-payload.test.js +194 -0
- package/bin/__tests__/omp-bridge.test.js +168 -0
- package/bin/__tests__/omp-hooks.test.js +178 -0
- package/bin/__tests__/omp-runtime.test.js +150 -0
- package/bin/__tests__/orca-session.test.js +225 -0
- package/bin/__tests__/orca-skill.test.js +210 -0
- package/bin/__tests__/package-inventory.test.js +1 -0
- package/bin/__tests__/runtime-dir.test.js +158 -0
- package/bin/__tests__/runtime-schema.test.js +5 -3
- package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
- package/bin/install.js +5 -5
- package/bin/lib/context.js +50 -3
- package/bin/lib/i18n.js +3 -30
- package/bin/phases/codex-hooks.js +90 -17
- package/bin/phases/copy-payload.js +7 -0
- package/bin/phases/omp-runtime.js +140 -0
- package/bin/phases/root-config.js +3 -0
- package/bin/phases/select-platform.js +22 -1
- package/bin/phases/summary.js +0 -4
- package/package.json +1 -1
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
- package/src/claude/hooks/__tests__/state.test.js +2 -0
- package/src/claude/hooks/agent.cjs +12 -7
- package/src/claude/hooks/completion-authority.cjs +10 -4
- package/src/claude/hooks/docs-sync.cjs +2 -1
- package/src/claude/hooks/inspect-block.cjs +27 -7
- package/src/claude/hooks/lib/config.cjs +5 -2
- package/src/claude/hooks/lib/counter.cjs +7 -5
- package/src/claude/hooks/lib/hook-payload.cjs +160 -0
- package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
- package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
- package/src/claude/hooks/precompact.cjs +2 -1
- package/src/claude/hooks/privacy-block.cjs +4 -2
- package/src/claude/hooks/rules.cjs +4 -2
- package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
- package/src/claude/hooks/semantic-review-authority.cjs +6 -4
- package/src/claude/hooks/session.cjs +16 -8
- package/src/claude/hooks/spec-gate.cjs +18 -8
- package/src/claude/hooks/spec-state.cjs +9 -5
- package/src/claude/hooks/state.cjs +6 -4
- package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
- package/src/claude/hooks/usage.cjs +5 -4
- package/src/claude/migration-manifest.json +3 -0
- package/src/claude/rules/skill-domain-routing.md +1 -0
- package/src/claude/rules/state-sync.md +7 -1
- package/src/claude/rules/workflow.md +2 -0
- package/src/claude/scripts/provenance.cjs +11 -4
- package/src/claude/scripts/spec-receipt.cjs +65 -4
- package/src/claude/skills/orca/SKILL.md +51 -0
- package/src/claude/skills/specs/SKILL.md +10 -11
- package/src/codex/hooks/session.cjs +6 -1
- package/src/codex/hooks/spec-state.cjs +1 -1
- package/src/codex/hooks.json +18 -18
- package/src/codex/rules/state-sync.md +7 -1
- package/src/omp/extensions/cafekit-bridge.mjs +233 -0
- package/src/omp/hooks/privacy-block.cjs +250 -0
- package/src/omp/runtime.json +33 -0
- package/bin/lib/rtk-run.js +0 -28
- package/bin/phases/setup-rtk.js +0 -159
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The gate scripts were authored against Claude Code's vocabulary. omp speaks a
|
|
4
|
+
// different one: its tool registry uses lowercase names, and its tool_call result has
|
|
5
|
+
// only { block, reason } — there is no ask state. `src/omp/hooks/` is an overlay holding
|
|
6
|
+
// only the files that carry those contract differences; the installer writes it over the
|
|
7
|
+
// portable Claude set. Each test states the failure it prevents, because an overlay that
|
|
8
|
+
// silently drifts back to the Claude contract disables a gate while looking installed.
|
|
9
|
+
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { spawnSync } = require('node:child_process');
|
|
15
|
+
const { test } = require('node:test');
|
|
16
|
+
|
|
17
|
+
const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
18
|
+
const OMP_HOOKS = path.join(PACKAGE_ROOT, 'src/omp/hooks');
|
|
19
|
+
const CLAUDE_HOOKS = path.join(PACKAGE_ROOT, 'src/claude/hooks');
|
|
20
|
+
const { normalizeToolName } = require(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/hook-payload.cjs'));
|
|
21
|
+
const MANIFEST = require(path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.json'));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The overlay is not runnable on its own: it requires the shared libraries the Claude set
|
|
25
|
+
* carries. Behaviour is therefore exercised on what an install produces — the manifest's
|
|
26
|
+
* hook set with the overlay written over it, under a dotted folder so runtime-dir derives
|
|
27
|
+
* `.omp` — while the source-shape checks below read `src/omp/hooks/` directly.
|
|
28
|
+
*/
|
|
29
|
+
const INSTALLED_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-installed-'));
|
|
30
|
+
const INSTALLED = path.join(INSTALLED_ROOT, '.omp', 'hooks');
|
|
31
|
+
for (const rel of MANIFEST.runtime.files.filter((f) => f.startsWith('hooks/'))) {
|
|
32
|
+
const target = path.join(INSTALLED_ROOT, '.omp', rel);
|
|
33
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
34
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude', rel), target);
|
|
35
|
+
}
|
|
36
|
+
fs.cpSync(OMP_HOOKS, INSTALLED, { recursive: true });
|
|
37
|
+
test.after(() => { fs.rmSync(INSTALLED_ROOT, { recursive: true, force: true }); });
|
|
38
|
+
|
|
39
|
+
function withSecretProject(run) {
|
|
40
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-hooks-'));
|
|
41
|
+
try {
|
|
42
|
+
fs.writeFileSync(path.join(root, '.env'), 'API_TOKEN=redacted\n');
|
|
43
|
+
return run(root);
|
|
44
|
+
} finally {
|
|
45
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Run a hook as a real child process and return its verdict, if any. */
|
|
50
|
+
function verdict(hookPath, payload) {
|
|
51
|
+
const result = spawnSync(process.execPath, [hookPath], {
|
|
52
|
+
input: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
});
|
|
55
|
+
const out = (result.stdout || '').trim();
|
|
56
|
+
let decision = null;
|
|
57
|
+
try {
|
|
58
|
+
decision = out ? JSON.parse(out)?.hookSpecificOutput?.permissionDecision ?? null : null;
|
|
59
|
+
} catch { decision = null; }
|
|
60
|
+
return { code: result.status, out, decision };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('omp tool names map to the Claude names the rules were written against', () => {
|
|
64
|
+
for (const [omp, claude] of [['bash', 'Bash'], ['read', 'Read'], ['edit', 'Edit'], ['write', 'Write'], ['grep', 'Grep']]) {
|
|
65
|
+
assert.equal(normalizeToolName(omp), claude, `${omp} must reach the ${claude} rule`);
|
|
66
|
+
}
|
|
67
|
+
// An unknown tool is left alone rather than renamed into a rule it does not belong to.
|
|
68
|
+
assert.equal(normalizeToolName('some_future_tool'), 'some_future_tool');
|
|
69
|
+
assert.equal(normalizeToolName('Bash'), 'Bash', 'a Claude name must survive unchanged');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a lowercase bash command reading a secret file is denied, where Claude asks', () => {
|
|
73
|
+
withSecretProject((root) => {
|
|
74
|
+
const payload = { tool_name: 'bash', tool_input: { command: `cat ${path.join(root, '.env')}` }, cwd: root };
|
|
75
|
+
|
|
76
|
+
// Both hooks now see the lowercase name, because the shared payload reader aliases it.
|
|
77
|
+
// What still differs is the answer: omp's tool_call result carries only block and
|
|
78
|
+
// reason, so an ask there would be dropped and the access allowed.
|
|
79
|
+
const claude = verdict(path.join(CLAUDE_HOOKS, 'privacy-block.cjs'), payload);
|
|
80
|
+
assert.equal(claude.decision, 'ask', 'the Claude hook now recognises the lowercase name and asks');
|
|
81
|
+
|
|
82
|
+
const omp = verdict(path.join(INSTALLED, 'privacy-block.cjs'), payload);
|
|
83
|
+
assert.equal(omp.decision, 'deny');
|
|
84
|
+
assert.match(omp.out, /\.env/, 'the denial must name what it protected');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a capitalised tool name keeps working, so the fork adds a case rather than swapping one', () => {
|
|
89
|
+
withSecretProject((root) => {
|
|
90
|
+
const omp = verdict(path.join(INSTALLED, 'privacy-block.cjs'),
|
|
91
|
+
{ tool_name: 'Bash', tool_input: { command: `cat ${path.join(root, '.env')}` }, cwd: root });
|
|
92
|
+
assert.equal(omp.decision, 'deny');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('the privacy hook denies where Claude asks, since omp tool_call has no ask state', () => {
|
|
97
|
+
const source = fs.readFileSync(path.join(OMP_HOOKS, 'privacy-block.cjs'), 'utf8');
|
|
98
|
+
assert.ok(!source.includes("permissionDecision: 'ask'"),
|
|
99
|
+
"omp's tool_call result carries only block and reason; an ask would be dropped and the access allowed");
|
|
100
|
+
assert.ok(source.includes("permissionDecision: 'deny'"));
|
|
101
|
+
|
|
102
|
+
withSecretProject((root) => {
|
|
103
|
+
const omp = verdict(path.join(INSTALLED, 'privacy-block.cjs'),
|
|
104
|
+
{ tool_name: 'read', tool_input: { file_path: path.join(root, '.env') }, cwd: root });
|
|
105
|
+
assert.equal(omp.decision, 'deny');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('an unevaluable access denies rather than allows', () => {
|
|
110
|
+
for (const malformed of ['not json', '', '{"tool_name":']) {
|
|
111
|
+
const omp = verdict(path.join(INSTALLED, 'privacy-block.cjs'), malformed);
|
|
112
|
+
assert.equal(omp.code, 0, 'the hook must not crash the turn');
|
|
113
|
+
if (malformed === 'not json' || malformed === '{"tool_name":') {
|
|
114
|
+
assert.equal(omp.decision, 'deny',
|
|
115
|
+
'a hook that cannot evaluate an access must not hand the model a secret-bearing path');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a write to a scaffolded task path is guarded under omp lowercase names too', () => {
|
|
121
|
+
// The guard is no longer an overlay file, so this is a behaviour case on the tree an
|
|
122
|
+
// install produces: omp sends `write`, and the shared reader has to make it reach the
|
|
123
|
+
// rule that was authored against Claude's `Write`.
|
|
124
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-guard-'));
|
|
125
|
+
try {
|
|
126
|
+
const target = path.join(root, 'specs', 'demo', 'tasks', 'task-R0-01-x.md');
|
|
127
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
128
|
+
fs.cpSync(path.join(PACKAGE_ROOT, 'src/claude/scripts'), path.join(INSTALLED_ROOT, '.omp', 'scripts'),
|
|
129
|
+
{ recursive: true, force: true });
|
|
130
|
+
const r = verdict(path.join(INSTALLED, 'task-scaffold-guard.cjs'),
|
|
131
|
+
{ tool_name: 'write', tool_input: { file_path: target }, cwd: root });
|
|
132
|
+
assert.equal(r.code, 2, "omp sends `write`; an exact 'Write' comparison would skip the guard entirely");
|
|
133
|
+
assert.equal(r.decision, 'deny');
|
|
134
|
+
} finally {
|
|
135
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** Every file under a directory, relative and sorted. */
|
|
140
|
+
function walk(dir, prefix = '') {
|
|
141
|
+
return fs.readdirSync(dir).flatMap((name) => {
|
|
142
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
143
|
+
return fs.statSync(path.join(dir, name)).isDirectory() ? walk(path.join(dir, name), rel) : [rel];
|
|
144
|
+
}).sort();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The contract edits, applied to the portable Claude bytes. This is the whole overlay. */
|
|
148
|
+
function expectedOverlay(name) {
|
|
149
|
+
let source = fs.readFileSync(path.join(CLAUDE_HOOKS, name), 'utf8');
|
|
150
|
+
const edit = (from, to) => {
|
|
151
|
+
assert.equal(source.split(from).length - 1, 1, `${name} must contain exactly one "${from.trim()}"`);
|
|
152
|
+
source = source.replace(from, to);
|
|
153
|
+
};
|
|
154
|
+
if (name === 'privacy-block.cjs') {
|
|
155
|
+
edit(" permissionDecision: 'ask',\n permissionDecisionReason: `Sensitive file access requires approval: ${basename}`\n",
|
|
156
|
+
" // omp has no ask state on tool_call, so an ask under Claude becomes a denial here.\n"
|
|
157
|
+
+ " permissionDecision: 'deny',\n permissionDecisionReason: `Sensitive file access requires approval: ${basename}`\n");
|
|
158
|
+
edit(" permissionDecision: 'ask',\n permissionDecisionReason: 'Sensitive access could not be evaluated safely.'\n",
|
|
159
|
+
" permissionDecision: 'deny',\n permissionDecisionReason: 'Sensitive access could not be evaluated safely.'\n");
|
|
160
|
+
}
|
|
161
|
+
return source;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
test('the fork differs from Claude only by its overlay', () => {
|
|
165
|
+
// A second file here would be a hook the installer stops taking from Claude, so a
|
|
166
|
+
// Claude fix would silently miss omp. A shared file that differs beyond its contract
|
|
167
|
+
// edits is the same drift in the other direction. Every lowercase tool name omp sends
|
|
168
|
+
// is handled by the shared payload reader, so only the ask-versus-deny difference —
|
|
169
|
+
// which is a contract, not a spelling — still needs a file of its own.
|
|
170
|
+
assert.deepEqual(walk(OMP_HOOKS), ['privacy-block.cjs']);
|
|
171
|
+
for (const name of ['privacy-block.cjs']) {
|
|
172
|
+
assert.equal(fs.readFileSync(path.join(OMP_HOOKS, name), 'utf8'), expectedOverlay(name),
|
|
173
|
+
`${name} must be the portable Claude file plus its omp contract edits, nothing else`);
|
|
174
|
+
}
|
|
175
|
+
const phase = fs.readFileSync(path.join(PACKAGE_ROOT, 'bin/phases/omp-runtime.js'), 'utf8');
|
|
176
|
+
assert.match(phase, /claudeHookFiles/, 'the phase must install the Claude set first');
|
|
177
|
+
assert.match(phase, /overlayFiles/, 'the phase must write the overlay over it');
|
|
178
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// omp discovers .claude/skills and .agents/skills on its own, so CafeKit ships it no
|
|
4
|
+
// skill payload. What omp lacks is the enforcement chain, so an omp install has to
|
|
5
|
+
// provision the hooks and the extension directory that carries it. These tests pin the
|
|
6
|
+
// install path only; gate behaviour belongs to the tasks that author the fork and bridge.
|
|
7
|
+
|
|
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
|
+
const { test } = require('node:test');
|
|
14
|
+
|
|
15
|
+
const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
16
|
+
const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
|
|
17
|
+
const { PLATFORMS } = require(path.join(PACKAGE_ROOT, 'bin/lib/context'));
|
|
18
|
+
|
|
19
|
+
function inTempProject(run) {
|
|
20
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-runtime-'));
|
|
21
|
+
try {
|
|
22
|
+
const init = spawnSync('git', ['-C', root, 'init', '-q'], { encoding: 'utf8' });
|
|
23
|
+
assert.equal(init.status, 0, init.stderr);
|
|
24
|
+
return run(root);
|
|
25
|
+
} finally {
|
|
26
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function install(root, platform, extra = []) {
|
|
31
|
+
return spawnSync(process.execPath, [INSTALLER, '--platform', platform, '--yes', ...extra],
|
|
32
|
+
{ cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('omp is a registered platform that ships no skill payload of its own', () => {
|
|
36
|
+
const omp = PLATFORMS.omp;
|
|
37
|
+
assert.ok(omp, 'omp must exist in the platform registry');
|
|
38
|
+
assert.equal(omp.folder, '.omp');
|
|
39
|
+
assert.equal(omp.capabilities.skills, false, 'omp reads .claude/skills itself; copying would duplicate');
|
|
40
|
+
assert.equal(omp.capabilities.agents, false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('an omp install creates hooks, an extensions directory, and platform metadata', () => {
|
|
44
|
+
inTempProject((root) => {
|
|
45
|
+
const result = install(root, 'omp');
|
|
46
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
47
|
+
|
|
48
|
+
const hooks = fs.readdirSync(path.join(root, '.omp', 'hooks')).filter((f) => f.endsWith('.cjs'));
|
|
49
|
+
assert.ok(hooks.length > 0, 'the gate scripts must be installed');
|
|
50
|
+
for (const required of ['spec-gate.cjs', 'privacy-block.cjs', 'rules.cjs']) {
|
|
51
|
+
assert.ok(hooks.includes(required), `missing ${required}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
assert.equal(fs.existsSync(path.join(root, '.omp', 'extensions')), true,
|
|
55
|
+
'omp auto-loads this directory; a half-provisioned install must be visibly empty, not absent');
|
|
56
|
+
|
|
57
|
+
const metadata = JSON.parse(fs.readFileSync(path.join(root, '.omp', 'cafekit.json'), 'utf8'));
|
|
58
|
+
assert.equal(metadata.platform, 'omp');
|
|
59
|
+
assert.ok(metadata.version, 'the install must record a version');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a non-omp install creates no .omp directory', () => {
|
|
64
|
+
inTempProject((root) => {
|
|
65
|
+
const result = install(root, 'claude');
|
|
66
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
67
|
+
assert.equal(fs.existsSync(path.join(root, '.omp')), false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('an omp install ignores its own auto-executed extension directory', () => {
|
|
72
|
+
inTempProject((root) => {
|
|
73
|
+
assert.equal(install(root, 'omp').status, 0);
|
|
74
|
+
const ignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8');
|
|
75
|
+
// A committed .omp/extensions/ would run arbitrary code on clone, before any gate
|
|
76
|
+
// can act, so the ignore rule is part of the install rather than user hygiene.
|
|
77
|
+
assert.match(ignore, /^\.omp\/$/m, '.omp/ must be ignored like .claude/ and .codex/');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('reinstalling omp is idempotent and preserves a user-added extension', () => {
|
|
82
|
+
inTempProject((root) => {
|
|
83
|
+
assert.equal(install(root, 'omp').status, 0);
|
|
84
|
+
const mine = path.join(root, '.omp', 'extensions', 'my-own.mjs');
|
|
85
|
+
fs.writeFileSync(mine, 'export default function () {}\n');
|
|
86
|
+
|
|
87
|
+
assert.equal(install(root, 'omp').status, 0);
|
|
88
|
+
assert.equal(fs.existsSync(mine), true, 'a reinstall erased an extension the user added');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('an omp install ships the scripts the Stop gate needs', () => {
|
|
93
|
+
inTempProject((root) => {
|
|
94
|
+
fs.mkdirSync(path.join(root, 'specs'), { recursive: true });
|
|
95
|
+
const result = install(root, 'omp');
|
|
96
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
97
|
+
|
|
98
|
+
// Every Stop-time gate requires ../scripts/*.cjs. Without these files the
|
|
99
|
+
// installed gate answers "Completion gate unavailable" and the bridge turns that
|
|
100
|
+
// into a block on every session_stop — the defect this case exists to pin.
|
|
101
|
+
for (const script of ['workflow-policy.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs', 'provenance.cjs']) {
|
|
102
|
+
assert.equal(fs.existsSync(path.join(root, '.omp', 'scripts', script)), true, `missing .omp/scripts/${script}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const gate = spawnSync(process.execPath, [path.join(root, '.omp', 'hooks', 'spec-gate.cjs')], {
|
|
106
|
+
cwd: root,
|
|
107
|
+
input: JSON.stringify({ session_id: 'gate-probe', cwd: root, hook_event_name: 'Stop', stop_hook_active: false }),
|
|
108
|
+
encoding: 'utf8',
|
|
109
|
+
});
|
|
110
|
+
assert.equal(gate.status, 0, gate.stderr);
|
|
111
|
+
assert.doesNotMatch(gate.stdout, /Completion gate unavailable/, 'the installed Stop gate must load its scripts');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('an omp-only install injects rules', () => {
|
|
116
|
+
inTempProject((root) => {
|
|
117
|
+
const result = install(root, 'omp');
|
|
118
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
119
|
+
assert.equal(fs.existsSync(path.join(root, '.claude')), false, 'this case is about a project with no .claude at all');
|
|
120
|
+
|
|
121
|
+
// The hooks read their configuration from the folder they live in, so omp needs its
|
|
122
|
+
// own runtime.json: no statusline (omp has none), usage off (that hook reads Claude
|
|
123
|
+
// Code's credential file), coding level 1 like Codex.
|
|
124
|
+
const runtime = JSON.parse(fs.readFileSync(path.join(root, '.omp', 'runtime.json'), 'utf8'));
|
|
125
|
+
assert.equal(runtime.codingLevel, 1);
|
|
126
|
+
assert.equal(runtime.usage.enabled, false);
|
|
127
|
+
assert.deepEqual(Object.keys(runtime).filter((k) => k.startsWith('statusline')), []);
|
|
128
|
+
assert.equal(runtime.$schema, './runtime.schema.json');
|
|
129
|
+
assert.equal(fs.existsSync(path.join(root, '.omp', 'runtime.schema.json')), true, 'the schema installs beside the file that references it');
|
|
130
|
+
|
|
131
|
+
// The Claude set arrives whole, the overlay on top, and nothing from the test tree.
|
|
132
|
+
const hooks = fs.readdirSync(path.join(root, '.omp', 'hooks'));
|
|
133
|
+
assert.equal(hooks.includes('__tests__'), false, 'the source tree holds tests that must never install');
|
|
134
|
+
for (const required of ['rules.cjs', 'spec-gate.cjs', 'session.cjs', 'lib/runtime-dir.cjs', 'lib/hook-payload.cjs']) {
|
|
135
|
+
assert.equal(fs.existsSync(path.join(root, '.omp', 'hooks', required)), true, `missing .omp/hooks/${required}`);
|
|
136
|
+
}
|
|
137
|
+
const privacy = fs.readFileSync(path.join(root, '.omp', 'hooks', 'privacy-block.cjs'), 'utf8');
|
|
138
|
+
assert.ok(privacy.includes("permissionDecision: 'deny'") && !privacy.includes("permissionDecision: 'ask'"),
|
|
139
|
+
'the installed privacy hook must be the omp overlay, not the Claude file');
|
|
140
|
+
|
|
141
|
+
const rules = spawnSync(process.execPath, [path.join(root, '.omp', 'hooks', 'rules.cjs')], {
|
|
142
|
+
cwd: root,
|
|
143
|
+
input: JSON.stringify({ session_id: `omp-rules-${Date.now()}`, cwd: root, prompt: 'hello' }),
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: '', PROJECT_ROOT: '' },
|
|
146
|
+
});
|
|
147
|
+
assert.equal(rules.status, 0, rules.stderr);
|
|
148
|
+
assert.match(rules.stdout, /## Rules/, 'without .omp/runtime.json the hook exits silently and omp never sees the rules');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// SessionStart is the one place a session can learn where it is before the user says a
|
|
4
|
+
// word. When ORCA_PANE_KEY is present the CafeKit session hook for Claude Code and for
|
|
5
|
+
// Codex now appends "Orca: pane" to its first line. The same environment carries two
|
|
6
|
+
// credentials, ORCA_AGENT_HOOK_TOKEN and ORCA_AGENT_LAUNCH_TOKEN; neither must ever reach
|
|
7
|
+
// stdout, stderr, or the env file the Claude hook writes. Every case here builds its own
|
|
8
|
+
// environment from a copy of process.env with every ORCA_* key removed first, because this
|
|
9
|
+
// suite itself commonly runs inside an Orca pane — spawning with the ambient environment
|
|
10
|
+
// unstripped would make the negative cases pass or fail by accident of where they run.
|
|
11
|
+
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { spawnSync } = require('node:child_process');
|
|
17
|
+
const { test } = require('node:test');
|
|
18
|
+
|
|
19
|
+
const PACKAGE_ROOT = path.join(__dirname, '..', '..');
|
|
20
|
+
const CLAUDE_HOOK = path.join(PACKAGE_ROOT, 'src/claude/hooks/session.cjs');
|
|
21
|
+
|
|
22
|
+
const HOOK_TOKEN_SENTINEL = 'sentinel-hook-token-2fc9a1';
|
|
23
|
+
const LAUNCH_TOKEN_SENTINEL = 'sentinel-launch-token-7be04d';
|
|
24
|
+
|
|
25
|
+
/** A copy of process.env with every ORCA_* key removed, then the case's own additions. */
|
|
26
|
+
function baseEnv(overrides = {}) {
|
|
27
|
+
const stripped = {};
|
|
28
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
29
|
+
if (!key.startsWith('ORCA_')) stripped[key] = value;
|
|
30
|
+
}
|
|
31
|
+
return { ...stripped, ...overrides };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function git(args, cwd) {
|
|
35
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
36
|
+
assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A git repo with one commit on a named branch, and no package.json: detectProjectType
|
|
40
|
+
* always resolves to at least "app", so this is the smallest fixture that still has a
|
|
41
|
+
* second, real fact (Branch) alongside the unavoidable Type. */
|
|
42
|
+
function withRepo(run) {
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-orca-session-'));
|
|
44
|
+
try {
|
|
45
|
+
git(['init', '-q'], dir);
|
|
46
|
+
git(['config', 'user.email', 'cafekit@example.invalid'], dir);
|
|
47
|
+
git(['config', 'user.name', 'CafeKit Test'], dir);
|
|
48
|
+
git(['commit', '--allow-empty', '-qm', 'fixture'], dir);
|
|
49
|
+
git(['checkout', '-q', '-b', 'orca-fixture'], dir);
|
|
50
|
+
return run(dir);
|
|
51
|
+
} finally {
|
|
52
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A plain directory: no git, no package.json. detectProjectType still returns "app", so
|
|
57
|
+
* this is the minimum a session can print — there is no directory shape that yields zero
|
|
58
|
+
* parts, on either hook, because "Type: app" is not conditional on anything being found. */
|
|
59
|
+
function withBareDir(run) {
|
|
60
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-orca-session-bare-'));
|
|
61
|
+
try {
|
|
62
|
+
return run(dir);
|
|
63
|
+
} finally {
|
|
64
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function runClaude(cwd, env, envFile) {
|
|
69
|
+
const result = spawnSync(process.execPath, [CLAUDE_HOOK], {
|
|
70
|
+
cwd,
|
|
71
|
+
input: JSON.stringify({ source: 'startup' }),
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
env: envFile ? { ...env, CLAUDE_ENV_FILE: envFile } : env,
|
|
74
|
+
});
|
|
75
|
+
assert.equal(result.status, 0, `hook must exit 0 (fail-open): ${result.stderr}`);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('the Claude session line ends with Orca: pane', () => {
|
|
80
|
+
withRepo((dir) => {
|
|
81
|
+
const without = runClaude(dir, baseEnv());
|
|
82
|
+
const withOrca = runClaude(dir, baseEnv({ ORCA_PANE_KEY: 'pane-abc' }));
|
|
83
|
+
assert.match(withOrca.stdout, /Orca: pane$/m, 'Orca: pane must be the last part on its line');
|
|
84
|
+
assert.equal(withOrca.stdout.trimEnd(), `${without.stdout.trimEnd()} | Orca: pane`,
|
|
85
|
+
'the line must be byte-equal to the no-variable line plus " | Orca: pane"');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('no Orca part outside a pane, byte for byte', () => {
|
|
90
|
+
withRepo((dir) => {
|
|
91
|
+
const first = runClaude(dir, baseEnv());
|
|
92
|
+
const second = runClaude(dir, baseEnv());
|
|
93
|
+
assert.doesNotMatch(first.stdout, /Orca/, 'without ORCA_PANE_KEY the line must never mention Orca');
|
|
94
|
+
assert.equal(first.stdout, second.stdout, 'the same fixture must print the same line every time');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('keying on ORCA_TERMINAL_HANDLE instead of ORCA_PANE_KEY must not add the marker', () => {
|
|
99
|
+
// The handle is a real, non-secret variable Orca also sets, but it is not the presence
|
|
100
|
+
// marker this packet keys on: a hook that checked it instead would fire outside a
|
|
101
|
+
// trusted pane state or miss a pane that only carries the key.
|
|
102
|
+
withRepo((dir) => {
|
|
103
|
+
const handleOnly = runClaude(dir, baseEnv({ ORCA_TERMINAL_HANDLE: 'term-xyz' }));
|
|
104
|
+
assert.doesNotMatch(handleOnly.stdout, /Orca/, 'ORCA_TERMINAL_HANDLE alone must not print the marker');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an empty project inside a pane still prints the marker', () => {
|
|
109
|
+
// No directory shape yields zero parts: detectProjectType always resolves to at least
|
|
110
|
+
// "app". This is the true minimum line, not the empty-parts case the plan first assumed.
|
|
111
|
+
withBareDir((dir) => {
|
|
112
|
+
const withOrca = runClaude(dir, baseEnv({ ORCA_PANE_KEY: 'pane-abc' }));
|
|
113
|
+
assert.equal(withOrca.stdout.trimEnd(), 'Session startup. Type: app | Orca: pane');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('no token ever reaches stdout, stderr, or the Claude env file', () => {
|
|
118
|
+
withRepo((dir) => {
|
|
119
|
+
const envFile = path.join(dir, 'orca-session-env.sh');
|
|
120
|
+
const result = runClaude(dir, baseEnv({
|
|
121
|
+
ORCA_PANE_KEY: 'pane-abc',
|
|
122
|
+
ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN_SENTINEL,
|
|
123
|
+
ORCA_AGENT_LAUNCH_TOKEN: LAUNCH_TOKEN_SENTINEL,
|
|
124
|
+
}), envFile);
|
|
125
|
+
assert.match(result.stdout, /Orca: pane$/m, 'the marker must still print alongside the tokens');
|
|
126
|
+
const envFileText = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf8') : '';
|
|
127
|
+
for (const sentinel of [HOOK_TOKEN_SENTINEL, LAUNCH_TOKEN_SENTINEL]) {
|
|
128
|
+
assert.doesNotMatch(result.stdout, new RegExp(sentinel), 'stdout must not carry the token');
|
|
129
|
+
assert.doesNotMatch(result.stderr, new RegExp(sentinel), 'stderr must not carry the token');
|
|
130
|
+
assert.doesNotMatch(envFileText, new RegExp(sentinel), 'the env file must not carry the token');
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------------------
|
|
136
|
+
// Codex: session.cjs resolves its own PROJECT_ROOT from lib/hook-context.cjs's __dirname,
|
|
137
|
+
// not from payload.cwd or process.cwd(), so testing it means composing the same relative
|
|
138
|
+
// layout an install produces rather than spawning the source file against an arbitrary
|
|
139
|
+
// directory. The Codex-side runtime-path-safety.cjs in the source tree is a thin re-export
|
|
140
|
+
// pointing at the Claude file three levels up; the real installer materializes the Claude
|
|
141
|
+
// file's content in its place (bin/phases/codex-runtime.js), so the composed tree does too.
|
|
142
|
+
// ---------------------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function composeCodexHookTree(root) {
|
|
145
|
+
const hooksDir = path.join(root, '.codex', 'hooks');
|
|
146
|
+
fs.mkdirSync(path.join(hooksDir, 'lib'), { recursive: true });
|
|
147
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks/session.cjs'), path.join(hooksDir, 'session.cjs'));
|
|
148
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks/completion-authority-state.cjs'),
|
|
149
|
+
path.join(hooksDir, 'completion-authority-state.cjs'));
|
|
150
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/hook-context.cjs'),
|
|
151
|
+
path.join(hooksDir, 'lib', 'hook-context.cjs'));
|
|
152
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/privacy-state.cjs'),
|
|
153
|
+
path.join(hooksDir, 'lib', 'privacy-state.cjs'));
|
|
154
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
|
|
155
|
+
path.join(hooksDir, 'lib', 'runtime-path-safety.cjs'));
|
|
156
|
+
return path.join(hooksDir, 'session.cjs');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function withCodexProject(run) {
|
|
160
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-orca-codex-session-'));
|
|
161
|
+
try {
|
|
162
|
+
const hook = composeCodexHookTree(dir);
|
|
163
|
+
return run(dir, hook);
|
|
164
|
+
} finally {
|
|
165
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function runCodex(hook, cwd, env) {
|
|
170
|
+
const result = spawnSync(process.execPath, [hook], {
|
|
171
|
+
cwd,
|
|
172
|
+
input: JSON.stringify({ source: 'startup', cwd, session_id: 'orca-session-test' }),
|
|
173
|
+
encoding: 'utf8',
|
|
174
|
+
env,
|
|
175
|
+
});
|
|
176
|
+
assert.equal(result.status, 0, `hook must exit 0 (fail-open): ${result.stderr}`);
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
test('the Codex session line ends with Orca: pane', () => {
|
|
181
|
+
withCodexProject((dir, hook) => {
|
|
182
|
+
git(['init', '-q'], dir);
|
|
183
|
+
git(['config', 'user.email', 'cafekit@example.invalid'], dir);
|
|
184
|
+
git(['config', 'user.name', 'CafeKit Test'], dir);
|
|
185
|
+
git(['commit', '--allow-empty', '-qm', 'fixture'], dir);
|
|
186
|
+
git(['checkout', '-q', '-b', 'orca-fixture'], dir);
|
|
187
|
+
|
|
188
|
+
const without = runCodex(hook, dir, baseEnv());
|
|
189
|
+
const withOrca = runCodex(hook, dir, baseEnv({ ORCA_PANE_KEY: 'pane-abc' }));
|
|
190
|
+
assert.match(withOrca.stdout, /Orca: pane$/m);
|
|
191
|
+
assert.equal(withOrca.stdout.split('\n')[0], `${without.stdout.split('\n')[0]} | Orca: pane`,
|
|
192
|
+
'the summary line must be byte-equal to the no-variable line plus " | Orca: pane"');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('no Orca part outside a pane on Codex, byte for byte', () => {
|
|
197
|
+
withCodexProject((dir, hook) => {
|
|
198
|
+
const first = runCodex(hook, dir, baseEnv());
|
|
199
|
+
const second = runCodex(hook, dir, baseEnv());
|
|
200
|
+
assert.doesNotMatch(first.stdout.split('\n')[0], /Orca/);
|
|
201
|
+
assert.equal(first.stdout, second.stdout);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('an empty Codex project inside a pane still prints the marker', () => {
|
|
206
|
+
withCodexProject((dir, hook) => {
|
|
207
|
+
const result = runCodex(hook, dir, baseEnv({ ORCA_PANE_KEY: 'pane-abc' }));
|
|
208
|
+
assert.equal(result.stdout.split('\n')[0], 'Session startup. Type: app | Orca: pane');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('no token ever reaches stdout or stderr on Codex', () => {
|
|
213
|
+
withCodexProject((dir, hook) => {
|
|
214
|
+
const result = runCodex(hook, dir, baseEnv({
|
|
215
|
+
ORCA_PANE_KEY: 'pane-abc',
|
|
216
|
+
ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN_SENTINEL,
|
|
217
|
+
ORCA_AGENT_LAUNCH_TOKEN: LAUNCH_TOKEN_SENTINEL,
|
|
218
|
+
}));
|
|
219
|
+
assert.match(result.stdout, /Orca: pane$/m);
|
|
220
|
+
for (const sentinel of [HOOK_TOKEN_SENTINEL, LAUNCH_TOKEN_SENTINEL]) {
|
|
221
|
+
assert.doesNotMatch(result.stdout, new RegExp(sentinel));
|
|
222
|
+
assert.doesNotMatch(result.stderr, new RegExp(sentinel));
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|