@inetafrica/open-claudia 2.15.0 → 3.0.1

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 (152) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +22 -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 +81 -3
  12. package/channels/telegram/adapter.js +65 -5
  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 +155 -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 +542 -224
  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 +121 -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 +125 -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 +171 -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 +1562 -1286
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/single-instance.js +46 -0
  63. package/core/state.js +1084 -97
  64. package/core/subagent.js +72 -98
  65. package/core/system-prompt.js +462 -279
  66. package/core/tool-guard.js +73 -39
  67. package/core/tools.js +22 -5
  68. package/core/transcripts.js +30 -1
  69. package/core/usage-log.js +19 -4
  70. package/core/utility-agent.js +654 -0
  71. package/core/web-auth.js +29 -13
  72. package/docs/CHANNEL_DESIGN.md +181 -0
  73. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  74. package/docs/PROVIDER_MIGRATION.md +67 -0
  75. package/health.js +50 -39
  76. package/package.json +48 -4
  77. package/setup.js +198 -38
  78. package/soul.md +39 -0
  79. package/test-ability-extraction.js +5 -0
  80. package/test-approval-async.js +63 -4
  81. package/test-cursor-removal.js +337 -0
  82. package/test-enforcer.js +70 -27
  83. package/test-fixtures/current-provider-parity.json +132 -0
  84. package/test-fixtures/fake-agent-cli.js +509 -0
  85. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  86. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  87. package/test-fixtures/migrations/claude-only/state.json +5 -0
  88. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  90. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  92. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  93. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  94. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  95. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  97. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  100. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  101. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  102. package/test-fixtures/migrations/missing-project/state.json +4 -0
  103. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  104. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  105. package/test-fixtures/migrations/multi-user/state.json +6 -0
  106. package/test-fixtures/provider-event-samples.json +17 -0
  107. package/test-learning-e2e.js +5 -0
  108. package/test-memory-mutation-queue.js +191 -0
  109. package/test-provider-boot-matrix.js +399 -0
  110. package/test-provider-bootstrap.js +158 -0
  111. package/test-provider-capabilities.js +232 -0
  112. package/test-provider-claude.js +206 -0
  113. package/test-provider-codex.js +228 -0
  114. package/test-provider-compaction.js +371 -0
  115. package/test-provider-core-prompt.js +264 -0
  116. package/test-provider-coupling-audit.js +90 -0
  117. package/test-provider-dream.js +312 -0
  118. package/test-provider-enforcer.js +252 -0
  119. package/test-provider-env-isolation.js +150 -0
  120. package/test-provider-events.js +171 -0
  121. package/test-provider-fixture.js +332 -0
  122. package/test-provider-gateway.js +508 -0
  123. package/test-provider-language.js +89 -0
  124. package/test-provider-migration-backup.js +424 -0
  125. package/test-provider-pack-review.js +436 -0
  126. package/test-provider-parity-e2e.js +537 -0
  127. package/test-provider-prompt-parity.js +89 -0
  128. package/test-provider-recall.js +271 -0
  129. package/test-provider-registry.js +251 -0
  130. package/test-provider-resume-parity.js +41 -0
  131. package/test-provider-run-lifecycle.js +349 -0
  132. package/test-provider-runner.js +271 -0
  133. package/test-provider-scheduler.js +689 -0
  134. package/test-provider-session-commands.js +185 -0
  135. package/test-provider-session-history.js +205 -0
  136. package/test-provider-side-chat.js +337 -0
  137. package/test-provider-state-migration.js +316 -0
  138. package/test-provider-stream-decoder.js +69 -0
  139. package/test-provider-subagent.js +228 -0
  140. package/test-provider-tool-hooks.js +360 -0
  141. package/test-recall-discoverer.js +1 -0
  142. package/test-recall-engine.js +3 -3
  143. package/test-recall-evolution.js +18 -0
  144. package/test-runner-watchdog-static.js +16 -8
  145. package/test-single-runtime.js +35 -0
  146. package/test-telegram-poll-recovery.js +93 -0
  147. package/test-tool-guard.js +56 -0
  148. package/test-tools.js +19 -0
  149. package/test-unified-identity.js +3 -1
  150. package/test-usage-accounting.js +92 -20
  151. package/test-utility-provider-policy.js +486 -0
  152. package/web.js +130 -35
package/core/approvals.js CHANGED
@@ -18,9 +18,15 @@ const fs = require("fs");
18
18
  const path = require("path");
19
19
  const crypto = require("crypto");
20
20
  const CONFIG_DIR = require("../config-dir");
21
+ const { atomicWriteFileSync } = require("./fsutil");
21
22
 
22
23
  const APPROVALS_DIR = process.env.APPROVALS_DIR || path.join(CONFIG_DIR, "approvals");
23
24
  const MAX_AGE_MS = 24 * 60 * 60 * 1000;
25
+ const LOCK_STALE_MS = 30 * 1000;
26
+ const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
27
+ // Both approval UIs render the complete command. Keeping this comfortably
28
+ // below chat message limits prevents an unseen suffix from being approved.
29
+ const MAX_COMMAND_BYTES = 512;
24
30
 
25
31
  function fileOf(id) {
26
32
  const base = path.basename(String(id || "").trim());
@@ -28,13 +34,71 @@ function fileOf(id) {
28
34
  return path.join(APPROVALS_DIR, `${base}.json`);
29
35
  }
30
36
 
37
+ function commandHash(command) {
38
+ return crypto.createHash("sha256").update(String(command || "")).digest("hex");
39
+ }
40
+
41
+ function approvedCommand(command) {
42
+ const value = String(command || "");
43
+ if (Buffer.byteLength(value) > MAX_COMMAND_BYTES) {
44
+ const error = new Error(`approval command exceeds ${MAX_COMMAND_BYTES} bytes`);
45
+ error.code = "APPROVAL_PAYLOAD_TOO_LARGE";
46
+ throw error;
47
+ }
48
+ return value;
49
+ }
50
+
51
+ function quoteArg(value) {
52
+ const text = String(value);
53
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
54
+ return `'${text.replace(/'/g, `'"'"'`)}'`;
55
+ }
56
+
57
+ function formatApprovedCommand(argv) {
58
+ if (!Array.isArray(argv) || argv.some((arg) => typeof arg !== "string")) {
59
+ const error = new TypeError("approved command argv must be an array of strings");
60
+ error.code = "INVALID_APPROVAL_COMMAND";
61
+ throw error;
62
+ }
63
+ return argv.map(quoteArg).join(" ");
64
+ }
65
+
66
+ function writeRecord(rec) {
67
+ atomicWriteFileSync(fileOf(rec.id), `${JSON.stringify(rec, null, 2)}\n`, { mode: 0o600 });
68
+ }
69
+
70
+ function acquireRecordLock(id) {
71
+ let lock;
72
+ try { lock = `${fileOf(id)}.lock`; } catch (_) { return null; }
73
+ for (let attempt = 0; attempt < 21; attempt += 1) {
74
+ try { return { fd: fs.openSync(lock, "wx", 0o600), lock }; }
75
+ catch (error) {
76
+ if (error.code !== "EEXIST") return null;
77
+ try {
78
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
79
+ fs.rmSync(lock, { force: true });
80
+ continue;
81
+ }
82
+ } catch (_) { continue; }
83
+ Atomics.wait(LOCK_WAIT, 0, 0, 5);
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function releaseRecordLock(held) {
90
+ if (!held) return;
91
+ try { fs.closeSync(held.fd); } catch (_) {}
92
+ try { fs.rmSync(held.lock, { force: true }); } catch (_) {}
93
+ }
94
+
31
95
  // Best-effort GC so decided/expired records don't accumulate forever.
32
96
  function prune() {
33
97
  let entries;
34
98
  try { entries = fs.readdirSync(APPROVALS_DIR); } catch (e) { return; }
35
99
  const cutoff = Date.now() - MAX_AGE_MS;
36
100
  for (const f of entries) {
37
- if (!f.endsWith(".json")) continue;
101
+ if (!f.endsWith(".json") && !f.endsWith(".lock")) continue;
38
102
  const p = path.join(APPROVALS_DIR, f);
39
103
  try { if (fs.statSync(p).mtimeMs < cutoff) fs.rmSync(p, { force: true }); } catch (e) {}
40
104
  }
@@ -49,6 +113,8 @@ function prune() {
49
113
  // `target*` external channel, and person/entity pointers for Always-allow.
50
114
  function create({
51
115
  tool, verb = "", tier = "destructive", command = "", channelId = "", adapter = "",
116
+ canonicalUserId = "", userId = "", provider = "", providerSettings = {},
117
+ project = "", projectDir = "", sessionId = "", previousSessionId = "", originRunId = "",
52
118
  kind = "tool", approverAdapter = "", approverChannel = "",
53
119
  payloadText = "", targetAdapter = "", targetChannel = "",
54
120
  personName = "", personId = "", entitySlug = "",
@@ -56,11 +122,18 @@ function create({
56
122
  fs.mkdirSync(APPROVALS_DIR, { recursive: true, mode: 0o700 });
57
123
  prune();
58
124
  const id = `apr_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
125
+ const exactCommand = approvedCommand(command);
59
126
  const rec = {
60
127
  id, status: "pending",
61
128
  tool: String(tool || ""), verb: String(verb || ""), tier: String(tier || ""),
62
- command: String(command || "").slice(0, 1000),
129
+ command: exactCommand,
130
+ commandHash: commandHash(exactCommand),
63
131
  channelId: String(channelId || ""), adapter: String(adapter || ""),
132
+ canonicalUserId: String(canonicalUserId || ""), userId: String(userId || ""),
133
+ provider: String(provider || ""), providerSettings: providerSettings && typeof providerSettings === "object" ? structuredClone(providerSettings) : {},
134
+ project: String(project || ""), projectDir: String(projectDir || ""),
135
+ sessionId: String(sessionId || ""), previousSessionId: String(previousSessionId || ""),
136
+ originRunId: String(originRunId || ""),
64
137
  kind: String(kind || "tool"),
65
138
  approverAdapter: String(approverAdapter || ""), approverChannel: String(approverChannel || ""),
66
139
  payloadText: String(payloadText || "").slice(0, 4000),
@@ -68,7 +141,7 @@ function create({
68
141
  personName: String(personName || ""), personId: String(personId || ""), entitySlug: String(entitySlug || ""),
69
142
  requestedAt: new Date().toISOString(),
70
143
  };
71
- fs.writeFileSync(fileOf(id), JSON.stringify(rec, null, 2), { mode: 0o600 });
144
+ writeRecord(rec);
72
145
  return rec;
73
146
  }
74
147
 
@@ -80,14 +153,18 @@ function read(id) {
80
153
  // Flip a pending record. Idempotence guard: a second button press on the same
81
154
  // message must not overturn the first decision.
82
155
  function decide(id, status, by = "") {
83
- const rec = read(id);
84
- if (!rec) return null;
85
- if (rec.status !== "pending") return rec;
86
- rec.status = status === "approved" ? "approved" : "denied";
87
- rec.decidedAt = new Date().toISOString();
88
- if (by) rec.decidedBy = String(by);
89
- fs.writeFileSync(fileOf(id), JSON.stringify(rec, null, 2), { mode: 0o600 });
90
- return rec;
156
+ const held = acquireRecordLock(id);
157
+ if (!held) return read(id);
158
+ try {
159
+ const rec = read(id);
160
+ if (!rec) return null;
161
+ if (rec.status !== "pending") return rec;
162
+ rec.status = status === "approved" ? "approved" : "denied";
163
+ rec.decidedAt = new Date().toISOString();
164
+ if (by) rec.decidedBy = String(by);
165
+ writeRecord(rec);
166
+ return rec;
167
+ } finally { releaseRecordLock(held); }
91
168
  }
92
169
 
93
170
  // Poll until decided or timeout. Timeout resolves as "timeout" — the caller
@@ -107,29 +184,59 @@ async function waitForDecision(id, { timeoutMs = 90000, pollMs = 1500 } = {}) {
107
184
  // actions.js uses this flag to know a later button press must wake the agent
108
185
  // (an attached waiter reacts by itself, a detached one cannot).
109
186
  function detach(id) {
110
- const rec = read(id);
111
- if (!rec || rec.status !== "pending") return rec;
112
- rec.detached = true;
113
- rec.detachedAt = new Date().toISOString();
114
- fs.writeFileSync(fileOf(id), JSON.stringify(rec, null, 2), { mode: 0o600 });
115
- return rec;
187
+ const held = acquireRecordLock(id);
188
+ if (!held) return read(id);
189
+ try {
190
+ const rec = read(id);
191
+ if (!rec || rec.status !== "pending") return rec;
192
+ rec.detached = true;
193
+ rec.detachedAt = new Date().toISOString();
194
+ writeRecord(rec);
195
+ return rec;
196
+ } finally { releaseRecordLock(held); }
116
197
  }
117
198
 
118
199
  // One-shot token redemption for the async path. Fail-closed on every branch:
119
200
  // only an approved, un-consumed, un-expired record whose stored command is
120
201
  // byte-identical to what is about to run can be consumed — and only once.
121
202
  function consume(id, commandLine) {
122
- const rec = read(id);
123
- if (!rec) return { ok: false, reason: "unknown or expired approval id" };
124
- if (rec.status !== "approved") return { ok: false, reason: `approval is ${rec.status}, not approved` };
125
- if (rec.consumedAt) return { ok: false, reason: "approval already consumed (single use)" };
126
- if (Date.now() - Date.parse(rec.requestedAt) > MAX_AGE_MS) return { ok: false, reason: "approval expired (24h)" };
127
- if (String(commandLine || "").slice(0, 1000) !== rec.command) {
128
- return { ok: false, reason: "command does not match the approved payload" };
129
- }
130
- rec.consumedAt = new Date().toISOString();
131
- fs.writeFileSync(fileOf(id), JSON.stringify(rec, null, 2), { mode: 0o600 });
132
- return { ok: true, record: rec };
203
+ try { fileOf(id); }
204
+ catch (_) { return { ok: false, reason: "unknown or expired approval id" }; }
205
+ const held = acquireRecordLock(id);
206
+ if (!held) return { ok: false, reason: "approval redemption could not be locked" };
207
+ try {
208
+ const rec = read(id);
209
+ if (!rec) return { ok: false, reason: "unknown or expired approval id" };
210
+ if (rec.status !== "approved") return { ok: false, reason: `approval is ${rec.status}, not approved` };
211
+ if (rec.consumedAt) return { ok: false, reason: "approval already consumed (single use)" };
212
+ if (Date.now() - Date.parse(rec.requestedAt) > MAX_AGE_MS) return { ok: false, reason: "approval expired (24h)" };
213
+ let candidate;
214
+ try { candidate = approvedCommand(commandLine); }
215
+ catch (_) { return { ok: false, reason: "command does not match the approved payload" }; }
216
+ // Records created before commandHash was introduced remain redeemable only
217
+ // when their stored payload is fully byte-identical and small enough to
218
+ // have been displayed in full. Longer legacy records safely require a new
219
+ // approval instead of accepting a hidden or previously truncated suffix.
220
+ const matches = rec.commandHash
221
+ ? candidate === rec.command && commandHash(candidate) === rec.commandHash
222
+ : Buffer.byteLength(String(rec.command || "")) <= MAX_COMMAND_BYTES
223
+ && candidate === String(rec.command || "");
224
+ if (!matches) return { ok: false, reason: "command does not match the approved payload" };
225
+ rec.consumedAt = new Date().toISOString();
226
+ writeRecord(rec);
227
+ return { ok: true, record: rec };
228
+ } finally { releaseRecordLock(held); }
133
229
  }
134
230
 
135
- module.exports = { APPROVALS_DIR, create, read, decide, waitForDecision, detach, consume };
231
+ module.exports = {
232
+ APPROVALS_DIR,
233
+ MAX_COMMAND_BYTES,
234
+ commandHash,
235
+ consume,
236
+ create,
237
+ decide,
238
+ detach,
239
+ formatApprovedCommand,
240
+ read,
241
+ waitForDecision,
242
+ };
package/core/auth-flow.js CHANGED
@@ -3,12 +3,13 @@
3
3
  // core/handlers; this module owns the heavy lifting they call into.
4
4
 
5
5
  const fs = require("fs");
6
- const { spawn, execSync } = require("child_process");
6
+ const { spawn, execFileSync } = require("child_process");
7
7
  const {
8
8
  config, saveEnvKey,
9
- CLAUDE_PATH, resolvedCodexPath,
10
- botSubprocessEnv,
9
+ discoverProviderExecutable,
10
+ botSubprocessEnv, agentEnvSource,
11
11
  } = require("./config");
12
+ const { claudeAgentEnv, codexAgentEnv } = require("./providers/env");
12
13
  const { vault } = require("./vault-store");
13
14
  const { redactSensitive, stripTerminalControls, extractUrls } = require("./redact");
14
15
  const { send } = require("./io");
@@ -28,10 +29,27 @@ function getClaudeOAuthToken() {
28
29
  }
29
30
 
30
31
  function claudeSubprocessEnv() {
31
- const env = botSubprocessEnv();
32
32
  const token = getClaudeOAuthToken().value;
33
- if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
34
- return env;
33
+ return claudeAgentEnv(agentEnvSource(), {
34
+ CLAUDE_CODE_OAUTH_TOKEN: token,
35
+ ANTHROPIC_API_KEY: config.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY,
36
+ CLAUDE_CONFIG_DIR: config.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR,
37
+ });
38
+ }
39
+
40
+ function codexSubprocessEnv() {
41
+ return codexAgentEnv(agentEnvSource(), {
42
+ OPENAI_API_KEY: config.OPENAI_API_KEY || process.env.OPENAI_API_KEY,
43
+ CODEX_HOME: config.CODEX_HOME || process.env.CODEX_HOME,
44
+ });
45
+ }
46
+
47
+ function codexExecutable() {
48
+ return discoverProviderExecutable("codex");
49
+ }
50
+
51
+ function claudeExecutable() {
52
+ return discoverProviderExecutable("claude");
35
53
  }
36
54
 
37
55
  function saveClaudeOAuthToken(token) {
@@ -179,8 +197,10 @@ function claudeUsageLimitMessage(details = "") {
179
197
  }
180
198
 
181
199
  function runClaudeAuthStatusDiagnostic() {
200
+ const binary = claudeExecutable();
201
+ if (!binary) return "Claude CLI not found";
182
202
  try {
183
- const output = execSync(`"${CLAUDE_PATH}" auth status`, {
203
+ const output = execFileSync(binary, ["auth", "status"], {
184
204
  cwd: process.env.HOME || require("os").homedir(),
185
205
  env: claudeSubprocessEnv(),
186
206
  encoding: "utf8",
@@ -193,6 +213,25 @@ function runClaudeAuthStatusDiagnostic() {
193
213
  }
194
214
  }
195
215
 
216
+ function claudeProviderAuthStatus(options = {}) {
217
+ const binary = options.binary || claudeExecutable();
218
+ if (!binary) return { state: "not_checked", reason: "Claude CLI not found" };
219
+ const tokenInfo = getClaudeOAuthToken();
220
+ if (tokenInfo.value) {
221
+ return { state: "authenticated", reason: null, method: "oauth_token", source: tokenInfo.source };
222
+ }
223
+ const runner = options.runCommand || runCommand;
224
+ const result = runner(binary, ["auth", "status"], {
225
+ env: claudeSubprocessEnv(),
226
+ timeout: 12000,
227
+ });
228
+ if (isClaudeAuthErrorText(result.output)) {
229
+ return { state: "unauthenticated", reason: summarizeAuthOutput(result.output, false) };
230
+ }
231
+ if (result.ok) return { state: "authenticated", reason: null, method: "cli_store", source: null };
232
+ return { state: "unknown", reason: summarizeAuthOutput(result.output, false) };
233
+ }
234
+
196
235
  async function sendClaudeAuthStatusSummary(prefix = "Claude auth status") {
197
236
  const output = runClaudeAuthStatusDiagnostic();
198
237
  const tokenInfo = getClaudeOAuthToken();
@@ -219,8 +258,13 @@ async function runClaudeAuthCommand(args, label, opts = {}) {
219
258
  await send(`Another Claude auth flow is already running (${state.pendingClaudeAuthLabel}). Send the requested code/token or wait for it to finish.`);
220
259
  return;
221
260
  }
261
+ const binary = claudeExecutable();
262
+ if (!binary) {
263
+ await send("Claude CLI not found. Install it or configure CLAUDE_PATH.");
264
+ return;
265
+ }
222
266
  await send(`${label} started...`);
223
- const proc = spawn(CLAUDE_PATH, args, {
267
+ const proc = spawn(binary, args, {
224
268
  cwd: process.env.HOME || require("os").homedir(),
225
269
  env: claudeSubprocessEnv(),
226
270
  stdio: ["pipe", "pipe", "pipe"],
@@ -298,7 +342,7 @@ function clearPendingCodexAuth(state = currentState()) {
298
342
 
299
343
  function runCommand(command, args = [], opts = {}) {
300
344
  try {
301
- const out = execSync([command, ...args].map((v) => `"${String(v).replace(/"/g, '\\"')}"`).join(" "), {
345
+ const out = execFileSync(command, args.map(String), {
302
346
  cwd: opts.cwd || process.env.HOME || require("os").homedir(),
303
347
  env: opts.env || botSubprocessEnv(),
304
348
  encoding: "utf-8",
@@ -312,28 +356,61 @@ function runCommand(command, args = [], opts = {}) {
312
356
  }
313
357
 
314
358
  function runCodexLoginStatus() {
315
- if (!resolvedCodexPath) return { ok: false, code: 1, output: "Codex CLI not found" };
316
- const result = runCommand(resolvedCodexPath, ["login", "status"], { timeout: 12000 });
359
+ const binary = codexExecutable();
360
+ if (!binary) return { ok: false, code: 1, output: "Codex CLI not found" };
361
+ const result = runCommand(binary, ["login", "status"], { timeout: 12000, env: codexSubprocessEnv() });
317
362
  return { ...result, ok: result.ok && !isCodexAuthErrorText(result.output) };
318
363
  }
319
364
 
365
+ function codexProviderAuthStatus(options = {}) {
366
+ const binary = options.binary || codexExecutable();
367
+ if (!binary) return { state: "not_checked", reason: "Codex CLI not found" };
368
+ const runner = options.runCommand || runCommand;
369
+ const result = runner(binary, ["login", "status"], {
370
+ env: codexSubprocessEnv(),
371
+ timeout: 12000,
372
+ });
373
+ if (result.ok && !isCodexAuthErrorText(result.output)) {
374
+ return { state: "authenticated", reason: null, method: "cli_store" };
375
+ }
376
+ if (isCodexAuthErrorText(result.output)) {
377
+ return { state: "unauthenticated", reason: summarizeAuthOutput(result.output, false) };
378
+ }
379
+ return { state: "unknown", reason: summarizeAuthOutput(result.output, false) };
380
+ }
381
+
382
+ function codexAuthHelp(reason = "Codex authentication failed") {
383
+ return [
384
+ `Codex authentication needs attention: ${redactSensitive(reason)}`,
385
+ "",
386
+ "Try one of these from chat:",
387
+ "1. /codex_auth_status — check the local Codex login state",
388
+ "2. /codex_login — start device-code login",
389
+ "3. /codex_setup_token — store an existing OpenAI API key through the Codex CLI",
390
+ "",
391
+ "Or run `codex login` in a terminal on the bot host.",
392
+ ].join("\n");
393
+ }
394
+
320
395
  // Quick "is this provider usable non-interactively" check for the unified
321
396
  // /login model picker. Claude is cheap (token presence is what the bot relies
322
397
  // on). Codex spawns `codex login status` once — fine for a setup screen.
323
398
  function providerAuthStatus() {
399
+ const binary = codexExecutable();
324
400
  return {
325
401
  claude: !!getClaudeOAuthToken().value,
326
- codex: resolvedCodexPath ? runCodexLoginStatus().ok : false,
402
+ codex: binary ? runCodexLoginStatus().ok : false,
327
403
  };
328
404
  }
329
405
 
330
406
  async function sendCodexAuthStatusSummary(prefix = "Codex auth status") {
407
+ const binary = codexExecutable();
331
408
  const status = runCodexLoginStatus();
332
- const version = resolvedCodexPath ? runCommand(resolvedCodexPath, ["--version"]) : { ok: false, output: "not found" };
409
+ const version = binary ? runCommand(binary, ["--version"], { env: codexSubprocessEnv() }) : { ok: false, output: "not found" };
333
410
  await send([
334
411
  prefix,
335
412
  "",
336
- `CLI: ${resolvedCodexPath ? "found" : "not found"}`,
413
+ `CLI: ${binary ? "found" : "not found"}`,
337
414
  `Version: ${summarizeAuthOutput(version.output, version.ok)}`,
338
415
  `Logged in: ${status.ok ? "yes" : "no/unknown"}`,
339
416
  `Status: ${summarizeAuthOutput(status.output, status.ok)}`,
@@ -342,10 +419,12 @@ async function sendCodexAuthStatusSummary(prefix = "Codex auth status") {
342
419
  }
343
420
 
344
421
  async function saveCodexApiKeyWithCli(apiKey) {
422
+ const binary = codexExecutable();
423
+ if (!binary) return { ok: false, code: 1, output: "Codex CLI not found" };
345
424
  return new Promise((resolve) => {
346
- const proc = spawn(resolvedCodexPath, ["login", "--with-api-key"], {
425
+ const proc = spawn(binary, ["login", "--with-api-key"], {
347
426
  cwd: process.env.HOME || require("os").homedir(),
348
- env: botSubprocessEnv(),
427
+ env: codexSubprocessEnv(),
349
428
  stdio: ["pipe", "pipe", "pipe"],
350
429
  });
351
430
  let output = "";
@@ -360,12 +439,13 @@ async function saveCodexApiKeyWithCli(apiKey) {
360
439
 
361
440
  async function runCodexDeviceLogin() {
362
441
  const state = currentState();
363
- if (!resolvedCodexPath) return send("Codex CLI not found. Install: npm install -g @openai/codex");
442
+ const binary = codexExecutable();
443
+ if (!binary) return send("Codex CLI not found. Install: npm install -g @openai/codex");
364
444
  if (state.pendingCodexAuthProcess) return send(`Another Codex auth flow is already running (${state.pendingCodexAuthLabel}). Send /cancel_codex_auth to cancel.`);
365
445
  await send("Codex device login started. I'll send the URL/code if the CLI prints one.");
366
- const proc = spawn(resolvedCodexPath, ["login", "--device-auth"], {
446
+ const proc = spawn(binary, ["login", "--device-auth"], {
367
447
  cwd: process.env.HOME || require("os").homedir(),
368
- env: botSubprocessEnv(),
448
+ env: codexSubprocessEnv(),
369
449
  stdio: ["pipe", "pipe", "pipe"],
370
450
  });
371
451
  state.pendingCodexAuthProcess = proc;
@@ -416,6 +496,8 @@ module.exports = {
416
496
  CLAUDE_OAUTH_TOKEN_KEY, CLAUDE_OAUTH_VAULT_KEY,
417
497
  getClaudeOAuthToken,
418
498
  claudeSubprocessEnv,
499
+ claudeProviderAuthStatus,
500
+ codexSubprocessEnv,
419
501
  saveClaudeOAuthToken,
420
502
  clearClaudeOAuthToken,
421
503
  extractClaudeToken,
@@ -437,6 +519,8 @@ module.exports = {
437
519
  clearPendingCodexAuth,
438
520
  runCommand,
439
521
  runCodexLoginStatus,
522
+ codexProviderAuthStatus,
523
+ codexAuthHelp,
440
524
  providerAuthStatus,
441
525
  sendCodexAuthStatusSummary,
442
526
  saveCodexApiKeyWithCli,
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ function resolveConfigDir(env = process.env) {
8
+ const configured = String(env.OPEN_CLAUDIA_CONFIG_DIR || "").trim();
9
+ if (configured) return path.resolve(configured);
10
+ const home = String(env.HOME || os.homedir()).trim();
11
+ return path.resolve(home, ".open-claudia");
12
+ }
13
+
14
+ function ensureConfigDir(configDir = resolveConfigDir()) {
15
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
16
+ return configDir;
17
+ }
18
+
19
+ module.exports = { resolveConfigDir, ensureConfigDir };