@nopeek/agent-bridge 0.1.0 → 0.2.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 +40 -8
- package/dist/bot.d.ts +10 -1
- package/dist/bot.js +57 -9
- package/dist/brain.d.ts +1 -1
- package/dist/brain.js +3 -1
- package/dist/bridge.d.ts +48 -3
- package/dist/bridge.js +203 -70
- package/dist/cli.js +77 -9
- package/dist/config.d.ts +24 -8
- package/dist/config.js +85 -29
- package/dist/control.js +1 -1
- package/dist/detect.d.ts +11 -0
- package/dist/detect.js +47 -0
- package/dist/localapi.d.ts +3 -0
- package/dist/localapi.js +179 -0
- package/dist/service.d.ts +4 -0
- package/dist/service.js +174 -0
- package/package.json +10 -9
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @nopeek/agent-bridge — connect your own agent runtime to NoPeek as E2EE bots.
|
|
3
|
-
//
|
|
3
|
+
//
|
|
4
|
+
// nopeek-agent-bridge install one-time: background service, then pair FROM THE APP
|
|
5
|
+
// nopeek-agent-bridge [run] foreground (unpaired bridges wait for the app)
|
|
6
|
+
// nopeek-agent-bridge status ask the running bridge how it's doing
|
|
7
|
+
// nopeek-agent-bridge uninstall remove the service
|
|
4
8
|
import { loadConfig, HELP } from "./config.js";
|
|
5
|
-
import {
|
|
9
|
+
import { BridgeApp, VERSION } from "./bridge.js";
|
|
10
|
+
import { startLocalApi } from "./localapi.js";
|
|
11
|
+
import { installService, uninstallService, printStatus } from "./service.js";
|
|
6
12
|
// ---------------------------------------------------------------- guards ----
|
|
7
13
|
// The bridge must never die to a stray rejection deep inside a WS/crypto
|
|
8
14
|
// callback — one flaky bot cannot take the fleet down.
|
|
@@ -16,25 +22,87 @@ if (typeof WebSocket === "undefined" || !globalThis.crypto?.subtle) {
|
|
|
16
22
|
console.error(`@nopeek/agent-bridge needs Node >= 22 (global WebSocket + fetch + WebCrypto). Current: ${process.version}`);
|
|
17
23
|
process.exit(1);
|
|
18
24
|
}
|
|
19
|
-
//
|
|
25
|
+
// ----------------------------------------------------------- subcommands ----
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const SUBCOMMANDS = new Set(["install", "uninstall", "status", "run", "help"]);
|
|
28
|
+
const sub = argv[0] && SUBCOMMANDS.has(argv[0]) ? argv[0] : "run";
|
|
29
|
+
const rest = sub === argv[0] ? argv.slice(1) : argv;
|
|
30
|
+
if (sub === "help") {
|
|
31
|
+
console.log(HELP);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
20
34
|
// Config errors exit cleanly with guidance — never a crash loop.
|
|
21
35
|
let cfg;
|
|
22
36
|
try {
|
|
23
|
-
cfg = loadConfig();
|
|
37
|
+
cfg = loadConfig(rest);
|
|
24
38
|
}
|
|
25
39
|
catch (err) {
|
|
26
40
|
console.error(`[config] ${err.message}\n`);
|
|
27
41
|
console.error(HELP);
|
|
28
42
|
process.exit(1);
|
|
29
43
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
if (sub === "uninstall") {
|
|
45
|
+
uninstallService();
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (sub === "status") {
|
|
49
|
+
await printStatus(cfg.port);
|
|
50
|
+
process.exit(process.exitCode ?? 0);
|
|
51
|
+
}
|
|
52
|
+
if (sub === "install") {
|
|
53
|
+
try {
|
|
54
|
+
await installService(cfg);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.error(`[install] ${err.message}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
// Optional: `install --pair npr_… --app-id app_…` pre-pairs the fresh service
|
|
61
|
+
// through its local API (same path the app uses).
|
|
62
|
+
if (cfg.pairingCode && cfg.appId) {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`http://127.0.0.1:${cfg.port}/pair`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "content-type": "application/json" },
|
|
67
|
+
body: JSON.stringify({ pairingSecret: cfg.pairingCode, appId: cfg.appId, apiUrl: cfg.apiUrl }),
|
|
68
|
+
});
|
|
69
|
+
const body = (await res.json().catch(() => ({})));
|
|
70
|
+
if (res.ok)
|
|
71
|
+
console.log(`[install] paired successfully`);
|
|
72
|
+
else
|
|
73
|
+
console.error(`[install] pairing failed: ${body.message ?? `HTTP ${res.status}`}`);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.error(`[install] pairing failed: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------- run -------
|
|
82
|
+
console.log(`[bridge] NoPeek agent-bridge v${VERSION} starting`);
|
|
83
|
+
console.log(`[bridge] api=${cfg.apiUrl} app=${cfg.appId ?? "(not paired)"} data=${cfg.dataDir} local-api=:${cfg.port} home=${cfg.homeDir}`);
|
|
84
|
+
console.log(`[bridge] default brain: ${cfg.brainCmd ? "cmd" : cfg.brainUrl ? "url" : "echo (choose an agent in the NoPeek app, or set --brain-cmd)"}` +
|
|
33
85
|
(Object.keys(cfg.brainMap).length ? ` + ${Object.keys(cfg.brainMap).length} per-bot override(s)` : ""));
|
|
34
|
-
const
|
|
86
|
+
const app = new BridgeApp(cfg);
|
|
87
|
+
let localApi;
|
|
88
|
+
try {
|
|
89
|
+
localApi = await startLocalApi(app);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const e = err;
|
|
93
|
+
if (e.code === "EADDRINUSE") {
|
|
94
|
+
console.error(`[bridge] port ${cfg.port} is already in use — another bridge is probably running (try 'nopeek-agent-bridge status'). Exiting.`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
console.error(`[bridge] could not start local API: ${e.message}`);
|
|
98
|
+
}
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
app.start();
|
|
35
102
|
const shutdown = (signal) => {
|
|
36
103
|
console.log(`[bridge] ${signal} — shutting down`);
|
|
37
|
-
|
|
104
|
+
app.stop();
|
|
105
|
+
localApi.close();
|
|
38
106
|
// Give sockets a beat to close, then force-exit so systemd/launchd restarts cleanly.
|
|
39
107
|
setTimeout(() => process.exit(0), 500).unref();
|
|
40
108
|
};
|
package/dist/config.d.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
/** Per-bot brain override, keyed by bot handle in BRAIN_MAP.
|
|
1
|
+
/** Per-bot brain override, keyed by bot handle in BRAIN_MAP.
|
|
2
|
+
* `echo: true` pins the bot to echo mode even when a global brain is set. */
|
|
2
3
|
export interface BrainSpec {
|
|
3
4
|
cmd?: string;
|
|
4
5
|
url?: string;
|
|
6
|
+
echo?: boolean;
|
|
5
7
|
}
|
|
6
8
|
export interface BridgeConfig {
|
|
7
9
|
/** NoPeek API base, e.g. https://d3qweh72vesa98.cloudfront.net */
|
|
8
10
|
apiUrl: string;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
/** Null until paired (from the app or via --pair/--app-id). */
|
|
12
|
+
appId: string | null;
|
|
13
|
+
/** Runtime pairing token (npr_…). Null until paired. */
|
|
14
|
+
pairingCode: string | null;
|
|
12
15
|
/** Global brain: shell command reading the message on stdin, printing the reply. */
|
|
13
16
|
brainCmd: string | null;
|
|
14
17
|
/** Global brain: webhook POSTed {text, botHandle, botUserId, channelId, senderUserId}. */
|
|
@@ -16,14 +19,27 @@ export interface BridgeConfig {
|
|
|
16
19
|
/** Per-handle overrides: { "<handle>": {"cmd": "…"} | {"url": "…"} }. */
|
|
17
20
|
brainMap: Record<string, BrainSpec>;
|
|
18
21
|
brainTimeoutMs: number;
|
|
19
|
-
/**
|
|
22
|
+
/** Local control API port (status + pairing + brain config, loopback only). */
|
|
20
23
|
port: number;
|
|
21
|
-
/** Where per-bot device identity/key stores live
|
|
24
|
+
/** Where per-bot device identity/key stores live. */
|
|
22
25
|
dataDir: string;
|
|
26
|
+
/** Bridge home: persisted settings, default data dir, service logs. */
|
|
27
|
+
homeDir: string;
|
|
28
|
+
/** Optional reply sent ONCE to an unauthorized sender. Null (default) =
|
|
29
|
+
* silently ignore them (most private — doesn't reveal the bot exists). */
|
|
30
|
+
declineMessage: string | null;
|
|
23
31
|
}
|
|
24
32
|
export declare const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
|
|
25
33
|
export declare const DEFAULT_PORT = 8790;
|
|
26
34
|
export declare const DEFAULT_BRAIN_TIMEOUT_MS = 180000;
|
|
27
|
-
export declare
|
|
28
|
-
|
|
35
|
+
export declare function defaultHomeDir(): string;
|
|
36
|
+
export declare const HELP = "nopeek-agent-bridge \u2014 run your agents as E2EE NoPeek bots\n\nUsage:\n nopeek-agent-bridge install Install as a background service (launchd/systemd),\n then finish setup from the NoPeek app:\n Bots -> Connect this computer.\n nopeek-agent-bridge uninstall Remove the background service (keeps data/settings).\n nopeek-agent-bridge status Show the running bridge's status.\n nopeek-agent-bridge [run] Run in the foreground. Unpaired bridges wait to be\n paired from the NoPeek app; --pair still works:\n npx @nopeek/agent-bridge --pair npr_\u2026 --app-id app_\u2026\n\nOptions:\n --pair <code> Pairing code from the NoPeek app (env NOPEEK_PAIRING_CODE)\n --app-id <id> NoPeek app id (env NOPEEK_APP_ID)\n --api-url <url> API base, default https://d3qweh72vesa98.cloudfront.net (env NOPEEK_API_URL)\n --brain-cmd <cmd> Shell brain: message on stdin -> reply on stdout (env BRAIN_CMD)\n --brain-url <url> Webhook brain: POST {text,...} -> {text|reply} (env BRAIN_URL)\n --brain-map <json> Per-bot overrides {\"<handle>\":{\"cmd\":\"\u2026\"}|{\"url\":\"\u2026\"}} (env BRAIN_MAP)\n --brain-timeout-ms <ms> Brain timeout, default 180000 (env BRAIN_TIMEOUT_MS)\n --port <port> Local control API port, default 8790 (env NOPEEK_BRIDGE_PORT)\n --data-dir <dir> Device-key store dir (env NOPEEK_BRIDGE_DATA_DIR)\n --home <dir> Bridge home, default ~/.nopeek-bridge (env NOPEEK_BRIDGE_HOME)\n --config <path> Config file, default ./nopeek-bridge.config.json\n -h, --help Show this help\n\nWith no brain configured, bots run in echo mode (\"You said: \u2026\") \u2014 a zero-config smoke test.\nPairing and brains can be managed entirely from the NoPeek app once the service is running.";
|
|
37
|
+
/** Load config from argv + env + cwd config file + home settings. Never
|
|
38
|
+
* requires pairing — an unpaired bridge waits for the app to pair it. */
|
|
29
39
|
export declare function loadConfig(argv?: string[]): BridgeConfig;
|
|
40
|
+
/**
|
|
41
|
+
* Persist the app-manageable parts of the config (pairing + brains) to
|
|
42
|
+
* <home>/settings.json so pairing from the NoPeek app survives restarts.
|
|
43
|
+
* Written atomically; the file contains a bearer credential — chmod 600.
|
|
44
|
+
*/
|
|
45
|
+
export declare function saveSettings(cfg: BridgeConfig): void;
|
package/dist/config.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
// Bridge config. Precedence: CLI flags > environment variables > optional
|
|
2
|
-
// ./nopeek-bridge.config.json (same key names as the env vars)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
// ./nopeek-bridge.config.json (same key names as the env vars) > persisted
|
|
3
|
+
// settings in the bridge home (~/.nopeek-bridge/settings.json — written when
|
|
4
|
+
// you pair or set brains FROM THE NOPEEK APP).
|
|
5
|
+
//
|
|
6
|
+
// Pairing is no longer required at startup: an unpaired bridge starts its
|
|
7
|
+
// local control API and waits for the NoPeek app to POST /pair.
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
7
11
|
import { parseArgs } from "node:util";
|
|
8
12
|
export const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
|
|
9
13
|
export const DEFAULT_PORT = 8790;
|
|
10
14
|
export const DEFAULT_BRAIN_TIMEOUT_MS = 180_000;
|
|
15
|
+
export function defaultHomeDir() {
|
|
16
|
+
return process.env.NOPEEK_BRIDGE_HOME || join(homedir(), ".nopeek-bridge");
|
|
17
|
+
}
|
|
11
18
|
const CLI_OPTIONS = {
|
|
12
19
|
pair: { type: "string" },
|
|
13
20
|
"api-url": { type: "string" },
|
|
@@ -16,8 +23,10 @@ const CLI_OPTIONS = {
|
|
|
16
23
|
"brain-url": { type: "string" },
|
|
17
24
|
"brain-map": { type: "string" },
|
|
18
25
|
"brain-timeout-ms": { type: "string" },
|
|
26
|
+
"decline-message": { type: "string" },
|
|
19
27
|
port: { type: "string" },
|
|
20
28
|
"data-dir": { type: "string" },
|
|
29
|
+
home: { type: "string" },
|
|
21
30
|
config: { type: "string" },
|
|
22
31
|
help: { type: "boolean", short: "h" },
|
|
23
32
|
};
|
|
@@ -30,29 +39,40 @@ const FLAG_TO_KEY = {
|
|
|
30
39
|
"brain-url": "BRAIN_URL",
|
|
31
40
|
"brain-map": "BRAIN_MAP",
|
|
32
41
|
"brain-timeout-ms": "BRAIN_TIMEOUT_MS",
|
|
42
|
+
"decline-message": "NOPEEK_DECLINE_MESSAGE",
|
|
33
43
|
port: "NOPEEK_BRIDGE_PORT",
|
|
34
44
|
"data-dir": "NOPEEK_BRIDGE_DATA_DIR",
|
|
45
|
+
home: "NOPEEK_BRIDGE_HOME",
|
|
35
46
|
};
|
|
36
47
|
export const HELP = `nopeek-agent-bridge — run your agents as E2EE NoPeek bots
|
|
37
48
|
|
|
38
49
|
Usage:
|
|
39
|
-
|
|
50
|
+
nopeek-agent-bridge install Install as a background service (launchd/systemd),
|
|
51
|
+
then finish setup from the NoPeek app:
|
|
52
|
+
Bots -> Connect this computer.
|
|
53
|
+
nopeek-agent-bridge uninstall Remove the background service (keeps data/settings).
|
|
54
|
+
nopeek-agent-bridge status Show the running bridge's status.
|
|
55
|
+
nopeek-agent-bridge [run] Run in the foreground. Unpaired bridges wait to be
|
|
56
|
+
paired from the NoPeek app; --pair still works:
|
|
57
|
+
npx @nopeek/agent-bridge --pair npr_… --app-id app_…
|
|
40
58
|
|
|
41
59
|
Options:
|
|
42
|
-
--pair <code>
|
|
43
|
-
--app-id <id> NoPeek app id
|
|
60
|
+
--pair <code> Pairing code from the NoPeek app (env NOPEEK_PAIRING_CODE)
|
|
61
|
+
--app-id <id> NoPeek app id (env NOPEEK_APP_ID)
|
|
44
62
|
--api-url <url> API base, default ${DEFAULT_API_URL} (env NOPEEK_API_URL)
|
|
45
63
|
--brain-cmd <cmd> Shell brain: message on stdin -> reply on stdout (env BRAIN_CMD)
|
|
46
64
|
--brain-url <url> Webhook brain: POST {text,...} -> {text|reply} (env BRAIN_URL)
|
|
47
65
|
--brain-map <json> Per-bot overrides {"<handle>":{"cmd":"…"}|{"url":"…"}} (env BRAIN_MAP)
|
|
48
66
|
--brain-timeout-ms <ms> Brain timeout, default ${DEFAULT_BRAIN_TIMEOUT_MS} (env BRAIN_TIMEOUT_MS)
|
|
49
|
-
--port <port>
|
|
50
|
-
--data-dir <dir> Device-key store dir
|
|
67
|
+
--port <port> Local control API port, default ${DEFAULT_PORT} (env NOPEEK_BRIDGE_PORT)
|
|
68
|
+
--data-dir <dir> Device-key store dir (env NOPEEK_BRIDGE_DATA_DIR)
|
|
69
|
+
--home <dir> Bridge home, default ~/.nopeek-bridge (env NOPEEK_BRIDGE_HOME)
|
|
51
70
|
--config <path> Config file, default ./nopeek-bridge.config.json
|
|
52
71
|
-h, --help Show this help
|
|
53
72
|
|
|
54
|
-
With no brain configured, bots run in echo mode ("You said: …") — a zero-config smoke test
|
|
55
|
-
|
|
73
|
+
With no brain configured, bots run in echo mode ("You said: …") — a zero-config smoke test.
|
|
74
|
+
Pairing and brains can be managed entirely from the NoPeek app once the service is running.`;
|
|
75
|
+
function readJsonFile(path, explicit) {
|
|
56
76
|
if (!existsSync(path)) {
|
|
57
77
|
if (explicit)
|
|
58
78
|
throw new Error(`config file not found: ${path}`);
|
|
@@ -66,7 +86,6 @@ function readConfigFile(path, explicit) {
|
|
|
66
86
|
continue;
|
|
67
87
|
out[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
68
88
|
}
|
|
69
|
-
console.log(`[config] loaded ${path}`);
|
|
70
89
|
return out;
|
|
71
90
|
}
|
|
72
91
|
catch (err) {
|
|
@@ -102,34 +121,40 @@ function parseBrainMap(raw) {
|
|
|
102
121
|
}
|
|
103
122
|
return out;
|
|
104
123
|
}
|
|
105
|
-
|
|
124
|
+
function settingsPath(homeDir) {
|
|
125
|
+
return join(homeDir, "settings.json");
|
|
126
|
+
}
|
|
127
|
+
/** Load config from argv + env + cwd config file + home settings. Never
|
|
128
|
+
* requires pairing — an unpaired bridge waits for the app to pair it. */
|
|
106
129
|
export function loadConfig(argv = process.argv.slice(2)) {
|
|
107
130
|
const { values: flags } = parseArgs({ args: argv, options: CLI_OPTIONS, strict: true });
|
|
108
131
|
if (flags.help) {
|
|
109
132
|
console.log(HELP);
|
|
110
133
|
process.exit(0);
|
|
111
134
|
}
|
|
135
|
+
const homeDir = resolve(flags.home ?? defaultHomeDir());
|
|
112
136
|
const configPath = resolve(process.cwd(), flags.config ?? "nopeek-bridge.config.json");
|
|
113
|
-
const file =
|
|
137
|
+
const file = readJsonFile(configPath, flags.config !== undefined);
|
|
138
|
+
if (Object.keys(file).length)
|
|
139
|
+
console.log(`[config] loaded ${configPath}`);
|
|
140
|
+
const saved = readJsonFile(settingsPath(homeDir), false);
|
|
141
|
+
if (Object.keys(saved).length)
|
|
142
|
+
console.log(`[config] loaded ${settingsPath(homeDir)}`);
|
|
114
143
|
const get = (flagName) => {
|
|
115
144
|
const key = FLAG_TO_KEY[flagName];
|
|
116
|
-
const v = flags[flagName] ??
|
|
145
|
+
const v = flags[flagName] ??
|
|
146
|
+
process.env[key] ??
|
|
147
|
+
file[key] ??
|
|
148
|
+
saved[key];
|
|
117
149
|
return v === undefined || v === "" ? undefined : v;
|
|
118
150
|
};
|
|
119
|
-
const pairingCode = get("pair");
|
|
120
|
-
if (!pairingCode) {
|
|
121
|
-
throw new Error(`missing pairing code.\n` +
|
|
122
|
-
`Get one from the NoPeek app ("Connect your computer"), then run:\n` +
|
|
123
|
-
` npx @nopeek/agent-bridge --pair npr_…\n` +
|
|
124
|
-
`or set NOPEEK_PAIRING_CODE (env or nopeek-bridge.config.json).`);
|
|
125
|
-
}
|
|
126
|
-
if (!pairingCode.startsWith("npr_")) {
|
|
151
|
+
const pairingCode = get("pair") ?? null;
|
|
152
|
+
if (pairingCode && !pairingCode.startsWith("npr_")) {
|
|
127
153
|
console.warn(`[config] pairing code does not start with "npr_" — double-check you pasted the runtime pairing code`);
|
|
128
154
|
}
|
|
129
|
-
const appId = get("app-id");
|
|
130
|
-
if (!appId) {
|
|
131
|
-
throw new Error(
|
|
132
|
-
`Pass --app-id app_… or set NOPEEK_APP_ID (shown alongside the pairing code in the NoPeek app).`);
|
|
155
|
+
const appId = get("app-id") ?? null;
|
|
156
|
+
if (pairingCode && !appId) {
|
|
157
|
+
throw new Error(`--pair was given without --app-id (or NOPEEK_APP_ID) — both are needed to connect.`);
|
|
133
158
|
}
|
|
134
159
|
const brainTimeoutMs = Number(get("brain-timeout-ms") ?? DEFAULT_BRAIN_TIMEOUT_MS);
|
|
135
160
|
if (!Number.isFinite(brainTimeoutMs) || brainTimeoutMs <= 0) {
|
|
@@ -139,6 +164,14 @@ export function loadConfig(argv = process.argv.slice(2)) {
|
|
|
139
164
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
140
165
|
throw new Error(`NOPEEK_BRIDGE_PORT must be a valid port number`);
|
|
141
166
|
}
|
|
167
|
+
// Data dir: explicit wins; a legacy ./data (pre-0.2 default) keeps working;
|
|
168
|
+
// otherwise device keys live in the bridge home.
|
|
169
|
+
const explicitDataDir = get("data-dir");
|
|
170
|
+
const dataDir = explicitDataDir
|
|
171
|
+
? resolve(process.cwd(), explicitDataDir)
|
|
172
|
+
: existsSync(resolve(process.cwd(), "data"))
|
|
173
|
+
? resolve(process.cwd(), "data")
|
|
174
|
+
: join(homeDir, "data");
|
|
142
175
|
return {
|
|
143
176
|
apiUrl: (get("api-url") ?? DEFAULT_API_URL).replace(/\/+$/, ""),
|
|
144
177
|
appId,
|
|
@@ -148,6 +181,29 @@ export function loadConfig(argv = process.argv.slice(2)) {
|
|
|
148
181
|
brainMap: parseBrainMap(get("brain-map")),
|
|
149
182
|
brainTimeoutMs,
|
|
150
183
|
port,
|
|
151
|
-
dataDir
|
|
184
|
+
dataDir,
|
|
185
|
+
homeDir,
|
|
186
|
+
declineMessage: get("decline-message") ?? null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Persist the app-manageable parts of the config (pairing + brains) to
|
|
191
|
+
* <home>/settings.json so pairing from the NoPeek app survives restarts.
|
|
192
|
+
* Written atomically; the file contains a bearer credential — chmod 600.
|
|
193
|
+
*/
|
|
194
|
+
export function saveSettings(cfg) {
|
|
195
|
+
mkdirSync(cfg.homeDir, { recursive: true });
|
|
196
|
+
const out = {
|
|
197
|
+
NOPEEK_API_URL: cfg.apiUrl,
|
|
198
|
+
...(cfg.appId ? { NOPEEK_APP_ID: cfg.appId } : {}),
|
|
199
|
+
...(cfg.pairingCode ? { NOPEEK_PAIRING_CODE: cfg.pairingCode } : {}),
|
|
200
|
+
...(cfg.brainCmd ? { BRAIN_CMD: cfg.brainCmd } : {}),
|
|
201
|
+
...(cfg.brainUrl ? { BRAIN_URL: cfg.brainUrl } : {}),
|
|
202
|
+
...(Object.keys(cfg.brainMap).length ? { BRAIN_MAP: JSON.stringify(cfg.brainMap) } : {}),
|
|
203
|
+
...(cfg.declineMessage ? { NOPEEK_DECLINE_MESSAGE: cfg.declineMessage } : {}),
|
|
152
204
|
};
|
|
205
|
+
const path = settingsPath(cfg.homeDir);
|
|
206
|
+
const tmp = `${path}.tmp`;
|
|
207
|
+
writeFileSync(tmp, JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
208
|
+
renameSync(tmp, path);
|
|
153
209
|
}
|
package/dist/control.js
CHANGED
|
@@ -27,7 +27,7 @@ export class ControlSocket {
|
|
|
27
27
|
connect() {
|
|
28
28
|
if (this.stopped)
|
|
29
29
|
return;
|
|
30
|
-
const url = `${this.cfg.apiUrl.replace(/^http/, "ws")}/v1/ws?runtimeToken=${encodeURIComponent(this.cfg.pairingCode)}`;
|
|
30
|
+
const url = `${this.cfg.apiUrl.replace(/^http/, "ws")}/v1/ws?runtimeToken=${encodeURIComponent(this.cfg.pairingCode ?? "")}`;
|
|
31
31
|
let ws;
|
|
32
32
|
try {
|
|
33
33
|
ws = new WebSocket(url);
|
package/dist/detect.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface DetectedRuntime {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
bin: string;
|
|
5
|
+
found: boolean;
|
|
6
|
+
path: string | null;
|
|
7
|
+
/** Suggested BRAIN_CMD for this runtime (message on stdin -> reply on stdout). */
|
|
8
|
+
template: string;
|
|
9
|
+
}
|
|
10
|
+
/** Probe all known runtimes concurrently (a couple hundred ms, cached by caller). */
|
|
11
|
+
export declare function detectRuntimes(): Promise<DetectedRuntime[]>;
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Detect agent runtimes installed on this machine so the NoPeek app can offer
|
|
2
|
+
// them as one-tap brains. Detection is just `command -v <bin>` — the app shows
|
|
3
|
+
// the suggested command template, the user can edit it, and the final string is
|
|
4
|
+
// stored locally (never on the server) as that bot's BRAIN_MAP entry.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
const CANDIDATES = [
|
|
7
|
+
{
|
|
8
|
+
id: "hermes",
|
|
9
|
+
label: "Hermes",
|
|
10
|
+
bin: "hermes",
|
|
11
|
+
template: 'hermes chat -Q -q "$(cat)"',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "claude",
|
|
15
|
+
label: "Claude Code",
|
|
16
|
+
bin: "claude",
|
|
17
|
+
template: 'claude -p "$(cat)"',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "llm",
|
|
21
|
+
label: "llm (Simon Willison)",
|
|
22
|
+
bin: "llm",
|
|
23
|
+
template: 'llm "$(cat)"',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "ollama",
|
|
27
|
+
label: "Ollama",
|
|
28
|
+
bin: "ollama",
|
|
29
|
+
template: 'ollama run llama3.2 "$(cat)"',
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
function which(bin) {
|
|
33
|
+
return new Promise((resolvePromise) => {
|
|
34
|
+
const child = spawn("bash", ["-lc", `command -v ${bin}`], { stdio: ["ignore", "pipe", "ignore"] });
|
|
35
|
+
let out = "";
|
|
36
|
+
child.stdout.on("data", (d) => (out += d.toString()));
|
|
37
|
+
child.on("error", () => resolvePromise(null));
|
|
38
|
+
child.on("close", (code) => resolvePromise(code === 0 && out.trim() ? out.trim().split("\n")[0] : null));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Probe all known runtimes concurrently (a couple hundred ms, cached by caller). */
|
|
42
|
+
export async function detectRuntimes() {
|
|
43
|
+
return Promise.all(CANDIDATES.map(async (c) => {
|
|
44
|
+
const path = await which(c.bin);
|
|
45
|
+
return { ...c, found: path !== null, path };
|
|
46
|
+
}));
|
|
47
|
+
}
|
package/dist/localapi.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Local control API — how the NoPeek app manages this bridge with zero
|
|
2
|
+
// terminal work after install. Loopback-only (127.0.0.1); the app on the same
|
|
3
|
+
// computer calls it directly from the browser.
|
|
4
|
+
//
|
|
5
|
+
// Auth model:
|
|
6
|
+
// GET / minimal, unauthenticated status (is a bridge here? paired?)
|
|
7
|
+
// POST /pair unauthenticated BUT only accepted while unpaired, and the
|
|
8
|
+
// npr_ secret itself is the (server-minted, unguessable) proof.
|
|
9
|
+
// everything else requires header `x-nopeek-runtime: rt_…` — the runtime id,
|
|
10
|
+
// which only the OWNER's logged-in app can fetch from the NoPeek server
|
|
11
|
+
// (GET /bot-runtimes). A random webpage can't know it, so drive-by requests
|
|
12
|
+
// to 127.0.0.1 can't read bot lists or change brain commands.
|
|
13
|
+
//
|
|
14
|
+
// CORS reflects the caller origin (the app may be served from any white-label
|
|
15
|
+
// domain) and answers Chrome's Private Network Access preflight.
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
18
|
+
import { PairError } from "./bridge.js";
|
|
19
|
+
import { detectRuntimes } from "./detect.js";
|
|
20
|
+
const BODY_LIMIT = 64 * 1024;
|
|
21
|
+
function setCors(req, res) {
|
|
22
|
+
const origin = req.headers.origin;
|
|
23
|
+
if (!origin)
|
|
24
|
+
return; // curl and friends — CORS is a browser concern
|
|
25
|
+
res.setHeader("access-control-allow-origin", origin);
|
|
26
|
+
res.setHeader("vary", "Origin");
|
|
27
|
+
res.setHeader("access-control-allow-methods", "GET,POST,PUT,DELETE,OPTIONS");
|
|
28
|
+
res.setHeader("access-control-allow-headers", "content-type, x-nopeek-runtime");
|
|
29
|
+
res.setHeader("access-control-max-age", "600");
|
|
30
|
+
if (req.headers["access-control-request-private-network"] === "true") {
|
|
31
|
+
// Chrome PNA: a public https page fetching 127.0.0.1 needs this on preflight.
|
|
32
|
+
res.setHeader("access-control-allow-private-network", "true");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function json(res, status, body) {
|
|
36
|
+
res.statusCode = status;
|
|
37
|
+
res.setHeader("content-type", "application/json");
|
|
38
|
+
res.end(JSON.stringify(body));
|
|
39
|
+
}
|
|
40
|
+
function readBody(req) {
|
|
41
|
+
return new Promise((resolvePromise, reject) => {
|
|
42
|
+
let body = "";
|
|
43
|
+
req.on("data", (d) => {
|
|
44
|
+
body += d.toString();
|
|
45
|
+
if (body.length > BODY_LIMIT) {
|
|
46
|
+
reject(new Error("body too large"));
|
|
47
|
+
req.destroy();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
req.on("end", () => {
|
|
51
|
+
if (!body.trim())
|
|
52
|
+
return resolvePromise({});
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(body);
|
|
55
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
56
|
+
return reject(new Error("body must be a JSON object"));
|
|
57
|
+
}
|
|
58
|
+
resolvePromise(parsed);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
reject(new Error("body is not valid JSON"));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
req.on("error", reject);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Constant-time compare of unequal-length strings via SHA-256. */
|
|
68
|
+
function safeEq(a, b) {
|
|
69
|
+
const ha = createHash("sha256").update(a).digest();
|
|
70
|
+
const hb = createHash("sha256").update(b).digest();
|
|
71
|
+
return timingSafeEqual(ha, hb);
|
|
72
|
+
}
|
|
73
|
+
function authorized(app, req) {
|
|
74
|
+
const header = req.headers["x-nopeek-runtime"];
|
|
75
|
+
const given = Array.isArray(header) ? header[0] : header;
|
|
76
|
+
if (!given)
|
|
77
|
+
return "denied";
|
|
78
|
+
const rt = app.runtimeId;
|
|
79
|
+
if (!rt)
|
|
80
|
+
return "no-runtime-yet"; // paired but control socket hasn't auth'd yet
|
|
81
|
+
return safeEq(given, rt) ? "ok" : "denied";
|
|
82
|
+
}
|
|
83
|
+
export function startLocalApi(app) {
|
|
84
|
+
const server = createServer((req, res) => {
|
|
85
|
+
void handle(app, req, res).catch((err) => {
|
|
86
|
+
console.error(`[localapi] ${err.message}`);
|
|
87
|
+
if (!res.headersSent)
|
|
88
|
+
json(res, 500, { error: "INTERNAL", message: "internal error" });
|
|
89
|
+
else
|
|
90
|
+
res.end();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
return new Promise((resolvePromise, reject) => {
|
|
94
|
+
server.once("error", reject);
|
|
95
|
+
server.listen(app.cfg.port, "127.0.0.1", () => {
|
|
96
|
+
console.log(`[localapi] http://127.0.0.1:${app.cfg.port}/ (status, pairing, brains — loopback only)`);
|
|
97
|
+
resolvePromise(server);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async function handle(app, req, res) {
|
|
102
|
+
setCors(req, res);
|
|
103
|
+
const method = req.method ?? "GET";
|
|
104
|
+
const path = (req.url ?? "/").split("?")[0].replace(/\/+$/, "") || "/";
|
|
105
|
+
if (method === "OPTIONS") {
|
|
106
|
+
res.statusCode = 204;
|
|
107
|
+
res.end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Unauthenticated: presence probe. Deliberately minimal — no handles, no
|
|
111
|
+
// runtime id, no brain config (any webpage can read this).
|
|
112
|
+
if (method === "GET" && path === "/") {
|
|
113
|
+
json(res, 200, app.statusMinimal());
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Unauthenticated but self-proving (npr_ secret) and unpaired-only.
|
|
117
|
+
if (method === "POST" && path === "/pair") {
|
|
118
|
+
let body;
|
|
119
|
+
try {
|
|
120
|
+
body = await readBody(req);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
json(res, 400, { error: "BAD_REQUEST", message: err.message });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await app.pair(body);
|
|
128
|
+
json(res, 200, { ok: true, paired: true });
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
if (err instanceof PairError) {
|
|
132
|
+
const status = err.code === "ALREADY_PAIRED" ? 409 : err.code === "BAD_REQUEST" ? 400 : err.code === "PAIR_REJECTED" ? 401 : 502;
|
|
133
|
+
json(res, status, { error: err.code, message: err.message });
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
json(res, 500, { error: "INTERNAL", message: err.message });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Everything below requires the runtime id capability.
|
|
142
|
+
const auth = authorized(app, req);
|
|
143
|
+
if (auth !== "ok") {
|
|
144
|
+
if (auth === "no-runtime-yet") {
|
|
145
|
+
json(res, 503, { error: "NOT_READY", message: "bridge is still connecting — retry in a moment" });
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
json(res, 403, { error: "FORBIDDEN", message: "missing or wrong x-nopeek-runtime header" });
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (method === "GET" && path === "/status") {
|
|
153
|
+
json(res, 200, app.statusFull());
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (method === "GET" && path === "/detect") {
|
|
157
|
+
json(res, 200, { runtimes: await detectRuntimes() });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (method === "PUT" && path === "/brains") {
|
|
161
|
+
let body;
|
|
162
|
+
try {
|
|
163
|
+
body = await readBody(req);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
json(res, 400, { error: "BAD_REQUEST", message: err.message });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
app.setBrains(body);
|
|
170
|
+
json(res, 200, { ok: true, status: app.statusFull() });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (method === "DELETE" && path === "/pair") {
|
|
174
|
+
app.unpair();
|
|
175
|
+
json(res, 200, { ok: true, paired: false });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
json(res, 404, { error: "NOT_FOUND", message: `no ${method} ${path}` });
|
|
179
|
+
}
|