@bulolo/hermes-link 0.2.9 → 0.3.1

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.
@@ -1,3649 +0,0 @@
1
- // src/http/app.ts
2
- import Koa from "koa";
3
- import bodyParser from "koa-bodyparser";
4
- import cors from "@koa/cors";
5
- import Router8 from "@koa/router";
6
- import { mkdir as mkdir8 } from "fs/promises";
7
-
8
- // src/runtime/logger.ts
9
- import { appendFile, mkdir, open, readFile, rename, rm, stat } from "fs/promises";
10
- import os2 from "os";
11
- import path2 from "path";
12
- import pino from "pino";
13
-
14
- // src/constants.ts
15
- import { createRequire } from "module";
16
- var _require = createRequire(import.meta.url);
17
- var _pkg = _require("../package.json");
18
- var LINK_COMMAND = "hermeslink";
19
- var LINK_VERSION = _pkg.version;
20
- var LINK_DEFAULT_PORT = 52379;
21
- var LINK_RUNTIME_DIR_NAME = ".hermeslink";
22
- var DEFAULT_LOG_FILE = "hermeslink.log";
23
- var DAEMON_LOG_FILE = "daemon.log";
24
- var GATEWAY_LOG_FILE = "hermes-gateway.log";
25
- var DEFAULT_HERMES_API_SERVER_PORT = 8642;
26
- var PROFILE_API_SERVER_PORT_START = DEFAULT_HERMES_API_SERVER_PORT + 1;
27
- var PROFILE_API_SERVER_PORT_END = DEFAULT_HERMES_API_SERVER_PORT + 999;
28
-
29
- // src/runtime/paths.ts
30
- import os from "os";
31
- import path from "path";
32
- function resolveRuntimeHome() {
33
- return process.env.HERMESLINK_HOME?.trim() ? path.resolve(process.env.HERMESLINK_HOME) : path.join(os.homedir(), LINK_RUNTIME_DIR_NAME);
34
- }
35
- function resolveRuntimePaths(homeDir = resolveRuntimeHome()) {
36
- return {
37
- homeDir,
38
- identityFile: path.join(homeDir, "identity.json"),
39
- configFile: path.join(homeDir, "config.json"),
40
- stateFile: path.join(homeDir, "state.json"),
41
- credentialsFile: path.join(homeDir, "credentials.json"),
42
- databaseFile: path.join(homeDir, "link.db"),
43
- conversationsDir: path.join(homeDir, "conversations"),
44
- blobsDir: path.join(homeDir, "blobs"),
45
- indexesDir: path.join(homeDir, "indexes"),
46
- logsDir: path.join(homeDir, "logs"),
47
- runDir: path.join(homeDir, "run"),
48
- pairingDir: path.join(homeDir, "pairing")
49
- };
50
- }
51
-
52
- // src/runtime/logger.ts
53
- var DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
54
- var DEFAULT_MAX_FILES = 5;
55
- var DEFAULT_READ_LIMIT = 200;
56
- var MAX_READ_LIMIT = 1e3;
57
- var DEFAULT_MAX_BYTES_PER_FILE = 512 * 1024;
58
- var LOG_LEVEL_PRIORITY = {
59
- debug: 10,
60
- info: 20,
61
- warn: 30,
62
- error: 40
63
- };
64
- var FileLogger = class {
65
- filePath;
66
- paths;
67
- maxFileBytes;
68
- maxFiles;
69
- minLevel;
70
- now;
71
- queue = Promise.resolve();
72
- constructor(options = {}) {
73
- this.paths = options.paths ?? resolveRuntimePaths();
74
- this.filePath = getLinkLogFile(this.paths, options.fileName);
75
- this.maxFileBytes = Math.max(256, Math.floor(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES));
76
- this.maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
77
- this.minLevel = options.minLevel ?? "warn";
78
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
79
- }
80
- debug(message, fields) {
81
- return this.write("debug", message, fields);
82
- }
83
- info(message, fields) {
84
- return this.write("info", message, fields);
85
- }
86
- warn(message, fields) {
87
- return this.write("warn", message, fields);
88
- }
89
- error(message, fields) {
90
- return this.write("error", message, fields);
91
- }
92
- write(level, message, fields) {
93
- if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[this.minLevel]) {
94
- return Promise.resolve();
95
- }
96
- const entry = {
97
- ts: this.now().toISOString(),
98
- level,
99
- message,
100
- ...fields ? { fields: sanitizeFields(fields) } : {}
101
- };
102
- const next = this.queue.then(() => this.appendEntry(entry)).catch(() => void 0);
103
- this.queue = next;
104
- return next;
105
- }
106
- flush() {
107
- return this.queue;
108
- }
109
- async appendEntry(entry) {
110
- await mkdir(this.paths.logsDir, { recursive: true, mode: 448 });
111
- const line = `${JSON.stringify(entry)}
112
- `;
113
- await rotateLogFileIfNeeded(this.filePath, Buffer.byteLength(line, "utf8"), this.maxFileBytes, this.maxFiles);
114
- await appendFile(this.filePath, line, { mode: 384 });
115
- }
116
- };
117
- function createFileLogger(options = {}) {
118
- return new FileLogger(options);
119
- }
120
- function getLinkLogFile(paths = resolveRuntimePaths(), fileName = DEFAULT_LOG_FILE) {
121
- return path2.join(paths.logsDir, fileName);
122
- }
123
- function getGatewayRuntimeLogFile(paths = resolveRuntimePaths()) {
124
- return getLinkLogFile(paths, GATEWAY_LOG_FILE);
125
- }
126
- function getGatewayLogFiles(paths = resolveRuntimePaths()) {
127
- const runtimeGatewayLog = getGatewayRuntimeLogFile(paths);
128
- const effectiveHome = path2.basename(paths.homeDir) === ".hermeslink" ? path2.dirname(paths.homeDir) : os2.homedir();
129
- const hermesGatewayErrorLog = path2.join(effectiveHome, ".hermes", "logs", "gateway.error.log");
130
- return Array.from(/* @__PURE__ */ new Set([runtimeGatewayLog, hermesGatewayErrorLog]));
131
- }
132
- function createRotatingTextLogWriter(options) {
133
- const paths = options.paths ?? resolveRuntimePaths();
134
- const filePath = getLinkLogFile(paths, options.fileName);
135
- const maxFileBytes = Math.max(256, Math.floor(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES));
136
- const maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
137
- let queue = Promise.resolve();
138
- return {
139
- filePath,
140
- write(chunk) {
141
- const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
142
- if (buffer.length === 0) {
143
- return queue;
144
- }
145
- const next = queue.then(async () => {
146
- await mkdir(paths.logsDir, { recursive: true, mode: 448 });
147
- await rotateLogFileIfNeeded(filePath, buffer.length, maxFileBytes, maxFiles);
148
- await appendFile(filePath, buffer, { mode: 384 });
149
- }).catch(() => void 0);
150
- queue = next;
151
- return next;
152
- },
153
- flush() {
154
- return queue;
155
- }
156
- };
157
- }
158
- async function readRecentLogEntries(options = {}) {
159
- const paths = options.paths ?? resolveRuntimePaths();
160
- const filePath = getLinkLogFile(paths, options.fileName);
161
- const limit = clampLimit(options.limit);
162
- const maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
163
- const maxBytesPerFile = Math.max(1024, Math.floor(options.maxBytesPerFile ?? DEFAULT_MAX_BYTES_PER_FILE));
164
- const files = [
165
- filePath,
166
- ...Array.from({ length: maxFiles }, (_, index) => rotatedLogFile(filePath, index + 1))
167
- ];
168
- const entries = [];
169
- for (const file of files) {
170
- const raw = await readTail(file, maxBytesPerFile);
171
- if (!raw) continue;
172
- const lines = raw.split(/\r?\n/u).filter(Boolean);
173
- for (let index = lines.length - 1; index >= 0 && entries.length < limit; index -= 1) {
174
- const entry = parseLogLine(lines[index]);
175
- if (entry) entries.push(entry);
176
- }
177
- if (entries.length >= limit) break;
178
- }
179
- return entries.reverse();
180
- }
181
- async function readRecentTextLogEntries(options = {}) {
182
- const paths = options.paths ?? resolveRuntimePaths();
183
- const primaryFiles = options.filePaths ?? [getLinkLogFile(paths, options.fileName)];
184
- const limit = clampLimit(options.limit);
185
- const maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
186
- const maxBytesPerFile = Math.max(1024, Math.floor(options.maxBytesPerFile ?? DEFAULT_MAX_BYTES_PER_FILE));
187
- const files = primaryFiles.flatMap((filePath) => [
188
- filePath,
189
- ...Array.from({ length: maxFiles }, (_, index) => rotatedLogFile(filePath, index + 1))
190
- ]);
191
- const entries = [];
192
- for (const file of files) {
193
- const tail = await readTailWithMetadata(file, maxBytesPerFile);
194
- if (!tail) continue;
195
- const lines = tail.content.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
196
- for (let index = lines.length - 1; index >= 0 && entries.length < limit; index -= 1) {
197
- entries.push(parseTextLogLine(lines[index], tail.modifiedAt));
198
- }
199
- if (entries.length >= limit) break;
200
- }
201
- return entries.reverse();
202
- }
203
- function readRecentGatewayLogEntries(options = {}) {
204
- const paths = options.paths ?? resolveRuntimePaths();
205
- return readRecentTextLogEntries({
206
- ...options,
207
- paths,
208
- filePaths: options.filePaths ?? getGatewayLogFiles(paths)
209
- });
210
- }
211
- function createLogger(options) {
212
- const paths = options.paths ?? resolveRuntimePaths();
213
- const logFile = getLinkLogFile(paths, options.fileName);
214
- return pino({ level: options.level ?? "warn" }, pino.destination({ dest: logFile, sync: false, mkdir: true }));
215
- }
216
- function clampLimit(value) {
217
- if (typeof value !== "number" || !Number.isFinite(value)) {
218
- return DEFAULT_READ_LIMIT;
219
- }
220
- return Math.min(MAX_READ_LIMIT, Math.max(1, Math.floor(value)));
221
- }
222
- async function rotateLogFileIfNeeded(filePath, nextBytes, maxFileBytes, maxFiles) {
223
- const current = await stat(filePath).catch(() => null);
224
- if (!current || current.size === 0 || current.size + nextBytes <= maxFileBytes) return;
225
- if (maxFiles === 0) {
226
- await rm(filePath, { force: true }).catch(() => void 0);
227
- return;
228
- }
229
- await rm(rotatedLogFile(filePath, maxFiles), { force: true }).catch(() => void 0);
230
- for (let index = maxFiles - 1; index >= 1; index -= 1) {
231
- await moveIfExists(rotatedLogFile(filePath, index), rotatedLogFile(filePath, index + 1));
232
- }
233
- await moveIfExists(filePath, rotatedLogFile(filePath, 1));
234
- }
235
- function sanitizeFields(fields) {
236
- return sanitizeObject(fields, 0);
237
- }
238
- function sanitizeValue(value, depth) {
239
- if (value === null || typeof value === "boolean") return value;
240
- if (typeof value === "number") return Number.isFinite(value) ? value : null;
241
- if (typeof value === "string") return value.length > 2e3 ? `${value.slice(0, 2e3)}...` : value;
242
- if (Array.isArray(value)) {
243
- if (depth >= 3) return "[array]";
244
- return value.slice(0, 20).map((item) => sanitizeValue(item, depth + 1));
245
- }
246
- if (typeof value === "object" && value !== null) {
247
- if (depth >= 3) return "[object]";
248
- return sanitizeObject(value, depth + 1);
249
- }
250
- return String(value);
251
- }
252
- function sanitizeObject(value, depth) {
253
- const result = {};
254
- for (const [key, child] of Object.entries(value).slice(0, 50)) {
255
- if (isSensitiveKey(key)) {
256
- result[key] = "[redacted]";
257
- continue;
258
- }
259
- result[key] = sanitizeValue(child, depth);
260
- }
261
- return result;
262
- }
263
- function isSensitiveKey(key) {
264
- return /(authorization|cookie|token|secret|password|private[_-]?key|api[_-]?key)/iu.test(key);
265
- }
266
- function parseLogLine(line) {
267
- try {
268
- const value = JSON.parse(line);
269
- if (!value || typeof value.ts !== "string" || !isLogLevel(value.level) || typeof value.message !== "string") {
270
- return null;
271
- }
272
- return {
273
- ts: value.ts,
274
- level: value.level,
275
- message: value.message,
276
- timestampSource: "structured",
277
- ...value.fields && typeof value.fields === "object" ? { fields: value.fields } : {}
278
- };
279
- } catch {
280
- return null;
281
- }
282
- }
283
- function parseTextLogLine(line, fallbackTimestamp) {
284
- const embeddedTimestamp = readTimestampFromTextLog(line);
285
- return {
286
- ts: embeddedTimestamp ?? fallbackTimestamp ?? null,
287
- level: inferTextLogLevel(line),
288
- message: line,
289
- ...embeddedTimestamp ? { timestampSource: "embedded" } : fallbackTimestamp ? { timestampSource: "file_mtime" } : {}
290
- };
291
- }
292
- function readTimestampFromTextLog(line) {
293
- const iso = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/u.exec(line);
294
- if (iso) return iso[0];
295
- const bracketed = /^\[?(?<value>\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]?/u.exec(line);
296
- if (!bracketed?.groups?.value) return null;
297
- const parsed = new Date(bracketed.groups.value.replace(" ", "T"));
298
- return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
299
- }
300
- function inferTextLogLevel(line) {
301
- if (/\b(error|fatal|traceback|failed|failure)\b/iu.test(line)) return "error";
302
- if (/\b(warn|warning)\b/iu.test(line)) return "warn";
303
- if (/\b(debug|trace)\b/iu.test(line)) return "debug";
304
- return "info";
305
- }
306
- function isLogLevel(value) {
307
- return value === "debug" || value === "info" || value === "warn" || value === "error";
308
- }
309
- async function readTail(filePath, maxBytes) {
310
- const tail = await readTailWithMetadata(filePath, maxBytes);
311
- return tail?.content ?? null;
312
- }
313
- async function readTailWithMetadata(filePath, maxBytes) {
314
- const info = await stat(filePath).catch(() => null);
315
- if (!info || info.size <= 0) return null;
316
- const modifiedAt = info.mtime.toISOString();
317
- if (info.size <= maxBytes) {
318
- const content = await readFile(filePath, "utf8").catch(() => null);
319
- return content === null ? null : { content, modifiedAt };
320
- }
321
- const handle = await open(filePath, "r").catch(() => null);
322
- if (!handle) return null;
323
- try {
324
- const length = Math.min(info.size, maxBytes);
325
- const buffer = Buffer.alloc(length);
326
- await handle.read(buffer, 0, length, info.size - length);
327
- return { content: buffer.toString("utf8"), modifiedAt };
328
- } finally {
329
- await handle.close();
330
- }
331
- }
332
- async function moveIfExists(from, to) {
333
- await rm(to, { force: true }).catch(() => void 0);
334
- await rename(from, to).catch((error) => {
335
- if (error.code !== "ENOENT") throw error;
336
- });
337
- }
338
- function rotatedLogFile(filePath, index) {
339
- return `${filePath}.${index}`;
340
- }
341
-
342
- // src/http/routes/system.ts
343
- import Router from "@koa/router";
344
-
345
- // src/autostart/autostart.ts
346
- import { execFile } from "child_process";
347
- import { mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile } from "fs/promises";
348
- import os3 from "os";
349
- import path3 from "path";
350
- import { promisify } from "util";
351
- var execFileAsync = promisify(execFile);
352
- var MACOS_LABEL = "com.hermes.link";
353
- async function enableAutostart() {
354
- const definition = await resolveAutostartDefinition();
355
- if (!definition) {
356
- return unsupportedStatus();
357
- }
358
- await mkdir2(path3.dirname(definition.filePath), { recursive: true, mode: 448 });
359
- await writeFile(definition.filePath, definition.content, { mode: 384 });
360
- if (definition.method === "systemd-user") {
361
- await execFileAsync("systemctl", ["--user", "enable", path3.basename(definition.filePath)]).catch(async () => {
362
- await rm2(definition.filePath, { force: true }).catch(() => void 0);
363
- const fallback = xdgAutostartDefinition();
364
- await mkdir2(path3.dirname(fallback.filePath), { recursive: true, mode: 448 });
365
- await writeFile(fallback.filePath, fallback.content, { mode: 384 });
366
- });
367
- }
368
- return getAutostartStatus();
369
- }
370
- async function disableAutostart() {
371
- const definitions = await allAutostartDefinitions();
372
- for (const definition of definitions) {
373
- if (definition.method === "systemd-user") {
374
- await execFileAsync("systemctl", ["--user", "disable", path3.basename(definition.filePath)]).catch(() => void 0);
375
- }
376
- await rm2(definition.filePath, { force: true }).catch(() => void 0);
377
- }
378
- return getAutostartStatus();
379
- }
380
- async function getAutostartStatus() {
381
- const definitions = await allAutostartDefinitions();
382
- if (definitions.length === 0) {
383
- return unsupportedStatus();
384
- }
385
- for (const definition of definitions) {
386
- const content = await readFile2(definition.filePath, "utf8").catch(() => null);
387
- if (content !== null) {
388
- return {
389
- supported: true,
390
- enabled: true,
391
- method: definition.method,
392
- filePath: definition.filePath
393
- };
394
- }
395
- }
396
- const primary = definitions[0];
397
- return {
398
- supported: true,
399
- enabled: false,
400
- method: primary.method,
401
- filePath: primary.filePath
402
- };
403
- }
404
- async function resolveAutostartDefinition() {
405
- if (process.platform === "darwin") {
406
- return launchdDefinition();
407
- }
408
- if (process.platform === "win32") {
409
- return windowsStartupDefinition();
410
- }
411
- if (process.platform === "linux") {
412
- return await hasSystemctlUser() ? systemdUserDefinition() : xdgAutostartDefinition();
413
- }
414
- return null;
415
- }
416
- async function allAutostartDefinitions() {
417
- if (process.platform === "darwin") {
418
- return [launchdDefinition()];
419
- }
420
- if (process.platform === "win32") {
421
- return [windowsStartupDefinition()];
422
- }
423
- if (process.platform === "linux") {
424
- return [systemdUserDefinition(), xdgAutostartDefinition()];
425
- }
426
- return [];
427
- }
428
- async function hasSystemctlUser() {
429
- try {
430
- await execFileAsync("systemctl", ["--user", "show-environment"]);
431
- return true;
432
- } catch {
433
- return false;
434
- }
435
- }
436
- function launchdDefinition() {
437
- const filePath = path3.join(os3.homedir(), "Library", "LaunchAgents", `${MACOS_LABEL}.plist`);
438
- return {
439
- method: "launchd",
440
- filePath,
441
- content: `<?xml version="1.0" encoding="UTF-8"?>
442
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
443
- <plist version="1.0">
444
- <dict>
445
- <key>Label</key>
446
- <string>${MACOS_LABEL}</string>
447
- <key>ProgramArguments</key>
448
- <array>
449
- <string>${xmlEscape(process.execPath)}</string>
450
- <string>${xmlEscape(currentCliScriptPath())}</string>
451
- <string>daemon-supervisor</string>
452
- </array>
453
- <key>RunAtLoad</key>
454
- <true/>
455
- <key>KeepAlive</key>
456
- <false/>
457
- </dict>
458
- </plist>
459
- `
460
- };
461
- }
462
- function systemdUserDefinition() {
463
- const filePath = path3.join(os3.homedir(), ".config", "systemd", "user", "hermeslink.service");
464
- return {
465
- method: "systemd-user",
466
- filePath,
467
- content: `[Unit]
468
- Description=Hermes Link
469
- After=network-online.target
470
-
471
- [Service]
472
- Type=simple
473
- ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(currentCliScriptPath())} daemon-supervisor
474
- Restart=no
475
-
476
- [Install]
477
- WantedBy=default.target
478
- `
479
- };
480
- }
481
- function xdgAutostartDefinition() {
482
- const filePath = path3.join(os3.homedir(), ".config", "autostart", "hermeslink.desktop");
483
- return {
484
- method: "xdg-autostart",
485
- filePath,
486
- content: `[Desktop Entry]
487
- Type=Application
488
- Name=Hermes Link
489
- Exec=${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon-supervisor
490
- Terminal=false
491
- X-GNOME-Autostart-enabled=true
492
- `
493
- };
494
- }
495
- function windowsStartupDefinition() {
496
- const appData = process.env.APPDATA ?? path3.join(os3.homedir(), "AppData", "Roaming");
497
- const filePath = path3.join(
498
- appData,
499
- "Microsoft",
500
- "Windows",
501
- "Start Menu",
502
- "Programs",
503
- "Startup",
504
- "HermesLink.cmd"
505
- );
506
- return {
507
- method: "windows-startup",
508
- filePath,
509
- content: `@echo off\r
510
- start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon-supervisor\r
511
- `
512
- };
513
- }
514
- function unsupportedStatus() {
515
- return { supported: false, enabled: false, method: "unsupported", filePath: null };
516
- }
517
- function xmlEscape(value) {
518
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
519
- }
520
- function systemdQuote(value) {
521
- return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
522
- }
523
- function desktopQuote(value) {
524
- return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
525
- }
526
- function currentCliScriptPath() {
527
- return process.argv[1];
528
- }
529
-
530
- // src/network/environment.ts
531
- import { existsSync, readFileSync } from "fs";
532
- import os4 from "os";
533
- function detectRuntimeEnvironment(env = process.env) {
534
- if (isWsl(env)) {
535
- return {
536
- kind: "wsl",
537
- lanAutoDiscoveryUsable: false,
538
- warning: "Detected WSL. The LAN IP found inside WSL is usually a private VM address and is not reachable from your phone. Use Relay or set `hermeslink config set lan-host <Windows LAN IP>`."
539
- };
540
- }
541
- if (isContainer(env)) {
542
- return {
543
- kind: "container",
544
- lanAutoDiscoveryUsable: false,
545
- warning: "Detected a container environment. Container LAN IPs are usually not reachable from your phone. Use Relay or set `hermeslink config set lan-host <host LAN IP>`."
546
- };
547
- }
548
- return { kind: "native", lanAutoDiscoveryUsable: true, warning: null };
549
- }
550
- function isWsl(env) {
551
- if (process.platform !== "linux") {
552
- return false;
553
- }
554
- if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
555
- return true;
556
- }
557
- const release = os4.release().toLowerCase();
558
- return release.includes("microsoft") || release.includes("wsl");
559
- }
560
- function isContainer(env) {
561
- if (env.container || env.CONTAINER || env.KUBERNETES_SERVICE_HOST) {
562
- return true;
563
- }
564
- if (existsSync("/.dockerenv")) {
565
- return true;
566
- }
567
- try {
568
- const cgroup = readFileSync("/proc/1/cgroup", "utf8").toLowerCase();
569
- return /docker|containerd|kubepods|libpod|podman/u.test(cgroup);
570
- } catch {
571
- return false;
572
- }
573
- }
574
-
575
- // src/storage/atomic-json.ts
576
- import { readFile as readFile3 } from "fs/promises";
577
-
578
- // src/storage/atomic-file.ts
579
- import { randomUUID } from "crypto";
580
- import {
581
- chmod,
582
- chown,
583
- lstat,
584
- mkdir as mkdir3,
585
- open as open2,
586
- readdir,
587
- rename as rename2,
588
- rm as rm3,
589
- stat as stat2
590
- } from "fs/promises";
591
- import path4 from "path";
592
- async function atomicWriteFilePreservingMetadata(filePath, value, options = {}) {
593
- const resolvedPath = path4.resolve(filePath);
594
- const directory = path4.dirname(resolvedPath);
595
- await ensureDirectoryWithInheritedMetadata(directory, options.directoryMode ?? 448);
596
- const existingMetadata = await readExistingFileMetadata(resolvedPath) ?? (options.metadataSourcePath ? await readExistingFileMetadata(path4.resolve(options.metadataSourcePath)) : null);
597
- const directoryMetadata = await readPathMetadata(directory);
598
- const metadata = {
599
- uid: existingMetadata?.uid ?? directoryMetadata.uid,
600
- gid: existingMetadata?.gid ?? directoryMetadata.gid,
601
- mode: existingMetadata?.mode ?? options.mode ?? 384
602
- };
603
- const tempPath = path4.join(
604
- directory,
605
- `.${path4.basename(resolvedPath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`
606
- );
607
- try {
608
- const handle = await open2(tempPath, "wx", metadata.mode);
609
- try {
610
- if (typeof value === "string") {
611
- await handle.writeFile(value, options.encoding ?? "utf8");
612
- } else {
613
- await handle.writeFile(value);
614
- }
615
- await handle.sync();
616
- } finally {
617
- await handle.close();
618
- }
619
- await applyMetadata(tempPath, metadata);
620
- await rename2(tempPath, resolvedPath);
621
- } catch (error) {
622
- await rm3(tempPath, { force: true });
623
- throw error;
624
- }
625
- }
626
- async function ensureDirectoryWithInheritedMetadata(directory, mode) {
627
- const { source, missing } = await findExistingAncestor(directory);
628
- await mkdir3(directory, { recursive: true, mode });
629
- for (const missingDirectory of missing) {
630
- await applyMetadata(missingDirectory, { uid: source.uid, gid: source.gid, mode });
631
- }
632
- }
633
- async function findExistingAncestor(directory) {
634
- const missing = [];
635
- let current = path4.resolve(directory);
636
- while (true) {
637
- const currentStat = await stat2(current).catch((error) => {
638
- if (isNodeError(error, "ENOENT")) return null;
639
- throw error;
640
- });
641
- if (currentStat) {
642
- if (!currentStat.isDirectory()) throw new Error(`${current} is not a directory`);
643
- return { source: metadataFromStats(currentStat), missing: missing.reverse() };
644
- }
645
- missing.push(current);
646
- const parent = path4.dirname(current);
647
- if (parent === current) throw new Error(`No existing parent directory for ${directory}`);
648
- current = parent;
649
- }
650
- }
651
- async function readExistingFileMetadata(filePath) {
652
- const fileStat = await stat2(filePath).catch((error) => {
653
- if (isNodeError(error, "ENOENT")) return null;
654
- throw error;
655
- });
656
- if (!fileStat) return null;
657
- if (!fileStat.isFile()) throw new Error(`${filePath} is not a file`);
658
- return metadataFromStats(fileStat);
659
- }
660
- async function readPathMetadata(filePath) {
661
- return metadataFromStats(await stat2(filePath));
662
- }
663
- async function applyMetadata(filePath, metadata) {
664
- await applyOwner(filePath, metadata);
665
- await chmod(filePath, metadata.mode);
666
- }
667
- async function applyOwner(filePath, metadata) {
668
- if (process.platform === "win32") return;
669
- const currentUid = typeof process.getuid === "function" ? process.getuid() : void 0;
670
- const currentGid = typeof process.getgid === "function" ? process.getgid() : void 0;
671
- if (metadata.uid === currentUid && metadata.gid === currentGid) return;
672
- try {
673
- await chown(filePath, metadata.uid, metadata.gid);
674
- } catch (error) {
675
- const current = await stat2(filePath);
676
- if (current.uid !== metadata.uid || current.gid !== metadata.gid) throw error;
677
- }
678
- }
679
- function metadataFromStats(statsValue) {
680
- return { uid: statsValue.uid, gid: statsValue.gid, mode: statsValue.mode & 511 };
681
- }
682
- function isNodeError(error, code) {
683
- return typeof error === "object" && error !== null && "code" in error && error.code === code;
684
- }
685
-
686
- // src/storage/atomic-json.ts
687
- async function readJsonFile(filePath) {
688
- try {
689
- const raw = await readFile3(filePath, "utf8");
690
- return JSON.parse(raw);
691
- } catch (error) {
692
- if (isNodeError(error, "ENOENT")) return null;
693
- throw error;
694
- }
695
- }
696
- async function writeJsonFile(filePath, value, mode = 384) {
697
- const payload = `${JSON.stringify(value, null, 2)}
698
- `;
699
- await atomicWriteFilePreservingMetadata(filePath, payload, { mode });
700
- }
701
-
702
- // src/link/state.ts
703
- import path5 from "path";
704
- var STATE_FILE = "link-state.json";
705
- function stateFilePath(paths) {
706
- return path5.join(paths.homeDir, STATE_FILE);
707
- }
708
- function defaultLinkState() {
709
- return {
710
- networkReport: {
711
- lastReportedAt: null,
712
- preferredUrls: [],
713
- lanIps: [],
714
- publicIpv4s: [],
715
- publicIpv6s: []
716
- },
717
- updateAvailable: null,
718
- updateDismissedAt: null
719
- };
720
- }
721
- async function readLinkState(paths) {
722
- const runtimePaths = paths ?? resolveRuntimePaths();
723
- const raw = await readJsonFile(stateFilePath(runtimePaths));
724
- if (!raw || typeof raw !== "object") return defaultLinkState();
725
- const state = raw;
726
- return {
727
- networkReport: readNetworkReportState(state.networkReport),
728
- updateAvailable: typeof state.updateAvailable === "string" ? state.updateAvailable : null,
729
- updateDismissedAt: typeof state.updateDismissedAt === "string" ? state.updateDismissedAt : null
730
- };
731
- }
732
- function readNetworkReportState(value) {
733
- if (!value || typeof value !== "object") {
734
- return defaultLinkState().networkReport;
735
- }
736
- const v = value;
737
- return {
738
- lastReportedAt: typeof v.lastReportedAt === "string" ? v.lastReportedAt : null,
739
- preferredUrls: readStringArray(v.preferredUrls),
740
- lanIps: readStringArray(v.lanIps),
741
- publicIpv4s: readStringArray(v.publicIpv4s),
742
- publicIpv6s: readStringArray(v.publicIpv6s)
743
- };
744
- }
745
- function readStringArray(value) {
746
- if (!Array.isArray(value)) return [];
747
- return value.filter((v) => typeof v === "string");
748
- }
749
- async function updateNetworkReportState(update, paths) {
750
- const runtimePaths = paths ?? resolveRuntimePaths();
751
- const current = await readLinkState(runtimePaths);
752
- const next = {
753
- ...current,
754
- networkReport: { ...current.networkReport, ...update }
755
- };
756
- await writeJsonFile(stateFilePath(runtimePaths), next);
757
- return next;
758
- }
759
- async function updateLinkState(update, paths) {
760
- const runtimePaths = paths ?? resolveRuntimePaths();
761
- const current = await readLinkState(runtimePaths);
762
- const next = { ...current, ...update };
763
- await writeJsonFile(stateFilePath(runtimePaths), next);
764
- return next;
765
- }
766
-
767
- // src/link/updates.ts
768
- async function checkForUpdates(options) {
769
- const paths = options.paths ?? resolveRuntimePaths();
770
- const state = await readLinkState(paths);
771
- const fetcher = options.fetchImpl ?? fetch;
772
- try {
773
- const response = await fetcher(
774
- `${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link-versions/latest`,
775
- { signal: AbortSignal.timeout(5e3) }
776
- );
777
- if (!response.ok) {
778
- return buildUpdateInfo(state.updateAvailable, state.updateDismissedAt);
779
- }
780
- const body = await response.json().catch(() => null);
781
- const latestVersion = typeof body?.version === "string" ? body.version : null;
782
- if (latestVersion && latestVersion !== LINK_VERSION) {
783
- await updateLinkState({ updateAvailable: latestVersion }, paths);
784
- return buildUpdateInfo(latestVersion, state.updateDismissedAt);
785
- }
786
- return buildUpdateInfo(latestVersion, state.updateDismissedAt);
787
- } catch {
788
- return buildUpdateInfo(state.updateAvailable, state.updateDismissedAt);
789
- }
790
- }
791
- function buildUpdateInfo(availableVersion, dismissedAt) {
792
- return {
793
- currentVersion: LINK_VERSION,
794
- availableVersion,
795
- dismissed: dismissedAt !== null
796
- };
797
- }
798
- async function dismissUpdate(paths) {
799
- const runtimePaths = paths ?? resolveRuntimePaths();
800
- await updateLinkState({ updateDismissedAt: (/* @__PURE__ */ new Date()).toISOString() }, runtimePaths);
801
- }
802
-
803
- // src/security/credentials.ts
804
- import { randomBytes, randomUUID as randomUUID2, timingSafeEqual, createHash } from "crypto";
805
- var ACCESS_TOKEN_TTL_MS = 15 * 60 * 1e3;
806
- var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
807
- var DEVICE_SEEN_WRITE_INTERVAL_MS = 60 * 60 * 1e3;
808
- function credentialsPath(paths) {
809
- return paths.credentialsFile;
810
- }
811
- function sha256(value) {
812
- return createHash("sha256").update(value).digest("hex");
813
- }
814
- function safeEqual(a, b) {
815
- const ab = Buffer.from(a);
816
- const bb = Buffer.from(b);
817
- return ab.length === bb.length && timingSafeEqual(ab, bb);
818
- }
819
- function randomToken(prefix) {
820
- return `${prefix}${randomBytes(24).toString("base64url")}`;
821
- }
822
- async function readCredentialStore(paths) {
823
- const raw = await readJsonFile(credentialsPath(paths));
824
- if (!raw || typeof raw !== "object") return { devices: [] };
825
- const store = raw;
826
- return { devices: Array.isArray(store.devices) ? store.devices : [] };
827
- }
828
- async function writeCredentialStore(paths, store) {
829
- await writeJsonFile(credentialsPath(paths), store);
830
- }
831
- function formatDeviceSession(device, accessToken, refreshToken) {
832
- return {
833
- device: {
834
- id: device.id,
835
- device_id: device.id,
836
- label: device.label,
837
- platform: device.platform,
838
- model: device.model ?? null,
839
- scope: device.scope
840
- },
841
- accessToken: { token: accessToken, expiresAt: device.access_expires_at },
842
- refreshToken: { token: refreshToken, expiresAt: device.refresh_expires_at }
843
- };
844
- }
845
- function formatDeviceListItem(device) {
846
- return {
847
- id: device.id,
848
- device_id: device.id,
849
- label: device.label,
850
- platform: device.platform,
851
- model: device.model ?? null,
852
- scope: device.scope,
853
- status: device.revoked_at ? "revoked" : "active",
854
- paired_at: device.created_at,
855
- created_at: device.created_at,
856
- updated_at: device.updated_at,
857
- access_expires_at: device.access_expires_at,
858
- refresh_expires_at: device.refresh_expires_at,
859
- last_seen_at: device.last_seen_at ?? null,
860
- revoked_at: device.revoked_at,
861
- app_hidden_at: device.app_hidden_at ?? null,
862
- app_instance_bound: Boolean(device.app_instance_hash)
863
- };
864
- }
865
- async function rotateDeviceSession(store, device, now, paths) {
866
- const accessToken = randomToken("hpat_");
867
- const refreshToken = randomToken("hprt_");
868
- device.access_token_hash = sha256(accessToken);
869
- device.access_expires_at = new Date(now.getTime() + ACCESS_TOKEN_TTL_MS).toISOString();
870
- device.refresh_token_hash = sha256(refreshToken);
871
- device.refresh_expires_at = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS).toISOString();
872
- device.last_seen_at = now.toISOString();
873
- device.updated_at = now.toISOString();
874
- await writeCredentialStore(paths, store);
875
- return formatDeviceSession(device, accessToken, refreshToken);
876
- }
877
- function hashAppInstanceId(value) {
878
- const normalized = normalizeAppInstanceId(value);
879
- return normalized ? sha256(`hermespilot-app-instance:${normalized}`) : null;
880
- }
881
- function normalizeAppInstanceId(value) {
882
- if (typeof value !== "string") return null;
883
- const trimmed = value.trim();
884
- return /^appi_[A-Za-z0-9_-]{16,96}$/u.test(trimmed) ? trimmed : null;
885
- }
886
- function normalizeDeviceLabel(value) {
887
- const trimmed = value.trim();
888
- return trimmed ? trimmed.slice(0, 128) : "HermesPilot App";
889
- }
890
- function normalizeDevicePlatform(value) {
891
- const trimmed = value.trim().toLowerCase();
892
- return trimmed ? trimmed.slice(0, 48) : "unknown";
893
- }
894
- function normalizeDeviceModel(value) {
895
- const trimmed = value?.trim();
896
- return trimmed ? trimmed.slice(0, 128) : null;
897
- }
898
- function isDeviceVisibleInApp(device) {
899
- return !device.app_hidden_at;
900
- }
901
- function maybeBindAppInstance(store, device, appInstanceId) {
902
- const appInstanceHash = hashAppInstanceId(appInstanceId);
903
- if (!appInstanceHash || device.app_instance_hash === appInstanceHash) return false;
904
- if (device.app_instance_hash) return false;
905
- const existing = store.devices.find((d) => d.id !== device.id && d.app_instance_hash === appInstanceHash);
906
- if (existing) return false;
907
- device.app_instance_hash = appInstanceHash;
908
- return true;
909
- }
910
- function updateDeviceDescriptor(device, input) {
911
- let changed = false;
912
- if (input.label !== void 0 && input.label !== null) {
913
- const label = normalizeDeviceLabel(input.label);
914
- if (device.label !== label) {
915
- device.label = label;
916
- changed = true;
917
- }
918
- }
919
- if (input.platform !== void 0 && input.platform !== null) {
920
- const platform = normalizeDevicePlatform(input.platform);
921
- if (device.platform !== platform) {
922
- device.platform = platform;
923
- changed = true;
924
- }
925
- }
926
- if (input.model !== void 0 && input.model !== null) {
927
- const model = normalizeDeviceModel(input.model);
928
- if ((device.model ?? null) !== model) {
929
- device.model = model;
930
- changed = true;
931
- }
932
- }
933
- return changed;
934
- }
935
- async function createDeviceSession(input, paths = resolveRuntimePaths()) {
936
- const store = await readCredentialStore(paths);
937
- const now = /* @__PURE__ */ new Date();
938
- const appInstanceHash = hashAppInstanceId(input.appInstanceId);
939
- if (appInstanceHash) {
940
- const existing = store.devices.find((d) => d.app_instance_hash === appInstanceHash);
941
- if (existing) {
942
- updateDeviceDescriptor(existing, input);
943
- existing.revoked_at = null;
944
- existing.app_hidden_at = null;
945
- return rotateDeviceSession(store, existing, now, paths);
946
- }
947
- }
948
- const device = {
949
- id: `dev_${randomUUID2().replaceAll("-", "")}`,
950
- label: normalizeDeviceLabel(input.label),
951
- platform: normalizeDevicePlatform(input.platform),
952
- model: normalizeDeviceModel(input.model),
953
- scope: "admin",
954
- app_instance_hash: appInstanceHash,
955
- access_token_hash: "",
956
- access_expires_at: "",
957
- refresh_token_hash: "",
958
- refresh_expires_at: "",
959
- created_at: now.toISOString(),
960
- updated_at: now.toISOString(),
961
- last_seen_at: now.toISOString(),
962
- revoked_at: null,
963
- app_hidden_at: null
964
- };
965
- store.devices.push(device);
966
- return rotateDeviceSession(store, device, now, paths);
967
- }
968
- async function refreshDeviceSession(refreshToken, options = {}, paths = resolveRuntimePaths()) {
969
- const tokenHash = sha256(refreshToken);
970
- const store = await readCredentialStore(paths);
971
- const device = store.devices.find((d) => safeEqual(d.refresh_token_hash, tokenHash));
972
- if (!device || device.revoked_at || Date.parse(device.refresh_expires_at) <= Date.now()) {
973
- throw Object.assign(new Error("Refresh token is invalid or expired"), { status: 401, code: "refresh_token_invalid" });
974
- }
975
- const now = /* @__PURE__ */ new Date();
976
- maybeBindAppInstance(store, device, options.appInstanceId);
977
- updateDeviceDescriptor(device, options);
978
- return rotateDeviceSession(store, device, now, paths);
979
- }
980
- async function revokeDeviceRefreshToken(refreshToken, paths = resolveRuntimePaths()) {
981
- const tokenHash = sha256(refreshToken);
982
- const store = await readCredentialStore(paths);
983
- const device = store.devices.find((d) => safeEqual(d.refresh_token_hash, tokenHash));
984
- if (!device || device.revoked_at) return;
985
- device.revoked_at = (/* @__PURE__ */ new Date()).toISOString();
986
- device.updated_at = device.revoked_at;
987
- await writeCredentialStore(paths, store);
988
- }
989
- async function authenticateDeviceAccessToken(token, paths = resolveRuntimePaths()) {
990
- const tokenHash = sha256(token);
991
- const store = await readCredentialStore(paths);
992
- const device = store.devices.find((d) => safeEqual(d.access_token_hash, tokenHash));
993
- if (!device || device.revoked_at || Date.parse(device.access_expires_at) <= Date.now()) return null;
994
- return device;
995
- }
996
- async function recordDeviceSeen(deviceId, options = {}, paths = resolveRuntimePaths()) {
997
- const store = await readCredentialStore(paths);
998
- const device = store.devices.find((d) => d.id === deviceId);
999
- if (!device || device.revoked_at) return device ?? null;
1000
- const now = /* @__PURE__ */ new Date();
1001
- const bound = maybeBindAppInstance(store, device, options.appInstanceId);
1002
- const descriptorUpdated = updateDeviceDescriptor(device, { model: options.model });
1003
- const shouldTouch = !device.last_seen_at || Number.isNaN(Date.parse(device.last_seen_at)) || now.getTime() - Date.parse(device.last_seen_at) >= DEVICE_SEEN_WRITE_INTERVAL_MS;
1004
- if (bound || descriptorUpdated || shouldTouch) {
1005
- device.last_seen_at = now.toISOString();
1006
- device.updated_at = now.toISOString();
1007
- await writeCredentialStore(paths, store);
1008
- }
1009
- return device;
1010
- }
1011
- async function listDevices(paths = resolveRuntimePaths()) {
1012
- const store = await readCredentialStore(paths);
1013
- return store.devices.filter(isDeviceVisibleInApp).map(formatDeviceListItem).sort((a, b) => {
1014
- if (a.status !== b.status) return a.status === "active" ? -1 : 1;
1015
- return Date.parse(b.created_at) - Date.parse(a.created_at);
1016
- });
1017
- }
1018
- async function readDeviceSummary(paths = resolveRuntimePaths()) {
1019
- const store = await readCredentialStore(paths);
1020
- const visible = store.devices.filter(isDeviceVisibleInApp);
1021
- const revoked = visible.filter((d) => d.revoked_at).length;
1022
- return { total: visible.length, active: visible.length - revoked, revoked };
1023
- }
1024
- async function revokeDeviceById(deviceId, paths = resolveRuntimePaths()) {
1025
- const store = await readCredentialStore(paths);
1026
- const device = store.devices.find((d) => d.id === deviceId);
1027
- if (!device) {
1028
- throw Object.assign(new Error("Device was not found"), { status: 404, code: "device_not_found" });
1029
- }
1030
- if (!device.revoked_at) {
1031
- device.revoked_at = (/* @__PURE__ */ new Date()).toISOString();
1032
- device.updated_at = device.revoked_at;
1033
- await writeCredentialStore(paths, store);
1034
- }
1035
- return formatDeviceListItem(device);
1036
- }
1037
- async function hideRevokedDeviceFromAppList(deviceId, paths = resolveRuntimePaths()) {
1038
- const store = await readCredentialStore(paths);
1039
- const device = store.devices.find((d) => d.id === deviceId);
1040
- if (!device) {
1041
- throw Object.assign(new Error("Device was not found"), { status: 404, code: "device_not_found" });
1042
- }
1043
- if (!device.revoked_at) {
1044
- throw Object.assign(new Error("Device must be revoked before it can be deleted from the app list"), {
1045
- status: 409,
1046
- code: "device_still_active"
1047
- });
1048
- }
1049
- if (!device.app_hidden_at) {
1050
- device.app_hidden_at = (/* @__PURE__ */ new Date()).toISOString();
1051
- device.updated_at = device.app_hidden_at;
1052
- await writeCredentialStore(paths, store);
1053
- }
1054
- return formatDeviceListItem(device);
1055
- }
1056
- async function renameDeviceById(deviceId, label, paths = resolveRuntimePaths()) {
1057
- const normalizedLabel = label.trim();
1058
- if (!normalizedLabel || normalizedLabel.length > 128) {
1059
- throw Object.assign(new Error("Device label is invalid"), { status: 400, code: "device_label_invalid" });
1060
- }
1061
- const store = await readCredentialStore(paths);
1062
- const device = store.devices.find((d) => d.id === deviceId);
1063
- if (!device) {
1064
- throw Object.assign(new Error("Device was not found"), { status: 404, code: "device_not_found" });
1065
- }
1066
- if (device.revoked_at) {
1067
- throw Object.assign(new Error("Device is revoked"), { status: 409, code: "device_revoked" });
1068
- }
1069
- device.label = normalizedLabel.slice(0, 128);
1070
- device.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1071
- await writeCredentialStore(paths, store);
1072
- return formatDeviceListItem(device);
1073
- }
1074
-
1075
- // src/security/app-connect-token.ts
1076
- import crypto from "crypto";
1077
- import path6 from "path";
1078
- var TOKENS_FILE = "app-connect-tokens.json";
1079
- var TOKEN_EXPIRY_MS = 5 * 60 * 1e3;
1080
- function tokensFilePath(paths) {
1081
- return path6.join(paths.homeDir, TOKENS_FILE);
1082
- }
1083
- async function readTokens(paths) {
1084
- const raw = await readJsonFile(tokensFilePath(paths));
1085
- if (!Array.isArray(raw)) return [];
1086
- const now = /* @__PURE__ */ new Date();
1087
- return raw.filter(isValidToken).filter((t) => new Date(t.expiresAt) > now);
1088
- }
1089
- function isValidToken(value) {
1090
- if (!value || typeof value !== "object") return false;
1091
- const t = value;
1092
- return typeof t.token === "string" && typeof t.createdAt === "string" && typeof t.expiresAt === "string";
1093
- }
1094
- async function saveTokens(tokens, paths) {
1095
- await writeJsonFile(tokensFilePath(paths), tokens);
1096
- }
1097
- async function generateAppConnectToken(paths) {
1098
- const runtimePaths = paths ?? resolveRuntimePaths();
1099
- const now = /* @__PURE__ */ new Date();
1100
- const token = {
1101
- token: crypto.randomBytes(32).toString("base64url"),
1102
- createdAt: now.toISOString(),
1103
- usedAt: null,
1104
- expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS).toISOString()
1105
- };
1106
- const tokens = await readTokens(runtimePaths);
1107
- tokens.push(token);
1108
- await saveTokens(tokens, runtimePaths);
1109
- return token;
1110
- }
1111
- async function consumeAppConnectToken(tokenValue, paths) {
1112
- const runtimePaths = paths ?? resolveRuntimePaths();
1113
- const tokens = await readTokens(runtimePaths);
1114
- const index = tokens.findIndex((t) => t.token === tokenValue && !t.usedAt);
1115
- if (index === -1) return null;
1116
- tokens[index].usedAt = (/* @__PURE__ */ new Date()).toISOString();
1117
- await saveTokens(tokens, runtimePaths);
1118
- return tokens[index];
1119
- }
1120
-
1121
- // src/core/errors.ts
1122
- var LinkHttpError = class extends Error {
1123
- status;
1124
- code;
1125
- constructor(status, code, message) {
1126
- super(message);
1127
- this.status = status;
1128
- this.code = code;
1129
- }
1130
- };
1131
-
1132
- // src/http/auth.ts
1133
- async function authenticateRequest(ctx, paths = resolveRuntimePaths()) {
1134
- const token = readBearerToken(ctx.get("authorization"));
1135
- if (!token) {
1136
- throw new LinkHttpError(401, "auth_required", "Authorization bearer token is required");
1137
- }
1138
- const device = await authenticateDeviceAccessToken(token, paths);
1139
- if (device) {
1140
- return { kind: "device", device };
1141
- }
1142
- if (token.startsWith("hpat_")) {
1143
- throw new LinkHttpError(401, "device_access_token_invalid", "Device access token is invalid or expired");
1144
- }
1145
- const localToken = await consumeAppConnectToken(token, paths);
1146
- if (localToken) {
1147
- return { kind: "app-connect", accountId: null, scopes: [], appInstanceId: null };
1148
- }
1149
- throw new LinkHttpError(401, "auth_invalid", "Token is invalid or expired");
1150
- }
1151
- function readBearerToken(value) {
1152
- const trimmed = value.trim();
1153
- if (!trimmed.toLowerCase().startsWith("bearer ")) return null;
1154
- const token = trimmed.slice(7).trim();
1155
- return token || null;
1156
- }
1157
- function readAppInstanceIdHeader(ctx) {
1158
- const value = ctx.get("x-hermes-app-instance-id").trim();
1159
- return /^appi_[A-Za-z0-9_-]{16,96}$/u.test(value) ? value : null;
1160
- }
1161
- function readDeviceModelHeader(ctx) {
1162
- const value = ctx.get("x-hermes-device-model").trim();
1163
- return value ? value.slice(0, 128) : null;
1164
- }
1165
-
1166
- // src/http/routes/system.ts
1167
- function createSystemRouter(options) {
1168
- const router = new Router({ prefix: "/api/v1/system" });
1169
- const paths = options.paths;
1170
- const auth = async (ctx, next) => {
1171
- await authenticateRequest(ctx, paths);
1172
- await next();
1173
- };
1174
- router.get("/status", async (ctx) => {
1175
- const state = await readLinkState(options.paths);
1176
- const autostart = await getAutostartStatus();
1177
- const environment = detectRuntimeEnvironment();
1178
- ctx.body = {
1179
- version: LINK_VERSION,
1180
- linkId: options.identity.link_id,
1181
- installId: options.identity.install_id,
1182
- port: options.config.port,
1183
- autostart: {
1184
- supported: autostart.supported,
1185
- enabled: autostart.enabled,
1186
- method: autostart.method
1187
- },
1188
- environment: {
1189
- kind: environment.kind,
1190
- warning: environment.warning
1191
- },
1192
- networkReport: state.networkReport,
1193
- updateAvailable: state.updateAvailable
1194
- };
1195
- });
1196
- router.get("/version", (ctx) => {
1197
- ctx.body = { version: LINK_VERSION };
1198
- });
1199
- router.post("/autostart/enable", auth, async (ctx) => {
1200
- const status = await enableAutostart();
1201
- ctx.body = status;
1202
- });
1203
- router.post("/autostart/disable", auth, async (ctx) => {
1204
- const status = await disableAutostart();
1205
- ctx.body = status;
1206
- });
1207
- router.get("/logs", auth, async (ctx) => {
1208
- const limit = Number(ctx.query.limit) || void 0;
1209
- const entries = await readRecentLogEntries({ paths: options.paths, limit });
1210
- ctx.body = { entries };
1211
- });
1212
- router.get("/logs/gateway", auth, async (ctx) => {
1213
- const limit = Number(ctx.query.limit) || void 0;
1214
- const entries = await readRecentGatewayLogEntries({ paths: options.paths, limit });
1215
- ctx.body = { entries };
1216
- });
1217
- router.get("/updates", auth, async (ctx) => {
1218
- const info = await checkForUpdates({
1219
- relayBaseUrl: options.config.relayBaseUrl,
1220
- paths: options.paths
1221
- });
1222
- ctx.body = info;
1223
- });
1224
- router.post("/updates/dismiss", auth, async (ctx) => {
1225
- await dismissUpdate(options.paths);
1226
- ctx.body = { ok: true };
1227
- });
1228
- return router;
1229
- }
1230
-
1231
- // src/http/routes/statistics.ts
1232
- import Router2 from "@koa/router";
1233
- function createStatisticsRouter(options) {
1234
- const router = new Router2({ prefix: "/api/v1/statistics" });
1235
- const paths = options.paths;
1236
- const auth = async (ctx, next) => {
1237
- await authenticateRequest(ctx, paths);
1238
- await next();
1239
- };
1240
- router.get("/conversations", auth, (ctx) => {
1241
- const { from, to, profile } = ctx.query;
1242
- let query = `SELECT date, profile_name, conversation_count, message_count,
1243
- total_input_tokens, total_output_tokens
1244
- FROM conversation_stats WHERE 1=1`;
1245
- const params = [];
1246
- if (typeof from === "string") {
1247
- query += " AND date >= ?";
1248
- params.push(from);
1249
- }
1250
- if (typeof to === "string") {
1251
- query += " AND date <= ?";
1252
- params.push(to);
1253
- }
1254
- if (typeof profile === "string" && profile) {
1255
- query += " AND profile_name = ?";
1256
- params.push(profile);
1257
- }
1258
- query += " ORDER BY date DESC, profile_name ASC LIMIT 500";
1259
- const rows = options.db.prepare(query).all(...params);
1260
- ctx.body = { rows };
1261
- });
1262
- router.get("/usage", auth, (ctx) => {
1263
- const { from, to, profile, model } = ctx.query;
1264
- let query = `SELECT date, profile_name, model,
1265
- input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, run_count
1266
- FROM run_usage_facts WHERE 1=1`;
1267
- const params = [];
1268
- if (typeof from === "string") {
1269
- query += " AND date >= ?";
1270
- params.push(from);
1271
- }
1272
- if (typeof to === "string") {
1273
- query += " AND date <= ?";
1274
- params.push(to);
1275
- }
1276
- if (typeof profile === "string" && profile) {
1277
- query += " AND profile_name = ?";
1278
- params.push(profile);
1279
- }
1280
- if (typeof model === "string" && model) {
1281
- query += " AND model = ?";
1282
- params.push(model);
1283
- }
1284
- query += " ORDER BY date DESC, profile_name ASC, model ASC LIMIT 1000";
1285
- const rows = options.db.prepare(query).all(...params);
1286
- ctx.body = { rows };
1287
- });
1288
- router.post("/conversations/upsert", auth, (ctx) => {
1289
- const body = ctx.request.body;
1290
- if (!body || typeof body !== "object") {
1291
- ctx.status = 400;
1292
- ctx.body = { error: "Invalid body" };
1293
- return;
1294
- }
1295
- const { date, profileName, conversationCount, messageCount, totalInputTokens, totalOutputTokens } = body;
1296
- if (!date || !profileName) {
1297
- ctx.status = 400;
1298
- ctx.body = { error: "Missing required fields: date, profileName" };
1299
- return;
1300
- }
1301
- options.db.prepare(
1302
- `INSERT INTO conversation_stats
1303
- (date, profile_name, conversation_count, message_count, total_input_tokens, total_output_tokens)
1304
- VALUES (?, ?, ?, ?, ?, ?)
1305
- ON CONFLICT (date, profile_name) DO UPDATE SET
1306
- conversation_count = excluded.conversation_count,
1307
- message_count = excluded.message_count,
1308
- total_input_tokens = excluded.total_input_tokens,
1309
- total_output_tokens = excluded.total_output_tokens`
1310
- ).run(
1311
- date,
1312
- profileName,
1313
- Number(conversationCount) || 0,
1314
- Number(messageCount) || 0,
1315
- Number(totalInputTokens) || 0,
1316
- Number(totalOutputTokens) || 0
1317
- );
1318
- ctx.body = { ok: true };
1319
- });
1320
- router.post("/usage/upsert", auth, (ctx) => {
1321
- const body = ctx.request.body;
1322
- if (!body || typeof body !== "object") {
1323
- ctx.status = 400;
1324
- ctx.body = { error: "Invalid body" };
1325
- return;
1326
- }
1327
- const { date, profileName, model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, runCount } = body;
1328
- if (!date || !profileName || !model) {
1329
- ctx.status = 400;
1330
- ctx.body = { error: "Missing required fields: date, profileName, model" };
1331
- return;
1332
- }
1333
- options.db.prepare(
1334
- `INSERT INTO run_usage_facts
1335
- (date, profile_name, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, run_count)
1336
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1337
- ON CONFLICT (date, profile_name, model) DO UPDATE SET
1338
- input_tokens = excluded.input_tokens,
1339
- output_tokens = excluded.output_tokens,
1340
- cache_creation_tokens = excluded.cache_creation_tokens,
1341
- cache_read_tokens = excluded.cache_read_tokens,
1342
- run_count = excluded.run_count`
1343
- ).run(
1344
- date,
1345
- profileName,
1346
- model,
1347
- Number(inputTokens) || 0,
1348
- Number(outputTokens) || 0,
1349
- Number(cacheCreationTokens) || 0,
1350
- Number(cacheReadTokens) || 0,
1351
- Number(runCount) || 0
1352
- );
1353
- ctx.body = { ok: true };
1354
- });
1355
- return router;
1356
- }
1357
-
1358
- // src/http/routes/bootstrap.ts
1359
- import Router3 from "@koa/router";
1360
-
1361
- // src/identity/identity.ts
1362
- import { generateKeyPairSync, randomUUID as randomUUID3, sign } from "crypto";
1363
- import { chmod as chmod2, mkdir as mkdir4 } from "fs/promises";
1364
- import { z } from "zod";
1365
- var linkIdentitySchema = z.object({
1366
- install_id: z.string().min(1),
1367
- link_id: z.string().min(1).nullable().optional(),
1368
- public_key_pem: z.string().min(1),
1369
- private_key_pem: z.string().min(1),
1370
- created_at: z.string().min(1),
1371
- updated_at: z.string().min(1)
1372
- });
1373
- async function loadIdentity(paths = resolveRuntimePaths()) {
1374
- const value = await readJsonFile(paths.identityFile);
1375
- if (value === null) {
1376
- return null;
1377
- }
1378
- return linkIdentitySchema.parse(value);
1379
- }
1380
- async function ensureIdentity(paths = resolveRuntimePaths()) {
1381
- const existing = await loadIdentity(paths);
1382
- if (existing) {
1383
- return existing;
1384
- }
1385
- await mkdir4(paths.homeDir, { recursive: true, mode: 448 });
1386
- await chmod2(paths.homeDir, 448).catch(() => void 0);
1387
- const { publicKey, privateKey } = generateKeyPairSync("ed25519");
1388
- const now = (/* @__PURE__ */ new Date()).toISOString();
1389
- const identity = {
1390
- install_id: `install_${randomUUID3().replaceAll("-", "")}`,
1391
- link_id: null,
1392
- public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
1393
- private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
1394
- created_at: now,
1395
- updated_at: now
1396
- };
1397
- await writeJsonFile(paths.identityFile, identity);
1398
- return identity;
1399
- }
1400
- async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
1401
- const identity = await ensureIdentity(paths);
1402
- const next = {
1403
- ...identity,
1404
- link_id: linkId,
1405
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
1406
- };
1407
- await writeJsonFile(paths.identityFile, next);
1408
- return next;
1409
- }
1410
- function signRelayNonce(identity, nonce) {
1411
- return signIdentityPayload(identity, nonce);
1412
- }
1413
- function signIdentityPayload(identity, payload) {
1414
- const signature = sign(null, Buffer.from(payload, "utf8"), identity.private_key_pem);
1415
- return signature.toString("base64url");
1416
- }
1417
-
1418
- // src/config/config.ts
1419
- var defaultLinkConfig = {
1420
- port: LINK_DEFAULT_PORT,
1421
- lanHost: null,
1422
- serverBaseUrl: "https://hermes-server.catwiki.ai",
1423
- relayBaseUrl: "https://hermes-relay.catwiki.ai",
1424
- appConnectTokenIssuer: "https://hermes-server.catwiki.ai",
1425
- appConnectTokenAudience: "hermes-link",
1426
- language: "auto",
1427
- logLevel: "warn"
1428
- };
1429
- async function loadConfig(paths = resolveRuntimePaths()) {
1430
- const existing = await readJsonFile(paths.configFile);
1431
- const language = normalizeConfiguredLanguage(existing?.language);
1432
- const lanHost = normalizeLanHost(existing?.lanHost);
1433
- const logLevel = normalizeLogLevel(existing?.logLevel ?? process.env.HERMESLINK_LOG_LEVEL);
1434
- return {
1435
- ...defaultLinkConfig,
1436
- ...existing ?? {},
1437
- language,
1438
- lanHost,
1439
- logLevel
1440
- };
1441
- }
1442
- async function saveConfig(patch, paths = resolveRuntimePaths()) {
1443
- const current = await loadConfig(paths);
1444
- const next = {
1445
- ...current,
1446
- ...patch,
1447
- logLevel: patch.logLevel === void 0 ? current.logLevel : normalizeLogLevel(patch.logLevel)
1448
- };
1449
- await writeJsonFile(paths.configFile, next);
1450
- return next;
1451
- }
1452
- function normalizeConfiguredLanguage(language) {
1453
- if (language === "zh-CN" || language === "en" || language === "auto") {
1454
- return language;
1455
- }
1456
- return defaultLinkConfig.language;
1457
- }
1458
- function normalizeLogLevel(level) {
1459
- if (level === "debug" || level === "info" || level === "warn" || level === "error") {
1460
- return level;
1461
- }
1462
- return defaultLinkConfig.logLevel;
1463
- }
1464
- function normalizeLanHost(value) {
1465
- if (value === null || value === void 0) return null;
1466
- if (typeof value !== "string") return null;
1467
- const host = value.trim().replace(/^\[/u, "").replace(/\]$/u, "");
1468
- if (!host) return null;
1469
- if (!isValidHostIpv4(host)) return null;
1470
- return host;
1471
- }
1472
- function isValidHostIpv4(value) {
1473
- const parts = value.split(".").map((part) => Number.parseInt(part, 10));
1474
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
1475
- return false;
1476
- }
1477
- const [, , , fourth] = parts;
1478
- return fourth !== 0 && fourth !== 255;
1479
- }
1480
-
1481
- // src/network/topology.ts
1482
- import os5 from "os";
1483
- var VIRTUAL_INTERFACE_NAME_PATTERN = /(docker|veth|vmnet|vmenet|vbox|virtualbox|vmware|tailscale|zerotier|wireguard|utun|virbr|hyper-v|vethernet|loopback|\blo\b|^lo\d*$|^br-|^bridge\d+$|^zt|^tun|^tap|awdl|llw|anpi|gif|stf|ipsec|ppp)/iu;
1484
- var MAX_LAN_IPS = 4;
1485
- var MAX_PUBLIC_IPV4S = 2;
1486
- var MAX_PUBLIC_IPV6S = 2;
1487
- async function discoverRouteCandidates(options) {
1488
- const environment = detectRuntimeEnvironment();
1489
- const configuredLanHost = normalizeLanHost(options.configuredLanHost);
1490
- const lanIps = configuredLanHost ? [configuredLanHost] : environment.lanAutoDiscoveryUsable ? discoverLanIps() : [];
1491
- const publicIps = options.relayBootstrapToken || options.observePublicRoute ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
1492
- const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
1493
- const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
1494
- const preferredUrls = [
1495
- ...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
1496
- ...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
1497
- ...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
1498
- `${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/links/${options.linkId}`
1499
- ];
1500
- return { lanIps, publicIpv4s, publicIpv6s, preferredUrls, environment };
1501
- }
1502
- function discoverLanIps() {
1503
- return discoverLanIpsFromInterfaces(os5.networkInterfaces());
1504
- }
1505
- function discoverLanIpsFromInterfaces(interfaces) {
1506
- const result = /* @__PURE__ */ new Set();
1507
- const candidates = [];
1508
- for (const [name, items] of Object.entries(interfaces)) {
1509
- if (shouldIgnoreInterface(name)) continue;
1510
- for (const item of items ?? []) {
1511
- if (!item.internal && item.address && item.family === "IPv4" && isUsableLanIpv4(item.address, item.netmask)) {
1512
- candidates.push({ name, address: item.address });
1513
- }
1514
- }
1515
- }
1516
- for (const candidate of candidates.sort(compareLanCandidate)) {
1517
- result.add(candidate.address);
1518
- }
1519
- return [...result].slice(0, MAX_LAN_IPS);
1520
- }
1521
- async function observePublicRoute(options) {
1522
- const fetcher = options.fetchImpl ?? fetch;
1523
- const simpleIp = await fetchPublicIpSimple(fetcher).catch(() => null);
1524
- try {
1525
- const response = await fetcher(
1526
- `${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/public-route/observe`,
1527
- {
1528
- method: "POST",
1529
- headers: {
1530
- "content-type": "application/json",
1531
- ...options.relayBootstrapToken ? { authorization: `Bearer ${options.relayBootstrapToken}` } : {}
1532
- },
1533
- body: JSON.stringify({
1534
- install_id: options.installId,
1535
- link_id: options.linkId,
1536
- public_key_pem: options.publicKeyPem
1537
- }),
1538
- signal: AbortSignal.timeout(5e3)
1539
- }
1540
- );
1541
- const payload = await response.json().catch(() => null);
1542
- const record = typeof payload?.record === "object" && payload.record !== null ? payload.record : null;
1543
- const observed = typeof payload?.observed === "object" && payload.observed !== null ? payload.observed : null;
1544
- const values = [
1545
- simpleIp,
1546
- readIpRecord(record?.ipv4),
1547
- readIpRecord(record?.ipv6),
1548
- typeof observed?.ip === "string" ? observed.ip : null
1549
- ].filter((v) => Boolean(v));
1550
- return {
1551
- publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
1552
- publicIpv6s: unique(values.filter(isUsablePublicIpv6))
1553
- };
1554
- } catch {
1555
- const values = [simpleIp].filter((v) => Boolean(v));
1556
- return {
1557
- publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
1558
- publicIpv6s: []
1559
- };
1560
- }
1561
- }
1562
- async function fetchPublicIpSimple(fetcher) {
1563
- const res = await fetcher("https://api.ipify.org?format=json", {
1564
- signal: AbortSignal.timeout(4e3)
1565
- });
1566
- const data = await res.json();
1567
- return typeof data.ip === "string" && data.ip.trim() ? data.ip.trim() : null;
1568
- }
1569
- function readIpRecord(value) {
1570
- if (typeof value !== "object" || value === null) return null;
1571
- const ip = value.ip;
1572
- return typeof ip === "string" && ip.trim() ? ip.trim() : null;
1573
- }
1574
- function buildDirectUrl(ip, port) {
1575
- return `http://${ip.includes(":") ? `[${ip}]` : ip}:${port}`;
1576
- }
1577
- function shouldIgnoreInterface(name) {
1578
- return !name.trim() || VIRTUAL_INTERFACE_NAME_PATTERN.test(name);
1579
- }
1580
- function compareLanCandidate(left, right) {
1581
- const priority = interfacePriority(left.name) - interfacePriority(right.name);
1582
- return priority || left.name.localeCompare(right.name) || left.address.localeCompare(right.address);
1583
- }
1584
- function interfacePriority(name) {
1585
- return /^(en|eth|wlan|wi-fi|wifi)/iu.test(name) ? 0 : 1;
1586
- }
1587
- function isUsableLanIpv4(address, netmask) {
1588
- return isPrivateIpv4(address) && !isNetworkOrBroadcastIpv4Address(address, netmask);
1589
- }
1590
- function isUsablePublicIpv4(address) {
1591
- return isValidIpv4(address) && !isSpecialIpv4(address);
1592
- }
1593
- function isUsablePublicIpv6(address) {
1594
- const normalized = address.toLowerCase();
1595
- return normalized.includes(":") && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && normalized !== "::" && normalized !== "::1";
1596
- }
1597
- function isPrivateIpv4(address) {
1598
- const parts = parseIpv4Segments(address);
1599
- if (!parts) return false;
1600
- const [first, second] = parts;
1601
- return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
1602
- }
1603
- function isSpecialIpv4(address) {
1604
- const parts = parseIpv4Segments(address);
1605
- if (!parts) return true;
1606
- const [first, second, third, fourth] = parts;
1607
- return first === 0 || first === 10 || first === 127 || first >= 224 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 192 && second === 0 && third === 2 || first === 198 && (second === 18 || second === 19) || first === 198 && second === 51 && third === 100 || first === 203 && second === 0 && third === 113 || first === 255 && second === 255 && third === 255 && fourth === 255;
1608
- }
1609
- function isNetworkOrBroadcastIpv4Address(address, netmask) {
1610
- const addressParts = parseIpv4Segments(address);
1611
- const netmaskParts = netmask ? parseIpv4Segments(netmask) : null;
1612
- if (!addressParts) return true;
1613
- if (!netmaskParts) {
1614
- const last = addressParts[3];
1615
- return last === 0 || last === 255;
1616
- }
1617
- const addressInt = ipv4SegmentsToInt(addressParts);
1618
- const netmaskInt = ipv4SegmentsToInt(netmaskParts);
1619
- const hostMask = ~netmaskInt >>> 0;
1620
- if (hostMask === 0) return false;
1621
- const networkInt = addressInt & netmaskInt;
1622
- const broadcastInt = (networkInt | hostMask) >>> 0;
1623
- return addressInt === networkInt || addressInt === broadcastInt;
1624
- }
1625
- function isValidIpv4(address) {
1626
- return Boolean(parseIpv4Segments(address));
1627
- }
1628
- function parseIpv4Segments(address) {
1629
- if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(address)) return null;
1630
- const parts = address.split(".").map((part) => Number.parseInt(part, 10));
1631
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
1632
- return parts;
1633
- }
1634
- function ipv4SegmentsToInt(parts) {
1635
- return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
1636
- }
1637
- function unique(values) {
1638
- return [...new Set(values)];
1639
- }
1640
-
1641
- // src/http/routes/bootstrap.ts
1642
- function routeObjects(urls) {
1643
- return urls.map((url) => ({
1644
- url,
1645
- kind: url.includes("/api/v1/relay/links/") ? "relay" : "lan"
1646
- }));
1647
- }
1648
- function createBootstrapRouter(options) {
1649
- const { paths } = options;
1650
- const router = new Router3();
1651
- router.get("/api/v1/bootstrap", async (ctx) => {
1652
- const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
1653
- const routes = identity?.link_id ? await discoverRouteCandidates({
1654
- port: config.port,
1655
- relayBaseUrl: config.relayBaseUrl,
1656
- linkId: identity.link_id,
1657
- installId: identity.install_id,
1658
- publicKeyPem: identity.public_key_pem,
1659
- configuredLanHost: config.lanHost
1660
- }) : null;
1661
- ctx.set("cache-control", "no-store");
1662
- ctx.body = {
1663
- link_id: identity?.link_id ?? null,
1664
- display_name: identity?.link_id ? "Hermes Link" : "Unpaired Hermes Link",
1665
- version: LINK_VERSION,
1666
- api_version: 1,
1667
- paired: Boolean(identity?.link_id),
1668
- pairing_supported: Boolean(identity?.link_id),
1669
- preferred_pairing_urls: routes?.preferredUrls ?? [],
1670
- routes: routes ? routeObjects(routes.preferredUrls) : [],
1671
- capabilities: {
1672
- runs: true,
1673
- sse: true,
1674
- relay: true,
1675
- profiles: true,
1676
- logs: true,
1677
- statistics: true,
1678
- conversations: true,
1679
- conversation_events: true,
1680
- conversation_delete: true,
1681
- conversation_bulk_delete: true,
1682
- conversation_clear_plan: true,
1683
- conversation_cancel: true,
1684
- conversation_rename: true,
1685
- blobs: true,
1686
- devices: true,
1687
- device_delete: true,
1688
- device_revoke: true,
1689
- device_rename: true,
1690
- device_session_enroll: true,
1691
- cron_jobs: true,
1692
- profile_skills: true,
1693
- profile_memory: true,
1694
- hermes_updates: true
1695
- }
1696
- };
1697
- });
1698
- return router;
1699
- }
1700
-
1701
- // src/http/routes/auth.ts
1702
- import Router4 from "@koa/router";
1703
- function readString(body, ...keys) {
1704
- if (!body || typeof body !== "object") return null;
1705
- for (const key of keys) {
1706
- const val = body[key];
1707
- if (typeof val === "string") return val;
1708
- }
1709
- return null;
1710
- }
1711
- async function readJsonBody(req) {
1712
- return new Promise((resolve, reject) => {
1713
- let data = "";
1714
- req.setEncoding("utf8");
1715
- req.on("data", (chunk) => {
1716
- data += chunk;
1717
- });
1718
- req.on("end", () => {
1719
- try {
1720
- resolve(data ? JSON.parse(data) : {});
1721
- } catch {
1722
- resolve({});
1723
- }
1724
- });
1725
- req.on("error", reject);
1726
- });
1727
- }
1728
- function createAuthRouter(options) {
1729
- const { paths, logger } = options;
1730
- const router = new Router4();
1731
- router.get("/api/v1/auth/me", async (ctx) => {
1732
- const auth = await authenticateRequest(ctx, paths);
1733
- const identity = await loadIdentity(paths);
1734
- if (!identity?.link_id) throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
1735
- const device = auth.device ? await recordDeviceSeen(
1736
- auth.device.id,
1737
- { appInstanceId: readAppInstanceIdHeader(ctx), model: readDeviceModelHeader(ctx) },
1738
- paths
1739
- ) ?? auth.device : null;
1740
- ctx.body = {
1741
- ok: true,
1742
- auth: { kind: auth.kind, account_id: auth.accountId ?? null },
1743
- link: { link_id: identity.link_id, display_name: "Hermes Link" },
1744
- device: device ? { id: device.id, device_id: device.id, label: device.label, platform: device.platform, model: device.model ?? null, scope: device.scope } : null
1745
- };
1746
- });
1747
- router.post("/api/v1/auth/device-session", async (ctx) => {
1748
- const auth = await authenticateRequest(ctx, paths);
1749
- if (auth.kind !== "app-connect") {
1750
- throw new LinkHttpError(403, "app_connect_required", "App connect token is required to create a device session");
1751
- }
1752
- if (auth.scopes && auth.scopes.length > 0 && !auth.scopes.includes("device:enroll")) {
1753
- throw new LinkHttpError(403, "device_enroll_scope_required", "App connect token cannot enroll a device");
1754
- }
1755
- const identity = await loadIdentity(paths);
1756
- if (!identity?.link_id) throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
1757
- const body = await readJsonBody(ctx.req);
1758
- const session = await createDeviceSession(
1759
- {
1760
- label: readString(body, "device_label", "deviceLabel") ?? "HermesPilot App",
1761
- platform: readString(body, "device_platform", "devicePlatform") ?? "unknown",
1762
- model: readString(body, "device_model", "deviceModel"),
1763
- appInstanceId: auth.appInstanceId
1764
- },
1765
- paths
1766
- );
1767
- ctx.body = {
1768
- ok: true,
1769
- link: { link_id: identity.link_id, display_name: "Hermes Link" },
1770
- device: session.device,
1771
- access_token: { token: session.accessToken.token, expires_at: session.accessToken.expiresAt },
1772
- refresh_token: { token: session.refreshToken.token, expires_at: session.refreshToken.expiresAt }
1773
- };
1774
- logger.info({ device_id: session.device.device_id, device_platform: session.device.platform }, "device_session_enrolled");
1775
- });
1776
- router.post("/api/v1/auth/refresh", async (ctx) => {
1777
- const body = await readJsonBody(ctx.req);
1778
- const refreshToken = readString(body, "refresh_token", "refreshToken");
1779
- if (!refreshToken) throw new LinkHttpError(400, "refresh_token_required", "refresh_token is required");
1780
- const session = await refreshDeviceSession(
1781
- refreshToken,
1782
- {
1783
- appInstanceId: readString(body, "app_instance_id", "appInstanceId"),
1784
- label: readString(body, "device_label", "deviceLabel"),
1785
- platform: readString(body, "device_platform", "devicePlatform"),
1786
- model: readString(body, "device_model", "deviceModel")
1787
- },
1788
- paths
1789
- );
1790
- ctx.body = {
1791
- ok: true,
1792
- device: session.device,
1793
- access_token: { token: session.accessToken.token, expires_at: session.accessToken.expiresAt },
1794
- refresh_token: { token: session.refreshToken.token, expires_at: session.refreshToken.expiresAt }
1795
- };
1796
- });
1797
- router.post("/api/v1/auth/logout", async (ctx) => {
1798
- const body = await readJsonBody(ctx.req);
1799
- const refreshToken = readString(body, "refresh_token", "refreshToken");
1800
- if (refreshToken) await revokeDeviceRefreshToken(refreshToken, paths);
1801
- ctx.body = { ok: true };
1802
- });
1803
- return router;
1804
- }
1805
-
1806
- // src/http/routes/devices.ts
1807
- import Router5 from "@koa/router";
1808
- function readString2(body, ...keys) {
1809
- if (!body || typeof body !== "object") return null;
1810
- for (const key of keys) {
1811
- const val = body[key];
1812
- if (typeof val === "string") return val;
1813
- }
1814
- return null;
1815
- }
1816
- async function readJsonBody2(req) {
1817
- return new Promise((resolve, reject) => {
1818
- let data = "";
1819
- req.setEncoding("utf8");
1820
- req.on("data", (chunk) => {
1821
- data += chunk;
1822
- });
1823
- req.on("end", () => {
1824
- try {
1825
- resolve(data ? JSON.parse(data) : {});
1826
- } catch {
1827
- resolve({});
1828
- }
1829
- });
1830
- req.on("error", reject);
1831
- });
1832
- }
1833
- function createDevicesRouter(options) {
1834
- const { paths, logger } = options;
1835
- const router = new Router5();
1836
- router.get("/api/v1/devices", async (ctx) => {
1837
- const auth = await authenticateRequest(ctx, paths);
1838
- const currentDevice = auth.device ? await recordDeviceSeen(
1839
- auth.device.id,
1840
- { appInstanceId: readAppInstanceIdHeader(ctx), model: readDeviceModelHeader(ctx) },
1841
- paths
1842
- ) ?? auth.device : null;
1843
- const [devices, summary] = await Promise.all([listDevices(paths), readDeviceSummary(paths)]);
1844
- const currentDeviceId = currentDevice?.id ?? null;
1845
- ctx.set("cache-control", "no-store");
1846
- ctx.body = {
1847
- ok: true,
1848
- current_device_id: currentDeviceId,
1849
- devices: devices.map((d) => ({ ...d, current: currentDeviceId === d.id })),
1850
- summary
1851
- };
1852
- });
1853
- router.delete("/api/v1/devices/:deviceId", async (ctx) => {
1854
- const auth = await authenticateRequest(ctx, paths);
1855
- const device = await revokeDeviceById(ctx.params.deviceId, paths);
1856
- const summary = await readDeviceSummary(paths);
1857
- ctx.body = {
1858
- ok: true,
1859
- current_device_revoked: auth.device?.id === device.id,
1860
- device: { ...device, current: auth.device?.id === device.id },
1861
- summary
1862
- };
1863
- logger.info({ device_id: device.id, current_device_revoked: auth.device?.id === device.id }, "device_revoked");
1864
- });
1865
- router.delete("/api/v1/devices/:deviceId/app-listing", async (ctx) => {
1866
- const auth = await authenticateRequest(ctx, paths);
1867
- const device = await hideRevokedDeviceFromAppList(ctx.params.deviceId, paths);
1868
- const summary = await readDeviceSummary(paths);
1869
- ctx.body = {
1870
- ok: true,
1871
- device: { ...device, current: auth.device?.id === device.id },
1872
- summary
1873
- };
1874
- logger.info({ device_id: device.id }, "device_app_listing_deleted");
1875
- });
1876
- router.patch("/api/v1/devices/:deviceId", async (ctx) => {
1877
- const auth = await authenticateRequest(ctx, paths);
1878
- const body = await readJsonBody2(ctx.req);
1879
- const label = readString2(body, "label", "device_label");
1880
- if (!label) throw new LinkHttpError(400, "device_label_required", "Device label is required");
1881
- const device = await renameDeviceById(ctx.params.deviceId, label, paths);
1882
- ctx.body = {
1883
- ok: true,
1884
- device: { ...device, current: auth.device?.id === device.id }
1885
- };
1886
- logger.info({ device_id: device.id }, "device_renamed");
1887
- });
1888
- return router;
1889
- }
1890
-
1891
- // src/http/routes/conversations.ts
1892
- import Router6 from "@koa/router";
1893
-
1894
- // src/http/sse-stream.ts
1895
- function beginSseStream(req, res, options = {}) {
1896
- res.writeHead(200, {
1897
- "Content-Type": "text/event-stream",
1898
- "Cache-Control": "no-cache",
1899
- Connection: "keep-alive",
1900
- "X-Accel-Buffering": "no"
1901
- });
1902
- res.flushHeaders?.();
1903
- const heartbeat = setInterval(() => {
1904
- if (!res.writableEnded) res.write(": heartbeat\n\n");
1905
- }, 25e3);
1906
- heartbeat.unref?.();
1907
- const cleanup = () => {
1908
- clearInterval(heartbeat);
1909
- options.onClose?.();
1910
- };
1911
- req.once("close", cleanup);
1912
- req.once("aborted", cleanup);
1913
- }
1914
- function writeSseEvent(res, data) {
1915
- if (res.writableEnded) return;
1916
- const json = typeof data === "string" ? data : JSON.stringify(data);
1917
- const d = data;
1918
- if (d && typeof d.seq === "number") {
1919
- res.write(`id: ${d.seq}
1920
- `);
1921
- }
1922
- if (d && typeof d.type === "string") {
1923
- res.write(`event: ${d.type}
1924
- `);
1925
- }
1926
- res.write(`data: ${json}
1927
-
1928
- `);
1929
- }
1930
-
1931
- // src/http/routes/conversations.ts
1932
- var MAX_BLOB_UPLOAD_BYTES = 50 * 1024 * 1024;
1933
- function readString3(body, ...keys) {
1934
- if (!body || typeof body !== "object") return null;
1935
- for (const key of keys) {
1936
- const val = body[key];
1937
- if (typeof val === "string") return val;
1938
- }
1939
- return null;
1940
- }
1941
- function readStringArray2(body, ...keys) {
1942
- if (!body || typeof body !== "object") return null;
1943
- for (const key of keys) {
1944
- const val = body[key];
1945
- if (Array.isArray(val) && val.every((v) => typeof v === "string")) return val;
1946
- }
1947
- return null;
1948
- }
1949
- function readQueryString(value) {
1950
- if (typeof value === "string" && value) return value;
1951
- return null;
1952
- }
1953
- function readLimit(value) {
1954
- const n = typeof value === "string" ? Number.parseInt(value, 10) : typeof value === "number" ? value : 20;
1955
- if (!Number.isFinite(n) || n < 1) return 20;
1956
- return Math.min(n, 100);
1957
- }
1958
- function readOptionalProfileName(body) {
1959
- return readString3(body, "profile", "profile_name", "profileName");
1960
- }
1961
- function readHeader(ctx, name) {
1962
- const val = ctx.get(name);
1963
- return val || null;
1964
- }
1965
- function resolveConversationEventCursor(options) {
1966
- const fromQuery = typeof options.queryAfter === "string" ? Number.parseInt(options.queryAfter, 10) : null;
1967
- if (fromQuery !== null && Number.isFinite(fromQuery)) return fromQuery;
1968
- const header = Array.isArray(options.lastEventIdHeader) ? options.lastEventIdHeader[0] : options.lastEventIdHeader;
1969
- if (typeof header === "string") {
1970
- const n = Number.parseInt(header, 10);
1971
- if (Number.isFinite(n)) return n;
1972
- }
1973
- return void 0;
1974
- }
1975
- function isConversationNotificationEvent(event) {
1976
- const type = event?.type;
1977
- if (typeof type !== "string") return false;
1978
- return ["conversation.updated", "message.created", "run.started", "run.completed", "run.failed", "run.cancelled"].includes(type);
1979
- }
1980
- async function readJsonBody3(req) {
1981
- return new Promise((resolve, reject) => {
1982
- let data = "";
1983
- req.setEncoding("utf8");
1984
- req.on("data", (chunk) => {
1985
- data += chunk;
1986
- });
1987
- req.on("end", () => {
1988
- try {
1989
- resolve(data ? JSON.parse(data) : {});
1990
- } catch {
1991
- resolve({});
1992
- }
1993
- });
1994
- req.on("error", reject);
1995
- });
1996
- }
1997
- async function readRawBody(req, maxBytes) {
1998
- return new Promise((resolve, reject) => {
1999
- const chunks = [];
2000
- let total = 0;
2001
- req.on("data", (chunk) => {
2002
- total += chunk.byteLength;
2003
- if (total > maxBytes) {
2004
- reject(new LinkHttpError(413, "blob_too_large", "Blob is too large"));
2005
- return;
2006
- }
2007
- chunks.push(chunk);
2008
- });
2009
- req.on("end", () => resolve(Buffer.concat(chunks)));
2010
- req.on("error", reject);
2011
- });
2012
- }
2013
- function readMessageAttachments(value) {
2014
- if (!Array.isArray(value)) return [];
2015
- return value;
2016
- }
2017
- function readUploadFilenameHeader(ctx) {
2018
- const cd = ctx.get("content-disposition");
2019
- if (!cd) return null;
2020
- const m = /filename="([^"]+)"/u.exec(cd);
2021
- return m ? m[1] ?? null : null;
2022
- }
2023
- function createConversationsRouter(options) {
2024
- const { paths, conversations } = options;
2025
- const router = new Router6();
2026
- router.get("/api/v1/conversations", async (ctx) => {
2027
- await authenticateRequest(ctx, paths);
2028
- ctx.set("cache-control", "no-store");
2029
- const result = await conversations.listConversationPage({
2030
- limit: readLimit(ctx.query.limit),
2031
- cursor: readQueryString(ctx.query.cursor) ?? readQueryString(ctx.query.after) ?? readQueryString(ctx.query.page_cursor)
2032
- });
2033
- ctx.body = { ok: true, conversations: result.conversations, page: result.page };
2034
- });
2035
- router.get("/api/v1/conversations/search", async (ctx) => {
2036
- await authenticateRequest(ctx, paths);
2037
- ctx.set("cache-control", "no-store");
2038
- const result = await conversations.searchConversationPage({
2039
- limit: readLimit(ctx.query.limit),
2040
- cursor: readQueryString(ctx.query.cursor) ?? readQueryString(ctx.query.after),
2041
- query: readQueryString(ctx.query.query) ?? readQueryString(ctx.query.q) ?? readQueryString(ctx.query.keyword) ?? ""
2042
- });
2043
- ctx.body = { ok: true, conversations: result.conversations, page: result.page };
2044
- });
2045
- router.post("/api/v1/conversations/clear-plans", async (ctx) => {
2046
- await authenticateRequest(ctx, paths);
2047
- const plan = await conversations.prepareClearAllConversationPlan();
2048
- ctx.status = 201;
2049
- ctx.body = { ok: true, plan };
2050
- });
2051
- router.get("/api/v1/conversations/clear-plans/:planId", async (ctx) => {
2052
- await authenticateRequest(ctx, paths);
2053
- ctx.set("cache-control", "no-store");
2054
- ctx.body = { ok: true, plan: await conversations.readClearAllConversationPlan(ctx.params.planId) };
2055
- });
2056
- router.post("/api/v1/conversations/clear-plans/:planId/execute", async (ctx) => {
2057
- await authenticateRequest(ctx, paths);
2058
- const plan = await conversations.startClearAllConversationPlan(ctx.params.planId);
2059
- const p = plan;
2060
- ctx.status = p.status === "completed" ? 200 : 202;
2061
- ctx.body = { ok: true, plan };
2062
- });
2063
- router.get("/api/v1/conversations/events", async (ctx) => {
2064
- await authenticateRequest(ctx, paths);
2065
- const mode = readQueryString(ctx.query.mode);
2066
- const notificationOnly = mode === "notifications";
2067
- ctx.respond = false;
2068
- const response = ctx.res;
2069
- let unsubscribe = () => {
2070
- };
2071
- beginSseStream(ctx.req, response, { onClose: () => unsubscribe() });
2072
- unsubscribe = conversations.subscribeAll((event) => {
2073
- if (notificationOnly && !isConversationNotificationEvent(event)) return;
2074
- writeSseEvent(response, event);
2075
- });
2076
- });
2077
- router.delete("/api/v1/conversations", async (ctx) => {
2078
- await authenticateRequest(ctx, paths);
2079
- const body = await readJsonBody3(ctx.req);
2080
- const conversationIds = readStringArray2(body, "conversation_ids", "conversationIds");
2081
- if (!conversationIds || conversationIds.length === 0) {
2082
- throw new LinkHttpError(400, "conversation_ids_required", "conversation_ids must be a non-empty array");
2083
- }
2084
- const deleted = await conversations.deleteConversations(conversationIds);
2085
- const ok = deleted.failed_count === 0;
2086
- ctx.status = ok ? 200 : 409;
2087
- ctx.body = {
2088
- ok,
2089
- ...!ok ? { error: { code: "conversation_bulk_delete_partial_failure", message: "Some conversations could not be deleted" } } : {},
2090
- ...deleted,
2091
- blob_gc_completed: true
2092
- };
2093
- });
2094
- router.post("/api/v1/conversations", async (ctx) => {
2095
- await authenticateRequest(ctx, paths);
2096
- const body = await readJsonBody3(ctx.req);
2097
- ctx.status = 201;
2098
- ctx.body = {
2099
- ok: true,
2100
- conversation: await conversations.createConversation({
2101
- title: readString3(body, "title") ?? void 0,
2102
- profileName: readOptionalProfileName(body)
2103
- })
2104
- };
2105
- });
2106
- router.get("/api/v1/conversations/:conversationId/messages", async (ctx) => {
2107
- await authenticateRequest(ctx, paths);
2108
- ctx.set("cache-control", "no-store");
2109
- const result = await conversations.getMessages(ctx.params.conversationId, {
2110
- limit: readLimit(ctx.query.limit),
2111
- beforeMessageId: readQueryString(ctx.query.before_message_id) ?? readQueryString(ctx.query.before)
2112
- });
2113
- ctx.body = { ok: true, conversation_id: ctx.params.conversationId, ...result };
2114
- });
2115
- router.get("/api/v1/conversations/:conversationId/events", async (ctx) => {
2116
- await authenticateRequest(ctx, paths);
2117
- const after = resolveConversationEventCursor({
2118
- queryAfter: ctx.query.after,
2119
- lastEventIdHeader: ctx.req.headers["last-event-id"]
2120
- });
2121
- const history = await conversations.listEvents(ctx.params.conversationId, after);
2122
- ctx.respond = false;
2123
- const response = ctx.res;
2124
- let unsubscribe = () => {
2125
- };
2126
- beginSseStream(ctx.req, response, { onClose: () => unsubscribe() });
2127
- for (const event of history) writeSseEvent(response, event);
2128
- unsubscribe = conversations.subscribe(ctx.params.conversationId, (event) => writeSseEvent(response, event));
2129
- });
2130
- router.post("/api/v1/conversations/:conversationId/messages", async (ctx) => {
2131
- await authenticateRequest(ctx, paths);
2132
- const body = await readJsonBody3(ctx.req);
2133
- const content = readString3(body, "content", "text", "input") ?? "";
2134
- const attachments = readMessageAttachments(body?.attachments ?? body?.blobs);
2135
- if (!content && attachments.length === 0) {
2136
- throw new LinkHttpError(400, "message_content_required", "message content is required");
2137
- }
2138
- ctx.status = 202;
2139
- ctx.body = {
2140
- ok: true,
2141
- ...await conversations.sendMessage({
2142
- conversationId: ctx.params.conversationId,
2143
- content,
2144
- attachments,
2145
- clientMessageId: readString3(body, "client_message_id", "clientMessageId") ?? void 0,
2146
- idempotencyKey: readHeader(ctx, "idempotency-key") ?? void 0,
2147
- profileName: readOptionalProfileName(body)
2148
- })
2149
- };
2150
- });
2151
- router.patch("/api/v1/conversations/:conversationId/model", async (ctx) => {
2152
- await authenticateRequest(ctx, paths);
2153
- const body = await readJsonBody3(ctx.req);
2154
- const modelId = readString3(body, "model_id", "modelId", "model");
2155
- if (!modelId) throw new LinkHttpError(400, "model_id_required", "model_id is required");
2156
- ctx.body = { ok: true, ...await conversations.setConversationModel(ctx.params.conversationId, modelId) };
2157
- });
2158
- router.patch("/api/v1/conversations/:conversationId/profile", async (ctx) => {
2159
- await authenticateRequest(ctx, paths);
2160
- const body = await readJsonBody3(ctx.req);
2161
- const profileName = readOptionalProfileName(body);
2162
- if (!profileName) throw new LinkHttpError(400, "profile_required", "profile is required");
2163
- ctx.body = { ok: true, ...await conversations.setConversationProfile(ctx.params.conversationId, profileName) };
2164
- });
2165
- router.patch("/api/v1/conversations/:conversationId/title", async (ctx) => {
2166
- await authenticateRequest(ctx, paths);
2167
- const body = await readJsonBody3(ctx.req);
2168
- const title = readString3(body, "title", "name", "display_name");
2169
- if (!title) throw new LinkHttpError(400, "title_required", "title is required");
2170
- ctx.body = { ok: true, ...await conversations.renameConversation(ctx.params.conversationId, title) };
2171
- });
2172
- router.post("/api/v1/conversations/:conversationId/ack", async (ctx) => {
2173
- await authenticateRequest(ctx, paths);
2174
- ctx.body = { ok: true };
2175
- });
2176
- router.post("/api/v1/conversations/:conversationId/runs/:runId/cancel", async (ctx) => {
2177
- await authenticateRequest(ctx, paths);
2178
- ctx.body = { ok: true, ...await conversations.cancelRun(ctx.params.conversationId, ctx.params.runId) };
2179
- });
2180
- router.post("/api/v1/conversations/:conversationId/approvals/:approvalId/approve", async (ctx) => {
2181
- await authenticateRequest(ctx, paths);
2182
- const body = await readJsonBody3(ctx.req);
2183
- const scope = readString3(body, "scope") ?? "always";
2184
- ctx.body = { ok: true, ...await conversations.resolveApproval({ conversationId: ctx.params.conversationId, approvalId: ctx.params.approvalId, decision: scope }) };
2185
- });
2186
- router.post("/api/v1/conversations/:conversationId/approvals/:approvalId/deny", async (ctx) => {
2187
- await authenticateRequest(ctx, paths);
2188
- ctx.body = { ok: true, ...await conversations.resolveApproval({ conversationId: ctx.params.conversationId, approvalId: ctx.params.approvalId, decision: "deny" }) };
2189
- });
2190
- router.delete("/api/v1/conversations/:conversationId", async (ctx) => {
2191
- await authenticateRequest(ctx, paths);
2192
- ctx.body = { ok: true, ...await conversations.deleteConversation(ctx.params.conversationId), blob_gc_completed: true };
2193
- });
2194
- router.post("/api/v1/conversations/:conversationId/blobs", async (ctx) => {
2195
- await authenticateRequest(ctx, paths);
2196
- const bytes = await readRawBody(ctx.req, MAX_BLOB_UPLOAD_BYTES);
2197
- if (bytes.byteLength === 0) throw new LinkHttpError(400, "blob_empty", "Blob body is empty");
2198
- const blob = await conversations.writeBlob(ctx.params.conversationId, {
2199
- bytes,
2200
- filename: readUploadFilenameHeader(ctx) ?? void 0,
2201
- mime: ctx.get("content-type") || void 0
2202
- });
2203
- ctx.status = 201;
2204
- ctx.body = { ok: true, blob };
2205
- });
2206
- router.get("/api/v1/conversations/:conversationId/blobs/:blobId", async (ctx) => {
2207
- await authenticateRequest(ctx, paths);
2208
- const blob = await conversations.readBlob(ctx.params.conversationId, ctx.params.blobId);
2209
- ctx.type = blob.mime;
2210
- ctx.set("content-disposition", `inline; filename="${blob.filename}"`);
2211
- ctx.set("content-length", String(blob.size));
2212
- ctx.body = blob.bytes;
2213
- });
2214
- router.delete("/api/v1/conversations/:conversationId/blobs/:blobId", async (ctx) => {
2215
- await authenticateRequest(ctx, paths);
2216
- ctx.body = { ok: true, ...await conversations.deleteUnreferencedBlob(ctx.params.conversationId, ctx.params.blobId) };
2217
- });
2218
- return router;
2219
- }
2220
-
2221
- // src/http/routes/pairing.ts
2222
- import Router7 from "@koa/router";
2223
- import path7 from "path";
2224
- function readString4(body, ...keys) {
2225
- if (!body || typeof body !== "object") return null;
2226
- for (const key of keys) {
2227
- const val = body[key];
2228
- if (typeof val === "string") return val;
2229
- }
2230
- return null;
2231
- }
2232
- async function readJsonBody4(req) {
2233
- return new Promise((resolve, reject) => {
2234
- let data = "";
2235
- req.setEncoding("utf8");
2236
- req.on("data", (chunk) => {
2237
- data += chunk;
2238
- });
2239
- req.on("end", () => {
2240
- try {
2241
- resolve(data ? JSON.parse(data) : {});
2242
- } catch {
2243
- resolve({});
2244
- }
2245
- });
2246
- req.on("error", reject);
2247
- });
2248
- }
2249
- function pairingSessionPath(sessionId, paths) {
2250
- return path7.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.json`);
2251
- }
2252
- function pairingClaimPath(sessionId, paths) {
2253
- return path7.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
2254
- }
2255
- async function readPairingSession(sessionId, paths) {
2256
- const record = await readJsonFile(pairingSessionPath(sessionId, paths));
2257
- if (!record || record.session_id !== sessionId || typeof record.code !== "string" || typeof record.link_id !== "string") {
2258
- return null;
2259
- }
2260
- const r = record;
2261
- return {
2262
- session_id: r.session_id,
2263
- code: r.code,
2264
- link_id: r.link_id,
2265
- display_name: r.display_name,
2266
- local_api_url: r.local_api_url,
2267
- server_base_url: r.server_base_url,
2268
- relay_base_url: r.relay_base_url,
2269
- preferred_urls: Array.isArray(r.preferred_urls) ? r.preferred_urls.filter((v) => typeof v === "string") : [],
2270
- created_at: r.created_at,
2271
- expires_at: r.expires_at
2272
- };
2273
- }
2274
- function isPairingSessionExpired(session) {
2275
- const ms = Date.parse(session.expires_at);
2276
- return !Number.isFinite(ms) || Date.now() >= ms;
2277
- }
2278
- async function recordPairingClaim(input, paths) {
2279
- const record = {
2280
- session_id: input.sessionId,
2281
- device_id: input.deviceId,
2282
- device_label: input.deviceLabel,
2283
- device_platform: input.devicePlatform,
2284
- claimed_at: (/* @__PURE__ */ new Date()).toISOString()
2285
- };
2286
- await writeJsonFile(pairingClaimPath(input.sessionId, paths), record);
2287
- }
2288
- function createPairingRouter(options) {
2289
- const { paths, logger, onPairingClaimed } = options;
2290
- const router = new Router7();
2291
- router.post("/api/v1/pairing/claim", async (ctx) => {
2292
- const body = await readJsonBody4(ctx.req);
2293
- const sessionId = readString4(body, "session_id", "sessionId");
2294
- const claimToken = readString4(body, "claim_token", "claimToken");
2295
- if (!sessionId || !claimToken) {
2296
- throw new LinkHttpError(400, "pairing_claim_invalid", "session_id and claim_token are required");
2297
- }
2298
- const [identity, localSession] = await Promise.all([
2299
- loadIdentity(paths),
2300
- readPairingSession(sessionId, paths)
2301
- ]);
2302
- if (!identity?.link_id) throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
2303
- if (!localSession) throw new LinkHttpError(404, "pairing_session_not_found", "Pairing session was not found");
2304
- if (isPairingSessionExpired(localSession)) throw new LinkHttpError(404, "pairing_session_expired", "Pairing session has expired");
2305
- if (localSession.link_id !== identity.link_id) throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim does not match this Link");
2306
- if (localSession.code !== claimToken) {
2307
- throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim token does not match");
2308
- }
2309
- const appInstanceId = readString4(body, "app_instance_id", "appInstanceId");
2310
- const deviceLabel = readString4(body, "device_label", "deviceLabel") ?? "HermesPilot App";
2311
- const devicePlatform = readString4(body, "device_platform", "devicePlatform") ?? "unknown";
2312
- const session = await createDeviceSession(
2313
- { label: deviceLabel, platform: devicePlatform, model: readString4(body, "device_model", "deviceModel"), appInstanceId },
2314
- paths
2315
- );
2316
- ctx.body = {
2317
- link: { link_id: identity.link_id, display_name: "Hermes Link" },
2318
- device: session.device,
2319
- access_token: { token: session.accessToken.token, expires_at: session.accessToken.expiresAt },
2320
- refresh_token: { token: session.refreshToken.token, expires_at: session.refreshToken.expiresAt }
2321
- };
2322
- logger.info({ device_id: session.device.device_id, device_platform: session.device.platform }, "pairing_claimed");
2323
- const timer = setTimeout(() => {
2324
- void recordPairingClaim(
2325
- { sessionId, deviceId: session.device.device_id, deviceLabel, devicePlatform },
2326
- paths
2327
- ).catch(() => void 0);
2328
- onPairingClaimed?.();
2329
- }, 250);
2330
- timer.unref?.();
2331
- });
2332
- return router;
2333
- }
2334
-
2335
- // src/relay/relay-client.ts
2336
- import { EventEmitter } from "events";
2337
-
2338
- // src/relay/bootstrap.ts
2339
- async function bootstrapWithRelay(options) {
2340
- const fetcher = options.fetchImpl ?? fetch;
2341
- const baseUrl = options.relayBaseUrl.replace(/\/+$/u, "");
2342
- const nonceResponse = await fetcher(`${baseUrl}/api/v1/relay/links/nonce`, {
2343
- method: "POST",
2344
- headers: { "content-type": "application/json" },
2345
- body: JSON.stringify({ install_id: options.identity.install_id })
2346
- });
2347
- if (!nonceResponse.ok) {
2348
- throw new Error(`Relay nonce request failed: ${nonceResponse.status}`);
2349
- }
2350
- const nonceBody = await nonceResponse.json();
2351
- const nonce = typeof nonceBody.nonce === "string" ? nonceBody.nonce : null;
2352
- if (!nonce) {
2353
- throw new Error("Relay did not return a nonce");
2354
- }
2355
- const signature = signRelayNonce(options.identity, nonce);
2356
- const registerResponse = await fetcher(`${baseUrl}/api/v1/relay/links`, {
2357
- method: "POST",
2358
- headers: { "content-type": "application/json" },
2359
- body: JSON.stringify({
2360
- install_id: options.identity.install_id,
2361
- public_key_pem: options.identity.public_key_pem,
2362
- nonce,
2363
- signature,
2364
- port: options.port
2365
- })
2366
- });
2367
- if (!registerResponse.ok) {
2368
- throw new Error(`Relay registration failed: ${registerResponse.status}`);
2369
- }
2370
- const registerBody = await registerResponse.json();
2371
- const linkId = typeof registerBody.link_id === "string" ? registerBody.link_id : null;
2372
- const token = typeof registerBody.token === "string" ? registerBody.token : null;
2373
- if (!linkId || !token) {
2374
- throw new Error("Relay registration response missing link_id or token");
2375
- }
2376
- return { linkId, token };
2377
- }
2378
- async function refreshRelayToken(options) {
2379
- const fetcher = options.fetchImpl ?? fetch;
2380
- const baseUrl = options.relayBaseUrl.replace(/\/+$/u, "");
2381
- const nonceResponse = await fetcher(`${baseUrl}/api/v1/relay/links/nonce`, {
2382
- method: "POST",
2383
- headers: { "content-type": "application/json" },
2384
- body: JSON.stringify({ install_id: options.identity.install_id })
2385
- });
2386
- if (!nonceResponse.ok) {
2387
- throw new Error(`Relay nonce request failed: ${nonceResponse.status}`);
2388
- }
2389
- const nonceBody = await nonceResponse.json();
2390
- const nonce = typeof nonceBody.nonce === "string" ? nonceBody.nonce : null;
2391
- if (!nonce) throw new Error("Relay did not return a nonce");
2392
- const signature = signRelayNonce(options.identity, nonce);
2393
- const tokenResponse = await fetcher(
2394
- `${baseUrl}/api/v1/relay/links/${options.identity.link_id}/token`,
2395
- {
2396
- method: "POST",
2397
- headers: { "content-type": "application/json" },
2398
- body: JSON.stringify({
2399
- install_id: options.identity.install_id,
2400
- nonce,
2401
- signature
2402
- })
2403
- }
2404
- );
2405
- if (!tokenResponse.ok) {
2406
- throw new Error(`Relay token refresh failed: ${tokenResponse.status}`);
2407
- }
2408
- const tokenBody = await tokenResponse.json();
2409
- const token = typeof tokenBody.token === "string" ? tokenBody.token : null;
2410
- if (!token) throw new Error("Relay did not return a token");
2411
- return token;
2412
- }
2413
-
2414
- // src/relay/relay-client.ts
2415
- var WS;
2416
- try {
2417
- WS = WebSocket;
2418
- } catch {
2419
- }
2420
- var RelayClient = class extends EventEmitter {
2421
- options;
2422
- ws = null;
2423
- state = "disconnected";
2424
- token;
2425
- logger;
2426
- reconnectTimer = null;
2427
- pingTimer = null;
2428
- reconnectDelay;
2429
- closed = false;
2430
- constructor(options) {
2431
- super();
2432
- this.options = options;
2433
- this.token = options.token;
2434
- this.reconnectDelay = options.reconnectDelayMs ?? 1e3;
2435
- this.logger = createFileLogger({ paths: options.paths });
2436
- }
2437
- get currentState() {
2438
- return this.state;
2439
- }
2440
- start() {
2441
- if (this.closed) return;
2442
- this.connect();
2443
- }
2444
- stop() {
2445
- this.closed = true;
2446
- this.clearTimers();
2447
- if (this.ws) {
2448
- this.state = "closing";
2449
- try {
2450
- this.ws.close(1e3, "client shutdown");
2451
- } catch {
2452
- }
2453
- }
2454
- }
2455
- send(message) {
2456
- if (this.state !== "connected" || !this.ws) return;
2457
- try {
2458
- this.ws.send(JSON.stringify(message));
2459
- } catch (err) {
2460
- this.logger.warn("Failed to send relay message", { error: String(err) }).catch(() => void 0);
2461
- }
2462
- }
2463
- connect() {
2464
- if (this.closed) return;
2465
- this.state = "connecting";
2466
- const wsUrl = this.buildWsUrl();
2467
- try {
2468
- const ws = new WS(wsUrl, {
2469
- headers: { authorization: `Bearer ${this.token}` }
2470
- });
2471
- this.ws = ws;
2472
- ws.addEventListener("open", () => {
2473
- this.reconnectDelay = this.options.reconnectDelayMs ?? 1e3;
2474
- this.state = "connected";
2475
- this.startPing();
2476
- this.emit("connected");
2477
- this.logger.info("Relay WebSocket connected").catch(() => void 0);
2478
- });
2479
- ws.addEventListener("message", (event) => {
2480
- try {
2481
- const message = JSON.parse(event.data);
2482
- this.emit("message", message);
2483
- } catch {
2484
- }
2485
- });
2486
- ws.addEventListener("close", (event) => {
2487
- this.stopPing();
2488
- this.ws = null;
2489
- this.state = "disconnected";
2490
- this.emit("disconnected", { code: event.code, reason: event.reason });
2491
- if (!this.closed) {
2492
- this.scheduleReconnect();
2493
- }
2494
- });
2495
- ws.addEventListener("error", (event) => {
2496
- this.logger.warn("Relay WebSocket error", { error: String(event) }).catch(() => void 0);
2497
- });
2498
- } catch (err) {
2499
- this.state = "disconnected";
2500
- this.logger.warn("Failed to create WebSocket", { error: String(err) }).catch(() => void 0);
2501
- if (!this.closed) {
2502
- this.scheduleReconnect();
2503
- }
2504
- }
2505
- }
2506
- buildWsUrl() {
2507
- const base = this.options.relayBaseUrl.replace(/\/+$/u, "").replace(/^http/u, "ws");
2508
- return `${base}/api/v1/relay/links/${this.options.identity.link_id}/ws`;
2509
- }
2510
- scheduleReconnect() {
2511
- const maxDelay = this.options.maxReconnectDelayMs ?? 3e4;
2512
- const delay = Math.min(this.reconnectDelay, maxDelay);
2513
- this.reconnectDelay = Math.min(this.reconnectDelay * 2, maxDelay);
2514
- this.reconnectTimer = setTimeout(() => {
2515
- if (this.closed) return;
2516
- this.maybeRefreshTokenAndReconnect().catch(() => void 0);
2517
- }, delay);
2518
- }
2519
- async maybeRefreshTokenAndReconnect() {
2520
- try {
2521
- this.token = await refreshRelayToken({
2522
- relayBaseUrl: this.options.relayBaseUrl,
2523
- identity: this.options.identity,
2524
- fetchImpl: this.options.fetchImpl
2525
- });
2526
- } catch {
2527
- }
2528
- this.connect();
2529
- }
2530
- startPing() {
2531
- const intervalMs = this.options.pingIntervalMs ?? 25e3;
2532
- this.pingTimer = setInterval(() => {
2533
- if (this.state === "connected" && this.ws) {
2534
- try {
2535
- this.ws.send(JSON.stringify({ type: "ping" }));
2536
- } catch {
2537
- }
2538
- }
2539
- }, intervalMs);
2540
- }
2541
- stopPing() {
2542
- if (this.pingTimer !== null) {
2543
- clearInterval(this.pingTimer);
2544
- this.pingTimer = null;
2545
- }
2546
- }
2547
- clearTimers() {
2548
- this.stopPing();
2549
- if (this.reconnectTimer !== null) {
2550
- clearTimeout(this.reconnectTimer);
2551
- this.reconnectTimer = null;
2552
- }
2553
- }
2554
- };
2555
-
2556
- // src/storage/sqlite.ts
2557
- import Database from "better-sqlite3";
2558
- function openSqliteDatabase(filePath, options = {}) {
2559
- return new Database(filePath, {
2560
- ...options.readonly === void 0 ? {} : { readonly: options.readonly, fileMustExist: options.readonly },
2561
- ...options.timeout === void 0 ? {} : { timeout: options.timeout }
2562
- });
2563
- }
2564
-
2565
- // src/storage/link-database.ts
2566
- import { mkdir as mkdir5 } from "fs/promises";
2567
- import path8 from "path";
2568
- async function initLinkDatabase(paths) {
2569
- await mkdir5(path8.dirname(paths.databaseFile), { recursive: true, mode: 448 });
2570
- const db = openDb(paths);
2571
- try {
2572
- db.exec(`
2573
- PRAGMA foreign_keys = ON;
2574
- PRAGMA busy_timeout = 5000;
2575
- PRAGMA journal_mode = WAL;
2576
-
2577
- CREATE TABLE IF NOT EXISTS conversation_stats (
2578
- conversation_id TEXT PRIMARY KEY,
2579
- kind TEXT NOT NULL,
2580
- title TEXT NOT NULL,
2581
- status TEXT NOT NULL,
2582
- hermes_session_id TEXT NOT NULL,
2583
- profile_uid TEXT,
2584
- profile_name_snapshot TEXT,
2585
- profile TEXT,
2586
- model TEXT,
2587
- provider TEXT,
2588
- context_window INTEGER,
2589
- input_tokens INTEGER NOT NULL DEFAULT 0,
2590
- output_tokens INTEGER NOT NULL DEFAULT 0,
2591
- total_tokens INTEGER NOT NULL DEFAULT 0,
2592
- message_count INTEGER NOT NULL DEFAULT 0,
2593
- run_count INTEGER NOT NULL DEFAULT 0,
2594
- created_at TEXT NOT NULL,
2595
- updated_at TEXT NOT NULL,
2596
- deleted_at TEXT,
2597
- stats_updated_at TEXT NOT NULL
2598
- );
2599
- CREATE INDEX IF NOT EXISTS idx_conversation_stats_status
2600
- ON conversation_stats(status);
2601
- CREATE INDEX IF NOT EXISTS idx_conversation_stats_updated_at
2602
- ON conversation_stats(updated_at);
2603
- CREATE INDEX IF NOT EXISTS idx_conversation_stats_model
2604
- ON conversation_stats(model);
2605
- CREATE INDEX IF NOT EXISTS idx_conversation_stats_profile
2606
- ON conversation_stats(profile);
2607
- CREATE INDEX IF NOT EXISTS idx_conversation_stats_profile_uid
2608
- ON conversation_stats(profile_uid);
2609
- CREATE INDEX IF NOT EXISTS idx_conversation_stats_profile_name_snapshot
2610
- ON conversation_stats(profile_name_snapshot);
2611
-
2612
- CREATE TABLE IF NOT EXISTS profile_registry (
2613
- profile_uid TEXT PRIMARY KEY,
2614
- profile_name TEXT NOT NULL UNIQUE,
2615
- profile_path TEXT NOT NULL,
2616
- display_name TEXT,
2617
- description TEXT,
2618
- avatar_type TEXT NOT NULL DEFAULT 'default',
2619
- avatar_url TEXT,
2620
- created_at TEXT NOT NULL,
2621
- updated_at TEXT NOT NULL
2622
- );
2623
- CREATE INDEX IF NOT EXISTS idx_profile_registry_profile_name
2624
- ON profile_registry(profile_name);
2625
-
2626
- CREATE TABLE IF NOT EXISTS run_usage_facts (
2627
- run_id TEXT PRIMARY KEY,
2628
- conversation_id TEXT NOT NULL,
2629
- profile_uid TEXT,
2630
- profile_name_snapshot TEXT,
2631
- profile TEXT,
2632
- model TEXT,
2633
- provider TEXT,
2634
- input_tokens INTEGER NOT NULL DEFAULT 0,
2635
- output_tokens INTEGER NOT NULL DEFAULT 0,
2636
- total_tokens INTEGER NOT NULL DEFAULT 0,
2637
- message_count INTEGER NOT NULL DEFAULT 0,
2638
- started_at TEXT NOT NULL,
2639
- completed_at TEXT NOT NULL,
2640
- updated_at TEXT NOT NULL
2641
- );
2642
- CREATE INDEX IF NOT EXISTS idx_run_usage_facts_completed_at
2643
- ON run_usage_facts(completed_at);
2644
- CREATE INDEX IF NOT EXISTS idx_run_usage_facts_conversation_id
2645
- ON run_usage_facts(conversation_id);
2646
- CREATE INDEX IF NOT EXISTS idx_run_usage_facts_model
2647
- ON run_usage_facts(model);
2648
- CREATE INDEX IF NOT EXISTS idx_run_usage_facts_profile_uid
2649
- ON run_usage_facts(profile_uid);
2650
- CREATE INDEX IF NOT EXISTS idx_run_usage_facts_profile_name_snapshot
2651
- ON run_usage_facts(profile_name_snapshot);
2652
- `);
2653
- } finally {
2654
- db.close();
2655
- }
2656
- }
2657
- function openDb(paths) {
2658
- const db = openSqliteDatabase(paths.databaseFile, { timeout: 5e3 });
2659
- db.exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;");
2660
- return db;
2661
- }
2662
-
2663
- // src/conversations/service.ts
2664
- import { EventEmitter as EventEmitter2 } from "events";
2665
- import crypto4 from "crypto";
2666
-
2667
- // src/conversations/store.ts
2668
- import { mkdir as mkdir6, readFile as readFile4, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2669
- import path9 from "path";
2670
- import crypto2 from "crypto";
2671
- function createConversationId() {
2672
- return `conv_${crypto2.randomUUID().replaceAll("-", "")}`;
2673
- }
2674
- function createMessageId() {
2675
- return `msg_${crypto2.randomUUID().replaceAll("-", "")}`;
2676
- }
2677
- function createRunId() {
2678
- return `run_${crypto2.randomUUID().replaceAll("-", "")}`;
2679
- }
2680
- function conversationDir(paths, conversationId) {
2681
- return path9.join(paths.conversationsDir, conversationId);
2682
- }
2683
- function manifestPath(paths, conversationId) {
2684
- return path9.join(conversationDir(paths, conversationId), "manifest.json");
2685
- }
2686
- function snapshotPath(paths, conversationId) {
2687
- return path9.join(conversationDir(paths, conversationId), "snapshot.json");
2688
- }
2689
- function eventsPath(paths, conversationId) {
2690
- return path9.join(conversationDir(paths, conversationId), "events.json");
2691
- }
2692
- function blobPath(paths, blobId) {
2693
- return path9.join(paths.blobsDir, blobId);
2694
- }
2695
- async function readJson(filePath, fallback) {
2696
- try {
2697
- const raw = await readFile4(filePath, "utf8");
2698
- return JSON.parse(raw);
2699
- } catch {
2700
- return fallback;
2701
- }
2702
- }
2703
- async function writeJson(filePath, data) {
2704
- await mkdir6(path9.dirname(filePath), { recursive: true, mode: 448 });
2705
- await writeFile2(filePath, JSON.stringify(data), { mode: 384 });
2706
- }
2707
- function assertValidConversationId(id) {
2708
- if (!/^conv_[a-zA-Z0-9]+$/u.test(id)) {
2709
- throw new LinkHttpError(400, "invalid_conversation_id", "Invalid conversation ID");
2710
- }
2711
- }
2712
- async function readManifest(paths, conversationId) {
2713
- assertValidConversationId(conversationId);
2714
- return readJson(manifestPath(paths, conversationId), null);
2715
- }
2716
- async function readActiveManifest(paths, conversationId) {
2717
- const manifest = await readManifest(paths, conversationId);
2718
- if (!manifest || manifest.status === "deleted_soft") {
2719
- throw new LinkHttpError(404, "conversation_not_found", "Conversation was not found");
2720
- }
2721
- return manifest;
2722
- }
2723
- async function writeManifest(paths, manifest) {
2724
- await writeJson(manifestPath(paths, manifest.id), manifest);
2725
- }
2726
- async function readSnapshot(paths, conversationId) {
2727
- return readJson(snapshotPath(paths, conversationId), { messages: [], runs: [] });
2728
- }
2729
- async function writeSnapshot(paths, conversationId, snapshot) {
2730
- await writeJson(snapshotPath(paths, conversationId), snapshot);
2731
- }
2732
- async function readEvents(paths, conversationId) {
2733
- return readJson(eventsPath(paths, conversationId), []);
2734
- }
2735
- async function appendEvent(paths, conversationId, event, manifestRef) {
2736
- const events = await readEvents(paths, conversationId);
2737
- const seq = (manifestRef.last_event_seq ?? 0) + 1;
2738
- manifestRef.last_event_seq = seq;
2739
- const fullEvent = {
2740
- ...event,
2741
- seq,
2742
- conversation_id: conversationId,
2743
- created_at: (/* @__PURE__ */ new Date()).toISOString()
2744
- };
2745
- events.push(fullEvent);
2746
- await writeJson(eventsPath(paths, conversationId), events);
2747
- return fullEvent;
2748
- }
2749
- async function listConversationIds(paths) {
2750
- try {
2751
- const { readdir: readdir3 } = await import("fs/promises");
2752
- const entries = await readdir3(paths.conversationsDir, { withFileTypes: true });
2753
- return entries.filter((e) => e.isDirectory() && e.name.startsWith("conv_")).map((e) => e.name);
2754
- } catch {
2755
- return [];
2756
- }
2757
- }
2758
-
2759
- // src/conversations/blobs.ts
2760
- import { mkdir as mkdir7, readFile as readFile5, rm as rm5, writeFile as writeFile3 } from "fs/promises";
2761
- import path10 from "path";
2762
- import crypto3 from "crypto";
2763
- var MAX_BLOB_UPLOAD_BYTES2 = 50 * 1024 * 1024;
2764
- function blobManifestPath(paths, blobId) {
2765
- return `${blobPath(paths, blobId)}.json`;
2766
- }
2767
- function normalizeMime(mime, filename) {
2768
- if (mime && mime !== "application/octet-stream") return mime;
2769
- if (filename) {
2770
- const ext = path10.extname(filename).toLowerCase();
2771
- const mimeMap = {
2772
- ".jpg": "image/jpeg",
2773
- ".jpeg": "image/jpeg",
2774
- ".png": "image/png",
2775
- ".gif": "image/gif",
2776
- ".webp": "image/webp",
2777
- ".mp3": "audio/mpeg",
2778
- ".mp4": "video/mp4",
2779
- ".wav": "audio/wav",
2780
- ".pdf": "application/pdf",
2781
- ".txt": "text/plain",
2782
- ".json": "application/json"
2783
- };
2784
- if (mimeMap[ext]) return mimeMap[ext];
2785
- }
2786
- return "application/octet-stream";
2787
- }
2788
- function sanitizeFilename(filename, fallback) {
2789
- if (!filename) return fallback;
2790
- return path10.basename(filename).replace(/[^\w.\-]/gu, "_").slice(0, 255) || fallback;
2791
- }
2792
- async function writeBlob(paths, conversationId, input) {
2793
- assertValidConversationId(conversationId);
2794
- if (input.bytes.byteLength === 0) {
2795
- throw new LinkHttpError(400, "blob_empty", "Blob body is empty");
2796
- }
2797
- if (input.bytes.byteLength > MAX_BLOB_UPLOAD_BYTES2) {
2798
- throw new LinkHttpError(413, "blob_too_large", "Blob is too large");
2799
- }
2800
- const id = `blob_${crypto3.randomUUID().replaceAll("-", "")}`;
2801
- const filePath = blobPath(paths, id);
2802
- await mkdir7(path10.dirname(filePath), { recursive: true, mode: 448 });
2803
- await writeFile3(filePath, input.bytes, { mode: 384 });
2804
- const blob = {
2805
- id,
2806
- size: input.bytes.byteLength,
2807
- mime: normalizeMime(input.mime, input.filename),
2808
- filename: sanitizeFilename(input.filename, id),
2809
- created_at: (/* @__PURE__ */ new Date()).toISOString(),
2810
- conversation_ids: [conversationId]
2811
- };
2812
- await writeJsonFile(blobManifestPath(paths, id), blob);
2813
- return blob;
2814
- }
2815
- async function readBlob(paths, conversationId, blobId) {
2816
- assertValidConversationId(conversationId);
2817
- const manifest = await readBlobManifest(paths, conversationId, blobId);
2818
- const filePath = blobPath(paths, blobId);
2819
- const bytes = await readFile5(filePath).catch((err) => {
2820
- if (err.code === "ENOENT") throw new LinkHttpError(404, "blob_not_found", "Blob was not found");
2821
- throw err;
2822
- });
2823
- return {
2824
- bytes,
2825
- mime: manifest.mime || "application/octet-stream",
2826
- filename: manifest.filename || blobId,
2827
- size: manifest.size || bytes.byteLength
2828
- };
2829
- }
2830
- async function readBlobManifest(paths, conversationId, blobId) {
2831
- const raw = await readJsonFile(blobManifestPath(paths, blobId));
2832
- if (!raw || typeof raw !== "object") {
2833
- throw new LinkHttpError(404, "blob_not_found", "Blob was not found");
2834
- }
2835
- const manifest = raw;
2836
- if (!manifest.conversation_ids?.includes(conversationId)) {
2837
- throw new LinkHttpError(404, "blob_not_found", "Blob was not found");
2838
- }
2839
- return raw;
2840
- }
2841
- function isBlobReferenced(snapshot, blobId) {
2842
- return snapshot.messages.some(
2843
- (m) => m.parts.some((p) => p.blob === blobId) || m.attachments.some((a) => a.blob_id === blobId)
2844
- );
2845
- }
2846
- async function deleteUnreferencedBlob(paths, conversationId, blobId, snapshot) {
2847
- assertValidConversationId(conversationId);
2848
- if (isBlobReferenced(snapshot, blobId)) {
2849
- throw new LinkHttpError(409, "blob_in_use", "Blob is already referenced by a conversation message");
2850
- }
2851
- const manifestPath2 = blobManifestPath(paths, blobId);
2852
- const raw = await readJsonFile(manifestPath2);
2853
- const manifest = raw;
2854
- const nextIds = (manifest?.conversation_ids ?? []).filter((id) => id !== conversationId);
2855
- if (nextIds.length > 0) {
2856
- await writeJsonFile(manifestPath2, { ...manifest, conversation_ids: nextIds });
2857
- } else {
2858
- await rm5(blobPath(paths, blobId), { force: true }).catch(() => void 0);
2859
- await rm5(manifestPath2, { force: true }).catch(() => void 0);
2860
- }
2861
- return { deleted: true, blob_id: blobId };
2862
- }
2863
-
2864
- // src/conversations/service.ts
2865
- var conversationLocks = /* @__PURE__ */ new Map();
2866
- function withConversationLock(id, fn) {
2867
- const current = conversationLocks.get(id) ?? Promise.resolve();
2868
- const next = current.then(fn, fn);
2869
- conversationLocks.set(id, next.catch(() => void 0));
2870
- return next;
2871
- }
2872
- function normalizeLimit(value, defaultValue, max) {
2873
- const n = typeof value === "string" ? Number.parseInt(value, 10) : typeof value === "number" ? value : defaultValue;
2874
- if (!Number.isFinite(n) || n < 1) return defaultValue;
2875
- return Math.min(n, max);
2876
- }
2877
- var ConversationService = class extends EventEmitter2 {
2878
- paths;
2879
- constructor(paths) {
2880
- super();
2881
- this.setMaxListeners(200);
2882
- this.paths = paths;
2883
- }
2884
- subscribeAll(handler) {
2885
- this.on("event", handler);
2886
- return () => this.off("event", handler);
2887
- }
2888
- subscribe(conversationId, handler) {
2889
- const key = `event:${conversationId}`;
2890
- this.on(key, handler);
2891
- return () => this.off(key, handler);
2892
- }
2893
- emit2(event) {
2894
- const e = event;
2895
- this.emit("event", e);
2896
- this.emit(`event:${event.conversation_id}`, e);
2897
- }
2898
- async appendAndEmit(conversationId, event, manifest) {
2899
- const full = await appendEvent(this.paths, conversationId, event, manifest);
2900
- this.emit2(full);
2901
- return full;
2902
- }
2903
- async listEvents(conversationId, after) {
2904
- const events = await readEvents(this.paths, conversationId);
2905
- if (after === void 0 || after === null) return events;
2906
- return events.filter((e) => e.seq > after);
2907
- }
2908
- async listConversationPage(options = {}) {
2909
- const limit = normalizeLimit(options.limit, 20, 100);
2910
- const ids = await listConversationIds(this.paths);
2911
- const manifests = [];
2912
- for (const id of ids) {
2913
- const m = await readManifest(this.paths, id);
2914
- if (m && m.status !== "deleted_soft") manifests.push(m);
2915
- }
2916
- manifests.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
2917
- let startIndex = 0;
2918
- if (options.cursor) {
2919
- const idx = manifests.findIndex((m) => m.id === options.cursor);
2920
- if (idx >= 0) startIndex = idx + 1;
2921
- }
2922
- const page = manifests.slice(startIndex, startIndex + limit);
2923
- const hasMore = startIndex + limit < manifests.length;
2924
- return {
2925
- conversations: page.map((m) => this.summarizeManifest(m)),
2926
- page: {
2927
- limit,
2928
- has_more: hasMore,
2929
- next_cursor: hasMore && page.length > 0 ? page[page.length - 1]?.id ?? null : null
2930
- }
2931
- };
2932
- }
2933
- async searchConversationPage(options = {}) {
2934
- if (!options.query?.trim()) return this.listConversationPage(options);
2935
- const q = options.query.trim().toLowerCase();
2936
- const limit = normalizeLimit(options.limit, 20, 100);
2937
- const ids = await listConversationIds(this.paths);
2938
- const results = [];
2939
- for (const id of ids) {
2940
- const m = await readManifest(this.paths, id);
2941
- if (m && m.status !== "deleted_soft" && m.title.toLowerCase().includes(q)) {
2942
- results.push(m);
2943
- }
2944
- }
2945
- results.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
2946
- const page = results.slice(0, limit);
2947
- return {
2948
- conversations: page.map((m) => this.summarizeManifest(m)),
2949
- page: { limit, has_more: results.length > limit, next_cursor: null }
2950
- };
2951
- }
2952
- async createConversation(options = {}) {
2953
- const now = (/* @__PURE__ */ new Date()).toISOString();
2954
- const id = createConversationId();
2955
- const manifest = {
2956
- id,
2957
- kind: "chat",
2958
- title: options.title ?? "New conversation",
2959
- status: "active",
2960
- hermes_session_id: null,
2961
- profile_uid: null,
2962
- profile_name_snapshot: options.profileName ?? null,
2963
- profile: options.profileName ?? null,
2964
- last_event_seq: 0,
2965
- created_at: now,
2966
- updated_at: now,
2967
- deleted_at: null
2968
- };
2969
- await writeManifest(this.paths, manifest);
2970
- return this.summarizeManifest(manifest);
2971
- }
2972
- async getMessages(conversationId, options = {}) {
2973
- const manifest = await readActiveManifest(this.paths, conversationId);
2974
- const snapshot = await readSnapshot(this.paths, conversationId);
2975
- const limit = normalizeLimit(options.limit, 50, 200);
2976
- const total = snapshot.messages.length;
2977
- const endIndex = options.beforeMessageId ? snapshot.messages.findIndex((m) => m.id === options.beforeMessageId) : total;
2978
- if (endIndex < 0) throw new LinkHttpError(400, "message_cursor_not_found", "Message cursor was not found");
2979
- const startIndex = Math.max(0, endIndex - limit);
2980
- const messages = snapshot.messages.slice(startIndex, endIndex);
2981
- return {
2982
- messages,
2983
- last_event_seq: manifest.last_event_seq,
2984
- page: {
2985
- limit,
2986
- has_more_before: startIndex > 0,
2987
- has_more_after: endIndex < total,
2988
- oldest_message_id: messages[0]?.id ?? null,
2989
- newest_message_id: messages[messages.length - 1]?.id ?? null
2990
- }
2991
- };
2992
- }
2993
- async sendMessage(input) {
2994
- return withConversationLock(input.conversationId, () => this.sendMessageLocked(input));
2995
- }
2996
- async sendMessageLocked(input) {
2997
- const content = input.content.trim();
2998
- if (!content) throw new LinkHttpError(400, "message_content_required", "message content is required");
2999
- const manifest = await readActiveManifest(this.paths, input.conversationId);
3000
- const snapshot = await readSnapshot(this.paths, input.conversationId);
3001
- const now = (/* @__PURE__ */ new Date()).toISOString();
3002
- const runId = createRunId();
3003
- const userMessageId = createMessageId();
3004
- const assistantMessageId = createMessageId();
3005
- const hasActive = snapshot.runs.some((r) => r.status === "running" || r.status === "queued");
3006
- const userMessage = {
3007
- id: userMessageId,
3008
- schema_version: 1,
3009
- conversation_id: manifest.id,
3010
- role: "user",
3011
- status: hasActive ? "queued" : "completed",
3012
- client_message_id: input.clientMessageId ?? void 0,
3013
- created_at: now,
3014
- updated_at: now,
3015
- sender: { id: "app_user", type: "human", display_name: "Me" },
3016
- parts: [{ type: "text", text: content }],
3017
- attachments: []
3018
- };
3019
- const assistantMessage = {
3020
- id: assistantMessageId,
3021
- schema_version: 1,
3022
- conversation_id: manifest.id,
3023
- role: "assistant",
3024
- status: hasActive ? "queued" : "streaming",
3025
- run_id: runId,
3026
- created_at: now,
3027
- updated_at: now,
3028
- sender: { id: "agent_default", type: "agent", display_name: "Hermes", profile: manifest.profile ?? void 0 },
3029
- parts: [{ type: "text", text: "" }],
3030
- attachments: []
3031
- };
3032
- const run = {
3033
- id: runId,
3034
- kind: "agent",
3035
- conversation_id: manifest.id,
3036
- trigger_message_id: userMessageId,
3037
- assistant_message_id: assistantMessageId,
3038
- hermes_session_id: manifest.hermes_session_id,
3039
- status: hasActive ? "queued" : "running",
3040
- started_at: now,
3041
- profile_name_snapshot: manifest.profile ?? void 0,
3042
- profile: manifest.profile ?? void 0
3043
- };
3044
- snapshot.messages.push(userMessage, assistantMessage);
3045
- snapshot.runs.push(run);
3046
- await writeSnapshot(this.paths, manifest.id, snapshot);
3047
- if (manifest.title === "New conversation") {
3048
- manifest.title = content.slice(0, 60);
3049
- }
3050
- manifest.updated_at = now;
3051
- await writeManifest(this.paths, manifest);
3052
- await this.appendAndEmit(manifest.id, { type: "message.created", message_id: userMessageId, payload: { message: userMessage } }, manifest);
3053
- await this.appendAndEmit(manifest.id, { type: "message.created", message_id: assistantMessageId, run_id: runId, payload: { message: assistantMessage } }, manifest);
3054
- const latestEvent = await this.appendAndEmit(manifest.id, { type: "run.started", message_id: assistantMessageId, run_id: runId, payload: { run } }, manifest);
3055
- await writeManifest(this.paths, manifest);
3056
- return {
3057
- conversation_id: manifest.id,
3058
- user_message: { id: userMessageId, status: userMessage.status },
3059
- assistant_message: { id: assistantMessageId, status: assistantMessage.status },
3060
- run: { id: runId, status: run.status },
3061
- last_event_seq: latestEvent.seq,
3062
- conversation: this.summarizeManifest(manifest)
3063
- };
3064
- }
3065
- async cancelRun(conversationId, runId) {
3066
- return withConversationLock(conversationId, async () => {
3067
- const manifest = await readActiveManifest(this.paths, conversationId);
3068
- const snapshot = await readSnapshot(this.paths, conversationId);
3069
- const run = snapshot.runs.find((r) => r.id === runId);
3070
- if (!run) throw new LinkHttpError(404, "run_not_found", "Run was not found");
3071
- if (run.status !== "running" && run.status !== "queued") {
3072
- return { conversation_id: conversationId, run: { id: run.id, status: run.status }, last_event_seq: manifest.last_event_seq };
3073
- }
3074
- const now = (/* @__PURE__ */ new Date()).toISOString();
3075
- run.status = "cancelled";
3076
- run.completed_at = now;
3077
- const assistant = snapshot.messages.find((m) => m.id === run.assistant_message_id);
3078
- if (assistant) {
3079
- assistant.status = "failed";
3080
- assistant.updated_at = now;
3081
- }
3082
- await writeSnapshot(this.paths, conversationId, snapshot);
3083
- await this.appendAndEmit(conversationId, { type: "run.cancelled", run_id: runId, payload: { run } }, manifest);
3084
- await writeManifest(this.paths, manifest);
3085
- return { conversation_id: conversationId, run: { id: run.id, status: run.status }, last_event_seq: manifest.last_event_seq };
3086
- });
3087
- }
3088
- async deleteConversation(conversationId) {
3089
- assertValidConversationId(conversationId);
3090
- return withConversationLock(conversationId, async () => {
3091
- const manifest = await readActiveManifest(this.paths, conversationId);
3092
- const now = (/* @__PURE__ */ new Date()).toISOString();
3093
- manifest.status = "deleted_soft";
3094
- manifest.deleted_at = now;
3095
- manifest.updated_at = now;
3096
- await writeManifest(this.paths, manifest);
3097
- return { conversation_id: conversationId, deleted_at: now };
3098
- });
3099
- }
3100
- async deleteConversations(conversationIds) {
3101
- const results = [];
3102
- let failedCount = 0;
3103
- for (const id of conversationIds) {
3104
- try {
3105
- const deleted = await this.deleteConversation(id);
3106
- results.push({ ...deleted, status: "deleted" });
3107
- } catch (err) {
3108
- failedCount++;
3109
- results.push({
3110
- conversation_id: id,
3111
- status: "failed",
3112
- error: { code: err instanceof LinkHttpError ? err.code : "internal_error", message: err.message }
3113
- });
3114
- }
3115
- }
3116
- return { deleted_count: conversationIds.length - failedCount, failed_count: failedCount, conversations: results };
3117
- }
3118
- async renameConversation(conversationId, title) {
3119
- return withConversationLock(conversationId, async () => {
3120
- const manifest = await readActiveManifest(this.paths, conversationId);
3121
- manifest.title = title;
3122
- manifest.updated_at = (/* @__PURE__ */ new Date()).toISOString();
3123
- await writeManifest(this.paths, manifest);
3124
- await this.appendAndEmit(conversationId, { type: "conversation.updated", payload: { conversation: this.summarizeManifest(manifest) } }, manifest);
3125
- await writeManifest(this.paths, manifest);
3126
- return { conversation_id: conversationId, title };
3127
- });
3128
- }
3129
- async setConversationModel(conversationId, modelId) {
3130
- return withConversationLock(conversationId, async () => {
3131
- const manifest = await readActiveManifest(this.paths, conversationId);
3132
- manifest.updated_at = (/* @__PURE__ */ new Date()).toISOString();
3133
- await writeManifest(this.paths, manifest);
3134
- return { conversation_id: conversationId, model_id: modelId };
3135
- });
3136
- }
3137
- async setConversationProfile(conversationId, profileName) {
3138
- return withConversationLock(conversationId, async () => {
3139
- const manifest = await readActiveManifest(this.paths, conversationId);
3140
- manifest.profile = profileName;
3141
- manifest.profile_name_snapshot = profileName;
3142
- manifest.updated_at = (/* @__PURE__ */ new Date()).toISOString();
3143
- await writeManifest(this.paths, manifest);
3144
- return { conversation_id: conversationId, profile: profileName };
3145
- });
3146
- }
3147
- async ackConversation(conversationId, lastEventSeq) {
3148
- const manifest = await readActiveManifest(this.paths, conversationId);
3149
- return { conversation_id: conversationId, last_event_seq: manifest.last_event_seq, acked_seq: lastEventSeq };
3150
- }
3151
- async writeBlob(conversationId, input) {
3152
- return writeBlob(this.paths, conversationId, input);
3153
- }
3154
- async readBlob(conversationId, blobId) {
3155
- return readBlob(this.paths, conversationId, blobId);
3156
- }
3157
- async deleteUnreferencedBlob(conversationId, blobId) {
3158
- const snapshot = await readSnapshot(this.paths, conversationId);
3159
- return deleteUnreferencedBlob(this.paths, conversationId, blobId, snapshot);
3160
- }
3161
- async deleteLocalConversationsForProfile(options) {
3162
- const ids = await listConversationIds(this.paths);
3163
- let count = 0;
3164
- for (const id of ids) {
3165
- const m = await readManifest(this.paths, id);
3166
- if (!m || m.status === "deleted_soft") continue;
3167
- if (m.profile === options.profileName || options.profileUid && m.profile_uid === options.profileUid) {
3168
- await this.deleteConversation(id);
3169
- count++;
3170
- }
3171
- }
3172
- return { deleted_count: count };
3173
- }
3174
- async prepareClearAllConversationPlan() {
3175
- const planId = `plan_${crypto4.randomUUID().replaceAll("-", "")}`;
3176
- const ids = await listConversationIds(this.paths);
3177
- const activeIds = [];
3178
- for (const id of ids) {
3179
- const m = await readManifest(this.paths, id);
3180
- if (m && m.status === "active") activeIds.push(id);
3181
- }
3182
- return {
3183
- id: planId,
3184
- status: "pending",
3185
- conversation_count: activeIds.length,
3186
- conversation_ids: activeIds,
3187
- created_at: (/* @__PURE__ */ new Date()).toISOString()
3188
- };
3189
- }
3190
- async readClearAllConversationPlan(planId) {
3191
- return { id: planId, status: "pending", conversation_count: 0, conversation_ids: [], created_at: (/* @__PURE__ */ new Date()).toISOString() };
3192
- }
3193
- async startClearAllConversationPlan(planId) {
3194
- const ids = await listConversationIds(this.paths);
3195
- let count = 0;
3196
- for (const id of ids) {
3197
- const m = await readManifest(this.paths, id);
3198
- if (m && m.status === "active") {
3199
- await this.deleteConversation(id).catch(() => void 0);
3200
- count++;
3201
- }
3202
- }
3203
- return { id: planId, status: "completed", deleted_count: count, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
3204
- }
3205
- async resolveApproval(input) {
3206
- return { conversation_id: input.conversationId, approval_id: input.approvalId, decision: input.decision };
3207
- }
3208
- async getStatistics(options) {
3209
- const ids = await listConversationIds(this.paths);
3210
- let total = 0, active = 0, messages = 0, runs = 0;
3211
- for (const id of ids) {
3212
- const m = await readManifest(this.paths, id);
3213
- if (!m) continue;
3214
- if (options.profileName && m.profile !== options.profileName) continue;
3215
- total++;
3216
- if (m.status === "active") active++;
3217
- const snap = await readSnapshot(this.paths, id);
3218
- messages += snap.messages.length;
3219
- runs += snap.runs.filter((r) => r.kind === "agent").length;
3220
- }
3221
- return {
3222
- conversations: { total, active },
3223
- messages: { total: messages },
3224
- runs: { total: runs },
3225
- models: { total: 0 },
3226
- skills: { total: 0 },
3227
- tools: { total: 0 },
3228
- profiles: { total: 0 }
3229
- };
3230
- }
3231
- summarizeManifest(manifest) {
3232
- return {
3233
- id: manifest.id,
3234
- kind: manifest.kind,
3235
- title: manifest.title,
3236
- status: manifest.status,
3237
- profile: manifest.profile,
3238
- profile_uid: manifest.profile_uid,
3239
- last_event_seq: manifest.last_event_seq,
3240
- created_at: manifest.created_at,
3241
- updated_at: manifest.updated_at,
3242
- stats: manifest.stats ?? null
3243
- };
3244
- }
3245
- };
3246
-
3247
- // src/hermes/gateway.ts
3248
- import { execFile as execFile3, spawn } from "child_process";
3249
- import { promisify as promisify3 } from "util";
3250
-
3251
- // src/hermes/config.ts
3252
- import os6 from "os";
3253
- import path11 from "path";
3254
- import YAML from "yaml";
3255
- var PROFILE_PERMISSION_TOOLSETS = [
3256
- {
3257
- key: "web",
3258
- label: "Web \u641C\u7D22",
3259
- description: "\u8054\u7F51\u641C\u7D22\u4E0E\u7F51\u9875\u5185\u5BB9\u63D0\u53D6",
3260
- risk: "low"
3261
- },
3262
- {
3263
- key: "vision",
3264
- label: "\u89C6\u89C9\u7406\u89E3",
3265
- description: "\u8BFB\u53D6\u56FE\u7247\u5185\u5BB9\u5E76\u53C2\u4E0E\u63A8\u7406",
3266
- risk: "low"
3267
- },
3268
- {
3269
- key: "image_gen",
3270
- label: "\u56FE\u7247\u751F\u6210",
3271
- description: "\u8C03\u7528\u56FE\u7247\u751F\u6210\u540E\u7AEF",
3272
- risk: "low"
3273
- },
3274
- {
3275
- key: "browser",
3276
- label: "\u6D4F\u89C8\u5668\u81EA\u52A8\u5316",
3277
- description: "\u6253\u5F00\u7F51\u9875\u3001\u70B9\u51FB\u3001\u8F93\u5165\u548C\u8BFB\u53D6\u9875\u9762",
3278
- risk: "medium"
3279
- },
3280
- {
3281
- key: "skills",
3282
- label: "Skills",
3283
- description: "\u8BFB\u53D6\u548C\u7BA1\u7406 Hermes skills",
3284
- risk: "medium"
3285
- },
3286
- {
3287
- key: "memory",
3288
- label: "Memory",
3289
- description: "\u8BFB\u53D6\u548C\u5199\u5165\u957F\u671F\u8BB0\u5FC6",
3290
- risk: "medium"
3291
- },
3292
- {
3293
- key: "session_search",
3294
- label: "\u4F1A\u8BDD\u641C\u7D22",
3295
- description: "\u641C\u7D22\u5386\u53F2\u4F1A\u8BDD\u4E0E\u6458\u8981",
3296
- risk: "medium"
3297
- },
3298
- {
3299
- key: "todo",
3300
- label: "\u4EFB\u52A1\u89C4\u5212",
3301
- description: "\u7EF4\u62A4\u4F1A\u8BDD\u5185\u4EFB\u52A1\u5217\u8868",
3302
- risk: "medium"
3303
- },
3304
- {
3305
- key: "delegation",
3306
- label: "\u5B50\u4EFB\u52A1\u4EE3\u7406",
3307
- description: "\u521B\u5EFA\u72EC\u7ACB\u4E0A\u4E0B\u6587\u7684\u5B50 Agent",
3308
- risk: "medium"
3309
- },
3310
- {
3311
- key: "terminal",
3312
- label: "\u7EC8\u7AEF\u6267\u884C",
3313
- description: "\u6267\u884C shell \u547D\u4EE4\u548C\u7BA1\u7406\u8FDB\u7A0B",
3314
- risk: "high"
3315
- },
3316
- {
3317
- key: "file",
3318
- label: "\u6587\u4EF6\u8BFB\u5199",
3319
- description: "\u8BFB\u53D6\u3001\u641C\u7D22\u3001\u5199\u5165\u548C patch \u6587\u4EF6",
3320
- risk: "high"
3321
- },
3322
- {
3323
- key: "code_execution",
3324
- label: "\u4EE3\u7801\u6267\u884C",
3325
- description: "\u5728\u5DE5\u5177\u6C99\u7BB1\u4E2D\u6267\u884C\u4EE3\u7801\u7247\u6BB5",
3326
- risk: "high"
3327
- },
3328
- {
3329
- key: "cronjob",
3330
- label: "\u5B9A\u65F6\u4EFB\u52A1",
3331
- description: "\u521B\u5EFA\u3001\u66F4\u65B0\u3001\u6682\u505C\u548C\u8FD0\u884C cron jobs",
3332
- risk: "high"
3333
- },
3334
- {
3335
- key: "messaging",
3336
- label: "\u8DE8\u5E73\u53F0\u6D88\u606F",
3337
- description: "\u5411 Telegram\u3001Discord\u3001Slack \u7B49\u5E73\u53F0\u53D1\u6D88\u606F",
3338
- risk: "high"
3339
- },
3340
- {
3341
- key: "homeassistant",
3342
- label: "\u667A\u80FD\u5BB6\u5C45",
3343
- description: "\u8BFB\u53D6\u548C\u63A7\u5236 Home Assistant \u8BBE\u5907",
3344
- risk: "high"
3345
- },
3346
- {
3347
- key: "stt",
3348
- label: "\u8BED\u97F3\u8F6C\u5199 (STT)",
3349
- description: "\u628A\u7528\u6237\u8BED\u97F3\u6D88\u606F\u8F6C\u6210\u6587\u672C\u8F93\u5165",
3350
- risk: "medium"
3351
- },
3352
- {
3353
- key: "tts",
3354
- label: "\u8BED\u97F3\u5408\u6210 (TTS)",
3355
- description: "\u751F\u6210\u8BED\u97F3\u97F3\u9891",
3356
- risk: "medium"
3357
- },
3358
- {
3359
- key: "moa",
3360
- label: "Mixture of Agents",
3361
- description: "\u8C03\u7528\u591A Agent \u63A8\u7406\u5DE5\u5177",
3362
- risk: "medium"
3363
- },
3364
- {
3365
- key: "rl",
3366
- label: "RL \u8BAD\u7EC3",
3367
- description: "\u7BA1\u7406\u5F3A\u5316\u5B66\u4E60\u8BAD\u7EC3\u4EFB\u52A1",
3368
- risk: "high"
3369
- }
3370
- ];
3371
- var PROFILE_PERMISSION_TOOLSET_KEYS = new Set(
3372
- PROFILE_PERMISSION_TOOLSETS.map((toolset) => toolset.key)
3373
- );
3374
- var API_SERVER_PROFILE_TOOLSET_KEYS = new Set(
3375
- PROFILE_PERMISSION_TOOLSETS.filter((toolset) => toolset.key !== "stt").map(
3376
- (toolset) => toolset.key
3377
- )
3378
- );
3379
- var profileApiServerPortAssignmentQueue = Promise.resolve();
3380
- function resolveHermesProfilesDir() {
3381
- const hermesHome = process.env.HERMES_HOME?.trim();
3382
- if (hermesHome) {
3383
- return path11.join(
3384
- resolveDefaultHermesRoot(path11.resolve(hermesHome)),
3385
- "profiles"
3386
- );
3387
- }
3388
- return path11.join(os6.homedir(), ".hermes", "profiles");
3389
- }
3390
- function isValidProfileName(value) {
3391
- return typeof value === "string" && /^[a-zA-Z0-9._-]{1,64}$/.test(value);
3392
- }
3393
- function resolveDefaultHermesRoot(hermesHome) {
3394
- if (path11.basename(path11.dirname(hermesHome)) === "profiles") {
3395
- return path11.dirname(path11.dirname(hermesHome));
3396
- }
3397
- return hermesHome;
3398
- }
3399
-
3400
- // src/hermes/cli.ts
3401
- import { execFile as execFile2 } from "child_process";
3402
- import { promisify as promisify2 } from "util";
3403
- var execFileAsync2 = promisify2(execFile2);
3404
-
3405
- // src/hermes/gateway.ts
3406
- import { readdir as readdir2 } from "fs/promises";
3407
- var execFileAsync3 = promisify3(execFile3);
3408
- async function listHermesProfiles() {
3409
- try {
3410
- const profilesDir = resolveHermesProfilesDir();
3411
- const entries = await readdir2(profilesDir, { withFileTypes: true });
3412
- return entries.filter((e) => e.isDirectory() && isValidProfileName(e.name)).map((e) => e.name);
3413
- } catch {
3414
- return [];
3415
- }
3416
- }
3417
-
3418
- // src/http/app.ts
3419
- async function startLinkService(options) {
3420
- const { config, identity, paths, relayToken } = options;
3421
- const logger = createLogger({ paths, fileName: "link.log", level: config.logLevel });
3422
- await initLinkDatabase(paths);
3423
- const db = openSqliteDatabase(paths.databaseFile, { timeout: 5e3 });
3424
- await mkdir8(paths.conversationsDir, { recursive: true, mode: 448 }).catch(() => void 0);
3425
- await mkdir8(paths.blobsDir, { recursive: true, mode: 448 }).catch(() => void 0);
3426
- await mkdir8(paths.pairingDir, { recursive: true, mode: 448 }).catch(() => void 0);
3427
- const conversations = new ConversationService(paths);
3428
- const app = new Koa();
3429
- app.use(cors({ origin: "*" }));
3430
- app.use(bodyParser({ jsonLimit: "10mb" }));
3431
- app.use(async (ctx, next) => {
3432
- try {
3433
- await next();
3434
- } catch (err) {
3435
- const error = err;
3436
- ctx.status = error.status ?? error.statusCode ?? 500;
3437
- ctx.body = {
3438
- ok: false,
3439
- error: {
3440
- code: error.code ?? "internal_error",
3441
- message: error.message ?? "Internal server error"
3442
- }
3443
- };
3444
- if (ctx.status >= 500) {
3445
- logger.error({ path: ctx.path, status: ctx.status, err: error.message }, "Request error");
3446
- }
3447
- }
3448
- });
3449
- const rootRouter = new Router8();
3450
- rootRouter.get("/pair", (ctx) => {
3451
- const connectToken = typeof ctx.query.connect_token === "string" ? ctx.query.connect_token : "";
3452
- ctx.type = "text/html";
3453
- ctx.body = buildPairingPage({ port: config.port, connectToken });
3454
- });
3455
- rootRouter.get("/api/v1/status", async (ctx) => {
3456
- await authenticateRequest(ctx, paths);
3457
- ctx.set("cache-control", "no-store");
3458
- const [devices, profiles, linkUpdate] = await Promise.all([
3459
- readDeviceSummary(paths),
3460
- listHermesProfiles().catch(() => []),
3461
- checkForUpdates({ relayBaseUrl: config.relayBaseUrl, paths }).catch(() => null)
3462
- ]);
3463
- ctx.body = {
3464
- ok: true,
3465
- version: LINK_VERSION,
3466
- paired: Boolean(identity.link_id),
3467
- link_id: identity.link_id ?? null,
3468
- port: config.port,
3469
- link: {
3470
- state: "online",
3471
- version: LINK_VERSION,
3472
- update_available: linkUpdate?.availableVersion != null
3473
- },
3474
- hermes: {
3475
- local_version: null,
3476
- update_available: false
3477
- },
3478
- gateway: { state: "unknown", issue: null },
3479
- api_server: { state: "unknown", issue: null },
3480
- devices,
3481
- profiles: { total: profiles.length }
3482
- };
3483
- });
3484
- rootRouter.get("/api/v1/logs", async (ctx) => {
3485
- await authenticateRequest(ctx, paths);
3486
- const source = typeof ctx.query.source === "string" ? ctx.query.source : "link";
3487
- const limit = typeof ctx.query.limit === "string" ? Number.parseInt(ctx.query.limit, 10) : 50;
3488
- ctx.set("cache-control", "no-store");
3489
- ctx.body = {
3490
- ok: true,
3491
- source,
3492
- logs: source === "gateway" ? await readRecentGatewayLogEntries({ paths, limit }) : await readRecentLogEntries({ paths, limit })
3493
- };
3494
- });
3495
- app.use(rootRouter.routes());
3496
- app.use(rootRouter.allowedMethods());
3497
- const bootstrapRouter = createBootstrapRouter({ paths });
3498
- app.use(bootstrapRouter.routes());
3499
- app.use(bootstrapRouter.allowedMethods());
3500
- const authRouter = createAuthRouter({ paths, logger });
3501
- app.use(authRouter.routes());
3502
- app.use(authRouter.allowedMethods());
3503
- const pairingRouter = createPairingRouter({ paths, logger });
3504
- app.use(pairingRouter.routes());
3505
- app.use(pairingRouter.allowedMethods());
3506
- const devicesRouter = createDevicesRouter({ paths, logger });
3507
- app.use(devicesRouter.routes());
3508
- app.use(devicesRouter.allowedMethods());
3509
- const conversationsRouter = createConversationsRouter({ paths, conversations, logger });
3510
- app.use(conversationsRouter.routes());
3511
- app.use(conversationsRouter.allowedMethods());
3512
- const systemRouter = createSystemRouter({ config, identity, paths });
3513
- app.use(systemRouter.routes());
3514
- app.use(systemRouter.allowedMethods());
3515
- const statsRouter = createStatisticsRouter({ db, paths });
3516
- app.use(statsRouter.routes());
3517
- app.use(statsRouter.allowedMethods());
3518
- const listenHost = process.env.HERMESLINK_LISTEN_HOST ?? "0.0.0.0";
3519
- const server = await new Promise((resolve, reject) => {
3520
- const s = app.listen(config.port, listenHost, () => resolve(s));
3521
- s.once("error", reject);
3522
- });
3523
- const relayClient = new RelayClient({
3524
- relayBaseUrl: config.relayBaseUrl,
3525
- identity,
3526
- token: relayToken,
3527
- paths
3528
- });
3529
- relayClient.start();
3530
- reportNetwork({ config, identity, paths }).catch(() => void 0);
3531
- const stop = async () => {
3532
- relayClient.stop();
3533
- await new Promise((resolve) => server.close(() => resolve()));
3534
- db.close();
3535
- logger.info("Link service stopped");
3536
- };
3537
- return { app, server, relayClient, stop };
3538
- }
3539
- async function reportNetwork(options) {
3540
- const candidates = await discoverRouteCandidates({
3541
- configuredLanHost: options.config.lanHost,
3542
- relayBaseUrl: options.config.relayBaseUrl,
3543
- linkId: options.identity.link_id ?? "",
3544
- port: options.config.port,
3545
- installId: options.identity.install_id,
3546
- publicKeyPem: options.identity.public_key_pem
3547
- });
3548
- await updateNetworkReportState(
3549
- {
3550
- lastReportedAt: (/* @__PURE__ */ new Date()).toISOString(),
3551
- preferredUrls: candidates.preferredUrls,
3552
- lanIps: candidates.lanIps,
3553
- publicIpv4s: candidates.publicIpv4s,
3554
- publicIpv6s: candidates.publicIpv6s
3555
- },
3556
- options.paths
3557
- );
3558
- }
3559
- function buildPairingPage(options) {
3560
- return `<!DOCTYPE html>
3561
- <html lang="en">
3562
- <head>
3563
- <meta charset="UTF-8" />
3564
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
3565
- <title>Hermes Link \u2014 Pairing</title>
3566
- <style>
3567
- * { box-sizing: border-box; margin: 0; padding: 0; }
3568
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f0f0f; color: #e5e5e5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
3569
- .card { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 12px; padding: 2rem; max-width: 420px; width: 100%; text-align: center; }
3570
- h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
3571
- p { color: #a0a0a0; font-size: 0.9rem; margin-bottom: 1.5rem; line-height: 1.5; }
3572
- .token { font-family: monospace; background: #0f0f0f; border: 1px solid #333; border-radius: 6px; padding: 0.75rem 1rem; font-size: 0.85rem; word-break: break-all; color: #7dd3fc; margin-bottom: 1.5rem; }
3573
- button { background: #3b82f6; color: #fff; border: none; border-radius: 8px; padding: 0.75rem 1.5rem; font-size: 0.95rem; cursor: pointer; width: 100%; }
3574
- button:hover { background: #2563eb; }
3575
- .status { margin-top: 1rem; font-size: 0.85rem; color: #6ee7b7; display: none; }
3576
- </style>
3577
- </head>
3578
- <body>
3579
- <div class="card">
3580
- <h1>Hermes Link Pairing</h1>
3581
- <p>Use this page to pair your device with the local Hermes Link service running on port ${options.port}.</p>
3582
- <div class="token" id="token">${options.connectToken}</div>
3583
- <button onclick="pair()">Pair This Device</button>
3584
- <div class="status" id="status">Paired successfully!</div>
3585
- </div>
3586
- <script>
3587
- async function pair() {
3588
- const token = document.getElementById('token').textContent.trim();
3589
- const btn = document.querySelector('button');
3590
- btn.disabled = true;
3591
- btn.textContent = 'Pairing...';
3592
- try {
3593
- const res = await fetch('/api/v1/auth/device-session', {
3594
- method: 'POST',
3595
- headers: {
3596
- 'Authorization': 'Bearer ' + token,
3597
- 'Content-Type': 'application/json'
3598
- },
3599
- body: JSON.stringify({
3600
- device_label: navigator.userAgent.slice(0, 64),
3601
- device_platform: 'web'
3602
- })
3603
- });
3604
- const data = await res.json();
3605
- if (res.ok && data.access_token) {
3606
- document.getElementById('status').style.display = 'block';
3607
- document.getElementById('status').innerHTML =
3608
- 'Paired! Access token:<br><code style="font-size:0.75rem;word-break:break-all">' +
3609
- data.access_token.token + '</code>';
3610
- btn.textContent = 'Paired';
3611
- } else {
3612
- throw new Error(data.error?.message || JSON.stringify(data));
3613
- }
3614
- } catch (e) {
3615
- btn.disabled = false;
3616
- btn.textContent = 'Pair This Device';
3617
- alert('Pairing failed: ' + e.message);
3618
- }
3619
- }
3620
- </script>
3621
- </body>
3622
- </html>`;
3623
- }
3624
-
3625
- export {
3626
- LINK_COMMAND,
3627
- LINK_VERSION,
3628
- DAEMON_LOG_FILE,
3629
- resolveRuntimePaths,
3630
- writeJsonFile,
3631
- loadConfig,
3632
- saveConfig,
3633
- normalizeLanHost,
3634
- loadIdentity,
3635
- ensureIdentity,
3636
- saveAssignedLinkId,
3637
- enableAutostart,
3638
- disableAutostart,
3639
- getAutostartStatus,
3640
- currentCliScriptPath,
3641
- detectRuntimeEnvironment,
3642
- createRotatingTextLogWriter,
3643
- readRecentLogEntries,
3644
- readRecentGatewayLogEntries,
3645
- bootstrapWithRelay,
3646
- generateAppConnectToken,
3647
- discoverRouteCandidates,
3648
- startLinkService
3649
- };