@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.
- package/.env.example +30 -4
- package/CHANGELOG.md +12 -0
- package/README.md +87 -66
- package/bin/agent.js +44 -18
- package/bin/cli.js +30 -28
- package/bin/dream.js +5 -11
- package/bin/loopback-client.js +29 -5
- package/bin/schedule.js +19 -5
- package/bin/tool.js +23 -5
- package/bot-agent.js +5 -2350
- package/bot.js +66 -3
- package/channels/telegram/adapter.js +6 -1
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +149 -40
- package/core/approvals.js +136 -29
- package/core/auth-flow.js +103 -19
- package/core/config-dir.js +19 -0
- package/core/config.js +115 -69
- package/core/doctor.js +111 -57
- package/core/dream.js +219 -62
- package/core/enforcer.js +0 -0
- package/core/entities.js +2 -1
- package/core/fsutil.js +21 -4
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +42 -6
- package/core/memory-mutation-queue.js +241 -0
- package/core/migration-backup.js +1060 -0
- package/core/pack-review.js +295 -88
- package/core/packs.js +4 -3
- package/core/persona.js +2 -1
- package/core/process-tree.js +19 -2
- package/core/provider-migration.js +39 -0
- package/core/provider-status.js +80 -0
- package/core/providers/claude-events.js +117 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +121 -0
- package/core/providers/codex-hook.js +280 -0
- package/core/providers/codex.js +286 -0
- package/core/providers/contract.js +153 -0
- package/core/providers/env.js +148 -0
- package/core/providers/events.js +170 -0
- package/core/providers/hook-command.js +22 -0
- package/core/providers/index.js +240 -0
- package/core/providers/utility-policy.js +269 -0
- package/core/recall/classic.js +2 -2
- package/core/recall/discoverer.js +71 -22
- package/core/recall/metrics.js +27 -1
- package/core/recall/tuning.js +4 -1
- package/core/recall/warm-walker.js +151 -108
- package/core/recall-filter.js +86 -61
- package/core/router.js +79 -7
- package/core/run-context.js +185 -0
- package/core/runner.js +1414 -1290
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1084 -97
- package/core/subagent.js +72 -98
- package/core/system-prompt.js +462 -279
- package/core/tool-guard.js +73 -39
- package/core/tools.js +22 -5
- package/core/transcripts.js +30 -1
- package/core/usage-log.js +19 -4
- package/core/utility-agent.js +654 -0
- package/core/web-auth.js +29 -13
- package/docs/CHANNEL_DESIGN.md +181 -0
- package/docs/MULTI_CHANNEL_PLAN.md +254 -0
- package/docs/PROVIDER_MIGRATION.md +67 -0
- package/health.js +50 -39
- package/package.json +48 -4
- package/setup.js +198 -38
- package/soul.md +39 -0
- package/test-ability-extraction.js +5 -0
- package/test-approval-async.js +63 -4
- package/test-cursor-removal.js +334 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +477 -0
- package/test-fixtures/migrations/claude-only/jobs.json +3 -0
- package/test-fixtures/migrations/claude-only/sessions.json +5 -0
- package/test-fixtures/migrations/claude-only/state.json +5 -0
- package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
- package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
- package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
- package/test-fixtures/migrations/cursor-selected/state.json +6 -0
- package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
- package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
- package/test-fixtures/migrations/dual-provider/state.json +10 -0
- package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
- package/test-fixtures/migrations/malformed-partial/state.json +1 -0
- package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
- package/test-fixtures/migrations/missing-project/jobs.json +3 -0
- package/test-fixtures/migrations/missing-project/sessions.json +3 -0
- package/test-fixtures/migrations/missing-project/state.json +4 -0
- package/test-fixtures/migrations/multi-user/jobs.json +4 -0
- package/test-fixtures/migrations/multi-user/sessions.json +4 -0
- package/test-fixtures/migrations/multi-user/state.json +6 -0
- package/test-fixtures/provider-event-samples.json +17 -0
- package/test-learning-e2e.js +5 -0
- package/test-memory-mutation-queue.js +191 -0
- package/test-provider-boot-matrix.js +399 -0
- package/test-provider-bootstrap.js +158 -0
- package/test-provider-capabilities.js +232 -0
- package/test-provider-claude.js +206 -0
- package/test-provider-codex.js +228 -0
- package/test-provider-compaction.js +371 -0
- package/test-provider-core-prompt.js +264 -0
- package/test-provider-coupling-audit.js +90 -0
- package/test-provider-dream.js +312 -0
- package/test-provider-enforcer.js +252 -0
- package/test-provider-env-isolation.js +150 -0
- package/test-provider-events.js +141 -0
- package/test-provider-fixture.js +332 -0
- package/test-provider-gateway.js +508 -0
- package/test-provider-language.js +89 -0
- package/test-provider-migration-backup.js +424 -0
- package/test-provider-pack-review.js +436 -0
- package/test-provider-parity-e2e.js +537 -0
- package/test-provider-prompt-parity.js +89 -0
- package/test-provider-recall.js +271 -0
- package/test-provider-registry.js +251 -0
- package/test-provider-resume-parity.js +41 -0
- package/test-provider-run-lifecycle.js +349 -0
- package/test-provider-runner.js +271 -0
- package/test-provider-scheduler.js +689 -0
- package/test-provider-session-commands.js +185 -0
- package/test-provider-session-history.js +205 -0
- package/test-provider-side-chat.js +337 -0
- package/test-provider-state-migration.js +316 -0
- package/test-provider-stream-decoder.js +69 -0
- package/test-provider-subagent.js +228 -0
- package/test-provider-tool-hooks.js +360 -0
- package/test-recall-discoverer.js +1 -0
- package/test-recall-engine.js +3 -3
- package/test-recall-evolution.js +18 -0
- package/test-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +56 -0
- package/test-tool-guard.js +56 -0
- package/test-tools.js +19 -0
- package/test-unified-identity.js +3 -1
- package/test-usage-accounting.js +92 -20
- package/test-utility-provider-policy.js +486 -0
- package/web.js +130 -35
package/test-approval-async.js
CHANGED
|
@@ -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
|
-
// ──
|
|
66
|
-
|
|
67
|
-
const
|
|
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
|
-
|
|
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/
|
|
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
|
|
25
|
+
let utilityCalls = 0;
|
|
24
26
|
let lastOpts = null;
|
|
25
|
-
let
|
|
26
|
-
const
|
|
27
|
-
require.cache[
|
|
28
|
-
id:
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
assert.strictEqual(lastOpts.
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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(
|
|
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
|
-
|
|
184
|
+
utilityCalls = 0;
|
|
151
185
|
const FC_P = "fail-closed-payload-abc";
|
|
152
|
-
|
|
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
|
-
|
|
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(
|
|
193
|
+
assert.strictEqual(utilityCalls, 2, "both attempts reached the model");
|
|
160
194
|
|
|
161
195
|
// ── unparseable + invalid decision both fail closed to escalate ──
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
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); });
|