@inetafrica/open-claudia 2.15.0 → 3.0.1
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 +22 -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 +81 -3
- package/channels/telegram/adapter.js +65 -5
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +155 -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 +542 -224
- 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 +121 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +125 -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 +171 -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 +1562 -1286
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/single-instance.js +46 -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 +337 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +509 -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 +171 -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 +93 -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,399 @@
|
|
|
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 http = require("http");
|
|
10
|
+
const { spawnSync } = require("child_process");
|
|
11
|
+
|
|
12
|
+
const root = __dirname;
|
|
13
|
+
|
|
14
|
+
function writeProviderCli(filePath, kind, mode, logPath) {
|
|
15
|
+
const source = `#!/usr/bin/env node
|
|
16
|
+
"use strict";
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({ kind: ${JSON.stringify(kind)}, args }) + "\\n");
|
|
20
|
+
const same = (...wanted) => args.length === wanted.length && args.every((value, index) => value === wanted[index]);
|
|
21
|
+
if (same("--version")) {
|
|
22
|
+
process.stdout.write(${JSON.stringify(kind === "claude" ? "1.2.3\n" : "codex-cli 0.144.0\n")});
|
|
23
|
+
} else if (${JSON.stringify(kind)} === "claude" && same("--help")) {
|
|
24
|
+
process.stdout.write(${JSON.stringify(mode === "incompatible"
|
|
25
|
+
? "--output-format --resume --permission-mode --settings\n"
|
|
26
|
+
: "--append-system-prompt --output-format --resume --permission-mode --settings\n")});
|
|
27
|
+
} else if (${JSON.stringify(kind)} === "claude" && same("auth", "status")) {
|
|
28
|
+
if (${JSON.stringify(mode)} === "unauthenticated") {
|
|
29
|
+
process.stderr.write("not logged in\\n");
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
} else process.stdout.write("authenticated via fixture store\\n");
|
|
32
|
+
} else if (${JSON.stringify(kind)} === "codex" && (same("exec", "--help") || same("exec", "resume", "--help"))) {
|
|
33
|
+
process.stdout.write(${JSON.stringify(mode === "incompatible"
|
|
34
|
+
? "--config --json --sandbox --model --skip-git-repo-check --strict-config --ignore-user-config --dangerously-bypass-hook-trust\n"
|
|
35
|
+
: "--config --json --sandbox --image --model --skip-git-repo-check --strict-config --ignore-user-config --dangerously-bypass-hook-trust\n")});
|
|
36
|
+
} else if (${JSON.stringify(kind)} === "codex" && args[0] === "features" && args.at(-1) === "list") {
|
|
37
|
+
process.stdout.write("hooks stable true\\nplugins stable false\\nunified_exec stable false\\n");
|
|
38
|
+
} else if (${JSON.stringify(kind)} === "codex" && same("login", "status")) {
|
|
39
|
+
if (${JSON.stringify(mode)} === "unauthenticated") {
|
|
40
|
+
process.stderr.write("not logged in\\n");
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
} else process.stdout.write("logged in\\n");
|
|
43
|
+
} else {
|
|
44
|
+
process.stderr.write("MODEL_PROMPT_INVOCATION " + JSON.stringify(args) + "\\n");
|
|
45
|
+
process.exitCode = 70;
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
fs.writeFileSync(filePath, source, { mode: 0o755 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readCommandLog(logPath) {
|
|
52
|
+
if (!fs.existsSync(logPath)) return [];
|
|
53
|
+
return fs.readFileSync(logPath, "utf8")
|
|
54
|
+
.split(/\r?\n/)
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.map((line) => JSON.parse(line));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isLocalStatusProbe(entry) {
|
|
60
|
+
const joined = entry.args.join(" ");
|
|
61
|
+
if (entry.kind === "claude") {
|
|
62
|
+
return joined === "--version" || joined === "--help" || joined === "auth status";
|
|
63
|
+
}
|
|
64
|
+
return joined === "--version"
|
|
65
|
+
|| joined === "exec --help"
|
|
66
|
+
|| joined === "exec resume --help"
|
|
67
|
+
|| joined === "login status"
|
|
68
|
+
|| (entry.args[0] === "features" && entry.args.at(-1) === "list");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function childProbe() {
|
|
72
|
+
const config = require("./core/config");
|
|
73
|
+
const providers = require("./core/providers");
|
|
74
|
+
const setup = require("./setup");
|
|
75
|
+
const health = require("./health");
|
|
76
|
+
const doctor = require("./core/doctor");
|
|
77
|
+
const web = require("./web");
|
|
78
|
+
const stateApi = require("./core/state");
|
|
79
|
+
const { captureRunContext } = require("./core/run-context");
|
|
80
|
+
|
|
81
|
+
const selection = providers.selectDefaultProvider(config.DEFAULT_PROVIDER);
|
|
82
|
+
const freshState = stateApi.currentState();
|
|
83
|
+
const stateProvider = stateApi.getActiveProvider(freshState);
|
|
84
|
+
const setupStatus = setup.inspectProviderSetup();
|
|
85
|
+
const healthStatus = await health.runHealthChecks({ quick: false, skipNetwork: true });
|
|
86
|
+
const doctorChecks = doctor.runDoctorChecks();
|
|
87
|
+
const webStatus = web.providerConfigurationStatus();
|
|
88
|
+
|
|
89
|
+
// Startup, not merely import, must remain available without a provider.
|
|
90
|
+
const server = web.startWebServer();
|
|
91
|
+
if (!server.listening) await new Promise((resolve) => server.once("listening", resolve));
|
|
92
|
+
await new Promise((resolve) => server.close(resolve));
|
|
93
|
+
|
|
94
|
+
let afterDisappear = null;
|
|
95
|
+
if (process.env.DISAPPEAR_PROVIDER) {
|
|
96
|
+
const providerId = process.env.DISAPPEAR_PROVIDER;
|
|
97
|
+
const executable = config.discoverProviderExecutable(providerId);
|
|
98
|
+
if (executable) fs.rmSync(executable, { force: true });
|
|
99
|
+
afterDisappear = {
|
|
100
|
+
status: providers.providerStatus(providerId),
|
|
101
|
+
selection: providers.selectDefaultProvider(config.DEFAULT_PROVIDER),
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
providers.resolveProviderForRun(providerId);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
afterDisappear.runError = error.code;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let noProviderTurn = null;
|
|
111
|
+
if (!selection.providerId) {
|
|
112
|
+
let admissionError;
|
|
113
|
+
try {
|
|
114
|
+
captureRunContext({ state: freshState, prompt: "must not spawn", cwd: process.cwd() });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
admissionError = error;
|
|
117
|
+
}
|
|
118
|
+
const sent = [];
|
|
119
|
+
const { runInChat } = require("./core/context");
|
|
120
|
+
const { deliverRouterError } = require("./core/router");
|
|
121
|
+
const delivered = await runInChat({
|
|
122
|
+
adapter: { type: "fixture", send: async (_channelId, message) => { sent.push(message); return true; } },
|
|
123
|
+
channelId: "fixture-chat",
|
|
124
|
+
canonicalUserId: "telegram:fixture-chat",
|
|
125
|
+
userId: "fixture-chat",
|
|
126
|
+
}, () => deliverRouterError(admissionError));
|
|
127
|
+
noProviderTurn = { admissionCode: admissionError?.code || null, delivered, sent };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
process.stdout.write(`BOOT_MATRIX:${JSON.stringify({
|
|
131
|
+
selection,
|
|
132
|
+
stateProvider,
|
|
133
|
+
setupStatus,
|
|
134
|
+
healthStatus,
|
|
135
|
+
doctorChecks,
|
|
136
|
+
webStatus,
|
|
137
|
+
afterDisappear,
|
|
138
|
+
noProviderTurn,
|
|
139
|
+
})}\n`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function httpRequest(port, pathname, options = {}) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const body = options.body === undefined ? null : JSON.stringify(options.body);
|
|
145
|
+
const req = http.request({
|
|
146
|
+
hostname: "127.0.0.1",
|
|
147
|
+
port,
|
|
148
|
+
path: pathname,
|
|
149
|
+
method: options.method || "GET",
|
|
150
|
+
headers: {
|
|
151
|
+
...(body ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } : {}),
|
|
152
|
+
...(options.cookie ? { Cookie: options.cookie } : {}),
|
|
153
|
+
},
|
|
154
|
+
}, (res) => {
|
|
155
|
+
let data = "";
|
|
156
|
+
res.setEncoding("utf8");
|
|
157
|
+
res.on("data", (chunk) => { data += chunk; });
|
|
158
|
+
res.on("end", () => resolve({
|
|
159
|
+
status: res.statusCode,
|
|
160
|
+
headers: res.headers,
|
|
161
|
+
body: data ? JSON.parse(data) : null,
|
|
162
|
+
}));
|
|
163
|
+
});
|
|
164
|
+
req.on("error", reject);
|
|
165
|
+
if (body) req.write(body);
|
|
166
|
+
req.end();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function webHttpProbe() {
|
|
171
|
+
const web = require("./web");
|
|
172
|
+
const server = web.startWebServer({
|
|
173
|
+
telegramGet: async () => ({ ok: true, result: { id: 1, username: "fixture_bot", first_name: "Fixture" } }),
|
|
174
|
+
});
|
|
175
|
+
if (!server.listening) await new Promise((resolve) => server.once("listening", resolve));
|
|
176
|
+
const port = server.address().port;
|
|
177
|
+
const login = await httpRequest(port, "/api/login", {
|
|
178
|
+
method: "POST",
|
|
179
|
+
body: { password: process.env.WEB_PASSWORD },
|
|
180
|
+
});
|
|
181
|
+
assert.strictEqual(login.status, 200);
|
|
182
|
+
const cookie = String(login.headers["set-cookie"][0]).split(";")[0];
|
|
183
|
+
const setup = await httpRequest(port, "/api/setup", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
cookie,
|
|
186
|
+
body: {
|
|
187
|
+
botToken: "fixture-token",
|
|
188
|
+
chatId: "fixture-chat",
|
|
189
|
+
workspace: process.env.WEB_FIXTURE_WORKSPACE,
|
|
190
|
+
claudePath: "",
|
|
191
|
+
codexPath: process.env.WEB_FIXTURE_CODEX,
|
|
192
|
+
defaultProvider: "codex",
|
|
193
|
+
utilityProvider: "active",
|
|
194
|
+
providerFallbacks: "claude",
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
assert.strictEqual(setup.status, 200, JSON.stringify(setup.body));
|
|
198
|
+
const status = await httpRequest(port, "/api/status", { cookie });
|
|
199
|
+
const configUpdate = await httpRequest(port, "/api/config", {
|
|
200
|
+
method: "POST",
|
|
201
|
+
cookie,
|
|
202
|
+
body: { UTILITY_PROVIDER: "codex", DREAM_PROVIDER: "codex" },
|
|
203
|
+
});
|
|
204
|
+
const config = await httpRequest(port, "/api/config", { cookie });
|
|
205
|
+
const soulUpdate = await httpRequest(port, "/api/soul", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
cookie,
|
|
208
|
+
body: { content: "# Soul\n\nProvider-neutral fixture soul.\n" },
|
|
209
|
+
});
|
|
210
|
+
const soul = await httpRequest(port, "/api/soul", { cookie });
|
|
211
|
+
await new Promise((resolve) => server.close(resolve));
|
|
212
|
+
const envPath = path.join(process.env.OPEN_CLAUDIA_CONFIG_DIR, ".env");
|
|
213
|
+
process.stdout.write(`WEB_HTTP:${JSON.stringify({
|
|
214
|
+
setup: setup.body,
|
|
215
|
+
status: status.body,
|
|
216
|
+
configUpdate: configUpdate.body,
|
|
217
|
+
config: config.body,
|
|
218
|
+
soulUpdate: soulUpdate.body,
|
|
219
|
+
soul: soul.body,
|
|
220
|
+
envMode: fs.statSync(envPath).mode & 0o777,
|
|
221
|
+
})}\n`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (process.argv[2] === "--probe") {
|
|
225
|
+
childProbe().catch((error) => {
|
|
226
|
+
process.stderr.write(`${error.stack || error.message}\n`);
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
});
|
|
229
|
+
} else if (process.argv[2] === "--web-http-probe") {
|
|
230
|
+
webHttpProbe().catch((error) => {
|
|
231
|
+
process.stderr.write(`${error.stack || error.message}\n`);
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
});
|
|
234
|
+
} else {
|
|
235
|
+
const scenarios = [
|
|
236
|
+
{ name: "claude-only", providers: { claude: "ok" }, expectedDefault: "claude" },
|
|
237
|
+
{ name: "codex-only", providers: { codex: "ok" }, expectedDefault: "codex" },
|
|
238
|
+
{ name: "codex-path-only", providers: { codex: "ok" }, pathOnly: true, expectedDefault: "codex" },
|
|
239
|
+
{ name: "dual", providers: { claude: "ok", codex: "ok" }, expectedDefault: "claude" },
|
|
240
|
+
{ name: "explicit-codex", providers: { claude: "ok", codex: "ok" }, defaultProvider: "codex", expectedDefault: "codex" },
|
|
241
|
+
{ name: "neither", providers: {}, expectedDefault: null },
|
|
242
|
+
{ name: "missing-executable", providers: { claude: "missing" }, expectedDefault: null },
|
|
243
|
+
{ name: "incompatible", providers: { claude: "incompatible" }, expectedDefault: null },
|
|
244
|
+
{ name: "unauthenticated", providers: { codex: "unauthenticated" }, expectedDefault: "codex", expectedAuth: "unauthenticated" },
|
|
245
|
+
{ name: "disappearing", providers: { claude: "ok" }, expectedDefault: "claude", disappear: "claude" },
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
const setupApi = require("./setup");
|
|
249
|
+
const resumed = setupApi.upgradeSetupState({
|
|
250
|
+
completedSteps: ["prerequisites", "telegram"],
|
|
251
|
+
data: { claudePath: "/legacy/claude" },
|
|
252
|
+
});
|
|
253
|
+
assert.ok(!resumed.completedSteps.includes("prerequisites"), "legacy setup state must revisit provider discovery");
|
|
254
|
+
assert.ok(resumed.completedSteps.includes("telegram"), "unrelated completed setup steps are preserved");
|
|
255
|
+
|
|
256
|
+
for (const scenario of scenarios) {
|
|
257
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-boot-${scenario.name}-`));
|
|
258
|
+
const home = path.join(temp, "home");
|
|
259
|
+
const configDir = path.join(temp, "config");
|
|
260
|
+
const workspace = path.join(temp, "workspace");
|
|
261
|
+
const binDir = path.join(temp, "bin");
|
|
262
|
+
const commandLog = path.join(temp, "provider-commands.jsonl");
|
|
263
|
+
fs.mkdirSync(home);
|
|
264
|
+
fs.mkdirSync(configDir);
|
|
265
|
+
fs.mkdirSync(workspace);
|
|
266
|
+
fs.mkdirSync(binDir);
|
|
267
|
+
|
|
268
|
+
const envLines = [
|
|
269
|
+
"CHANNELS=",
|
|
270
|
+
"TELEGRAM_BOT_TOKEN=fixture-token",
|
|
271
|
+
"TELEGRAM_CHAT_ID=fixture-chat",
|
|
272
|
+
`WORKSPACE=${workspace}`,
|
|
273
|
+
];
|
|
274
|
+
for (const providerId of ["claude", "codex"]) {
|
|
275
|
+
const mode = scenario.providers[providerId];
|
|
276
|
+
if (!mode) continue;
|
|
277
|
+
const envKey = providerId === "claude" ? "CLAUDE_PATH" : "CODEX_PATH";
|
|
278
|
+
if (mode === "missing") {
|
|
279
|
+
envLines.push(`${envKey}=${path.join(binDir, `${providerId}-missing`)}`);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const executable = path.join(binDir, providerId);
|
|
283
|
+
writeProviderCli(executable, providerId, mode, commandLog);
|
|
284
|
+
if (!scenario.pathOnly) envLines.push(`${envKey}=${executable}`);
|
|
285
|
+
}
|
|
286
|
+
if (scenario.defaultProvider) envLines.push(`DEFAULT_PROVIDER=${scenario.defaultProvider}`);
|
|
287
|
+
fs.writeFileSync(path.join(configDir, ".env"), `${envLines.join("\n")}\n`);
|
|
288
|
+
|
|
289
|
+
const child = spawnSync(process.execPath, [__filename, "--probe"], {
|
|
290
|
+
cwd: root,
|
|
291
|
+
env: {
|
|
292
|
+
HOME: home,
|
|
293
|
+
PATH: [binDir, path.dirname(process.execPath), "/usr/bin", "/bin"].join(path.delimiter),
|
|
294
|
+
OPEN_CLAUDIA_CONFIG_DIR: configDir,
|
|
295
|
+
OPEN_CLAUDIA_TEST: "1",
|
|
296
|
+
WEB_PORT: "0",
|
|
297
|
+
DISAPPEAR_PROVIDER: scenario.disappear || "",
|
|
298
|
+
},
|
|
299
|
+
encoding: "utf8",
|
|
300
|
+
timeout: 30000,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
assert.ifError(child.error);
|
|
305
|
+
assert.strictEqual(child.status, 0, child.stderr || child.stdout);
|
|
306
|
+
const marker = child.stdout.split(/\r?\n/).find((line) => line.startsWith("BOOT_MATRIX:"));
|
|
307
|
+
assert.ok(marker, `${scenario.name}: missing boot-matrix output`);
|
|
308
|
+
const output = JSON.parse(marker.slice("BOOT_MATRIX:".length));
|
|
309
|
+
assert.strictEqual(output.selection.providerId, scenario.expectedDefault, `${scenario.name}: deterministic default`);
|
|
310
|
+
assert.strictEqual(output.stateProvider, scenario.expectedDefault, `${scenario.name}: fresh state adopts the default`);
|
|
311
|
+
assert.strictEqual(output.setupStatus.defaultProvider.providerId, scenario.expectedDefault, `${scenario.name}: terminal setup default`);
|
|
312
|
+
assert.strictEqual(output.webStatus.defaultProvider.providerId, scenario.expectedDefault, `${scenario.name}: web default`);
|
|
313
|
+
assert.strictEqual(output.healthStatus.providerSummary.defaultProvider.providerId, scenario.expectedDefault, `${scenario.name}: health default`);
|
|
314
|
+
|
|
315
|
+
for (const providerId of ["claude", "codex"]) {
|
|
316
|
+
const expectedMode = scenario.providers[providerId];
|
|
317
|
+
const setupProvider = output.setupStatus.providers.find((entry) => entry.id === providerId);
|
|
318
|
+
const webProvider = output.webStatus.providers.find((entry) => entry.id === providerId);
|
|
319
|
+
assert.ok(setupProvider && webProvider, `${scenario.name}: ${providerId} must be enumerated`);
|
|
320
|
+
assert.strictEqual(webProvider.availability.state, setupProvider.availability.state);
|
|
321
|
+
if (expectedMode === "missing") assert.strictEqual(setupProvider.availability.state, "missing");
|
|
322
|
+
else if (!expectedMode) assert.strictEqual(setupProvider.availability.state, "unconfigured");
|
|
323
|
+
else assert.strictEqual(setupProvider.availability.state, "available");
|
|
324
|
+
if (expectedMode === "incompatible") assert.strictEqual(setupProvider.compatibility.state, "incompatible");
|
|
325
|
+
if (providerId === scenario.expectedDefault && scenario.expectedAuth) {
|
|
326
|
+
assert.strictEqual(setupProvider.auth.state, scenario.expectedAuth);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const doctorLabels = output.doctorChecks.map((check) => check.label);
|
|
331
|
+
assert.ok(doctorLabels.includes("Claude Code availability"), `${scenario.name}: doctor enumerates Claude`);
|
|
332
|
+
assert.ok(doctorLabels.includes("OpenAI Codex availability"), `${scenario.name}: doctor enumerates Codex`);
|
|
333
|
+
assert.ok(doctorLabels.includes("Default provider"), `${scenario.name}: doctor reports the default choice`);
|
|
334
|
+
|
|
335
|
+
if (scenario.disappear) {
|
|
336
|
+
assert.strictEqual(output.afterDisappear.status.availability.state, "missing");
|
|
337
|
+
assert.strictEqual(output.afterDisappear.selection.providerId, null);
|
|
338
|
+
assert.strictEqual(output.afterDisappear.runError, "PROVIDER_UNAVAILABLE");
|
|
339
|
+
}
|
|
340
|
+
if (!scenario.expectedDefault) {
|
|
341
|
+
assert.strictEqual(output.noProviderTurn.admissionCode, "NO_AVAILABLE_PROVIDER");
|
|
342
|
+
assert.strictEqual(output.noProviderTurn.delivered.error.code, "NO_AVAILABLE_PROVIDER");
|
|
343
|
+
assert.strictEqual(output.noProviderTurn.sent.length, 1, `${scenario.name}: no-provider error is user-visible`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const commands = readCommandLog(commandLog);
|
|
347
|
+
assert.ok(commands.every(isLocalStatusProbe), `${scenario.name}: health/setup invoked a model prompt: ${JSON.stringify(commands)}`);
|
|
348
|
+
} finally {
|
|
349
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const webTemp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-web-boot-"));
|
|
354
|
+
try {
|
|
355
|
+
const configDir = path.join(webTemp, "new-config");
|
|
356
|
+
const home = path.join(webTemp, "home");
|
|
357
|
+
const binDir = path.join(webTemp, "bin");
|
|
358
|
+
const workspace = path.join(webTemp, "workspace");
|
|
359
|
+
const commandLog = path.join(webTemp, "provider-commands.jsonl");
|
|
360
|
+
fs.mkdirSync(home);
|
|
361
|
+
fs.mkdirSync(binDir);
|
|
362
|
+
const codexPath = path.join(binDir, "codex");
|
|
363
|
+
writeProviderCli(codexPath, "codex", "ok", commandLog);
|
|
364
|
+
const webChild = spawnSync(process.execPath, [__filename, "--web-http-probe"], {
|
|
365
|
+
cwd: root,
|
|
366
|
+
env: {
|
|
367
|
+
HOME: home,
|
|
368
|
+
PATH: [binDir, path.dirname(process.execPath), "/usr/bin", "/bin"].join(path.delimiter),
|
|
369
|
+
OPEN_CLAUDIA_CONFIG_DIR: configDir,
|
|
370
|
+
OPEN_CLAUDIA_TEST: "1",
|
|
371
|
+
WEB_PORT: "0",
|
|
372
|
+
WEB_PASSWORD: "FixturePass1!",
|
|
373
|
+
WEB_FIXTURE_CODEX: codexPath,
|
|
374
|
+
WEB_FIXTURE_WORKSPACE: workspace,
|
|
375
|
+
},
|
|
376
|
+
encoding: "utf8",
|
|
377
|
+
timeout: 30000,
|
|
378
|
+
});
|
|
379
|
+
assert.ifError(webChild.error);
|
|
380
|
+
assert.strictEqual(webChild.status, 0, webChild.stderr || webChild.stdout);
|
|
381
|
+
const marker = webChild.stdout.split(/\r?\n/).find((line) => line.startsWith("WEB_HTTP:"));
|
|
382
|
+
assert.ok(marker, "fresh web startup must complete HTTP setup probe");
|
|
383
|
+
const output = JSON.parse(marker.slice("WEB_HTTP:".length));
|
|
384
|
+
assert.strictEqual(output.setup.ok, true);
|
|
385
|
+
assert.strictEqual(output.status.defaultProvider.providerId, "codex", "web setup refreshes cached default status");
|
|
386
|
+
assert.strictEqual(output.status.providers.find((provider) => provider.id === "codex").availability.state, "available");
|
|
387
|
+
assert.strictEqual(output.configUpdate.ok, true, "web config POST is reachable");
|
|
388
|
+
assert.strictEqual(output.config.UTILITY_PROVIDER, "codex");
|
|
389
|
+
assert.strictEqual(output.config.DREAM_PROVIDER, "codex");
|
|
390
|
+
assert.strictEqual(output.soulUpdate.ok, true, "web soul POST is reachable");
|
|
391
|
+
assert.match(output.soul.content, /Provider-neutral fixture soul/);
|
|
392
|
+
assert.strictEqual(output.envMode, 0o600, "web config is private");
|
|
393
|
+
assert.ok(readCommandLog(commandLog).every(isLocalStatusProbe), "web setup/status must not invoke a model prompt");
|
|
394
|
+
} finally {
|
|
395
|
+
fs.rmSync(webTemp, { recursive: true, force: true });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
console.log("provider boot matrix OK");
|
|
399
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
function statusProvider(id, overrides = {}) {
|
|
12
|
+
const { CAPABILITY_KEYS } = require("./core/providers/contract");
|
|
13
|
+
const capabilities = Object.fromEntries(CAPABILITY_KEYS.map((key) => [key, {
|
|
14
|
+
support: "unsupported",
|
|
15
|
+
reason: "fixture capability",
|
|
16
|
+
...(key === "effort" ? { values: [] } : {}),
|
|
17
|
+
}]));
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
label: id === "claude" ? "Claude Code" : "OpenAI Codex",
|
|
21
|
+
isAvailable: () => true,
|
|
22
|
+
executable: () => `/fixture/${id}`,
|
|
23
|
+
defaultModel: () => null,
|
|
24
|
+
tierModel: (tier) => `${id}-${tier}`,
|
|
25
|
+
modelChoices: () => [],
|
|
26
|
+
compatibilityStatus: () => overrides.compatibility || { state: "compatible", version: "1.0.0", reason: null },
|
|
27
|
+
capabilities,
|
|
28
|
+
buildMainInvocation: () => ({ binary: `/fixture/${id}`, args: [], env: {}, stdin: null, parserState: {} }),
|
|
29
|
+
buildUtilityInvocation: () => ({ binary: `/fixture/${id}`, args: [], env: {}, stdin: null, parserState: {} }),
|
|
30
|
+
createEventDecoder: () => ({ push: () => [], end: () => [] }),
|
|
31
|
+
normalizeEvent: () => [],
|
|
32
|
+
authStatus: () => overrides.auth || { state: "authenticated", reason: null },
|
|
33
|
+
authHelp: () => "fixture auth help",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function childProbe() {
|
|
38
|
+
const configDirApi = require("./core/config-dir");
|
|
39
|
+
const config = require("./core/config");
|
|
40
|
+
|
|
41
|
+
// Setup and status helpers must be importable without starting setup,
|
|
42
|
+
// writing state, probing a provider, or exiting the process.
|
|
43
|
+
require("./setup");
|
|
44
|
+
require("./health");
|
|
45
|
+
require("./core/doctor");
|
|
46
|
+
|
|
47
|
+
const result = {
|
|
48
|
+
configDir: config.CONFIG_DIR,
|
|
49
|
+
resolvedConfigDir: configDirApi.resolveConfigDir(process.env),
|
|
50
|
+
workspace: config.WORKSPACE,
|
|
51
|
+
claudeConfigured: config.configuredProviderPath("claude"),
|
|
52
|
+
codexConfigured: config.configuredProviderPath("codex"),
|
|
53
|
+
claudeAvailability: config.providerExecutableStatus("claude"),
|
|
54
|
+
codexAvailability: config.providerExecutableStatus("codex"),
|
|
55
|
+
};
|
|
56
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (process.argv[2] === "--probe") {
|
|
60
|
+
childProbe();
|
|
61
|
+
} else {
|
|
62
|
+
const { resolveConfigDir } = require("./core/config-dir");
|
|
63
|
+
assert.strictEqual(
|
|
64
|
+
resolveConfigDir({ HOME: "/fixture/home", OPEN_CLAUDIA_CONFIG_DIR: "/fixture/config" }),
|
|
65
|
+
path.resolve("/fixture/config"),
|
|
66
|
+
);
|
|
67
|
+
assert.strictEqual(
|
|
68
|
+
resolveConfigDir({ HOME: "/fixture/home" }),
|
|
69
|
+
path.resolve("/fixture/home/.open-claudia"),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const cases = [
|
|
73
|
+
{ name: "claude-only", claude: "present", codex: null, expected: ["available", "unconfigured"] },
|
|
74
|
+
{ name: "codex-only", claude: null, codex: "present", expected: ["unconfigured", "available"] },
|
|
75
|
+
{ name: "both", claude: "present", codex: "present", expected: ["available", "available"] },
|
|
76
|
+
{ name: "neither", claude: null, codex: null, expected: ["unconfigured", "unconfigured"] },
|
|
77
|
+
{ name: "missing-claude", claude: "missing", codex: null, expected: ["missing", "unconfigured"] },
|
|
78
|
+
{ name: "missing-codex", claude: null, codex: "missing", expected: ["unconfigured", "missing"] },
|
|
79
|
+
{ name: "path-only-claude", claude: "path-only", codex: null, expected: ["available", "unconfigured"] },
|
|
80
|
+
{ name: "path-only-codex", claude: null, codex: "path-only", expected: ["unconfigured", "available"] },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
for (const testCase of cases) {
|
|
84
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-bootstrap-${testCase.name}-`));
|
|
85
|
+
const home = path.join(root, "home");
|
|
86
|
+
const configDir = path.join(root, "config");
|
|
87
|
+
const workspace = path.join(root, "workspace");
|
|
88
|
+
const binDir = path.join(root, "bin");
|
|
89
|
+
fs.mkdirSync(home);
|
|
90
|
+
fs.mkdirSync(binDir);
|
|
91
|
+
|
|
92
|
+
const configuredPath = (providerName, disposition) => {
|
|
93
|
+
if (!disposition) return undefined;
|
|
94
|
+
if (disposition === "missing") return path.join(binDir, `${providerName}-missing`);
|
|
95
|
+
const target = path.join(binDir, providerName);
|
|
96
|
+
fs.symlinkSync(process.execPath, target);
|
|
97
|
+
return disposition === "path-only" ? undefined : target;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const env = {
|
|
101
|
+
HOME: home,
|
|
102
|
+
PATH: binDir,
|
|
103
|
+
OPEN_CLAUDIA_CONFIG_DIR: configDir,
|
|
104
|
+
WORKSPACE: workspace,
|
|
105
|
+
CHANNELS: "",
|
|
106
|
+
OPEN_CLAUDIA_TEST: "1",
|
|
107
|
+
CLAUDE_PATH: configuredPath("claude", testCase.claude),
|
|
108
|
+
CODEX_PATH: configuredPath("codex", testCase.codex),
|
|
109
|
+
};
|
|
110
|
+
for (const key of Object.keys(env)) {
|
|
111
|
+
if (env[key] === undefined) delete env[key];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const child = spawnSync(process.execPath, [__filename, "--probe"], {
|
|
116
|
+
cwd: __dirname,
|
|
117
|
+
env,
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
timeout: 10000,
|
|
120
|
+
});
|
|
121
|
+
assert.ifError(child.error);
|
|
122
|
+
assert.strictEqual(child.status, 0, child.stderr || `${testCase.name} import probe failed`);
|
|
123
|
+
const output = JSON.parse(child.stdout.trim());
|
|
124
|
+
assert.strictEqual(output.configDir, path.resolve(configDir));
|
|
125
|
+
assert.strictEqual(output.resolvedConfigDir, path.resolve(configDir));
|
|
126
|
+
assert.strictEqual(output.workspace, workspace);
|
|
127
|
+
assert.deepStrictEqual(
|
|
128
|
+
[output.claudeAvailability.state, output.codexAvailability.state],
|
|
129
|
+
testCase.expected,
|
|
130
|
+
);
|
|
131
|
+
assert.ok(!fs.existsSync(configDir), `${testCase.name} imports must not create the config directory`);
|
|
132
|
+
assert.ok(!fs.existsSync(workspace), `${testCase.name} imports must not create the workspace`);
|
|
133
|
+
} finally {
|
|
134
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Compatibility and auth are provider-owned typed states. They are kept
|
|
139
|
+
// separate so setup/status can explain an old CLI versus a logged-out CLI.
|
|
140
|
+
const { ProviderRegistry } = require("./core/providers");
|
|
141
|
+
const oldCli = new ProviderRegistry();
|
|
142
|
+
oldCli.registerProvider(statusProvider("claude", {
|
|
143
|
+
compatibility: { state: "incompatible", version: "0.0.1", reason: "too old" },
|
|
144
|
+
}));
|
|
145
|
+
assert.strictEqual(oldCli.providerStatus("claude").compatibility.state, "incompatible");
|
|
146
|
+
|
|
147
|
+
const loggedOut = new ProviderRegistry();
|
|
148
|
+
loggedOut.registerProvider(statusProvider("codex", {
|
|
149
|
+
auth: { state: "unauthenticated", reason: "login required" },
|
|
150
|
+
}));
|
|
151
|
+
assert.strictEqual(loggedOut.providerStatus("codex").auth.state, "unauthenticated");
|
|
152
|
+
|
|
153
|
+
const cliSource = fs.readFileSync(path.join(__dirname, "bin", "cli.js"), "utf8");
|
|
154
|
+
assert.match(cliSource, /case "setup":[\s\S]*setup\.js"\)\)\.main\(\)/, "CLI setup command must call the guarded setup entrypoint");
|
|
155
|
+
assert.match(cliSource, /Legacy:[\s\S]*setup\.js"\)\)\.main\(\)/, "legacy auth command must call the guarded setup entrypoint");
|
|
156
|
+
|
|
157
|
+
console.log("provider bootstrap OK");
|
|
158
|
+
}
|