@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
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
const crypto = require("crypto");
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-utility-policy-"));
|
|
12
|
+
process.env.OPEN_CLAUDIA_TEST = "1";
|
|
13
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = path.join(root, "config");
|
|
14
|
+
process.env.WORKSPACE = root;
|
|
15
|
+
|
|
16
|
+
const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
|
|
17
|
+
const { createClaudeProvider } = require("./core/providers/claude");
|
|
18
|
+
const { createCodexProvider } = require("./core/providers/codex");
|
|
19
|
+
const {
|
|
20
|
+
UtilityPolicyError,
|
|
21
|
+
createUtilityPolicy,
|
|
22
|
+
parseProviderFallbacks,
|
|
23
|
+
} = require("./core/providers/utility-policy");
|
|
24
|
+
const {
|
|
25
|
+
UtilityAgentError,
|
|
26
|
+
createUtilityAgentService,
|
|
27
|
+
} = require("./core/utility-agent");
|
|
28
|
+
|
|
29
|
+
function wait(ms) {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function waitForFile(file, timeoutMs = 2000) {
|
|
34
|
+
const started = Date.now();
|
|
35
|
+
while (!fs.existsSync(file)) {
|
|
36
|
+
if (Date.now() - started > timeoutMs) throw new Error(`timed out waiting for ${path.basename(file)}`);
|
|
37
|
+
await wait(5);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function waitForPidExit(pid, timeoutMs = 2000) {
|
|
42
|
+
const started = Date.now();
|
|
43
|
+
while (Date.now() - started <= timeoutMs) {
|
|
44
|
+
try { process.kill(pid, 0); }
|
|
45
|
+
catch (error) { if (error.code === "ESRCH") return; throw error; }
|
|
46
|
+
await wait(10);
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`descendant pid ${pid} survived utility cleanup`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function providerEnv(kind, controls = {}) {
|
|
52
|
+
return {
|
|
53
|
+
PATH: process.env.PATH || "",
|
|
54
|
+
HOME: root,
|
|
55
|
+
FAKE_AGENT_KIND: kind,
|
|
56
|
+
...controls,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function providers(controls = {}) {
|
|
61
|
+
return {
|
|
62
|
+
claude: createClaudeProvider({
|
|
63
|
+
hookTransport: false,
|
|
64
|
+
resolveExecutable: () => fixture,
|
|
65
|
+
defaultModel: "claude-high-fixture",
|
|
66
|
+
buildEnv: () => providerEnv("claude", controls.claude),
|
|
67
|
+
getAuthStatus: () => ({ state: "authenticated", reason: null }),
|
|
68
|
+
}),
|
|
69
|
+
codex: createCodexProvider({
|
|
70
|
+
hookTransport: false,
|
|
71
|
+
resolveExecutable: () => fixture,
|
|
72
|
+
defaultModel: "codex-high-fixture",
|
|
73
|
+
buildEnv: () => providerEnv("codex", controls.codex),
|
|
74
|
+
getAuthStatus: () => ({ state: "authenticated", reason: null }),
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function registry(providerMap, states = {}) {
|
|
80
|
+
return {
|
|
81
|
+
getProvider(id) {
|
|
82
|
+
if (!providerMap[id]) throw Object.assign(new Error(`unknown provider ${id}`), { code: "UNKNOWN_PROVIDER" });
|
|
83
|
+
return providerMap[id];
|
|
84
|
+
},
|
|
85
|
+
listProviders: () => Object.values(providerMap),
|
|
86
|
+
providerStatus(id) {
|
|
87
|
+
const state = states[id] || {};
|
|
88
|
+
return {
|
|
89
|
+
id,
|
|
90
|
+
availability: state.availability || { state: "available", executable: fixture, reason: null },
|
|
91
|
+
compatibility: state.compatibility || { state: "compatible", reason: null },
|
|
92
|
+
auth: state.auth || { state: "authenticated", reason: null },
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function assertRejectsCode(promise, code) {
|
|
99
|
+
await assert.rejects(promise, (error) => {
|
|
100
|
+
assert.strictEqual(error.code, code);
|
|
101
|
+
return true;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function main() {
|
|
106
|
+
assert.deepStrictEqual(parseProviderFallbacks(" codex,claude,codex "), ["codex", "claude"]);
|
|
107
|
+
assert.throws(() => parseProviderFallbacks("cursor"), (error) => error.code === "INVALID_UTILITY_PROVIDER");
|
|
108
|
+
|
|
109
|
+
const map = providers();
|
|
110
|
+
const allReady = registry(map);
|
|
111
|
+
|
|
112
|
+
const activePolicy = createUtilityPolicy({
|
|
113
|
+
registry: allReady,
|
|
114
|
+
env: { UTILITY_PROVIDER: "active" },
|
|
115
|
+
activeProvider: () => "codex",
|
|
116
|
+
});
|
|
117
|
+
const active = activePolicy.resolve({ purpose: "recall", tier: "low" });
|
|
118
|
+
assert.strictEqual(active.providerId, "codex", "active chat provider wins for active utility policy");
|
|
119
|
+
assert.strictEqual(active.model, map.codex.tierModel("low"), "provider owns tier-to-model mapping");
|
|
120
|
+
assert.strictEqual(active.fallbackUsed, false);
|
|
121
|
+
|
|
122
|
+
const inheritedActive = createUtilityPolicy({
|
|
123
|
+
registry: allReady,
|
|
124
|
+
env: { UTILITY_PROVIDER: "active", OC_PROVIDER: "codex" },
|
|
125
|
+
}).resolve({ purpose: "subagent", tier: "low" });
|
|
126
|
+
assert.strictEqual(inheritedActive.providerId, "codex", "utility CLI children inherit the admitted provider through OC_PROVIDER");
|
|
127
|
+
|
|
128
|
+
const globalPolicy = createUtilityPolicy({
|
|
129
|
+
registry: allReady,
|
|
130
|
+
env: { UTILITY_PROVIDER: "active", DEFAULT_PROVIDER: "claude" },
|
|
131
|
+
activeProvider: () => null,
|
|
132
|
+
});
|
|
133
|
+
assert.strictEqual(globalPolicy.resolve({ purpose: "dream", tier: "high" }).providerId, "claude",
|
|
134
|
+
"global work uses DEFAULT_PROVIDER when no chat is active");
|
|
135
|
+
|
|
136
|
+
const codexOnlyGlobal = createUtilityPolicy({
|
|
137
|
+
registry: registry(map, {
|
|
138
|
+
claude: { availability: { state: "missing", executable: null, reason: "Claude absent" } },
|
|
139
|
+
}),
|
|
140
|
+
env: { UTILITY_PROVIDER: "active" },
|
|
141
|
+
activeProvider: () => null,
|
|
142
|
+
});
|
|
143
|
+
assert.strictEqual(codexOnlyGlobal.resolve({ purpose: "dream", tier: "high" }).providerId, "codex",
|
|
144
|
+
"an unset global default deterministically selects the first runnable provider in Codex-only installs");
|
|
145
|
+
|
|
146
|
+
const purposePolicy = createUtilityPolicy({
|
|
147
|
+
registry: allReady,
|
|
148
|
+
env: { UTILITY_PROVIDER: "claude", DREAM_PROVIDER: "codex", PACK_REVIEW_MODEL_CODEX: "review-codex-exact" },
|
|
149
|
+
activeProvider: () => "claude",
|
|
150
|
+
});
|
|
151
|
+
const purpose = purposePolicy.resolve({ purpose: "dream", tier: "high" });
|
|
152
|
+
assert.strictEqual(purpose.providerId, "codex", "per-purpose provider override has highest configured priority");
|
|
153
|
+
assert.strictEqual(
|
|
154
|
+
createUtilityPolicy({
|
|
155
|
+
registry: allReady,
|
|
156
|
+
env: { UTILITY_PROVIDER: "active", RECALL_PROVIDER: "codex" },
|
|
157
|
+
activeProvider: () => "claude",
|
|
158
|
+
}).resolve({ purpose: "recall", activeProvider: "claude", tier: "low" }).providerId,
|
|
159
|
+
"codex",
|
|
160
|
+
"purpose override wins over the foreground provider captured at admission",
|
|
161
|
+
);
|
|
162
|
+
assert.strictEqual(
|
|
163
|
+
createUtilityPolicy({
|
|
164
|
+
registry: allReady,
|
|
165
|
+
env: { UTILITY_PROVIDER: "active" },
|
|
166
|
+
activeProvider: () => "claude",
|
|
167
|
+
}).resolve({ purpose: "recall", activeProvider: "codex", tier: "low" }).providerId,
|
|
168
|
+
"codex",
|
|
169
|
+
"active policy uses the immutable captured provider instead of mutable live state",
|
|
170
|
+
);
|
|
171
|
+
const reviewExact = purposePolicy.resolve({ purpose: "review", provider: "codex", tier: "medium" });
|
|
172
|
+
assert.strictEqual(reviewExact.model, "review-codex-exact", "provider-specific exact-model override is preserved");
|
|
173
|
+
|
|
174
|
+
const legacyModelPolicy = createUtilityPolicy({
|
|
175
|
+
registry: allReady,
|
|
176
|
+
env: { UTILITY_PROVIDER: "claude", PACK_REVIEW_MODEL: "legacy-claude-review" },
|
|
177
|
+
});
|
|
178
|
+
assert.strictEqual(legacyModelPolicy.resolve({ purpose: "review", tier: "medium" }).model, "legacy-claude-review");
|
|
179
|
+
assert.notStrictEqual(
|
|
180
|
+
createUtilityPolicy({ registry: allReady, env: { UTILITY_PROVIDER: "codex", PACK_REVIEW_MODEL: "legacy-claude-review" } })
|
|
181
|
+
.resolve({ purpose: "review", tier: "medium" }).model,
|
|
182
|
+
"legacy-claude-review",
|
|
183
|
+
"legacy Claude model aliases never leak into Codex",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const fallbackRegistry = registry(map, {
|
|
187
|
+
claude: { availability: { state: "missing", executable: null, reason: "fixture unavailable" } },
|
|
188
|
+
});
|
|
189
|
+
const fallbackPolicy = createUtilityPolicy({
|
|
190
|
+
registry: fallbackRegistry,
|
|
191
|
+
env: { UTILITY_PROVIDER: "claude", PROVIDER_FALLBACKS: "codex" },
|
|
192
|
+
});
|
|
193
|
+
const fallback = fallbackPolicy.resolve({ purpose: "recall", tier: "low" });
|
|
194
|
+
assert.strictEqual(fallback.providerId, "codex");
|
|
195
|
+
assert.strictEqual(fallback.preferredProviderId, "claude");
|
|
196
|
+
assert.strictEqual(fallback.fallbackUsed, true);
|
|
197
|
+
|
|
198
|
+
const authFallback = createUtilityPolicy({
|
|
199
|
+
registry: registry(map, { claude: { auth: { state: "unauthenticated", reason: "login required" } } }),
|
|
200
|
+
env: { ENFORCER_PROVIDER: "claude", PROVIDER_FALLBACKS: "codex" },
|
|
201
|
+
}).resolve({ purpose: "enforcer", tier: "medium" });
|
|
202
|
+
assert.strictEqual(authFallback.providerId, "codex", "configured enforcer fallback handles auth failure");
|
|
203
|
+
assert.strictEqual(authFallback.fallbackUsed, true, "enforcer fallback is explicit in metadata");
|
|
204
|
+
|
|
205
|
+
assert.throws(() => createUtilityPolicy({
|
|
206
|
+
registry: fallbackRegistry,
|
|
207
|
+
env: { UTILITY_PROVIDER: "claude" },
|
|
208
|
+
}).resolve({ purpose: "enforcer", tier: "medium" }), (error) => {
|
|
209
|
+
assert.ok(error instanceof UtilityPolicyError);
|
|
210
|
+
assert.strictEqual(error.code, "NO_AVAILABLE_UTILITY_PROVIDER");
|
|
211
|
+
return true;
|
|
212
|
+
}, "enforcer never invents an unconfigured fallback");
|
|
213
|
+
|
|
214
|
+
const claudeCapture = path.join(root, "claude-capture.json");
|
|
215
|
+
const codexCapture = path.join(root, "codex-capture.json");
|
|
216
|
+
const serviceProviders = providers({
|
|
217
|
+
claude: { FAKE_AGENT_CAPTURE: claudeCapture, FAKE_AGENT_TEXT: "utility Claude output" },
|
|
218
|
+
codex: { FAKE_AGENT_CAPTURE: codexCapture, FAKE_AGENT_TEXT: "utility Codex output" },
|
|
219
|
+
});
|
|
220
|
+
const serviceRegistry = registry(serviceProviders);
|
|
221
|
+
const service = createUtilityAgentService({
|
|
222
|
+
registry: serviceRegistry,
|
|
223
|
+
env: { UTILITY_PROVIDER: "claude", PROVIDER_FALLBACKS: "codex" },
|
|
224
|
+
maxGlobalConcurrency: 3,
|
|
225
|
+
maxPurposeConcurrency: 2,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const shellSentinel = "literal $(touch must-not-run) $HOME";
|
|
229
|
+
const claudeRun = await service.spawnUtilityAgent(shellSentinel, {
|
|
230
|
+
purpose: "subagent",
|
|
231
|
+
tier: "low",
|
|
232
|
+
systemPrompt: "UTILITY CORE SENTINEL",
|
|
233
|
+
readOnly: true,
|
|
234
|
+
allowedTools: [],
|
|
235
|
+
cwd: root,
|
|
236
|
+
});
|
|
237
|
+
assert.strictEqual(claudeRun.ok, true);
|
|
238
|
+
assert.strictEqual(claudeRun.provider, "claude");
|
|
239
|
+
assert.strictEqual(claudeRun.model, serviceProviders.claude.tierModel("low"));
|
|
240
|
+
assert.strictEqual(claudeRun.text, "utility Claude output");
|
|
241
|
+
assert.strictEqual(claudeRun.fallbackUsed, false);
|
|
242
|
+
assert.strictEqual(claudeRun.noProductSideEffects.requested, true);
|
|
243
|
+
assert.strictEqual(claudeRun.noProductSideEffects.support, "native");
|
|
244
|
+
assert.strictEqual(claudeRun.noTools.requested, true);
|
|
245
|
+
assert.strictEqual(claudeRun.noTools.verified, false, "tool suppression is never overstated without a provider probe");
|
|
246
|
+
const claudeArgv = JSON.parse(fs.readFileSync(claudeCapture, "utf8")).argv;
|
|
247
|
+
assert.ok(claudeArgv.includes("--no-session-persistence"), "utility calls are fresh and ephemeral");
|
|
248
|
+
assert.ok(!claudeArgv.includes("--resume"));
|
|
249
|
+
assert.ok(claudeArgv.includes("UTILITY CORE SENTINEL"));
|
|
250
|
+
assert.ok(!claudeArgv.includes(shellSentinel), "private utility prompt is absent from argv");
|
|
251
|
+
const claudeCaptureRecord = JSON.parse(fs.readFileSync(claudeCapture, "utf8"));
|
|
252
|
+
assert.deepStrictEqual(claudeCaptureRecord.stdin, {
|
|
253
|
+
present: true,
|
|
254
|
+
bytes: Buffer.byteLength(shellSentinel),
|
|
255
|
+
sha256: crypto.createHash("sha256").update(shellSentinel).digest("hex"),
|
|
256
|
+
}, "fixture records only protected-stdin size/hash, never prompt content");
|
|
257
|
+
assert.ok(!fs.readFileSync(claudeCapture, "utf8").includes(shellSentinel), "capture never persists utility prompt content");
|
|
258
|
+
assert.ok(!fs.existsSync(path.join(root, "must-not-run")), "utility prompt is never shell-evaluated");
|
|
259
|
+
|
|
260
|
+
const codexRun = await service.spawnUtilityAgent("codex utility", {
|
|
261
|
+
purpose: "recall",
|
|
262
|
+
provider: "codex",
|
|
263
|
+
tier: "low",
|
|
264
|
+
systemPrompt: "CODEX UTILITY CORE",
|
|
265
|
+
readOnly: true,
|
|
266
|
+
cwd: root,
|
|
267
|
+
});
|
|
268
|
+
assert.strictEqual(codexRun.ok, true);
|
|
269
|
+
assert.strictEqual(codexRun.provider, "codex");
|
|
270
|
+
assert.strictEqual(codexRun.text, "utility Codex output");
|
|
271
|
+
const codexArgv = JSON.parse(fs.readFileSync(codexCapture, "utf8")).argv;
|
|
272
|
+
assert.deepStrictEqual(codexArgv.slice(0, 1), ["exec"]);
|
|
273
|
+
assert.ok(codexArgv.includes("--ephemeral"));
|
|
274
|
+
assert.ok(codexArgv.includes("read-only"));
|
|
275
|
+
|
|
276
|
+
const runtimeFallbackService = createUtilityAgentService({
|
|
277
|
+
registry: registry(providers({
|
|
278
|
+
claude: { FAKE_AGENT_SCENARIO: "auth-failure" },
|
|
279
|
+
codex: { FAKE_AGENT_TEXT: "fallback judged safely" },
|
|
280
|
+
})),
|
|
281
|
+
env: { ENFORCER_PROVIDER: "claude", PROVIDER_FALLBACKS: "codex" },
|
|
282
|
+
});
|
|
283
|
+
const runtimeFallback = await runtimeFallbackService.spawnUtilityAgent("guard", {
|
|
284
|
+
purpose: "enforcer", tier: "medium", systemPrompt: "guard", readOnly: true, cwd: root,
|
|
285
|
+
});
|
|
286
|
+
assert.strictEqual(runtimeFallback.provider, "codex", "runtime auth failure advances to configured fallback");
|
|
287
|
+
assert.strictEqual(runtimeFallback.preferredProvider, "claude");
|
|
288
|
+
assert.strictEqual(runtimeFallback.fallbackUsed, true);
|
|
289
|
+
assert.strictEqual(runtimeFallback.text, "fallback judged safely");
|
|
290
|
+
|
|
291
|
+
const redactionService = createUtilityAgentService({
|
|
292
|
+
registry: registry(providers({
|
|
293
|
+
claude: { FAKE_AGENT_STDERR: "do not leak sk-proj-abcdefghijklmnopqrstuvwxyz", FAKE_AGENT_TEXT: "safe" },
|
|
294
|
+
})),
|
|
295
|
+
env: { UTILITY_PROVIDER: "claude" },
|
|
296
|
+
maxStderrBytes: 24,
|
|
297
|
+
});
|
|
298
|
+
const redacted = await redactionService.spawnUtilityAgent("redact", {
|
|
299
|
+
purpose: "subagent", systemPrompt: "redact", cwd: root,
|
|
300
|
+
});
|
|
301
|
+
assert.ok(!redacted.stderr.includes("sk-proj-"), "bounded stderr is redacted at the service boundary");
|
|
302
|
+
assert.strictEqual(redacted.stderrTruncated, true);
|
|
303
|
+
|
|
304
|
+
const schemaProviders = providers({
|
|
305
|
+
codex: { FAKE_AGENT_TEXT: '{"decision":"allow"}' },
|
|
306
|
+
});
|
|
307
|
+
const schemaService = createUtilityAgentService({
|
|
308
|
+
registry: registry(schemaProviders),
|
|
309
|
+
env: { UTILITY_PROVIDER: "codex" },
|
|
310
|
+
});
|
|
311
|
+
const schemaRun = await schemaService.spawnUtilityAgent("judge", {
|
|
312
|
+
purpose: "enforcer",
|
|
313
|
+
tier: "medium",
|
|
314
|
+
systemPrompt: "schema judge",
|
|
315
|
+
readOnly: true,
|
|
316
|
+
outputSchema: {
|
|
317
|
+
type: "object",
|
|
318
|
+
required: ["decision"],
|
|
319
|
+
properties: { decision: { type: "string", enum: ["allow", "block", "escalate"] } },
|
|
320
|
+
},
|
|
321
|
+
cwd: root,
|
|
322
|
+
});
|
|
323
|
+
assert.deepStrictEqual(schemaRun.json, { decision: "allow" });
|
|
324
|
+
assert.strictEqual(schemaRun.provider, "codex");
|
|
325
|
+
|
|
326
|
+
const structuredClaudeService = createUtilityAgentService({
|
|
327
|
+
registry: registry(providers({
|
|
328
|
+
claude: {
|
|
329
|
+
FAKE_AGENT_TEXT: '```json\n{"decision":"block","explanation":"presentation only"}\n```',
|
|
330
|
+
FAKE_AGENT_STRUCTURED_JSON: '{"decision":"allow"}',
|
|
331
|
+
},
|
|
332
|
+
})),
|
|
333
|
+
env: { UTILITY_PROVIDER: "claude" },
|
|
334
|
+
maxOutputBytes: 24,
|
|
335
|
+
});
|
|
336
|
+
const structuredClaude = await structuredClaudeService.spawnUtilityAgent("judge", {
|
|
337
|
+
purpose: "enforcer",
|
|
338
|
+
tier: "medium",
|
|
339
|
+
systemPrompt: "schema judge",
|
|
340
|
+
readOnly: true,
|
|
341
|
+
outputSchema: {
|
|
342
|
+
type: "object",
|
|
343
|
+
required: ["decision"],
|
|
344
|
+
properties: { decision: { type: "string", enum: ["allow", "block", "escalate"] } },
|
|
345
|
+
},
|
|
346
|
+
cwd: root,
|
|
347
|
+
});
|
|
348
|
+
assert.deepStrictEqual(structuredClaude.json, { decision: "allow" });
|
|
349
|
+
assert.strictEqual(structuredClaude.text, '{"decision":"allow"}');
|
|
350
|
+
assert.strictEqual(structuredClaude.truncated, false, "truncation follows the selected structured result");
|
|
351
|
+
|
|
352
|
+
await assertRejectsCode(schemaService.spawnUtilityAgent("judge", {
|
|
353
|
+
purpose: "enforcer",
|
|
354
|
+
tier: "medium",
|
|
355
|
+
systemPrompt: "schema judge",
|
|
356
|
+
readOnly: true,
|
|
357
|
+
outputSchema: {
|
|
358
|
+
type: "object",
|
|
359
|
+
required: ["missing"],
|
|
360
|
+
properties: { missing: { type: "string" } },
|
|
361
|
+
},
|
|
362
|
+
cwd: root,
|
|
363
|
+
}), "UTILITY_SCHEMA_INVALID");
|
|
364
|
+
|
|
365
|
+
const truncService = createUtilityAgentService({
|
|
366
|
+
registry: registry(providers({ claude: { FAKE_AGENT_TEXT: "0123456789abcdefghijklmnopqrstuvwxyz" } })),
|
|
367
|
+
env: { UTILITY_PROVIDER: "claude" },
|
|
368
|
+
maxOutputBytes: 12,
|
|
369
|
+
});
|
|
370
|
+
const truncated = await truncService.spawnUtilityAgent("truncate", {
|
|
371
|
+
purpose: "subagent", systemPrompt: "truncate", cwd: root,
|
|
372
|
+
});
|
|
373
|
+
assert.strictEqual(truncated.text, "0123456789ab");
|
|
374
|
+
assert.strictEqual(truncated.truncated, true);
|
|
375
|
+
|
|
376
|
+
const delayedCapture = path.join(root, "delayed-capture.json");
|
|
377
|
+
const limited = createUtilityAgentService({
|
|
378
|
+
registry: registry(providers({ claude: { FAKE_AGENT_CAPTURE: delayedCapture, FAKE_AGENT_CLOSE_DELAY_MS: "180" } })),
|
|
379
|
+
env: { UTILITY_PROVIDER: "claude" },
|
|
380
|
+
maxGlobalConcurrency: 1,
|
|
381
|
+
maxPurposeConcurrency: 1,
|
|
382
|
+
});
|
|
383
|
+
const first = limited.spawnUtilityAgent("first", { purpose: "subagent", systemPrompt: "limited", cwd: root });
|
|
384
|
+
await waitForFile(delayedCapture);
|
|
385
|
+
assert.deepStrictEqual(limited.stats(), { activeGlobal: 1, activeByPurpose: { subagent: 1 } });
|
|
386
|
+
await assertRejectsCode(
|
|
387
|
+
limited.spawnUtilityAgent("second", { purpose: "subagent", systemPrompt: "limited", cwd: root }),
|
|
388
|
+
"UTILITY_CONCURRENCY_LIMIT",
|
|
389
|
+
);
|
|
390
|
+
await first;
|
|
391
|
+
assert.deepStrictEqual(limited.stats(), { activeGlobal: 0, activeByPurpose: {} });
|
|
392
|
+
|
|
393
|
+
const recursive = createUtilityAgentService({
|
|
394
|
+
registry: serviceRegistry,
|
|
395
|
+
env: { UTILITY_PROVIDER: "claude" },
|
|
396
|
+
maxRecursionDepth: 1,
|
|
397
|
+
});
|
|
398
|
+
await recursive.withUtilityContext(async () => {
|
|
399
|
+
await assertRejectsCode(
|
|
400
|
+
recursive.spawnUtilityAgent("nested", { purpose: "subagent", systemPrompt: "nested", cwd: root }),
|
|
401
|
+
"UTILITY_RECURSION_LIMIT",
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const signalCapture = path.join(root, "timeout-signal.json");
|
|
406
|
+
const descendantCapture = path.join(root, "timeout-descendant.json");
|
|
407
|
+
const timeoutService = createUtilityAgentService({
|
|
408
|
+
registry: registry(providers({
|
|
409
|
+
claude: {
|
|
410
|
+
FAKE_AGENT_SCENARIO: "forced-termination",
|
|
411
|
+
FAKE_AGENT_IGNORE_SIGTERM: "1",
|
|
412
|
+
FAKE_AGENT_SIGNAL_CAPTURE: signalCapture,
|
|
413
|
+
FAKE_AGENT_DESCENDANT_CAPTURE: descendantCapture,
|
|
414
|
+
},
|
|
415
|
+
})),
|
|
416
|
+
env: { UTILITY_PROVIDER: "claude" },
|
|
417
|
+
killGraceMs: 40,
|
|
418
|
+
});
|
|
419
|
+
const timeoutStarted = Date.now();
|
|
420
|
+
await assert.rejects(
|
|
421
|
+
timeoutService.spawnUtilityAgent("timeout", {
|
|
422
|
+
purpose: "subagent", systemPrompt: "timeout", timeoutMs: 180, cwd: root,
|
|
423
|
+
}),
|
|
424
|
+
(error) => {
|
|
425
|
+
assert.ok(error instanceof UtilityAgentError);
|
|
426
|
+
assert.strictEqual(error.code, "UTILITY_TIMEOUT");
|
|
427
|
+
assert.strictEqual(error.result.timedOut, true);
|
|
428
|
+
assert.strictEqual(error.result.provider, "claude");
|
|
429
|
+
return true;
|
|
430
|
+
},
|
|
431
|
+
);
|
|
432
|
+
assert.ok(Date.now() - timeoutStarted >= 180, "timeout settles only after its deadline and process close");
|
|
433
|
+
await waitForFile(signalCapture);
|
|
434
|
+
assert.strictEqual(JSON.parse(fs.readFileSync(signalCapture, "utf8")).signal, "SIGTERM");
|
|
435
|
+
await waitForFile(descendantCapture);
|
|
436
|
+
await waitForPidExit(JSON.parse(fs.readFileSync(descendantCapture, "utf8")).pid);
|
|
437
|
+
assert.strictEqual(timeoutService.stats().activeGlobal, 0, "timeout cleanup releases concurrency only after close");
|
|
438
|
+
|
|
439
|
+
const abortCapture = path.join(root, "abort-signal.json");
|
|
440
|
+
const abortService = createUtilityAgentService({
|
|
441
|
+
registry: registry(providers({
|
|
442
|
+
codex: { FAKE_AGENT_SCENARIO: "forced-termination", FAKE_AGENT_SIGNAL_CAPTURE: abortCapture },
|
|
443
|
+
})),
|
|
444
|
+
env: { UTILITY_PROVIDER: "codex" },
|
|
445
|
+
});
|
|
446
|
+
const controller = new AbortController();
|
|
447
|
+
const aborted = abortService.spawnUtilityAgent("abort", {
|
|
448
|
+
purpose: "recall", systemPrompt: "abort", signal: controller.signal, cwd: root,
|
|
449
|
+
});
|
|
450
|
+
await wait(100);
|
|
451
|
+
controller.abort();
|
|
452
|
+
await assertRejectsCode(aborted, "UTILITY_CANCELLED");
|
|
453
|
+
await waitForFile(abortCapture);
|
|
454
|
+
|
|
455
|
+
const drainCapture = path.join(root, "drain-signal.json");
|
|
456
|
+
const drainService = createUtilityAgentService({
|
|
457
|
+
registry: registry(providers({
|
|
458
|
+
claude: { FAKE_AGENT_SCENARIO: "forced-termination", FAKE_AGENT_SIGNAL_CAPTURE: drainCapture },
|
|
459
|
+
})),
|
|
460
|
+
env: { UTILITY_PROVIDER: "claude" },
|
|
461
|
+
killGraceMs: 40,
|
|
462
|
+
});
|
|
463
|
+
const drainingRun = drainService.spawnUtilityAgent("shutdown drain", {
|
|
464
|
+
purpose: "review", systemPrompt: "drain", cwd: root,
|
|
465
|
+
});
|
|
466
|
+
const drainingAssertion = assertRejectsCode(drainingRun, "UTILITY_CANCELLED");
|
|
467
|
+
await wait(100);
|
|
468
|
+
const drained = await drainService.cancelAll({ timeoutMs: 1000 });
|
|
469
|
+
await drainingAssertion;
|
|
470
|
+
assert.deepStrictEqual(drained, { drained: true, active: 0 }, "bounded shutdown cancellation waits for utility child cleanup");
|
|
471
|
+
await waitForFile(drainCapture);
|
|
472
|
+
|
|
473
|
+
const config = require("./core/config");
|
|
474
|
+
for (const key of ["DEFAULT_PROVIDER", "UTILITY_PROVIDER", "PROVIDER_FALLBACKS", "UTILITY_PURPOSE_PROVIDERS"]) {
|
|
475
|
+
assert.ok(Object.prototype.hasOwnProperty.call(config, key), `config exports ${key}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
console.log("utility provider policy OK");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
main().catch((error) => {
|
|
482
|
+
console.error(error.stack || error.message);
|
|
483
|
+
process.exitCode = 1;
|
|
484
|
+
}).finally(() => {
|
|
485
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
486
|
+
});
|