@inetafrica/open-claudia 3.3.2 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.4.1 — Fable 5.1, and the CLI floor it quietly needs
4
+
5
+ - **`claude-fable-5-1` joins the Claude model picker.** The identifier is dash-form; `claude-fable-5.1` does not exist and fails as an unknown model.
6
+ - **It carries a CLI floor: Claude Code >= 2.1.251.** An older CLI does not degrade gracefully — it returns an API 400 naming the required version. The local install was taken 2.1.150 -> 2.1.258 before the model was listed, and the picker offering a button the local binary cannot serve is the failure mode to avoid. Pod images install Claude Code unpinned, so a rebuild clears the floor by itself, but pods still on older images will 400 on this model until they are rolled.
7
+ - Verified by building the real main invocation through the provider and spawning it, rather than by a hand-written CLI probe: the API reported `claude-fable-5-1` as the serving model, and `claude-opus-4-8` was re-run through the same harness to prove the incumbent path was untouched. Full suite 135/135.
8
+
9
+ ## v3.4.0 — Who you are, where you are, and what you may do there are three different questions
10
+
11
+ - **The bug that started it: a wakeup scheduled in a Codex room kept running on Claude.** `getActiveProvider` resolves the room's overlay from `state.roomKey`, but that key was stamped from `currentConversationKey()` — and a scheduled job resumes its own saved thread, whose conversation key is the job's *project name*, not a room. The overlay never resolved, so every job silently fell back to the creator's personal default. The thread and the room are now separate values (`roomKey`, defaulting to the conversation key so every inbound path is unchanged); the scheduler passes the room the job was created in. The same fusion is why a group answered as Codex to one person and Claude to another. Relatedly, a run that reverts to the provider it was scheduled under because the room's current choice is not runnable now **says so** — in the prompt and in a ⚠️ line on the advisory — instead of leaving a Codex room reporting a Claude quota error with nothing to explain where Claude came from.
12
+ - **A room is not a person.** Authority was resolved from `channelId`, which on every transport where a room holds many people is not an identity at all. Principal is now always the transport USER id, carried separately from the room all the way to the enforcer. A group carrying `isOwner` in `auth.json` can no longer promote every member of that group to owner.
13
+ - **Capability is a lookup, not a judgment.** A live `(principal × room)` grant short-circuits the enforcer *before* any model call, so an owner's own decision is never re-litigated by a judge. The grant unit is the **pair**: trusting someone in one group never trusts them in another, nor trusts the rest of that group — "🌍 Always — everywhere" is a separate, explicit tap next to the default "✅ Always — here". Grants carry a capability ladder (`read` < `act` < `destructive`, so an `act` grant can never authorize a destructive run), an optional subject scope, and optional expiry or one-turn burn-down. Re-granting replaces rather than stacks, so downgrading actually downgrades. Deliberately additive: *no grant* is not a denial — the existing mandate and channel-rule paths still run underneath. New `/grants` lists and revokes; every issue is audited.
14
+ - **Group admins carry zero authority in the pod.** Presence is surface-owned (whoever administers a room decides whether the agent is *in* it — an agent is a user like any other); capability is pod-owned and set by owners alone. Otherwise sharing a room with someone would make them an admin of your account.
15
+ - **A second owner could never approve anything.** Escalation resolved `owners()[0]` and posted every card to that one person, so a card went unanswered whenever they were away. Cards now fan out to every reachable owner, deduped by send target; any owner may decide whichever copy they see, and the decision is idempotent.
16
+ - **A grant that expired looked exactly like one you never gave.** The bot simply started asking again with nothing to explain why. Escalation cards now name the lapsed line and its date, at the moment the lapse bites.
17
+ - **A torn write could silently demote the owner.** `people.json` was written without fsync or a backup and read as `[]` on any failure — no person found means "external", so one interrupted write guarded the owner in their own pod. Worse, a corrupt `auth.json` parsed as *no owner*, and no owner is what lets the next `/auth` from a stranger seize the pod. Both stores now write atomically with a `.bak` snapshot, recover from it transparently, and serialize read-modify-writes through a cross-process lock (the bot and the CLI are separate writers). `hasOwner()` now distinguishes *absent* (fresh pod, still claimable) from *unreadable* (fails closed). The dashboard and installer carried their own `loadAuth` that would have overwritten the recoverable copy with an empty roster; both fixed.
18
+ - **Surface-agnostic by construction.** A room is `<surface>:<id>` — a Spaces task thread resolves identically to a Telegram group, so a Spaces-only pod needs no chat surface at all.
19
+ - Regression coverage: `test-grants.js` (the pair is the unit, the capability ladder, reach vs strength, scope, revocation/expiry/one-turn, replace-not-stack, default-deny) and `test-store-durability.js` (`.bak` recovery, fail-closed vs still-claimable, lock exclusivity and stale reclaim) join the chain.
20
+
3
21
  ## v3.3.2 — Known people are recognized by transport id, approved owners stay owners, and a mandate can no longer land on the wrong note
4
22
 
5
23
  - **Every known person got interrogated on a fresh Kazee DM.** The intro flow matched inbound chats only by `(adapter, channelId)` — but Kazee DMs key the envelope's channelId by the conversation *document* id while people handles store the sender's *user* id, so nothing ever matched: known teammates, and the owner on pods without `KAZEE_OWNER_USER_ID`, all got "who are you" (the Coders Africa incident, 2026-08-19/20). Now a sender whose transport user id already maps to a person is silently **adopted**: the DM channel is linked to their record, an auth entry is written, any stale pending intro is resolved, and `handleInbound` returns false so the router falls through and handles the turn normally. Adoption is gated on adapter-attested `surface: "dm"` — the Kazee adapter sets it only on a positive chat-meta read (`isGroupChat: false`); a failed meta read attests nothing (fail closed) — because auth.json standing is per-CHANNEL and adopting a room would hand every member the sender's standing. Display names are never matched; only the transport id, the same trust level as the env owner match.
@@ -301,6 +301,7 @@ class KazeeAdapter {
301
301
  }
302
302
  envelope.engaged = true;
303
303
  envelope.surface = "group";
304
+ envelope.isGroup = true;
304
305
  } else if (meta) {
305
306
  // Adapter-attested 1:1 conversation — required before intro-flow may
306
307
  // auto-adopt the chat for a known sender. Set only on a positive meta
@@ -269,11 +269,20 @@ class SpacesAdapter {
269
269
  if (!engage) return;
270
270
 
271
271
  const channelId = `task:${n.taskId}`;
272
+ // A task is a room, never a person. Falling back to the task id when the
273
+ // notification carries no actor minted an identity out of the room, so
274
+ // everyone commenting on that task shared one principal. With no actor we
275
+ // have no principal, and say so in a form that cannot collide with a real
276
+ // Spaces user id.
277
+ const actorId = n.actorId ? String(n.actorId) : "";
278
+ const principalId = actorId || `unknown-actor:${n.taskId}`;
272
279
  const envelope = {
273
280
  adapter: this,
274
281
  channelId,
275
- canonicalUserId: canonicalForChannel("spaces", n.actorId || n.taskId),
276
- userId: n.actorId || n.taskId,
282
+ canonicalUserId: canonicalForChannel("spaces", principalId),
283
+ userId: principalId,
284
+ // Task threads are multi-party rooms: no room-derived authority, ever.
285
+ isGroup: true,
277
286
  type: "text",
278
287
  text: n.content || `[${n.type} on task ${n.title || n.taskId}]`,
279
288
  // Thread the bot's reply under the exact comment that triggered this engage
@@ -478,13 +478,16 @@ class TelegramAdapter {
478
478
  this.bot.on("document", (msg) => this._emit("message", this._envelopeMedia(msg, "document", msg.document)));
479
479
 
480
480
  this.bot.on("callback_query", (q) => {
481
- const chatId = String(q.message?.chat?.id || q.from?.id);
482
- const userId = String(q.from?.id || chatId);
481
+ const { chatId, userId, canonicalUserId, isGroup } = this._identity(
482
+ { chat: q.message?.chat, from: q.from },
483
+ q.from?.id,
484
+ );
483
485
  this._emit("action", {
484
486
  adapter: this,
485
487
  channelId: chatId,
486
- canonicalUserId: canonicalForChannel("telegram", chatId),
488
+ canonicalUserId,
487
489
  userId,
490
+ isGroup,
488
491
  type: "action",
489
492
  messageId: q.message?.message_id,
490
493
  from: { id: userId, name: [q.from?.first_name, q.from?.last_name].filter(Boolean).join(" "), username: q.from?.username },
@@ -499,14 +502,30 @@ class TelegramAdapter {
499
502
  });
500
503
  }
501
504
 
505
+ // Who is speaking (sender id) and where (chat id) are different questions.
506
+ // Resolving canonical identity from the chat id made every member of a group
507
+ // share one identity — including, where the group carried isOwner, the
508
+ // owner's. In a Telegram DM chat.id === from.id, so DM identity is unchanged.
509
+ _identity(msg, fallbackChatId) {
510
+ const chatId = String(msg?.chat?.id ?? fallbackChatId ?? "");
511
+ const senderId = msg?.from?.id != null ? String(msg.from.id) : chatId;
512
+ const chatType = String(msg?.chat?.type || "");
513
+ return {
514
+ chatId,
515
+ userId: senderId,
516
+ canonicalUserId: canonicalForChannel("telegram", senderId),
517
+ isGroup: chatType === "group" || chatType === "supergroup" || chatType === "channel",
518
+ };
519
+ }
520
+
502
521
  _envelopeText(msg, extra = {}) {
503
- const chatId = String(msg.chat.id);
504
- const userId = String(msg.from?.id || chatId);
522
+ const { chatId, userId, canonicalUserId, isGroup } = this._identity(msg);
505
523
  return {
506
524
  adapter: this,
507
525
  channelId: chatId,
508
- canonicalUserId: canonicalForChannel("telegram", chatId),
526
+ canonicalUserId,
509
527
  userId,
528
+ isGroup,
510
529
  type: extra.isCommand ? "command" : "text",
511
530
  text: msg.text,
512
531
  messageId: msg.message_id,
@@ -524,13 +543,13 @@ class TelegramAdapter {
524
543
  }
525
544
 
526
545
  _envelopeMedia(msg, kind, media) {
527
- const chatId = String(msg.chat.id);
528
- const userId = String(msg.from?.id || chatId);
546
+ const { chatId, userId, canonicalUserId, isGroup } = this._identity(msg);
529
547
  return {
530
548
  adapter: this,
531
549
  channelId: chatId,
532
- canonicalUserId: canonicalForChannel("telegram", chatId),
550
+ canonicalUserId,
533
551
  userId,
552
+ isGroup,
534
553
  type: kind,
535
554
  messageId: msg.message_id,
536
555
  replyToId: msg.reply_to_message?.message_id,
package/core/access.js CHANGED
@@ -6,7 +6,8 @@
6
6
 
7
7
  const fs = require("fs");
8
8
  const { AUTH_FILE, CHAT_ID, CHAT_IDS, config, saveEnvKey } = require("./config");
9
- const { currentTransport, currentUserId } = require("./context");
9
+ const { currentTransport, currentUserId, currentIsGroup } = require("./context");
10
+ const { atomicWriteFileSync, readJsonWithFallback, withFileLock } = require("./fsutil");
10
11
 
11
12
  // Per-transport owner identifiers that don't live in CHAT_IDS or auth.json.
12
13
  // Kazee envelopes carry the chat-document id as channelId, while the bot
@@ -14,6 +15,10 @@ const { currentTransport, currentUserId } = require("./context");
14
15
  // /channel add can update the value without a process restart.
15
16
  function transportOwnerUserId(transport) {
16
17
  if (transport === "kazee") return config.KAZEE_OWNER_USER_ID || "";
18
+ // Spaces actors are central/Kazee user ids, so the owner carries the same id
19
+ // there. Without this the owner's own task comments authorize as a stranger
20
+ // on a Spaces-only pod, which has no Kazee chat surface to fall back on.
21
+ if (transport === "spaces") return config.KAZEE_OWNER_USER_ID || "";
17
22
  // The voice channel is single-owner: the bearer token gates the connection,
18
23
  // and every envelope carries this fixed owner id, so it authorizes as owner.
19
24
  if (transport === "voice") return config.VOICE_OWNER_USER_ID || "voice-owner";
@@ -33,19 +38,30 @@ function matchesTransportOwner() {
33
38
  }
34
39
 
35
40
  function loadAuth() {
36
- try {
37
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
38
- return {
39
- authorized: Array.isArray(auth.authorized) ? auth.authorized : [],
40
- pending: Array.isArray(auth.pending) ? auth.pending : [],
41
- };
42
- } catch (e) {
43
- return { authorized: [], pending: [] };
44
- }
41
+ const auth = readJsonWithFallback(AUTH_FILE, null) || {};
42
+ return {
43
+ authorized: Array.isArray(auth.authorized) ? auth.authorized : [],
44
+ pending: Array.isArray(auth.pending) ? auth.pending : [],
45
+ };
45
46
  }
46
47
 
48
+ // This file decides who owns the agent, so a torn write is not a small loss:
49
+ // a half-written auth.json used to parse as "no owner", and no owner is what
50
+ // lets the next /auth from a stranger seize ownership. Atomic + `.bak` so the
51
+ // last good roster survives a crash mid-write.
47
52
  function saveAuth(auth) {
48
- fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
53
+ atomicWriteFileSync(AUTH_FILE, `${JSON.stringify(auth, null, 2)}\n`, { backup: true });
54
+ }
55
+
56
+ // Read-modify-write under the store lock — an approval and a revoke landing
57
+ // together must not have one silently overwrite the other's roster.
58
+ function mutateAuth(fn) {
59
+ return withFileLock(AUTH_FILE, () => {
60
+ const auth = loadAuth();
61
+ const result = fn(auth);
62
+ saveAuth(auth);
63
+ return result;
64
+ });
49
65
  }
50
66
 
51
67
  function isChatAuthorized(chatId, envelope) {
@@ -71,8 +87,7 @@ function isChatAuthorized(chatId, envelope) {
71
87
  } catch (e) {}
72
88
  if (CHAT_IDS.includes(id)) return true;
73
89
  try {
74
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
75
- if (Array.isArray(auth.authorized) && auth.authorized.some((a) => String(a.chatId) === id)) return true;
90
+ if (loadAuth().authorized.some((a) => String(a.chatId) === id)) return true;
76
91
  } catch (e) {}
77
92
  // Also accept any chat that has been linked to a person via an intro.
78
93
  try {
@@ -86,17 +101,36 @@ function isChatAuthorized(chatId, envelope) {
86
101
  return false;
87
102
  }
88
103
 
104
+ // Owner is a property of the PERSON, never of the room they are speaking in.
105
+ // The argument is named chatId for call-site compatibility, but it is only
106
+ // honoured as an identity in a 1:1 chat, where chat and speaker are the same
107
+ // being. In a group the room id is discarded outright: a group carrying
108
+ // isOwner in auth.json (or sitting in CHAT_IDS) would otherwise promote every
109
+ // member of that group to owner.
89
110
  function isChatOwner(chatId) {
90
111
  if (matchesTransportOwner()) return true;
91
- const id = String(chatId || "");
112
+ const inGroup = currentIsGroup();
113
+ const speakerId = String(currentUserId() || "");
114
+ // In a group only the speaker counts; in a DM the passed chat id is the
115
+ // speaker, and stays usable for callers outside a turn scope (CLI, tests).
116
+ const id = inGroup ? speakerId : (String(chatId || "") || speakerId);
92
117
  if (!id) return false;
93
- if (id === String(CHAT_ID || "")) return true;
118
+ // A person marked isOwner on their people record owns the agent everywhere,
119
+ // on every surface — that record is written only by an existing owner.
120
+ try {
121
+ const people = require("./people");
122
+ const transport = currentTransport();
123
+ if (transport && speakerId) {
124
+ const person = people.findByHandle(transport, speakerId);
125
+ if (person && person.isOwner === true) return true;
126
+ }
127
+ } catch (e) {}
128
+ if (!inGroup && id === String(CHAT_ID || "")) return true;
94
129
  try {
95
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
96
- const authorized = Array.isArray(auth.authorized) ? auth.authorized : [];
130
+ const { authorized } = loadAuth();
97
131
  if (authorized.some((a) => String(a.chatId) === id && a.isOwner === true)) return true;
98
132
  // No explicit owner flag → fall back to .env CHAT_IDS membership.
99
- if (!authorized.some((a) => a.isOwner === true) && CHAT_IDS.includes(id)) return true;
133
+ if (!inGroup && !authorized.some((a) => a.isOwner === true) && CHAT_IDS.includes(id)) return true;
100
134
  } catch (e) {}
101
135
  return false;
102
136
  }
@@ -114,48 +148,48 @@ function updateAuthorizedChatEnv(auth) {
114
148
  }
115
149
 
116
150
  function approveAuthRequest(chatId) {
117
- const auth = loadAuth();
118
- if (auth.authorized.some((a) => String(a.chatId) === chatId)) {
119
- auth.pending = auth.pending.filter((p) => String(p.chatId) !== chatId);
120
- saveAuth(auth);
121
- updateAuthorizedChatEnv(auth);
122
- return { status: "already_authorized" };
123
- }
124
- const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
125
- if (idx < 0) return { status: "not_found" };
126
- const approved = auth.pending.splice(idx, 1)[0];
127
- auth.authorized.push({
128
- chatId: approved.chatId,
129
- name: approved.name,
130
- username: approved.username,
131
- isOwner: false,
132
- authorizedAt: new Date().toISOString(),
151
+ const outcome = mutateAuth((auth) => {
152
+ if (auth.authorized.some((a) => String(a.chatId) === chatId)) {
153
+ auth.pending = auth.pending.filter((p) => String(p.chatId) !== chatId);
154
+ return { status: "already_authorized", auth };
155
+ }
156
+ const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
157
+ if (idx < 0) return { status: "not_found" };
158
+ const approved = auth.pending.splice(idx, 1)[0];
159
+ auth.authorized.push({
160
+ chatId: approved.chatId,
161
+ name: approved.name,
162
+ username: approved.username,
163
+ isOwner: false,
164
+ authorizedAt: new Date().toISOString(),
165
+ });
166
+ return { status: "approved", request: approved, auth };
133
167
  });
134
- saveAuth(auth);
135
- updateAuthorizedChatEnv(auth);
136
- return { status: "approved", request: approved };
168
+ if (outcome.auth) updateAuthorizedChatEnv(outcome.auth);
169
+ return outcome.status === "approved"
170
+ ? { status: "approved", request: outcome.request }
171
+ : { status: outcome.status };
137
172
  }
138
173
 
139
174
  function denyAuthRequest(chatId) {
140
- const auth = loadAuth();
141
- const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
142
- if (idx < 0) return { status: "not_found" };
143
- const denied = auth.pending.splice(idx, 1)[0];
144
- saveAuth(auth);
145
- return { status: "denied", request: denied };
175
+ return mutateAuth((auth) => {
176
+ const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
177
+ if (idx < 0) return { status: "not_found" };
178
+ return { status: "denied", request: auth.pending.splice(idx, 1)[0] };
179
+ });
146
180
  }
147
181
 
148
182
  function recordPendingAuthRequest({ chatId, name, username }) {
149
- const auth = loadAuth();
150
- if (auth.pending.some((p) => String(p.chatId) === String(chatId))) return { status: "already_pending" };
151
- auth.pending.push({
152
- chatId: String(chatId),
153
- name,
154
- username,
155
- requestedAt: new Date().toISOString(),
183
+ return mutateAuth((auth) => {
184
+ if (auth.pending.some((p) => String(p.chatId) === String(chatId))) return { status: "already_pending" };
185
+ auth.pending.push({
186
+ chatId: String(chatId),
187
+ name,
188
+ username,
189
+ requestedAt: new Date().toISOString(),
190
+ });
191
+ return { status: "queued" };
156
192
  });
157
- saveAuth(auth);
158
- return { status: "queued" };
159
193
  }
160
194
 
161
195
  // Remove a chatId from the legacy auth.json. Owners are never removed
@@ -164,20 +198,21 @@ function recordPendingAuthRequest({ chatId, name, username }) {
164
198
  function revokeChat(chatId) {
165
199
  const id = String(chatId || "");
166
200
  if (!id) return { ok: false, reason: "missing_id" };
167
- const auth = loadAuth();
168
- const before = auth.authorized.length;
169
- auth.authorized = auth.authorized.filter((a) => {
170
- if (String(a.chatId) !== id) return true;
171
- if (a.isOwner === true) return true;
172
- return false;
201
+ const outcome = mutateAuth((auth) => {
202
+ const before = { authorized: auth.authorized.length, pending: auth.pending.length };
203
+ auth.authorized = auth.authorized.filter((a) => {
204
+ if (String(a.chatId) !== id) return true;
205
+ if (a.isOwner === true) return true;
206
+ return false;
207
+ });
208
+ auth.pending = auth.pending.filter((p) => String(p.chatId) !== id);
209
+ if (auth.authorized.length === before.authorized && auth.pending.length === before.pending) {
210
+ return { ok: false, reason: "not_found" };
211
+ }
212
+ return { ok: true, auth };
173
213
  });
174
- auth.pending = auth.pending.filter((p) => String(p.chatId) !== id);
175
- if (auth.authorized.length === before) {
176
- if (auth.pending.length === 0) return { ok: false, reason: "not_found" };
177
- }
178
- saveAuth(auth);
179
- updateAuthorizedChatEnv(auth);
180
- return { ok: true };
214
+ if (outcome.auth) updateAuthorizedChatEnv(outcome.auth);
215
+ return outcome.ok ? { ok: true } : { ok: false, reason: outcome.reason };
181
216
  }
182
217
 
183
218
  function listAuthorizedRaw() {
@@ -188,33 +223,42 @@ function listAuthorizedRaw() {
188
223
  };
189
224
  }
190
225
 
226
+ // The only caller that matters treats `false` as "this pod is unclaimed, so the
227
+ // next /auth may bootstrap an owner". That makes the answer security-critical in
228
+ // exactly one direction: a wrong `false` hands the agent to whoever asks next.
229
+ //
230
+ // So the two ways of having no owner are NOT the same. A missing auth.json means
231
+ // a genuinely fresh pod and answers false. A file that exists but neither it nor
232
+ // its `.bak` will parse means the roster is unknown, not empty — that fails
233
+ // closed, because refusing a legitimate re-bootstrap is recoverable and handing
234
+ // ownership to a stranger is not.
191
235
  function hasOwner() {
192
236
  if (CHAT_ID) return true;
193
237
  // A provisioned Kazee owner (env-injected or set via /channel add) counts
194
238
  // before their first message — otherwise a stranger's /auth on a
195
239
  // Kazee-only pod would seize ownership.
196
240
  if (config.KAZEE_OWNER_USER_ID) return true;
197
- try {
198
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
199
- return Array.isArray(auth.authorized) && auth.authorized.some((a) => a.isOwner === true);
200
- } catch (e) {
201
- return false;
241
+ const auth = readJsonWithFallback(AUTH_FILE, null);
242
+ if (auth && Array.isArray(auth.authorized)) {
243
+ return auth.authorized.some((a) => a.isOwner === true);
202
244
  }
245
+ try { return fs.existsSync(AUTH_FILE); } catch (e) { return true; }
203
246
  }
204
247
 
205
248
  function bootstrapOwner({ chatId, name, username }) {
206
249
  const id = String(chatId);
207
- const auth = loadAuth();
208
- auth.pending = auth.pending.filter((p) => String(p.chatId) !== id);
209
- auth.authorized = auth.authorized.filter((a) => String(a.chatId) !== id);
210
- auth.authorized.push({
211
- chatId: id,
212
- name: name || "",
213
- username: username || "",
214
- isOwner: true,
215
- authorizedAt: new Date().toISOString(),
250
+ mutateAuth((auth) => {
251
+ auth.pending = auth.pending.filter((p) => String(p.chatId) !== id);
252
+ auth.authorized = auth.authorized.filter((a) => String(a.chatId) !== id);
253
+ auth.authorized.push({
254
+ chatId: id,
255
+ name: name || "",
256
+ username: username || "",
257
+ isOwner: true,
258
+ authorizedAt: new Date().toISOString(),
259
+ });
260
+ return { ok: true };
216
261
  });
217
- saveAuth(auth);
218
262
  if (currentTransport() === "kazee") {
219
263
  // Kazee owner identity is the user id (what transportOwnerUserId
220
264
  // compares), not the chat-document id the envelope carries as chatId —
@@ -231,6 +275,7 @@ function bootstrapOwner({ chatId, name, username }) {
231
275
  module.exports = {
232
276
  loadAuth,
233
277
  saveAuth,
278
+ mutateAuth,
234
279
  isChatAuthorized,
235
280
  isChatOwner,
236
281
  authRequestLabel,
package/core/actions.js CHANGED
@@ -726,6 +726,48 @@ async function handleAction(envelope) {
726
726
  if (makeRule && rec.status === "approved" && wasPending) await promptRuleScope();
727
727
  return;
728
728
  }
729
+ if (d.startsWith("grn:")) {
730
+ // Standing capability grant from an approval card, with its REACH. Unlike
731
+ // the grl: rules below — free text a model judges — this writes a
732
+ // deterministic (principal × room) record, so the next equivalent request
733
+ // is cleared by lookup and never reaches the model at all.
734
+ //
735
+ // "here" is the default because the grant unit is the PAIR: trusting
736
+ // someone in one room must not trust them in every room they share with
737
+ // the bot. "everywhere" is a separate, explicit tap.
738
+ if (!isChatOwner(envelope.channelId)) return send("Owner only — grants are restricted.");
739
+ const parts = d.split(":");
740
+ const rec = require("./approvals").read(parts[1]);
741
+ const reach = parts[2]; // "room" | "all"
742
+ if (reach !== "room" && reach !== "all") return;
743
+ if (!rec) return send("That request expired — nothing to grant from it.");
744
+ if (!rec.speakerId || !rec.room) return send("I can't tell who asked apart from where they asked — no grant written.");
745
+ // A reply/triage card only ever cleared talking; an action card carries the
746
+ // tier it asked for. Never grant more than the card actually showed.
747
+ const capability = rec.kind === "action"
748
+ ? (String(rec.tier || "") === "destructive" ? "destructive" : "act")
749
+ : "read";
750
+ let record;
751
+ try {
752
+ record = require("./grants").grant({
753
+ adapter: rec.targetAdapter || rec.adapter,
754
+ senderId: rec.speakerId,
755
+ room: rec.room,
756
+ allRooms: reach === "all",
757
+ capability,
758
+ personId: rec.personId || null,
759
+ grantedBy: String(envelope.channelId),
760
+ });
761
+ } catch (e) {
762
+ return send(`Couldn't write that grant: ${e.message}`);
763
+ }
764
+ try { require("./audit").log("grant.issued", { id: record.id, principal: `${record.principal.adapter}:${record.principal.channelId}`, room: record.room, capability: record.capability, by: record.grantedBy }); } catch (e) {}
765
+ const who = rec.personName || "them";
766
+ const where = reach === "all"
767
+ ? "every room they share with me"
768
+ : (rec.channelLabel || rec.room);
769
+ return send(`✅ Granted ${who} \`${capability}\` in ${where}.\nI'll stop asking for this. Revoke: /grants revoke ${record.id}`);
770
+ }
729
771
  if (d.startsWith("grl:")) {
730
772
  // External-guardrail "Make a rule" scope buttons. Owner-only. Records the
731
773
  // chosen scope on the conversation state, then the router's handleText
package/core/approvals.js CHANGED
@@ -119,6 +119,11 @@ function create({
119
119
  payloadText = "", targetAdapter = "", targetChannel = "",
120
120
  personName = "", personId = "", entitySlug = "", inboundText = "",
121
121
  channelLabel = "",
122
+ // WHO spoke and WHERE, kept apart: a capability grant issued from this card is
123
+ // for the pair, and targetChannel is only the room. On any transport where a
124
+ // room holds many people they are different values, so recording only the
125
+ // room would hand one member's grant to everyone in it.
126
+ speakerId = "", room = "",
122
127
  } = {}) {
123
128
  fs.mkdirSync(APPROVALS_DIR, { recursive: true, mode: 0o700 });
124
129
  prune();
@@ -142,6 +147,7 @@ function create({
142
147
  personName: String(personName || ""), personId: String(personId || ""), entitySlug: String(entitySlug || ""),
143
148
  inboundText: String(inboundText || "").slice(0, 1000),
144
149
  channelLabel: String(channelLabel || "").slice(0, 120),
150
+ speakerId: String(speakerId || ""), room: String(room || ""),
145
151
  requestedAt: new Date().toISOString(),
146
152
  };
147
153
  writeRecord(rec);
package/core/config.js CHANGED
@@ -159,6 +159,7 @@ const AUTH_FILE = config.AUTH_FILE || path.join(CONFIG_DIR, "auth.json");
159
159
  const IDENTITIES_FILE = config.IDENTITIES_FILE || path.join(CONFIG_DIR, "identities.json");
160
160
  const PEOPLE_FILE = config.PEOPLE_FILE || path.join(CONFIG_DIR, "people.json");
161
161
  const INTROS_FILE = config.INTROS_FILE || path.join(CONFIG_DIR, "intros.json");
162
+ const GRANTS_FILE = config.GRANTS_FILE || path.join(CONFIG_DIR, "grants.json");
162
163
  const AUDIT_FILE = config.AUDIT_FILE || path.join(CONFIG_DIR, "audit.log");
163
164
  const STATE_FILE = path.join(CONFIG_DIR, "state.json");
164
165
  const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
@@ -381,7 +382,7 @@ module.exports = {
381
382
  ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL,
382
383
  SPEECH_SERVICE_URL, SPEECH_API_KEY, AGENTSPACE_POD_TOKEN, POCKET_TTS_VOICE, TTS_PROVIDER, VOICE_REPLY_MODE,
383
384
  SOUL_FILE, CRONS_FILE, JOBS_FILE, TASKS_DIR, VAULT_FILE, AUTH_FILE, IDENTITIES_FILE,
384
- PEOPLE_FILE, INTROS_FILE, AUDIT_FILE,
385
+ PEOPLE_FILE, INTROS_FILE, GRANTS_FILE, AUDIT_FILE,
385
386
  STATE_FILE, SESSIONS_FILE,
386
387
  TEMP_DIR, FILES_DIR,
387
388
  MAX_FILE_SIZE, MAX_VOICE_SIZE, MAX_PROCESS_TIMEOUT, COMPACT_SUMMARY_TIMEOUT,
package/core/context.js CHANGED
@@ -6,8 +6,8 @@ const { AsyncLocalStorage } = require("node:async_hooks");
6
6
 
7
7
  const chatContext = new AsyncLocalStorage();
8
8
 
9
- function runInChat({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey, inboundText, speakerName, triageApprovalId }, fn) {
10
- return chatContext.run({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey, inboundText, speakerName, triageApprovalId }, fn);
9
+ function runInChat({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup }, fn) {
10
+ return chatContext.run({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup: !!isGroup }, fn);
11
11
  }
12
12
 
13
13
  function currentStore() {
@@ -44,6 +44,15 @@ function currentTransport() {
44
44
  return s ? s.transport : null;
45
45
  }
46
46
 
47
+ // True when this turn is happening in a multi-party room rather than a DM.
48
+ // Authority is never derived from a room, so ownership checks that would
49
+ // otherwise accept a configured chat id must refuse it here: a group id in
50
+ // CHAT_IDS or auth.json would otherwise make every member an owner.
51
+ function currentIsGroup() {
52
+ const s = currentStore();
53
+ return !!(s && s.isGroup);
54
+ }
55
+
47
56
  // The per-thread conversation key for this turn (e.g. "telegram:6251055967",
48
57
  // "spaces:task:42"). Distinct from the (possibly unified) canonical speaker
49
58
  // identity: identity says WHO is talking, conversationKey says WHICH thread —
@@ -54,6 +63,19 @@ function currentConversationKey() {
54
63
  return s && s.conversationKey ? s.conversationKey : null;
55
64
  }
56
65
 
66
+ // The room this turn happens in, as `<surface>:<id>` — what owns the shared
67
+ // runtime settings (provider/model/effort) and the capability grants. For an
68
+ // inbound message the thread IS the room, so conversationKey is the default.
69
+ // A scheduled job is the case that forces them apart: it resumes its own saved
70
+ // thread (conversationKey = the job's project) while still belonging to the
71
+ // room it was created in, and must answer under that room's provider — not the
72
+ // creator's personal default.
73
+ function currentRoomKey() {
74
+ const s = currentStore();
75
+ if (!s) return null;
76
+ return s.roomKey || s.conversationKey || null;
77
+ }
78
+
57
79
  // The raw inbound text of the turn being handled, and the transport display
58
80
  // name of whoever sent it. Used by the external guardrail to show the owner
59
81
  // WHO asked and WHAT they asked on the approval card — the speaker's people
@@ -95,7 +117,9 @@ module.exports = {
95
117
  currentCanonicalUserId,
96
118
  currentUserId,
97
119
  currentTransport,
120
+ currentIsGroup,
98
121
  currentConversationKey,
122
+ currentRoomKey,
99
123
  currentRaw,
100
124
  currentInboundText,
101
125
  setCurrentInboundText,