@nopeek/agent-bridge 0.6.0 → 0.6.2

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 CHANGED
@@ -25,6 +25,8 @@ export declare class BotRunner {
25
25
  private allowed;
26
26
  private accessLoaded;
27
27
  private declined;
28
+ private ownerPresence;
29
+ private mutedNoOwner;
28
30
  private chains;
29
31
  private cantPost;
30
32
  private log;
@@ -44,6 +46,11 @@ export declare class BotRunner {
44
46
  refreshAccess(): Promise<void>;
45
47
  /** Sender permitted to talk to this bot? Fail closed if access never loaded. */
46
48
  private isAllowed;
49
+ /** Is the bot's owner currently a member of this channel? Server-checked via
50
+ * the channel roster (the bot is a member, so it may list members), cached
51
+ * for a short TTL, invalidated by member.joined/left. On a fetch failure we
52
+ * keep the last-known answer; with no known answer we FAIL CLOSED. */
53
+ private ownerIsChannelMember;
47
54
  private run;
48
55
  private connectOnce;
49
56
  /** 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,13 @@ 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
32
42
  // Per-channel serialization: two messages in one channel must be answered in
33
43
  // order, one at a time — concurrent brain runs against the same agent session
34
44
  // (e.g. one Hermes session per channel) deadlock or reply out of order.
@@ -97,6 +107,13 @@ export class BotRunner {
97
107
  throw new Error(`access HTTP ${res.status}`);
98
108
  const j = (await res.json());
99
109
  this.allowed = new Set(j.allowedUserIds ?? []);
110
+ // The access response is authoritative on ownership — adopt it so the
111
+ // owner-present policy always knows who the owner is, even when the
112
+ // runtime/bots listing omitted the fields.
113
+ if (j.ownerId) {
114
+ this.info.ownerId = j.ownerId;
115
+ this.info.ownerType = j.ownerType ?? this.info.ownerType;
116
+ }
100
117
  this.accessLoaded = true;
101
118
  this.log(`access: policy=${j.policy ?? "private"}, ${this.allowed.size} allowed sender(s)`);
102
119
  }
@@ -112,6 +129,34 @@ export class BotRunner {
112
129
  return false; // fail closed until we know the list
113
130
  return this.allowed.has(senderUserId);
114
131
  }
132
+ /** Is the bot's owner currently a member of this channel? Server-checked via
133
+ * the channel roster (the bot is a member, so it may list members), cached
134
+ * for a short TTL, invalidated by member.joined/left. On a fetch failure we
135
+ * keep the last-known answer; with no known answer we FAIL CLOSED. */
136
+ async ownerIsChannelMember(channelId) {
137
+ const ownerId = this.info.ownerId;
138
+ if (!ownerId)
139
+ return false; // unknown owner → fail closed
140
+ const cached = this.ownerPresence.get(channelId);
141
+ if (cached && Date.now() - cached.at < OWNER_PRESENCE_TTL_MS)
142
+ return cached.present;
143
+ try {
144
+ let present = false;
145
+ let cursor = "";
146
+ do {
147
+ const qs = `?limit=500${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
148
+ const page = await this.np.api("GET", `/v1/apps/${this.pairing.appId}/channels/${channelId}/members${qs}`);
149
+ present = (page.members ?? []).some((m) => m.userId === ownerId);
150
+ cursor = present ? "" : (page.nextCursor ?? "");
151
+ } while (!present && cursor);
152
+ this.ownerPresence.set(channelId, { present, at: Date.now() });
153
+ return present;
154
+ }
155
+ catch (err) {
156
+ this.logErr(`owner-membership check failed for ${channelId}: ${err.message}`);
157
+ return cached ? cached.present : false; // stale-but-known beats guessing; else closed
158
+ }
159
+ }
115
160
  async run() {
116
161
  this.log(`starting (${this.info.userId}) brain=${this.brainKind}`);
117
162
  let delay = 2_000;
@@ -149,6 +194,7 @@ export class BotRunner {
149
194
  this.np = np;
150
195
  this.connected = true;
151
196
  this.channelCache.clear();
197
+ this.ownerPresence.clear();
152
198
  this.log(`connected, device ${np.deviceId} (platform=server), store ${store.file}`);
153
199
  np.on("connected", (() => {
154
200
  this.connected = true;
@@ -161,6 +207,20 @@ export class BotRunner {
161
207
  np.on("channelKeyReceived", ((p) => {
162
208
  this.log(`received channel key for ${p.channelId} (welcome ceremony completed)`);
163
209
  }));
210
+ // Roster changed → the cached owner-membership answer for that channel is
211
+ // stale. Invalidate so the very next message re-checks (owner joining
212
+ // un-mutes immediately; owner leaving mutes immediately).
213
+ const onRosterChange = ((p) => {
214
+ if (!p?.channelId)
215
+ return;
216
+ this.ownerPresence.delete(p.channelId);
217
+ if (p.userId && p.userId === this.info.ownerId) {
218
+ this.mutedNoOwner.delete(p.channelId); // re-log if the owner leaves again later
219
+ this.log(`owner membership changed in ${p.channelId} — presence cache invalidated`);
220
+ }
221
+ });
222
+ np.on("member.joined", onRosterChange);
223
+ np.on("member.left", onRosterChange);
164
224
  np.on("message", ((m) => {
165
225
  const prev = this.chains.get(m.channelId) ?? Promise.resolve();
166
226
  const next = prev.then(() => this.handleMessage(m).catch((err) => {
@@ -248,6 +308,24 @@ export class BotRunner {
248
308
  }
249
309
  if (m.body?.type !== "text" || typeof m.body.text !== "string" || !m.body.text.trim())
250
310
  return;
311
+ // OWNER-PRESENT POLICY: in a multi-party channel (anything but a direct
312
+ // chat), a non-owner sender may use the bot ONLY while the bot's owner is
313
+ // also a member of that channel. Owner absent → completely silent (no
314
+ // brain run, not even the decline notice), regardless of grants. Workspace
315
+ // bots are exempt — they have no single owner user to require present.
316
+ if (m.senderUserId !== this.info.ownerId && this.info.ownerType !== "workspace") {
317
+ const ch = await this.getChannel(m.channelId);
318
+ if (ch.record.kind !== "direct") {
319
+ if (!(await this.ownerIsChannelMember(m.channelId))) {
320
+ if (!this.mutedNoOwner.has(m.channelId)) {
321
+ this.mutedNoOwner.add(m.channelId);
322
+ this.log(`muting ${m.channelId} — owner ${this.info.ownerId ?? "?"} is not a member (owner-present policy); staying silent`);
323
+ }
324
+ return;
325
+ }
326
+ this.mutedNoOwner.delete(m.channelId);
327
+ }
328
+ }
251
329
  // ACCESS CONTROL: a bot is private. If the sender isn't authorized (owner,
252
330
  // workspace member, or explicitly granted), the brain is NEVER invoked — so
253
331
  // a stranger who found the @handle can't make the owner's agent do anything.
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
2
- export declare const VERSION = "0.6.0";
2
+ export declare const VERSION = "0.6.2";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
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.0";
20
+ export const VERSION = "0.6.2";
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.0",
3
+ "version": "0.6.2",
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.1"
26
+ "@nopeek/chat": "0.2.4"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.10.0",