@nopeek/agent-bridge 0.6.1 → 0.6.3
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/dist/bot.d.ts +11 -0
- package/dist/bot.js +92 -0
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +1 -1
- package/package.json +2 -2
package/dist/bot.d.ts
CHANGED
|
@@ -25,6 +25,9 @@ export declare class BotRunner {
|
|
|
25
25
|
private allowed;
|
|
26
26
|
private accessLoaded;
|
|
27
27
|
private declined;
|
|
28
|
+
private ownerPresence;
|
|
29
|
+
private mutedNoOwner;
|
|
30
|
+
private runtimeOwnerId;
|
|
28
31
|
private chains;
|
|
29
32
|
private cantPost;
|
|
30
33
|
private log;
|
|
@@ -44,6 +47,14 @@ export declare class BotRunner {
|
|
|
44
47
|
refreshAccess(): Promise<void>;
|
|
45
48
|
/** Sender permitted to talk to this bot? Fail closed if access never loaded. */
|
|
46
49
|
private isAllowed;
|
|
50
|
+
/** The USER whose presence the owner-present policy requires: the bot's
|
|
51
|
+
* owner for user-owned bots, else the user who paired this runtime. */
|
|
52
|
+
private effectiveOwnerId;
|
|
53
|
+
/** Is the bot's owner currently a member of this channel? Server-checked via
|
|
54
|
+
* the channel roster (the bot is a member, so it may list members), cached
|
|
55
|
+
* for a short TTL, invalidated by member.joined/left. On a fetch failure we
|
|
56
|
+
* keep the last-known answer; with no known answer we FAIL CLOSED. */
|
|
57
|
+
private ownerIsChannelMember;
|
|
47
58
|
private run;
|
|
48
59
|
private connectOnce;
|
|
49
60
|
/** Runtime sessions expire; reconnect with a fresh one shortly before that. */
|
package/dist/bot.js
CHANGED
|
@@ -7,6 +7,9 @@ import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
|
|
|
7
7
|
import { FileStore } from "./storage.js";
|
|
8
8
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
9
|
const MAX_BACKOFF_MS = 60_000;
|
|
10
|
+
// Owner-membership answers are cached per channel for a short window; a
|
|
11
|
+
// member.joined/left frame for the channel invalidates it immediately anyway.
|
|
12
|
+
const OWNER_PRESENCE_TTL_MS = 60_000;
|
|
10
13
|
// Refresh the bot session 5 min before it expires (clamped to a sane window).
|
|
11
14
|
const REFRESH_MARGIN_MS = 5 * 60_000;
|
|
12
15
|
const MAX_REFRESH_DELAY_MS = 6 * 24 * 60 * 60_000;
|
|
@@ -29,6 +32,17 @@ export class BotRunner {
|
|
|
29
32
|
allowed = new Set();
|
|
30
33
|
accessLoaded = false;
|
|
31
34
|
declined = new Set(); // (senderId) already told "not authorized" once
|
|
35
|
+
// OWNER-PRESENT POLICY (multi-party channels): other users may interact with
|
|
36
|
+
// the bot in a group ONLY while the bot's owner is ALSO a member of that
|
|
37
|
+
// channel. Owner absent → the bot is silent there for everyone (no brain
|
|
38
|
+
// run, no decline), regardless of grants. Membership is checked against the
|
|
39
|
+
// server per channel and cached briefly; member.joined/left invalidate it.
|
|
40
|
+
ownerPresence = new Map();
|
|
41
|
+
mutedNoOwner = new Set(); // channels already logged as owner-absent
|
|
42
|
+
// For a workspace-owned bot there is no single owner USER — the accountable
|
|
43
|
+
// human is whoever paired the runtime that runs it. The access endpoint
|
|
44
|
+
// reports that as runtimeOwnerUserId; the owner-present policy uses it.
|
|
45
|
+
runtimeOwnerId;
|
|
32
46
|
// Per-channel serialization: two messages in one channel must be answered in
|
|
33
47
|
// order, one at a time — concurrent brain runs against the same agent session
|
|
34
48
|
// (e.g. one Hermes session per channel) deadlock or reply out of order.
|
|
@@ -97,6 +111,15 @@ export class BotRunner {
|
|
|
97
111
|
throw new Error(`access HTTP ${res.status}`);
|
|
98
112
|
const j = (await res.json());
|
|
99
113
|
this.allowed = new Set(j.allowedUserIds ?? []);
|
|
114
|
+
if (j.runtimeOwnerUserId)
|
|
115
|
+
this.runtimeOwnerId = j.runtimeOwnerUserId;
|
|
116
|
+
// The access response is authoritative on ownership — adopt it so the
|
|
117
|
+
// owner-present policy always knows who the owner is, even when the
|
|
118
|
+
// runtime/bots listing omitted the fields.
|
|
119
|
+
if (j.ownerId) {
|
|
120
|
+
this.info.ownerId = j.ownerId;
|
|
121
|
+
this.info.ownerType = j.ownerType ?? this.info.ownerType;
|
|
122
|
+
}
|
|
100
123
|
this.accessLoaded = true;
|
|
101
124
|
this.log(`access: policy=${j.policy ?? "private"}, ${this.allowed.size} allowed sender(s)`);
|
|
102
125
|
}
|
|
@@ -112,6 +135,41 @@ export class BotRunner {
|
|
|
112
135
|
return false; // fail closed until we know the list
|
|
113
136
|
return this.allowed.has(senderUserId);
|
|
114
137
|
}
|
|
138
|
+
/** The USER whose presence the owner-present policy requires: the bot's
|
|
139
|
+
* owner for user-owned bots, else the user who paired this runtime. */
|
|
140
|
+
effectiveOwnerId() {
|
|
141
|
+
if (this.info.ownerType === "workspace")
|
|
142
|
+
return this.runtimeOwnerId;
|
|
143
|
+
return this.info.ownerId ?? this.runtimeOwnerId;
|
|
144
|
+
}
|
|
145
|
+
/** Is the bot's owner currently a member of this channel? Server-checked via
|
|
146
|
+
* the channel roster (the bot is a member, so it may list members), cached
|
|
147
|
+
* for a short TTL, invalidated by member.joined/left. On a fetch failure we
|
|
148
|
+
* keep the last-known answer; with no known answer we FAIL CLOSED. */
|
|
149
|
+
async ownerIsChannelMember(channelId) {
|
|
150
|
+
const ownerId = this.effectiveOwnerId();
|
|
151
|
+
if (!ownerId)
|
|
152
|
+
return false; // unknown owner → fail closed
|
|
153
|
+
const cached = this.ownerPresence.get(channelId);
|
|
154
|
+
if (cached && Date.now() - cached.at < OWNER_PRESENCE_TTL_MS)
|
|
155
|
+
return cached.present;
|
|
156
|
+
try {
|
|
157
|
+
let present = false;
|
|
158
|
+
let cursor = "";
|
|
159
|
+
do {
|
|
160
|
+
const qs = `?limit=500${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
|
|
161
|
+
const page = await this.np.api("GET", `/v1/apps/${this.pairing.appId}/channels/${channelId}/members${qs}`);
|
|
162
|
+
present = (page.members ?? []).some((m) => m.userId === ownerId);
|
|
163
|
+
cursor = present ? "" : (page.nextCursor ?? "");
|
|
164
|
+
} while (!present && cursor);
|
|
165
|
+
this.ownerPresence.set(channelId, { present, at: Date.now() });
|
|
166
|
+
return present;
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
this.logErr(`owner-membership check failed for ${channelId}: ${err.message}`);
|
|
170
|
+
return cached ? cached.present : false; // stale-but-known beats guessing; else closed
|
|
171
|
+
}
|
|
172
|
+
}
|
|
115
173
|
async run() {
|
|
116
174
|
this.log(`starting (${this.info.userId}) brain=${this.brainKind}`);
|
|
117
175
|
let delay = 2_000;
|
|
@@ -149,6 +207,7 @@ export class BotRunner {
|
|
|
149
207
|
this.np = np;
|
|
150
208
|
this.connected = true;
|
|
151
209
|
this.channelCache.clear();
|
|
210
|
+
this.ownerPresence.clear();
|
|
152
211
|
this.log(`connected, device ${np.deviceId} (platform=server), store ${store.file}`);
|
|
153
212
|
np.on("connected", (() => {
|
|
154
213
|
this.connected = true;
|
|
@@ -161,6 +220,20 @@ export class BotRunner {
|
|
|
161
220
|
np.on("channelKeyReceived", ((p) => {
|
|
162
221
|
this.log(`received channel key for ${p.channelId} (welcome ceremony completed)`);
|
|
163
222
|
}));
|
|
223
|
+
// Roster changed → the cached owner-membership answer for that channel is
|
|
224
|
+
// stale. Invalidate so the very next message re-checks (owner joining
|
|
225
|
+
// un-mutes immediately; owner leaving mutes immediately).
|
|
226
|
+
const onRosterChange = ((p) => {
|
|
227
|
+
if (!p?.channelId)
|
|
228
|
+
return;
|
|
229
|
+
this.ownerPresence.delete(p.channelId);
|
|
230
|
+
if (p.userId && p.userId === this.effectiveOwnerId()) {
|
|
231
|
+
this.mutedNoOwner.delete(p.channelId); // re-log if the owner leaves again later
|
|
232
|
+
this.log(`owner membership changed in ${p.channelId} — presence cache invalidated`);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
np.on("member.joined", onRosterChange);
|
|
236
|
+
np.on("member.left", onRosterChange);
|
|
164
237
|
np.on("message", ((m) => {
|
|
165
238
|
const prev = this.chains.get(m.channelId) ?? Promise.resolve();
|
|
166
239
|
const next = prev.then(() => this.handleMessage(m).catch((err) => {
|
|
@@ -248,6 +321,25 @@ export class BotRunner {
|
|
|
248
321
|
}
|
|
249
322
|
if (m.body?.type !== "text" || typeof m.body.text !== "string" || !m.body.text.trim())
|
|
250
323
|
return;
|
|
324
|
+
// OWNER-PRESENT POLICY: in a multi-party channel (anything but a direct
|
|
325
|
+
// chat), a non-owner sender may use the bot ONLY while the bot's owner is
|
|
326
|
+
// also a member of that channel. Owner absent → completely silent (no
|
|
327
|
+
// brain run, not even the decline notice), regardless of grants. For a
|
|
328
|
+
// workspace bot "owner" means the user who paired this runtime.
|
|
329
|
+
const effOwner = this.effectiveOwnerId();
|
|
330
|
+
if (m.senderUserId !== effOwner) {
|
|
331
|
+
const ch = await this.getChannel(m.channelId);
|
|
332
|
+
if (ch.record.kind !== "direct") {
|
|
333
|
+
if (!(await this.ownerIsChannelMember(m.channelId))) {
|
|
334
|
+
if (!this.mutedNoOwner.has(m.channelId)) {
|
|
335
|
+
this.mutedNoOwner.add(m.channelId);
|
|
336
|
+
this.log(`muting ${m.channelId} — owner ${effOwner ?? "?"} is not a member (owner-present policy); staying silent`);
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
this.mutedNoOwner.delete(m.channelId);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
251
343
|
// ACCESS CONTROL: a bot is private. If the sender isn't authorized (owner,
|
|
252
344
|
// workspace member, or explicitly granted), the brain is NEVER invoked — so
|
|
253
345
|
// a stranger who found the @handle can't make the owner's agent do anything.
|
package/dist/bridge.d.ts
CHANGED
package/dist/bridge.js
CHANGED
|
@@ -17,7 +17,7 @@ import { resolveBrain } from "./brain.js";
|
|
|
17
17
|
import { provisionSoul, provisionHermesProfile } from "./backends.js";
|
|
18
18
|
import { reportCapabilities } from "./capabilities.js";
|
|
19
19
|
import { isBrainBackend } from "./config.js";
|
|
20
|
-
export const VERSION = "0.6.
|
|
20
|
+
export const VERSION = "0.6.3";
|
|
21
21
|
/** How often to re-probe + report brain availability to the server. */
|
|
22
22
|
const CAPABILITIES_INTERVAL_MS = 5 * 60_000;
|
|
23
23
|
export class PairError extends Error {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Run your own agents as E2EE NoPeek bots. Pairs with one-time codes (multiple accounts per computer), runs every bot each account owns, and pipes messages to any command or webhook.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"node": ">=22"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@nopeek/chat": "0.2.
|
|
26
|
+
"@nopeek/chat": "0.2.4"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^22.10.0",
|