@inetafrica/open-claudia 3.3.1 → 3.4.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 +21 -0
- package/channels/kazee/adapter.js +6 -0
- package/channels/spaces/adapter.js +11 -2
- package/channels/telegram/adapter.js +28 -9
- package/core/access.js +125 -80
- package/core/actions.js +58 -4
- package/core/approvals.js +6 -0
- package/core/config.js +2 -1
- package/core/context.js +26 -2
- package/core/enforcer.js +113 -3
- package/core/fsutil.js +42 -1
- package/core/grants.js +221 -0
- package/core/handlers.js +49 -0
- package/core/intro-flow.js +83 -17
- package/core/mandates.js +10 -1
- package/core/people.js +157 -134
- package/core/relationship.js +45 -4
- package/core/router.js +18 -7
- package/core/scheduler.js +23 -2
- package/core/state.js +77 -3
- package/package.json +2 -2
- package/setup.js +2 -5
- package/test-enforcer.js +31 -0
- package/test-mandate-hygiene.js +23 -5
- package/test-provider-gateway.js +1 -0
- package/test-provider-language.js +26 -1
- package/web.js +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.4.0 — Who you are, where you are, and what you may do there are three different questions
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
## 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
|
|
16
|
+
|
|
17
|
+
- **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.
|
|
18
|
+
- **Approving an owner onto a second channel demoted them there.** `applyApproval` hardcoded `isOwner: false` into the auth entry while `isChatOwner` reads exactly that file. The entry now mirrors `person.isOwner`, and a stale `false` entry from the bug era is upgraded in place on the next approval.
|
|
19
|
+
- **The mandate writer can no longer be steered by a display name.** `appendMandateLine`'s legacy fallback upserted by `personName` when the record carried no slug — a spoofable transport display name could land owner-authored security text on a same-named OTHER person's note (reachable from telegram-group externals, where there is no person record). Writes are now strictly slug-keyed: `entitySlug`, else the person REGISTRY (`personId` → `resolveEntitySlug`), else **refused** — never the name. The router's rule-capture path passes `personId` so per-person rules keep landing after the fallback's removal.
|
|
20
|
+
- **Stranded grants are surfaced the moment they bite.** The dual-file trap — a mandate granted on the name-obvious note (`rahil-malde.md`) while the enforcer reads the person's hashed record slug — made grants silently do nothing. Escalation cards now carry a ⚠️ hint naming the file holding the dead grant and pointing at Verify / Make-a-rule, whenever the speaker's live mandate is empty but a same-named sibling note (that no other person owns) holds one. Detection only: the stranded text is never applied and never quoted.
|
|
21
|
+
- **Router plumbing for the fall-through:** the cross-channel dedup check is memoized per envelope object, so the adopted first message isn't swallowed by `handleText`'s re-check while true redeliveries (fresh envelope, same message id) still dedup globally.
|
|
22
|
+
- Regression coverage: `test-intro-adoption.js` joins the chain (adoption unit + through-the-router integration, fail-closed attestation, approval owner-mirror and in-place repair); `test-mandate-hygiene.js` pins the name-fallback refusal and personId resolution; `test-kazee-group-gate.js` pins the dm attestation and its fail-closed absence; `test-enforcer.js` pins the stranded-mandate hint and that the stranded text never reaches the live mandate.
|
|
23
|
+
|
|
3
24
|
## v3.3.1 — Message length can no longer kill the voice: chunked pocket TTS (re-cut carrying unpublished v3.3.0)
|
|
4
25
|
|
|
5
26
|
- **Long voice notes died at 60 seconds of synthesis.** `pocketToVoice` sent the ENTIRE cleaned text to the speech gateway as one request under `AbortSignal.timeout(60000)`. A long reply blew that window, the pocket route returned null, and on pods the fallback chain has nowhere to go (no ElevenLabs key, no macOS `say`) — so `say` bounced with "no TTS route available" and agents improvised manual per-paragraph chunking (sales-bot, 2026-08-19). Long text is now sentence-packed into ≤450-char chunks (`chunkForTTS`; run-on sentences hard-split at word boundaries so nothing can recreate the timeout), each chunk synthesized in its own 120s window with one retry (a transient gateway blip no longer wastes the chunks already done), and the wavs concatenated via ffmpeg's concat demuxer into ONE ogg/opus. Short text keeps the exact single-request behavior. Benefits every consumer of `textToVoice`/`textToVoiceFile`: `say`, `tts`, and whole voice-note replies; the voice channel's streamed per-sentence path is untouched. Regression pinned in `test-speech-loopback.js`: multiple gateway calls each inside the pack limit, same voice across chunks, lossless reassembly, temp cleanup, and chunker unit coverage.
|
|
@@ -301,6 +301,12 @@ class KazeeAdapter {
|
|
|
301
301
|
}
|
|
302
302
|
envelope.engaged = true;
|
|
303
303
|
envelope.surface = "group";
|
|
304
|
+
envelope.isGroup = true;
|
|
305
|
+
} else if (meta) {
|
|
306
|
+
// Adapter-attested 1:1 conversation — required before intro-flow may
|
|
307
|
+
// auto-adopt the chat for a known sender. Set only on a positive meta
|
|
308
|
+
// read: an unresolvable chat stays unattested (fail closed).
|
|
309
|
+
envelope.surface = "dm";
|
|
304
310
|
}
|
|
305
311
|
}
|
|
306
312
|
|
|
@@ -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",
|
|
276
|
-
userId:
|
|
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
|
|
482
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
return
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
@@ -89,7 +89,21 @@ function appendMandate(rec, line) {
|
|
|
89
89
|
function appendMandateLine(rec, lineText, log, opts) {
|
|
90
90
|
const entities = require("./entities");
|
|
91
91
|
const mandates = require("./mandates");
|
|
92
|
-
|
|
92
|
+
// Strictly slug-keyed: resolve through the record's entitySlug, else through
|
|
93
|
+
// the person record by id. NEVER name-keyed — a display name is spoofable,
|
|
94
|
+
// and a name-keyed upsert can land security text on a same-named OTHER
|
|
95
|
+
// person's note (the exact escalation the hashed auto-contact slugs exist
|
|
96
|
+
// to prevent).
|
|
97
|
+
let slug = String(rec.entitySlug || "").trim();
|
|
98
|
+
if (!slug && rec.personId) {
|
|
99
|
+
try {
|
|
100
|
+
const people = require("./people");
|
|
101
|
+
const person = people.findById(String(rec.personId));
|
|
102
|
+
slug = String((person && people.resolveEntitySlug(person)) || "").trim();
|
|
103
|
+
} catch (e) { slug = ""; }
|
|
104
|
+
}
|
|
105
|
+
if (!slug) return { ok: false, reason: "no linked contact record to hold the grant" };
|
|
106
|
+
const ent = entities.readEntity(slug);
|
|
93
107
|
const name = (ent && ent.name) || rec.personName;
|
|
94
108
|
if (!name) return { ok: false, reason: "no contact record" };
|
|
95
109
|
const coreText = mandates.splitExpiry(String(lineText || "")).text;
|
|
@@ -121,14 +135,12 @@ function appendMandateLine(rec, lineText, log, opts) {
|
|
|
121
135
|
return { ok: false, reason: "their mandate is full — trim it with /guardrails" };
|
|
122
136
|
}
|
|
123
137
|
const up = entities.upsertEntity({
|
|
124
|
-
slug
|
|
138
|
+
slug,
|
|
125
139
|
name,
|
|
126
140
|
relationship: (ent && ent.relationship) || "external",
|
|
127
141
|
mandate,
|
|
128
142
|
log,
|
|
129
143
|
});
|
|
130
|
-
// id is slug-scoped, so derive it from the entity the write actually landed
|
|
131
|
-
// on (legacy name-keyed upserts can resolve to a pre-existing slug).
|
|
132
144
|
const res = { ok: true, id: mandates.lineId(up.entity.slug, coreText), expiresAt: expiresAt || null };
|
|
133
145
|
if (refreshed) res.refreshed = true;
|
|
134
146
|
return res;
|
|
@@ -714,6 +726,48 @@ async function handleAction(envelope) {
|
|
|
714
726
|
if (makeRule && rec.status === "approved" && wasPending) await promptRuleScope();
|
|
715
727
|
return;
|
|
716
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
|
+
}
|
|
717
771
|
if (d.startsWith("grl:")) {
|
|
718
772
|
// External-guardrail "Make a rule" scope buttons. Owner-only. Records the
|
|
719
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,
|