@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 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
- console.error("No channels configured. Set CHANNELS=telegram and TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID, or CHANNELS=kazee with KAZEE_URL/KAZEE_BOT_TOKEN.");
78
- process.exit(1);
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, and chat-central has no DM-by-userId send. Owner DM still
294
- // wakes the bot fine this is just the proactive "I'm back" ping.
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 for the bot to function
12
- const REQUIRED_ENV_KEYS = [
13
- "TELEGRAM_BOT_TOKEN",
14
- "TELEGRAM_CHAT_ID",
15
- "WORKSPACE",
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 = REQUIRED_ENV_KEYS.filter((k) => !env[k]);
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
- REQUIRED_ENV_KEYS,
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.9",
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.9 approved by Sumeet 2026-07-12 ("ok then please fix that"): decoder
36
- // LINE_TOO_LARGE/MALFORMED_JSON downgraded to non-fatal warnings and the JSONL
37
- // line cap raised 1MB→16MB after a base64-image tool result killed rahil's
38
- // live turn on v3.0.8.
39
- assert.strictEqual(pkg.version, "3.0.9", "release version must remain unchanged without explicit approval");
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 { password } = JSON.parse(body);
300
- if (password === getPassword()) {
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": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
390
+ "Set-Cookie": sessionCookie(req),
304
391
  });
305
- return res.end(JSON.stringify({ ok: true, mustChangePassword: !isPasswordChanged() }));
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": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
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 && r.mustChangePassword) showForceChangePassword(pw);
722
- else if (r?.ok) init();
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": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
1310
+ "Set-Cookie": sessionCookie(req),
1252
1311
  });
1253
1312
  return res.end();
1254
1313
  }