@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
@@ -7,6 +7,7 @@ const assert = require("assert");
7
7
  const fs = require("fs");
8
8
  const os = require("os");
9
9
  const path = require("path");
10
+ const { spawn } = require("child_process");
10
11
 
11
12
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "approval-async-test-"));
12
13
  process.env.APPROVALS_DIR = path.join(tmp, "approvals");
@@ -48,6 +49,30 @@ const approvals = require("./core/approvals");
48
49
  assert.strictEqual(replay.ok, false, "second consume refused (single use)");
49
50
  assert.ok(/already consumed/.test(replay.reason));
50
51
 
52
+ const concurrent = approvals.create({ tool: "fleet", command: CMD, channelId: "c1", adapter: "tg" });
53
+ approvals.decide(concurrent.id, "approved", "c1");
54
+ const consumeInChild = () => new Promise((resolve, reject) => {
55
+ const source = [
56
+ `const approvals = require(${JSON.stringify(path.join(__dirname, "core", "approvals.js"))});`,
57
+ `process.stdout.write(JSON.stringify(approvals.consume(${JSON.stringify(concurrent.id)}, ${JSON.stringify(CMD)})));`,
58
+ ].join("\n");
59
+ const child = spawn(process.execPath, ["-e", source], {
60
+ env: { ...process.env, APPROVALS_DIR: approvals.APPROVALS_DIR },
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ });
63
+ let stdout = "";
64
+ let stderr = "";
65
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
66
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
67
+ child.once("error", reject);
68
+ child.once("close", (code) => {
69
+ if (code !== 0) reject(new Error(stderr || `approval child exited ${code}`));
70
+ else resolve(JSON.parse(stdout));
71
+ });
72
+ });
73
+ const concurrentResults = await Promise.all([consumeInChild(), consumeInChild()]);
74
+ assert.strictEqual(concurrentResults.filter((result) => result.ok).length, 1, "concurrent redemption has exactly one winner");
75
+
51
76
  // ── unknown id fails closed ──
52
77
  assert.strictEqual(approvals.consume("apr_nope", CMD).ok, false, "unknown id refused");
53
78
 
@@ -62,11 +87,45 @@ const approvals = require("./core/approvals");
62
87
  assert.strictEqual(expired.ok, false, "expired token refused");
63
88
  assert.ok(/expired/.test(expired.reason));
64
89
 
65
- // ── long commands: stored payload is capped at 1000 chars and matching respects the cap ──
66
- const longCmd = "open-claudia tool run fleet purge " + "x".repeat(1200);
67
- const lrec = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: longCmd, channelId: "c1", adapter: "tg" });
90
+ // ── command binding: the whole visible payload is hashed. Commands too long
91
+ // to display completely are rejected rather than approving a hidden suffix.
92
+ const boundCmd = "open-claudia tool run fleet purge " + "x".repeat(400);
93
+ const lrec = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: boundCmd, channelId: "c1", adapter: "tg" });
94
+ assert.strictEqual(lrec.command, boundCmd, "the exact approved command is retained");
95
+ assert.match(lrec.commandHash, /^[a-f0-9]{64}$/, "the approval carries a full-payload digest");
68
96
  approvals.decide(lrec.id, "approved", "c1");
69
- assert.strictEqual(approvals.consume(lrec.id, longCmd).ok, true, "capped command still matches its own approval");
97
+ const changedSuffix = `${boundCmd.slice(0, -8)}DIFFERENT`;
98
+ assert.strictEqual(approvals.consume(lrec.id, changedSuffix).ok, false, "a suffix mismatch is refused");
99
+ assert.strictEqual(approvals.consume(lrec.id, boundCmd).ok, true, "the full exact command still redeems");
100
+ assert.throws(
101
+ () => approvals.create({ tool: "fleet", command: "open-claudia tool run fleet purge " + "x".repeat(1200) }),
102
+ (error) => error && error.code === "APPROVAL_PAYLOAD_TOO_LARGE",
103
+ "an approval never hides an undisplayed command suffix",
104
+ );
105
+
106
+ const legacy = approvals.create({ tool: "fleet", command: CMD, channelId: "c1", adapter: "tg" });
107
+ approvals.decide(legacy.id, "approved", "c1");
108
+ const legacyFile = path.join(approvals.APPROVALS_DIR, `${legacy.id}.json`);
109
+ const legacyRecord = JSON.parse(fs.readFileSync(legacyFile, "utf8"));
110
+ delete legacyRecord.commandHash;
111
+ fs.writeFileSync(legacyFile, JSON.stringify(legacyRecord));
112
+ assert.strictEqual(approvals.consume(legacy.id, CMD).ok, true, "safe short pre-hash approvals remain redeemable");
113
+
114
+ const unsafeLegacy = approvals.create({ tool: "fleet", command: CMD, channelId: "c1", adapter: "tg" });
115
+ approvals.decide(unsafeLegacy.id, "approved", "c1");
116
+ const unsafeLegacyFile = path.join(approvals.APPROVALS_DIR, `${unsafeLegacy.id}.json`);
117
+ const unsafeLegacyRecord = JSON.parse(fs.readFileSync(unsafeLegacyFile, "utf8"));
118
+ delete unsafeLegacyRecord.commandHash;
119
+ unsafeLegacyRecord.command = "x".repeat(600);
120
+ fs.writeFileSync(unsafeLegacyFile, JSON.stringify(unsafeLegacyRecord));
121
+ assert.strictEqual(
122
+ approvals.consume(unsafeLegacy.id, unsafeLegacyRecord.command).ok,
123
+ false,
124
+ "legacy approvals with an undisplayed suffix require a fresh decision",
125
+ );
126
+
127
+ const quotedCommand = approvals.formatApprovedCommand(["open-claudia", "tool", "run", "fleet", "purge", "node one", "it's-safe"]);
128
+ assert.strictEqual(quotedCommand, "open-claudia tool run fleet purge 'node one' 'it'\"'\"'s-safe'");
70
129
 
71
130
  // ── waitForDecision default fast-path window is 90s (not the old 5 min) ──
72
131
  const src = fs.readFileSync(path.join(__dirname, "core", "approvals.js"), "utf-8");
@@ -0,0 +1,334 @@
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 = __dirname;
12
+ const fixturesRoot = path.join(root, "test-fixtures", "migrations");
13
+
14
+ function fixture(name, file) {
15
+ return JSON.parse(fs.readFileSync(path.join(fixturesRoot, name, file), "utf8"));
16
+ }
17
+
18
+ function cursorArchiveMigrationProbe(stateApi, jobsApi) {
19
+ assert.ok(stateApi.STATE_SCHEMA_VERSION >= 3, "removed-provider state requires a new schema version");
20
+ assert.ok(stateApi.SESSIONS_SCHEMA_VERSION >= 3, "removed-provider sessions require a new schema version");
21
+
22
+ const stateDoc = stateApi.migrateStateDocument(fixture("cursor-selected", "state.json"), {
23
+ defaultUserId: "telegram:fixture",
24
+ canonicalizeUserId: (value) => String(value),
25
+ });
26
+ const state = stateDoc.users["telegram:fixture"];
27
+ assert.strictEqual(state.settings.backend, null, "a removed provider selection must not fall through to Claude or Codex");
28
+ assert.strictEqual(state.legacyProviderState.providerSelectionRequired, true);
29
+ assert.strictEqual(state.legacyProviderState.archivedBackend, "cursor");
30
+ assert.strictEqual(state.legacyProviderState.archivedSessionPointers.cursor, "cursor-session-unresolved");
31
+ assert.strictEqual(state.legacyProviderState.archivedActiveSessions["legacy-cursor"].cursor, "cursor-session-unresolved");
32
+ assert.strictEqual(state.legacyProviderState.archivedProviderSettings.cursor.model, "cursor-model");
33
+ assert.ok(!Object.values(state.activeSessions).some((providers) => Object.prototype.hasOwnProperty.call(providers, "cursor")));
34
+ assert.ok(!Object.prototype.hasOwnProperty.call(state.providerSettings, "cursor"));
35
+ assert.deepStrictEqual(stateApi.migrateStateDocument(stateDoc, {
36
+ defaultUserId: "telegram:fixture",
37
+ canonicalizeUserId: (value) => String(value),
38
+ }), stateDoc, "removed-provider state migration is idempotent");
39
+
40
+ const sessions = stateApi.migrateSessionsDocument(fixture("cursor-selected", "sessions.json"), {
41
+ defaultUserId: "telegram:fixture",
42
+ canonicalizeUserId: (value) => String(value),
43
+ ownsSession: () => true,
44
+ });
45
+ const ambiguous = sessions.users["telegram:fixture"]["legacy-cursor"][0];
46
+ assert.strictEqual(ambiguous.id, "cursor-or-claude-ambiguous");
47
+ assert.strictEqual(ambiguous.provider, "legacy");
48
+ assert.strictEqual(ambiguous.selectable, false);
49
+
50
+ const explicit = stateApi.migrateSessionsDocument({
51
+ schemaVersion: 2,
52
+ users: { "telegram:fixture": { "legacy-cursor": [{ id: "cursor-explicit", provider: "cursor", title: "Archived" }] } },
53
+ }, {
54
+ defaultUserId: "telegram:fixture",
55
+ canonicalizeUserId: (value) => String(value),
56
+ ownsSession: () => true,
57
+ });
58
+ const record = explicit.users["telegram:fixture"]["legacy-cursor"][0];
59
+ assert.strictEqual(record.provider, "legacy");
60
+ assert.strictEqual(record.archivedProvider, "cursor");
61
+ assert.strictEqual(record.selectable, false);
62
+ assert.strictEqual(record.legacyResolution, "removed-provider");
63
+
64
+ const jobs = jobsApi.migrateJobsDocument(fixture("cursor-selected", "jobs.json"), {
65
+ defaultCanonicalUserId: "telegram:fixture",
66
+ defaultChannelId: "fixture-channel",
67
+ defaultAdapter: "telegram-fixture",
68
+ defaultAdapterType: "telegram",
69
+ now: () => 1_720_000_000_000,
70
+ });
71
+ assert.strictEqual(jobs.jobs.length, 0);
72
+ assert.strictEqual(jobs.archived[0].provider, "cursor");
73
+ assert.strictEqual(jobs.archived[0].sessionId, "cursor-session-unresolved");
74
+ assert.strictEqual(jobs.archived[0].disabled, true);
75
+ assert.ok(!jobs.archived.some((job) => ["claude", "codex"].includes(job.provider)));
76
+ }
77
+
78
+ function migrationOrderingAndRollbackProbe(migrationApi) {
79
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-cursor-removal-"));
80
+ try {
81
+ const configDir = path.join(temp, "config");
82
+ fs.mkdirSync(configDir, { recursive: true });
83
+ fs.cpSync(path.join(fixturesRoot, "cursor-selected"), configDir, { recursive: true });
84
+ const sources = migrationApi.defaultMigrationSources({ configDir });
85
+ const originals = new Map(sources.map((source) => [
86
+ source.path,
87
+ fs.existsSync(source.sourcePath) ? fs.readFileSync(source.sourcePath) : null,
88
+ ]));
89
+ const snapshot = migrationApi.ensureMigrationSnapshot({ configDir, sources });
90
+ migrationApi.markMigrationComponentPrepared(snapshot.snapshotDir, "state", { configDir, sources });
91
+ assert.throws(
92
+ () => migrationApi.markMigrationComponentActivated(snapshot.snapshotDir, "state", { configDir, sources }),
93
+ (error) => error.code === "MIGRATION_COMPONENT_NOT_MIGRATED",
94
+ "unchanged legacy state cannot be falsely marked activated",
95
+ );
96
+
97
+ const runUpgrade = () => spawnSync(process.execPath, ["-e", [
98
+ "const migration = require('./core/provider-migration');",
99
+ "const result = migration.completeProviderMigration();",
100
+ "const fs = require('fs');",
101
+ "const path = require('path');",
102
+ "const dir = process.env.OPEN_CLAUDIA_CONFIG_DIR;",
103
+ "const state = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));",
104
+ "const sessions = JSON.parse(fs.readFileSync(path.join(dir, 'sessions.json'), 'utf8'));",
105
+ "const jobs = JSON.parse(fs.readFileSync(path.join(dir, 'jobs.json'), 'utf8'));",
106
+ "process.stdout.write(JSON.stringify({ result, state, sessions, jobs }));",
107
+ ].join("\n")], {
108
+ cwd: root,
109
+ env: {
110
+ HOME: path.join(temp, "home"),
111
+ PATH: [path.dirname(process.execPath), "/usr/bin", "/bin"].join(path.delimiter),
112
+ OPEN_CLAUDIA_CONFIG_DIR: configDir,
113
+ WORKSPACE: path.join(temp, "workspace"),
114
+ CHANNELS: "",
115
+ TELEGRAM_CHAT_ID: "fixture",
116
+ OPEN_CLAUDIA_TEST: "1",
117
+ },
118
+ encoding: "utf8",
119
+ timeout: 30000,
120
+ });
121
+ fs.mkdirSync(path.join(temp, "home"), { recursive: true });
122
+ fs.mkdirSync(path.join(temp, "workspace"), { recursive: true });
123
+ const first = runUpgrade();
124
+ assert.strictEqual(first.status, 0, first.stderr || first.stdout);
125
+ const upgraded = JSON.parse(first.stdout);
126
+ assert.strictEqual(upgraded.state.schemaVersion, 3);
127
+ const migratedState = upgraded.state.users["telegram:fixture"];
128
+ assert.strictEqual(migratedState.settings.backend, null);
129
+ assert.strictEqual(migratedState.legacyProviderState.archivedBackend, "cursor");
130
+ assert.strictEqual(migratedState.legacyProviderState.providerSelectionRequired, true);
131
+ assert.ok(!Object.values(migratedState.activeSessions).some((bucket) => bucket.cursor));
132
+ assert.strictEqual(upgraded.sessions.schemaVersion, 3);
133
+ const session = upgraded.sessions.users["telegram:fixture"]["legacy-cursor"][0];
134
+ assert.strictEqual(session.provider, "legacy");
135
+ assert.strictEqual(session.selectable, false);
136
+ assert.strictEqual(upgraded.jobs.schemaVersion, 2);
137
+ assert.strictEqual(upgraded.jobs.jobs.length, 0);
138
+ assert.strictEqual(upgraded.jobs.archived[0].provider, "cursor");
139
+ assert.strictEqual(upgraded.jobs.archived[0].disabled, true);
140
+ assert.ok(upgraded.jobs.archived.some((job) => job.id === "legacy-cron" && job.disabled === true));
141
+ assert.strictEqual(upgraded.jobs.legacyCronMigration.status, "archived");
142
+ assert.strictEqual(upgraded.jobs.legacyCronMigration.recordCount, 1,
143
+ "provider-removal readiness includes every active legacy cron record");
144
+
145
+ const second = runUpgrade();
146
+ assert.strictEqual(second.status, 0, second.stderr || second.stdout);
147
+ assert.deepStrictEqual(JSON.parse(second.stdout), upgraded, "pre-runtime migration is restart-idempotent");
148
+
149
+ const ready = migrationApi.requireProviderRemovalReady({ configDir, sources });
150
+ assert.strictEqual(ready.journal.stage, "activated");
151
+ assert.deepStrictEqual(Object.keys(ready.journal.components).sort(), ["jobs", "sessions", "state"]);
152
+ for (const component of Object.values(ready.journal.components)) {
153
+ assert.match(component.sha256, /^[a-f0-9]{64}$/);
154
+ assert.ok(component.schemaVersion >= 2);
155
+ assert.ok(component.path.endsWith(".json"));
156
+ }
157
+ migrationApi.validateMigrationSnapshot(snapshot.snapshotDir);
158
+
159
+ for (const source of sources) {
160
+ fs.mkdirSync(path.dirname(source.sourcePath), { recursive: true });
161
+ fs.writeFileSync(source.sourcePath, `mutated:${source.path}\n`);
162
+ }
163
+ migrationApi.rollbackMigrationSnapshot(snapshot.snapshotDir, { configDir, sources });
164
+ for (const source of sources) {
165
+ const original = originals.get(source.path);
166
+ assert.strictEqual(fs.existsSync(source.sourcePath), original !== null, `${source.path}: original presence restored`);
167
+ if (original !== null) assert.deepStrictEqual(fs.readFileSync(source.sourcePath), original, `${source.path}: original bytes restored`);
168
+ }
169
+ } finally {
170
+ fs.rmSync(temp, { recursive: true, force: true });
171
+ }
172
+
173
+ const blocked = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-cursor-removal-blocked-"));
174
+ try {
175
+ const configDir = path.join(blocked, "config");
176
+ fs.mkdirSync(configDir, { recursive: true });
177
+ fs.cpSync(path.join(fixturesRoot, "cursor-selected"), configDir, { recursive: true });
178
+ const sources = migrationApi.defaultMigrationSources({ configDir });
179
+ migrationApi.ensureMigrationSnapshot({ configDir, sources });
180
+ assert.throws(
181
+ () => migrationApi.requireProviderRemovalReady({ configDir, sources }),
182
+ (error) => error.code === "PROVIDER_REMOVAL_NOT_READY",
183
+ );
184
+ } finally {
185
+ fs.rmSync(blocked, { recursive: true, force: true });
186
+ }
187
+ }
188
+
189
+ async function callbackCompatibilityProbe(actionsApi) {
190
+ assert.strictEqual(typeof actionsApi.isRemovedProviderCallback, "function");
191
+ assert.strictEqual(typeof actionsApi.handleRemovedProviderCallback, "function");
192
+ for (const payload of ["be:cursor", "mb:cursor", "mb:cursor:composer-2"]) {
193
+ assert.strictEqual(actionsApi.isRemovedProviderCallback(payload), true);
194
+ const state = {
195
+ settings: { backend: "codex" },
196
+ activeSessions: { alpha: { codex: "thread-alpha" } },
197
+ providerSettings: { codex: { model: "gpt" } },
198
+ };
199
+ const before = structuredClone(state);
200
+ const sent = [];
201
+ const handled = await actionsApi.handleRemovedProviderCallback(payload, async (text) => { sent.push(text); });
202
+ assert.strictEqual(handled, true);
203
+ assert.deepStrictEqual(state, before, "legacy callback compatibility cannot mutate provider state");
204
+ assert.strictEqual(sent.length, 1);
205
+ assert.match(sent[0], /older Open Claudia release/i);
206
+ assert.match(sent[0], /support has been removed/i);
207
+ assert.match(sent[0], /\/backend/);
208
+ }
209
+ assert.strictEqual(actionsApi.isRemovedProviderCallback("be:claude"), false);
210
+ assert.strictEqual(actionsApi.isRemovedProviderCallback("mb:codex:gpt-5"), false);
211
+ }
212
+
213
+ function noFallbackProbe(runContextApi, configApi) {
214
+ const state = {
215
+ userId: "fixture-user",
216
+ currentSession: { name: "alpha", dir: "/workspace/alpha" },
217
+ settings: { backend: "cursor" },
218
+ activeSessions: { alpha: {} },
219
+ providerSettings: {},
220
+ };
221
+ assert.throws(
222
+ () => runContextApi.captureRunContext({ state, prompt: "must not fall through", cwd: "/workspace/alpha" }),
223
+ (error) => error.code === "REMOVED_PROVIDER",
224
+ );
225
+ assert.throws(
226
+ () => configApi.discoverProviderExecutable("cursor"),
227
+ (error) => error.code === "UNKNOWN_PROVIDER",
228
+ );
229
+ }
230
+
231
+ function backupClassificationProbe(migrationApi) {
232
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-cursor-classification-"));
233
+ try {
234
+ const configDir = path.join(temp, "config");
235
+ fs.mkdirSync(configDir, { recursive: true });
236
+ fs.writeFileSync(path.join(configDir, "state.json"), JSON.stringify({
237
+ schemaVersion: 2,
238
+ users: {
239
+ owner: {
240
+ settings: { backend: "claude" },
241
+ activeSessions: { alpha: { cursor: "cursor-transitional-session" } },
242
+ providerSettings: { cursor: { model: "cursor-transitional-model" } },
243
+ },
244
+ },
245
+ }));
246
+ fs.writeFileSync(path.join(configDir, "sessions.json"), JSON.stringify({
247
+ schemaVersion: 2,
248
+ users: { owner: { alpha: [{ id: "cursor-explicit-session", provider: "cursor" }] } },
249
+ }));
250
+ fs.writeFileSync(path.join(configDir, "jobs.json"), "[]\n");
251
+ const snapshot = migrationApi.ensureMigrationSnapshot({ configDir, sources: migrationApi.defaultMigrationSources({ configDir }) });
252
+ const entries = new Map(snapshot.manifest.files.map((entry) => [entry.path, entry]));
253
+ assert.match(entries.get("state.json").schemaGuess, /cursor/i);
254
+ assert.strictEqual(entries.get("state.json").resolution, "unresolved");
255
+ assert.match(entries.get("sessions.json").schemaGuess, /cursor/i);
256
+ assert.strictEqual(entries.get("sessions.json").resolution, "unresolved");
257
+ } finally {
258
+ fs.rmSync(temp, { recursive: true, force: true });
259
+ }
260
+ }
261
+
262
+ function semanticStaticProbe() {
263
+ const activeFiles = [
264
+ "core/config.js",
265
+ "core/state.js",
266
+ "core/run-context.js",
267
+ "core/system-prompt.js",
268
+ "core/runner.js",
269
+ "core/handlers.js",
270
+ "core/doctor.js",
271
+ "web.js",
272
+ ".env.example",
273
+ ];
274
+ const source = Object.fromEntries(activeFiles.map((file) => [file, fs.readFileSync(path.join(root, file), "utf8")]));
275
+
276
+ const forbidden = {
277
+ "core/config.js": [/CURSOR_PATH/, /resolvedCursorPath/, /cursor:\s*["']agent["']/, /providerId\s*===\s*["']cursor["']/],
278
+ "core/state.js": [/SELECTABLE_SESSION_PROVIDERS[^\n]*cursor/, /Object\.defineProperty\(state,\s*["']cursorSessionId["']/],
279
+ "core/run-context.js": [/return\s+["']cursorSessionId["']/],
280
+ "core/system-prompt.js": [/state\.cursorSessionId/, /provider\s*===\s*["']cursor["']/],
281
+ "core/runner.js": [/legacyCursorProvider/, /createLegacyCursorProvider/, /buildCursorArgs/, /discoverProviderExecutable\(["']cursor["']\)/, /settings\.backend\s*===\s*["']cursor["']/],
282
+ "core/handlers.js": [/name:\s*["']cursor["']/, /callback_data:\s*["']be:cursor["']/, /callback_data:\s*["']mb:cursor:/, /discoverProviderExecutable\(["']cursor["']\)/],
283
+ "core/doctor.js": [/CURSOR_PATH/, /discoverProviderExecutable\(["']cursor["']\)/, /Cursor Agent/],
284
+ ".env.example": [/CURSOR_PATH/],
285
+ };
286
+ for (const [file, patterns] of Object.entries(forbidden)) {
287
+ for (const pattern of patterns) assert.doesNotMatch(source[file], pattern, `${file} retains active removed-provider support: ${pattern}`);
288
+ }
289
+
290
+ // CSS cursor declarations are presentation, not the removed provider.
291
+ assert.match(source["web.js"], /cursor:\s*pointer/);
292
+ const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
293
+ assert.doesNotMatch(packageJson.description, /Cursor Agent/i);
294
+ const readme = fs.readFileSync(path.join(root, "README.md"), "utf8");
295
+ assert.doesNotMatch(readme, /Cursor Agent|CURSOR_PATH|\/cursor\b/i);
296
+ const channelDesign = fs.readFileSync(path.join(root, "docs", "CHANNEL_DESIGN.md"), "utf8");
297
+ assert.doesNotMatch(channelDesign, /runCursor|Cursor Agent/i);
298
+
299
+ // Required preservation exceptions.
300
+ assert.ok(fs.existsSync(path.join(root, ".cursor", "rules", "project.mdc")));
301
+ assert.match(fs.readFileSync(path.join(root, "soul.md"), "utf8"), /Cursor IDE/);
302
+ assert.match(fs.readFileSync(path.join(root, "docs", "MULTI_CHANNEL_PLAN.md"), "utf8"), /leaves cursor for args/);
303
+ assert.match(fs.readFileSync(path.join(root, "core", "providers", "env.js"), "utf8"), /CURSOR_API_KEY/);
304
+ assert.match(fs.readFileSync(path.join(root, "core", "migration-backup.js"), "utf8"), /cursorSessionId/);
305
+ assert.match(fs.readFileSync(path.join(root, "core", "jobs.js"), "utf8"), /cursorSessionId/);
306
+ assert.match(fs.readFileSync(path.join(root, "test-provider-registry.js"), "utf8"), /provider\("cursor"\)/);
307
+
308
+ const changelog = fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
309
+ assert.match(changelog.slice(0, 2500), /active Cursor Agent runtime support has been removed/i);
310
+ assert.match(changelog.slice(0, 2500), /rollback/i);
311
+ }
312
+
313
+ async function main() {
314
+ const migrationApi = require("./core/migration-backup");
315
+ const stateApi = require("./core/state");
316
+ const jobsApi = require("./core/jobs");
317
+ const runContextApi = require("./core/run-context");
318
+ const configApi = require("./core/config");
319
+ const registry = require("./core/providers");
320
+
321
+ assert.deepStrictEqual(registry.listProviders().map((provider) => provider.id), ["claude", "codex"]);
322
+ cursorArchiveMigrationProbe(stateApi, jobsApi);
323
+ migrationOrderingAndRollbackProbe(migrationApi);
324
+ backupClassificationProbe(migrationApi);
325
+ noFallbackProbe(runContextApi, configApi);
326
+ await callbackCompatibilityProbe(require("./core/actions"));
327
+ semanticStaticProbe();
328
+ console.log("cursor removal OK");
329
+ }
330
+
331
+ main().catch((error) => {
332
+ console.error(error.stack || error.message);
333
+ process.exit(1);
334
+ });
package/test-enforcer.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The independent guardrail (Phase 3.3/3.4). The enforcer vets every outbound
2
2
  // reply and every write/destructive action aimed at an EXTERNAL person against
3
3
  // the owner-authored mandate, using an isolated model call it can never be
4
- // talked out of. These tests stub that model (core/subagent) and the transport
4
+ // talked out of. These tests stub that model (core/utility-agent) and the transport
5
5
  // (core/adapter-registry) so we can pin the security-critical behaviour with no
6
6
  // network: owner is a strict no-op, the guard fails CLOSED (error/unparseable →
7
7
  // escalate, never cached), identical vets are cached once, and an escalation
@@ -14,21 +14,31 @@ const path = require("path");
14
14
 
15
15
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "enforcer-"));
16
16
  process.env.OPEN_CLAUDIA_TEST = "1";
17
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = path.join(tmp, "config");
17
18
  process.env.ENTITIES_DIR = path.join(tmp, "entities");
18
19
  process.env.PEOPLE_FILE = path.join(tmp, "people.json");
19
20
  process.env.APPROVALS_DIR = path.join(tmp, "approvals");
21
+ fs.mkdirSync(process.env.OPEN_CLAUDIA_CONFIG_DIR, { recursive: true });
20
22
 
21
23
  // ── stub the model (destructured at enforcer load) and the transport (lazily
22
24
  // required in adapterFor) BEFORE requiring the enforcer. ──────────────────
23
- let subagentCalls = 0;
25
+ let utilityCalls = 0;
24
26
  let lastOpts = null;
25
- let subagentResponder = async () => ({ text: '{"decision":"allow","reason":"ok"}' });
26
- const subagentPath = require.resolve("./core/subagent");
27
- require.cache[subagentPath] = {
28
- id: subagentPath, filename: subagentPath, loaded: true,
27
+ let utilityResponder = async () => ({ text: '{"decision":"allow","reason":"ok"}' });
28
+ const utilityPath = require.resolve("./core/utility-agent");
29
+ require.cache[utilityPath] = {
30
+ id: utilityPath, filename: utilityPath, loaded: true,
29
31
  exports: {
30
- spawnSubagent: async (prompt, opts) => { subagentCalls++; lastOpts = opts; return subagentResponder(prompt, opts); },
31
- concurrentCount: () => 0,
32
+ spawnUtilityAgent: async (prompt, opts) => {
33
+ utilityCalls++;
34
+ lastOpts = opts;
35
+ return {
36
+ provider: "claude",
37
+ model: "claude-enforcer-fixture",
38
+ fallbackUsed: false,
39
+ ...await utilityResponder(prompt, opts),
40
+ };
41
+ },
32
42
  },
33
43
  };
34
44
 
@@ -77,24 +87,28 @@ const extSpeaker = relationship.speakerFor("telegram", CH_EXT);
77
87
  delete process.env.ENFORCER; // back to default-on for the rest
78
88
 
79
89
  // ── owner is a strict no-op: guardOutboundReply allows without vetting ──
80
- subagentCalls = 0;
90
+ utilityCalls = 0;
81
91
  const ownerRes = await inChat(CH_OWNER, () => enforcer.guardOutboundReply("here are the k8s creds"));
82
92
  assert.deepStrictEqual(ownerRes, { allow: true, guarded: false }, "owner reply is never guarded");
83
- assert.strictEqual(subagentCalls, 0, "owner path never calls the guard model");
93
+ assert.strictEqual(utilityCalls, 0, "owner path never calls the guard model");
84
94
 
85
95
  // ── external allow: guardOutboundReply vets and lets an in-scope reply through ──
86
- subagentResponder = async () => ({ text: '{"decision":"allow","reason":"invoicing is in scope"}' });
96
+ utilityResponder = async () => ({ text: '{"decision":"allow","reason":"invoicing is in scope"}' });
87
97
  const extAllow = await inChat(CH_EXT, () => enforcer.guardOutboundReply("Your invoice is fully paid."));
88
98
  assert.strictEqual(extAllow.allow, true, "in-scope external reply allowed");
89
99
  assert.strictEqual(extAllow.guarded, true, "external reply is guarded");
90
100
  assert.strictEqual(extAllow.decision, "allow", "decision surfaced");
91
- // guard sub-agent is invoked read-only with the fixed system prompt.
92
- assert.deepStrictEqual(lastOpts.allowedTools, [], "guard sub-agent gets no tools");
93
- assert.strictEqual(lastOpts.permissionMode, "plan", "guard runs in plan mode (read-only)");
101
+ // The utility guard is schema-bounded and read-only without overstating
102
+ // provider-hosted tool suppression.
103
+ assert.strictEqual(lastOpts.purpose, "enforcer");
104
+ assert.strictEqual(lastOpts.tier, "medium");
105
+ assert.strictEqual(lastOpts.readOnly, true);
106
+ assert.ok(lastOpts.outputSchema, "guard requests a structured verdict");
107
+ assert.strictEqual(lastOpts.allowedTools, undefined, "guard does not claim unverified hosted-tool suppression");
94
108
  assert.strictEqual(lastOpts.systemPrompt, enforcer.GUARD_SYSTEM_PROMPT, "guard uses the fixed guard prompt");
95
109
 
96
110
  // ── external block → escalate: reply held, owner buttoned, external stalled ──
97
- subagentResponder = async () => ({ text: '{"decision":"block","reason":"outside the invoicing mandate"}' });
111
+ utilityResponder = async () => ({ text: '{"decision":"block","reason":"outside the invoicing mandate"}' });
98
112
  sends.length = 0;
99
113
  const blocked = await inChat(CH_EXT, () => enforcer.guardOutboundReply("Here are the server credentials: root/hunter2."));
100
114
  assert.strictEqual(blocked.allow, false, "out-of-scope reply is NOT sent");
@@ -122,7 +136,22 @@ const extSpeaker = relationship.speakerFor("telegram", CH_EXT);
122
136
  assert.strictEqual(rr.personName, "Zephyr Contact", "person recorded for Always-allow");
123
137
 
124
138
  // ── escalate() record shape: destructive action ──
125
- const escAct = await enforcer.escalate({ speaker: extSpeaker, kind: "action", command: "open-claudia tool run fleet restart n1", tier: "destructive", reason: "not in mandate" });
139
+ const escAct = await enforcer.escalate({
140
+ speaker: extSpeaker,
141
+ kind: "action",
142
+ command: "open-claudia tool run fleet restart n1",
143
+ tier: "destructive",
144
+ reason: "not in mandate",
145
+ canonicalUserId: "owner-canonical",
146
+ userId: "external-user",
147
+ provider: "codex",
148
+ providerSettings: { model: "gpt-fixture" },
149
+ project: "fixture-project",
150
+ projectDir: "/fixture/project",
151
+ sessionId: "fixture-session",
152
+ previousSessionId: "fixture-previous",
153
+ originRunId: "fixture-run",
154
+ });
126
155
  assert.strictEqual(escAct.escalated, true, "action escalation succeeds");
127
156
  const ar = approvals.read(escAct.id);
128
157
  assert.strictEqual(ar.tool, "external-action", "action → external-action tool");
@@ -130,41 +159,55 @@ const extSpeaker = relationship.speakerFor("telegram", CH_EXT);
130
159
  assert.strictEqual(ar.tier, "destructive", "action tier preserved");
131
160
  assert.ok(ar.command.includes("fleet restart n1"), "the exact command is stored");
132
161
  assert.strictEqual(ar.payloadText, "", "an action carries no reply payload");
162
+ assert.strictEqual(ar.provider, "codex", "external actions retain their originating provider");
163
+ assert.deepStrictEqual(ar.providerSettings, { model: "gpt-fixture" });
164
+ assert.strictEqual(ar.project, "fixture-project", "external actions retain the originating project");
165
+ assert.strictEqual(ar.canonicalUserId, "owner-canonical", "external action wakeups remain pinned to the canonical user");
166
+ assert.strictEqual(ar.originRunId, "fixture-run", "external action wakeups retain run lineage");
133
167
 
134
168
  // ── vetAction allow ──
135
- subagentResponder = async () => ({ text: '{"decision":"allow","reason":"listing is in scope"}' });
169
+ utilityResponder = async () => ({ text: '{"decision":"allow","reason":"listing is in scope"}' });
136
170
  const va = await enforcer.vetAction(extSpeaker, { command: "open-claudia tool run kticket list", tier: "write" });
137
171
  assert.strictEqual(va.decision, "allow", "in-scope action allowed");
138
172
 
139
173
  // ── cache: two identical vets → the model is called exactly once ──
140
- subagentCalls = 0;
141
- subagentResponder = async () => ({ text: '{"decision":"allow","reason":"ok"}' });
174
+ utilityCalls = 0;
175
+ utilityResponder = async () => ({ text: '{"decision":"allow","reason":"ok"}' });
142
176
  const CACHE_P = "identical-cache-payload-xyz";
143
177
  const c1 = await enforcer.vetReply(extSpeaker, CACHE_P);
144
178
  const c2 = await enforcer.vetReply(extSpeaker, CACHE_P);
145
179
  assert.strictEqual(c1.decision, "allow");
146
180
  assert.strictEqual(c2.decision, "allow");
147
- assert.strictEqual(subagentCalls, 1, "identical vet is cached — the guard model runs once");
181
+ assert.strictEqual(utilityCalls, 1, "identical vet is cached — the guard model runs once");
148
182
 
149
183
  // ── fail CLOSED and NOT cached: a thrown model call → escalate, re-judged next time ──
150
- subagentCalls = 0;
184
+ utilityCalls = 0;
151
185
  const FC_P = "fail-closed-payload-abc";
152
- subagentResponder = async () => { throw new Error("model unreachable"); };
186
+ utilityResponder = async () => { throw new Error("model unreachable"); };
153
187
  const f1 = await enforcer.vetReply(extSpeaker, FC_P);
154
188
  assert.strictEqual(f1.decision, "escalate", "thrown model → escalate (fail closed)");
155
189
  assert.ok(/failing closed/.test(f1.reason), "fail-closed reason surfaced");
156
- subagentResponder = async () => ({ text: '{"decision":"allow","reason":"now reachable"}' });
190
+ utilityResponder = async () => ({ text: '{"decision":"allow","reason":"now reachable"}' });
157
191
  const f2 = await enforcer.vetReply(extSpeaker, FC_P);
158
192
  assert.strictEqual(f2.decision, "allow", "the failure was NOT cached — it is re-judged");
159
- assert.strictEqual(subagentCalls, 2, "both attempts reached the model");
193
+ assert.strictEqual(utilityCalls, 2, "both attempts reached the model");
160
194
 
161
195
  // ── unparseable + invalid decision both fail closed to escalate ──
162
- subagentResponder = async () => ({ text: "I'm sorry, I can't do that." });
196
+ utilityResponder = async () => ({ text: "I'm sorry, I can't do that." });
163
197
  const up = await enforcer.vetReply(extSpeaker, "no-json-payload-1");
164
198
  assert.strictEqual(up.decision, "escalate", "no JSON in the verdict → escalate");
165
- subagentResponder = async () => ({ text: '{"decision":"maybe","reason":"unsure"}' });
199
+ utilityResponder = async () => ({ text: '{"decision":"maybe","reason":"unsure"}' });
166
200
  const iv = await enforcer.vetReply(extSpeaker, "bad-decision-payload-1");
167
- assert.strictEqual(iv.decision, "escalate", "unknown decision value coerced to escalate");
201
+ assert.strictEqual(iv.decision, "escalate", "unknown decision value fails closed to escalate");
202
+
203
+ // Audit records only stable decision/provider metadata. Guard prompts,
204
+ // mandates, proposed replies, and model reasons stay out of the log.
205
+ const verdictAudit = require("./core/audit").tail(100).filter((entry) => entry.kind === "enforcer.verdict");
206
+ assert.ok(verdictAudit.some((entry) => entry.provider === "claude" && entry.model === "claude-enforcer-fixture"));
207
+ assert.ok(verdictAudit.some((entry) => entry.status === "failed" && entry.decision === "escalate"));
208
+ const auditRaw = fs.readFileSync(require("./core/config").AUDIT_FILE, "utf8");
209
+ assert.ok(!auditRaw.includes("hunter2"), "blocked reply content never enters the audit log");
210
+ assert.ok(!auditRaw.includes("May only discuss invoicing status"), "owner-authored mandate never enters the audit log");
168
211
 
169
212
  console.log("enforcer OK");
170
213
  })().catch((e) => { console.error(e); process.exit(1); });