@inetafrica/open-claudia 2.15.0 → 3.0.0

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.
Files changed (151) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +66 -3
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +115 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +21 -4
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/jobs.js +589 -72
  29. package/core/lessons.js +3 -2
  30. package/core/loopback.js +42 -6
  31. package/core/memory-mutation-queue.js +241 -0
  32. package/core/migration-backup.js +1060 -0
  33. package/core/pack-review.js +295 -88
  34. package/core/packs.js +4 -3
  35. package/core/persona.js +2 -1
  36. package/core/process-tree.js +19 -2
  37. package/core/provider-migration.js +39 -0
  38. package/core/provider-status.js +80 -0
  39. package/core/providers/claude-events.js +117 -0
  40. package/core/providers/claude-hook.js +114 -0
  41. package/core/providers/claude.js +296 -0
  42. package/core/providers/codex-events.js +121 -0
  43. package/core/providers/codex-hook.js +280 -0
  44. package/core/providers/codex.js +286 -0
  45. package/core/providers/contract.js +153 -0
  46. package/core/providers/env.js +148 -0
  47. package/core/providers/events.js +170 -0
  48. package/core/providers/hook-command.js +22 -0
  49. package/core/providers/index.js +240 -0
  50. package/core/providers/utility-policy.js +269 -0
  51. package/core/recall/classic.js +2 -2
  52. package/core/recall/discoverer.js +71 -22
  53. package/core/recall/metrics.js +27 -1
  54. package/core/recall/tuning.js +4 -1
  55. package/core/recall/warm-walker.js +151 -108
  56. package/core/recall-filter.js +86 -61
  57. package/core/router.js +79 -7
  58. package/core/run-context.js +185 -0
  59. package/core/runner.js +1414 -1290
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/state.js +1084 -97
  63. package/core/subagent.js +72 -98
  64. package/core/system-prompt.js +462 -279
  65. package/core/tool-guard.js +73 -39
  66. package/core/tools.js +22 -5
  67. package/core/transcripts.js +30 -1
  68. package/core/usage-log.js +19 -4
  69. package/core/utility-agent.js +654 -0
  70. package/core/web-auth.js +29 -13
  71. package/docs/CHANNEL_DESIGN.md +181 -0
  72. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  73. package/docs/PROVIDER_MIGRATION.md +67 -0
  74. package/health.js +50 -39
  75. package/package.json +48 -4
  76. package/setup.js +198 -38
  77. package/soul.md +39 -0
  78. package/test-ability-extraction.js +5 -0
  79. package/test-approval-async.js +63 -4
  80. package/test-cursor-removal.js +334 -0
  81. package/test-enforcer.js +70 -27
  82. package/test-fixtures/current-provider-parity.json +132 -0
  83. package/test-fixtures/fake-agent-cli.js +477 -0
  84. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  85. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  86. package/test-fixtures/migrations/claude-only/state.json +5 -0
  87. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  88. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  90. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  91. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  92. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  93. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  94. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  95. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  97. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  99. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  100. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  101. package/test-fixtures/migrations/missing-project/state.json +4 -0
  102. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  103. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  104. package/test-fixtures/migrations/multi-user/state.json +6 -0
  105. package/test-fixtures/provider-event-samples.json +17 -0
  106. package/test-learning-e2e.js +5 -0
  107. package/test-memory-mutation-queue.js +191 -0
  108. package/test-provider-boot-matrix.js +399 -0
  109. package/test-provider-bootstrap.js +158 -0
  110. package/test-provider-capabilities.js +232 -0
  111. package/test-provider-claude.js +206 -0
  112. package/test-provider-codex.js +228 -0
  113. package/test-provider-compaction.js +371 -0
  114. package/test-provider-core-prompt.js +264 -0
  115. package/test-provider-coupling-audit.js +90 -0
  116. package/test-provider-dream.js +312 -0
  117. package/test-provider-enforcer.js +252 -0
  118. package/test-provider-env-isolation.js +150 -0
  119. package/test-provider-events.js +141 -0
  120. package/test-provider-fixture.js +332 -0
  121. package/test-provider-gateway.js +508 -0
  122. package/test-provider-language.js +89 -0
  123. package/test-provider-migration-backup.js +424 -0
  124. package/test-provider-pack-review.js +436 -0
  125. package/test-provider-parity-e2e.js +537 -0
  126. package/test-provider-prompt-parity.js +89 -0
  127. package/test-provider-recall.js +271 -0
  128. package/test-provider-registry.js +251 -0
  129. package/test-provider-resume-parity.js +41 -0
  130. package/test-provider-run-lifecycle.js +349 -0
  131. package/test-provider-runner.js +271 -0
  132. package/test-provider-scheduler.js +689 -0
  133. package/test-provider-session-commands.js +185 -0
  134. package/test-provider-session-history.js +205 -0
  135. package/test-provider-side-chat.js +337 -0
  136. package/test-provider-state-migration.js +316 -0
  137. package/test-provider-stream-decoder.js +69 -0
  138. package/test-provider-subagent.js +228 -0
  139. package/test-provider-tool-hooks.js +360 -0
  140. package/test-recall-discoverer.js +1 -0
  141. package/test-recall-engine.js +3 -3
  142. package/test-recall-evolution.js +18 -0
  143. package/test-runner-watchdog-static.js +16 -8
  144. package/test-single-runtime.js +35 -0
  145. package/test-telegram-poll-recovery.js +56 -0
  146. package/test-tool-guard.js +56 -0
  147. package/test-tools.js +19 -0
  148. package/test-unified-identity.js +3 -1
  149. package/test-usage-accounting.js +92 -20
  150. package/test-utility-provider-policy.js +486 -0
  151. package/web.js +130 -35
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const { createJsonlDecoder } = require("./core/providers/events");
7
+
8
+ const unicodeEvent = { type: "fixture", text: "café 👋" };
9
+ const unicodeLine = Buffer.from(`${JSON.stringify(unicodeEvent)}\n`, "utf8");
10
+ const emoji = Buffer.from("👋", "utf8");
11
+ const emojiAt = unicodeLine.indexOf(emoji);
12
+ assert.ok(emojiAt > 0);
13
+
14
+ const splitUtf8 = createJsonlDecoder();
15
+ assert.deepStrictEqual(splitUtf8.push(unicodeLine.subarray(0, emojiAt + 1)), []);
16
+ assert.deepStrictEqual(splitUtf8.push(unicodeLine.subarray(emojiAt + 1)), [unicodeEvent]);
17
+ assert.strictEqual(splitUtf8.bufferedBytes, 0);
18
+
19
+ const jsonSplit = createJsonlDecoder();
20
+ assert.deepStrictEqual(jsonSplit.push(Buffer.from('{"type":"part')), []);
21
+ assert.deepStrictEqual(jsonSplit.push(Buffer.from('ial","n":1}\n')), [{ type: "partial", n: 1 }]);
22
+
23
+ const coalesced = createJsonlDecoder();
24
+ assert.deepStrictEqual(coalesced.push(Buffer.from(
25
+ '{"type":"one"}\r\n\r\n \r\n{"type":"two"}\r\n',
26
+ )), [{ type: "one" }, { type: "two" }]);
27
+
28
+ const noNewline = createJsonlDecoder();
29
+ assert.deepStrictEqual(noNewline.push(Buffer.from('{"type":"tail","ok":true}')), []);
30
+ assert.deepStrictEqual(noNewline.end(), [{ type: "tail", ok: true }]);
31
+ assert.deepStrictEqual(noNewline.end(), [], "EOF flush is idempotent");
32
+ assert.throws(
33
+ () => noNewline.push(Buffer.from("{}\n")),
34
+ (error) => error && error.code === "DECODER_CLOSED",
35
+ );
36
+
37
+ const malformed = createJsonlDecoder();
38
+ const malformedEvents = malformed.push(Buffer.from('{"secret":"must-not-leak"\n{"type":"recovered"}\n'));
39
+ assert.strictEqual(malformedEvents[0].type, "decoder.error");
40
+ assert.strictEqual(malformedEvents[0].code, "MALFORMED_JSON");
41
+ assert.ok(!JSON.stringify(malformedEvents[0]).includes("must-not-leak"));
42
+ assert.deepStrictEqual(malformedEvents[1], { type: "recovered" });
43
+
44
+ const bounded = createJsonlDecoder({ maxLineBytes: 32 });
45
+ const overlong = bounded.push(Buffer.from(`{"value":"${"x".repeat(80)}`));
46
+ assert.strictEqual(overlong.length, 1);
47
+ assert.strictEqual(overlong[0].type, "decoder.error");
48
+ assert.strictEqual(overlong[0].code, "LINE_TOO_LARGE");
49
+ assert.ok(bounded.bufferedBytes <= 32, "tail buffering must stay bounded");
50
+ assert.strictEqual(bounded.discarding, true);
51
+ assert.deepStrictEqual(
52
+ bounded.push(Buffer.from('discarded"}\n{"type":"after-oversize"}\n')),
53
+ [{ type: "after-oversize" }],
54
+ );
55
+ assert.strictEqual(bounded.discarding, false);
56
+
57
+ const lineTooLarge = createJsonlDecoder({ maxLineBytes: 16 });
58
+ const oversizedLine = lineTooLarge.push(Buffer.from(`{"x":"${"y".repeat(40)}"}\n{"ok":1}\n`));
59
+ assert.strictEqual(oversizedLine[0].code, "LINE_TOO_LARGE");
60
+ assert.deepStrictEqual(oversizedLine[1], { ok: 1 });
61
+
62
+ const first = createJsonlDecoder();
63
+ const second = createJsonlDecoder();
64
+ first.push(Buffer.from('{"id":"first"}'));
65
+ second.push(Buffer.from('{"id":"second"}'));
66
+ assert.deepStrictEqual(first.end(), [{ id: "first" }]);
67
+ assert.deepStrictEqual(second.end(), [{ id: "second" }]);
68
+
69
+ console.log("provider stream decoder OK");
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-provider-subagent-"));
11
+ process.env.OPEN_CLAUDIA_TEST = "1";
12
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = path.join(root, "config");
13
+ process.env.WORKSPACE = root;
14
+
15
+ const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
16
+ const { createClaudeProvider } = require("./core/providers/claude");
17
+ const { createCodexProvider } = require("./core/providers/codex");
18
+ const { createUtilityAgentService } = require("./core/utility-agent");
19
+ const {
20
+ buildSubagentSystemPrompt,
21
+ concurrentCount,
22
+ createSubagentService,
23
+ } = require("./core/subagent");
24
+ const { HELP, parseAgentArgs } = require("./bin/agent");
25
+
26
+ function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
27
+
28
+ async function waitForFile(file, timeoutMs = 2000) {
29
+ const started = Date.now();
30
+ while (!fs.existsSync(file)) {
31
+ if (Date.now() - started > timeoutMs) throw new Error(`timed out waiting for ${path.basename(file)}`);
32
+ await wait(5);
33
+ }
34
+ }
35
+
36
+ function makeProvider(kind, controls = {}) {
37
+ const common = {
38
+ hookTransport: false,
39
+ resolveExecutable: () => fixture,
40
+ defaultModel: `${kind}-high-fixture`,
41
+ buildEnv: () => ({
42
+ HOME: root,
43
+ PATH: process.env.PATH || path.dirname(process.execPath),
44
+ FAKE_AGENT_KIND: kind,
45
+ ...controls,
46
+ }),
47
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
48
+ };
49
+ return kind === "claude" ? createClaudeProvider(common) : createCodexProvider(common);
50
+ }
51
+
52
+ function makeRegistry(provider) {
53
+ return {
54
+ getProvider(id) {
55
+ if (id !== provider.id) throw Object.assign(new Error(`unknown ${id}`), { code: "UNKNOWN_PROVIDER" });
56
+ return provider;
57
+ },
58
+ listProviders: () => [provider],
59
+ providerStatus(id) {
60
+ assert.strictEqual(id, provider.id);
61
+ return {
62
+ id,
63
+ availability: { state: "available", executable: fixture, reason: null },
64
+ compatibility: { state: "compatible", reason: null },
65
+ auth: { state: "authenticated", reason: null },
66
+ };
67
+ },
68
+ };
69
+ }
70
+
71
+ function harness(kind, controls = {}, serviceOptions = {}) {
72
+ const provider = makeProvider(kind, controls);
73
+ const utility = createUtilityAgentService({
74
+ registry: makeRegistry(provider),
75
+ env: { UTILITY_PROVIDER: kind },
76
+ ...serviceOptions,
77
+ });
78
+ return {
79
+ provider,
80
+ utility,
81
+ subagent: createSubagentService({ spawnUtilityAgent: utility.spawnUtilityAgent }),
82
+ };
83
+ }
84
+
85
+ async function main() {
86
+ assert.strictEqual(typeof createSubagentService, "function", "subagent compatibility wrapper exposes an injectable service");
87
+ assert.match(buildSubagentSystemPrompt("researcher"), /spawned by Open Claudia/i);
88
+ assert.ok(!/another Claude assistant/i.test(buildSubagentSystemPrompt()), "sub-agent identity is provider-neutral");
89
+
90
+ const parsed = parseAgentArgs(["literal", "prompt", "--role", "researcher", "--provider", "codex", "--timeout", "12", "--json"]);
91
+ assert.deepStrictEqual(parsed, {
92
+ prompt: "literal prompt",
93
+ role: "researcher",
94
+ provider: "codex",
95
+ cwd: process.cwd(),
96
+ timeoutMs: 12000,
97
+ json: true,
98
+ });
99
+ assert.strictEqual(parseAgentArgs(["default policy prompt"]).provider, null,
100
+ "omitting --provider leaves SUBAGENT_PROVIDER eligible to select the provider");
101
+ assert.throws(() => parseAgentArgs(["prompt", "--provider", "cursor"]), /active, claude, or codex/i);
102
+ assert.throws(() => parseAgentArgs(["prompt", "--provider"]), /requires a value/i);
103
+ assert.match(HELP, /--provider active\|claude\|codex/);
104
+ assert.ok(!/Claude sub-agent/i.test(HELP));
105
+
106
+ for (const kind of ["claude", "codex"]) {
107
+ const capture = path.join(root, `${kind}-read-only.json`);
108
+ const { subagent } = harness(kind, {
109
+ FAKE_AGENT_CAPTURE: capture,
110
+ FAKE_AGENT_TEXT: `${kind} focused result`,
111
+ });
112
+ const result = await subagent.spawnSubagent("focused prompt", {
113
+ provider: kind,
114
+ role: "researcher",
115
+ channelId: `${kind}-channel`,
116
+ cwd: root,
117
+ });
118
+ assert.strictEqual(result.text, `${kind} focused result`);
119
+ assert.strictEqual(result.provider, kind);
120
+ assert.strictEqual(result.truncated, false);
121
+ assert.strictEqual(result.code, 0);
122
+ const record = JSON.parse(fs.readFileSync(capture, "utf8"));
123
+ assert.ok(!record.argv.includes("focused prompt"), `${kind} sub-agent prompt stays out of argv`);
124
+ assert.strictEqual(record.stdin.present, true);
125
+ if (kind === "claude") {
126
+ assert.deepStrictEqual(
127
+ record.argv.slice(record.argv.indexOf("--permission-mode"), record.argv.indexOf("--permission-mode") + 2),
128
+ ["--permission-mode", "plan"],
129
+ );
130
+ assert.ok(record.argv.includes("--no-session-persistence"));
131
+ } else {
132
+ assert.deepStrictEqual(
133
+ record.argv.slice(record.argv.indexOf("--sandbox"), record.argv.indexOf("--sandbox") + 2),
134
+ ["--sandbox", "read-only"],
135
+ );
136
+ assert.ok(record.argv.includes("--ephemeral"));
137
+ }
138
+ assert.strictEqual(concurrentCount(`${kind}-channel`), 0);
139
+ }
140
+
141
+ const claudeToolsCapture = path.join(root, "claude-tools.json");
142
+ const claudeTools = harness("claude", { FAKE_AGENT_CAPTURE: claudeToolsCapture });
143
+ await claudeTools.subagent.spawnSubagent("tool policy", {
144
+ provider: "claude",
145
+ allowedTools: ["Read", "Glob"],
146
+ disallowedTools: ["Write", "Edit"],
147
+ cwd: root,
148
+ });
149
+ const toolArgv = JSON.parse(fs.readFileSync(claudeToolsCapture, "utf8")).argv;
150
+ assert.deepStrictEqual(toolArgv.slice(toolArgv.indexOf("--allowedTools"), toolArgv.indexOf("--allowedTools") + 2), ["--allowedTools", "Read,Glob"]);
151
+ assert.deepStrictEqual(toolArgv.slice(toolArgv.indexOf("--disallowedTools"), toolArgv.indexOf("--disallowedTools") + 2), ["--disallowedTools", "Write,Edit"]);
152
+ await assert.rejects(
153
+ claudeTools.subagent.spawnSubagent("unsafe", { provider: "claude", readOnly: false, cwd: root }),
154
+ (error) => error.code === "UTILITY_READ_ONLY_REQUIRED",
155
+ "sub-agent compatibility surface cannot silently opt out of read-only policy",
156
+ );
157
+
158
+ const codexTools = harness("codex");
159
+ await assert.rejects(
160
+ codexTools.subagent.spawnSubagent("tool policy", {
161
+ provider: "codex",
162
+ allowedTools: ["Read"],
163
+ cwd: root,
164
+ }),
165
+ (error) => error.code === "UNSUPPORTED_PROVIDER_CAPABILITY" && /allow\/deny/i.test(error.message),
166
+ "Codex tool-list limits are surfaced accurately, never silently claimed",
167
+ );
168
+
169
+ for (const kind of ["claude", "codex"]) {
170
+ const clipped = harness(kind, { FAKE_AGENT_TEXT: "abcdefghijklmnopqrstuvwxyz" }, { maxOutputBytes: 9 });
171
+ const result = await clipped.subagent.spawnSubagent("clip", { provider: kind, cwd: root });
172
+ assert.strictEqual(result.text, "abcdefghi");
173
+ assert.strictEqual(result.truncated, true);
174
+
175
+ const timed = harness(kind, {
176
+ FAKE_AGENT_SCENARIO: "forced-termination",
177
+ FAKE_AGENT_IGNORE_SIGTERM: "1",
178
+ }, { killGraceMs: 30 });
179
+ await assert.rejects(
180
+ timed.subagent.spawnSubagent("timeout", { provider: kind, timeoutMs: 140, cwd: root }),
181
+ (error) => error.code === "UTILITY_TIMEOUT" && error.result?.timedOut === true,
182
+ `${kind} sub-agent timeout is terminal and stable`,
183
+ );
184
+ assert.strictEqual(timed.utility.stats().activeGlobal, 0);
185
+ }
186
+
187
+ const firstCapture = path.join(root, "concurrent-first.json");
188
+ const secondCapture = path.join(root, "concurrent-second.json");
189
+ let invocation = 0;
190
+ const provider = makeProvider("claude");
191
+ const baseBuild = provider.buildUtilityInvocation;
192
+ provider.buildUtilityInvocation = (context) => {
193
+ invocation += 1;
194
+ const built = baseBuild(context);
195
+ return {
196
+ ...built,
197
+ env: {
198
+ ...built.env,
199
+ FAKE_AGENT_CLOSE_DELAY_MS: "180",
200
+ FAKE_AGENT_CAPTURE: invocation === 1 ? firstCapture : secondCapture,
201
+ },
202
+ };
203
+ };
204
+ const concurrentUtility = createUtilityAgentService({
205
+ registry: makeRegistry(provider),
206
+ env: { UTILITY_PROVIDER: "claude" },
207
+ maxGlobalConcurrency: 2,
208
+ maxPurposeConcurrency: 2,
209
+ });
210
+ const concurrentSubagent = createSubagentService({ spawnUtilityAgent: concurrentUtility.spawnUtilityAgent });
211
+ const first = concurrentSubagent.spawnSubagent("first", { channelId: "shared-channel", cwd: root });
212
+ const second = concurrentSubagent.spawnSubagent("second", { channelId: "shared-channel", cwd: root });
213
+ await Promise.all([waitForFile(firstCapture), waitForFile(secondCapture)]);
214
+ assert.strictEqual(concurrentCount("shared-channel"), 2, "compatibility wrapper tracks in-flight work by channel");
215
+ await Promise.all([first, second]);
216
+ assert.strictEqual(concurrentCount("shared-channel"), 0);
217
+
218
+ const systemPromptSource = fs.readFileSync(path.join(__dirname, "core", "system-prompt.js"), "utf8");
219
+ assert.match(systemPromptSource, /--provider active\|claude\|codex/);
220
+ assert.ok(!/spawned by another Claude assistant/i.test(systemPromptSource));
221
+
222
+ console.log("provider subagent OK");
223
+ }
224
+
225
+ main().catch((error) => {
226
+ console.error(error.stack || error.message);
227
+ process.exitCode = 1;
228
+ }).finally(() => fs.rmSync(root, { recursive: true, force: true }));
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+ const { spawnSync } = require("child_process");
10
+
11
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-provider-hooks-"));
12
+ const configDir = path.join(root, "open-claudia-config");
13
+ const projectDir = path.join(root, "project.with.dots");
14
+ const guardLog = path.join(configDir, "tool-guard.jsonl");
15
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = configDir;
16
+ process.env.TOOL_GUARD_LOG = guardLog;
17
+ process.env.TOOLING_MODE_FILE = path.join(configDir, "tooling-mode.json");
18
+ fs.mkdirSync(projectDir, { recursive: true });
19
+
20
+ const {
21
+ buildClaudeHookSettings,
22
+ createClaudeHookTransport,
23
+ ensureClaudeHookSettings,
24
+ } = require("./core/providers/claude-hook");
25
+ const {
26
+ buildCodexHookOverrides,
27
+ clearCodexHookCompatibilityCache,
28
+ createCodexHookTransport,
29
+ prepareCodexHook,
30
+ probeCodexHookCompatibility,
31
+ verifyPreparedCodexHook,
32
+ } = require("./core/providers/codex-hook");
33
+ const { createClaudeProvider } = require("./core/providers/claude");
34
+ const { createCodexProvider } = require("./core/providers/codex");
35
+ const { validateInvocation } = require("./core/providers/contract");
36
+ const { claudeAgentEnv, codexAgentEnv } = require("./core/providers/env");
37
+ const { shellQuote } = require("./core/providers/hook-command");
38
+ const { captureRunContext } = require("./core/run-context");
39
+
40
+ function compatibleCodexCommand(_binary, args) {
41
+ if (args[0] === "--version") return { ok: true, output: "codex-cli 0.144.0", code: 0 };
42
+ if (args.join(" ") === "exec --help") {
43
+ return {
44
+ ok: true,
45
+ output: "--config --json --sandbox --image --model --skip-git-repo-check --strict-config --ignore-user-config --dangerously-bypass-hook-trust",
46
+ code: 0,
47
+ };
48
+ }
49
+ if (args.join(" ") === "exec resume --help") {
50
+ return {
51
+ ok: true,
52
+ output: "--config --json --image --model --skip-git-repo-check --strict-config --ignore-user-config --dangerously-bypass-hook-trust",
53
+ code: 0,
54
+ };
55
+ }
56
+ if (args[0] === "features") {
57
+ return {
58
+ ok: true,
59
+ output: [
60
+ "hooks stable true",
61
+ "plugins stable false",
62
+ "unified_exec stable false",
63
+ ].join("\n"),
64
+ code: 0,
65
+ };
66
+ }
67
+ return { ok: false, output: "unexpected compatibility command", code: 1 };
68
+ }
69
+
70
+ function baseContext(overrides = {}) {
71
+ return Object.freeze({
72
+ coreInstructions: "CORE POLICY",
73
+ dynamicContext: "",
74
+ userPrompt: "fixture prompt",
75
+ cwd: projectDir,
76
+ providerSettings: { budget: null, worktree: false },
77
+ ...overrides,
78
+ });
79
+ }
80
+
81
+ try {
82
+ const injectionMarker = path.join(root, "hook-command-injection.marker");
83
+ const hostileLiteral = `literal'$(touch ${injectionMarker})'value`;
84
+ const quoted = spawnSync(`printf '%s' ${shellQuote(hostileLiteral)}`, {
85
+ shell: true,
86
+ encoding: "utf8",
87
+ });
88
+ assert.strictEqual(quoted.status, 0);
89
+ assert.strictEqual(quoted.stdout, hostileLiteral, "generated hook paths are shell-quoted as literal data");
90
+ assert.strictEqual(fs.existsSync(injectionMarker), false, "hook path quoting cannot execute substitutions");
91
+
92
+ // Claude keeps its existing --settings transport, but the generated file is
93
+ // now owned and verified by the Claude adapter rather than the command policy.
94
+ const claudeSettingsFile = path.join(configDir, "claude-deny-gate.settings.json");
95
+ const claudeSettings = buildClaudeHookSettings();
96
+ assert.strictEqual(claudeSettings.hooks.PreToolUse[0].matcher, "Bash");
97
+ assert.match(claudeSettings.hooks.PreToolUse[0].hooks[0].command, /deny-gate-hook/);
98
+ assert.match(claudeSettings.hooks.PreToolUse[0].hooks[0].command, new RegExp(process.execPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
99
+ assert.strictEqual(ensureClaudeHookSettings({ settingsFile: claudeSettingsFile }), claudeSettingsFile);
100
+ assert.deepStrictEqual(JSON.parse(fs.readFileSync(claudeSettingsFile, "utf8")), claudeSettings);
101
+ if (process.platform !== "win32") {
102
+ assert.strictEqual(fs.statSync(claudeSettingsFile).mode & 0o777, 0o600, "generated hook settings are private");
103
+ }
104
+ const before = fs.statSync(claudeSettingsFile).mtimeMs;
105
+ assert.strictEqual(ensureClaudeHookSettings({ settingsFile: claudeSettingsFile }), claudeSettingsFile);
106
+ assert.strictEqual(fs.statSync(claudeSettingsFile).mtimeMs, before, "verified settings are not churned");
107
+
108
+ if (process.platform !== "win32") {
109
+ const victim = path.join(root, "must-not-overwrite.json");
110
+ const link = path.join(configDir, "symlink.settings.json");
111
+ fs.writeFileSync(victim, "preserve me\n");
112
+ fs.symlinkSync(victim, link);
113
+ assert.throws(
114
+ () => ensureClaudeHookSettings({ settingsFile: link }),
115
+ (error) => error && error.code === "UNSAFE_TOOL_HOOK_PATH",
116
+ );
117
+ assert.strictEqual(fs.readFileSync(victim, "utf8"), "preserve me\n");
118
+ }
119
+
120
+ const claudeTransport = createClaudeHookTransport({ settingsFile: claudeSettingsFile });
121
+ const claudeProvider = createClaudeProvider({
122
+ resolveExecutable: () => "/fixture/claude",
123
+ defaultModel: "claude-fixture",
124
+ buildEnv: () => ({ PATH: "/fixture", OPEN_CLAUDIA_CONFIG_DIR: configDir }),
125
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
126
+ hookTransport: claudeTransport,
127
+ });
128
+ const claudeInvocation = claudeProvider.buildMainInvocation(baseContext());
129
+ assert.strictEqual(claudeInvocation.args[claudeInvocation.args.indexOf("--settings") + 1], claudeSettingsFile);
130
+ const defaultClaudeProvider = createClaudeProvider({
131
+ resolveExecutable: () => "/fixture/claude",
132
+ defaultModel: "claude-fixture",
133
+ buildEnv: () => ({ PATH: "/fixture", OPEN_CLAUDIA_CONFIG_DIR: configDir }),
134
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
135
+ });
136
+ const defaultClaudeInvocation = defaultClaudeProvider.buildMainInvocation(baseContext());
137
+ assert.ok(defaultClaudeInvocation.args.includes("--settings"), "Claude factories enforce the generated hook by default");
138
+
139
+ // Codex uses only transient, generated CLI overrides. User config is ignored,
140
+ // the current project is explicitly untrusted, plugin hooks and unified_exec
141
+ // are disabled, and hook trust is bypassed only after exact verification.
142
+ const overrides = buildCodexHookOverrides({ cwd: projectDir });
143
+ assert.strictEqual(overrides.featuresHooks, "features.hooks=true");
144
+ assert.strictEqual(overrides.featuresPlugins, "features.plugins=false");
145
+ assert.strictEqual(overrides.featuresUnifiedExec, "features.unified_exec=false");
146
+ assert.match(overrides.projectTrust, /^projects=\{.+trust_level="untrusted".+\}$/);
147
+ assert.match(overrides.preToolUse, /^hooks\.PreToolUse=/);
148
+ assert.match(overrides.preToolUse, /deny-gate-hook/);
149
+
150
+ const prepared = prepareCodexHook({ cwd: projectDir });
151
+ assert.strictEqual(verifyPreparedCodexHook(prepared), true);
152
+ assert.ok(Object.isFrozen(prepared));
153
+ assert.ok(Object.isFrozen(prepared.args));
154
+ assert.ok(prepared.args.includes("--ignore-user-config"));
155
+ assert.ok(prepared.args.includes("--strict-config"));
156
+ assert.ok(prepared.args.includes("--dangerously-bypass-hook-trust"));
157
+ assert.ok(!prepared.args.includes("--profile"), "a merging Codex profile is never trusted");
158
+ const tampered = Object.freeze({ ...prepared, args: Object.freeze([...prepared.args, "--profile", "project-hooks"]) });
159
+ assert.strictEqual(verifyPreparedCodexHook(tampered), false, "trust bypass rejects non-generated argv");
160
+
161
+ clearCodexHookCompatibilityCache();
162
+ const codexTransport = createCodexHookTransport({ cache: false });
163
+ const codexProvider = createCodexProvider({
164
+ resolveExecutable: () => "/fixture/codex",
165
+ defaultModel: "gpt-fixture",
166
+ buildEnv: () => ({ PATH: "/fixture", OPEN_CLAUDIA_CONFIG_DIR: configDir }),
167
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
168
+ runCommand: compatibleCodexCommand,
169
+ hookTransport: codexTransport,
170
+ });
171
+ assert.strictEqual(codexProvider.capabilities.preToolHook.support, "native");
172
+ assert.strictEqual(codexProvider.compatibilityStatus().state, "compatible");
173
+ const fresh = codexProvider.buildMainInvocation(baseContext({ fresh: true }));
174
+ const resumed = codexProvider.buildMainInvocation(baseContext({ sessionId: "thread-fixture" }));
175
+ for (const invocation of [fresh, resumed]) {
176
+ assert.ok(invocation.args.includes("--ignore-user-config"));
177
+ assert.ok(invocation.args.includes("--dangerously-bypass-hook-trust"));
178
+ assert.ok(invocation.args.some((arg) => arg === overrides.preToolUse));
179
+ assert.ok(invocation.args.some((arg) => arg === overrides.projectTrust));
180
+ assert.ok(invocation.args.indexOf("--dangerously-bypass-hook-trust") < invocation.args.indexOf(invocation.args.at(-1)));
181
+ }
182
+ assert.strictEqual(resumed.args[1], "resume");
183
+ const defaultCodexProvider = createCodexProvider({
184
+ resolveExecutable: () => "/fixture/codex",
185
+ defaultModel: "gpt-fixture",
186
+ buildEnv: () => ({ PATH: "/fixture", OPEN_CLAUDIA_CONFIG_DIR: configDir }),
187
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
188
+ runCommand: compatibleCodexCommand,
189
+ });
190
+ assert.ok(
191
+ defaultCodexProvider.buildMainInvocation(baseContext({ fresh: true })).args.includes("--dangerously-bypass-hook-trust"),
192
+ "Codex factories enforce the verified transient hook by default",
193
+ );
194
+
195
+ // Mandatory initialization is fail-closed before spawn. Read-only fallback
196
+ // exists only behind an explicit policy bit and always carries a warning.
197
+ const unavailableTransport = createCodexHookTransport({ cache: false });
198
+ const unavailableProvider = createCodexProvider({
199
+ resolveExecutable: () => "/fixture/codex-without-hooks",
200
+ defaultModel: "gpt-fixture",
201
+ buildEnv: () => ({ PATH: "/fixture" }),
202
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
203
+ runCommand: (_binary, args) => args[0] === "--version"
204
+ ? { ok: true, output: "codex-cli 0.100.0", code: 0 }
205
+ : { ok: true, output: "--json --sandbox", code: 0 },
206
+ hookTransport: unavailableTransport,
207
+ });
208
+ assert.throws(
209
+ () => unavailableProvider.buildMainInvocation(baseContext()),
210
+ (error) => error && error.code === "MANDATORY_TOOL_HOOK_UNAVAILABLE",
211
+ );
212
+ assert.throws(
213
+ () => unavailableProvider.buildMainInvocation(baseContext({ readOnly: true })),
214
+ (error) => error && error.code === "MANDATORY_TOOL_HOOK_UNAVAILABLE",
215
+ );
216
+ const warnedReadOnly = unavailableProvider.buildMainInvocation(baseContext({
217
+ readOnly: true,
218
+ allowReadOnlyWithoutToolHook: true,
219
+ }));
220
+ assert.strictEqual(validateInvocation(warnedReadOnly), warnedReadOnly);
221
+ assert.ok(Array.isArray(warnedReadOnly.warnings) && warnedReadOnly.warnings.length === 1);
222
+ assert.match(warnedReadOnly.warnings[0].message, /read-only/i);
223
+ assert.match(warnedReadOnly.warnings[0].message, /hook/i);
224
+ assert.ok(!warnedReadOnly.args.includes("--dangerously-bypass-hook-trust"));
225
+ assert.ok(!warnedReadOnly.args.includes("--dangerously-bypass-approvals-and-sandbox"));
226
+ const capturedFallbackPolicy = captureRunContext({
227
+ state: {
228
+ userId: "fixture-user",
229
+ currentSession: { name: "fixture-project", dir: projectDir },
230
+ settings: { backend: "codex", budget: null, worktree: false },
231
+ activeSessions: { "fixture-project": {} },
232
+ lastInputWasVoice: false,
233
+ },
234
+ prompt: "read-only fallback fixture",
235
+ cwd: projectDir,
236
+ opts: { readOnly: true, allowReadOnlyWithoutToolHook: true },
237
+ });
238
+ assert.strictEqual(capturedFallbackPolicy.readOnly, true);
239
+ assert.strictEqual(capturedFallbackPolicy.allowReadOnlyWithoutToolHook, true, "the explicit fallback policy survives admission");
240
+
241
+ // Custom Open Claudia config roots reach the provider child so the shared
242
+ // hook reads the correct tooling mode and writes the correct audit log.
243
+ const envSource = { PATH: "/fixture", HOME: "/fixture/home", OPEN_CLAUDIA_CONFIG_DIR: configDir };
244
+ assert.strictEqual(claudeAgentEnv(envSource).OPEN_CLAUDIA_CONFIG_DIR, configDir);
245
+ assert.strictEqual(codexAgentEnv(envSource).OPEN_CLAUDIA_CONFIG_DIR, configDir);
246
+
247
+ // Hermetic behavioral compatibility probe: the fake Codex CLI runs the
248
+ // configured PreToolUse hook against a harmless false-positive command. The
249
+ // deny is observed and neither the command marker nor an arbitrary project
250
+ // hook marker is allowed to execute.
251
+ const marker = path.join(root, "denied-command.marker");
252
+ const arbitraryMarker = path.join(root, "arbitrary-hook.marker");
253
+ const capture = path.join(root, "fake-codex-hook-capture.json");
254
+ const probe = probeCodexHookCompatibility({
255
+ binary: path.join(__dirname, "test-fixtures", "fake-agent-cli.js"),
256
+ prepared,
257
+ cwd: projectDir,
258
+ markerFile: marker,
259
+ auditFile: guardLog,
260
+ env: {
261
+ PATH: process.env.PATH,
262
+ HOME: root,
263
+ OPEN_CLAUDIA_CONFIG_DIR: configDir,
264
+ TOOL_GUARD_LOG: guardLog,
265
+ TOOLING_MODE_FILE: process.env.TOOLING_MODE_FILE,
266
+ FAKE_AGENT_KIND: "codex",
267
+ FAKE_AGENT_CAPTURE: capture,
268
+ FAKE_AGENT_ARBITRARY_HOOK_MARKER: arbitraryMarker,
269
+ },
270
+ spawnCommand: spawnSync,
271
+ });
272
+ assert.strictEqual(probe.ok, true, probe.diagnostic);
273
+ assert.strictEqual(probe.denyObserved, true);
274
+ assert.strictEqual(fs.existsSync(marker), false, "denied marker command never executes");
275
+ assert.strictEqual(fs.existsSync(arbitraryMarker), false, "arbitrary project hooks never inherit trust bypass");
276
+ const probeCapture = JSON.parse(fs.readFileSync(capture, "utf8"));
277
+ assert.strictEqual(probeCapture.hookProbe.hookExit, 2);
278
+ assert.strictEqual(probeCapture.hookProbe.generatedOnly, true);
279
+
280
+ // Doctor wiring: the live probe is strictly opt-in, only fires when codex is
281
+ // fully healthy, and surfaces its verdict as a doctor check. Boot, health,
282
+ // and setup call runDoctorChecks() bare and must never reach a model turn.
283
+ {
284
+ const { runDoctorChecks } = require("./core/doctor");
285
+ const fixtureStatus = (id, authState) => ({
286
+ id,
287
+ label: id === "claude" ? "Claude Code" : "OpenAI Codex",
288
+ availability: { state: "available", executable: `/fixture/${id}`, reason: null },
289
+ compatibility: { state: "compatible", version: "1.0.0", reason: null },
290
+ auth: { state: authState, reason: authState === "authenticated" ? null : "fixture logged out" },
291
+ });
292
+ const registryStub = (codexAuth) => ({
293
+ listProviders: () => [
294
+ { id: "claude", label: "Claude Code", authHelp: () => "log in to claude" },
295
+ { id: "codex", label: "OpenAI Codex", authHelp: () => "log in to codex" },
296
+ ],
297
+ providerStatus: (id) => fixtureStatus(id, id === "codex" ? codexAuth : "authenticated"),
298
+ selectDefaultProvider: () => ({ providerId: "claude", source: "detected", reason: null }),
299
+ });
300
+ const executableStatus = (id) => ({ state: "available", configured: true, executable: `/fixture/${id}` });
301
+ const enforcementCheck = (checks) => checks.find((c) => /tool-hook enforcement/.test(c.label));
302
+
303
+ const probeCalls = [];
304
+ const okProbe = (args) => {
305
+ probeCalls.push(args);
306
+ return { ok: true, status: 0, denyObserved: true, markerExecuted: false, diagnostic: null };
307
+ };
308
+
309
+ const withoutOptIn = runDoctorChecks({
310
+ registry: registryStub("authenticated"), executableStatus, runCodexHookProbe: okProbe,
311
+ });
312
+ assert.strictEqual(enforcementCheck(withoutOptIn), undefined, "the live probe check must be opt-in");
313
+ assert.strictEqual(probeCalls.length, 0, "runDoctorChecks() without opt-in must never invoke the live probe");
314
+
315
+ const optedIn = runDoctorChecks({
316
+ registry: registryStub("authenticated"), executableStatus, probeCodexHook: true, runCodexHookProbe: okProbe,
317
+ });
318
+ const passed = enforcementCheck(optedIn);
319
+ assert.ok(passed, "opting in must add the enforcement check");
320
+ assert.strictEqual(passed.ok, true);
321
+ assert.strictEqual(probeCalls.length, 1);
322
+ assert.strictEqual(probeCalls[0].binary, "/fixture/codex", "the probe must target the codex executable doctor verified");
323
+
324
+ const failing = runDoctorChecks({
325
+ registry: registryStub("authenticated"), executableStatus, probeCodexHook: true,
326
+ runCodexHookProbe: () => ({
327
+ ok: false, status: 0, denyObserved: false, markerExecuted: true,
328
+ diagnostic: "Codex tool-hook probe failed (status=0, denyObserved=false, markerExecuted=true)",
329
+ }),
330
+ });
331
+ const failed = enforcementCheck(failing);
332
+ assert.strictEqual(failed.ok, false, "a probe that observes no deny must fail doctor");
333
+ assert.ok(!failed.warn, "enforcement failure is a hard problem, not a warning");
334
+ assert.match(failed.detail, /markerExecuted=true/);
335
+ assert.match(failed.action, /Do not run Codex tasks/);
336
+
337
+ const skippedProbeCalls = [];
338
+ const skipped = runDoctorChecks({
339
+ registry: registryStub("unauthenticated"), executableStatus, probeCodexHook: true,
340
+ runCodexHookProbe: (args) => { skippedProbeCalls.push(args); return { ok: true }; },
341
+ });
342
+ const skippedCheck = enforcementCheck(skipped);
343
+ assert.strictEqual(skippedCheck.ok, true);
344
+ assert.strictEqual(skippedCheck.warn, true, "an unready codex skips as a warning, not a failure");
345
+ assert.match(skippedCheck.detail, /skipped/);
346
+ assert.strictEqual(skippedProbeCalls.length, 0, "an unready codex must never trigger a real exec turn");
347
+
348
+ const throwing = runDoctorChecks({
349
+ registry: registryStub("authenticated"), executableStatus, probeCodexHook: true,
350
+ runCodexHookProbe: () => { throw new Error("spawn ETIMEDOUT"); },
351
+ });
352
+ const threw = enforcementCheck(throwing);
353
+ assert.strictEqual(threw.ok, false, "a probe crash must fail doctor, not crash it");
354
+ assert.match(threw.detail, /ETIMEDOUT/);
355
+ }
356
+
357
+ console.log("provider tool hooks OK");
358
+ } finally {
359
+ fs.rmSync(root, { recursive: true, force: true });
360
+ }
@@ -17,6 +17,7 @@ process.env.TOOLS_DIR = path.join(tmp, "tools");
17
17
 
18
18
  const disc = require("./core/recall/discoverer");
19
19
  const toolsLib = require("./core/tools");
20
+ assert.strictEqual(typeof disc.runWalker, "function", "discoverer exposes the provider-aware walker boundary");
20
21
 
21
22
  // A reusable tool whose description matches a query should seed the discoverer
22
23
  // as a first-class candidate (origin user), survive the walker-off fail-open,