@inetafrica/open-claudia 2.14.8 → 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 (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -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 +105 -7
  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 +127 -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 +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. package/web.js +159 -45
package/core/config.js CHANGED
@@ -3,21 +3,17 @@
3
3
 
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
- const { execFileSync } = require("child_process");
7
- const CONFIG_DIR = require("../config-dir");
6
+ const { resolveConfigDir, ensureConfigDir } = require("./config-dir");
7
+ const { atomicWriteFileSync } = require("./fsutil");
8
8
  const { truthy: configTruthy } = require("../project-transcripts");
9
9
 
10
10
  const BOT_DIR = path.resolve(__dirname, "..");
11
+ const CONFIG_DIR = resolveConfigDir();
11
12
  const ENV_PATH = path.join(CONFIG_DIR, ".env");
12
13
 
13
- function loadEnv() {
14
- if (!fs.existsSync(ENV_PATH)) {
15
- if (process.env.OPEN_CLAUDIA_TEST === "1") return { ...process.env };
16
- console.error("No .env file found. Run: node setup.js");
17
- process.exit(1);
18
- }
19
- const lines = fs.readFileSync(ENV_PATH, "utf-8").split("\n");
20
- const env = { ...process.env };
14
+ function parseEnvText(text) {
15
+ const env = {};
16
+ const lines = String(text || "").split(/\r?\n/);
21
17
  for (const line of lines) {
22
18
  const idx = line.indexOf("=");
23
19
  if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
@@ -25,8 +21,16 @@ function loadEnv() {
25
21
  return env;
26
22
  }
27
23
 
24
+ function loadEnv(envPath = ENV_PATH, processEnvironment = process.env) {
25
+ const fileEnv = fs.existsSync(envPath)
26
+ ? parseEnvText(fs.readFileSync(envPath, "utf-8"))
27
+ : {};
28
+ return { ...processEnvironment, ...fileEnv };
29
+ }
30
+
28
31
  function saveEnvKey(key, value) {
29
- const content = fs.readFileSync(ENV_PATH, "utf-8");
32
+ ensureConfigDir(CONFIG_DIR);
33
+ const content = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf-8") : "";
30
34
  const lines = content.split("\n");
31
35
  let found = false;
32
36
  const updated = lines.map((line) => {
@@ -34,7 +38,7 @@ function saveEnvKey(key, value) {
34
38
  return line;
35
39
  });
36
40
  if (!found) updated.push(`${key}=${value}`);
37
- fs.writeFileSync(ENV_PATH, updated.join("\n"));
41
+ atomicWriteFileSync(ENV_PATH, updated.join("\n"), { backup: true, mode: 0o600 });
38
42
  }
39
43
 
40
44
  const config = loadEnv();
@@ -43,42 +47,73 @@ function hasPathSeparator(value) {
43
47
  return value.includes("/") || value.includes("\\");
44
48
  }
45
49
 
46
- function which(command) {
47
- const lookup = process.platform === "win32" ? "where" : "which";
48
- return execFileSync(lookup, [command], { encoding: "utf-8" })
49
- .split(/\r?\n/)
50
- .map((line) => line.trim())
51
- .filter(Boolean)[0] || null;
50
+ const PROVIDER_COMMANDS = Object.freeze({ claude: "claude", codex: "codex" });
51
+ const DEFAULT_PROVIDER = String(config.DEFAULT_PROVIDER || "").trim().toLowerCase() || null;
52
+ const UTILITY_PROVIDER = String(config.UTILITY_PROVIDER || "active").trim().toLowerCase() || "active";
53
+ const PROVIDER_FALLBACKS = Object.freeze(String(config.PROVIDER_FALLBACKS || "")
54
+ .split(",")
55
+ .map((value) => value.trim().toLowerCase())
56
+ .filter(Boolean));
57
+ const UTILITY_PURPOSE_PROVIDERS = Object.freeze({
58
+ dream: String(config.DREAM_PROVIDER || "").trim().toLowerCase() || null,
59
+ introspection: String(config.DREAM_PROVIDER || "").trim().toLowerCase() || null,
60
+ review: String(config.MEMORY_PROVIDER || "").trim().toLowerCase() || null,
61
+ recall: String(config.RECALL_PROVIDER || "").trim().toLowerCase() || null,
62
+ enforcer: String(config.ENFORCER_PROVIDER || "").trim().toLowerCase() || null,
63
+ subagent: String(config.SUBAGENT_PROVIDER || "").trim().toLowerCase() || null,
64
+ });
65
+
66
+ function configuredProviderPath(providerId) {
67
+ if (providerId === "claude") return String(config.CLAUDE_PATH || "").trim() || null;
68
+ if (providerId === "codex") return String(config.CODEX_PATH || "").trim() || null;
69
+ throw Object.assign(new Error(`Unknown provider: ${providerId}`), { code: "UNKNOWN_PROVIDER" });
52
70
  }
53
71
 
54
- function resolveExecutablePath(configured, fallbackCommand, label, opts = {}) {
55
- const raw = (configured || fallbackCommand || "").trim();
56
- if (!raw) {
57
- if (opts.required) {
58
- console.error(`${label} not set`);
59
- process.exit(1);
72
+ function executableCandidate(command, env = process.env) {
73
+ if (!command) return null;
74
+ if (path.isAbsolute(command) || hasPathSeparator(command)) {
75
+ try {
76
+ fs.accessSync(command, fs.constants.X_OK);
77
+ return path.resolve(command);
78
+ } catch (error) {
79
+ return null;
60
80
  }
61
- return null;
62
81
  }
63
-
64
- if (path.isAbsolute(raw) || hasPathSeparator(raw)) {
65
- if (fs.existsSync(raw)) return raw;
66
- if (opts.required) {
67
- console.error(`${label} not found at: ${raw}`);
68
- process.exit(1);
82
+ const suffixes = process.platform === "win32"
83
+ ? String(env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
84
+ : [""];
85
+ for (const directory of String(env.PATH || "").split(path.delimiter).filter(Boolean)) {
86
+ for (const suffix of suffixes) {
87
+ const candidate = path.join(directory, process.platform === "win32" ? command + suffix : command);
88
+ try {
89
+ fs.accessSync(candidate, fs.constants.X_OK);
90
+ return candidate;
91
+ } catch (error) { /* continue */ }
69
92
  }
70
- return null;
71
93
  }
94
+ return null;
95
+ }
72
96
 
73
- try {
74
- return which(raw);
75
- } catch (e) {
76
- if (opts.required) {
77
- console.error(`${label} not found on PATH: ${raw}`);
78
- process.exit(1);
79
- }
80
- return null;
97
+ function resolveExecutablePath(configured, fallbackCommand, _label, _opts = {}) {
98
+ const raw = (configured || fallbackCommand || "").trim();
99
+ return raw ? executableCandidate(raw) : null;
100
+ }
101
+
102
+ function discoverProviderExecutable(providerId, env = process.env) {
103
+ const configured = configuredProviderPath(providerId);
104
+ return executableCandidate(configured || PROVIDER_COMMANDS[providerId], env);
105
+ }
106
+
107
+ function providerExecutableStatus(providerId, env = process.env) {
108
+ const configured = configuredProviderPath(providerId);
109
+ const executable = discoverProviderExecutable(providerId, env);
110
+ if (executable) {
111
+ return { state: "available", executable, configured: !!configured, reason: null };
112
+ }
113
+ if (configured) {
114
+ return { state: "missing", executable: null, configured: true, reason: `Configured ${providerId} executable was not found` };
81
115
  }
116
+ return { state: "unconfigured", executable: null, configured: false, reason: `${providerId} is not configured or installed` };
82
117
  }
83
118
 
84
119
  // Telegram (kept for backwards compat — channels list defaults to telegram)
@@ -86,11 +121,9 @@ const TOKEN = config.TELEGRAM_BOT_TOKEN;
86
121
  const CHAT_IDS = (config.TELEGRAM_CHAT_ID || "").split(",").map((s) => s.trim()).filter(Boolean);
87
122
  const CHAT_ID = CHAT_IDS[0];
88
123
 
89
- const WORKSPACE = config.WORKSPACE;
90
- const CLAUDE_PATH = resolveExecutablePath(config.CLAUDE_PATH, null, "Claude CLI", { required: true });
91
- const CURSOR_PATH = config.CURSOR_PATH || null;
124
+ const WORKSPACE = config.WORKSPACE || null;
125
+ const CLAUDE_PATH = configuredProviderPath("claude");
92
126
  const CODEX_PATH = config.CODEX_PATH || null;
93
- const DEFAULT_CLAUDE_MODEL = config.CLAUDE_MODEL || process.env.CLAUDE_MODEL || "claude-opus-4-8";
94
127
  const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
95
128
  const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
96
129
  const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
@@ -129,31 +162,22 @@ const MAX_VOICE_SIZE = 10 * 1024 * 1024;
129
162
  const MAX_PROCESS_TIMEOUT = 360 * 60 * 1000;
130
163
  const COMPACT_SUMMARY_TIMEOUT = 10 * 60 * 1000;
131
164
 
132
- if (!WORKSPACE) { console.error("WORKSPACE not set"); process.exit(1); }
165
+ // Compatibility values for the pre-registry runner. They contain only an
166
+ // explicitly configured command; fallback PATH discovery remains lazy through
167
+ // discoverProviderExecutable().
168
+ const resolvedCodexPath = CODEX_PATH;
133
169
 
134
- if (!fs.existsSync(WORKSPACE)) {
135
- try {
136
- fs.mkdirSync(WORKSPACE, { recursive: true });
137
- console.error(`Created workspace: ${WORKSPACE}`);
138
- } catch (e) {
139
- console.error(`Failed to create workspace: ${e.message}`);
140
- process.exit(1);
170
+ function ensureRuntimeDirectories() {
171
+ ensureConfigDir(CONFIG_DIR);
172
+ for (const directory of [WORKSPACE, TEMP_DIR, FILES_DIR]) {
173
+ if (directory) fs.mkdirSync(directory, { recursive: true });
141
174
  }
175
+ return { configDir: CONFIG_DIR, workspace: WORKSPACE, tempDir: TEMP_DIR, filesDir: FILES_DIR };
142
176
  }
143
177
 
144
- let resolvedCursorPath = resolveExecutablePath(CURSOR_PATH, "agent", "Cursor Agent CLI");
145
- if (resolvedCursorPath) console.error(`Cursor Agent CLI: ${resolvedCursorPath}`);
146
-
147
- let resolvedCodexPath = resolveExecutablePath(CODEX_PATH, "codex", "Codex CLI");
148
- if (resolvedCodexPath) console.error(`Codex CLI: ${resolvedCodexPath}`);
149
-
150
- if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
151
- if (!fs.existsSync(FILES_DIR)) fs.mkdirSync(FILES_DIR, { recursive: true });
152
-
153
178
  const FULL_PATH = [
154
- path.dirname(CLAUDE_PATH),
155
- resolvedCursorPath ? path.dirname(resolvedCursorPath) : null,
156
- resolvedCodexPath ? path.dirname(resolvedCodexPath) : null,
179
+ CLAUDE_PATH && hasPathSeparator(CLAUDE_PATH) ? path.dirname(CLAUDE_PATH) : null,
180
+ resolvedCodexPath && hasPathSeparator(resolvedCodexPath) ? path.dirname(resolvedCodexPath) : null,
157
181
  path.dirname(process.execPath),
158
182
  FFMPEG ? path.dirname(FFMPEG) : null,
159
183
  WHISPER_CLI ? path.dirname(WHISPER_CLI) : null,
@@ -161,7 +185,8 @@ const FULL_PATH = [
161
185
  ? [process.env.APPDATA, process.env.LOCALAPPDATA].filter(Boolean).map((p) => path.join(p, "npm"))
162
186
  : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
163
187
  ),
164
- ].filter(Boolean).join(path.delimiter);
188
+ ...String(process.env.PATH || "").split(path.delimiter),
189
+ ].filter(Boolean).filter((value, index, values) => values.indexOf(value) === index).join(path.delimiter);
165
190
 
166
191
  // Kazee runs on one fixed host — like Telegram's api.telegram.org it's
167
192
  // infrastructure, not a per-user setting. Hard-coded so onboarding only asks
@@ -229,30 +254,63 @@ function loadChannels() {
229
254
  return channels;
230
255
  }
231
256
 
257
+ // The bot's own control-plane secrets. No subprocess the bot spawns — the
258
+ // Claude/Codex CLIs, sub-agents, the recall walker, tools, and auth flows —
259
+ // ever needs these: the model CLI is authed via CLAUDE_CODE_OAUTH_TOKEN (added
260
+ // on top by claudeSubprocessEnv) or its own API key, and `open-claudia` CLI
261
+ // invocations re-read the .env file from disk. Stripping them from the ambient
262
+ // subprocess env closes the easiest exfil path — a prompt-injected agent (or any
263
+ // child) dumping its own environment can no longer read the bot's Telegram/Kazee
264
+ // tokens or the web control token.
265
+ const CONFINED_SECRET_KEYS = ["TELEGRAM_BOT_TOKEN", "KAZEE_BOT_TOKEN", "BOT_CONTROL_TOKEN"];
266
+
232
267
  function botSubprocessEnv() {
233
268
  // Credential confinement (5b): the operational keyring is deliberately NOT
234
269
  // merged here. Agent shells, sub-agents, and every other bot subprocess run
235
270
  // cred-free; keyring values enter ONLY tool-run subprocesses (tools.runEnv —
236
271
  // the keys a tool declares via --requires) and the logged escape hatch
237
272
  // (`keyring exec --reason`). See core/tool-guard.js for the deny-gate half.
238
- return { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
273
+ const env = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
274
+ for (const k of CONFINED_SECRET_KEYS) delete env[k];
275
+ return env;
276
+ }
277
+
278
+ // Source material for provider-specific allowlist builders. This may contain
279
+ // sensitive config values; core/providers/env.js always reduces and scrubs it
280
+ // before a provider process receives anything.
281
+ function agentEnvSource() {
282
+ return {
283
+ ...process.env,
284
+ ...config,
285
+ PATH: FULL_PATH,
286
+ HOME: process.env.HOME || require("os").homedir(),
287
+ };
239
288
  }
240
289
 
241
290
  module.exports = {
242
291
  config,
292
+ parseEnvText,
293
+ loadEnv,
243
294
  saveEnvKey,
244
295
  loadChannels,
245
296
  botSubprocessEnv,
297
+ agentEnvSource,
298
+ configuredProviderPath,
299
+ discoverProviderExecutable,
300
+ providerExecutableStatus,
301
+ resolveExecutablePath,
302
+ DEFAULT_PROVIDER,
303
+ UTILITY_PROVIDER,
304
+ PROVIDER_FALLBACKS,
305
+ UTILITY_PURPOSE_PROVIDERS,
306
+ ensureRuntimeDirectories,
246
307
  KAZEE_DEFAULT_URL,
247
308
  BOT_DIR,
248
309
  CONFIG_DIR,
249
310
  TOKEN, CHAT_IDS, CHAT_ID,
250
311
  WORKSPACE,
251
312
  CLAUDE_PATH,
252
- DEFAULT_CLAUDE_MODEL,
253
- CURSOR_PATH,
254
313
  CODEX_PATH,
255
- resolvedCursorPath,
256
314
  resolvedCodexPath,
257
315
  AUTO_COMPACT_TOKENS,
258
316
  MIN_COMPACT_INTERVAL_MS,
package/core/doctor.js CHANGED
@@ -2,40 +2,21 @@
2
2
  // before the user spends time on a task that's going to fail anyway.
3
3
 
4
4
  const fs = require("fs");
5
+ const os = require("os");
5
6
  const path = require("path");
6
- const { execSync } = require("child_process");
7
+ const { execFileSync } = require("child_process");
7
8
  const {
8
- CLAUDE_PATH, CURSOR_PATH, CODEX_PATH, resolvedCursorPath, resolvedCodexPath,
9
9
  WHISPER_CLI, WHISPER_MODEL, FFMPEG, WORKSPACE, CONFIG_DIR,
10
10
  botSubprocessEnv,
11
11
  } = require("./config");
12
- const { redactSensitive, stripTerminalControls } = require("./redact");
13
- const { isClaudeAuthErrorText, isCodexAuthErrorText, claudeSubprocessEnv } = require("./auth-flow");
14
-
15
- function shellQuote(value) {
16
- return `"${String(value).replace(/"/g, '\\"')}"`;
17
- }
18
-
19
- function runCommandForDoctor(command, args = [], opts = {}) {
20
- try {
21
- const out = execSync([command, ...args].map(shellQuote).join(" "), {
22
- cwd: opts.cwd || process.env.HOME || require("os").homedir(),
23
- env: opts.env || botSubprocessEnv(),
24
- encoding: "utf-8",
25
- timeout: opts.timeout || 10000,
26
- stdio: ["ignore", "pipe", "pipe"],
27
- });
28
- return { ok: true, output: out.trim(), code: 0 };
29
- } catch (e) {
30
- return { ok: false, output: `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim(), code: e.status ?? 1 };
31
- }
32
- }
12
+ const { redactSensitive } = require("./redact");
13
+ const { inspectProviderConfiguration } = require("./provider-status");
33
14
 
34
15
  function binaryCheck(binPath, name) {
35
16
  if (!binPath) return { ok: false, label: name, detail: "not configured/found" };
36
17
  try {
37
18
  if (fs.existsSync(binPath)) fs.accessSync(binPath, fs.constants.X_OK);
38
- else execSync(process.platform === "win32" ? `where ${shellQuote(binPath)}` : `which ${shellQuote(binPath)}`, { stdio: "ignore", env: botSubprocessEnv() });
19
+ else execFileSync(process.platform === "win32" ? "where" : "which", [String(binPath)], { stdio: "ignore", env: botSubprocessEnv() });
39
20
  return { ok: true, label: name, detail: binPath };
40
21
  } catch (e) {
41
22
  return { ok: false, label: name, detail: `not executable: ${binPath}` };
@@ -55,46 +36,119 @@ function checkWritableDir(dirPath, label) {
55
36
  }
56
37
  }
57
38
 
58
- function summarize(output, ok) {
59
- const clean = redactSensitive(stripTerminalControls(output || "")).replace(/\s+/g, " ").trim();
60
- if (!clean) return ok ? "ok" : "no output";
61
- return clean.length > 160 ? clean.slice(0, 157) + "..." : clean;
39
+ function defaultCodexHookProbe({ binary }) {
40
+ const { prepareCodexHook, probeCodexHookCompatibility } = require("./providers/codex-hook");
41
+ const { codexSubprocessEnv } = require("./auth-flow");
42
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-hook-probe-"));
43
+ try {
44
+ return probeCodexHookCompatibility({
45
+ binary,
46
+ prepared: prepareCodexHook({ cwd: temp }),
47
+ cwd: temp,
48
+ markerFile: path.join(temp, "probe.marker"),
49
+ auditFile: path.join(temp, "probe-audit.jsonl"),
50
+ env: codexSubprocessEnv(),
51
+ // A real model turn, not a fixture: allow for slow first-token latency.
52
+ timeoutMs: 45000,
53
+ });
54
+ } finally {
55
+ fs.rmSync(temp, { recursive: true, force: true });
56
+ }
62
57
  }
63
58
 
64
- function runDoctorChecks() {
59
+ function codexHookEnforcementCheck(providerSummary, options) {
60
+ const label = "Codex tool-hook enforcement (live probe)";
61
+ const codex = providerSummary.providers.find((provider) => provider.id === "codex");
62
+ const ready = codex
63
+ && codex.availability.state === "available"
64
+ && codex.compatibility.state === "compatible"
65
+ && codex.auth.state === "authenticated";
66
+ if (!ready) {
67
+ return {
68
+ ok: true,
69
+ warn: true,
70
+ label,
71
+ detail: codex
72
+ ? `skipped — codex not ready (${codex.availability.state}/${codex.compatibility.state}/${codex.auth.state})`
73
+ : "skipped — codex is not registered",
74
+ action: "",
75
+ };
76
+ }
77
+ const runProbe = options.runCodexHookProbe || defaultCodexHookProbe;
78
+ try {
79
+ const probe = runProbe({ binary: codex.availability.executable });
80
+ return probe.ok
81
+ ? { ok: true, label, detail: "deny rule enforced end-to-end against the real CLI" }
82
+ : {
83
+ ok: false,
84
+ label,
85
+ detail: redactSensitive(probe.diagnostic || "probe failed"),
86
+ action: "Do not run Codex tasks until this passes — update the Codex CLI and re-run /doctor.",
87
+ };
88
+ } catch (e) {
89
+ return {
90
+ ok: false,
91
+ label,
92
+ detail: redactSensitive(e.message),
93
+ action: "Do not run Codex tasks until this passes — update the Codex CLI and re-run /doctor.",
94
+ };
95
+ }
96
+ }
97
+
98
+ function runDoctorChecks(options = {}) {
65
99
  const checks = [];
66
100
  const nodeMajor = parseInt(process.version.slice(1).split(".")[0], 10);
67
101
  checks.push({ ok: nodeMajor >= 18, label: "Node.js", detail: process.version, action: nodeMajor >= 18 ? "" : "Install Node.js 18+." });
68
102
 
69
- const claudeBin = binaryCheck(CLAUDE_PATH, "Claude CLI");
70
- if (claudeBin.ok) {
71
- const ver = runCommandForDoctor(CLAUDE_PATH, ["--version"]);
72
- const auth = runCommandForDoctor(CLAUDE_PATH, ["auth", "status"], { env: claudeSubprocessEnv(), timeout: 12000 });
73
- checks.push({ ok: ver.ok, label: "Claude version", detail: summarize(ver.output, ver.ok), action: ver.ok ? "" : "Check CLAUDE_PATH." });
74
- checks.push({ ok: auth.ok && !isClaudeAuthErrorText(auth.output), label: "Claude auth", detail: summarize(auth.output, auth.ok), action: auth.ok && !isClaudeAuthErrorText(auth.output) ? "" : "Run /auth_status then /setup_token or /login." });
75
- } else checks.push({ ...claudeBin, action: "Install @anthropic-ai/claude-code or fix CLAUDE_PATH." });
76
-
77
- if (CURSOR_PATH || resolvedCursorPath) {
78
- const cursorPath = resolvedCursorPath || CURSOR_PATH;
79
- const cursorBin = binaryCheck(cursorPath, "Cursor Agent");
80
- if (cursorBin.ok) {
81
- const ver = runCommandForDoctor(cursorPath, ["--version"]);
82
- const status = runCommandForDoctor(cursorPath, ["status"], { timeout: 12000 });
83
- checks.push({ ok: ver.ok, label: "Cursor version", detail: summarize(ver.output, ver.ok), action: ver.ok ? "" : "Check CURSOR_PATH." });
84
- checks.push({ ok: status.ok, label: "Cursor auth/status", detail: summarize(status.output, status.ok), action: status.ok ? "" : "Run `agent login` on this host." });
85
- } else checks.push({ ...cursorBin, action: "Install Cursor Agent or fix CURSOR_PATH." });
86
- } else checks.push({ ok: true, warn: true, label: "Cursor Agent", detail: "not configured/found (optional)", action: "Install only if you want /cursor." });
103
+ const providerSummary = inspectProviderConfiguration({
104
+ registry: options.registry,
105
+ executableStatus: options.executableStatus,
106
+ probeAuth: options.probeAuth !== false,
107
+ });
108
+ for (const provider of providerSummary.providers) {
109
+ const available = provider.availability.state === "available";
110
+ const compatible = provider.compatibility.state === "compatible";
111
+ const authenticated = provider.auth.state === "authenticated";
112
+ checks.push({
113
+ ok: available,
114
+ warn: !available,
115
+ label: `${provider.label} availability`,
116
+ detail: available ? provider.availability.executable : provider.availability.state,
117
+ action: available ? "" : provider.recovery,
118
+ });
119
+ checks.push({
120
+ ok: available && compatible,
121
+ warn: !available || !compatible,
122
+ label: `${provider.label} compatibility`,
123
+ detail: provider.compatibility.version
124
+ ? `${provider.compatibility.state} (${provider.compatibility.version})`
125
+ : provider.compatibility.state,
126
+ action: available && !compatible ? provider.recovery : "",
127
+ });
128
+ checks.push({
129
+ ok: available && compatible && authenticated,
130
+ warn: !available || !compatible || !authenticated,
131
+ label: `${provider.label} auth`,
132
+ detail: provider.auth.state,
133
+ action: available && compatible && !authenticated ? provider.recovery : "",
134
+ });
135
+ }
136
+ // The live hook probe drives a real `codex exec` model turn (tokens,
137
+ // seconds), so it is strictly opt-in: only user-invoked /doctor passes it.
138
+ // Boot, health, and setup surfaces must never spawn a model prompt.
139
+ if (options.probeCodexHook === true) {
140
+ checks.push(codexHookEnforcementCheck(providerSummary, options));
141
+ }
87
142
 
88
- if (CODEX_PATH || resolvedCodexPath) {
89
- const codexPath = resolvedCodexPath || CODEX_PATH;
90
- const codexBin = binaryCheck(codexPath, "Codex CLI");
91
- if (codexBin.ok) {
92
- const ver = runCommandForDoctor(codexPath, ["--version"]);
93
- const status = runCommandForDoctor(codexPath, ["login", "status"], { timeout: 12000 });
94
- checks.push({ ok: ver.ok, label: "Codex version", detail: summarize(ver.output, ver.ok), action: ver.ok ? "" : "Check CODEX_PATH." });
95
- checks.push({ ok: status.ok && !isCodexAuthErrorText(status.output), label: "Codex auth", detail: summarize(status.output, status.ok), action: status.ok && !isCodexAuthErrorText(status.output) ? "" : "Run /codex_login or /codex_setup_token." });
96
- } else checks.push({ ...codexBin, action: "Install @openai/codex or fix CODEX_PATH." });
97
- } else checks.push({ ok: true, warn: true, label: "Codex CLI", detail: "not configured/found (optional)", action: "Install only if you want /codex." });
143
+ checks.push({
144
+ ok: !!providerSummary.defaultProvider.providerId,
145
+ warn: !providerSummary.defaultProvider.providerId,
146
+ label: "Default provider",
147
+ detail: providerSummary.defaultProvider.label
148
+ ? `${providerSummary.defaultProvider.label} (${providerSummary.defaultProvider.source})`
149
+ : "none",
150
+ action: providerSummary.defaultProvider.providerId ? "" : providerSummary.defaultProvider.reason,
151
+ });
98
152
 
99
153
  if (FFMPEG || WHISPER_CLI || WHISPER_MODEL) {
100
154
  const ff = binaryCheck(FFMPEG || "ffmpeg", "ffmpeg");