@nopeek/agent-bridge 0.5.6 → 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 +3 -2
- package/dist/bot.js +9 -7
- 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/dist/cli.js
CHANGED
|
@@ -58,17 +58,18 @@ if (sub === "install") {
|
|
|
58
58
|
process.exit(1);
|
|
59
59
|
}
|
|
60
60
|
// Optional: `install --pair npr_… --app-id app_…` pre-pairs the fresh service
|
|
61
|
-
// through its local API (same path the app uses).
|
|
62
|
-
|
|
61
|
+
// through its local API (same path the app uses). Multi-account: every
|
|
62
|
+
// pairing known to THIS invocation is offered; the bridge skips duplicates.
|
|
63
|
+
for (const pairing of cfg.pairings) {
|
|
63
64
|
try {
|
|
64
65
|
const res = await fetch(`http://127.0.0.1:${cfg.port}/pair`, {
|
|
65
66
|
method: "POST",
|
|
66
67
|
headers: { "content-type": "application/json" },
|
|
67
|
-
body: JSON.stringify({ pairingSecret:
|
|
68
|
+
body: JSON.stringify({ pairingSecret: pairing.pairingCode, appId: pairing.appId, apiUrl: pairing.apiUrl }),
|
|
68
69
|
});
|
|
69
70
|
const body = (await res.json().catch(() => ({})));
|
|
70
|
-
if (res.ok)
|
|
71
|
-
console.log(`[install] paired
|
|
71
|
+
if (res.ok || body.error === "ALREADY_PAIRED")
|
|
72
|
+
console.log(`[install] paired with app ${pairing.appId}`);
|
|
72
73
|
else
|
|
73
74
|
console.error(`[install] pairing failed: ${body.message ?? `HTTP ${res.status}`}`);
|
|
74
75
|
}
|
|
@@ -80,7 +81,7 @@ if (sub === "install") {
|
|
|
80
81
|
}
|
|
81
82
|
// ---------------------------------------------------------------- run -------
|
|
82
83
|
console.log(`[bridge] NoPeek agent-bridge v${VERSION} starting`);
|
|
83
|
-
console.log(`[bridge] api=${cfg.apiUrl}
|
|
84
|
+
console.log(`[bridge] api=${cfg.apiUrl} accounts=${cfg.pairings.length ? cfg.pairings.map((p) => p.label ?? p.appId).join(", ") : "(not paired)"} data=${cfg.dataDir} local-api=:${cfg.port} home=${cfg.homeDir}`);
|
|
84
85
|
console.log(`[bridge] default brain: ${cfg.brainCmd ? "cmd" : cfg.brainUrl ? "url" : "echo (choose an agent in the NoPeek app, or set --brain-cmd)"}` +
|
|
85
86
|
(Object.keys(cfg.brainMap).length ? ` + ${Object.keys(cfg.brainMap).length} per-bot override(s)` : ""));
|
|
86
87
|
const app = new BridgeApp(cfg);
|
package/dist/config.d.ts
CHANGED
|
@@ -17,13 +17,32 @@ export interface BrainSpec {
|
|
|
17
17
|
auto?: boolean;
|
|
18
18
|
}
|
|
19
19
|
export declare function isBrainBackend(v: unknown): v is BrainBackend;
|
|
20
|
+
/**
|
|
21
|
+
* One paired NoPeek account. A bridge holds MANY of these — one per account
|
|
22
|
+
* that connected this computer — and runs every pairing's bot fleet at once.
|
|
23
|
+
* `pairingCode` is the per-account runtime secret (npr_…), so it is what makes
|
|
24
|
+
* a pairing unique; `appId` is the white-label APP id and is shared by every
|
|
25
|
+
* account of the same app.
|
|
26
|
+
*/
|
|
27
|
+
export interface Pairing {
|
|
28
|
+
appId: string;
|
|
29
|
+
/** Runtime pairing token (npr_…) — unique per pairing. */
|
|
30
|
+
pairingCode: string;
|
|
31
|
+
/** API base this pairing talks to (accounts on different white-label servers
|
|
32
|
+
* can coexist on one bridge). */
|
|
33
|
+
apiUrl: string;
|
|
34
|
+
/** Human label from the app ("Nabil's MacBook", account name, …). */
|
|
35
|
+
label?: string;
|
|
36
|
+
/** Runtime id (rt_…) — supplied by the app at pair time and/or learned from
|
|
37
|
+
* the control socket's auth.ok. Used to authorize local-API calls and to
|
|
38
|
+
* target DELETE /pair?runtimeId=…. */
|
|
39
|
+
runtimeId?: string;
|
|
40
|
+
}
|
|
20
41
|
export interface BridgeConfig {
|
|
21
|
-
/** NoPeek API base, e.g. https://d3qweh72vesa98.cloudfront.net */
|
|
42
|
+
/** Default NoPeek API base, e.g. https://d3qweh72vesa98.cloudfront.net */
|
|
22
43
|
apiUrl: string;
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
/** Runtime pairing token (npr_…). Null until paired. */
|
|
26
|
-
pairingCode: string | null;
|
|
44
|
+
/** All paired accounts. Empty until the app pairs (or --pair/--app-id). */
|
|
45
|
+
pairings: Pairing[];
|
|
27
46
|
/** Global brain: shell command reading the message on stdin, printing the reply. */
|
|
28
47
|
brainCmd: string | null;
|
|
29
48
|
/** Global brain: webhook POSTed {text, botHandle, botUserId, channelId, senderUserId}. */
|
package/dist/config.js
CHANGED
|
@@ -162,6 +162,44 @@ function parseServerBackends(raw) {
|
|
|
162
162
|
}
|
|
163
163
|
return out;
|
|
164
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Parse the persisted pairings array (NOPEEK_PAIRINGS). Lenient like
|
|
167
|
+
* parseServerBackends: a corrupt value never crashes the bridge — bad entries
|
|
168
|
+
* are dropped, duplicates (same pairingCode) collapse to the first one.
|
|
169
|
+
*/
|
|
170
|
+
function parsePairings(raw, fallbackApiUrl) {
|
|
171
|
+
if (!raw)
|
|
172
|
+
return [];
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(raw);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
if (!Array.isArray(parsed))
|
|
181
|
+
return [];
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const entry of parsed) {
|
|
184
|
+
if (typeof entry !== "object" || entry === null)
|
|
185
|
+
continue;
|
|
186
|
+
const { appId, pairingCode, apiUrl, label, runtimeId } = entry;
|
|
187
|
+
if (typeof appId !== "string" || !appId.trim())
|
|
188
|
+
continue;
|
|
189
|
+
if (typeof pairingCode !== "string" || !pairingCode.trim())
|
|
190
|
+
continue;
|
|
191
|
+
if (out.some((p) => p.pairingCode === pairingCode))
|
|
192
|
+
continue;
|
|
193
|
+
out.push({
|
|
194
|
+
appId: appId.trim(),
|
|
195
|
+
pairingCode: pairingCode.trim(),
|
|
196
|
+
apiUrl: (typeof apiUrl === "string" && apiUrl.trim() ? apiUrl.trim() : fallbackApiUrl).replace(/\/+$/, ""),
|
|
197
|
+
...(typeof label === "string" && label.trim() ? { label: label.trim() } : {}),
|
|
198
|
+
...(typeof runtimeId === "string" && runtimeId.trim() ? { runtimeId: runtimeId.trim() } : {}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
165
203
|
function settingsPath(homeDir) {
|
|
166
204
|
return join(homeDir, "settings.json");
|
|
167
205
|
}
|
|
@@ -189,6 +227,12 @@ export function loadConfig(argv = process.argv.slice(2)) {
|
|
|
189
227
|
saved[key];
|
|
190
228
|
return v === undefined || v === "" ? undefined : v;
|
|
191
229
|
};
|
|
230
|
+
const apiUrl = (get("api-url") ?? DEFAULT_API_URL).replace(/\/+$/, "");
|
|
231
|
+
// New format first: an explicit pairings array (env/config/persisted).
|
|
232
|
+
const pairings = parsePairings(process.env.NOPEEK_PAIRINGS ?? file.NOPEEK_PAIRINGS ?? saved.NOPEEK_PAIRINGS, apiUrl);
|
|
233
|
+
// LEGACY single pairing (pre-0.6): --pair/--app-id flags, env vars, or the
|
|
234
|
+
// old NOPEEK_PAIRING_CODE/NOPEEK_APP_ID keys in settings.json. Still read,
|
|
235
|
+
// migrated into the array; saveSettings writes ONLY the new format.
|
|
192
236
|
const pairingCode = get("pair") ?? null;
|
|
193
237
|
if (pairingCode && !pairingCode.startsWith("npr_")) {
|
|
194
238
|
console.warn(`[config] pairing code does not start with "npr_" — double-check you pasted the runtime pairing code`);
|
|
@@ -197,6 +241,20 @@ export function loadConfig(argv = process.argv.slice(2)) {
|
|
|
197
241
|
if (pairingCode && !appId) {
|
|
198
242
|
throw new Error(`--pair was given without --app-id (or NOPEEK_APP_ID) — both are needed to connect.`);
|
|
199
243
|
}
|
|
244
|
+
if (pairingCode && appId && !pairings.some((p) => p.pairingCode === pairingCode)) {
|
|
245
|
+
pairings.push({ appId, pairingCode, apiUrl });
|
|
246
|
+
}
|
|
247
|
+
// The flag/env value wins in get(); if the SAVED file also holds a (different)
|
|
248
|
+
// legacy pairing, merge it too so migration never drops an active account.
|
|
249
|
+
const savedLegacyCode = saved.NOPEEK_PAIRING_CODE;
|
|
250
|
+
const savedLegacyApp = saved.NOPEEK_APP_ID;
|
|
251
|
+
if (savedLegacyCode && savedLegacyApp && !pairings.some((p) => p.pairingCode === savedLegacyCode)) {
|
|
252
|
+
pairings.push({
|
|
253
|
+
appId: savedLegacyApp,
|
|
254
|
+
pairingCode: savedLegacyCode,
|
|
255
|
+
apiUrl: (saved.NOPEEK_API_URL ?? apiUrl).replace(/\/+$/, ""),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
200
258
|
const brainTimeoutMs = Number(get("brain-timeout-ms") ?? DEFAULT_BRAIN_TIMEOUT_MS);
|
|
201
259
|
if (!Number.isFinite(brainTimeoutMs) || brainTimeoutMs <= 0) {
|
|
202
260
|
throw new Error(`BRAIN_TIMEOUT_MS must be a positive number of milliseconds`);
|
|
@@ -213,10 +271,9 @@ export function loadConfig(argv = process.argv.slice(2)) {
|
|
|
213
271
|
: existsSync(resolve(process.cwd(), "data"))
|
|
214
272
|
? resolve(process.cwd(), "data")
|
|
215
273
|
: join(homeDir, "data");
|
|
216
|
-
|
|
217
|
-
apiUrl
|
|
218
|
-
|
|
219
|
-
pairingCode,
|
|
274
|
+
const cfg = {
|
|
275
|
+
apiUrl,
|
|
276
|
+
pairings,
|
|
220
277
|
brainCmd: get("brain-cmd") ?? null,
|
|
221
278
|
brainUrl: get("brain-url") ?? null,
|
|
222
279
|
brainMap: parseBrainMap(get("brain-map")),
|
|
@@ -230,6 +287,19 @@ export function loadConfig(argv = process.argv.slice(2)) {
|
|
|
230
287
|
declineMessage: get("decline-message") ?? null,
|
|
231
288
|
appUrl: get("app-url") ?? DEFAULT_APP_URL,
|
|
232
289
|
};
|
|
290
|
+
// One-time migration: if settings.json still holds the legacy single-pairing
|
|
291
|
+
// keys, rewrite it in the new NOPEEK_PAIRINGS format right away (idempotent,
|
|
292
|
+
// atomic). Flag/env-only pairings stay ephemeral, exactly as before 0.6.
|
|
293
|
+
if (savedLegacyCode && savedLegacyApp) {
|
|
294
|
+
try {
|
|
295
|
+
saveSettings(cfg);
|
|
296
|
+
console.log(`[config] migrated legacy single pairing to the multi-account format (${settingsPath(homeDir)})`);
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
console.warn(`[config] could not rewrite settings in the new format: ${err.message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return cfg;
|
|
233
303
|
}
|
|
234
304
|
/**
|
|
235
305
|
* Persist the app-manageable parts of the config (pairing + brains) to
|
|
@@ -240,8 +310,9 @@ export function saveSettings(cfg) {
|
|
|
240
310
|
mkdirSync(cfg.homeDir, { recursive: true });
|
|
241
311
|
const out = {
|
|
242
312
|
NOPEEK_API_URL: cfg.apiUrl,
|
|
243
|
-
|
|
244
|
-
|
|
313
|
+
// New multi-account format only — the legacy NOPEEK_APP_ID /
|
|
314
|
+
// NOPEEK_PAIRING_CODE keys are read on load but never written back.
|
|
315
|
+
...(cfg.pairings.length ? { NOPEEK_PAIRINGS: JSON.stringify(cfg.pairings) } : {}),
|
|
245
316
|
...(cfg.brainCmd ? { BRAIN_CMD: cfg.brainCmd } : {}),
|
|
246
317
|
...(cfg.brainUrl ? { BRAIN_URL: cfg.brainUrl } : {}),
|
|
247
318
|
...(Object.keys(cfg.brainMap).length ? { BRAIN_MAP: JSON.stringify(cfg.brainMap) } : {}),
|
package/dist/control.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Pairing, BrainBackend } from "./config.js";
|
|
2
2
|
export interface AdoptBotFrame {
|
|
3
3
|
type: "adopt_bot";
|
|
4
4
|
botUserId: string;
|
|
@@ -31,12 +31,14 @@ export interface ControlHandlers {
|
|
|
31
31
|
export declare class ControlSocket {
|
|
32
32
|
connected: boolean;
|
|
33
33
|
runtimeId: string | null;
|
|
34
|
-
private
|
|
34
|
+
private pairing;
|
|
35
35
|
private handlers;
|
|
36
36
|
private ws;
|
|
37
37
|
private stopped;
|
|
38
38
|
private delay;
|
|
39
|
-
|
|
39
|
+
/** Log tag — with several sockets running, lines must say whose they are. */
|
|
40
|
+
private tag;
|
|
41
|
+
constructor(pairing: Pairing, handlers: ControlHandlers);
|
|
40
42
|
start(): void;
|
|
41
43
|
stop(): void;
|
|
42
44
|
private connect;
|
package/dist/control.js
CHANGED
|
@@ -2,14 +2,17 @@ import { isBrainBackend } from "./config.js";
|
|
|
2
2
|
export class ControlSocket {
|
|
3
3
|
connected = false;
|
|
4
4
|
runtimeId = null;
|
|
5
|
-
|
|
5
|
+
pairing;
|
|
6
6
|
handlers;
|
|
7
7
|
ws = null;
|
|
8
8
|
stopped = false;
|
|
9
9
|
delay = 2_000;
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
/** Log tag — with several sockets running, lines must say whose they are. */
|
|
11
|
+
tag;
|
|
12
|
+
constructor(pairing, handlers) {
|
|
13
|
+
this.pairing = pairing;
|
|
12
14
|
this.handlers = handlers;
|
|
15
|
+
this.tag = `[control:${pairing.label ?? pairing.runtimeId ?? pairing.appId}]`;
|
|
13
16
|
}
|
|
14
17
|
start() {
|
|
15
18
|
this.connect();
|
|
@@ -28,19 +31,19 @@ export class ControlSocket {
|
|
|
28
31
|
connect() {
|
|
29
32
|
if (this.stopped)
|
|
30
33
|
return;
|
|
31
|
-
const url = `${this.
|
|
34
|
+
const url = `${this.pairing.apiUrl.replace(/^http/, "ws")}/v1/ws?runtimeToken=${encodeURIComponent(this.pairing.pairingCode)}`;
|
|
32
35
|
let ws;
|
|
33
36
|
try {
|
|
34
37
|
ws = new WebSocket(url);
|
|
35
38
|
}
|
|
36
39
|
catch (err) {
|
|
37
|
-
console.error(
|
|
40
|
+
console.error(`${this.tag} could not open socket: ${err.message}`);
|
|
38
41
|
this.scheduleReconnect();
|
|
39
42
|
return;
|
|
40
43
|
}
|
|
41
44
|
this.ws = ws;
|
|
42
45
|
ws.onopen = () => {
|
|
43
|
-
console.log(
|
|
46
|
+
console.log(`${this.tag} socket open, awaiting auth.ok`);
|
|
44
47
|
};
|
|
45
48
|
ws.onmessage = (ev) => {
|
|
46
49
|
let frame;
|
|
@@ -48,7 +51,7 @@ export class ControlSocket {
|
|
|
48
51
|
frame = JSON.parse(String(ev.data));
|
|
49
52
|
}
|
|
50
53
|
catch {
|
|
51
|
-
console.error(
|
|
54
|
+
console.error(`${this.tag} non-JSON frame ignored: ${String(ev.data).slice(0, 200)}`);
|
|
52
55
|
return;
|
|
53
56
|
}
|
|
54
57
|
this.handleFrame(frame);
|
|
@@ -61,7 +64,7 @@ export class ControlSocket {
|
|
|
61
64
|
this.connected = false;
|
|
62
65
|
if (this.stopped)
|
|
63
66
|
return;
|
|
64
|
-
console.error(
|
|
67
|
+
console.error(`${this.tag} socket closed (code=${ev.code}${ev.reason ? `, reason=${ev.reason}` : ""})${was ? "" : " before auth.ok — is the pairing code valid?"}`);
|
|
65
68
|
this.scheduleReconnect();
|
|
66
69
|
};
|
|
67
70
|
}
|
|
@@ -71,23 +74,23 @@ export class ControlSocket {
|
|
|
71
74
|
this.connected = true;
|
|
72
75
|
this.delay = 2_000; // reset backoff on a good auth
|
|
73
76
|
this.runtimeId = String(frame.runtimeId ?? "");
|
|
74
|
-
console.log(
|
|
77
|
+
console.log(`${this.tag} authenticated as runtime ${this.runtimeId} (control=${String(frame.control)})`);
|
|
75
78
|
this.handlers.onAuthed(this.runtimeId);
|
|
76
79
|
return;
|
|
77
80
|
}
|
|
78
81
|
case "adopt_bot": {
|
|
79
82
|
const f = frame;
|
|
80
|
-
console.log(
|
|
83
|
+
console.log(`${this.tag} adopt_bot @${f.handle} (${f.botUserId}) owner=${f.ownerUserId}`);
|
|
81
84
|
this.handlers.onAdoptBot(f);
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
84
87
|
case "bot_config_changed": {
|
|
85
88
|
const f = frame;
|
|
86
89
|
if (!isBrainBackend(f.backend)) {
|
|
87
|
-
console.error(
|
|
90
|
+
console.error(`${this.tag} bot_config_changed with invalid backend "${String(f.backend)}" ignored`);
|
|
88
91
|
return;
|
|
89
92
|
}
|
|
90
|
-
console.log(
|
|
93
|
+
console.log(`${this.tag} bot_config_changed bot=${f.botUserId} backend=${f.backend}`);
|
|
91
94
|
this.handlers.onBotConfig(f);
|
|
92
95
|
return;
|
|
93
96
|
}
|
|
@@ -95,7 +98,7 @@ export class ControlSocket {
|
|
|
95
98
|
const f = frame;
|
|
96
99
|
// TODO(grants): enforce these — for now bots answer everyone in their
|
|
97
100
|
// channels; we only log so operators can see grants flowing.
|
|
98
|
-
console.log(
|
|
101
|
+
console.log(`${this.tag} bot_grant_changed bot=${f.botUserId} channel=${f.channelId} grantee=${f.granteeUserId} action=${f.action} (logged only; enforcement TODO)`);
|
|
99
102
|
this.handlers.onGrantChanged(f);
|
|
100
103
|
return;
|
|
101
104
|
}
|
|
@@ -110,13 +113,13 @@ export class ControlSocket {
|
|
|
110
113
|
}
|
|
111
114
|
default:
|
|
112
115
|
// Forward-compatible: unknown frames are logged, never fatal.
|
|
113
|
-
console.log(
|
|
116
|
+
console.log(`${this.tag} unhandled frame type "${String(frame.type)}"`);
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
scheduleReconnect() {
|
|
117
120
|
if (this.stopped)
|
|
118
121
|
return;
|
|
119
|
-
console.log(
|
|
122
|
+
console.log(`${this.tag} reconnecting in ${this.delay / 1000}s`);
|
|
120
123
|
const t = setTimeout(() => this.connect(), this.delay);
|
|
121
124
|
t.unref?.();
|
|
122
125
|
this.delay = Math.min(this.delay * 2, 30_000);
|
package/dist/localapi.js
CHANGED
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
// computer calls it directly from the browser.
|
|
4
4
|
//
|
|
5
5
|
// Auth model:
|
|
6
|
-
// GET / minimal, unauthenticated status (is a bridge here? paired?
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
6
|
+
// GET / minimal, unauthenticated status (is a bridge here? paired?
|
|
7
|
+
// how many accounts? — never runtime ids, they're the capability)
|
|
8
|
+
// POST /pair unauthenticated; the npr_ secret itself is the (server-minted,
|
|
9
|
+
// unguessable) proof and is validated against the API before
|
|
10
|
+
// anything persists. A bridge holds MULTIPLE pairings — one per
|
|
11
|
+
// NoPeek account — so pairing while already paired ADDS one;
|
|
12
|
+
// only an exact duplicate (same secret) is rejected.
|
|
13
|
+
// everything else requires header `x-nopeek-runtime: rt_…` — a runtime id,
|
|
14
|
+
// which only an OWNER's logged-in app can fetch from the NoPeek server
|
|
15
|
+
// (GET /bot-runtimes). ANY of the paired accounts' runtime ids is accepted.
|
|
16
|
+
// A random webpage can't know one, so drive-by requests to 127.0.0.1 can't
|
|
17
|
+
// read bot lists or change brain commands.
|
|
18
|
+
// DELETE /pair?runtimeId=rt_… removes ONE pairing; no query = remove ALL
|
|
19
|
+
// (the legacy pre-0.6 shape keeps working).
|
|
13
20
|
//
|
|
14
21
|
// CORS reflects the caller origin (the app may be served from any white-label
|
|
15
22
|
// domain) and answers Chrome's Private Network Access preflight.
|
|
@@ -70,15 +77,19 @@ function safeEq(a, b) {
|
|
|
70
77
|
const hb = createHash("sha256").update(b).digest();
|
|
71
78
|
return timingSafeEqual(ha, hb);
|
|
72
79
|
}
|
|
80
|
+
/** ANY paired account's runtime id is a valid capability. */
|
|
73
81
|
function authorized(app, req) {
|
|
74
82
|
const header = req.headers["x-nopeek-runtime"];
|
|
75
83
|
const given = Array.isArray(header) ? header[0] : header;
|
|
76
84
|
if (!given)
|
|
77
|
-
return "denied";
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
80
|
-
return "no-runtime-yet"; // paired but control socket
|
|
81
|
-
|
|
85
|
+
return { kind: "denied" };
|
|
86
|
+
const ids = app.runtimeIds();
|
|
87
|
+
if (!ids.length)
|
|
88
|
+
return { kind: "no-runtime-yet" }; // paired but no control socket has auth'd yet
|
|
89
|
+
for (const id of ids)
|
|
90
|
+
if (safeEq(given, id))
|
|
91
|
+
return { kind: "ok", runtimeId: id };
|
|
92
|
+
return { kind: "denied" };
|
|
82
93
|
}
|
|
83
94
|
export function startLocalApi(app) {
|
|
84
95
|
const server = createServer((req, res) => {
|
|
@@ -101,7 +112,9 @@ export function startLocalApi(app) {
|
|
|
101
112
|
async function handle(app, req, res) {
|
|
102
113
|
setCors(req, res);
|
|
103
114
|
const method = req.method ?? "GET";
|
|
104
|
-
const
|
|
115
|
+
const rawUrl = req.url ?? "/";
|
|
116
|
+
const path = rawUrl.split("?")[0].replace(/\/+$/, "") || "/";
|
|
117
|
+
const query = new URL(rawUrl, "http://127.0.0.1").searchParams;
|
|
105
118
|
if (method === "OPTIONS") {
|
|
106
119
|
res.statusCode = 204;
|
|
107
120
|
res.end();
|
|
@@ -113,7 +126,8 @@ async function handle(app, req, res) {
|
|
|
113
126
|
json(res, 200, app.statusMinimal());
|
|
114
127
|
return;
|
|
115
128
|
}
|
|
116
|
-
// Unauthenticated but self-proving (npr_ secret
|
|
129
|
+
// Unauthenticated but self-proving (npr_ secret, validated against the API).
|
|
130
|
+
// Works whether this is the FIRST pairing or an additional account.
|
|
117
131
|
if (method === "POST" && path === "/pair") {
|
|
118
132
|
let body;
|
|
119
133
|
try {
|
|
@@ -124,8 +138,8 @@ async function handle(app, req, res) {
|
|
|
124
138
|
return;
|
|
125
139
|
}
|
|
126
140
|
try {
|
|
127
|
-
await app.pair(body);
|
|
128
|
-
json(res, 200, { ok: true, paired: true });
|
|
141
|
+
const pairing = await app.pair(body);
|
|
142
|
+
json(res, 200, { ok: true, paired: true, runtimeId: pairing.runtimeId ?? null, pairings: app.cfg.pairings.length });
|
|
129
143
|
}
|
|
130
144
|
catch (err) {
|
|
131
145
|
if (err instanceof PairError) {
|
|
@@ -138,10 +152,10 @@ async function handle(app, req, res) {
|
|
|
138
152
|
}
|
|
139
153
|
return;
|
|
140
154
|
}
|
|
141
|
-
// Everything below requires
|
|
155
|
+
// Everything below requires a runtime id capability (any paired account's).
|
|
142
156
|
const auth = authorized(app, req);
|
|
143
|
-
if (auth !== "ok") {
|
|
144
|
-
if (auth === "no-runtime-yet") {
|
|
157
|
+
if (auth.kind !== "ok") {
|
|
158
|
+
if (auth.kind === "no-runtime-yet") {
|
|
145
159
|
json(res, 503, { error: "NOT_READY", message: "bridge is still connecting — retry in a moment" });
|
|
146
160
|
}
|
|
147
161
|
else {
|
|
@@ -150,7 +164,7 @@ async function handle(app, req, res) {
|
|
|
150
164
|
return;
|
|
151
165
|
}
|
|
152
166
|
if (method === "GET" && path === "/status") {
|
|
153
|
-
json(res, 200, app.statusFull());
|
|
167
|
+
json(res, 200, app.statusFull(auth.runtimeId));
|
|
154
168
|
return;
|
|
155
169
|
}
|
|
156
170
|
if (method === "GET" && path === "/detect") {
|
|
@@ -167,12 +181,23 @@ async function handle(app, req, res) {
|
|
|
167
181
|
return;
|
|
168
182
|
}
|
|
169
183
|
app.setBrains(body);
|
|
170
|
-
json(res, 200, { ok: true, status: app.statusFull() });
|
|
184
|
+
json(res, 200, { ok: true, status: app.statusFull(auth.runtimeId) });
|
|
171
185
|
return;
|
|
172
186
|
}
|
|
187
|
+
// ?runtimeId=rt_… removes ONE account's pairing; no query = remove ALL
|
|
188
|
+
// (pre-0.6 clients send no query and expect a fully unpaired bridge).
|
|
173
189
|
if (method === "DELETE" && path === "/pair") {
|
|
174
|
-
|
|
175
|
-
|
|
190
|
+
const runtimeId = query.get("runtimeId");
|
|
191
|
+
if (runtimeId) {
|
|
192
|
+
if (!app.unpair(runtimeId)) {
|
|
193
|
+
json(res, 404, { error: "NOT_FOUND", message: `no pairing for runtime ${runtimeId}` });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
app.unpair();
|
|
199
|
+
}
|
|
200
|
+
json(res, 200, { ok: true, paired: app.paired, pairings: app.cfg.pairings.length });
|
|
176
201
|
return;
|
|
177
202
|
}
|
|
178
203
|
json(res, 404, { error: "NOT_FOUND", message: `no ${method} ${path}` });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run your own agents as E2EE NoPeek bots. Pairs with
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
|
7
7
|
"bin": {
|