@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
@@ -25,8 +25,39 @@ const CONFIG_DIR = require("../config-dir");
25
25
  const GUARD_LOG = process.env.TOOL_GUARD_LOG || path.join(CONFIG_DIR, "tool-guard.jsonl");
26
26
  const SETTINGS_FILE = process.env.DENY_GATE_SETTINGS || path.join(CONFIG_DIR, "deny-gate.settings.json");
27
27
 
28
- // The authorised channels themselves must never be denied.
29
- const ALLOW_RE = /^\s*open-claudia\s+(?:tool\s+run|keyring\s+exec)\b/;
28
+ // The authorised channels themselves must never be denied, but the exemption
29
+ // covers one complete outer shell command only. A prefix such as
30
+ // `open-claudia tool run ... && curl ...` must not bless the raw suffix, and
31
+ // command/process substitutions execute before the authorised CLI sees argv.
32
+ const AUTHORIZED_PREFIX_RE = /^\s*open-claudia\s+(?:tool\s+run|keyring\s+exec)\b/;
33
+
34
+ function isAuthorizedChannelCommand(command) {
35
+ const value = String(command || "");
36
+ if (!AUTHORIZED_PREFIX_RE.test(value)) return false;
37
+ let single = false;
38
+ let double = false;
39
+ for (let index = 0; index < value.length; index += 1) {
40
+ const char = value[index];
41
+ if (char === "\\" && !single) {
42
+ index += 1;
43
+ continue;
44
+ }
45
+ if (char === "'" && !double) {
46
+ single = !single;
47
+ continue;
48
+ }
49
+ if (char === '"' && !single) {
50
+ double = !double;
51
+ continue;
52
+ }
53
+ if (single) continue;
54
+ if (char === "`" || ((char === "$" || char === "<" || char === ">") && value[index + 1] === "(")) {
55
+ return false;
56
+ }
57
+ if (!double && /[;&|\r\n]/.test(char)) return false;
58
+ }
59
+ return !single && !double;
60
+ }
30
61
 
31
62
  // An acting network/DB/shell-access binary at a command position (start of
32
63
  // string or after a separator). Deliberately excludes read-only diagnosis
@@ -79,7 +110,7 @@ const RULES = [
79
110
  function matchRule(command) {
80
111
  const cmd = String(command || "");
81
112
  if (!cmd.trim()) return null;
82
- if (ALLOW_RE.test(cmd)) return null;
113
+ if (isAuthorizedChannelCommand(cmd)) return null;
83
114
  for (const rule of RULES) {
84
115
  if (rule.raw) {
85
116
  if (rule.raw.test(cmd)) return rule;
@@ -98,6 +129,30 @@ function denyMessage(rule) {
98
129
  );
99
130
  }
100
131
 
132
+ // Provider-neutral command policy. This function deliberately performs no
133
+ // payload parsing, configuration reads, logging, or process/exit translation;
134
+ // those are transport concerns owned by the Claude and Codex hook adapters.
135
+ function evaluateCommandPolicy(command, { mode = "strict" } = {}) {
136
+ if (mode !== "strict" && mode !== "relaxed") {
137
+ const error = new Error(`Invalid tool policy mode: ${mode}`);
138
+ error.code = "INVALID_TOOL_POLICY_MODE";
139
+ throw error;
140
+ }
141
+ const rule = matchRule(command);
142
+ if (!rule) {
143
+ return { decision: "allow", ruleId: null, message: "", auditKind: null };
144
+ }
145
+ if (mode === "relaxed") {
146
+ return { decision: "allow", ruleId: rule.id, message: "", auditKind: "relaxed-pass" };
147
+ }
148
+ return {
149
+ decision: "deny",
150
+ ruleId: rule.id,
151
+ message: denyMessage(rule),
152
+ auditKind: "deny",
153
+ };
154
+ }
155
+
101
156
  // Append-only audit trail — read by the 5c KPI pass. Best-effort: a logging
102
157
  // failure must not turn into a denial or a crash.
103
158
  function logGuardEvent(evt) {
@@ -141,7 +196,7 @@ const RAW_OP_PATTERNS = [
141
196
  function matchRawOp(command) {
142
197
  const cmd = String(command || "");
143
198
  if (!cmd.trim()) return null;
144
- if (ALLOW_RE.test(cmd)) return null;
199
+ if (isAuthorizedChannelCommand(cmd)) return null;
145
200
  if (matchRule(cmd)) return null;
146
201
  for (const p of RAW_OP_PATTERNS) if (p.re.test(cmd)) return p;
147
202
  return null;
@@ -191,57 +246,36 @@ function runHook(payload) {
191
246
  try {
192
247
  if (!data || data.tool_name !== "Bash") return { exit: 0, stderr: "" };
193
248
  const command = data.tool_input && data.tool_input.command;
194
- const rule = matchRule(command);
195
- if (!rule) return { exit: 0, stderr: "" };
196
249
  // Relaxed tooling mode: the gate observes but never blocks. The event is
197
250
  // still logged (as relaxed-pass, not deny) so the dream KPI can report
198
251
  // what WOULD have been denied — visibility survives the mode switch.
199
- let relaxed = false;
200
- try { relaxed = require("./tooling-mode").getToolingMode() === "relaxed"; } catch (e) { /* fail-strict */ }
201
- if (relaxed) {
202
- logGuardEvent({ kind: "relaxed-pass", rule: rule.id, command: String(command).slice(0, 300) });
203
- return { exit: 0, stderr: "" };
252
+ let mode = "strict";
253
+ try { mode = require("./tooling-mode").getToolingMode(); } catch (e) { /* fail-strict */ }
254
+ const policy = evaluateCommandPolicy(command, { mode });
255
+ if (policy.auditKind) {
256
+ logGuardEvent({ kind: policy.auditKind, rule: policy.ruleId, command: String(command).slice(0, 300) });
204
257
  }
205
- logGuardEvent({ kind: "deny", rule: rule.id, command: String(command).slice(0, 300) });
206
- return { exit: 2, stderr: denyMessage(rule) };
258
+ return policy.decision === "deny"
259
+ ? { exit: 2, stderr: policy.message }
260
+ : { exit: 0, stderr: "" };
207
261
  } catch (e) {
208
262
  return { exit: 0, stderr: "" };
209
263
  }
210
264
  }
211
265
 
212
- // The claude --settings payload wiring the hook in. Absolute node + cli paths
213
- // so it works regardless of the subprocess PATH; 10s timeout so a hung gate
214
- // can't stall a turn (hook timeout → claude proceeds, i.e. fail-open).
266
+ // Compatibility wrappers for callers that predate provider adapters. New code
267
+ // should use core/providers/claude-hook directly; delegating here keeps the old
268
+ // API without retaining a second, weaker settings writer.
215
269
  function hookSettings() {
216
- const cli = path.resolve(__dirname, "..", "bin", "cli.js");
217
- return {
218
- hooks: {
219
- PreToolUse: [
220
- {
221
- matcher: "Bash",
222
- hooks: [
223
- { type: "command", command: `${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} deny-gate-hook`, timeout: 10 },
224
- ],
225
- },
226
- ],
227
- },
228
- };
270
+ return require("./providers/claude-hook").buildClaudeHookSettings();
229
271
  }
230
272
 
231
- // Write (or refresh) the settings file and return its path for --settings.
232
- // Content-compared so repeated spawns don't churn the file. Throws only up to
233
- // the caller's try/catch — a failure there just means no gate this turn.
234
273
  function ensureHookSettings() {
235
- const body = JSON.stringify(hookSettings(), null, 2) + "\n";
236
- try {
237
- if (fs.readFileSync(SETTINGS_FILE, "utf-8") === body) return SETTINGS_FILE;
238
- } catch (e) { /* missing/unreadable → write */ }
239
- fs.writeFileSync(SETTINGS_FILE, body);
240
- return SETTINGS_FILE;
274
+ return require("./providers/claude-hook").ensureClaudeHookSettings({ settingsFile: SETTINGS_FILE });
241
275
  }
242
276
 
243
277
  module.exports = {
244
278
  RULES, RAW_OP_PATTERNS, GUARD_LOG, SETTINGS_FILE,
245
- matchRule, denyMessage, logGuardEvent, runHook, hookSettings, ensureHookSettings,
279
+ isAuthorizedChannelCommand, matchRule, denyMessage, evaluateCommandPolicy, logGuardEvent, runHook, hookSettings, ensureHookSettings,
246
280
  matchRawOp, noteRawOp, readGuardEvents,
247
281
  };
package/core/tools.js CHANGED
@@ -949,13 +949,26 @@ function runGate(tool, argv = [], env = process.env) {
949
949
 
950
950
  // Env for running a tool: the bot's cred-free subprocess env plus ONLY the
951
951
  // keyring keys this tool declares via --requires — least-privilege credential
952
- // injection (5b). Undeclared keys never reach the subprocess; process.env wins
953
- // on conflict (bot infra config stays authoritative). Keys read fresh each run
954
- // so a cred set mid-session is live immediately.
952
+ // injection (5b). Provider/control credentials inherited by the Open Claudia
953
+ // CLI are stripped before declared keyring values are added, so a tool cannot
954
+ // accidentally inherit the active model provider's auth. Other ambient values
955
+ // retain the legacy process-env precedence. Keys are read fresh each run.
955
956
  function runEnv(tool) {
956
957
  let base;
957
958
  try { base = require("./config").botSubprocessEnv(); }
958
959
  catch (e) { base = { ...process.env }; }
960
+ try {
961
+ const { ALWAYS_STRIPPED_KEYS } = require("./providers/env");
962
+ for (const key of ALWAYS_STRIPPED_KEYS) delete base[key];
963
+ } catch (e) {
964
+ // The provider boundary is a required local module, but preserve a
965
+ // fail-closed fallback if a partial installation reaches this path.
966
+ for (const key of [
967
+ "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "CURSOR_API_KEY",
968
+ "CLAUDE_CONFIG_DIR", "CODEX_HOME", "TELEGRAM_BOT_TOKEN", "KAZEE_BOT_TOKEN",
969
+ "BOT_CONTROL_TOKEN", "OC_SEND_TOKEN",
970
+ ]) delete base[key];
971
+ }
959
972
  const requires = (tool && tool.requires) || [];
960
973
  if (requires.length) {
961
974
  try {
@@ -973,10 +986,14 @@ function runEnv(tool) {
973
986
  // by `tool show` to warn before a run fails on a missing credential.
974
987
  function missingRequires(tool) {
975
988
  if (!tool || !tool.requires || !tool.requires.length) return [];
989
+ let confined = new Set();
990
+ try { confined = require("./providers/env").ALWAYS_STRIPPED_KEYS; } catch (_) {}
976
991
  let present;
977
992
  try { present = new Set(require("./keyring").keys()); }
978
- catch (e) { present = new Set(Object.keys(process.env)); }
979
- return tool.requires.filter((k) => !present.has(k) && process.env[k] === undefined);
993
+ catch (e) { present = new Set(Object.keys(process.env).filter((key) => !confined.has(key))); }
994
+ return tool.requires.filter((key) => (
995
+ !present.has(key) && (confined.has(key) || process.env[key] === undefined)
996
+ ));
980
997
  }
981
998
 
982
999
  // Recognise a Write/Edit aimed at a tool (for chat announcements). Legacy flat
@@ -27,12 +27,37 @@ function transcriptProjectInfo(state = currentState()) {
27
27
  return projectTranscripts.pointer(state.currentSession.dir, state.currentSession.name, state.userId);
28
28
  }
29
29
 
30
+ // Build a read-only transcript view from the synchronously captured run
31
+ // identity. This does not migrate state; it only prevents an awaiting prompt
32
+ // build from following a later project/user switch.
33
+ function transcriptStateForRun(runContext, state = currentState()) {
34
+ if (!runContext) return state;
35
+ const project = runContext.project && typeof runContext.project === "object"
36
+ ? runContext.project
37
+ : (runContext.project ? { name: runContext.project, dir: null } : null);
38
+ return {
39
+ ...state,
40
+ userId: runContext.canonicalUserId || state.userId,
41
+ currentSession: project
42
+ ? { name: project.name || null, dir: project.dir || project.path || null }
43
+ : null,
44
+ };
45
+ }
46
+
47
+ function transcriptProjectInfoForRun(runContext, state = currentState()) {
48
+ return transcriptProjectInfo(transcriptStateForRun(runContext, state));
49
+ }
50
+
30
51
  function transcriptPointerNote(state = currentState()) {
31
52
  if (!state.currentSession) return "";
32
53
  return projectTranscripts.buildPointerNote(state.currentSession.dir, state.currentSession.name, state.userId);
33
54
  }
34
55
 
35
- function appendProjectTranscript(role, text, metadata = {}, state = currentState(), getActiveSessionId) {
56
+ function transcriptPointerNoteForRun(runContext, state = currentState()) {
57
+ return transcriptPointerNote(transcriptStateForRun(runContext, state));
58
+ }
59
+
60
+ function appendProjectTranscript(role, text, metadata = {}, state = currentState(), getActiveSessionId, options = {}) {
36
61
  if (!state.currentSession) return null;
37
62
  try {
38
63
  const transport = (state.userId || "").split(":")[0] || "telegram";
@@ -54,6 +79,7 @@ function appendProjectTranscript(role, text, metadata = {}, state = currentState
54
79
  return result;
55
80
  } catch (e) {
56
81
  console.error("Transcript write failed:", redactSensitive(e.message));
82
+ if (options.strict) throw e;
57
83
  return null;
58
84
  }
59
85
  }
@@ -102,7 +128,10 @@ function stripTranscriptPointerForStorage(prompt) {
102
128
  module.exports = {
103
129
  projectTranscripts,
104
130
  transcriptProjectInfo,
131
+ transcriptProjectInfoForRun,
105
132
  transcriptPointerNote,
133
+ transcriptPointerNoteForRun,
134
+ transcriptStateForRun,
106
135
  appendProjectTranscript,
107
136
  recentTranscriptText,
108
137
  promptWithTranscriptPointer,
package/core/usage-log.js CHANGED
@@ -8,10 +8,24 @@ const CONFIG_DIR = require("../config-dir");
8
8
 
9
9
  const USAGE_HISTORY_FILE = path.join(CONFIG_DIR, "usage-history.jsonl");
10
10
 
11
- function appendUsageRecord(record) {
11
+ function normalizeUsageRecord(record) {
12
+ const normalized = { ...(record || {}) };
13
+ const provider = normalized.provider || normalized.backend || null;
14
+ if (provider) {
15
+ normalized.provider = provider;
16
+ normalized.backend = normalized.backend || provider;
17
+ }
18
+ return normalized;
19
+ }
20
+
21
+ function appendUsageRecord(record, options = {}) {
12
22
  try {
13
- fs.appendFileSync(USAGE_HISTORY_FILE, JSON.stringify(record) + "\n");
14
- } catch (e) {}
23
+ fs.appendFileSync(USAGE_HISTORY_FILE, JSON.stringify(normalizeUsageRecord(record)) + "\n");
24
+ } catch (e) {
25
+ if (options.strict) throw e;
26
+ return false;
27
+ }
28
+ return true;
15
29
  }
16
30
 
17
31
  function loadUsageHistory() {
@@ -21,7 +35,7 @@ function loadUsageHistory() {
21
35
  for (const line of raw.split("\n")) {
22
36
  const t = line.trim();
23
37
  if (!t) continue;
24
- try { out.push(JSON.parse(t)); } catch (e) {}
38
+ try { out.push(normalizeUsageRecord(JSON.parse(t))); } catch (e) {}
25
39
  }
26
40
  return out;
27
41
  }
@@ -101,6 +115,7 @@ function evaluateUsageAlert(previousRecords, record, policy = usageAlertPolicy()
101
115
  }
102
116
 
103
117
  module.exports = {
118
+ normalizeUsageRecord,
104
119
  appendUsageRecord,
105
120
  loadUsageHistory,
106
121
  usageAlertPolicy,