@inetafrica/open-claudia 3.0.9 → 3.0.12
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/CHANGELOG.md +7 -0
- package/bot.js +17 -5
- package/channels/kazee/adapter.js +24 -0
- package/core/access.js +13 -0
- package/core/boot-onboarding.js +212 -0
- package/core/router.js +12 -0
- package/health.js +16 -8
- package/package.json +3 -2
- package/test-kazee-channel-health.js +135 -0
- package/test-provider-language.js +6 -5
- package/web.js +99 -40
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.10 — Kazee-only pods are first-class
|
|
4
|
+
|
|
5
|
+
- **/doctor no longer demands Telegram env on a Kazee-only install.** `health.js` required `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` unconditionally — a pod provisioned with `CHANNELS=kazee` failed its env check despite being perfectly configured. Required keys are now derived from `CHANNELS` (default `telegram`, preserving legacy behaviour): telegram installs require the Telegram pair, kazee installs require `KAZEE_BOT_TOKEN`, and `WORKSPACE` stays universal.
|
|
6
|
+
- **Kazee owner bootstrap stores the right identity in the right key.** `/auth` bootstrap on a kazee transport wrote the chat-document id into `TELEGRAM_CHAT_ID` — the wrong id (owner checks compare the Kazee *user* id) in the wrong key (polluting Telegram's authorized list). It now writes `KAZEE_OWNER_USER_ID` with the sender's user id and updates the in-memory config so ownership is live without a restart.
|
|
7
|
+
- **An env-provisioned Kazee owner counts as an owner.** `hasOwner()` only looked at `TELEGRAM_CHAT_ID` and auth.json — on a Kazee-only pod provisioned with `KAZEE_OWNER_USER_ID` (the agent-space wizard path), the first stranger to send `/auth` would have been crowned bootstrap owner. `KAZEE_OWNER_USER_ID` now counts.
|
|
8
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). This is the bot-side half of Kazee pod provisioning via the agent-space wizard (wizard + provisioner land in agent-space itself). Coverage: new hermetic `test-kazee-channel-health.js` (required-key matrix; kazee bootstrap env writes; provisioned-owner /auth guard), wired into `npm test` and shipped in `files`.
|
|
9
|
+
|
|
3
10
|
## v3.0.9 — one fat record no longer kills the turn
|
|
4
11
|
|
|
5
12
|
- **Decoder failures are warnings, not run failures.** The v3 JSONL stream decoder capped provider records at 1MB and surfaced any oversized or malformed line as a terminal `error` event — so a single base64-image tool result (routine in image workflows) killed an otherwise healthy live turn with `Claude Code run failed: Provider JSONL record exceeded 1048576 bytes` (rahil, v3.0.8, 2026-07-12). `LINE_TOO_LARGE` / `MALFORMED_JSON` now normalize to a new non-terminal `warning` event: the record is skipped, the stream continues, and the warning is logged. If the dropped line happened to be the terminal record, the existing `PROVIDER_MISSING_TERMINAL` net still fails the run cleanly.
|
package/bot.js
CHANGED
|
@@ -74,8 +74,14 @@ registry.setHandlers({ onMessage, onAction });
|
|
|
74
74
|
const adapters = registry.bootstrap();
|
|
75
75
|
|
|
76
76
|
if (adapters.length === 0) {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
if (process.env.WEB_UI === "true") {
|
|
78
|
+
// Channel-less pods are valid: the dashboard (and AgentSpace) can attach
|
|
79
|
+
// telegram/kazee later, and addAdapter() boots them without a restart.
|
|
80
|
+
console.log("No channels configured yet — waiting for one to be attached via the dashboard.");
|
|
81
|
+
} else {
|
|
82
|
+
console.error("No channels configured. Set CHANNELS=telegram and TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID, or CHANNELS=kazee with KAZEE_URL/KAZEE_BOT_TOKEN.");
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
// ── Graceful shutdown & error handling ─────────────────────────────
|
|
@@ -290,16 +296,22 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
290
296
|
|
|
291
297
|
// Notify owner that bot is back online via every adapter that knows
|
|
292
298
|
// an owner channel. Kazee skipped: ownerUserId is a user id, not a
|
|
293
|
-
// chat_id
|
|
294
|
-
//
|
|
299
|
+
// chat_id — boot onboarding below opens the DM when outreach is needed.
|
|
300
|
+
// Skipped entirely pre-onboarding: a fresh pod's first words should be
|
|
301
|
+
// the onboarding greeting, not "Back online".
|
|
295
302
|
for (const a of adapters) {
|
|
296
303
|
try {
|
|
297
|
-
if (a.type === "telegram" && CHAT_ID) {
|
|
304
|
+
if (a.type === "telegram" && CHAT_ID && isOnboarded()) {
|
|
298
305
|
await a.send(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}.`);
|
|
299
306
|
}
|
|
300
307
|
} catch (e) {}
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
// Proactive first-boot outreach: greet an env-provisioned owner, guide them
|
|
311
|
+
// through /login, then run the LLM onboarding interview once authed.
|
|
312
|
+
try { await require("./core/boot-onboarding").initBootOnboarding(adapters); }
|
|
313
|
+
catch (e) { console.error("boot onboarding failed:", e.message); }
|
|
314
|
+
|
|
303
315
|
// Surface jobs the provider migration disabled. Announce once per job:
|
|
304
316
|
// marked only after a successful send, so a down adapter retries next boot.
|
|
305
317
|
try {
|
|
@@ -264,6 +264,30 @@ class KazeeAdapter {
|
|
|
264
264
|
return null;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
// Get-or-create the 1:1 chat between the bot and a user, returning its
|
|
268
|
+
// chatId. chat-central's createChat returns the existing DM when one
|
|
269
|
+
// already exists, so this is idempotent. Requires chats.create on the bot
|
|
270
|
+
// token — callers must treat null as "outreach unavailable", not fatal.
|
|
271
|
+
async openDirectChat(userId) {
|
|
272
|
+
if (!this.botUserId || !userId) return null;
|
|
273
|
+
try {
|
|
274
|
+
const res = await this._request("POST", "/chat", {
|
|
275
|
+
participants: { members: [String(this.botUserId), String(userId)] },
|
|
276
|
+
chatName: "Direct Message",
|
|
277
|
+
chatType: "chat",
|
|
278
|
+
isGroupChat: false,
|
|
279
|
+
});
|
|
280
|
+
if (res && res.success === false) {
|
|
281
|
+
console.error("Kazee openDirectChat rejected:", res.error || "unknown error");
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
return res && res.chatId ? String(res.chatId) : null;
|
|
285
|
+
} catch (e) {
|
|
286
|
+
console.error("Kazee openDirectChat error:", e.message);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
267
291
|
async edit(channelId, messageId, text, opts = {}) {
|
|
268
292
|
const buttons = this._normalizeKeyboard(opts.keyboard);
|
|
269
293
|
const body = { content: normalize(text) };
|
package/core/access.js
CHANGED
|
@@ -169,6 +169,10 @@ function listAuthorizedRaw() {
|
|
|
169
169
|
|
|
170
170
|
function hasOwner() {
|
|
171
171
|
if (CHAT_ID) return true;
|
|
172
|
+
// A provisioned Kazee owner (env-injected or set via /channel add) counts
|
|
173
|
+
// before their first message — otherwise a stranger's /auth on a
|
|
174
|
+
// Kazee-only pod would seize ownership.
|
|
175
|
+
if (config.KAZEE_OWNER_USER_ID) return true;
|
|
172
176
|
try {
|
|
173
177
|
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
|
174
178
|
return Array.isArray(auth.authorized) && auth.authorized.some((a) => a.isOwner === true);
|
|
@@ -190,6 +194,15 @@ function bootstrapOwner({ chatId, name, username }) {
|
|
|
190
194
|
authorizedAt: new Date().toISOString(),
|
|
191
195
|
});
|
|
192
196
|
saveAuth(auth);
|
|
197
|
+
if (currentTransport() === "kazee") {
|
|
198
|
+
// Kazee owner identity is the user id (what transportOwnerUserId
|
|
199
|
+
// compares), not the chat-document id the envelope carries as chatId —
|
|
200
|
+
// and it must never be written into TELEGRAM_CHAT_ID.
|
|
201
|
+
const ownerUserId = String(currentUserId() || id);
|
|
202
|
+
saveEnvKey("KAZEE_OWNER_USER_ID", ownerUserId);
|
|
203
|
+
config.KAZEE_OWNER_USER_ID = ownerUserId;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
193
206
|
if (!CHAT_IDS.includes(id)) CHAT_IDS.push(id);
|
|
194
207
|
saveEnvKey("TELEGRAM_CHAT_ID", CHAT_IDS.join(","));
|
|
195
208
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Proactive first-boot outreach. A provisioned pod knows its owner from env
|
|
2
|
+
// (TELEGRAM_CHAT_ID / KAZEE_OWNER_USER_ID) but historically sat silent until
|
|
3
|
+
// the owner messaged first. Instead: greet the owner at boot, walk them
|
|
4
|
+
// through connecting a provider (/login), and once one is authenticated run
|
|
5
|
+
// an LLM-led interview that captures the bot's role and the owner's profile
|
|
6
|
+
// into the soul file. Flags persist in CONFIG_DIR so restarts and upgrades
|
|
7
|
+
// never re-ping; no env-provisioned owner means stay quiet and let /auth
|
|
8
|
+
// claim ownership exactly as before.
|
|
9
|
+
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
|
|
15
|
+
const { CONFIG_DIR, WORKSPACE, SOUL_FILE, config, saveEnvKey } = require("./config");
|
|
16
|
+
const { runInChat } = require("./context");
|
|
17
|
+
const { canonicalForChannel } = require("./identity");
|
|
18
|
+
|
|
19
|
+
const FLAGS_FILE = path.join(CONFIG_DIR, "boot-onboarding.json");
|
|
20
|
+
const AUTH_POLL_MS = 30 * 1000;
|
|
21
|
+
|
|
22
|
+
let authWatcher = null;
|
|
23
|
+
|
|
24
|
+
function readFlags() {
|
|
25
|
+
try { return JSON.parse(fs.readFileSync(FLAGS_FILE, "utf8")) || {}; }
|
|
26
|
+
catch (e) { return {}; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeFlags(patch) {
|
|
30
|
+
const flags = { ...readFlags(), ...patch };
|
|
31
|
+
try { fs.writeFileSync(FLAGS_FILE, JSON.stringify(flags, null, 2)); }
|
|
32
|
+
catch (e) { console.error("boot-onboarding: could not persist flags:", e.message); }
|
|
33
|
+
return flags;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isOnboarded() {
|
|
37
|
+
return config.ONBOARDED === "true";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Authoritative auth check via the provider registry (probes CLI auth state,
|
|
41
|
+
// so API-key-provisioned pods count as authenticated — token presence alone
|
|
42
|
+
// would miss them). Registry caches healthy probes; negatives always re-probe.
|
|
43
|
+
function authenticatedProviderId() {
|
|
44
|
+
try {
|
|
45
|
+
const providers = require("./providers");
|
|
46
|
+
for (const provider of providers.listProviders()) {
|
|
47
|
+
try {
|
|
48
|
+
const status = providers.providerStatus(provider.id);
|
|
49
|
+
if (status.availability?.state === "available"
|
|
50
|
+
&& status.compatibility?.state !== "incompatible"
|
|
51
|
+
&& status.auth?.state === "authenticated") return provider.id;
|
|
52
|
+
} catch (e) { /* try the next provider */ }
|
|
53
|
+
}
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error("boot-onboarding: provider status check failed:", e.message);
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function resolveOwnerChat(adapters) {
|
|
61
|
+
for (const a of adapters) {
|
|
62
|
+
if (a.type === "telegram" && a.ownerChatId) {
|
|
63
|
+
return { adapter: a, channelId: String(a.ownerChatId), userId: String(a.ownerChatId) };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const a of adapters) {
|
|
67
|
+
if (a.type === "kazee" && a.ownerUserId && typeof a.openDirectChat === "function") {
|
|
68
|
+
const chatId = await a.openDirectChat(a.ownerUserId);
|
|
69
|
+
if (chatId) return { adapter: a, channelId: String(chatId), userId: String(a.ownerUserId) };
|
|
70
|
+
console.error("boot-onboarding: could not open a Kazee DM with the owner — outreach skipped (bot token may lack chats.create).");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The scope must land in the same state bucket as the owner's own replies:
|
|
77
|
+
// kazee envelopes key canonical identity on the sender's userId, telegram DMs
|
|
78
|
+
// on the chat id — mirror that here or the interview forks into a bucket the
|
|
79
|
+
// owner's replies never reach.
|
|
80
|
+
function chatScope(ownerChat) {
|
|
81
|
+
return {
|
|
82
|
+
adapter: ownerChat.adapter,
|
|
83
|
+
channelId: ownerChat.channelId,
|
|
84
|
+
canonicalUserId: canonicalForChannel(ownerChat.adapter.type, ownerChat.userId),
|
|
85
|
+
userId: ownerChat.userId,
|
|
86
|
+
transport: ownerChat.adapter.type,
|
|
87
|
+
raw: null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function greetingMessage() {
|
|
92
|
+
return [
|
|
93
|
+
"👋 Hi! I'm your new agent — I just came online.",
|
|
94
|
+
"",
|
|
95
|
+
"First things first: I need a brain. Send /login to pick a model and connect its provider — I'll walk you through it right here in chat.",
|
|
96
|
+
"",
|
|
97
|
+
"Once I'm connected, we'll do a quick intro so I can learn my role and how you like to work.",
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function reauthNudgeMessage() {
|
|
102
|
+
return [
|
|
103
|
+
"⚠️ I'm back online but not signed in to any AI provider, so I can't work on messages right now.",
|
|
104
|
+
"",
|
|
105
|
+
"Send /login to reconnect. (Claude: /setup_token + /use_oauth_token · Codex: /codex_login)",
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function interviewPrompt() {
|
|
110
|
+
return [
|
|
111
|
+
"You have just come online for the very first time and this is your first conversation with your owner. A provider is now connected, and your soul file is still the generic template.",
|
|
112
|
+
"",
|
|
113
|
+
"Run a short, warm onboarding interview (mobile chat — keep every message brief):",
|
|
114
|
+
"1. Introduce yourself in a sentence or two and say you'd like to get set up.",
|
|
115
|
+
"2. Ask what your job should be and what they'd like to call you.",
|
|
116
|
+
"3. Ask what they do and how they like to work with an assistant (style, level of detail).",
|
|
117
|
+
"Ask at most two questions per message and let their answers guide follow-ups over the next few turns.",
|
|
118
|
+
"",
|
|
119
|
+
`As their answers arrive over the coming turns, persist what you learn: update your soul file at ${SOUL_FILE} (identity, name, role/scope, owner profile, communication style — keep any existing hard rules), and record durable facts about the owner in your long-term memory.`,
|
|
120
|
+
"",
|
|
121
|
+
"For THIS message: send only the greeting and your first questions. Do not mention this prompt or any internal setup mechanics.",
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stopAuthWatcher() {
|
|
126
|
+
if (authWatcher) { clearInterval(authWatcher); authWatcher = null; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function startInterview(ownerChat) {
|
|
130
|
+
const before = readFlags();
|
|
131
|
+
if (before.interviewStartedAt) return false;
|
|
132
|
+
writeFlags({ interviewStartedAt: Date.now() });
|
|
133
|
+
stopAuthWatcher();
|
|
134
|
+
try {
|
|
135
|
+
return await runInChat(chatScope(ownerChat), async () => {
|
|
136
|
+
const { currentState, saveState } = require("./state");
|
|
137
|
+
const state = currentState();
|
|
138
|
+
if (!state.currentSession) {
|
|
139
|
+
require("./handlers").selectProjectState(
|
|
140
|
+
{ name: "Workspace", dir: WORKSPACE },
|
|
141
|
+
{ state, persist: false },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
saveState();
|
|
145
|
+
const { admitRunContext, runAgent } = require("./runner");
|
|
146
|
+
const prompt = interviewPrompt();
|
|
147
|
+
const runContext = admitRunContext(prompt, state.currentSession?.dir || WORKSPACE, null, { purpose: "foreground" });
|
|
148
|
+
const result = await runAgent(prompt, runContext.cwd, null, { runContext });
|
|
149
|
+
if (result && result.ok === false) throw new Error(result.error?.message || "interview run did not complete");
|
|
150
|
+
// Marked only after the opening turn actually reached the owner, so a
|
|
151
|
+
// failed dispatch retries next boot instead of half-onboarding the bot.
|
|
152
|
+
saveEnvKey("ONBOARDED", "true");
|
|
153
|
+
config.ONBOARDED = "true";
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error("boot-onboarding: interview run failed, will retry next boot:", e.message);
|
|
158
|
+
writeFlags({ interviewStartedAt: null });
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function armAuthWatcher(ownerChat) {
|
|
164
|
+
stopAuthWatcher();
|
|
165
|
+
authWatcher = setInterval(() => {
|
|
166
|
+
if (isOnboarded() || readFlags().interviewStartedAt) return stopAuthWatcher();
|
|
167
|
+
if (!authenticatedProviderId()) return;
|
|
168
|
+
startInterview(ownerChat).catch((e) => console.error("boot-onboarding: interview dispatch failed:", e.message));
|
|
169
|
+
}, AUTH_POLL_MS);
|
|
170
|
+
if (typeof authWatcher.unref === "function") authWatcher.unref();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function initBootOnboarding(adapters) {
|
|
174
|
+
const ownerChat = await resolveOwnerChat(adapters);
|
|
175
|
+
if (!ownerChat) return;
|
|
176
|
+
|
|
177
|
+
const flags = readFlags();
|
|
178
|
+
const authedProvider = authenticatedProviderId();
|
|
179
|
+
|
|
180
|
+
if (isOnboarded()) {
|
|
181
|
+
if (authedProvider) {
|
|
182
|
+
// Healthy boot: re-arm the one-shot nudge for a future auth loss.
|
|
183
|
+
if (flags.unauthNudgedAt) writeFlags({ unauthNudgedAt: null });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!flags.unauthNudgedAt) {
|
|
187
|
+
const sent = await runInChat(chatScope(ownerChat), () => require("./io").send(reauthNudgeMessage()));
|
|
188
|
+
if (sent?.ok) writeFlags({ unauthNudgedAt: Date.now() });
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (authedProvider) {
|
|
194
|
+
await startInterview(ownerChat);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!flags.greetedAt) {
|
|
199
|
+
const sent = await runInChat(chatScope(ownerChat), () => require("./io").send(greetingMessage()));
|
|
200
|
+
if (sent?.ok) writeFlags({ greetedAt: Date.now() });
|
|
201
|
+
}
|
|
202
|
+
armAuthWatcher(ownerChat);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
initBootOnboarding,
|
|
207
|
+
startInterview,
|
|
208
|
+
resolveOwnerChat,
|
|
209
|
+
readFlags,
|
|
210
|
+
FLAGS_FILE,
|
|
211
|
+
stopAuthWatcher,
|
|
212
|
+
};
|
package/core/router.js
CHANGED
|
@@ -37,6 +37,18 @@ async function deliverRouterError(error) {
|
|
|
37
37
|
const code = error?.code || "ROUTER_ERROR";
|
|
38
38
|
if (!USER_VISIBLE_PROVIDER_ERRORS.has(code)) return null;
|
|
39
39
|
const message = error?.message || "No compatible provider is available.";
|
|
40
|
+
if (code === "PROVIDER_UNAUTHENTICATED") {
|
|
41
|
+
const providerId = error?.details?.providerId;
|
|
42
|
+
await send([
|
|
43
|
+
"I'm not signed in to an AI provider yet, so I can't work on that message.",
|
|
44
|
+
"",
|
|
45
|
+
"Send /login to pick a model and connect — I'll walk you through it right here in chat.",
|
|
46
|
+
providerId === "codex"
|
|
47
|
+
? "Shortcuts: /codex_login (device auth) or /codex_setup_token (paste an OpenAI API key securely)."
|
|
48
|
+
: "Shortcuts: /setup_token then /use_oauth_token (long-lived Claude token), or /login for the guided flow.",
|
|
49
|
+
].join("\n"));
|
|
50
|
+
return { ok: false, status: "failed", error: { code, message } };
|
|
51
|
+
}
|
|
40
52
|
await send(`${message}\n\nOpen /backend to review providers, or run /doctor for local compatibility and authentication checks.`);
|
|
41
53
|
return { ok: false, status: "failed", error: { code, message } };
|
|
42
54
|
}
|
package/health.js
CHANGED
|
@@ -8,12 +8,20 @@ const { inspectProviderConfiguration } = require("./core/provider-status");
|
|
|
8
8
|
const ENV_FILE = path.join(CONFIG_DIR, ".env");
|
|
9
9
|
const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
|
|
10
10
|
|
|
11
|
-
// Required env keys
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
11
|
+
// Required env keys depend on which channels the install runs: WORKSPACE is
|
|
12
|
+
// universal, and each channel listed in CHANNELS (default telegram) adds its
|
|
13
|
+
// own keys — a Kazee-only pod must not fail /doctor over missing Telegram env.
|
|
14
|
+
function requiredEnvKeys(env = {}) {
|
|
15
|
+
const channels = String(env.CHANNELS || "telegram")
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((entry) => entry.trim().split(":")[0])
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
const keys = [];
|
|
20
|
+
if (channels.includes("telegram")) keys.push("TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID");
|
|
21
|
+
if (channels.includes("kazee")) keys.push("KAZEE_BOT_TOKEN");
|
|
22
|
+
keys.push("WORKSPACE");
|
|
23
|
+
return keys;
|
|
24
|
+
}
|
|
17
25
|
|
|
18
26
|
// Optional but recommended
|
|
19
27
|
const OPTIONAL_ENV_KEYS = [
|
|
@@ -255,7 +263,7 @@ async function runHealthChecks(options = {}) {
|
|
|
255
263
|
return results;
|
|
256
264
|
}
|
|
257
265
|
|
|
258
|
-
const missingKeys =
|
|
266
|
+
const missingKeys = requiredEnvKeys(env).filter((k) => !env[k]);
|
|
259
267
|
if (missingKeys.length > 0) {
|
|
260
268
|
results.checks.env = { ok: false, reason: "missing_keys", message: `Missing required config: ${missingKeys.join(", ")}`, missingKeys };
|
|
261
269
|
results.ok = false;
|
|
@@ -471,6 +479,6 @@ module.exports = {
|
|
|
471
479
|
ensureSetup,
|
|
472
480
|
formatSetupResults,
|
|
473
481
|
ensureAgentBrowser,
|
|
474
|
-
|
|
482
|
+
requiredEnvKeys,
|
|
475
483
|
OPTIONAL_ENV_KEYS,
|
|
476
484
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.12",
|
|
4
4
|
"description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
|
-
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js",
|
|
12
|
+
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js",
|
|
13
13
|
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"test-enforcer.js",
|
|
66
66
|
"test-identity-prune.js",
|
|
67
67
|
"test-kazee-message-id.js",
|
|
68
|
+
"test-kazee-channel-health.js",
|
|
68
69
|
"test-delivery-contract.js",
|
|
69
70
|
"test-run-lock.js",
|
|
70
71
|
"test-web-sessions.js",
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
if (process.argv[2] === "--owner-probe") {
|
|
12
|
+
(async () => {
|
|
13
|
+
const { runInChat } = require("./core/context");
|
|
14
|
+
const access = require("./core/access");
|
|
15
|
+
const { config } = require("./core/config");
|
|
16
|
+
|
|
17
|
+
if (process.env.PROBE_MODE === "provisioned") {
|
|
18
|
+
// Agent-space injects KAZEE_OWNER_USER_ID at provision time: the owner
|
|
19
|
+
// exists before their first message, so a stranger's /auth must not
|
|
20
|
+
// reach the bootstrap path.
|
|
21
|
+
assert.strictEqual(access.hasOwner(), true, "env-provisioned kazee owner counts as owner");
|
|
22
|
+
console.log("OWNER_PROBE_OK");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
assert.strictEqual(access.hasOwner(), false, "fresh kazee pod starts ownerless");
|
|
27
|
+
await runInChat({
|
|
28
|
+
adapter: { type: "kazee", id: "kazee" },
|
|
29
|
+
channelId: "chat-doc-1",
|
|
30
|
+
canonicalUserId: "kazee:user-77",
|
|
31
|
+
userId: "user-77",
|
|
32
|
+
transport: "kazee",
|
|
33
|
+
}, async () => {
|
|
34
|
+
access.bootstrapOwner({ chatId: "chat-doc-1", name: "Owner", username: "" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const env = fs.readFileSync(path.join(process.env.OPEN_CLAUDIA_CONFIG_DIR, ".env"), "utf8");
|
|
38
|
+
assert.match(env, /^KAZEE_OWNER_USER_ID=user-77$/m, "kazee bootstrap stores the owner USER id");
|
|
39
|
+
assert.ok(!/^TELEGRAM_CHAT_ID=.*(chat-doc-1|user-77)/m.test(env), "kazee ids must never land in TELEGRAM_CHAT_ID");
|
|
40
|
+
assert.strictEqual(config.KAZEE_OWNER_USER_ID, "user-77", "owner is live without a restart");
|
|
41
|
+
assert.strictEqual(access.hasOwner(), true);
|
|
42
|
+
|
|
43
|
+
await runInChat({
|
|
44
|
+
adapter: { type: "kazee", id: "kazee" },
|
|
45
|
+
channelId: "chat-doc-2",
|
|
46
|
+
canonicalUserId: "kazee:user-77",
|
|
47
|
+
userId: "user-77",
|
|
48
|
+
transport: "kazee",
|
|
49
|
+
}, async () => {
|
|
50
|
+
assert.strictEqual(access.isChatOwner("chat-doc-2"), true, "owner matches by user id from any kazee chat");
|
|
51
|
+
assert.strictEqual(access.isChatAuthorized("chat-doc-2"), true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await runInChat({
|
|
55
|
+
adapter: { type: "kazee", id: "kazee" },
|
|
56
|
+
channelId: "chat-doc-3",
|
|
57
|
+
canonicalUserId: "kazee:stranger-1",
|
|
58
|
+
userId: "stranger-1",
|
|
59
|
+
transport: "kazee",
|
|
60
|
+
}, async () => {
|
|
61
|
+
assert.strictEqual(access.isChatOwner("chat-doc-3"), false, "strangers are not owner");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
console.log("OWNER_PROBE_OK");
|
|
65
|
+
})().catch((error) => {
|
|
66
|
+
process.stderr.write(`${error.stack || error.message}\n`);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
const { requiredEnvKeys } = require("./health");
|
|
71
|
+
|
|
72
|
+
assert.deepStrictEqual(
|
|
73
|
+
requiredEnvKeys({}),
|
|
74
|
+
["TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID", "WORKSPACE"],
|
|
75
|
+
"no CHANNELS keeps the legacy telegram requirement",
|
|
76
|
+
);
|
|
77
|
+
assert.deepStrictEqual(
|
|
78
|
+
requiredEnvKeys({ CHANNELS: "kazee" }),
|
|
79
|
+
["KAZEE_BOT_TOKEN", "WORKSPACE"],
|
|
80
|
+
"a kazee-only pod must not require telegram env",
|
|
81
|
+
);
|
|
82
|
+
assert.deepStrictEqual(
|
|
83
|
+
requiredEnvKeys({ CHANNELS: "telegram, kazee:main" }),
|
|
84
|
+
["TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID", "KAZEE_BOT_TOKEN", "WORKSPACE"],
|
|
85
|
+
"dual-channel installs require both, id overrides included",
|
|
86
|
+
);
|
|
87
|
+
assert.deepStrictEqual(requiredEnvKeys({ CHANNELS: "voice" }), ["WORKSPACE"]);
|
|
88
|
+
|
|
89
|
+
const scenarios = [
|
|
90
|
+
{ name: "bootstrap", mode: "bootstrap", envLines: [
|
|
91
|
+
"CHANNELS=kazee",
|
|
92
|
+
"KAZEE_BOT_TOKEN=fixture-kazee-token",
|
|
93
|
+
"KAZEE_BOT_USER_ID=bot-1",
|
|
94
|
+
] },
|
|
95
|
+
{ name: "provisioned", mode: "provisioned", envLines: [
|
|
96
|
+
"CHANNELS=kazee",
|
|
97
|
+
"KAZEE_BOT_TOKEN=fixture-kazee-token",
|
|
98
|
+
"KAZEE_BOT_USER_ID=bot-1",
|
|
99
|
+
"KAZEE_OWNER_USER_ID=owner-1",
|
|
100
|
+
] },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
for (const scenario of scenarios) {
|
|
104
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-kazee-${scenario.name}-`));
|
|
105
|
+
try {
|
|
106
|
+
const configDir = path.join(temp, "config");
|
|
107
|
+
const workspace = path.join(temp, "workspace");
|
|
108
|
+
fs.mkdirSync(configDir);
|
|
109
|
+
fs.mkdirSync(workspace);
|
|
110
|
+
fs.writeFileSync(
|
|
111
|
+
path.join(configDir, ".env"),
|
|
112
|
+
`${scenario.envLines.join("\n")}\nWORKSPACE=${workspace}\n`,
|
|
113
|
+
);
|
|
114
|
+
const child = spawnSync(process.execPath, [__filename, "--owner-probe"], {
|
|
115
|
+
cwd: __dirname,
|
|
116
|
+
env: {
|
|
117
|
+
HOME: path.join(temp, "config"),
|
|
118
|
+
PATH: [path.dirname(process.execPath), "/usr/bin", "/bin"].join(path.delimiter),
|
|
119
|
+
OPEN_CLAUDIA_CONFIG_DIR: configDir,
|
|
120
|
+
OPEN_CLAUDIA_TEST: "1",
|
|
121
|
+
PROBE_MODE: scenario.mode,
|
|
122
|
+
},
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
timeout: 30000,
|
|
125
|
+
});
|
|
126
|
+
assert.ifError(child.error);
|
|
127
|
+
assert.strictEqual(child.status, 0, `${scenario.name}: ${child.stderr || child.stdout}`);
|
|
128
|
+
assert.match(child.stdout, /OWNER_PROBE_OK/, scenario.name);
|
|
129
|
+
} finally {
|
|
130
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log("kazee channel health OK");
|
|
135
|
+
}
|
|
@@ -32,11 +32,12 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
|
|
|
32
32
|
|
|
33
33
|
const pkg = JSON.parse(read("package.json"));
|
|
34
34
|
assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
35
|
-
// 3.0.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// live
|
|
39
|
-
|
|
35
|
+
// 3.0.12 approved by Sumeet 2026-07-13 ("Yes" to the platform unification
|
|
36
|
+
// epic): dashboard auth hardening (no forced password change, login
|
|
37
|
+
// rate-limiting + lockout, constant-time compares, Secure cookie),
|
|
38
|
+
// channel-less boot with the web UI up, and live `channels` in /api/status
|
|
39
|
+
// for AgentSpace channel chips.
|
|
40
|
+
assert.strictEqual(pkg.version, "3.0.12", "release version must remain unchanged without explicit approval");
|
|
40
41
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
41
42
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
42
43
|
}
|
package/web.js
CHANGED
|
@@ -143,6 +143,59 @@ function checkAuth(req) {
|
|
|
143
143
|
return webSessions.valid(token);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// Hash both sides so timingSafeEqual gets equal-length buffers and the
|
|
147
|
+
// comparison leaks neither content nor password length.
|
|
148
|
+
function safeEqual(a, b) {
|
|
149
|
+
const ha = crypto.createHash("sha256").update(String(a)).digest();
|
|
150
|
+
const hb = crypto.createHash("sha256").update(String(b)).digest();
|
|
151
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Login rate limiting: 5 failures → lockout with exponential backoff
|
|
155
|
+
// (30s, 60s, ... capped at 15min). Keyed by client IP (first XFF hop
|
|
156
|
+
// when behind the ingress). In-memory — resets on restart, which is fine.
|
|
157
|
+
const loginFailures = new Map();
|
|
158
|
+
const LOCKOUT_THRESHOLD = 5;
|
|
159
|
+
const LOCKOUT_BASE_MS = 30 * 1000;
|
|
160
|
+
const LOCKOUT_MAX_MS = 15 * 60 * 1000;
|
|
161
|
+
|
|
162
|
+
function clientKey(req) {
|
|
163
|
+
const xff = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
|
|
164
|
+
return xff || req.socket?.remoteAddress || "unknown";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function loginLockedMs(req) {
|
|
168
|
+
const rec = loginFailures.get(clientKey(req));
|
|
169
|
+
if (!rec || !rec.lockedUntil) return 0;
|
|
170
|
+
const left = rec.lockedUntil - Date.now();
|
|
171
|
+
return left > 0 ? left : 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function recordLoginFailure(req) {
|
|
175
|
+
const key = clientKey(req);
|
|
176
|
+
const rec = loginFailures.get(key) || { count: 0, lockouts: 0, lockedUntil: 0 };
|
|
177
|
+
rec.count += 1;
|
|
178
|
+
if (rec.count >= LOCKOUT_THRESHOLD) {
|
|
179
|
+
rec.count = 0;
|
|
180
|
+
const wait = Math.min(LOCKOUT_BASE_MS * 2 ** rec.lockouts, LOCKOUT_MAX_MS);
|
|
181
|
+
rec.lockouts += 1;
|
|
182
|
+
rec.lockedUntil = Date.now() + wait;
|
|
183
|
+
}
|
|
184
|
+
loginFailures.set(key, rec);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function clearLoginFailures(req) {
|
|
188
|
+
loginFailures.delete(clientKey(req));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Secure flag only when the client actually arrived over HTTPS (ingress
|
|
192
|
+
// sets X-Forwarded-Proto) — keeps plain-HTTP port-forward logins working.
|
|
193
|
+
function sessionCookie(req) {
|
|
194
|
+
const proto = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim();
|
|
195
|
+
const secure = proto === "https" ? "; Secure" : "";
|
|
196
|
+
return `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400${secure}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
146
199
|
// ── Config helpers ─────────────────────────────────────────────────
|
|
147
200
|
|
|
148
201
|
function loadEnv() {
|
|
@@ -289,6 +342,32 @@ function telegramGet(token, method) {
|
|
|
289
342
|
});
|
|
290
343
|
}
|
|
291
344
|
|
|
345
|
+
// Live channel list from the in-process adapter registry — the source of
|
|
346
|
+
// truth for what this pod is actually connected to (provisioning-time env
|
|
347
|
+
// goes stale once channels are attached/removed at runtime).
|
|
348
|
+
let tgUsernameCache = null;
|
|
349
|
+
async function liveChannels() {
|
|
350
|
+
let adapters = [];
|
|
351
|
+
try { adapters = require("./core/adapter-registry").getAdapters(); } catch (e) { return []; }
|
|
352
|
+
const out = [];
|
|
353
|
+
for (const a of adapters) {
|
|
354
|
+
const ch = { id: a.id, type: a.type };
|
|
355
|
+
if (a.type === "telegram") {
|
|
356
|
+
const token = loadEnv().TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN;
|
|
357
|
+
if (token) {
|
|
358
|
+
if (!tgUsernameCache) {
|
|
359
|
+
const me = await telegramGet(token, "getMe");
|
|
360
|
+
if (me && me.ok) tgUsernameCache = (me.result && me.result.username) || null;
|
|
361
|
+
}
|
|
362
|
+
if (tgUsernameCache) ch.username = tgUsernameCache;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (a.type === "kazee" && a.botUserId) ch.botUserId = String(a.botUserId);
|
|
366
|
+
out.push(ch);
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
|
|
292
371
|
// ── API routes ─────────────────────────────────────────────────────
|
|
293
372
|
|
|
294
373
|
async function handleAPI(req, res, body, dependencies = {}) {
|
|
@@ -296,14 +375,23 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
296
375
|
|
|
297
376
|
// Login — no auth required
|
|
298
377
|
if (url === "/api/login" && req.method === "POST") {
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
378
|
+
const lockedMs = loginLockedMs(req);
|
|
379
|
+
if (lockedMs > 0) {
|
|
380
|
+
const seconds = Math.ceil(lockedMs / 1000);
|
|
381
|
+
res.writeHead(429, { "Content-Type": "application/json", "Retry-After": String(seconds) });
|
|
382
|
+
return res.end(JSON.stringify({ ok: false, error: `Too many attempts — try again in ${seconds}s` }));
|
|
383
|
+
}
|
|
384
|
+
let password;
|
|
385
|
+
try { ({ password } = JSON.parse(body)); } catch (e) { /* fall through to failure */ }
|
|
386
|
+
if (typeof password === "string" && safeEqual(password, getPassword())) {
|
|
387
|
+
clearLoginFailures(req);
|
|
301
388
|
res.writeHead(200, {
|
|
302
389
|
"Content-Type": "application/json",
|
|
303
|
-
"Set-Cookie":
|
|
390
|
+
"Set-Cookie": sessionCookie(req),
|
|
304
391
|
});
|
|
305
|
-
return res.end(JSON.stringify({ ok: true
|
|
392
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
306
393
|
}
|
|
394
|
+
recordLoginFailure(req);
|
|
307
395
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
308
396
|
return res.end(JSON.stringify({ ok: false, error: "Wrong password" }));
|
|
309
397
|
}
|
|
@@ -347,6 +435,7 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
347
435
|
providers: providerStatus.providers,
|
|
348
436
|
defaultProvider: providerStatus.defaultProvider,
|
|
349
437
|
onboarded: env.ONBOARDED === "true",
|
|
438
|
+
channels: await liveChannels(),
|
|
350
439
|
}));
|
|
351
440
|
}
|
|
352
441
|
|
|
@@ -575,10 +664,10 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
575
664
|
}));
|
|
576
665
|
}
|
|
577
666
|
|
|
578
|
-
// Change password
|
|
667
|
+
// Change password (voluntary — never forced)
|
|
579
668
|
if (url === "/api/password" && req.method === "POST") {
|
|
580
669
|
const { current, newPassword } = JSON.parse(body);
|
|
581
|
-
if (current !== getPassword()) {
|
|
670
|
+
if (typeof current !== "string" || !safeEqual(current, getPassword())) {
|
|
582
671
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
583
672
|
return res.end(JSON.stringify({ error: "Wrong current password" }));
|
|
584
673
|
}
|
|
@@ -593,7 +682,7 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
593
682
|
webSessions.revokeAll();
|
|
594
683
|
res.writeHead(200, {
|
|
595
684
|
"Content-Type": "application/json",
|
|
596
|
-
"Set-Cookie":
|
|
685
|
+
"Set-Cookie": sessionCookie(req),
|
|
597
686
|
});
|
|
598
687
|
return res.end(JSON.stringify({ ok: true }));
|
|
599
688
|
}
|
|
@@ -718,38 +807,8 @@ function showLogin() {
|
|
|
718
807
|
async function doLogin() {
|
|
719
808
|
const pw = $("#pw").value;
|
|
720
809
|
const r = await api("/api/login", { method: "POST", body: { password: pw } });
|
|
721
|
-
if (r?.ok
|
|
722
|
-
else
|
|
723
|
-
else $("#login-msg").innerHTML = '<div class="msg err">Wrong password</div>';
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
function showForceChangePassword(currentPw) {
|
|
727
|
-
$("#app").innerHTML = \`
|
|
728
|
-
<div class="login-page"><div class="login-box">
|
|
729
|
-
<h1>Change Password</h1>
|
|
730
|
-
<p class="subtitle">You must set a secure password before continuing</p>
|
|
731
|
-
<div id="change-msg"></div>
|
|
732
|
-
<div class="card">
|
|
733
|
-
<div class="form-group">
|
|
734
|
-
<input type="password" id="new-pw-1" placeholder="New password">
|
|
735
|
-
</div>
|
|
736
|
-
<div class="form-group">
|
|
737
|
-
<input type="password" id="new-pw-2" placeholder="Confirm password" onkeydown="if(event.key==='Enter')doForceChange()">
|
|
738
|
-
</div>
|
|
739
|
-
<p style="font-size:11px;color:#888;margin:8px 0">Min 12 chars, uppercase, lowercase, number, and symbol</p>
|
|
740
|
-
<button onclick="doForceChange()" style="width:100%">Set Password</button>
|
|
741
|
-
</div>
|
|
742
|
-
</div></div>\`;
|
|
743
|
-
window._forceChangePw = currentPw;
|
|
744
|
-
setTimeout(() => $("#new-pw-1")?.focus(), 100);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
async function doForceChange() {
|
|
748
|
-
const p1 = $("#new-pw-1").value, p2 = $("#new-pw-2").value;
|
|
749
|
-
if (p1 !== p2) { $("#change-msg").innerHTML = '<div class="msg err">Passwords do not match</div>'; return; }
|
|
750
|
-
const r = await api("/api/password", { method: "POST", body: { current: window._forceChangePw, newPassword: p1 } });
|
|
751
|
-
if (r?.ok) { delete window._forceChangePw; init(); }
|
|
752
|
-
else $("#change-msg").innerHTML = '<div class="msg err">' + (r?.error || "Failed") + '</div>';
|
|
810
|
+
if (r?.ok) init();
|
|
811
|
+
else $("#login-msg").innerHTML = '<div class="msg err">' + (r?.error || "Wrong password") + '</div>';
|
|
753
812
|
}
|
|
754
813
|
|
|
755
814
|
function render() {
|
|
@@ -1248,7 +1307,7 @@ function startWebServer(options = {}) {
|
|
|
1248
1307
|
if (consume(token)) {
|
|
1249
1308
|
res.writeHead(302, {
|
|
1250
1309
|
"Location": "/",
|
|
1251
|
-
"Set-Cookie":
|
|
1310
|
+
"Set-Cookie": sessionCookie(req),
|
|
1252
1311
|
});
|
|
1253
1312
|
return res.end();
|
|
1254
1313
|
}
|