@hermespilot/link 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/chunk-SCIAZZ4C.js +2992 -0
- package/dist/chunk-SCIAZZ4C.js.map +1 -0
- package/dist/cli/index.js +16 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/http/app.d.ts +3 -0
- package/dist/http/app.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-E54NSFGF.js +0 -1652
- package/dist/chunk-E54NSFGF.js.map +0 -1
package/dist/chunk-E54NSFGF.js
DELETED
|
@@ -1,1652 +0,0 @@
|
|
|
1
|
-
// src/http/app.ts
|
|
2
|
-
import Koa from "koa";
|
|
3
|
-
import Router from "@koa/router";
|
|
4
|
-
import { Readable } from "stream";
|
|
5
|
-
|
|
6
|
-
// src/constants.ts
|
|
7
|
-
var LINK_VERSION = "0.1.5";
|
|
8
|
-
var LINK_COMMAND = "hermeslink";
|
|
9
|
-
var LINK_DEFAULT_PORT = 52379;
|
|
10
|
-
var LINK_RUNTIME_DIR_NAME = ".hermeslink";
|
|
11
|
-
|
|
12
|
-
// src/runtime/paths.ts
|
|
13
|
-
import os from "os";
|
|
14
|
-
import path from "path";
|
|
15
|
-
function resolveRuntimeHome() {
|
|
16
|
-
return process.env.HERMESLINK_HOME?.trim() ? path.resolve(process.env.HERMESLINK_HOME) : path.join(os.homedir(), LINK_RUNTIME_DIR_NAME);
|
|
17
|
-
}
|
|
18
|
-
function resolveRuntimePaths(homeDir = resolveRuntimeHome()) {
|
|
19
|
-
return {
|
|
20
|
-
homeDir,
|
|
21
|
-
identityFile: path.join(homeDir, "identity.json"),
|
|
22
|
-
configFile: path.join(homeDir, "config.json"),
|
|
23
|
-
stateFile: path.join(homeDir, "state.json"),
|
|
24
|
-
credentialsFile: path.join(homeDir, "credentials.json"),
|
|
25
|
-
databaseFile: path.join(homeDir, "link.db"),
|
|
26
|
-
logsDir: path.join(homeDir, "logs"),
|
|
27
|
-
runDir: path.join(homeDir, "run"),
|
|
28
|
-
pairingDir: path.join(homeDir, "pairing")
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// src/storage/atomic-json.ts
|
|
33
|
-
import { mkdir, open, readFile, rename, rm } from "fs/promises";
|
|
34
|
-
import path2 from "path";
|
|
35
|
-
async function readJsonFile(filePath) {
|
|
36
|
-
try {
|
|
37
|
-
const raw = await readFile(filePath, "utf8");
|
|
38
|
-
return JSON.parse(raw);
|
|
39
|
-
} catch (error) {
|
|
40
|
-
if (isNodeError(error, "ENOENT")) {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
async function writeJsonFile(filePath, value, mode = 384) {
|
|
47
|
-
await mkdir(path2.dirname(filePath), { recursive: true, mode: 448 });
|
|
48
|
-
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
49
|
-
const payload = `${JSON.stringify(value, null, 2)}
|
|
50
|
-
`;
|
|
51
|
-
const handle = await open(tmpPath, "w", mode);
|
|
52
|
-
try {
|
|
53
|
-
await handle.writeFile(payload, "utf8");
|
|
54
|
-
await handle.sync();
|
|
55
|
-
} finally {
|
|
56
|
-
await handle.close();
|
|
57
|
-
}
|
|
58
|
-
try {
|
|
59
|
-
await rename(tmpPath, filePath);
|
|
60
|
-
} catch (error) {
|
|
61
|
-
await rm(tmpPath, { force: true });
|
|
62
|
-
throw error;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
function isNodeError(error, code) {
|
|
66
|
-
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// src/config/config.ts
|
|
70
|
-
var defaultLinkConfig = {
|
|
71
|
-
port: LINK_DEFAULT_PORT,
|
|
72
|
-
serverBaseUrl: "https://hermes-server.clawpilot.me",
|
|
73
|
-
relayBaseUrl: "https://hermes-relay.clawpilot.me",
|
|
74
|
-
appConnectTokenIssuer: "https://hermes-server.clawpilot.me",
|
|
75
|
-
appConnectTokenAudience: "hermes-link",
|
|
76
|
-
language: "auto"
|
|
77
|
-
};
|
|
78
|
-
async function loadConfig(paths = resolveRuntimePaths()) {
|
|
79
|
-
const existing = await readJsonFile(paths.configFile);
|
|
80
|
-
const language = normalizeConfiguredLanguage(existing?.language);
|
|
81
|
-
return {
|
|
82
|
-
...defaultLinkConfig,
|
|
83
|
-
...existing ?? {},
|
|
84
|
-
language
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
function normalizeConfiguredLanguage(language) {
|
|
88
|
-
if (language === "zh-CN" || language === "en" || language === "auto") {
|
|
89
|
-
return language;
|
|
90
|
-
}
|
|
91
|
-
return defaultLinkConfig.language;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// src/http/errors.ts
|
|
95
|
-
var LinkHttpError = class extends Error {
|
|
96
|
-
constructor(status, code, message) {
|
|
97
|
-
super(message);
|
|
98
|
-
this.status = status;
|
|
99
|
-
this.code = code;
|
|
100
|
-
}
|
|
101
|
-
status;
|
|
102
|
-
code;
|
|
103
|
-
};
|
|
104
|
-
function isLinkHttpError(error) {
|
|
105
|
-
return error instanceof LinkHttpError;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// src/hermes/api-server.ts
|
|
109
|
-
import { randomUUID } from "crypto";
|
|
110
|
-
|
|
111
|
-
// src/hermes/config.ts
|
|
112
|
-
import { randomBytes } from "crypto";
|
|
113
|
-
import { copyFile, mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
|
|
114
|
-
import os2 from "os";
|
|
115
|
-
import path3 from "path";
|
|
116
|
-
import YAML from "yaml";
|
|
117
|
-
function resolveHermesProfileDir(profileName = "default") {
|
|
118
|
-
if (profileName === "default") {
|
|
119
|
-
return path3.join(os2.homedir(), ".hermes");
|
|
120
|
-
}
|
|
121
|
-
return path3.join(os2.homedir(), ".hermes", "profiles", profileName);
|
|
122
|
-
}
|
|
123
|
-
function resolveHermesConfigPath(profileName = "default") {
|
|
124
|
-
return path3.join(resolveHermesProfileDir(profileName), "config.yaml");
|
|
125
|
-
}
|
|
126
|
-
async function readHermesApiServerConfig(profileName = "default", configPath = resolveHermesConfigPath(profileName)) {
|
|
127
|
-
const existingRaw = await readFile2(configPath, "utf8").catch((error) => {
|
|
128
|
-
if (isNodeError2(error, "ENOENT")) {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
throw error;
|
|
132
|
-
});
|
|
133
|
-
if (!existingRaw) {
|
|
134
|
-
return {};
|
|
135
|
-
}
|
|
136
|
-
const config = toRecord(YAML.parse(existingRaw));
|
|
137
|
-
const platforms = toRecord(config.platforms);
|
|
138
|
-
const apiServer = toRecord(platforms.api_server);
|
|
139
|
-
return readApiServerConfig(toRecord(apiServer.extra));
|
|
140
|
-
}
|
|
141
|
-
async function ensureHermesApiServerKey(profileName = "default", configPath = resolveHermesConfigPath(profileName)) {
|
|
142
|
-
const existingRaw = await readFile2(configPath, "utf8").catch((error) => {
|
|
143
|
-
if (isNodeError2(error, "ENOENT")) {
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
throw error;
|
|
147
|
-
});
|
|
148
|
-
const document = existingRaw ? YAML.parseDocument(existingRaw) : new YAML.Document({});
|
|
149
|
-
const config = toRecord(document.toJSON());
|
|
150
|
-
const platforms = ensureRecord(config, "platforms");
|
|
151
|
-
const apiServer = ensureRecord(platforms, "api_server");
|
|
152
|
-
const extra = ensureRecord(apiServer, "extra");
|
|
153
|
-
const beforeKey = typeof extra.key === "string" && extra.key.length > 0 ? extra.key : null;
|
|
154
|
-
let changed = false;
|
|
155
|
-
if (!beforeKey) {
|
|
156
|
-
extra.key = randomBytes(32).toString("base64url");
|
|
157
|
-
changed = true;
|
|
158
|
-
}
|
|
159
|
-
if (!changed) {
|
|
160
|
-
return {
|
|
161
|
-
configPath,
|
|
162
|
-
apiServer: readApiServerConfig(extra),
|
|
163
|
-
changed: false,
|
|
164
|
-
keyAdded: false,
|
|
165
|
-
backupPath: null,
|
|
166
|
-
notice: null
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
const backupPath = existingRaw ? `${configPath}.bak.${Date.now()}` : null;
|
|
170
|
-
await mkdir2(path3.dirname(configPath), { recursive: true, mode: 448 });
|
|
171
|
-
if (backupPath) {
|
|
172
|
-
await copyFile(configPath, backupPath);
|
|
173
|
-
}
|
|
174
|
-
document.contents = document.createNode(config);
|
|
175
|
-
await writeFile(configPath, document.toString(), { mode: 384 });
|
|
176
|
-
return {
|
|
177
|
-
configPath,
|
|
178
|
-
apiServer: readApiServerConfig(extra),
|
|
179
|
-
changed: true,
|
|
180
|
-
keyAdded: true,
|
|
181
|
-
backupPath,
|
|
182
|
-
notice: "\u5DF2\u4E3A Hermes API Server \u81EA\u52A8\u8865\u5145 key\uFF1B\u672A\u8986\u76D6\u5DF2\u6709 port/host/key\u3002"
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
function readApiServerConfig(extra) {
|
|
186
|
-
return {
|
|
187
|
-
host: typeof extra.host === "string" ? extra.host : void 0,
|
|
188
|
-
port: typeof extra.port === "number" ? extra.port : void 0,
|
|
189
|
-
key: typeof extra.key === "string" ? extra.key : void 0
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
function toRecord(value) {
|
|
193
|
-
return typeof value === "object" && value !== null ? value : {};
|
|
194
|
-
}
|
|
195
|
-
function ensureRecord(parent, key) {
|
|
196
|
-
const value = parent[key];
|
|
197
|
-
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
198
|
-
return value;
|
|
199
|
-
}
|
|
200
|
-
const next = {};
|
|
201
|
-
parent[key] = next;
|
|
202
|
-
return next;
|
|
203
|
-
}
|
|
204
|
-
function isNodeError2(error, code) {
|
|
205
|
-
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// src/hermes/api-server.ts
|
|
209
|
-
var fallbackRuns = /* @__PURE__ */ new Map();
|
|
210
|
-
async function listHermesModels(options = {}) {
|
|
211
|
-
const response = await callHermesApi("/v1/models", { method: "GET" }, options);
|
|
212
|
-
if (response.status === 404) {
|
|
213
|
-
return { models: [] };
|
|
214
|
-
}
|
|
215
|
-
return await readJsonResponse(response);
|
|
216
|
-
}
|
|
217
|
-
async function createHermesRun(input, options = {}) {
|
|
218
|
-
const response = await callHermesApi(
|
|
219
|
-
"/v1/runs",
|
|
220
|
-
{
|
|
221
|
-
method: "POST",
|
|
222
|
-
body: JSON.stringify(input),
|
|
223
|
-
headers: { "content-type": "application/json" }
|
|
224
|
-
},
|
|
225
|
-
options
|
|
226
|
-
);
|
|
227
|
-
if (response.status === 404 || response.status === 503) {
|
|
228
|
-
const runId2 = `run_${randomUUID().replaceAll("-", "")}`;
|
|
229
|
-
fallbackRuns.set(runId2, { input: input.input, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
230
|
-
return { run_id: runId2, fallback: true };
|
|
231
|
-
}
|
|
232
|
-
const payload = await readJsonResponse(response);
|
|
233
|
-
const runId = readString(payload, "run_id") ?? readString(payload, "runId") ?? readString(payload, "id");
|
|
234
|
-
if (!runId) {
|
|
235
|
-
throw new LinkHttpError(502, "hermes_run_invalid", "Hermes API Server did not return a run id");
|
|
236
|
-
}
|
|
237
|
-
return { run_id: runId, fallback: false };
|
|
238
|
-
}
|
|
239
|
-
async function streamHermesRunEvents(runId, options = {}) {
|
|
240
|
-
const fallback = fallbackRuns.get(runId);
|
|
241
|
-
if (fallback) {
|
|
242
|
-
fallbackRuns.delete(runId);
|
|
243
|
-
return new Response(createFallbackSseStream(fallback.input), {
|
|
244
|
-
headers: {
|
|
245
|
-
"content-type": "text/event-stream; charset=utf-8",
|
|
246
|
-
"cache-control": "no-store"
|
|
247
|
-
}
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
const response = await callHermesApi(`/v1/runs/${encodeURIComponent(runId)}/events`, { method: "GET" }, options);
|
|
251
|
-
if (!response.ok || !response.body) {
|
|
252
|
-
throw new LinkHttpError(502, "hermes_events_unavailable", "Hermes run event stream is unavailable");
|
|
253
|
-
}
|
|
254
|
-
return new Response(response.body, {
|
|
255
|
-
status: response.status,
|
|
256
|
-
headers: {
|
|
257
|
-
"content-type": response.headers.get("content-type") ?? "text/event-stream; charset=utf-8",
|
|
258
|
-
"cache-control": "no-store"
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
async function cancelHermesRun(runId, options = {}) {
|
|
263
|
-
const response = await callHermesApi(
|
|
264
|
-
`/v1/runs/${encodeURIComponent(runId)}/cancel`,
|
|
265
|
-
{ method: "POST" },
|
|
266
|
-
options
|
|
267
|
-
);
|
|
268
|
-
if (!response.ok && response.status !== 404) {
|
|
269
|
-
throw new LinkHttpError(502, "hermes_cancel_failed", "Hermes run cancel failed");
|
|
270
|
-
}
|
|
271
|
-
fallbackRuns.delete(runId);
|
|
272
|
-
}
|
|
273
|
-
async function callHermesApi(path7, init, options) {
|
|
274
|
-
const config = await readHermesApiServerConfig();
|
|
275
|
-
if (!config.port || !config.key) {
|
|
276
|
-
return new Response(null, { status: 503 });
|
|
277
|
-
}
|
|
278
|
-
const fetcher = options.fetchImpl ?? fetch;
|
|
279
|
-
const headers = new Headers(init.headers);
|
|
280
|
-
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
281
|
-
headers.set("x-api-key", config.key);
|
|
282
|
-
headers.set("authorization", `Bearer ${config.key}`);
|
|
283
|
-
return await fetcher(`http://127.0.0.1:${config.port}${path7}`, {
|
|
284
|
-
...init,
|
|
285
|
-
headers
|
|
286
|
-
}).catch(() => new Response(null, { status: 503 }));
|
|
287
|
-
}
|
|
288
|
-
async function readJsonResponse(response) {
|
|
289
|
-
const payload = await response.json().catch(() => null);
|
|
290
|
-
if (!response.ok || typeof payload !== "object" || payload === null) {
|
|
291
|
-
throw new LinkHttpError(502, "hermes_response_invalid", "Hermes API Server returned an invalid response");
|
|
292
|
-
}
|
|
293
|
-
return payload;
|
|
294
|
-
}
|
|
295
|
-
function createFallbackSseStream(input) {
|
|
296
|
-
const encoder = new TextEncoder();
|
|
297
|
-
return new ReadableStream({
|
|
298
|
-
start(controller) {
|
|
299
|
-
const message = `Hermes API Server is not running yet. Link received: ${input}`;
|
|
300
|
-
controller.enqueue(encoder.encode(`event: message.delta
|
|
301
|
-
data: ${JSON.stringify({ type: "message.delta", delta: message })}
|
|
302
|
-
|
|
303
|
-
`));
|
|
304
|
-
controller.enqueue(encoder.encode(`event: run.completed
|
|
305
|
-
data: ${JSON.stringify({ type: "run.completed" })}
|
|
306
|
-
|
|
307
|
-
`));
|
|
308
|
-
controller.close();
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
function readString(payload, key) {
|
|
313
|
-
const value = payload[key];
|
|
314
|
-
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// src/hermes/profiles.ts
|
|
318
|
-
import { mkdir as mkdir3, readdir, rename as rename2, rm as rm2, stat } from "fs/promises";
|
|
319
|
-
import os3 from "os";
|
|
320
|
-
import path4 from "path";
|
|
321
|
-
var DEFAULT_PROFILE = "default";
|
|
322
|
-
var PROFILE_NAME_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
323
|
-
async function listHermesProfiles(paths = resolveRuntimePaths()) {
|
|
324
|
-
const activeProfile = await getActiveProfile(paths);
|
|
325
|
-
const profiles = /* @__PURE__ */ new Map();
|
|
326
|
-
profiles.set(DEFAULT_PROFILE, profileInfo(DEFAULT_PROFILE, activeProfile));
|
|
327
|
-
const profilesDir = path4.join(os3.homedir(), ".hermes", "profiles");
|
|
328
|
-
const entries = await readdir(profilesDir, { withFileTypes: true }).catch((error) => {
|
|
329
|
-
if (isNodeError3(error, "ENOENT")) {
|
|
330
|
-
return [];
|
|
331
|
-
}
|
|
332
|
-
throw error;
|
|
333
|
-
});
|
|
334
|
-
for (const entry of entries) {
|
|
335
|
-
if (entry.isDirectory() && PROFILE_NAME_PATTERN.test(entry.name)) {
|
|
336
|
-
profiles.set(entry.name, profileInfo(entry.name, activeProfile));
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
return [...profiles.values()].sort((left, right) => {
|
|
340
|
-
if (left.name === DEFAULT_PROFILE) {
|
|
341
|
-
return -1;
|
|
342
|
-
}
|
|
343
|
-
if (right.name === DEFAULT_PROFILE) {
|
|
344
|
-
return 1;
|
|
345
|
-
}
|
|
346
|
-
return left.name.localeCompare(right.name);
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
async function getHermesProfileStatus(name, paths = resolveRuntimePaths()) {
|
|
350
|
-
assertProfileName(name);
|
|
351
|
-
const profile = profileInfo(name, await getActiveProfile(paths));
|
|
352
|
-
const exists = await stat(profile.path).then((value) => value.isDirectory()).catch((error) => {
|
|
353
|
-
if (isNodeError3(error, "ENOENT")) {
|
|
354
|
-
return false;
|
|
355
|
-
}
|
|
356
|
-
throw error;
|
|
357
|
-
});
|
|
358
|
-
const config = await readHermesApiServerConfig(name, profile.configPath);
|
|
359
|
-
return {
|
|
360
|
-
...profile,
|
|
361
|
-
exists,
|
|
362
|
-
apiKeyConfigured: Boolean(config.key)
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
async function createHermesProfile(name) {
|
|
366
|
-
assertProfileName(name);
|
|
367
|
-
if (name === DEFAULT_PROFILE) {
|
|
368
|
-
throw new Error("default profile already exists");
|
|
369
|
-
}
|
|
370
|
-
const profile = profileInfo(name, DEFAULT_PROFILE);
|
|
371
|
-
if (await pathExists(profile.path)) {
|
|
372
|
-
throw new Error("profile already exists");
|
|
373
|
-
}
|
|
374
|
-
await mkdir3(profile.path, { recursive: true, mode: 448 });
|
|
375
|
-
await ensureHermesApiServerKey(name, profile.configPath);
|
|
376
|
-
return profile;
|
|
377
|
-
}
|
|
378
|
-
async function useHermesProfile(name, paths = resolveRuntimePaths()) {
|
|
379
|
-
assertProfileName(name);
|
|
380
|
-
const profile = profileInfo(name, name);
|
|
381
|
-
await mkdir3(profile.path, { recursive: true, mode: 448 });
|
|
382
|
-
const current = await readJsonFile(paths.stateFile) ?? {};
|
|
383
|
-
await writeJsonFile(paths.stateFile, { ...current, activeProfile: name });
|
|
384
|
-
return profile;
|
|
385
|
-
}
|
|
386
|
-
async function renameHermesProfile(oldName, newName) {
|
|
387
|
-
assertMutableProfile(oldName);
|
|
388
|
-
assertMutableProfile(newName);
|
|
389
|
-
const oldProfile = profileInfo(oldName, DEFAULT_PROFILE);
|
|
390
|
-
const newProfile = profileInfo(newName, DEFAULT_PROFILE);
|
|
391
|
-
await rename2(oldProfile.path, newProfile.path);
|
|
392
|
-
return newProfile;
|
|
393
|
-
}
|
|
394
|
-
async function deleteHermesProfile(name) {
|
|
395
|
-
assertMutableProfile(name);
|
|
396
|
-
await rm2(resolveHermesProfileDir(name), { recursive: true, force: true });
|
|
397
|
-
}
|
|
398
|
-
async function getActiveProfile(paths) {
|
|
399
|
-
const state = await readJsonFile(paths.stateFile);
|
|
400
|
-
return typeof state?.activeProfile === "string" && PROFILE_NAME_PATTERN.test(state.activeProfile) ? state.activeProfile : DEFAULT_PROFILE;
|
|
401
|
-
}
|
|
402
|
-
function profileInfo(name, activeProfile) {
|
|
403
|
-
return {
|
|
404
|
-
name,
|
|
405
|
-
active: name === activeProfile,
|
|
406
|
-
path: resolveHermesProfileDir(name),
|
|
407
|
-
configPath: resolveHermesConfigPath(name)
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
function assertMutableProfile(name) {
|
|
411
|
-
assertProfileName(name);
|
|
412
|
-
if (name === DEFAULT_PROFILE) {
|
|
413
|
-
throw new Error("default profile cannot be renamed or deleted");
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
function assertProfileName(name) {
|
|
417
|
-
if (!PROFILE_NAME_PATTERN.test(name)) {
|
|
418
|
-
throw new Error("invalid profile name");
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
async function pathExists(targetPath) {
|
|
422
|
-
return await stat(targetPath).then(() => true).catch((error) => {
|
|
423
|
-
if (isNodeError3(error, "ENOENT")) {
|
|
424
|
-
return false;
|
|
425
|
-
}
|
|
426
|
-
throw error;
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
function isNodeError3(error, code) {
|
|
430
|
-
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// src/identity/identity.ts
|
|
434
|
-
import { generateKeyPairSync, randomUUID as randomUUID2, sign } from "crypto";
|
|
435
|
-
import { mkdir as mkdir4, chmod } from "fs/promises";
|
|
436
|
-
import { z } from "zod";
|
|
437
|
-
var linkIdentitySchema = z.object({
|
|
438
|
-
install_id: z.string().min(1),
|
|
439
|
-
link_id: z.string().min(1).nullable().optional(),
|
|
440
|
-
public_key_pem: z.string().min(1),
|
|
441
|
-
private_key_pem: z.string().min(1),
|
|
442
|
-
created_at: z.string().min(1),
|
|
443
|
-
updated_at: z.string().min(1)
|
|
444
|
-
});
|
|
445
|
-
async function loadIdentity(paths = resolveRuntimePaths()) {
|
|
446
|
-
const value = await readJsonFile(paths.identityFile);
|
|
447
|
-
if (value === null) {
|
|
448
|
-
return null;
|
|
449
|
-
}
|
|
450
|
-
return linkIdentitySchema.parse(value);
|
|
451
|
-
}
|
|
452
|
-
async function ensureIdentity(paths = resolveRuntimePaths()) {
|
|
453
|
-
const existing = await loadIdentity(paths);
|
|
454
|
-
if (existing) {
|
|
455
|
-
return existing;
|
|
456
|
-
}
|
|
457
|
-
await mkdir4(paths.homeDir, { recursive: true, mode: 448 });
|
|
458
|
-
await chmod(paths.homeDir, 448).catch(() => void 0);
|
|
459
|
-
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
460
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
461
|
-
const identity = {
|
|
462
|
-
install_id: `install_${randomUUID2().replaceAll("-", "")}`,
|
|
463
|
-
link_id: null,
|
|
464
|
-
public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
465
|
-
private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
466
|
-
created_at: now,
|
|
467
|
-
updated_at: now
|
|
468
|
-
};
|
|
469
|
-
await writeJsonFile(paths.identityFile, identity);
|
|
470
|
-
return identity;
|
|
471
|
-
}
|
|
472
|
-
async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
|
|
473
|
-
const identity = await ensureIdentity(paths);
|
|
474
|
-
const next = {
|
|
475
|
-
...identity,
|
|
476
|
-
link_id: linkId,
|
|
477
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
478
|
-
};
|
|
479
|
-
await writeJsonFile(paths.identityFile, next);
|
|
480
|
-
return next;
|
|
481
|
-
}
|
|
482
|
-
function signRelayNonce(identity, nonce) {
|
|
483
|
-
const signature = sign(null, Buffer.from(nonce, "utf8"), identity.private_key_pem);
|
|
484
|
-
return signature.toString("base64url");
|
|
485
|
-
}
|
|
486
|
-
function getIdentityStatus(identity) {
|
|
487
|
-
return {
|
|
488
|
-
installId: identity.install_id,
|
|
489
|
-
linkId: identity.link_id ?? null,
|
|
490
|
-
hasPrivateKey: identity.private_key_pem.trim().length > 0,
|
|
491
|
-
publicKeyPem: identity.public_key_pem
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// src/pairing/pairing.ts
|
|
496
|
-
import path5 from "path";
|
|
497
|
-
import { rm as rm3 } from "fs/promises";
|
|
498
|
-
|
|
499
|
-
// src/security/devices.ts
|
|
500
|
-
import { randomBytes as randomBytes2, randomUUID as randomUUID3, timingSafeEqual, createHash } from "crypto";
|
|
501
|
-
var ACCESS_TOKEN_TTL_MS = 15 * 60 * 1e3;
|
|
502
|
-
var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
|
|
503
|
-
async function createDeviceSession(input, paths = resolveRuntimePaths()) {
|
|
504
|
-
const store = await readCredentialStore(paths);
|
|
505
|
-
const now = /* @__PURE__ */ new Date();
|
|
506
|
-
const accessToken = randomToken("hpat_");
|
|
507
|
-
const refreshToken = randomToken("hprt_");
|
|
508
|
-
const device = {
|
|
509
|
-
id: `dev_${randomUUID3().replaceAll("-", "")}`,
|
|
510
|
-
label: input.label,
|
|
511
|
-
platform: input.platform,
|
|
512
|
-
scope: "admin",
|
|
513
|
-
access_token_hash: sha256(accessToken),
|
|
514
|
-
access_expires_at: new Date(now.getTime() + ACCESS_TOKEN_TTL_MS).toISOString(),
|
|
515
|
-
refresh_token_hash: sha256(refreshToken),
|
|
516
|
-
refresh_expires_at: new Date(now.getTime() + REFRESH_TOKEN_TTL_MS).toISOString(),
|
|
517
|
-
created_at: now.toISOString(),
|
|
518
|
-
updated_at: now.toISOString(),
|
|
519
|
-
revoked_at: null
|
|
520
|
-
};
|
|
521
|
-
store.devices.push(device);
|
|
522
|
-
await writeCredentialStore(paths, store);
|
|
523
|
-
return formatDeviceSession(device, accessToken, refreshToken);
|
|
524
|
-
}
|
|
525
|
-
async function authenticateDeviceAccessToken(token, paths = resolveRuntimePaths()) {
|
|
526
|
-
const tokenHash = sha256(token);
|
|
527
|
-
const store = await readCredentialStore(paths);
|
|
528
|
-
const device = store.devices.find((item) => safeEqual(item.access_token_hash, tokenHash));
|
|
529
|
-
if (!device || device.revoked_at || Date.parse(device.access_expires_at) <= Date.now()) {
|
|
530
|
-
return null;
|
|
531
|
-
}
|
|
532
|
-
return device;
|
|
533
|
-
}
|
|
534
|
-
async function refreshDeviceSession(refreshToken, paths = resolveRuntimePaths()) {
|
|
535
|
-
const tokenHash = sha256(refreshToken);
|
|
536
|
-
const store = await readCredentialStore(paths);
|
|
537
|
-
const device = store.devices.find((item) => safeEqual(item.refresh_token_hash, tokenHash));
|
|
538
|
-
if (!device || device.revoked_at || Date.parse(device.refresh_expires_at) <= Date.now()) {
|
|
539
|
-
throw new LinkHttpError(401, "refresh_token_invalid", "Refresh token is invalid or expired");
|
|
540
|
-
}
|
|
541
|
-
const now = /* @__PURE__ */ new Date();
|
|
542
|
-
const nextAccessToken = randomToken("hpat_");
|
|
543
|
-
const nextRefreshToken = randomToken("hprt_");
|
|
544
|
-
device.access_token_hash = sha256(nextAccessToken);
|
|
545
|
-
device.access_expires_at = new Date(now.getTime() + ACCESS_TOKEN_TTL_MS).toISOString();
|
|
546
|
-
device.refresh_token_hash = sha256(nextRefreshToken);
|
|
547
|
-
device.refresh_expires_at = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS).toISOString();
|
|
548
|
-
device.updated_at = now.toISOString();
|
|
549
|
-
await writeCredentialStore(paths, store);
|
|
550
|
-
return formatDeviceSession(device, nextAccessToken, nextRefreshToken);
|
|
551
|
-
}
|
|
552
|
-
async function revokeDeviceRefreshToken(refreshToken, paths = resolveRuntimePaths()) {
|
|
553
|
-
const tokenHash = sha256(refreshToken);
|
|
554
|
-
const store = await readCredentialStore(paths);
|
|
555
|
-
const device = store.devices.find((item) => safeEqual(item.refresh_token_hash, tokenHash));
|
|
556
|
-
if (!device || device.revoked_at) {
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
device.revoked_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
560
|
-
device.updated_at = device.revoked_at;
|
|
561
|
-
await writeCredentialStore(paths, store);
|
|
562
|
-
}
|
|
563
|
-
async function readCredentialStore(paths) {
|
|
564
|
-
const existing = await readJsonFile(paths.credentialsFile);
|
|
565
|
-
return {
|
|
566
|
-
devices: Array.isArray(existing?.devices) ? existing.devices.filter(isDeviceRecord) : []
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
async function writeCredentialStore(paths, store) {
|
|
570
|
-
await writeJsonFile(paths.credentialsFile, store);
|
|
571
|
-
}
|
|
572
|
-
function formatDeviceSession(device, accessToken, refreshToken) {
|
|
573
|
-
return {
|
|
574
|
-
device: {
|
|
575
|
-
id: device.id,
|
|
576
|
-
device_id: device.id,
|
|
577
|
-
label: device.label,
|
|
578
|
-
platform: device.platform,
|
|
579
|
-
scope: device.scope
|
|
580
|
-
},
|
|
581
|
-
accessToken: {
|
|
582
|
-
token: accessToken,
|
|
583
|
-
expiresAt: device.access_expires_at
|
|
584
|
-
},
|
|
585
|
-
refreshToken: {
|
|
586
|
-
token: refreshToken,
|
|
587
|
-
expiresAt: device.refresh_expires_at
|
|
588
|
-
}
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
function isDeviceRecord(value) {
|
|
592
|
-
return typeof value === "object" && value !== null && typeof value.id === "string" && typeof value.access_token_hash === "string" && typeof value.refresh_token_hash === "string";
|
|
593
|
-
}
|
|
594
|
-
function randomToken(prefix) {
|
|
595
|
-
return `${prefix}${randomBytes2(24).toString("base64url")}`;
|
|
596
|
-
}
|
|
597
|
-
function sha256(value) {
|
|
598
|
-
return createHash("sha256").update(value).digest("hex");
|
|
599
|
-
}
|
|
600
|
-
function safeEqual(left, right) {
|
|
601
|
-
const leftBytes = Buffer.from(left);
|
|
602
|
-
const rightBytes = Buffer.from(right);
|
|
603
|
-
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// src/relay/bootstrap.ts
|
|
607
|
-
async function bootstrapRelayLink(options) {
|
|
608
|
-
const fetcher = options.fetchImpl ?? fetch;
|
|
609
|
-
const baseUrl = options.relayBaseUrl.replace(/\/+$/u, "");
|
|
610
|
-
const commonPayload = {
|
|
611
|
-
install_id: options.identity.install_id,
|
|
612
|
-
link_id: options.identity.link_id ?? void 0,
|
|
613
|
-
public_key_pem: options.identity.public_key_pem
|
|
614
|
-
};
|
|
615
|
-
const challenge = await postJson(
|
|
616
|
-
fetcher,
|
|
617
|
-
`${baseUrl}/api/v1/relay/link/challenge`,
|
|
618
|
-
options.relayBootstrapToken,
|
|
619
|
-
commonPayload
|
|
620
|
-
);
|
|
621
|
-
if (challenge.ok !== true || typeof challenge.nonce !== "string") {
|
|
622
|
-
throw new Error("Relay did not return a valid install challenge");
|
|
623
|
-
}
|
|
624
|
-
const proof = {
|
|
625
|
-
nonce: challenge.nonce,
|
|
626
|
-
signature: signRelayNonce(options.identity, challenge.nonce)
|
|
627
|
-
};
|
|
628
|
-
const assigned = await postJson(
|
|
629
|
-
fetcher,
|
|
630
|
-
`${baseUrl}/api/v1/relay/link/bootstrap`,
|
|
631
|
-
options.relayBootstrapToken,
|
|
632
|
-
{
|
|
633
|
-
...commonPayload,
|
|
634
|
-
proof
|
|
635
|
-
}
|
|
636
|
-
);
|
|
637
|
-
if (assigned.ok !== true || typeof assigned.link_id !== "string") {
|
|
638
|
-
throw new Error("Relay did not return a valid link_id");
|
|
639
|
-
}
|
|
640
|
-
await saveAssignedLinkId(assigned.link_id, options.paths ?? resolveRuntimePaths());
|
|
641
|
-
return {
|
|
642
|
-
linkId: assigned.link_id,
|
|
643
|
-
reused: assigned.reused === true
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
async function postJson(fetcher, url, token, body) {
|
|
647
|
-
const response = await fetcher(url, {
|
|
648
|
-
method: "POST",
|
|
649
|
-
headers: {
|
|
650
|
-
authorization: `Bearer ${token}`,
|
|
651
|
-
"content-type": "application/json"
|
|
652
|
-
},
|
|
653
|
-
body: JSON.stringify(body)
|
|
654
|
-
});
|
|
655
|
-
const payload = await response.json().catch(() => null);
|
|
656
|
-
if (!response.ok) {
|
|
657
|
-
const message = readErrorMessage(payload) ?? `Relay request failed with HTTP ${response.status}`;
|
|
658
|
-
throw new Error(message);
|
|
659
|
-
}
|
|
660
|
-
if (!payload) {
|
|
661
|
-
throw new Error("Relay returned an empty response");
|
|
662
|
-
}
|
|
663
|
-
return payload;
|
|
664
|
-
}
|
|
665
|
-
function readErrorMessage(payload) {
|
|
666
|
-
if (typeof payload !== "object" || payload === null) {
|
|
667
|
-
return null;
|
|
668
|
-
}
|
|
669
|
-
const error = payload.error;
|
|
670
|
-
if (typeof error !== "object" || error === null) {
|
|
671
|
-
return null;
|
|
672
|
-
}
|
|
673
|
-
const message = error.message;
|
|
674
|
-
return typeof message === "string" ? message : null;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// src/topology/network.ts
|
|
678
|
-
import os4 from "os";
|
|
679
|
-
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;
|
|
680
|
-
var MAX_LAN_IPS = 4;
|
|
681
|
-
var MAX_PUBLIC_IPV4S = 2;
|
|
682
|
-
var MAX_PUBLIC_IPV6S = 2;
|
|
683
|
-
async function discoverRouteCandidates(options) {
|
|
684
|
-
const lanIps = discoverLanIps();
|
|
685
|
-
const publicIps = options.relayBootstrapToken ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
|
|
686
|
-
const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
|
|
687
|
-
const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
|
|
688
|
-
const preferredUrls = [
|
|
689
|
-
...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
|
|
690
|
-
...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
|
|
691
|
-
...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
|
|
692
|
-
`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/links/${options.linkId}`
|
|
693
|
-
];
|
|
694
|
-
return {
|
|
695
|
-
lanIps,
|
|
696
|
-
publicIpv4s,
|
|
697
|
-
publicIpv6s,
|
|
698
|
-
preferredUrls
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
function discoverLanIps() {
|
|
702
|
-
return discoverLanIpsFromInterfaces(os4.networkInterfaces());
|
|
703
|
-
}
|
|
704
|
-
function discoverLanIpsFromInterfaces(interfaces) {
|
|
705
|
-
const result = /* @__PURE__ */ new Set();
|
|
706
|
-
const candidates = [];
|
|
707
|
-
for (const [name, items] of Object.entries(interfaces)) {
|
|
708
|
-
if (shouldIgnoreInterface(name)) {
|
|
709
|
-
continue;
|
|
710
|
-
}
|
|
711
|
-
for (const item of items ?? []) {
|
|
712
|
-
if (!item.internal && item.address && item.family === "IPv4" && isUsableLanIpv4(item.address, item.netmask)) {
|
|
713
|
-
candidates.push({ name, address: item.address });
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
for (const candidate of candidates.sort(compareLanCandidate)) {
|
|
718
|
-
result.add(candidate.address);
|
|
719
|
-
}
|
|
720
|
-
return [...result].slice(0, MAX_LAN_IPS);
|
|
721
|
-
}
|
|
722
|
-
async function observePublicRoute(options) {
|
|
723
|
-
const fetcher = options.fetchImpl ?? fetch;
|
|
724
|
-
const response = await fetcher(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/public-route/observe`, {
|
|
725
|
-
method: "POST",
|
|
726
|
-
headers: {
|
|
727
|
-
authorization: `Bearer ${options.relayBootstrapToken}`,
|
|
728
|
-
"content-type": "application/json"
|
|
729
|
-
},
|
|
730
|
-
body: JSON.stringify({
|
|
731
|
-
install_id: options.installId,
|
|
732
|
-
link_id: options.linkId,
|
|
733
|
-
public_key_pem: options.publicKeyPem
|
|
734
|
-
})
|
|
735
|
-
});
|
|
736
|
-
const payload = await response.json().catch(() => null);
|
|
737
|
-
const record = typeof payload?.record === "object" && payload.record !== null ? payload.record : null;
|
|
738
|
-
const observed = typeof payload?.observed === "object" && payload.observed !== null ? payload.observed : null;
|
|
739
|
-
const values = [
|
|
740
|
-
readIpRecord(record?.ipv4),
|
|
741
|
-
readIpRecord(record?.ipv6),
|
|
742
|
-
typeof observed?.ip === "string" ? observed.ip : null
|
|
743
|
-
].filter((value) => Boolean(value));
|
|
744
|
-
return {
|
|
745
|
-
publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
|
|
746
|
-
publicIpv6s: unique(values.filter(isUsablePublicIpv6))
|
|
747
|
-
};
|
|
748
|
-
}
|
|
749
|
-
function readIpRecord(value) {
|
|
750
|
-
if (typeof value !== "object" || value === null) {
|
|
751
|
-
return null;
|
|
752
|
-
}
|
|
753
|
-
const ip = value.ip;
|
|
754
|
-
return typeof ip === "string" && ip.trim() ? ip.trim() : null;
|
|
755
|
-
}
|
|
756
|
-
function buildDirectUrl(ip, port) {
|
|
757
|
-
return `http://${ip.includes(":") ? `[${ip}]` : ip}:${port}`;
|
|
758
|
-
}
|
|
759
|
-
function shouldIgnoreInterface(name) {
|
|
760
|
-
return !name.trim() || VIRTUAL_INTERFACE_NAME_PATTERN.test(name);
|
|
761
|
-
}
|
|
762
|
-
function compareLanCandidate(left, right) {
|
|
763
|
-
const priority = interfacePriority(left.name) - interfacePriority(right.name);
|
|
764
|
-
return priority || left.name.localeCompare(right.name) || left.address.localeCompare(right.address);
|
|
765
|
-
}
|
|
766
|
-
function interfacePriority(name) {
|
|
767
|
-
if (/^(en|eth|wlan|wi-fi|wifi)/iu.test(name)) {
|
|
768
|
-
return 0;
|
|
769
|
-
}
|
|
770
|
-
return 1;
|
|
771
|
-
}
|
|
772
|
-
function isUsableLanIpv4(address, netmask) {
|
|
773
|
-
return isPrivateIpv4(address) && !isNetworkOrBroadcastIpv4Address(address, netmask);
|
|
774
|
-
}
|
|
775
|
-
function isUsablePublicIpv4(address) {
|
|
776
|
-
return isValidIpv4(address) && !isSpecialIpv4(address);
|
|
777
|
-
}
|
|
778
|
-
function isUsablePublicIpv6(address) {
|
|
779
|
-
const normalized = address.toLowerCase();
|
|
780
|
-
return normalized.includes(":") && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && normalized !== "::" && normalized !== "::1";
|
|
781
|
-
}
|
|
782
|
-
function isPrivateIpv4(address) {
|
|
783
|
-
const parts = parseIpv4Segments(address);
|
|
784
|
-
if (!parts) {
|
|
785
|
-
return false;
|
|
786
|
-
}
|
|
787
|
-
const [first, second] = parts;
|
|
788
|
-
return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
|
|
789
|
-
}
|
|
790
|
-
function isSpecialIpv4(address) {
|
|
791
|
-
const parts = parseIpv4Segments(address);
|
|
792
|
-
if (!parts) {
|
|
793
|
-
return true;
|
|
794
|
-
}
|
|
795
|
-
const [first, second, third, fourth] = parts;
|
|
796
|
-
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;
|
|
797
|
-
}
|
|
798
|
-
function isNetworkOrBroadcastIpv4Address(address, netmask) {
|
|
799
|
-
const addressParts = parseIpv4Segments(address);
|
|
800
|
-
const netmaskParts = netmask ? parseIpv4Segments(netmask) : null;
|
|
801
|
-
if (!addressParts) {
|
|
802
|
-
return true;
|
|
803
|
-
}
|
|
804
|
-
if (!netmaskParts) {
|
|
805
|
-
const last = addressParts[3];
|
|
806
|
-
return last === 0 || last === 255;
|
|
807
|
-
}
|
|
808
|
-
const addressInt = ipv4SegmentsToInt(addressParts);
|
|
809
|
-
const netmaskInt = ipv4SegmentsToInt(netmaskParts);
|
|
810
|
-
const hostMask = ~netmaskInt >>> 0;
|
|
811
|
-
if (hostMask === 0) {
|
|
812
|
-
return false;
|
|
813
|
-
}
|
|
814
|
-
const networkInt = addressInt & netmaskInt;
|
|
815
|
-
const broadcastInt = (networkInt | hostMask) >>> 0;
|
|
816
|
-
return addressInt === networkInt || addressInt === broadcastInt;
|
|
817
|
-
}
|
|
818
|
-
function isValidIpv4(address) {
|
|
819
|
-
return Boolean(parseIpv4Segments(address));
|
|
820
|
-
}
|
|
821
|
-
function parseIpv4Segments(address) {
|
|
822
|
-
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(address)) {
|
|
823
|
-
return null;
|
|
824
|
-
}
|
|
825
|
-
const parts = address.split(".").map((part) => Number.parseInt(part, 10));
|
|
826
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
827
|
-
return null;
|
|
828
|
-
}
|
|
829
|
-
return parts;
|
|
830
|
-
}
|
|
831
|
-
function ipv4SegmentsToInt(parts) {
|
|
832
|
-
return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
|
|
833
|
-
}
|
|
834
|
-
function unique(values) {
|
|
835
|
-
return [...new Set(values)];
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
// src/pairing/pairing.ts
|
|
839
|
-
async function preparePairing(paths = resolveRuntimePaths()) {
|
|
840
|
-
const config = await loadConfig(paths);
|
|
841
|
-
const identity = await ensureIdentity(paths);
|
|
842
|
-
const created = await postServerJson(config.serverBaseUrl, "/api/v1/link-pairings", {
|
|
843
|
-
install_id: identity.install_id,
|
|
844
|
-
link_id: identity.link_id ?? void 0,
|
|
845
|
-
display_name: defaultDisplayName(),
|
|
846
|
-
public_key_pem: identity.public_key_pem
|
|
847
|
-
});
|
|
848
|
-
const relayBaseUrl = created.relayBaseUrl || config.relayBaseUrl;
|
|
849
|
-
const assigned = await bootstrapRelayLink({
|
|
850
|
-
relayBaseUrl,
|
|
851
|
-
relayBootstrapToken: created.relayBootstrapToken,
|
|
852
|
-
identity,
|
|
853
|
-
paths
|
|
854
|
-
});
|
|
855
|
-
const updatedIdentity = await loadIdentity(paths) ?? identity;
|
|
856
|
-
const routes = await discoverRouteCandidates({
|
|
857
|
-
port: config.port,
|
|
858
|
-
relayBaseUrl,
|
|
859
|
-
relayBootstrapToken: created.relayBootstrapToken,
|
|
860
|
-
linkId: assigned.linkId,
|
|
861
|
-
installId: updatedIdentity.install_id,
|
|
862
|
-
publicKeyPem: updatedIdentity.public_key_pem
|
|
863
|
-
});
|
|
864
|
-
await patchServerJson(config.serverBaseUrl, `/api/v1/link-pairings/${created.sessionId}/link`, created.pairingToken, {
|
|
865
|
-
install_id: updatedIdentity.install_id,
|
|
866
|
-
link_id: assigned.linkId,
|
|
867
|
-
display_name: defaultDisplayName(),
|
|
868
|
-
lan_ips: routes.lanIps,
|
|
869
|
-
public_ipv4s: routes.publicIpv4s,
|
|
870
|
-
public_ipv6s: routes.publicIpv6s,
|
|
871
|
-
preferred_urls: routes.preferredUrls
|
|
872
|
-
});
|
|
873
|
-
const qrPayload = {
|
|
874
|
-
kind: "hermes_link_pairing",
|
|
875
|
-
version: 1,
|
|
876
|
-
link_id: assigned.linkId,
|
|
877
|
-
display_name: defaultDisplayName(),
|
|
878
|
-
session_id: created.sessionId,
|
|
879
|
-
code: created.code,
|
|
880
|
-
preferred_urls: qrPreferredUrls(routes)
|
|
881
|
-
};
|
|
882
|
-
return {
|
|
883
|
-
sessionId: created.sessionId,
|
|
884
|
-
code: created.code,
|
|
885
|
-
pairingToken: created.pairingToken,
|
|
886
|
-
relayBootstrapToken: created.relayBootstrapToken,
|
|
887
|
-
relayBaseUrl,
|
|
888
|
-
displayName: defaultDisplayName(),
|
|
889
|
-
linkId: assigned.linkId,
|
|
890
|
-
routes,
|
|
891
|
-
qrPayload
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
|
-
async function recordPairingClaim(input, paths = resolveRuntimePaths()) {
|
|
895
|
-
const record = {
|
|
896
|
-
session_id: input.sessionId,
|
|
897
|
-
device_id: input.deviceId,
|
|
898
|
-
device_label: input.deviceLabel,
|
|
899
|
-
device_platform: input.devicePlatform,
|
|
900
|
-
claimed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
901
|
-
};
|
|
902
|
-
await writeJsonFile(pairingClaimPath(input.sessionId, paths), record);
|
|
903
|
-
return record;
|
|
904
|
-
}
|
|
905
|
-
async function readPairingClaim(sessionId, paths = resolveRuntimePaths()) {
|
|
906
|
-
const record = await readJsonFile(pairingClaimPath(sessionId, paths));
|
|
907
|
-
if (!record || record.session_id !== sessionId || typeof record.device_id !== "string") {
|
|
908
|
-
return null;
|
|
909
|
-
}
|
|
910
|
-
return {
|
|
911
|
-
session_id: record.session_id,
|
|
912
|
-
device_id: record.device_id,
|
|
913
|
-
device_label: typeof record.device_label === "string" ? record.device_label : "",
|
|
914
|
-
device_platform: typeof record.device_platform === "string" ? record.device_platform : "unknown",
|
|
915
|
-
claimed_at: typeof record.claimed_at === "string" ? record.claimed_at : ""
|
|
916
|
-
};
|
|
917
|
-
}
|
|
918
|
-
async function clearPairingClaim(sessionId, paths = resolveRuntimePaths()) {
|
|
919
|
-
await rm3(pairingClaimPath(sessionId, paths), { force: true }).catch(() => void 0);
|
|
920
|
-
}
|
|
921
|
-
async function claimPairing(input) {
|
|
922
|
-
const paths = input.paths ?? resolveRuntimePaths();
|
|
923
|
-
const [identity, config] = await Promise.all([loadRequiredIdentity(paths), loadConfig(paths)]);
|
|
924
|
-
const verified = await postServerJson(
|
|
925
|
-
config.serverBaseUrl,
|
|
926
|
-
`/api/v1/link-pairings/${input.sessionId}/claim/verify`,
|
|
927
|
-
{
|
|
928
|
-
claim_token: input.claimToken
|
|
929
|
-
}
|
|
930
|
-
);
|
|
931
|
-
if (verified.ok !== true || verified.linkId !== identity.link_id) {
|
|
932
|
-
throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim does not match this Link");
|
|
933
|
-
}
|
|
934
|
-
const session = await createDeviceSession(
|
|
935
|
-
{
|
|
936
|
-
label: input.deviceLabel || "HermesPilot App",
|
|
937
|
-
platform: input.devicePlatform || "unknown"
|
|
938
|
-
},
|
|
939
|
-
paths
|
|
940
|
-
);
|
|
941
|
-
return {
|
|
942
|
-
link: {
|
|
943
|
-
link_id: identity.link_id,
|
|
944
|
-
display_name: defaultDisplayName()
|
|
945
|
-
},
|
|
946
|
-
device: formatDevice(session.device),
|
|
947
|
-
access_token: {
|
|
948
|
-
token: session.accessToken.token,
|
|
949
|
-
expires_at: session.accessToken.expiresAt
|
|
950
|
-
},
|
|
951
|
-
refresh_token: {
|
|
952
|
-
token: session.refreshToken.token,
|
|
953
|
-
expires_at: session.refreshToken.expiresAt
|
|
954
|
-
}
|
|
955
|
-
};
|
|
956
|
-
}
|
|
957
|
-
async function loadRequiredIdentity(paths) {
|
|
958
|
-
const identity = await loadIdentity(paths);
|
|
959
|
-
if (!identity?.link_id) {
|
|
960
|
-
throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
|
|
961
|
-
}
|
|
962
|
-
return identity;
|
|
963
|
-
}
|
|
964
|
-
async function postServerJson(serverBaseUrl, path7, body) {
|
|
965
|
-
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path7}`, {
|
|
966
|
-
method: "POST",
|
|
967
|
-
headers: {
|
|
968
|
-
accept: "application/json",
|
|
969
|
-
"content-type": "application/json"
|
|
970
|
-
},
|
|
971
|
-
body: JSON.stringify(body)
|
|
972
|
-
});
|
|
973
|
-
return readJsonResponse2(response);
|
|
974
|
-
}
|
|
975
|
-
async function patchServerJson(serverBaseUrl, path7, token, body) {
|
|
976
|
-
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path7}`, {
|
|
977
|
-
method: "PATCH",
|
|
978
|
-
headers: {
|
|
979
|
-
accept: "application/json",
|
|
980
|
-
authorization: `Bearer ${token}`,
|
|
981
|
-
"content-type": "application/json"
|
|
982
|
-
},
|
|
983
|
-
body: JSON.stringify(body)
|
|
984
|
-
});
|
|
985
|
-
return readJsonResponse2(response);
|
|
986
|
-
}
|
|
987
|
-
async function readJsonResponse2(response) {
|
|
988
|
-
const payload = await response.json().catch(() => null);
|
|
989
|
-
if (!response.ok || !payload) {
|
|
990
|
-
const message = readErrorMessage2(payload) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
|
|
991
|
-
throw new LinkHttpError(response.status, "server_request_failed", message);
|
|
992
|
-
}
|
|
993
|
-
return payload;
|
|
994
|
-
}
|
|
995
|
-
function readErrorMessage2(payload) {
|
|
996
|
-
if (typeof payload !== "object" || payload === null) {
|
|
997
|
-
return null;
|
|
998
|
-
}
|
|
999
|
-
const error = payload.error;
|
|
1000
|
-
if (typeof error !== "object" || error === null) {
|
|
1001
|
-
return null;
|
|
1002
|
-
}
|
|
1003
|
-
const message = error.message;
|
|
1004
|
-
return typeof message === "string" ? message : null;
|
|
1005
|
-
}
|
|
1006
|
-
function defaultDisplayName() {
|
|
1007
|
-
return `Hermes Link ${process.platform}`;
|
|
1008
|
-
}
|
|
1009
|
-
function pairingClaimPath(sessionId, paths) {
|
|
1010
|
-
return path5.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
|
|
1011
|
-
}
|
|
1012
|
-
function qrPreferredUrls(routes) {
|
|
1013
|
-
return routes.preferredUrls.filter((url) => !url.includes("/api/v1/relay/links/")).slice(0, 1);
|
|
1014
|
-
}
|
|
1015
|
-
function formatDevice(device) {
|
|
1016
|
-
return {
|
|
1017
|
-
id: device.id,
|
|
1018
|
-
device_id: device.device_id,
|
|
1019
|
-
label: device.label,
|
|
1020
|
-
platform: device.platform,
|
|
1021
|
-
scope: device.scope
|
|
1022
|
-
};
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// src/security/app-connect-token.ts
|
|
1026
|
-
import { createPublicKey, verify } from "crypto";
|
|
1027
|
-
var cachedJwks = null;
|
|
1028
|
-
async function verifyAppConnectToken(token, options) {
|
|
1029
|
-
const segments = token.split(".");
|
|
1030
|
-
if (segments.length !== 3) {
|
|
1031
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token is malformed");
|
|
1032
|
-
}
|
|
1033
|
-
const [encodedHeader, encodedPayload, encodedSignature] = segments;
|
|
1034
|
-
const header = decodeJson(encodedHeader);
|
|
1035
|
-
const payload = decodeJson(encodedPayload);
|
|
1036
|
-
if (header.alg !== "ES256" || header.typ !== "JWT") {
|
|
1037
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token algorithm is unsupported");
|
|
1038
|
-
}
|
|
1039
|
-
if (payload.token_type !== "hermes_app_connect" || payload.iss !== options.config.appConnectTokenIssuer || payload.aud !== options.config.appConnectTokenAudience || payload.link_id !== options.linkId || !Number.isFinite(payload.exp) || payload.exp <= Math.floor(Date.now() / 1e3)) {
|
|
1040
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token claims are invalid");
|
|
1041
|
-
}
|
|
1042
|
-
const jwks = await getJwks(options.config, options.fetchImpl ?? fetch);
|
|
1043
|
-
const key = jwks.find((item) => item.kid === header.kid) ?? jwks[0];
|
|
1044
|
-
if (!key) {
|
|
1045
|
-
throw new LinkHttpError(503, "app_connect_jwks_unavailable", "App connect token key is unavailable");
|
|
1046
|
-
}
|
|
1047
|
-
const publicKey = createPublicKey({ key, format: "jwk" });
|
|
1048
|
-
const ok = verify(
|
|
1049
|
-
"sha256",
|
|
1050
|
-
Buffer.from(`${encodedHeader}.${encodedPayload}`),
|
|
1051
|
-
{ key: publicKey, dsaEncoding: "ieee-p1363" },
|
|
1052
|
-
Buffer.from(base64UrlToBase64(encodedSignature), "base64")
|
|
1053
|
-
);
|
|
1054
|
-
if (!ok) {
|
|
1055
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token signature is invalid");
|
|
1056
|
-
}
|
|
1057
|
-
return payload;
|
|
1058
|
-
}
|
|
1059
|
-
async function getJwks(config, fetcher) {
|
|
1060
|
-
if (cachedJwks && cachedJwks.expiresAt > Date.now()) {
|
|
1061
|
-
return cachedJwks.keys;
|
|
1062
|
-
}
|
|
1063
|
-
const response = await fetcher(`${config.serverBaseUrl.replace(/\/+$/u, "")}/api/v1/app-connect/jwks.json`, {
|
|
1064
|
-
headers: { accept: "application/json" }
|
|
1065
|
-
});
|
|
1066
|
-
if (!response.ok) {
|
|
1067
|
-
throw new LinkHttpError(503, "app_connect_jwks_unavailable", "Unable to load app connect JWKS");
|
|
1068
|
-
}
|
|
1069
|
-
const payload = await response.json();
|
|
1070
|
-
const keys = Array.isArray(payload.keys) ? payload.keys : [];
|
|
1071
|
-
cachedJwks = {
|
|
1072
|
-
keys,
|
|
1073
|
-
expiresAt: Date.now() + 5 * 60 * 1e3
|
|
1074
|
-
};
|
|
1075
|
-
return keys;
|
|
1076
|
-
}
|
|
1077
|
-
function decodeJson(value) {
|
|
1078
|
-
return JSON.parse(Buffer.from(base64UrlToBase64(value), "base64").toString("utf8"));
|
|
1079
|
-
}
|
|
1080
|
-
function base64UrlToBase64(value) {
|
|
1081
|
-
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
1082
|
-
return normalized + "=".repeat((4 - normalized.length % 4) % 4);
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
// src/runtime/logger.ts
|
|
1086
|
-
import { appendFile, mkdir as mkdir5, open as open2, readFile as readFile3, rename as rename3, rm as rm4, stat as stat2 } from "fs/promises";
|
|
1087
|
-
import path6 from "path";
|
|
1088
|
-
var DEFAULT_LOG_FILE = "hermeslink.log";
|
|
1089
|
-
var DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
|
|
1090
|
-
var DEFAULT_MAX_FILES = 5;
|
|
1091
|
-
var DEFAULT_READ_LIMIT = 200;
|
|
1092
|
-
var MAX_READ_LIMIT = 1e3;
|
|
1093
|
-
var DEFAULT_MAX_BYTES_PER_FILE = 512 * 1024;
|
|
1094
|
-
var FileLogger = class {
|
|
1095
|
-
filePath;
|
|
1096
|
-
paths;
|
|
1097
|
-
maxFileBytes;
|
|
1098
|
-
maxFiles;
|
|
1099
|
-
now;
|
|
1100
|
-
queue = Promise.resolve();
|
|
1101
|
-
constructor(options = {}) {
|
|
1102
|
-
this.paths = options.paths ?? resolveRuntimePaths();
|
|
1103
|
-
this.filePath = getLinkLogFile(this.paths, options.fileName);
|
|
1104
|
-
this.maxFileBytes = Math.max(256, Math.floor(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES));
|
|
1105
|
-
this.maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
|
|
1106
|
-
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1107
|
-
}
|
|
1108
|
-
debug(message, fields) {
|
|
1109
|
-
return this.write("debug", message, fields);
|
|
1110
|
-
}
|
|
1111
|
-
info(message, fields) {
|
|
1112
|
-
return this.write("info", message, fields);
|
|
1113
|
-
}
|
|
1114
|
-
warn(message, fields) {
|
|
1115
|
-
return this.write("warn", message, fields);
|
|
1116
|
-
}
|
|
1117
|
-
error(message, fields) {
|
|
1118
|
-
return this.write("error", message, fields);
|
|
1119
|
-
}
|
|
1120
|
-
write(level, message, fields) {
|
|
1121
|
-
const entry = {
|
|
1122
|
-
ts: this.now().toISOString(),
|
|
1123
|
-
level,
|
|
1124
|
-
message,
|
|
1125
|
-
...fields ? { fields: sanitizeFields(fields) } : {}
|
|
1126
|
-
};
|
|
1127
|
-
const next = this.queue.then(() => this.appendEntry(entry)).catch(() => void 0);
|
|
1128
|
-
this.queue = next;
|
|
1129
|
-
return next;
|
|
1130
|
-
}
|
|
1131
|
-
flush() {
|
|
1132
|
-
return this.queue;
|
|
1133
|
-
}
|
|
1134
|
-
async appendEntry(entry) {
|
|
1135
|
-
await mkdir5(this.paths.logsDir, { recursive: true, mode: 448 });
|
|
1136
|
-
const line = `${JSON.stringify(entry)}
|
|
1137
|
-
`;
|
|
1138
|
-
await this.rotateIfNeeded(Buffer.byteLength(line, "utf8"));
|
|
1139
|
-
await appendFile(this.filePath, line, { mode: 384 });
|
|
1140
|
-
}
|
|
1141
|
-
async rotateIfNeeded(nextBytes) {
|
|
1142
|
-
const current = await stat2(this.filePath).catch(() => null);
|
|
1143
|
-
if (!current || current.size === 0 || current.size + nextBytes <= this.maxFileBytes) {
|
|
1144
|
-
return;
|
|
1145
|
-
}
|
|
1146
|
-
if (this.maxFiles === 0) {
|
|
1147
|
-
await rm4(this.filePath, { force: true }).catch(() => void 0);
|
|
1148
|
-
return;
|
|
1149
|
-
}
|
|
1150
|
-
await rm4(rotatedLogFile(this.filePath, this.maxFiles), { force: true }).catch(() => void 0);
|
|
1151
|
-
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
|
|
1152
|
-
await moveIfExists(rotatedLogFile(this.filePath, index), rotatedLogFile(this.filePath, index + 1));
|
|
1153
|
-
}
|
|
1154
|
-
await moveIfExists(this.filePath, rotatedLogFile(this.filePath, 1));
|
|
1155
|
-
}
|
|
1156
|
-
};
|
|
1157
|
-
function createFileLogger(options = {}) {
|
|
1158
|
-
return new FileLogger(options);
|
|
1159
|
-
}
|
|
1160
|
-
function getLinkLogFile(paths = resolveRuntimePaths(), fileName = DEFAULT_LOG_FILE) {
|
|
1161
|
-
return path6.join(paths.logsDir, fileName);
|
|
1162
|
-
}
|
|
1163
|
-
async function readRecentLogEntries(options = {}) {
|
|
1164
|
-
const paths = options.paths ?? resolveRuntimePaths();
|
|
1165
|
-
const filePath = getLinkLogFile(paths, options.fileName);
|
|
1166
|
-
const limit = clampLimit(options.limit);
|
|
1167
|
-
const maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
|
|
1168
|
-
const maxBytesPerFile = Math.max(1024, Math.floor(options.maxBytesPerFile ?? DEFAULT_MAX_BYTES_PER_FILE));
|
|
1169
|
-
const files = [filePath, ...Array.from({ length: maxFiles }, (_, index) => rotatedLogFile(filePath, index + 1))];
|
|
1170
|
-
const entries = [];
|
|
1171
|
-
for (const file of files) {
|
|
1172
|
-
const raw = await readTail(file, maxBytesPerFile);
|
|
1173
|
-
if (!raw) {
|
|
1174
|
-
continue;
|
|
1175
|
-
}
|
|
1176
|
-
const lines = raw.split(/\r?\n/u).filter(Boolean);
|
|
1177
|
-
for (let index = lines.length - 1; index >= 0 && entries.length < limit; index -= 1) {
|
|
1178
|
-
const entry = parseLogLine(lines[index]);
|
|
1179
|
-
if (entry) {
|
|
1180
|
-
entries.push(entry);
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
if (entries.length >= limit) {
|
|
1184
|
-
break;
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
return entries.reverse();
|
|
1188
|
-
}
|
|
1189
|
-
function clampLimit(value) {
|
|
1190
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
1191
|
-
return DEFAULT_READ_LIMIT;
|
|
1192
|
-
}
|
|
1193
|
-
return Math.min(MAX_READ_LIMIT, Math.max(1, Math.floor(value)));
|
|
1194
|
-
}
|
|
1195
|
-
function sanitizeFields(fields) {
|
|
1196
|
-
return sanitizeObject(fields, 0);
|
|
1197
|
-
}
|
|
1198
|
-
function sanitizeValue(value, depth) {
|
|
1199
|
-
if (value === null || typeof value === "boolean") {
|
|
1200
|
-
return value;
|
|
1201
|
-
}
|
|
1202
|
-
if (typeof value === "number") {
|
|
1203
|
-
return Number.isFinite(value) ? value : null;
|
|
1204
|
-
}
|
|
1205
|
-
if (typeof value === "string") {
|
|
1206
|
-
return value.length > 2e3 ? `${value.slice(0, 2e3)}...` : value;
|
|
1207
|
-
}
|
|
1208
|
-
if (Array.isArray(value)) {
|
|
1209
|
-
if (depth >= 3) {
|
|
1210
|
-
return "[array]";
|
|
1211
|
-
}
|
|
1212
|
-
return value.slice(0, 20).map((item) => sanitizeValue(item, depth + 1));
|
|
1213
|
-
}
|
|
1214
|
-
if (typeof value === "object" && value !== null) {
|
|
1215
|
-
if (depth >= 3) {
|
|
1216
|
-
return "[object]";
|
|
1217
|
-
}
|
|
1218
|
-
return sanitizeObject(value, depth + 1);
|
|
1219
|
-
}
|
|
1220
|
-
return String(value);
|
|
1221
|
-
}
|
|
1222
|
-
function sanitizeObject(value, depth) {
|
|
1223
|
-
const result = {};
|
|
1224
|
-
for (const [key, child] of Object.entries(value).slice(0, 50)) {
|
|
1225
|
-
if (isSensitiveKey(key)) {
|
|
1226
|
-
result[key] = "[redacted]";
|
|
1227
|
-
continue;
|
|
1228
|
-
}
|
|
1229
|
-
result[key] = sanitizeValue(child, depth);
|
|
1230
|
-
}
|
|
1231
|
-
return result;
|
|
1232
|
-
}
|
|
1233
|
-
function isSensitiveKey(key) {
|
|
1234
|
-
return /(authorization|cookie|token|secret|password|private[_-]?key|api[_-]?key)/iu.test(key);
|
|
1235
|
-
}
|
|
1236
|
-
function parseLogLine(line) {
|
|
1237
|
-
try {
|
|
1238
|
-
const value = JSON.parse(line);
|
|
1239
|
-
if (!value || typeof value.ts !== "string" || !isLogLevel(value.level) || typeof value.message !== "string") {
|
|
1240
|
-
return null;
|
|
1241
|
-
}
|
|
1242
|
-
return {
|
|
1243
|
-
ts: value.ts,
|
|
1244
|
-
level: value.level,
|
|
1245
|
-
message: value.message,
|
|
1246
|
-
...value.fields && typeof value.fields === "object" ? { fields: value.fields } : {}
|
|
1247
|
-
};
|
|
1248
|
-
} catch {
|
|
1249
|
-
return null;
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
function isLogLevel(value) {
|
|
1253
|
-
return value === "debug" || value === "info" || value === "warn" || value === "error";
|
|
1254
|
-
}
|
|
1255
|
-
async function readTail(filePath, maxBytes) {
|
|
1256
|
-
const info = await stat2(filePath).catch(() => null);
|
|
1257
|
-
if (!info || info.size <= 0) {
|
|
1258
|
-
return null;
|
|
1259
|
-
}
|
|
1260
|
-
if (info.size <= maxBytes) {
|
|
1261
|
-
return await readFile3(filePath, "utf8").catch(() => null);
|
|
1262
|
-
}
|
|
1263
|
-
const handle = await open2(filePath, "r").catch(() => null);
|
|
1264
|
-
if (!handle) {
|
|
1265
|
-
return null;
|
|
1266
|
-
}
|
|
1267
|
-
try {
|
|
1268
|
-
const length = Math.min(info.size, maxBytes);
|
|
1269
|
-
const buffer = Buffer.alloc(length);
|
|
1270
|
-
await handle.read(buffer, 0, length, info.size - length);
|
|
1271
|
-
return buffer.toString("utf8");
|
|
1272
|
-
} finally {
|
|
1273
|
-
await handle.close();
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
async function moveIfExists(from, to) {
|
|
1277
|
-
await rm4(to, { force: true }).catch(() => void 0);
|
|
1278
|
-
await rename3(from, to).catch((error) => {
|
|
1279
|
-
if (error.code !== "ENOENT") {
|
|
1280
|
-
throw error;
|
|
1281
|
-
}
|
|
1282
|
-
});
|
|
1283
|
-
}
|
|
1284
|
-
function rotatedLogFile(filePath, index) {
|
|
1285
|
-
return `${filePath}.${index}`;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
// src/http/app.ts
|
|
1289
|
-
async function createApp(options = {}) {
|
|
1290
|
-
const paths = options.paths ?? resolveRuntimePaths();
|
|
1291
|
-
const logger = options.logger ?? createFileLogger({ paths });
|
|
1292
|
-
const app = new Koa();
|
|
1293
|
-
const router = new Router();
|
|
1294
|
-
app.use(async (ctx, next) => {
|
|
1295
|
-
const startedAt = Date.now();
|
|
1296
|
-
try {
|
|
1297
|
-
await next();
|
|
1298
|
-
} catch (error) {
|
|
1299
|
-
const profileError = error instanceof Error && error.message === "invalid profile name";
|
|
1300
|
-
const status = isLinkHttpError(error) ? error.status : profileError ? 400 : 500;
|
|
1301
|
-
ctx.status = status;
|
|
1302
|
-
ctx.body = {
|
|
1303
|
-
ok: false,
|
|
1304
|
-
error: {
|
|
1305
|
-
code: isLinkHttpError(error) ? error.code : status === 400 ? "invalid_profile_name" : "internal_error",
|
|
1306
|
-
message: error instanceof Error ? error.message : "Internal error"
|
|
1307
|
-
}
|
|
1308
|
-
};
|
|
1309
|
-
void logger.write(status >= 500 ? "error" : "warn", "http_request_failed", {
|
|
1310
|
-
method: ctx.method,
|
|
1311
|
-
path: ctx.path,
|
|
1312
|
-
status,
|
|
1313
|
-
code: isLinkHttpError(error) ? error.code : status === 400 ? "invalid_profile_name" : "internal_error",
|
|
1314
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1315
|
-
});
|
|
1316
|
-
} finally {
|
|
1317
|
-
void logger.info("http_request", {
|
|
1318
|
-
method: ctx.method,
|
|
1319
|
-
path: ctx.path,
|
|
1320
|
-
status: ctx.status,
|
|
1321
|
-
duration_ms: Date.now() - startedAt
|
|
1322
|
-
});
|
|
1323
|
-
}
|
|
1324
|
-
});
|
|
1325
|
-
router.get("/api/v1/bootstrap", async (ctx) => {
|
|
1326
|
-
const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
|
|
1327
|
-
const routes = identity?.link_id ? await discoverRouteCandidates({
|
|
1328
|
-
port: config.port,
|
|
1329
|
-
relayBaseUrl: config.relayBaseUrl,
|
|
1330
|
-
linkId: identity.link_id,
|
|
1331
|
-
installId: identity.install_id,
|
|
1332
|
-
publicKeyPem: identity.public_key_pem
|
|
1333
|
-
}) : null;
|
|
1334
|
-
ctx.set("cache-control", "no-store");
|
|
1335
|
-
ctx.body = {
|
|
1336
|
-
link_id: identity?.link_id ?? null,
|
|
1337
|
-
display_name: identity?.link_id ? "Hermes Link" : "Unpaired Hermes Link",
|
|
1338
|
-
version: LINK_VERSION,
|
|
1339
|
-
api_version: 1,
|
|
1340
|
-
paired: Boolean(identity?.link_id),
|
|
1341
|
-
pairing_supported: Boolean(identity?.link_id),
|
|
1342
|
-
preferred_pairing_urls: routes?.preferredUrls ?? [],
|
|
1343
|
-
routes: routes ? routeObjects(routes.preferredUrls) : [],
|
|
1344
|
-
capabilities: {
|
|
1345
|
-
runs: true,
|
|
1346
|
-
sse: true,
|
|
1347
|
-
relay: true,
|
|
1348
|
-
profiles: true,
|
|
1349
|
-
logs: true
|
|
1350
|
-
}
|
|
1351
|
-
};
|
|
1352
|
-
});
|
|
1353
|
-
router.post("/api/v1/pairing/claim", async (ctx) => {
|
|
1354
|
-
const body = await readJsonBody(ctx.req);
|
|
1355
|
-
const sessionId = readString2(body, "session_id") ?? readString2(body, "sessionId");
|
|
1356
|
-
const claimToken = readString2(body, "claim_token") ?? readString2(body, "claimToken");
|
|
1357
|
-
if (!sessionId || !claimToken) {
|
|
1358
|
-
throw new LinkHttpError(400, "pairing_claim_invalid", "session_id and claim_token are required");
|
|
1359
|
-
}
|
|
1360
|
-
const claimed = await claimPairing({
|
|
1361
|
-
sessionId,
|
|
1362
|
-
claimToken,
|
|
1363
|
-
deviceLabel: readString2(body, "device_label") ?? readString2(body, "deviceLabel") ?? "HermesPilot App",
|
|
1364
|
-
devicePlatform: readString2(body, "device_platform") ?? readString2(body, "devicePlatform") ?? "unknown",
|
|
1365
|
-
paths
|
|
1366
|
-
});
|
|
1367
|
-
ctx.body = claimed;
|
|
1368
|
-
void logger.info("pairing_claimed", {
|
|
1369
|
-
device_id: claimed.device.device_id,
|
|
1370
|
-
device_platform: claimed.device.platform
|
|
1371
|
-
});
|
|
1372
|
-
const timer = setTimeout(() => {
|
|
1373
|
-
void recordPairingClaim(
|
|
1374
|
-
{
|
|
1375
|
-
sessionId,
|
|
1376
|
-
deviceId: claimed.device.device_id,
|
|
1377
|
-
deviceLabel: claimed.device.label,
|
|
1378
|
-
devicePlatform: claimed.device.platform
|
|
1379
|
-
},
|
|
1380
|
-
paths
|
|
1381
|
-
).catch((error) => {
|
|
1382
|
-
void logger.warn("pairing_claim_record_failed", {
|
|
1383
|
-
session_id: sessionId,
|
|
1384
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1385
|
-
});
|
|
1386
|
-
});
|
|
1387
|
-
void options.onPairingClaimed?.();
|
|
1388
|
-
}, 250);
|
|
1389
|
-
timer.unref?.();
|
|
1390
|
-
});
|
|
1391
|
-
router.get("/api/v1/auth/me", async (ctx) => {
|
|
1392
|
-
const auth = await authenticateRequest(ctx, paths);
|
|
1393
|
-
const identity = await loadRequiredIdentity2(paths);
|
|
1394
|
-
ctx.body = {
|
|
1395
|
-
ok: true,
|
|
1396
|
-
auth: { kind: auth.kind, account_id: auth.accountId ?? null },
|
|
1397
|
-
link: {
|
|
1398
|
-
link_id: identity.link_id,
|
|
1399
|
-
display_name: "Hermes Link"
|
|
1400
|
-
},
|
|
1401
|
-
device: auth.device ? {
|
|
1402
|
-
id: auth.device.id,
|
|
1403
|
-
device_id: auth.device.id,
|
|
1404
|
-
label: auth.device.label,
|
|
1405
|
-
platform: auth.device.platform,
|
|
1406
|
-
scope: auth.device.scope
|
|
1407
|
-
} : null
|
|
1408
|
-
};
|
|
1409
|
-
});
|
|
1410
|
-
router.post("/api/v1/auth/refresh", async (ctx) => {
|
|
1411
|
-
const body = await readJsonBody(ctx.req);
|
|
1412
|
-
const refreshToken = readString2(body, "refresh_token") ?? readString2(body, "refreshToken");
|
|
1413
|
-
if (!refreshToken) {
|
|
1414
|
-
throw new LinkHttpError(400, "refresh_token_required", "refresh_token is required");
|
|
1415
|
-
}
|
|
1416
|
-
const session = await refreshDeviceSession(refreshToken, paths);
|
|
1417
|
-
ctx.body = {
|
|
1418
|
-
ok: true,
|
|
1419
|
-
device: session.device,
|
|
1420
|
-
access_token: {
|
|
1421
|
-
token: session.accessToken.token,
|
|
1422
|
-
expires_at: session.accessToken.expiresAt
|
|
1423
|
-
},
|
|
1424
|
-
refresh_token: {
|
|
1425
|
-
token: session.refreshToken.token,
|
|
1426
|
-
expires_at: session.refreshToken.expiresAt
|
|
1427
|
-
}
|
|
1428
|
-
};
|
|
1429
|
-
});
|
|
1430
|
-
router.post("/api/v1/auth/logout", async (ctx) => {
|
|
1431
|
-
const body = await readJsonBody(ctx.req);
|
|
1432
|
-
const refreshToken = readString2(body, "refresh_token") ?? readString2(body, "refreshToken");
|
|
1433
|
-
if (refreshToken) {
|
|
1434
|
-
await revokeDeviceRefreshToken(refreshToken, paths);
|
|
1435
|
-
}
|
|
1436
|
-
ctx.body = { ok: true };
|
|
1437
|
-
});
|
|
1438
|
-
router.get("/api/v1/status", async (ctx) => {
|
|
1439
|
-
await authenticateRequest(ctx, paths);
|
|
1440
|
-
const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
|
|
1441
|
-
ctx.body = {
|
|
1442
|
-
ok: true,
|
|
1443
|
-
version: LINK_VERSION,
|
|
1444
|
-
paired: Boolean(identity?.link_id),
|
|
1445
|
-
link_id: identity?.link_id ?? null,
|
|
1446
|
-
port: config.port
|
|
1447
|
-
};
|
|
1448
|
-
});
|
|
1449
|
-
router.get("/api/v1/logs", async (ctx) => {
|
|
1450
|
-
await authenticateRequest(ctx, paths);
|
|
1451
|
-
ctx.set("cache-control", "no-store");
|
|
1452
|
-
ctx.body = {
|
|
1453
|
-
ok: true,
|
|
1454
|
-
logs: await readRecentLogEntries({
|
|
1455
|
-
paths,
|
|
1456
|
-
limit: readLimit(ctx.query.limit)
|
|
1457
|
-
})
|
|
1458
|
-
};
|
|
1459
|
-
});
|
|
1460
|
-
router.get("/api/v1/models", async (ctx) => {
|
|
1461
|
-
await authenticateRequest(ctx, paths);
|
|
1462
|
-
ctx.body = await listHermesModels();
|
|
1463
|
-
});
|
|
1464
|
-
router.post("/api/v1/runs", async (ctx) => {
|
|
1465
|
-
await authenticateRequest(ctx, paths);
|
|
1466
|
-
const body = await readJsonBody(ctx.req);
|
|
1467
|
-
const input = readString2(body, "input");
|
|
1468
|
-
if (!input) {
|
|
1469
|
-
throw new LinkHttpError(400, "run_input_required", "input is required");
|
|
1470
|
-
}
|
|
1471
|
-
ctx.status = 202;
|
|
1472
|
-
ctx.body = await createHermesRun({
|
|
1473
|
-
input,
|
|
1474
|
-
conversation_history: readConversationHistory(body.conversation_history ?? body.conversationHistory),
|
|
1475
|
-
session_id: readString2(body, "session_id") ?? readString2(body, "sessionId") ?? void 0
|
|
1476
|
-
});
|
|
1477
|
-
});
|
|
1478
|
-
router.get("/api/v1/runs/:runId/events", async (ctx) => {
|
|
1479
|
-
await authenticateRequest(ctx, paths);
|
|
1480
|
-
const response = await streamHermesRunEvents(ctx.params.runId);
|
|
1481
|
-
ctx.status = response.status;
|
|
1482
|
-
for (const [key, value] of response.headers.entries()) {
|
|
1483
|
-
ctx.set(key, value);
|
|
1484
|
-
}
|
|
1485
|
-
ctx.respond = false;
|
|
1486
|
-
const nodeResponse = ctx.res;
|
|
1487
|
-
nodeResponse.statusCode = response.status;
|
|
1488
|
-
for (const [key, value] of response.headers.entries()) {
|
|
1489
|
-
nodeResponse.setHeader(key, value);
|
|
1490
|
-
}
|
|
1491
|
-
if (response.body) {
|
|
1492
|
-
Readable.fromWeb(response.body).pipe(nodeResponse);
|
|
1493
|
-
} else {
|
|
1494
|
-
nodeResponse.end();
|
|
1495
|
-
}
|
|
1496
|
-
});
|
|
1497
|
-
router.post("/api/v1/runs/:runId/cancel", async (ctx) => {
|
|
1498
|
-
await authenticateRequest(ctx, paths);
|
|
1499
|
-
await cancelHermesRun(ctx.params.runId);
|
|
1500
|
-
ctx.body = { ok: true };
|
|
1501
|
-
});
|
|
1502
|
-
router.get("/api/v1/profiles", async (ctx) => {
|
|
1503
|
-
await authenticateRequest(ctx, paths);
|
|
1504
|
-
ctx.set("cache-control", "no-store");
|
|
1505
|
-
ctx.body = {
|
|
1506
|
-
ok: true,
|
|
1507
|
-
profiles: await listHermesProfiles()
|
|
1508
|
-
};
|
|
1509
|
-
});
|
|
1510
|
-
router.get("/api/v1/profiles/:name/status", async (ctx) => {
|
|
1511
|
-
await authenticateRequest(ctx, paths);
|
|
1512
|
-
ctx.set("cache-control", "no-store");
|
|
1513
|
-
ctx.body = {
|
|
1514
|
-
ok: true,
|
|
1515
|
-
profile: await getHermesProfileStatus(ctx.params.name)
|
|
1516
|
-
};
|
|
1517
|
-
});
|
|
1518
|
-
router.post("/api/v1/profiles", async (ctx) => {
|
|
1519
|
-
await authenticateRequest(ctx, paths);
|
|
1520
|
-
const body = await readJsonBody(ctx.req);
|
|
1521
|
-
const name = readProfileName(body);
|
|
1522
|
-
ctx.status = 201;
|
|
1523
|
-
ctx.body = {
|
|
1524
|
-
ok: true,
|
|
1525
|
-
profile: await createHermesProfile(name)
|
|
1526
|
-
};
|
|
1527
|
-
});
|
|
1528
|
-
router.post("/api/v1/profiles/:name/use", async (ctx) => {
|
|
1529
|
-
await authenticateRequest(ctx, paths);
|
|
1530
|
-
ctx.body = {
|
|
1531
|
-
ok: true,
|
|
1532
|
-
profile: await useHermesProfile(ctx.params.name)
|
|
1533
|
-
};
|
|
1534
|
-
});
|
|
1535
|
-
router.patch("/api/v1/profiles/:name", async (ctx) => {
|
|
1536
|
-
await authenticateRequest(ctx, paths);
|
|
1537
|
-
const body = await readJsonBody(ctx.req);
|
|
1538
|
-
const name = readProfileName(body);
|
|
1539
|
-
ctx.body = {
|
|
1540
|
-
ok: true,
|
|
1541
|
-
profile: await renameHermesProfile(ctx.params.name, name)
|
|
1542
|
-
};
|
|
1543
|
-
});
|
|
1544
|
-
router.delete("/api/v1/profiles/:name", async (ctx) => {
|
|
1545
|
-
await authenticateRequest(ctx, paths);
|
|
1546
|
-
await deleteHermesProfile(ctx.params.name);
|
|
1547
|
-
ctx.status = 204;
|
|
1548
|
-
});
|
|
1549
|
-
app.use(router.routes());
|
|
1550
|
-
app.use(router.allowedMethods());
|
|
1551
|
-
return app;
|
|
1552
|
-
}
|
|
1553
|
-
async function readJsonBody(request) {
|
|
1554
|
-
const chunks = [];
|
|
1555
|
-
for await (const chunk of request) {
|
|
1556
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1557
|
-
}
|
|
1558
|
-
if (chunks.length === 0) {
|
|
1559
|
-
return {};
|
|
1560
|
-
}
|
|
1561
|
-
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1562
|
-
}
|
|
1563
|
-
function readProfileName(body) {
|
|
1564
|
-
if (typeof body.name !== "string") {
|
|
1565
|
-
throw new Error("invalid profile name");
|
|
1566
|
-
}
|
|
1567
|
-
return body.name;
|
|
1568
|
-
}
|
|
1569
|
-
async function authenticateRequest(ctx, paths) {
|
|
1570
|
-
const token = readBearerToken(ctx.get("authorization"));
|
|
1571
|
-
if (!token) {
|
|
1572
|
-
throw new LinkHttpError(401, "auth_required", "Authorization bearer token is required");
|
|
1573
|
-
}
|
|
1574
|
-
const device = await authenticateDeviceAccessToken(token, paths);
|
|
1575
|
-
if (device) {
|
|
1576
|
-
return { kind: "device", device };
|
|
1577
|
-
}
|
|
1578
|
-
const [identity, config] = await Promise.all([loadRequiredIdentity2(paths), loadConfig(paths)]);
|
|
1579
|
-
const claims = await verifyAppConnectToken(token, {
|
|
1580
|
-
config,
|
|
1581
|
-
linkId: identity.link_id
|
|
1582
|
-
});
|
|
1583
|
-
return { kind: "app-connect", accountId: claims.sub };
|
|
1584
|
-
}
|
|
1585
|
-
async function loadRequiredIdentity2(paths) {
|
|
1586
|
-
const identity = await loadIdentity(paths);
|
|
1587
|
-
if (!identity?.link_id) {
|
|
1588
|
-
throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
|
|
1589
|
-
}
|
|
1590
|
-
return identity;
|
|
1591
|
-
}
|
|
1592
|
-
function readBearerToken(value) {
|
|
1593
|
-
const trimmed = value.trim();
|
|
1594
|
-
if (!trimmed.toLowerCase().startsWith("bearer ")) {
|
|
1595
|
-
return null;
|
|
1596
|
-
}
|
|
1597
|
-
const token = trimmed.slice(7).trim();
|
|
1598
|
-
return token || null;
|
|
1599
|
-
}
|
|
1600
|
-
function readString2(body, key) {
|
|
1601
|
-
const value = body[key];
|
|
1602
|
-
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
1603
|
-
}
|
|
1604
|
-
function readLimit(value) {
|
|
1605
|
-
const raw = Array.isArray(value) ? value[0] : value;
|
|
1606
|
-
if (typeof raw !== "string") {
|
|
1607
|
-
return void 0;
|
|
1608
|
-
}
|
|
1609
|
-
const parsed = Number.parseInt(raw, 10);
|
|
1610
|
-
return Number.isFinite(parsed) ? parsed : void 0;
|
|
1611
|
-
}
|
|
1612
|
-
function readConversationHistory(value) {
|
|
1613
|
-
if (!Array.isArray(value)) {
|
|
1614
|
-
return [];
|
|
1615
|
-
}
|
|
1616
|
-
return value.map((item) => {
|
|
1617
|
-
if (typeof item !== "object" || item === null) {
|
|
1618
|
-
return null;
|
|
1619
|
-
}
|
|
1620
|
-
const role = item.role;
|
|
1621
|
-
const content = item.content;
|
|
1622
|
-
if (typeof role !== "string" || typeof content !== "string") {
|
|
1623
|
-
return null;
|
|
1624
|
-
}
|
|
1625
|
-
return { role, content };
|
|
1626
|
-
}).filter((item) => Boolean(item));
|
|
1627
|
-
}
|
|
1628
|
-
function routeObjects(urls) {
|
|
1629
|
-
return urls.map((url) => ({
|
|
1630
|
-
kind: url.includes("/api/v1/relay/links/") ? "relay" : "lan",
|
|
1631
|
-
url,
|
|
1632
|
-
observed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1633
|
-
}));
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
export {
|
|
1637
|
-
LINK_VERSION,
|
|
1638
|
-
LINK_COMMAND,
|
|
1639
|
-
resolveRuntimePaths,
|
|
1640
|
-
loadConfig,
|
|
1641
|
-
ensureHermesApiServerKey,
|
|
1642
|
-
loadIdentity,
|
|
1643
|
-
ensureIdentity,
|
|
1644
|
-
getIdentityStatus,
|
|
1645
|
-
preparePairing,
|
|
1646
|
-
readPairingClaim,
|
|
1647
|
-
clearPairingClaim,
|
|
1648
|
-
createFileLogger,
|
|
1649
|
-
getLinkLogFile,
|
|
1650
|
-
createApp
|
|
1651
|
-
};
|
|
1652
|
-
//# sourceMappingURL=chunk-E54NSFGF.js.map
|