@inetafrica/open-claudia 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.14.0
4
+ - **`/channel add kazee` now finishes cleanly — the validation step was probing the wrong path.** The live adapter mounts every REST route under `{root}/api`, but the setup probe hit `{root}/me`. So the correct host (`https://chat.example.com`) always 404'd at validation, while the only URL that *passed* (`…/api`) then made the adapter build `…/api/api` and break. There was no single URL a user could type to succeed. `kazee-probe` now normalises either form to the root and probes the real `{root}/api/me` (new exported `normalizeKazeeRoot`); the wizard and `setup.js` both store the normalised root. Step 1 now asks for the host and states "I add the /api path myself."
5
+ - **The bot's own user id is no longer thrown away.** Validation already fetches it from `/me`, but the wizard never persisted `KAZEE_BOT_USER_ID` or passed it to the adapter — so a wizard-added channel booted without self-echo filtering or slash-command registration (and `loadChannels` warned on every restart). The wizard and `setup.js` now save it and hand it to the adapter.
6
+ - **Owner is captured by listening, not by pasting a Mongo id (Step 3 redesign).** After URL+token validate, the wizard brings Kazee **live immediately** (owner still blank) and asks the operator to send any message from their Kazee account. A new cross-channel hook — `router.tryCaptureOwner` → `channel-wizard.tryCaptureOwner` — intercepts that inbound Kazee message (it lands on a different channel than the operator's, so the operator-keyed `isAwaiting()` gate can't see it), then asks back on the operator's channel *"Is this you — the owner?"* with **Yes / No, keep listening / Skip** buttons. Yes grabs the sender's Kazee user id; owner recognition then flows through the existing env path (`identity.isConfiguredOwnerChannel` reads `KAZEE_OWNER_USER_ID`). Pasting an id or "skip" still works as a fallback; Cancel now tears the half-added adapter back down and rolls the `.env` edits back.
7
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. Purely a UX + correctness fix to the Kazee onboarding wizard — inert for deployments that never run `/channel add kazee`.
8
+
9
+ ## v2.13.1
10
+ - **Guardrail no longer mistakes the owner for an external speaker on `/link`'d or env-unified channels (Phase 3 fix).** v2.13.0's relationship classifier resolved ownership through `people.findByHandle` alone — the handles physically listed on the owner's person record — while the queue/state/run-lock resolve it through `identity.canonicalForChannel`, which also honours explicit `/link`s (`identities.json`) and the configured-owner env path. A channel the owner had unified by `/link` or env-config (e.g. a second Kazee handle) was therefore *owner* to the run-lock (shared queue → "Queued.") but *external* to the guardrail (default-deny → the stranger reply), so the bot could greet its own owner as a limited-access outsider. The classifier now resolves ownership through **canonical identity — the same source of truth the rest of the bot uses**: a channel is the owner's when it resolves to the owner canonical **or** its person record is `isOwner`. New `identity.channelIsOwner()`; `relationship.speakerFor` consults it before default-deny. Every guard consumer (external-mode system-prompt block, `io`/`loopback` egress gates, runner turn-guard, CLI guards) funnels through this one function, so the fix lands everywhere at once.
11
+ - **R1 preserved — a note still can never confer owner.** `channelIsOwner` is driven only by the canonical map, which is written **exclusively by owner/operator actions** (`/link`, `.env`), never by a note; and the owner stays anchored to a real owner record via `ownerCanonical → people.isOwner`. The default-deny posture for genuinely external or unmapped channels is unchanged.
12
+ - **Tests.** `test-relationship.js` gains a regression reproducing the exact production bug — an owner Kazee channel `/link`'d to the owner canonical but **absent from the people record** now resolves to `owner` (unguarded), while an unmapped channel on the same transport stays `external` (guarded), proving the fix does not widen access. Full `npm test` green.
13
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. Purely a correctness fix to owner recognition — inert for deployments that never hit the split-brain.
14
+
3
15
  ## v2.13.0
4
16
  - **The independent relationship guardrail is now LIVE — an owned bot is finally safe to point at external people (Phase 3).** Phases 0–2 unified the bot's identity across channels and taught it WHO it's talking to; this phase adds the missing half: hard limits on what it may do or say when that person is **not** the owner. Everything is a strict **no-op for the owner and for no-context callers** (CLI/cron/tests) — the owner reply/tool/file paths stay **byte-for-byte unchanged** (risk R9/R14), so this is inert on every existing single-owner deployment until an external channel is actually `/auth`'d. Classification lives in `core/relationship.js` and is **cheap, synchronous, and never derived from message text**: the owner is owner **only** via `people.isOwner`; a non-owner whose note claims relationship `owner` is coerced to `external` (**R1 — a note can never confer owner powers**); every unclassified or unmapped authed non-owner defaults to `external` (**default-deny**).
5
17
  - **Owner-authored mandate, set from chat (3.1).** `entity persona <name> --relationship|--role|--style|--knows|--mandate` lets the owner declare, per person, the ONLY things the bot is cleared to do or discuss with them. The verb is **owner-gated** (an external speaker can never edit their own mandate — self-escalation is refused) and each flag replaces its section wholesale.
@@ -5,6 +5,13 @@
5
5
  // enters the wizard their next plain-text message becomes the wizard's
6
6
  // input. A Cancel button (payload "chw:cancel") bails out at any step.
7
7
  //
8
+ // Owner capture is cross-channel: once URL+token validate, we bring the
9
+ // Kazee adapter live and listen for an inbound message ON KAZEE, then ask
10
+ // the operator (back on the channel they ran /channel from) to confirm
11
+ // "is this you?". router.tryCaptureOwner() feeds that inbound message in —
12
+ // it arrives on a different channel than the operator's, so isAwaiting()
13
+ // (keyed to the operator) never sees it via handleText().
14
+ //
8
15
  // validateKazee() is exported separately so setup.js can reuse the same
9
16
  // /me probe without going through the chat-driven flow.
10
17
 
@@ -31,22 +38,45 @@ function endSession(canonicalUserId) {
31
38
 
32
39
  const cancelKeyboard = { inline_keyboard: [[{ text: "Cancel", callback_data: "chw:cancel" }]] };
33
40
 
34
- async function start(canonicalUserId, kind) {
41
+ // Prompts issued while listening for (and confirming) the owner's Kazee
42
+ // message must land on the channel the operator ran /channel from — not the
43
+ // Kazee chat a trigger arrived on. Reach it through the stored origin
44
+ // adapter, bypassing the ambient io.send() scope.
45
+ async function sayToOrigin(session, text, opts) {
46
+ try {
47
+ if (session?.origin?.adapter && session.origin.channelId) {
48
+ return await session.origin.adapter.send(session.origin.channelId, text, opts);
49
+ }
50
+ } catch (e) { console.error("channel-wizard sayToOrigin:", e.message); }
51
+ return send(text, opts); // fallback: ambient scope
52
+ }
53
+
54
+ async function start(canonicalUserId, kind, envelope) {
35
55
  if (kind !== "kazee") {
36
56
  await send(`Channel type "${kind}" not supported yet.`);
37
57
  return;
38
58
  }
39
- sessions.set(canonicalUserId, { kind: "kazee", step: "awaiting-url", data: {} });
59
+ const origin = envelope?.adapter && envelope?.channelId
60
+ ? { adapter: envelope.adapter, channelId: envelope.channelId }
61
+ : null;
62
+ sessions.set(canonicalUserId, { kind: "kazee", step: "awaiting-url", data: {}, origin });
40
63
  await send(
41
- "Add a Kazee channel.\n\nStep 1 of 3: Kazee bot API URL (e.g. https://chat.example.com/api).",
64
+ "Add a Kazee channel.\n\nStep 1 of 3: Kazee host URL (e.g. https://chat.example.com). I add the /api path myself.",
42
65
  { keyboard: cancelKeyboard },
43
66
  );
44
67
  }
45
68
 
46
69
  async function cancel(canonicalUserId, { silent } = {}) {
47
- if (!sessions.has(canonicalUserId)) return;
70
+ const session = sessions.get(canonicalUserId);
71
+ if (!session) return;
48
72
  sessions.delete(canonicalUserId);
49
- if (!silent) await send("Channel setup cancelled.");
73
+ // If we already brought the adapter live for owner-capture, tear it back
74
+ // down and roll the .env edits back so a cancel leaves no half-added channel.
75
+ if (session.data?.live) {
76
+ try { await registry.removeAdapter("kazee"); } catch (e) {}
77
+ revertEnv(session.data.previous);
78
+ }
79
+ if (!silent) await sayToOrigin(session, "Channel setup cancelled.");
50
80
  }
51
81
 
52
82
  async function handleText(envelope) {
@@ -59,7 +89,7 @@ async function handleText(envelope) {
59
89
  await send("That doesn't look like a URL. Try again, or hit Cancel.", { keyboard: cancelKeyboard });
60
90
  return;
61
91
  }
62
- session.data.url = text.replace(/\/$/, "");
92
+ session.data.url = text;
63
93
  session.step = "awaiting-token";
64
94
  await send(
65
95
  "Step 2 of 3: Kazee bot token. I'll delete your message after reading.",
@@ -83,83 +113,177 @@ async function handleText(envelope) {
83
113
  await send(`Validation failed: ${check.error}\n\nRun /channel add kazee to try again.`);
84
114
  return;
85
115
  }
116
+ session.data.url = check.url || session.data.url.replace(/\/+$/, "");
86
117
  session.data.botUserId = check.botUserId || "";
87
- session.step = "awaiting-owner";
118
+
119
+ // Bring the adapter live now so it can receive the owner's message.
120
+ const live = await bringLive(session);
121
+ if (!live.ok) {
122
+ sessions.delete(envelope.canonicalUserId);
123
+ await send(`Could not start the Kazee adapter: ${live.error}\n\nReverted .env. Run /channel add kazee to try again.`);
124
+ return;
125
+ }
126
+ session.step = "awaiting-owner-message";
88
127
  await send(
89
- `Step 3 of 3: Kazee user id that should be treated as the owner (or send "skip" to leave blank).`,
128
+ "Connected to Kazee ✅\n\nStep 3 of 3: open Kazee and send me any message I'll grab your account and confirm it here.\n\n(Or send \"skip\" to leave the owner unset.)",
90
129
  { keyboard: cancelKeyboard },
91
130
  );
92
131
  return;
93
132
  }
94
133
 
95
- if (session.step === "awaiting-owner") {
96
- const ownerUserId = text.toLowerCase() === "skip" ? "" : text;
97
- session.data.ownerUserId = ownerUserId;
98
- session.step = "applying";
99
- await applySession(envelope.canonicalUserId, session);
134
+ // Text fallback for step 3 the operator can still type a user id or
135
+ // "skip" on their own channel instead of sending a Kazee message.
136
+ if (session.step === "awaiting-owner-message" || session.step === "confirming-owner") {
137
+ if (text.toLowerCase() === "skip") {
138
+ await finalizeOwner(envelope.canonicalUserId, session, "");
139
+ return;
140
+ }
141
+ if (/^[a-f0-9]{24}$/i.test(text)) {
142
+ await finalizeOwner(envelope.canonicalUserId, session, text);
143
+ return;
144
+ }
145
+ if (session.step === "awaiting-owner-message") {
146
+ await send(
147
+ "Still listening. Send a message from your Kazee account, paste a user id, or send \"skip\".",
148
+ { keyboard: cancelKeyboard },
149
+ );
150
+ }
100
151
  return;
101
152
  }
102
153
  }
103
154
 
155
+ // Fired by router.tryCaptureOwner() for inbound messages arriving ON the
156
+ // freshly-added Kazee adapter while a session is listening. Returns true
157
+ // when it consumed the message (so the router stops normal processing).
158
+ async function tryCaptureOwner(envelope) {
159
+ const adapterId = envelope.adapter?.id;
160
+ if (!adapterId) return false;
161
+ for (const [, session] of sessions) {
162
+ if (session.step !== "awaiting-owner-message") continue;
163
+ if (session.data.adapterId !== adapterId) continue;
164
+ const userId = String(envelope.userId || envelope.from?.id || "");
165
+ if (!userId) return true; // consume malformed input, keep listening
166
+ const name = envelope.from?.name || envelope.from?.username || "";
167
+ session.data.candidate = { userId, name };
168
+ session.step = "confirming-owner";
169
+ // Light ack on the Kazee side so the sender knows it landed.
170
+ try { await envelope.adapter.send(envelope.channelId, "Got your message 👋 — finishing setup on my end."); } catch (e) {}
171
+ const who = name ? `${name} (${userId})` : userId;
172
+ await sayToOrigin(
173
+ session,
174
+ `Got a Kazee message from ${who}.\n\nIs this you — the owner?`,
175
+ { keyboard: { inline_keyboard: [
176
+ [{ text: "Yes, that's me", callback_data: "chw:owner-yes" }],
177
+ [{ text: "No, keep listening", callback_data: "chw:owner-no" }],
178
+ [{ text: "Skip (no owner)", callback_data: "chw:owner-skip" }],
179
+ ] } },
180
+ );
181
+ return true;
182
+ }
183
+ return false;
184
+ }
185
+
104
186
  async function handleAction(envelope) {
105
187
  const payload = envelope.action?.payload || envelope.action?.buttonId || "";
106
- if (payload !== "chw:cancel") return false;
107
- await cancel(envelope.canonicalUserId);
188
+ if (!String(payload).startsWith("chw:")) return false;
189
+ const session = sessions.get(envelope.canonicalUserId);
190
+
191
+ if (payload === "chw:cancel") {
192
+ await cancel(envelope.canonicalUserId);
193
+ return true;
194
+ }
195
+ if (!session) return true; // stale button — swallow so it doesn't fall through
196
+
197
+ if (payload === "chw:owner-yes") {
198
+ const cand = session.data.candidate;
199
+ if (!cand?.userId) {
200
+ session.step = "awaiting-owner-message";
201
+ await sayToOrigin(session, "Lost that candidate — send another message from Kazee.", { keyboard: cancelKeyboard });
202
+ return true;
203
+ }
204
+ await finalizeOwner(envelope.canonicalUserId, session, cand.userId);
205
+ return true;
206
+ }
207
+ if (payload === "chw:owner-no") {
208
+ session.data.candidate = null;
209
+ session.step = "awaiting-owner-message";
210
+ await sayToOrigin(session, "OK — still listening. Send a message from the owner's Kazee account.", { keyboard: cancelKeyboard });
211
+ return true;
212
+ }
213
+ if (payload === "chw:owner-skip") {
214
+ await finalizeOwner(envelope.canonicalUserId, session, "");
215
+ return true;
216
+ }
108
217
  return true;
109
218
  }
110
219
 
111
- async function applySession(canonicalUserId, session) {
112
- const { url, token, ownerUserId } = session.data;
220
+ // ── env plumbing ────────────────────────────────────────────────────
113
221
 
114
- // Capture rollback state so we can undo .env changes if the adapter
115
- // refuses to start.
116
- const previous = {
222
+ function snapshotEnv() {
223
+ return {
117
224
  KAZEE_URL: config.KAZEE_URL || "",
118
225
  KAZEE_BOT_TOKEN: config.KAZEE_BOT_TOKEN || "",
226
+ KAZEE_BOT_USER_ID: config.KAZEE_BOT_USER_ID || "",
119
227
  KAZEE_OWNER_USER_ID: config.KAZEE_OWNER_USER_ID || "",
120
228
  CHANNELS: config.CHANNELS || "telegram",
121
229
  };
230
+ }
231
+
232
+ function revertEnv(previous) {
233
+ if (!previous) return;
234
+ for (const [k, v] of Object.entries(previous)) {
235
+ saveEnvKey(k, v);
236
+ config[k] = v;
237
+ }
238
+ }
239
+
240
+ // Persist URL/token/botUserId, add "kazee" to CHANNELS, and boot the adapter
241
+ // (owner still blank). Snapshots the prior env for rollback on failure/cancel.
242
+ async function bringLive(session) {
243
+ const { url, token, botUserId } = session.data;
244
+ const previous = snapshotEnv();
245
+ session.data.previous = previous;
122
246
 
123
247
  saveEnvKey("KAZEE_URL", url);
124
248
  saveEnvKey("KAZEE_BOT_TOKEN", token);
125
- saveEnvKey("KAZEE_OWNER_USER_ID", ownerUserId);
126
-
249
+ saveEnvKey("KAZEE_BOT_USER_ID", botUserId);
127
250
  const channels = previous.CHANNELS.split(",").map((s) => s.trim()).filter(Boolean);
128
- if (!channels.some((c) => c === "kazee" || c.startsWith("kazee:"))) {
129
- channels.push("kazee");
130
- }
251
+ if (!channels.some((c) => c === "kazee" || c.startsWith("kazee:"))) channels.push("kazee");
131
252
  const newChannels = channels.join(",");
132
253
  saveEnvKey("CHANNELS", newChannels);
133
254
 
134
- // Refresh in-process view so subsequent loadChannels() sees the new
135
- // values without a restart.
136
255
  config.KAZEE_URL = url;
137
256
  config.KAZEE_BOT_TOKEN = token;
138
- config.KAZEE_OWNER_USER_ID = ownerUserId;
257
+ config.KAZEE_BOT_USER_ID = botUserId;
139
258
  config.CHANNELS = newChannels;
140
259
 
141
260
  const result = await registry.addAdapter(
142
- { id: "kazee", type: "kazee", opts: { url, token, ownerUserId } },
261
+ { id: "kazee", type: "kazee", opts: { url, token, ownerUserId: "", botUserId } },
143
262
  { publicCommands: publicCommands() },
144
263
  );
145
-
146
264
  if (!result.ok) {
147
- // Roll the .env back so the next bot start doesn't crash-loop.
148
- saveEnvKey("KAZEE_URL", previous.KAZEE_URL);
149
- saveEnvKey("KAZEE_BOT_TOKEN", previous.KAZEE_BOT_TOKEN);
150
- saveEnvKey("KAZEE_OWNER_USER_ID", previous.KAZEE_OWNER_USER_ID);
151
- saveEnvKey("CHANNELS", previous.CHANNELS);
152
- config.KAZEE_URL = previous.KAZEE_URL;
153
- config.KAZEE_BOT_TOKEN = previous.KAZEE_BOT_TOKEN;
154
- config.KAZEE_OWNER_USER_ID = previous.KAZEE_OWNER_USER_ID;
155
- config.CHANNELS = previous.CHANNELS;
156
- sessions.delete(canonicalUserId);
157
- await send(`Could not start the Kazee adapter: ${result.error}\n\nReverted .env. Run /channel add kazee to try again.`);
158
- return;
265
+ revertEnv(previous);
266
+ return result;
159
267
  }
268
+ session.data.adapterId = "kazee";
269
+ session.data.live = true;
270
+ return result;
271
+ }
160
272
 
273
+ // Owner decided (id or blank). Persist it, flip the live adapter's owner,
274
+ // and close the session.
275
+ async function finalizeOwner(canonicalUserId, session, ownerUserId) {
276
+ saveEnvKey("KAZEE_OWNER_USER_ID", ownerUserId);
277
+ config.KAZEE_OWNER_USER_ID = ownerUserId;
278
+ const adapter = registry.findAdapter("kazee");
279
+ if (adapter) adapter.ownerUserId = ownerUserId;
161
280
  sessions.delete(canonicalUserId);
162
- await send("Kazee channel added and live.");
281
+ await sayToOrigin(
282
+ session,
283
+ ownerUserId
284
+ ? "Kazee channel added and live. You're set as the owner — message me from Kazee and I'll know it's you. 🎉"
285
+ : "Kazee channel added and live. No owner set — run /channel add kazee again if you want to set one.",
286
+ );
163
287
  }
164
288
 
165
289
  module.exports = {
@@ -170,5 +294,6 @@ module.exports = {
170
294
  cancel,
171
295
  handleText,
172
296
  handleAction,
297
+ tryCaptureOwner,
173
298
  validateKazee,
174
299
  };
package/core/handlers.js CHANGED
@@ -1664,7 +1664,7 @@ register({
1664
1664
  if (addMatch) {
1665
1665
  const kind = addMatch[1].toLowerCase();
1666
1666
  if (kind !== "kazee") return send(`Channel type "${kind}" not supported.`);
1667
- await wizard.start(env.canonicalUserId, "kazee");
1667
+ await wizard.start(env.canonicalUserId, "kazee", env);
1668
1668
  return;
1669
1669
  }
1670
1670
 
package/core/identity.js CHANGED
@@ -69,6 +69,23 @@ function ownerCanonical() {
69
69
  return "";
70
70
  }
71
71
 
72
+ // True when a channel resolves to the owner's canonical identity — the shared
73
+ // source of truth the queue/state/run-lock already uses. The relationship
74
+ // guardrail leans on this so the owner is recognised on EVERY channel they've
75
+ // unified: a people-record handle, an explicit /link (identities.json), or the
76
+ // configured-owner env path. Resolving ownership through the people-record
77
+ // handle list alone is too narrow — a channel unified by /link or env-config is
78
+ // never mirrored onto the record, so the owner would be falsely classed
79
+ // external there. The canonical map is only ever written by owner/operator
80
+ // actions, so this can never let a note confer owner (R1 preserved — a note
81
+ // touches neither identities.json nor .env).
82
+ function channelIsOwner(transport, channelId) {
83
+ const canonical = normalizeCanonicalUserId(canonicalForChannel(transport, channelId));
84
+ if (!canonical) return false;
85
+ const owner = normalizeCanonicalUserId(ownerCanonical());
86
+ return !!owner && canonical === owner;
87
+ }
88
+
72
89
  function canonicalForChannel(transport, channelId) {
73
90
  const key = channelKey(transport, channelId);
74
91
  const explicit = normalizeCanonicalUserId(identities.channels[key]);
@@ -127,6 +144,7 @@ module.exports = {
127
144
  defaultCanonicalForChannel,
128
145
  isConfiguredOwnerChannel,
129
146
  ownerCanonical,
147
+ channelIsOwner,
130
148
  canonicalForChannel,
131
149
  canonicalForTelegram,
132
150
  canonicalForStoredUserKey,
@@ -6,10 +6,19 @@ const https = require("https");
6
6
  const http = require("http");
7
7
  const { URL } = require("url");
8
8
 
9
+ // The adapter mounts every REST route under {root}/api and talks socket.io
10
+ // at {root}. Callers may hand us either the bare root host or a URL that
11
+ // already ends in /api — normalise both to the root so we always probe the
12
+ // real {root}/api/me and the caller can store one canonical KAZEE_URL.
13
+ function normalizeKazeeRoot(url) {
14
+ return String(url || "").trim().replace(/\/+$/, "").replace(/\/api$/i, "");
15
+ }
16
+
9
17
  function validateKazee({ url, token }) {
10
18
  return new Promise((resolve) => {
19
+ const root = normalizeKazeeRoot(url);
11
20
  let u;
12
- try { u = new URL(url.replace(/\/$/, "") + "/me"); }
21
+ try { u = new URL(root + "/api/me"); }
13
22
  catch (e) { return resolve({ ok: false, error: `Bad URL: ${e.message}` }); }
14
23
  const lib = u.protocol === "https:" ? https : http;
15
24
  const req = lib.request({
@@ -26,16 +35,16 @@ function validateKazee({ url, token }) {
26
35
  try {
27
36
  const body = chunks ? JSON.parse(chunks) : {};
28
37
  const botUserId = body?.user?._id || body?.user?.id || body?._id || "";
29
- resolve({ ok: true, botUserId });
38
+ resolve({ ok: true, botUserId, url: root });
30
39
  } catch (e) {
31
- resolve({ ok: true, botUserId: "" });
40
+ resolve({ ok: true, botUserId: "", url: root });
32
41
  }
33
42
  } else if (res.statusCode === 401 || res.statusCode === 403) {
34
43
  resolve({ ok: false, error: "Token rejected (401/403). Check that it's a valid Kazee bot token." });
35
44
  } else if (res.statusCode === 404) {
36
- resolve({ ok: false, error: "GET /me returned 404. Check that the URL points at the Kazee bot API root." });
45
+ resolve({ ok: false, error: "GET /api/me returned 404. Check the URL points at your Kazee host (e.g. https://chat.example.com)." });
37
46
  } else {
38
- resolve({ ok: false, error: `GET /me → HTTP ${res.statusCode}` });
47
+ resolve({ ok: false, error: `GET /api/me → HTTP ${res.statusCode}` });
39
48
  }
40
49
  });
41
50
  });
@@ -45,4 +54,4 @@ function validateKazee({ url, token }) {
45
54
  });
46
55
  }
47
56
 
48
- module.exports = { validateKazee };
57
+ module.exports = { validateKazee, normalizeKazeeRoot };
@@ -18,18 +18,37 @@ function speakerFor(adapterType, channelId) {
18
18
  if (!adapterType || !channelId) return null;
19
19
  const people = require("./people");
20
20
  const person = people.findByHandle(adapterType, channelId);
21
- if (!person) {
22
- // Authed but not mapped to a person record treat as external.
21
+
22
+ // Ownership is resolved through canonical identity the SAME source of
23
+ // truth the queue/state/run-lock uses — so the owner is recognised on every
24
+ // channel they've unified (a people handle, an explicit /link, or the
25
+ // configured-owner env path), not just channels physically mirrored onto
26
+ // the people record. Consulting only findByHandle is too narrow: a channel
27
+ // unified by /link or env-config is never written back to the record, so the
28
+ // owner would be falsely classed external there. channelIsOwner is driven
29
+ // only by owner/operator-written identity links (identities.json / .env), so
30
+ // a note can never trip it (R1 preserved — the owner stays anchored to a
31
+ // real owner record via ownerCanonical → people.isOwner).
32
+ let ownerByCanonical = false;
33
+ try { ownerByCanonical = require("./identity").channelIsOwner(adapterType, channelId); } catch (e) {}
34
+
35
+ if (ownerByCanonical || (person && person.isOwner)) {
36
+ let ownerPerson = person && person.isOwner ? person : null;
37
+ if (!ownerPerson) { try { ownerPerson = (people.owners() || [])[0] || null; } catch (e) {} }
38
+ const name = ownerPerson ? ownerPerson.name : (person ? person.name : "");
23
39
  return {
24
- person: null, name: "", isOwner: false, relationship: "external",
25
- slug: null, mandate: "", style: "", entityName: "",
40
+ person: ownerPerson || person || null, name, isOwner: true, relationship: "owner",
41
+ slug: null, mandate: "", style: "", entityName: name,
26
42
  adapterType: String(adapterType), channelId: String(channelId),
27
43
  };
28
44
  }
29
- if (person.isOwner) {
45
+
46
+ if (!person) {
47
+ // Authed but not mapped to a person record and not an owner-canonical
48
+ // channel → treat as external (default-deny).
30
49
  return {
31
- person, name: person.name, isOwner: true, relationship: "owner",
32
- slug: null, mandate: "", style: "", entityName: person.name,
50
+ person: null, name: "", isOwner: false, relationship: "external",
51
+ slug: null, mandate: "", style: "", entityName: "",
33
52
  adapterType: String(adapterType), channelId: String(channelId),
34
53
  };
35
54
  }
package/core/router.js CHANGED
@@ -63,6 +63,12 @@ async function onMessage(envelope) {
63
63
  // owner's canonical bucket on first contact, before any state is read.
64
64
  try { autoLinkOwnerChannel(envelope.adapter?.type, envelope.channelId); } catch (e) {}
65
65
 
66
+ // A channel wizard listening for the owner's message on a freshly-added
67
+ // adapter consumes that inbound message here: it arrives on a different
68
+ // channel than the operator's, so the isAwaiting() gate below (keyed to
69
+ // the operator) never sees it.
70
+ if (await channelWizard.tryCaptureOwner(envelope)) return;
71
+
66
72
  // While a channel wizard is open, capture text input before slash
67
73
  // dispatch — except for /cancel, which always bails out.
68
74
  if (channelWizard.isAwaiting(envelope.canonicalUserId)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
package/setup.js CHANGED
@@ -674,11 +674,11 @@ async function main() {
674
674
  const addKazee = await ask(" Add a Kazee channel now? (y/n) [n]: ");
675
675
  if (addKazee.toLowerCase() === "y") {
676
676
  while (true) {
677
- const url = (await ask(" Kazee bot API URL (e.g. https://chat.example.com/api): ")).trim();
677
+ const url = (await ask(" Kazee host URL (e.g. https://chat.example.com — I add /api myself): ")).trim();
678
678
  if (!/^https?:\/\//i.test(url)) { console.log(" That doesn't look like a URL."); continue; }
679
679
  const token = (await ask(" Kazee bot token: ")).trim();
680
680
  if (!token) { console.log(" Empty token."); continue; }
681
- console.log(" Validating against Kazee /me…");
681
+ console.log(" Validating against Kazee /api/me…");
682
682
  const check = await validateKazee({ url, token });
683
683
  if (!check.ok) {
684
684
  console.log(` Validation failed: ${check.error}`);
@@ -687,7 +687,7 @@ async function main() {
687
687
  continue;
688
688
  }
689
689
  const ownerUserId = (await ask(" Kazee owner user id (blank to skip): ")).trim();
690
- state.data.kazee = { url: url.replace(/\/$/, ""), token, ownerUserId };
690
+ state.data.kazee = { url: check.url || url.replace(/\/+$/, ""), token, ownerUserId, botUserId: check.botUserId || "" };
691
691
  console.log(" Kazee channel configured.");
692
692
  break;
693
693
  }
@@ -756,6 +756,7 @@ async function main() {
756
756
  if (d.kazee) {
757
757
  envLines.push(`KAZEE_URL=${d.kazee.url}`);
758
758
  envLines.push(`KAZEE_BOT_TOKEN=${d.kazee.token}`);
759
+ envLines.push(`KAZEE_BOT_USER_ID=${d.kazee.botUserId || ""}`);
759
760
  envLines.push(`KAZEE_OWNER_USER_ID=${d.kazee.ownerUserId || ""}`);
760
761
  }
761
762
  const env = envLines.join("\n");
@@ -119,4 +119,28 @@ assert.ok(target, "ownerTarget resolves an owner handle");
119
119
  assert.strictEqual(target.adapter, "telegram", "ownerTarget adapter");
120
120
  assert.strictEqual(target.channelId, CH_OWNER_ALT, "ownerTarget picks the primary channel, not the first");
121
121
 
122
+ // ── split-brain fix: an owner channel unified via /link (identities.json) but
123
+ // NEVER mirrored onto the people record must STILL resolve to the owner. This
124
+ // is the production bug — a Kazee handle /link'd to the owner's canonical id was
125
+ // invisible to people.findByHandle, so the guardrail falsely treated the owner
126
+ // as an external speaker. Ownership now flows through canonical identity. ──
127
+ const identity = require("./core/identity");
128
+ const ownerCanon = identity.ownerCanonical();
129
+ assert.ok(ownerCanon, "owner has a canonical id");
130
+ identity.setIdentityMapping("kazee", "owner-linked-kazee", ownerCanon);
131
+ assert.strictEqual(people.findByHandle("kazee", "owner-linked-kazee"), null,
132
+ "precondition: the /link'd kazee channel is absent from the people record");
133
+ const linkedSp = relationship.speakerFor("kazee", "owner-linked-kazee");
134
+ assert.strictEqual(linkedSp.isOwner, true, "owner-canonical channel → owner even without a people handle");
135
+ assert.strictEqual(linkedSp.relationship, "owner", "owner-canonical channel relationship");
136
+ assert.strictEqual(linkedSp.mandate, "", "owner never carries a mandate");
137
+ assert.strictEqual(relationship.guardActive(linkedSp), false, "owner-canonical channel is never guarded");
138
+
139
+ // ── and the fix must NOT widen: an unmapped channel on the same transport that
140
+ // does not resolve to the owner canonical stays external + guarded. ──
141
+ const strayKazee = relationship.speakerFor("kazee", "some-stranger-999");
142
+ assert.strictEqual(strayKazee.isOwner, false, "unmapped kazee channel is not owner");
143
+ assert.strictEqual(strayKazee.relationship, "external", "unmapped kazee channel → external");
144
+ assert.strictEqual(relationship.guardActive(strayKazee), true, "unmapped kazee channel stays guarded");
145
+
122
146
  console.log("relationship OK");