@nopeek/agent-bridge 0.5.5 → 0.6.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/README.md +9 -7
- package/dist/bot.d.ts +8 -2
- package/dist/bot.js +37 -8
- package/dist/bridge.d.ts +47 -29
- package/dist/bridge.js +314 -198
- package/dist/capabilities.d.ts +7 -5
- package/dist/capabilities.js +9 -9
- package/dist/cli.js +7 -6
- package/dist/config.d.ts +24 -5
- package/dist/config.js +77 -6
- package/dist/control.d.ts +5 -3
- package/dist/control.js +18 -15
- package/dist/localapi.js +48 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ Run your own AI agents as **end-to-end-encrypted NoPeek bots** — from your Mac
|
|
|
4
4
|
|
|
5
5
|
The bridge is a small always-on process that:
|
|
6
6
|
|
|
7
|
-
1. **Pairs** with your NoPeek account — one tap in the NoPeek app ("Connect this computer"), or a one-time pairing code (`npr_…`) if you prefer the terminal.
|
|
8
|
-
2. **Runs every bot
|
|
7
|
+
1. **Pairs** with your NoPeek account — one tap in the NoPeek app ("Connect this computer"), or a one-time pairing code (`npr_…`) if you prefer the terminal. **Multi-account**: one installed bridge holds any number of pairings — each NoPeek account on this computer taps "Add this account" and its bots run alongside everyone else's.
|
|
8
|
+
2. **Runs every bot each paired account owns** — each bot connects as a real NoPeek user with its own server device, publishes MLS key packages, and decrypts messages locally like any other client. The server never sees plaintext.
|
|
9
9
|
3. **Pipes each incoming message to your "brain"** — any shell command (message on stdin, reply on stdout) or any HTTP webhook — and sends the reply back into the encrypted channel.
|
|
10
10
|
|
|
11
11
|
When you create a new bot in the NoPeek app, the bridge adopts it live over its control connection. No restart, no redeploy.
|
|
@@ -45,14 +45,14 @@ The bridge serves a loopback-only HTTP API on `127.0.0.1:8790` — this is what
|
|
|
45
45
|
|
|
46
46
|
| Endpoint | Auth | Purpose |
|
|
47
47
|
| --- | --- | --- |
|
|
48
|
-
| `GET /` | none | Minimal status: `{ok, service, version, paired, machine, uptime}` |
|
|
49
|
-
| `POST /pair` | the `npr_…` secret itself
|
|
50
|
-
| `GET /status` | `x-nopeek-runtime: rt_…` | Full status: bots, brains
|
|
48
|
+
| `GET /` | none | Minimal status: `{ok, service, version, paired, pairings, machine, uptime}` (`pairings` = account count only — never ids) |
|
|
49
|
+
| `POST /pair` | the `npr_…` secret itself (validated against the API) | `{pairingSecret, appId, apiUrl?, label?, runtimeId?}` — ADDS a pairing; only an exact duplicate (same secret) is rejected (`409 ALREADY_PAIRED`) |
|
|
50
|
+
| `GET /status` | `x-nopeek-runtime: rt_…` | Full status: `pairings: [{runtimeId, appId, label, connected, bots}]` per account, plus brains and a flattened `bots` list |
|
|
51
51
|
| `GET /detect` | `x-nopeek-runtime` | Agent runtimes found on PATH (hermes, claude, llm, ollama) with suggested commands |
|
|
52
52
|
| `PUT /brains` | `x-nopeek-runtime` | `{brainCmd?, brainUrl?, map?: {"<handle>": {cmd|url|echo}|null}}` — applies live, persists |
|
|
53
|
-
| `DELETE /pair
|
|
53
|
+
| `DELETE /pair?runtimeId=rt_…` | `x-nopeek-runtime` | Remove ONE account's pairing (its bots stop; others keep running). Without the query: remove ALL pairings (the pre-0.6 behavior) |
|
|
54
54
|
|
|
55
|
-
The `x-nopeek-runtime` header is
|
|
55
|
+
The `x-nopeek-runtime` header is a runtime id — a capability only an owner's logged-in app can fetch from the NoPeek server, so a random webpage poking `127.0.0.1` can't read your bot list or change brain commands. ANY paired account's runtime id is accepted. Everything the app configures persists to `~/.nopeek-bridge/settings.json` (mode 600); pre-0.6 single-pairing settings are migrated to the multi-account format automatically on first start.
|
|
56
56
|
|
|
57
57
|
## How the brain works
|
|
58
58
|
|
|
@@ -171,6 +171,8 @@ Example config file:
|
|
|
171
171
|
}
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
+
(The single `NOPEEK_PAIRING_CODE`/`NOPEEK_APP_ID` pair is still read everywhere for convenience; the bridge's own `settings.json` stores pairings as a `NOPEEK_PAIRINGS` array — one entry per paired account — and migrates the legacy keys automatically.)
|
|
175
|
+
|
|
174
176
|
## Health endpoint
|
|
175
177
|
|
|
176
178
|
`GET http://localhost:8790/` →
|
package/dist/bot.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BridgeConfig, BrainBackend } from "./config.js";
|
|
1
|
+
import type { BridgeConfig, Pairing, BrainBackend } from "./config.js";
|
|
2
2
|
export interface BotInfo {
|
|
3
3
|
userId: string;
|
|
4
4
|
handle: string;
|
|
@@ -16,6 +16,7 @@ export declare class BotRunner {
|
|
|
16
16
|
handled: number;
|
|
17
17
|
brainKind: string;
|
|
18
18
|
private cfg;
|
|
19
|
+
private pairing;
|
|
19
20
|
private np;
|
|
20
21
|
private stopped;
|
|
21
22
|
private refreshTimer;
|
|
@@ -24,9 +25,11 @@ export declare class BotRunner {
|
|
|
24
25
|
private allowed;
|
|
25
26
|
private accessLoaded;
|
|
26
27
|
private declined;
|
|
28
|
+
private chains;
|
|
29
|
+
private cantPost;
|
|
27
30
|
private log;
|
|
28
31
|
private logErr;
|
|
29
|
-
constructor(info: BotInfo, cfg: BridgeConfig);
|
|
32
|
+
constructor(info: BotInfo, cfg: BridgeConfig, pairing: Pairing);
|
|
30
33
|
/** Re-read the effective brain (e.g. after a live server backend change) so
|
|
31
34
|
* status reflects it immediately. The next message re-resolves regardless. */
|
|
32
35
|
refreshBrainKind(): void;
|
|
@@ -50,4 +53,7 @@ export declare class BotRunner {
|
|
|
50
53
|
/** Bounded dedupe so a redelivered frame is never answered twice. */
|
|
51
54
|
private remember;
|
|
52
55
|
private handleMessage;
|
|
56
|
+
/** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
|
|
57
|
+
* future message too — mute the channel so the brain stops running there. */
|
|
58
|
+
private notePostFailure;
|
|
53
59
|
}
|
package/dist/bot.js
CHANGED
|
@@ -16,6 +16,7 @@ export class BotRunner {
|
|
|
16
16
|
handled = 0;
|
|
17
17
|
brainKind;
|
|
18
18
|
cfg;
|
|
19
|
+
pairing;
|
|
19
20
|
np = null;
|
|
20
21
|
stopped = false;
|
|
21
22
|
refreshTimer = null;
|
|
@@ -28,11 +29,19 @@ export class BotRunner {
|
|
|
28
29
|
allowed = new Set();
|
|
29
30
|
accessLoaded = false;
|
|
30
31
|
declined = new Set(); // (senderId) already told "not authorized" once
|
|
32
|
+
// Per-channel serialization: two messages in one channel must be answered in
|
|
33
|
+
// order, one at a time — concurrent brain runs against the same agent session
|
|
34
|
+
// (e.g. one Hermes session per channel) deadlock or reply out of order.
|
|
35
|
+
chains = new Map();
|
|
36
|
+
// Channels the bot can't post to (e.g. broadcast, non-operator): after the
|
|
37
|
+
// first FORBIDDEN, skip the brain entirely — replies there can never land.
|
|
38
|
+
cantPost = new Set();
|
|
31
39
|
log;
|
|
32
40
|
logErr;
|
|
33
|
-
constructor(info, cfg) {
|
|
41
|
+
constructor(info, cfg, pairing) {
|
|
34
42
|
this.info = info;
|
|
35
|
-
this.cfg = cfg;
|
|
43
|
+
this.cfg = cfg; // global brain/data config (shared, mutated live by setBrains)
|
|
44
|
+
this.pairing = pairing; // this bot's account: apiUrl + appId + runtime secret
|
|
36
45
|
this.brainKind = resolveBrain(cfg, info.handle).kind;
|
|
37
46
|
const tag = `[bot:@${info.handle}]`;
|
|
38
47
|
this.log = (m) => console.log(`${tag} ${m}`);
|
|
@@ -64,10 +73,10 @@ export class BotRunner {
|
|
|
64
73
|
this.connected = false;
|
|
65
74
|
}
|
|
66
75
|
async mintSession() {
|
|
67
|
-
const res = await fetch(`${this.
|
|
76
|
+
const res = await fetch(`${this.pairing.apiUrl}/v1/apps/${this.pairing.appId}/bots/${this.info.userId}/runtime-session`, {
|
|
68
77
|
method: "POST",
|
|
69
78
|
headers: {
|
|
70
|
-
authorization: `Bearer ${this.
|
|
79
|
+
authorization: `Bearer ${this.pairing.pairingCode}`,
|
|
71
80
|
"content-type": "application/json",
|
|
72
81
|
},
|
|
73
82
|
});
|
|
@@ -83,7 +92,7 @@ export class BotRunner {
|
|
|
83
92
|
* rather than open. */
|
|
84
93
|
async refreshAccess() {
|
|
85
94
|
try {
|
|
86
|
-
const res = await fetch(`${this.
|
|
95
|
+
const res = await fetch(`${this.pairing.apiUrl}/v1/apps/${this.pairing.appId}/bots/${this.info.userId}/access`, { headers: { authorization: `Bearer ${this.pairing.pairingCode}` } });
|
|
87
96
|
if (!res.ok)
|
|
88
97
|
throw new Error(`access HTTP ${res.status}`);
|
|
89
98
|
const j = (await res.json());
|
|
@@ -126,9 +135,9 @@ export class BotRunner {
|
|
|
126
135
|
// what a bot wants — peers claim those packages to send it welcomes. The
|
|
127
136
|
// FileStore makes restarts reuse the same device instead of minting new ones.
|
|
128
137
|
const np = await NoPeek.connect({
|
|
129
|
-
apiUrl: this.
|
|
138
|
+
apiUrl: this.pairing.apiUrl,
|
|
130
139
|
sessionToken: session.sessionToken,
|
|
131
|
-
appId: this.
|
|
140
|
+
appId: this.pairing.appId,
|
|
132
141
|
userId: this.info.userId,
|
|
133
142
|
platform: "server",
|
|
134
143
|
storage: store,
|
|
@@ -153,8 +162,15 @@ export class BotRunner {
|
|
|
153
162
|
this.log(`received channel key for ${p.channelId} (welcome ceremony completed)`);
|
|
154
163
|
}));
|
|
155
164
|
np.on("message", ((m) => {
|
|
156
|
-
|
|
165
|
+
const prev = this.chains.get(m.channelId) ?? Promise.resolve();
|
|
166
|
+
const next = prev.then(() => this.handleMessage(m).catch((err) => {
|
|
167
|
+
this.notePostFailure(m.channelId, err);
|
|
157
168
|
this.logErr(`handler error for ${m.messageId}: ${err.message}`);
|
|
169
|
+
}));
|
|
170
|
+
this.chains.set(m.channelId, next);
|
|
171
|
+
void next.finally(() => {
|
|
172
|
+
if (this.chains.get(m.channelId) === next)
|
|
173
|
+
this.chains.delete(m.channelId);
|
|
158
174
|
});
|
|
159
175
|
}));
|
|
160
176
|
await this.refreshAccess(); // load the allow list before we answer anyone
|
|
@@ -216,6 +232,8 @@ export class BotRunner {
|
|
|
216
232
|
this.remember(m.messageId);
|
|
217
233
|
if (m.senderUserId === this.info.userId)
|
|
218
234
|
return; // never answer ourselves
|
|
235
|
+
if (this.cantPost.has(m.channelId))
|
|
236
|
+
return; // replies can't land here — don't burn a brain run
|
|
219
237
|
if (m.decryptionFailed) {
|
|
220
238
|
// Likely a missed welcome (message arrived before our key). Sync the key
|
|
221
239
|
// so the NEXT message decrypts; this frame's plaintext is unrecoverable.
|
|
@@ -278,6 +296,7 @@ export class BotRunner {
|
|
|
278
296
|
}
|
|
279
297
|
streamRef.p = ch.stream();
|
|
280
298
|
streamRef.p.catch((err) => {
|
|
299
|
+
this.notePostFailure(m.channelId, err);
|
|
281
300
|
this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
|
|
282
301
|
});
|
|
283
302
|
}
|
|
@@ -323,4 +342,14 @@ export class BotRunner {
|
|
|
323
342
|
this.handled++;
|
|
324
343
|
this.log(`${m.channelId} -> replied (${reply.trim().length} chars, handled=${this.handled})`);
|
|
325
344
|
}
|
|
345
|
+
/** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
|
|
346
|
+
* future message too — mute the channel so the brain stops running there. */
|
|
347
|
+
notePostFailure(channelId, err) {
|
|
348
|
+
if (!/only operators can post|FORBIDDEN/i.test(err.message))
|
|
349
|
+
return;
|
|
350
|
+
if (this.cantPost.has(channelId))
|
|
351
|
+
return;
|
|
352
|
+
this.cantPost.add(channelId);
|
|
353
|
+
this.log(`muting ${channelId} — bot cannot post here (${err.message.slice(0, 80)})`);
|
|
354
|
+
}
|
|
326
355
|
}
|
package/dist/bridge.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import type { BridgeConfig, BrainSpec } from "./config.js";
|
|
2
|
-
export declare const VERSION = "0.
|
|
1
|
+
import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
|
|
2
|
+
export declare const VERSION = "0.6.0";
|
|
3
3
|
export interface PairRequest {
|
|
4
4
|
pairingSecret: string;
|
|
5
5
|
appId: string;
|
|
6
6
|
apiUrl?: string;
|
|
7
|
+
/** Human label for this pairing (shown in status; e.g. the account name). */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** The runtime id the app just minted alongside the secret. Stored so the
|
|
10
|
+
* local API can authorize this pairing's calls before the control socket
|
|
11
|
+
* has authenticated (auth.ok remains authoritative and overwrites it). */
|
|
12
|
+
runtimeId?: string;
|
|
7
13
|
}
|
|
8
14
|
export interface BrainsPatch {
|
|
9
15
|
/** Global command brain; null clears it. */
|
|
@@ -20,29 +26,50 @@ export declare class PairError extends Error {
|
|
|
20
26
|
readonly code: "ALREADY_PAIRED" | "PAIR_REJECTED" | "PAIR_UNREACHABLE" | "BAD_REQUEST";
|
|
21
27
|
constructor(code: "ALREADY_PAIRED" | "PAIR_REJECTED" | "PAIR_UNREACHABLE" | "BAD_REQUEST", message: string);
|
|
22
28
|
}
|
|
29
|
+
/** One paired account's status as reported over the local API. */
|
|
30
|
+
export interface PairingStatus {
|
|
31
|
+
runtimeId: string | null;
|
|
32
|
+
appId: string;
|
|
33
|
+
label: string | null;
|
|
34
|
+
connected: boolean;
|
|
35
|
+
bots: Array<Record<string, unknown>>;
|
|
36
|
+
}
|
|
23
37
|
export declare class BridgeApp {
|
|
24
38
|
readonly cfg: BridgeConfig;
|
|
25
39
|
readonly startedAt: number;
|
|
26
|
-
private
|
|
27
|
-
private control;
|
|
40
|
+
private runtimes;
|
|
28
41
|
private capabilitiesTimer;
|
|
29
42
|
private stopped;
|
|
30
43
|
constructor(cfg: BridgeConfig);
|
|
31
44
|
get paired(): boolean;
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
/** Every runtime id this bridge answers for (persisted or live-auth'd).
|
|
46
|
+
* The local API accepts ANY of these as the x-nopeek-runtime capability. */
|
|
47
|
+
runtimeIds(): string[];
|
|
35
48
|
start(): void;
|
|
36
49
|
stop(): void;
|
|
50
|
+
private startRuntime;
|
|
51
|
+
/** Re-probe + report brain availability every ~5 min (once per pairing) so a
|
|
52
|
+
* login/logout on this computer surfaces in each account's picker without a
|
|
53
|
+
* reconnect. onAuthed covers initial + reconnect reports; this covers drift.
|
|
54
|
+
* unref so a running interval never keeps the process alive on its own. */
|
|
55
|
+
private ensureCapabilitiesTimer;
|
|
56
|
+
/**
|
|
57
|
+
* Pair ANOTHER NoPeek account onto this bridge (or the first one — same
|
|
58
|
+
* flow). Every request is validated against the API before anything is
|
|
59
|
+
* persisted: the npr_ secret is server-minted and unguessable, so possession
|
|
60
|
+
* of a VALID one is the proof. Only an exact duplicate (same secret, i.e.
|
|
61
|
+
* literally the same pairing) is rejected.
|
|
62
|
+
*/
|
|
63
|
+
pair(req: PairRequest): Promise<Pairing>;
|
|
37
64
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
65
|
+
* Undo pairing (device keys stay either way).
|
|
66
|
+
* unpair() — legacy no-arg: remove EVERY pairing (pre-0.6 shape).
|
|
67
|
+
* unpair(runtimeId) — remove just that account's pairing; others keep running.
|
|
68
|
+
* Returns false when a runtimeId was given but matches no pairing.
|
|
41
69
|
*/
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
/** Apply a brain change live (next message uses it) and persist it. */
|
|
70
|
+
unpair(runtimeId?: string): boolean;
|
|
71
|
+
/** Apply a brain change live (next message uses it) and persist it.
|
|
72
|
+
* Brains are GLOBAL per machine — handles are globally unique. */
|
|
46
73
|
setBrains(patch: BrainsPatch): void;
|
|
47
74
|
/**
|
|
48
75
|
* Record a SERVER-provided backend for a handle and make it take effect.
|
|
@@ -51,21 +78,12 @@ export declare class BridgeApp {
|
|
|
51
78
|
* either way but is inert while a local override exists. When it IS the
|
|
52
79
|
* effective brain (no local override), provision the soul/profile up front so
|
|
53
80
|
* the very first message doesn't wait on it. Persisted for reconnects.
|
|
81
|
+
* (Internal — called by each PairingRuntime; the map is machine-global.)
|
|
54
82
|
*/
|
|
55
|
-
|
|
83
|
+
applyServerBackend(rawHandle: string, backend: BrainBackend): void;
|
|
56
84
|
statusMinimal(): Record<string, unknown>;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* Auto-provision a brain for a newly adopted bot: run BRAIN_PROVISION_CMD
|
|
62
|
-
* (e.g. "create a Hermes profile with its own soul + memory for this handle")
|
|
63
|
-
* and store its stdout as the bot's brain command. Best-effort — on any
|
|
64
|
-
* failure the bot simply keeps the default brain.
|
|
65
|
-
*/
|
|
66
|
-
private provisionBrain;
|
|
67
|
-
/** Fetch the authoritative bot list and start anything we're missing. */
|
|
68
|
-
private syncBots;
|
|
69
|
-
private startCore;
|
|
70
|
-
private stopCore;
|
|
85
|
+
/** Full status (authenticated). `callerRuntimeId` — when known — keeps the
|
|
86
|
+
* legacy top-level `runtime`/`appId` fields pointing at the CALLER's own
|
|
87
|
+
* pairing so pre-0.6 clients keep working unchanged. */
|
|
88
|
+
statusFull(callerRuntimeId?: string): Record<string, unknown>;
|
|
71
89
|
}
|