@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.
- package/.env.example +30 -4
- package/CHANGELOG.md +21 -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 +105 -7
- 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 +127 -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 +55 -0
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/identity.js +8 -11
- package/core/io.js +24 -3
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +45 -8
- 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 +1415 -1282
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1096 -95
- 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/core/web-sessions.js +78 -0
- 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 +51 -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-delivery-contract.js +88 -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-run-lock.js +63 -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/test-web-sessions.js +74 -0
- package/web.js +159 -45
package/test-recall-engine.js
CHANGED
|
@@ -26,19 +26,19 @@ if (prev === undefined) delete process.env.RECALL_ENGINE; else process.env.RECAL
|
|
|
26
26
|
packsLib: { matchPacks: () => [{ dir: "p1", name: "P1" }] },
|
|
27
27
|
entitiesLib: { matchEntities: () => [{ slug: "e1", name: "E1" }] },
|
|
28
28
|
mergeMatches: (a, b) => { calls.push("merge"); return a.map((m) => ({ ...m, origin: "user" })); },
|
|
29
|
-
filterMatches: async (u, c, pm, em) => { calls.push(
|
|
29
|
+
filterMatches: async (u, c, pm, em, opts) => { calls.push(`filter:${opts.provider}`); return { packMatches: pm, entityMatches: em }; },
|
|
30
30
|
logRecall: () => { calls.push("log"); },
|
|
31
31
|
buildPackBlock: (m) => { calls.push("buildPack"); return m.length ? "PACKBLOCK" : ""; },
|
|
32
32
|
buildEntityBlock: (m) => { calls.push("buildEntity"); return m.length ? "ENTITYBLOCK" : ""; },
|
|
33
33
|
};
|
|
34
34
|
const out = await recall.getEngine("classic").run({
|
|
35
|
-
userText: "hello", contextText: "", fullContext: "", packLimit: 6, budget: {}, helpers,
|
|
35
|
+
userText: "hello", contextText: "", fullContext: "", packLimit: 6, budget: {}, helpers, provider: "codex",
|
|
36
36
|
});
|
|
37
37
|
assert.strictEqual(out.packBlock, "PACKBLOCK");
|
|
38
38
|
assert.strictEqual(out.entityBlock, "ENTITYBLOCK");
|
|
39
39
|
assert.strictEqual(out.packMatches.length, 1);
|
|
40
40
|
assert.strictEqual(out.entityMatches.length, 1);
|
|
41
|
-
assert.ok(calls.includes("filter") && calls.includes("log") && calls.includes("buildPack"));
|
|
41
|
+
assert.ok(calls.includes("filter:codex") && calls.includes("log") && calls.includes("buildPack"));
|
|
42
42
|
|
|
43
43
|
// resilient: a throwing matcher must not blow up the engine
|
|
44
44
|
const out2 = await recall.getEngine("classic").run({
|
package/test-recall-evolution.js
CHANGED
|
@@ -211,4 +211,22 @@ const parsed = dream.parseDream(JSON.stringify({ report: "hi", tuning: { knob: "
|
|
|
211
211
|
assert.deepStrictEqual(parsed.tuning, { knob: "episodeLimit", to: 4, reason: "r" }, "tuning proposal parses");
|
|
212
212
|
assert.strictEqual(dream.parseDream(JSON.stringify({ report: "hi", tuning: "bogus" })).tuning, null, "non-object tuning ignored");
|
|
213
213
|
|
|
214
|
+
// Provider-neutral dream audit metadata stays visible in the durable report,
|
|
215
|
+
// while invalid tier configuration preserves the historical high-tier default.
|
|
216
|
+
process.env.DREAM_TIER = "sideways";
|
|
217
|
+
assert.strictEqual(dream.dreamTier(), "high");
|
|
218
|
+
process.env.DREAM_TIER = "medium";
|
|
219
|
+
assert.strictEqual(dream.dreamTier(), "medium");
|
|
220
|
+
delete process.env.DREAM_TIER;
|
|
221
|
+
const dreamReportPath = dream.writeDreamReport({
|
|
222
|
+
trigger: "test",
|
|
223
|
+
intelligence: { provider: "codex", model: "gpt-fixture", effort: "xhigh", status: "succeeded" },
|
|
224
|
+
introspectionIntelligence: { provider: "claude", model: "claude-fixture", status: "succeeded" },
|
|
225
|
+
consolidation: { report: "provider metadata fixture", lines: [] },
|
|
226
|
+
introspection: { report: "introspection fixture", lines: [], proposed: null },
|
|
227
|
+
});
|
|
228
|
+
const dreamReport = fs.readFileSync(dreamReportPath, "utf8");
|
|
229
|
+
assert.match(dreamReport, /Provider: codex · Model: gpt-fixture/);
|
|
230
|
+
assert.match(dreamReport, /Introspection: claude · Model: claude-fixture/);
|
|
231
|
+
|
|
214
232
|
console.log("recall evolution OK");
|
package/test-run-lock.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// P0-3: single-flight run-lock regression test. Under unified identity the
|
|
2
|
+
// owner's Telegram and Kazee turns resolve to ONE canonical user and therefore
|
|
3
|
+
// share ONE in-memory state object. If two of those turns could both pass the
|
|
4
|
+
// "is a run in progress?" check they would race the shared state and the JSON
|
|
5
|
+
// files behind it. acquireRunLock() closes that window by arming the lock
|
|
6
|
+
// SYNCHRONOUSLY (no await between the check and the arm), so the second turn is
|
|
7
|
+
// always refused and queued. This test pins that contract on the raw helper.
|
|
8
|
+
|
|
9
|
+
const assert = require("assert");
|
|
10
|
+
process.env.OPEN_CLAUDIA_TEST = "1";
|
|
11
|
+
|
|
12
|
+
const { acquireRunLock } = require("./core/state");
|
|
13
|
+
|
|
14
|
+
// A run-lock must be synchronous — its whole point is that no await can
|
|
15
|
+
// interleave between the check and the arm. If this ever returned a Promise the
|
|
16
|
+
// guarantee would silently evaporate.
|
|
17
|
+
{
|
|
18
|
+
const r = acquireRunLock({ runningProcess: null, preparingRun: false });
|
|
19
|
+
assert.strictEqual(typeof r, "boolean", "acquireRunLock must be synchronous (return a boolean, not a Promise)");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Idle → acquires and arms preparingRun.
|
|
23
|
+
{
|
|
24
|
+
const s = { runningProcess: null, preparingRun: false };
|
|
25
|
+
assert.strictEqual(acquireRunLock(s), true, "idle state acquires");
|
|
26
|
+
assert.strictEqual(s.preparingRun, true, "acquiring arms preparingRun");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Already preparing (armed but not yet spawned) → refused, lock left intact.
|
|
30
|
+
{
|
|
31
|
+
const s = { runningProcess: null, preparingRun: true };
|
|
32
|
+
assert.strictEqual(acquireRunLock(s), false, "preparing state is refused");
|
|
33
|
+
assert.strictEqual(s.preparingRun, true, "refusal leaves the existing lock armed");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A process is actively running → refused.
|
|
37
|
+
{
|
|
38
|
+
const s = { runningProcess: { pid: 123 }, preparingRun: false };
|
|
39
|
+
assert.strictEqual(acquireRunLock(s), false, "running process is refused");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// THE guarantee: two turns sharing ONE state object (owner's Telegram + Kazee
|
|
43
|
+
// under unified identity). Exactly one may acquire; the other must be refused
|
|
44
|
+
// until the first fully releases.
|
|
45
|
+
{
|
|
46
|
+
const shared = { runningProcess: null, preparingRun: false };
|
|
47
|
+
|
|
48
|
+
const first = acquireRunLock(shared); // Telegram turn arrives
|
|
49
|
+
const second = acquireRunLock(shared); // Kazee turn arrives before the first releases
|
|
50
|
+
assert.strictEqual(first, true, "first turn wins the lock");
|
|
51
|
+
assert.strictEqual(second, false, "second concurrent turn is refused (no double-acquire)");
|
|
52
|
+
|
|
53
|
+
// First turn spawns its process, then finishes and clears both flags.
|
|
54
|
+
shared.preparingRun = false;
|
|
55
|
+
shared.runningProcess = { pid: 1 }; // spawned
|
|
56
|
+
assert.strictEqual(acquireRunLock(shared), false, "still refused while the process runs");
|
|
57
|
+
shared.runningProcess = null; // turn complete
|
|
58
|
+
|
|
59
|
+
// Now the queued second turn (drained from messageQueue) can proceed.
|
|
60
|
+
assert.strictEqual(acquireRunLock(shared), true, "lock is re-acquirable once the turn fully releases");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log("run-lock OK");
|
|
@@ -6,16 +6,24 @@ const path = require('path');
|
|
|
6
6
|
const root = __dirname;
|
|
7
7
|
const runner = fs.readFileSync(path.join(root, 'core', 'runner.js'), 'utf8');
|
|
8
8
|
const monolith = fs.readFileSync(path.join(root, 'bot-agent.js'), 'utf8');
|
|
9
|
+
const lifecycleStart = runner.indexOf('async function executeProviderInvocation');
|
|
10
|
+
const lifecycleEnd = runner.indexOf('function capabilitySupported', lifecycleStart);
|
|
11
|
+
assert.ok(lifecycleStart >= 0 && lifecycleEnd > lifecycleStart, 'provider lifecycle implementation must exist');
|
|
12
|
+
const lifecycle = runner.slice(lifecycleStart, lifecycleEnd);
|
|
9
13
|
|
|
10
14
|
assert.match(runner, /OC_TURN_IDLE_MS/, 'live core/runner.js must read OC_TURN_IDLE_MS');
|
|
11
|
-
assert.match(
|
|
12
|
-
assert.match(
|
|
13
|
-
assert.match(
|
|
14
|
-
assert.match(
|
|
15
|
-
assert.match(
|
|
16
|
-
assert.match(
|
|
17
|
-
assert.match(
|
|
15
|
+
assert.match(lifecycle, /watchdogTripped/, 'active provider lifecycle must guard after watchdog trip');
|
|
16
|
+
assert.match(lifecycle, /lastActivity\s*=\s*Date\.now\(\)/, 'active provider lifecycle must track last child activity');
|
|
17
|
+
assert.match(lifecycle, /proc\.stdout\.on\("data",[\s\S]{0,120}\blastActivity\s*=\s*Date\.now\(\)/, 'stdout must refresh lastActivity');
|
|
18
|
+
assert.match(lifecycle, /proc\.stderr\.on\("data",[\s\S]{0,120}\blastActivity\s*=\s*Date\.now\(\)/, 'stderr must refresh lastActivity');
|
|
19
|
+
assert.match(lifecycle, /beginTermination\(true\)/, 'idle watchdog must use the tracked termination lifecycle');
|
|
20
|
+
assert.match(lifecycle, /killTree\(proc\?\.pid,\s*"SIGTERM"\)/, 'watchdog must terminate the child process tree first');
|
|
21
|
+
assert.match(lifecycle, /killTree\(proc\?\.pid,\s*"SIGKILL"\)/, 'watchdog must force-kill the child process tree after grace');
|
|
22
|
+
assert.match(lifecycle, /await terminationPromise/, 'terminal settlement must await forced descendant cleanup');
|
|
23
|
+
assert.match(runner, /await drainQueuedMessages\(state\)/, 'watchdog path must not strand queued follow-up messages');
|
|
18
24
|
|
|
19
|
-
assert.
|
|
25
|
+
assert.ok(monolith.split('\n').length <= 12, 'bot-agent.js must be only a compatibility shim');
|
|
26
|
+
assert.match(monolith, /require\(["']\.\/bot["']\)/, 'legacy entrypoint must delegate to bot.js');
|
|
27
|
+
assert.ok(!/(?:OC_TURN_IDLE_MS|CLAUDE_PATH|spawn\(|parseStream|writeFile|saveState|codex)/i.test(monolith), 'compatibility shim must contain no watchdog, provider, parser, or writer implementation');
|
|
20
28
|
|
|
21
29
|
console.log('runner watchdog static OK');
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
const root = __dirname;
|
|
10
|
+
const shim = fs.readFileSync(path.join(root, "bot-agent.js"), "utf8");
|
|
11
|
+
const bot = fs.readFileSync(path.join(root, "bot.js"), "utf8");
|
|
12
|
+
const cli = fs.readFileSync(path.join(root, "bin", "cli.js"), "utf8");
|
|
13
|
+
const runner = fs.readFileSync(path.join(root, "core", "runner.js"), "utf8");
|
|
14
|
+
const sideChat = fs.readFileSync(path.join(root, "core", "side-chat.js"), "utf8");
|
|
15
|
+
|
|
16
|
+
assert.ok(shim.split("\n").length <= 12, "bot-agent.js must be only a small compatibility shim");
|
|
17
|
+
assert.match(shim, /require\(["']\.\/bot["']\)/, "compatibility shim delegates to the modular runtime");
|
|
18
|
+
for (const [label, pattern] of [
|
|
19
|
+
["provider invocation", /\b(?:spawn|execFile|build\w*Invocation)\b/],
|
|
20
|
+
["session parser", /(?:parseStream|session[_A-Z]|SessionId)/],
|
|
21
|
+
["state writer", /(?:writeFile|appendFile|rename|saveState|recordSession)/],
|
|
22
|
+
["Claude executable", /CLAUDE_PATH/],
|
|
23
|
+
["Codex logic", /codex/i],
|
|
24
|
+
]) {
|
|
25
|
+
assert.ok(!pattern.test(shim), `bot-agent.js must contain no ${label}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
assert.match(bot, /readRuntimeMode/, "the modular runtime owns both mode behaviors");
|
|
29
|
+
assert.match(runner, /SideChatCoordinator|sideChatCoordinator/, "side chat is part of the modular runner");
|
|
30
|
+
assert.match(sideChat, /class SideChatCoordinator/, "side-chat coordinator is explicit and testable");
|
|
31
|
+
assert.ok(!cli.includes('return "bot-agent.js"'), "agent mode must not select the legacy entrypoint");
|
|
32
|
+
assert.ok(!cli.includes('require(path.join(botDir, botFile))'), "CLI startup must not dynamically select a second runtime");
|
|
33
|
+
assert.match(cli, /require\(path\.join\(botDir, ["']bot\.js["']\)\)/, "CLI always starts the modular runtime");
|
|
34
|
+
|
|
35
|
+
console.log("single runtime OK");
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const assert = require("assert");
|
|
4
|
+
const { TelegramAdapter } = require("./channels/telegram/adapter");
|
|
5
|
+
|
|
6
|
+
async function testHealCancelsWedgedLongPollBeforeRestarting() {
|
|
7
|
+
const calls = [];
|
|
8
|
+
let stopOptions;
|
|
9
|
+
const adapter = Object.create(TelegramAdapter.prototype);
|
|
10
|
+
adapter._healTimer = null;
|
|
11
|
+
adapter._healthyTimer = null;
|
|
12
|
+
adapter._wedgedSince = 0;
|
|
13
|
+
adapter._healBackoff = 0;
|
|
14
|
+
adapter._agent = {
|
|
15
|
+
destroy() { calls.push("destroy"); },
|
|
16
|
+
};
|
|
17
|
+
adapter.bot = {
|
|
18
|
+
async stopPolling(options) {
|
|
19
|
+
stopOptions = options;
|
|
20
|
+
calls.push("stop");
|
|
21
|
+
},
|
|
22
|
+
async startPolling() {
|
|
23
|
+
calls.push("start");
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const realSetTimeout = global.setTimeout;
|
|
28
|
+
const realClearTimeout = global.clearTimeout;
|
|
29
|
+
global.setTimeout = (fn, ms) => {
|
|
30
|
+
if (ms === 500) fn();
|
|
31
|
+
return { ms };
|
|
32
|
+
};
|
|
33
|
+
global.clearTimeout = () => {};
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
await adapter._heal();
|
|
37
|
+
} finally {
|
|
38
|
+
global.setTimeout = realSetTimeout;
|
|
39
|
+
global.clearTimeout = realClearTimeout;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
assert.deepStrictEqual(
|
|
43
|
+
stopOptions,
|
|
44
|
+
{ cancel: true, reason: "stale Telegram long-poll recovery" },
|
|
45
|
+
"recovery must cancel a wedged getUpdates request rather than await it forever",
|
|
46
|
+
);
|
|
47
|
+
assert.deepStrictEqual(calls, ["stop", "destroy", "start"]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
(async () => {
|
|
51
|
+
await testHealCancelsWedgedLongPollBeforeRestarting();
|
|
52
|
+
console.log("telegram poll recovery OK");
|
|
53
|
+
})().catch((err) => {
|
|
54
|
+
console.error(err.stack || err);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
package/test-tool-guard.js
CHANGED
|
@@ -17,6 +17,42 @@ process.env.KEYRING_FILE = path.join(tmp, "keyring.json");
|
|
|
17
17
|
|
|
18
18
|
const guard = require("./core/tool-guard");
|
|
19
19
|
|
|
20
|
+
// The Open Claudia command policy is provider-neutral and side-effect free.
|
|
21
|
+
// Hook payload parsing, tooling-mode reads, audit writes, and exit-code
|
|
22
|
+
// translation belong to the Claude/Codex transport adapters around it.
|
|
23
|
+
const strictDecision = guard.evaluateCommandPolicy(
|
|
24
|
+
"curl https://central.inet.africa/policy-sentinel",
|
|
25
|
+
{ mode: "strict" },
|
|
26
|
+
);
|
|
27
|
+
assert.deepStrictEqual(strictDecision, {
|
|
28
|
+
decision: "deny",
|
|
29
|
+
ruleId: "inet-central-api",
|
|
30
|
+
message: guard.denyMessage(guard.RULES.find((rule) => rule.id === "inet-central-api")),
|
|
31
|
+
auditKind: "deny",
|
|
32
|
+
});
|
|
33
|
+
const relaxedDecision = guard.evaluateCommandPolicy(
|
|
34
|
+
"curl https://central.inet.africa/policy-sentinel",
|
|
35
|
+
{ mode: "relaxed" },
|
|
36
|
+
);
|
|
37
|
+
assert.deepStrictEqual(relaxedDecision, {
|
|
38
|
+
decision: "allow",
|
|
39
|
+
ruleId: "inet-central-api",
|
|
40
|
+
message: "",
|
|
41
|
+
auditKind: "relaxed-pass",
|
|
42
|
+
});
|
|
43
|
+
assert.deepStrictEqual(guard.evaluateCommandPolicy("ls -la", { mode: "strict" }), {
|
|
44
|
+
decision: "allow",
|
|
45
|
+
ruleId: null,
|
|
46
|
+
message: "",
|
|
47
|
+
auditKind: null,
|
|
48
|
+
});
|
|
49
|
+
assert.throws(
|
|
50
|
+
() => guard.evaluateCommandPolicy("ls", { mode: "unknown" }),
|
|
51
|
+
(error) => error && error.code === "INVALID_TOOL_POLICY_MODE",
|
|
52
|
+
"invalid policy modes fail closed at configuration time",
|
|
53
|
+
);
|
|
54
|
+
assert.ok(!fs.existsSync(process.env.TOOL_GUARD_LOG), "pure policy evaluation never writes the audit log");
|
|
55
|
+
|
|
20
56
|
// ── denies: acting command × prod surface ──
|
|
21
57
|
assert.strictEqual(guard.matchRule("curl -s https://central.inet.africa/api/v1/users").id, "inet-central-api");
|
|
22
58
|
assert.strictEqual(guard.matchRule("wget http://ticketcentral.inet.africa/tickets").id, "ticket-central-api");
|
|
@@ -39,6 +75,26 @@ assert.strictEqual(guard.matchRule("curl https://api.github.com/repos"), null, "
|
|
|
39
75
|
assert.strictEqual(guard.matchRule("open-claudia tool run inet-central-cli users list"), null, "tool run is the authorised channel");
|
|
40
76
|
assert.strictEqual(guard.matchRule('open-claudia keyring exec --reason "x" -- curl https://central.inet.africa/x'), null,
|
|
41
77
|
"keyring exec is the logged escape hatch, never denied");
|
|
78
|
+
assert.strictEqual(
|
|
79
|
+
guard.matchRule("open-claudia tool run inet-central-cli users list && curl https://central.inet.africa/x").id,
|
|
80
|
+
"inet-central-api",
|
|
81
|
+
"an authorised command prefix cannot smuggle a raw compound-command suffix",
|
|
82
|
+
);
|
|
83
|
+
assert.strictEqual(
|
|
84
|
+
guard.matchRule('open-claudia keyring exec --reason "x" -- echo safe; curl https://central.inet.africa/x').id,
|
|
85
|
+
"inet-central-api",
|
|
86
|
+
"the escape hatch exemption is limited to one complete outer command",
|
|
87
|
+
);
|
|
88
|
+
assert.strictEqual(
|
|
89
|
+
guard.matchRule("open-claudia tool run probe --arg \"$(curl https://central.inet.africa/x)\"").id,
|
|
90
|
+
"inet-central-api",
|
|
91
|
+
"command substitution is not hidden by an authorised outer command",
|
|
92
|
+
);
|
|
93
|
+
assert.strictEqual(
|
|
94
|
+
guard.matchRule("open-claudia tool run probe --arg 'curl https://central.inet.africa/x'"),
|
|
95
|
+
null,
|
|
96
|
+
"a quoted literal argument remains part of the authorised command",
|
|
97
|
+
);
|
|
42
98
|
assert.strictEqual(guard.matchRule(""), null, "empty command → allow");
|
|
43
99
|
assert.strictEqual(guard.matchRule("see the ticketcentral docs for details"), null, "mentioning a surface without acting → allow");
|
|
44
100
|
|
package/test-tools.js
CHANGED
|
@@ -108,18 +108,37 @@ delete process.env.api_key;
|
|
|
108
108
|
// conflict; a tool with no requires gets no keyring keys at all ──
|
|
109
109
|
fs.writeFileSync(process.env.KEYRING_FILE, JSON.stringify({
|
|
110
110
|
api_key: "from-keyring", api_secret: "s3cret-from-keyring", unrelated_key: "never-injected",
|
|
111
|
+
OPENAI_API_KEY: "declared-keyring-provider-canary",
|
|
111
112
|
}));
|
|
112
113
|
process.env.api_key = "env-wins";
|
|
114
|
+
process.env.OPENAI_API_KEY = "ambient-provider-canary";
|
|
115
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = "ambient-claude-canary";
|
|
113
116
|
const env = tools.runEnv(tools.findTool("hello")); // requires: api_key, api_secret
|
|
114
117
|
assert.ok(env && typeof env === "object" && env.PATH, "runEnv yields an env object carrying PATH");
|
|
115
118
|
assert.strictEqual(env.api_secret, "s3cret-from-keyring", "a declared --requires key is injected from the keyring");
|
|
116
119
|
assert.strictEqual(env.api_key, "env-wins", "process.env wins over the keyring on conflict");
|
|
117
120
|
assert.strictEqual(env.unrelated_key, undefined, "an undeclared keyring key never reaches a tool run");
|
|
121
|
+
assert.strictEqual(env.OPENAI_API_KEY, undefined, "Codex auth never leaks from the provider shell into a tool");
|
|
122
|
+
assert.strictEqual(env.CLAUDE_CODE_OAUTH_TOKEN, undefined, "Claude auth never leaks from the provider shell into a tool");
|
|
123
|
+
assert.strictEqual(
|
|
124
|
+
tools.runEnv({ requires: ["OPENAI_API_KEY"] }).OPENAI_API_KEY,
|
|
125
|
+
"declared-keyring-provider-canary",
|
|
126
|
+
"a provider credential reaches a tool only when explicitly declared in the keyring",
|
|
127
|
+
);
|
|
118
128
|
const envNoReq = tools.runEnv(tools.findTool("peek")); // no requires declared
|
|
119
129
|
assert.strictEqual(envNoReq.api_secret, undefined, "a tool with no requires gets no keyring keys");
|
|
120
130
|
assert.strictEqual(tools.runEnv().unrelated_key, undefined, "runEnv without a tool injects nothing");
|
|
121
131
|
delete process.env.api_key;
|
|
132
|
+
delete process.env.OPENAI_API_KEY;
|
|
133
|
+
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
122
134
|
fs.writeFileSync(process.env.KEYRING_FILE, "{}");
|
|
135
|
+
process.env.OPENAI_API_KEY = "ambient-provider-canary";
|
|
136
|
+
assert.deepStrictEqual(
|
|
137
|
+
tools.missingRequires({ requires: ["OPENAI_API_KEY"] }),
|
|
138
|
+
["OPENAI_API_KEY"],
|
|
139
|
+
"ambient provider auth does not satisfy a tool's explicit keyring requirement",
|
|
140
|
+
);
|
|
141
|
+
delete process.env.OPENAI_API_KEY;
|
|
123
142
|
|
|
124
143
|
// ── toolNameFromPath recognises legacy flat files and the directory layout's
|
|
125
144
|
// agent-editable members; state.json and outside paths do not resolve ──
|
package/test-unified-identity.js
CHANGED
|
@@ -78,7 +78,9 @@ function runChild(mode) {
|
|
|
78
78
|
assert.ok(!("voice:voice-owner" in after.users), "on: voice bucket folded away");
|
|
79
79
|
assert.ok("telegram:6251055967" in after.users, "on: owner bucket present");
|
|
80
80
|
const sess = JSON.parse(fs.readFileSync(path.join(CFG, "sessions.json"), "utf-8"));
|
|
81
|
-
assert.
|
|
81
|
+
assert.strictEqual(sess.schemaVersion, state.SESSIONS_SCHEMA_VERSION, "on: provider session schema persisted");
|
|
82
|
+
assert.ok(sess.users["telegram:6251055967"] && sess.users["telegram:6251055967"].hr, "on: hr session folded into owner");
|
|
83
|
+
assert.strictEqual(sess.users["telegram:6251055967"].hr[0].provider, "legacy", "on: provider-blind history remains unclassified");
|
|
82
84
|
const backups = fs.existsSync(path.join(CFG, "backups")) ? fs.readdirSync(path.join(CFG, "backups")) : [];
|
|
83
85
|
assert.ok(backups.length >= 3, "on: state/sessions/identities backed up before merge");
|
|
84
86
|
}
|
package/test-usage-accounting.js
CHANGED
|
@@ -1,29 +1,47 @@
|
|
|
1
1
|
const assert = require("assert");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-usage-accounting-"));
|
|
6
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = configDir;
|
|
2
7
|
const {
|
|
3
8
|
usageParts,
|
|
4
9
|
normalizeCodexUsage,
|
|
5
10
|
applyUsageToState,
|
|
6
11
|
shouldAutoCompact,
|
|
7
12
|
} = require("./core/runner");
|
|
13
|
+
const stateApi = require("./core/state");
|
|
14
|
+
const { normalizeUsageRecord, loadUsageHistory, USAGE_HISTORY_FILE } = require("./core/usage-log");
|
|
8
15
|
|
|
9
16
|
function state(overrides = {}) {
|
|
10
|
-
|
|
17
|
+
const requestedUsage = overrides.sessionUsage;
|
|
18
|
+
const result = {
|
|
19
|
+
userId: "usage-fixture",
|
|
20
|
+
currentSession: { name: "alpha", dir: "/workspace/alpha" },
|
|
11
21
|
settings: { backend: "codex", compactWindow: 100 },
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
sessionUsage: {
|
|
15
|
-
turns: 0,
|
|
16
|
-
inputTokens: 0,
|
|
17
|
-
outputTokens: 0,
|
|
18
|
-
cacheReadTokens: 0,
|
|
19
|
-
cacheCreationTokens: 0,
|
|
20
|
-
costUsd: 0,
|
|
21
|
-
lastInputTokens: 0,
|
|
22
|
-
liveContextTokens: 0,
|
|
23
|
-
lastUsageScope: "turn_delta",
|
|
24
|
-
},
|
|
22
|
+
activeSessions: { alpha: { codex: "codex-session-1", claude: "claude-session-1" } },
|
|
23
|
+
usageBySession: {},
|
|
25
24
|
...overrides,
|
|
26
25
|
};
|
|
26
|
+
delete result.sessionUsage;
|
|
27
|
+
if (requestedUsage) {
|
|
28
|
+
result.usageBySession["alpha\0codex\0codex-session-1"] = {
|
|
29
|
+
...stateApi.freshUsage(),
|
|
30
|
+
...requestedUsage,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
Object.defineProperty(result, "sessionUsage", {
|
|
34
|
+
configurable: true,
|
|
35
|
+
enumerable: true,
|
|
36
|
+
get() { return stateApi.getActiveUsage(result); },
|
|
37
|
+
set(value) {
|
|
38
|
+
const project = stateApi.getProjectKey(result);
|
|
39
|
+
const provider = stateApi.getActiveProvider(result);
|
|
40
|
+
const sessionId = stateApi.getProviderSession(result, provider, project);
|
|
41
|
+
result.usageBySession[`${project}\0${provider}\0${sessionId || "new"}`] = value;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
return result;
|
|
27
45
|
}
|
|
28
46
|
|
|
29
47
|
assert.deepStrictEqual(
|
|
@@ -41,7 +59,7 @@ const first = normalizeCodexUsage(s, "codex-session-1", {
|
|
|
41
59
|
input_tokens: 1000,
|
|
42
60
|
cached_input_tokens: 700,
|
|
43
61
|
output_tokens: 50,
|
|
44
|
-
});
|
|
62
|
+
}, { project: "alpha" });
|
|
45
63
|
assert.strictEqual(first.usageScope, "session_total");
|
|
46
64
|
assert.strictEqual(usageParts(first.usage, "codex").context, 1000);
|
|
47
65
|
|
|
@@ -49,7 +67,7 @@ const second = normalizeCodexUsage(s, "codex-session-1", {
|
|
|
49
67
|
input_tokens: 1250,
|
|
50
68
|
cached_input_tokens: 800,
|
|
51
69
|
output_tokens: 80,
|
|
52
|
-
});
|
|
70
|
+
}, { project: "alpha" });
|
|
53
71
|
assert.strictEqual(second.usageScope, "turn_delta");
|
|
54
72
|
assert.deepStrictEqual(second.usage, {
|
|
55
73
|
input_tokens: 250,
|
|
@@ -60,6 +78,9 @@ assert.deepStrictEqual(second.usage, {
|
|
|
60
78
|
});
|
|
61
79
|
|
|
62
80
|
applyUsageToState(s, second.usage, 0, {
|
|
81
|
+
backend: "codex",
|
|
82
|
+
project: "alpha",
|
|
83
|
+
sessionId: "codex-session-1",
|
|
63
84
|
usageScope: second.usageScope,
|
|
64
85
|
liveContextTokens: usageParts(second.rawUsage, "codex").context,
|
|
65
86
|
});
|
|
@@ -69,16 +90,67 @@ assert.strictEqual(s.sessionUsage.cacheReadTokens, 100);
|
|
|
69
90
|
assert.strictEqual(s.sessionUsage.lastInputTokens, 250);
|
|
70
91
|
assert.strictEqual(s.sessionUsage.liveContextTokens, 1250);
|
|
71
92
|
|
|
93
|
+
// The same native-looking ID in another project has an independent cumulative
|
|
94
|
+
// checkpoint; live provider/project switches cannot redirect captured usage.
|
|
95
|
+
const otherProject = normalizeCodexUsage(s, "codex-session-1", {
|
|
96
|
+
input_tokens: 2000,
|
|
97
|
+
cached_input_tokens: 1200,
|
|
98
|
+
output_tokens: 100,
|
|
99
|
+
}, { project: "beta" });
|
|
100
|
+
assert.strictEqual(otherProject.usageScope, "session_total");
|
|
101
|
+
s.currentSession = { name: "beta", dir: "/workspace/beta" };
|
|
102
|
+
s.settings.backend = "claude";
|
|
103
|
+
s.activeSessions.beta = { claude: "claude-beta", codex: "codex-session-1" };
|
|
104
|
+
applyUsageToState(s, { input_tokens: 25, cached_input_tokens: 5, output_tokens: 2 }, 0, {
|
|
105
|
+
backend: "codex",
|
|
106
|
+
project: "alpha",
|
|
107
|
+
sessionId: "codex-session-1",
|
|
108
|
+
usageScope: "turn_delta",
|
|
109
|
+
});
|
|
110
|
+
assert.strictEqual(stateApi.getUsageForSession(s, "alpha", "codex", "codex-session-1").turns, 2);
|
|
111
|
+
assert.strictEqual(stateApi.getUsageForSession(s, "beta", "claude", "claude-beta").turns, 0);
|
|
112
|
+
|
|
113
|
+
assert.deepStrictEqual(normalizeUsageRecord({ backend: "codex", contextTokens: 10 }), {
|
|
114
|
+
backend: "codex",
|
|
115
|
+
provider: "codex",
|
|
116
|
+
contextTokens: 10,
|
|
117
|
+
});
|
|
118
|
+
assert.deepStrictEqual(normalizeUsageRecord({ provider: "claude", contextTokens: 10 }), {
|
|
119
|
+
provider: "claude",
|
|
120
|
+
backend: "claude",
|
|
121
|
+
contextTokens: 10,
|
|
122
|
+
});
|
|
123
|
+
fs.writeFileSync(USAGE_HISTORY_FILE, `${JSON.stringify({ backend: "codex", contextTokens: 12 })}\n`);
|
|
124
|
+
assert.deepStrictEqual(loadUsageHistory(), [{
|
|
125
|
+
backend: "codex",
|
|
126
|
+
provider: "codex",
|
|
127
|
+
contextTokens: 12,
|
|
128
|
+
}]);
|
|
129
|
+
|
|
130
|
+
const recentlyCompactedCodex = state({
|
|
131
|
+
sessionUsage: {
|
|
132
|
+
liveContextTokens: 120,
|
|
133
|
+
lastInputTokens: 120,
|
|
134
|
+
lastCompactedAt: Date.now(),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
assert.strictEqual(shouldAutoCompact(recentlyCompactedCodex), false);
|
|
138
|
+
recentlyCompactedCodex.settings.backend = "claude";
|
|
139
|
+
Object.assign(stateApi.getUsageForSession(recentlyCompactedCodex, "alpha", "claude", "claude-session-1"), {
|
|
140
|
+
liveContextTokens: 120,
|
|
141
|
+
lastInputTokens: 120,
|
|
142
|
+
});
|
|
143
|
+
assert.strictEqual(shouldAutoCompact(recentlyCompactedCodex), true, "another provider tuple is not throttled by Codex compaction");
|
|
144
|
+
|
|
72
145
|
assert.strictEqual(shouldAutoCompact(state({
|
|
73
146
|
sessionUsage: { liveContextTokens: 99, lastInputTokens: 99 },
|
|
74
147
|
})), false);
|
|
75
148
|
assert.strictEqual(shouldAutoCompact(state({
|
|
76
|
-
sessionUsage: { liveContextTokens: 150, lastInputTokens: 150 },
|
|
77
|
-
lastCompactedAt: Date.now(),
|
|
149
|
+
sessionUsage: { liveContextTokens: 150, lastInputTokens: 150, lastCompactedAt: Date.now() },
|
|
78
150
|
})), false);
|
|
79
151
|
assert.strictEqual(shouldAutoCompact(state({
|
|
80
|
-
sessionUsage: { liveContextTokens: 250, lastInputTokens: 250 },
|
|
81
|
-
lastCompactedAt: Date.now(),
|
|
152
|
+
sessionUsage: { liveContextTokens: 250, lastInputTokens: 250, lastCompactedAt: Date.now() },
|
|
82
153
|
})), true);
|
|
83
154
|
|
|
155
|
+
fs.rmSync(configDir, { recursive: true, force: true });
|
|
84
156
|
console.log("usage accounting OK");
|