@bulolo/hermes-link 0.2.1 → 0.2.2
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 +82 -65
- package/dist/{chunk-CIUWLPSN.js → chunk-QL5SEM7J.js} +207 -253
- package/dist/cli/index.js +32 -45
- package/dist/http/app.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,103 +1,120 @@
|
|
|
1
1
|
# @bulolo/hermes-link
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
本地伴随服务,为 [Hermes Agent](https://github.com/nousresearch/hermes-agent) 提供移动端接入能力,支持局域网直连。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 环境要求
|
|
6
6
|
|
|
7
7
|
- Node.js >= 20
|
|
8
|
-
-
|
|
8
|
+
- 已安装并运行的 [Hermes Agent](https://github.com/nousresearch/hermes-agent)
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## 安装
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g @bulolo/hermes-link
|
|
13
|
+
npm install -g @bulolo/hermes-link --registry https://registry.npmjs.org
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## 快速开始
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
#
|
|
19
|
+
# 启动后台服务
|
|
20
20
|
hermeslink start
|
|
21
21
|
|
|
22
|
-
#
|
|
22
|
+
# 生成配对二维码(手机扫码连接)
|
|
23
|
+
hermeslink pair
|
|
24
|
+
|
|
25
|
+
# 查看状态
|
|
23
26
|
hermeslink status
|
|
24
27
|
|
|
25
|
-
#
|
|
28
|
+
# 查看日志
|
|
26
29
|
hermeslink logs
|
|
27
30
|
```
|
|
28
31
|
|
|
29
|
-
##
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
32
|
+
## 配对流程
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
1. 运行 hermeslink pair
|
|
36
|
+
│
|
|
37
|
+
▼
|
|
38
|
+
终端显示二维码
|
|
39
|
+
│
|
|
40
|
+
▼
|
|
41
|
+
手机 App 扫码
|
|
42
|
+
│
|
|
43
|
+
▼
|
|
44
|
+
直连本地服务 (局域网 / 127.0.0.1)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
配对完成后,手机 App 获得访问令牌,后续请求直接连接本地服务,无需经过外部服务器。
|
|
48
|
+
|
|
49
|
+
## 命令一览
|
|
50
|
+
|
|
51
|
+
| 命令 | 说明 |
|
|
52
|
+
|------|------|
|
|
53
|
+
| `hermeslink start` | 启动后台守护进程 |
|
|
54
|
+
| `hermeslink stop` | 停止守护进程 |
|
|
55
|
+
| `hermeslink restart` | 重启守护进程 |
|
|
56
|
+
| `hermeslink status` | 查看运行状态 |
|
|
57
|
+
| `hermeslink pair` | 生成配对二维码 |
|
|
58
|
+
| `hermeslink config get` | 查看当前配置 |
|
|
59
|
+
| `hermeslink config set <key> <value>` | 修改配置 |
|
|
60
|
+
| `hermeslink autostart enable` | 开机自启 |
|
|
61
|
+
| `hermeslink autostart disable` | 关闭自启 |
|
|
62
|
+
| `hermeslink logs` | 查看日志 |
|
|
63
|
+
| `hermeslink logs --gateway` | 查看 Hermes 网关日志 |
|
|
64
|
+
| `hermeslink version` | 查看版本 |
|
|
65
|
+
|
|
66
|
+
## 配置
|
|
48
67
|
|
|
49
68
|
```bash
|
|
50
|
-
hermeslink config set port 52379
|
|
51
|
-
hermeslink config set lan-host 192.168.1.10
|
|
52
|
-
hermeslink config set language zh-CN
|
|
53
|
-
hermeslink config set log-level debug
|
|
69
|
+
hermeslink config set port 52379 # 修改监听端口
|
|
70
|
+
hermeslink config set lan-host 192.168.1.10 # 手动指定局域网 IP
|
|
71
|
+
hermeslink config set language zh-CN # 语言 (auto/en/zh-CN)
|
|
72
|
+
hermeslink config set log-level debug # 日志级别 (debug/info/warn/error)
|
|
54
73
|
```
|
|
55
74
|
|
|
56
|
-
|
|
75
|
+
配置文件位于 `~/.hermeslink/config.json`。
|
|
57
76
|
|
|
58
|
-
##
|
|
77
|
+
## 工作原理
|
|
59
78
|
|
|
60
79
|
```
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
hermeslink (this service)
|
|
67
|
-
│
|
|
68
|
-
▼
|
|
69
|
-
Hermes Agent (local)
|
|
70
|
-
localhost:8642
|
|
80
|
+
手机 App
|
|
81
|
+
│
|
|
82
|
+
└──→ hermeslink (本地, 端口 52379)
|
|
83
|
+
│
|
|
84
|
+
└──→ Hermes Agent (localhost:8642)
|
|
71
85
|
```
|
|
72
86
|
|
|
73
|
-
`hermeslink`
|
|
87
|
+
`hermeslink` 在本地运行一个 HTTP 服务,手机 App 通过局域网直接访问。对话、文件、指令均在本地处理,数据不经过外部服务器。
|
|
74
88
|
|
|
75
|
-
##
|
|
89
|
+
## 运行时文件
|
|
76
90
|
|
|
77
|
-
|
|
91
|
+
所有文件存储于 `~/.hermeslink/`:
|
|
78
92
|
|
|
79
|
-
|
|
|
80
|
-
|
|
81
|
-
| `config.json` |
|
|
82
|
-
| `identity.json` |
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
93
|
+
| 路径 | 说明 |
|
|
94
|
+
|------|------|
|
|
95
|
+
| `config.json` | 用户配置 |
|
|
96
|
+
| `identity.json` | 设备身份(ed25519 密钥对)|
|
|
97
|
+
| `credentials.json` | 已配对设备的访问令牌 |
|
|
98
|
+
| `conversations/` | 对话数据 |
|
|
99
|
+
| `blobs/` | 文件附件 |
|
|
100
|
+
| `pairing/` | 配对会话 |
|
|
101
|
+
| `link.db` | SQLite 数据库(统计信息)|
|
|
102
|
+
| `logs/` | 日志文件 |
|
|
86
103
|
|
|
87
|
-
##
|
|
104
|
+
## 环境变量
|
|
88
105
|
|
|
89
|
-
|
|
|
90
|
-
|
|
91
|
-
| `HERMESLINK_HOME` |
|
|
92
|
-
| `HERMESLINK_LOG_LEVEL` |
|
|
93
|
-
| `HERMESLINK_LANG` |
|
|
94
|
-
| `HERMES_BIN` |
|
|
106
|
+
| 变量 | 说明 |
|
|
107
|
+
|------|------|
|
|
108
|
+
| `HERMESLINK_HOME` | 覆盖运行时目录(默认 `~/.hermeslink`)|
|
|
109
|
+
| `HERMESLINK_LOG_LEVEL` | 覆盖日志级别 |
|
|
110
|
+
| `HERMESLINK_LANG` | 覆盖语言 |
|
|
111
|
+
| `HERMES_BIN` | `hermes` 二进制路径(默认 `hermes`)|
|
|
95
112
|
|
|
96
|
-
##
|
|
113
|
+
## 开机自启
|
|
97
114
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
115
|
+
- **macOS**:通过 launchd(`~/Library/LaunchAgents/com.hermes.link.plist`)
|
|
116
|
+
- **Linux**:通过 systemd 用户服务或 XDG autostart
|
|
117
|
+
- **Windows**:通过 Startup 文件夹
|
|
101
118
|
|
|
102
119
|
```bash
|
|
103
120
|
hermeslink autostart enable
|
|
@@ -800,9 +800,6 @@ async function dismissUpdate(paths) {
|
|
|
800
800
|
await updateLinkState({ updateDismissedAt: (/* @__PURE__ */ new Date()).toISOString() }, runtimePaths);
|
|
801
801
|
}
|
|
802
802
|
|
|
803
|
-
// src/http/auth.ts
|
|
804
|
-
import { createPublicKey, verify } from "crypto";
|
|
805
|
-
|
|
806
803
|
// src/security/credentials.ts
|
|
807
804
|
import { randomBytes, randomUUID as randomUUID2, timingSafeEqual, createHash } from "crypto";
|
|
808
805
|
var ACCESS_TOKEN_TTL_MS = 15 * 60 * 1e3;
|
|
@@ -1075,133 +1072,50 @@ async function renameDeviceById(deviceId, label, paths = resolveRuntimePaths())
|
|
|
1075
1072
|
return formatDeviceListItem(device);
|
|
1076
1073
|
}
|
|
1077
1074
|
|
|
1078
|
-
// src/
|
|
1079
|
-
import
|
|
1080
|
-
import
|
|
1081
|
-
|
|
1082
|
-
var
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
const value = await readJsonFile(paths.identityFile);
|
|
1092
|
-
if (value === null) {
|
|
1093
|
-
return null;
|
|
1094
|
-
}
|
|
1095
|
-
return linkIdentitySchema.parse(value);
|
|
1096
|
-
}
|
|
1097
|
-
async function ensureIdentity(paths = resolveRuntimePaths()) {
|
|
1098
|
-
const existing = await loadIdentity(paths);
|
|
1099
|
-
if (existing) {
|
|
1100
|
-
return existing;
|
|
1101
|
-
}
|
|
1102
|
-
await mkdir4(paths.homeDir, { recursive: true, mode: 448 });
|
|
1103
|
-
await chmod2(paths.homeDir, 448).catch(() => void 0);
|
|
1104
|
-
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
1105
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1106
|
-
const identity = {
|
|
1107
|
-
install_id: `install_${randomUUID3().replaceAll("-", "")}`,
|
|
1108
|
-
link_id: null,
|
|
1109
|
-
public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
1110
|
-
private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
1111
|
-
created_at: now,
|
|
1112
|
-
updated_at: now
|
|
1113
|
-
};
|
|
1114
|
-
await writeJsonFile(paths.identityFile, identity);
|
|
1115
|
-
return identity;
|
|
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);
|
|
1116
1088
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1121
|
-
link_id: linkId,
|
|
1122
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1123
|
-
};
|
|
1124
|
-
await writeJsonFile(paths.identityFile, next);
|
|
1125
|
-
return next;
|
|
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";
|
|
1126
1093
|
}
|
|
1127
|
-
function
|
|
1128
|
-
|
|
1094
|
+
async function saveTokens(tokens, paths) {
|
|
1095
|
+
await writeJsonFile(tokensFilePath(paths), tokens);
|
|
1129
1096
|
}
|
|
1130
|
-
function
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
lanHost: null,
|
|
1139
|
-
serverBaseUrl: "https://hermes-server.catwiki.ai",
|
|
1140
|
-
relayBaseUrl: "https://hermes-relay.catwiki.ai",
|
|
1141
|
-
appConnectTokenIssuer: "https://hermes-server.catwiki.ai",
|
|
1142
|
-
appConnectTokenAudience: "hermes-link",
|
|
1143
|
-
language: "auto",
|
|
1144
|
-
logLevel: "warn"
|
|
1145
|
-
};
|
|
1146
|
-
async function loadConfig(paths = resolveRuntimePaths()) {
|
|
1147
|
-
const existing = await readJsonFile(paths.configFile);
|
|
1148
|
-
const language = normalizeConfiguredLanguage(existing?.language);
|
|
1149
|
-
const lanHost = normalizeLanHost(existing?.lanHost);
|
|
1150
|
-
const logLevel = normalizeLogLevel(existing?.logLevel ?? process.env.HERMESLINK_LOG_LEVEL);
|
|
1151
|
-
return {
|
|
1152
|
-
...defaultLinkConfig,
|
|
1153
|
-
...existing ?? {},
|
|
1154
|
-
language,
|
|
1155
|
-
lanHost,
|
|
1156
|
-
logLevel
|
|
1157
|
-
};
|
|
1158
|
-
}
|
|
1159
|
-
async function saveConfig(patch, paths = resolveRuntimePaths()) {
|
|
1160
|
-
const current = await loadConfig(paths);
|
|
1161
|
-
const next = {
|
|
1162
|
-
...current,
|
|
1163
|
-
...patch,
|
|
1164
|
-
logLevel: patch.logLevel === void 0 ? current.logLevel : normalizeLogLevel(patch.logLevel)
|
|
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()
|
|
1165
1105
|
};
|
|
1166
|
-
await
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
if (language === "zh-CN" || language === "en" || language === "auto") {
|
|
1171
|
-
return language;
|
|
1172
|
-
}
|
|
1173
|
-
return defaultLinkConfig.language;
|
|
1174
|
-
}
|
|
1175
|
-
function normalizeLogLevel(level) {
|
|
1176
|
-
if (level === "debug" || level === "info" || level === "warn" || level === "error") {
|
|
1177
|
-
return level;
|
|
1178
|
-
}
|
|
1179
|
-
return defaultLinkConfig.logLevel;
|
|
1180
|
-
}
|
|
1181
|
-
function normalizeLanHost(value) {
|
|
1182
|
-
if (value === null || value === void 0) {
|
|
1183
|
-
return null;
|
|
1184
|
-
}
|
|
1185
|
-
if (typeof value !== "string") {
|
|
1186
|
-
return null;
|
|
1187
|
-
}
|
|
1188
|
-
const host = value.trim().replace(/^\[/u, "").replace(/\]$/u, "");
|
|
1189
|
-
if (!host) {
|
|
1190
|
-
return null;
|
|
1191
|
-
}
|
|
1192
|
-
if (!isUsableLanIpv4(host)) {
|
|
1193
|
-
return null;
|
|
1194
|
-
}
|
|
1195
|
-
return host;
|
|
1106
|
+
const tokens = await readTokens(runtimePaths);
|
|
1107
|
+
tokens.push(token);
|
|
1108
|
+
await saveTokens(tokens, runtimePaths);
|
|
1109
|
+
return token;
|
|
1196
1110
|
}
|
|
1197
|
-
function
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
return
|
|
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];
|
|
1205
1119
|
}
|
|
1206
1120
|
|
|
1207
1121
|
// src/core/errors.ts
|
|
@@ -1216,7 +1130,6 @@ var LinkHttpError = class extends Error {
|
|
|
1216
1130
|
};
|
|
1217
1131
|
|
|
1218
1132
|
// src/http/auth.ts
|
|
1219
|
-
var cachedJwks = null;
|
|
1220
1133
|
async function authenticateRequest(ctx, paths = resolveRuntimePaths()) {
|
|
1221
1134
|
const token = readBearerToken(ctx.get("authorization"));
|
|
1222
1135
|
if (!token) {
|
|
@@ -1229,21 +1142,11 @@ async function authenticateRequest(ctx, paths = resolveRuntimePaths()) {
|
|
|
1229
1142
|
if (token.startsWith("hpat_")) {
|
|
1230
1143
|
throw new LinkHttpError(401, "device_access_token_invalid", "Device access token is invalid or expired");
|
|
1231
1144
|
}
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
kind: "app-connect",
|
|
1236
|
-
accountId: typeof claims.sub === "string" ? claims.sub : null,
|
|
1237
|
-
scopes: normalizeScopes(claims.scope),
|
|
1238
|
-
appInstanceId: normalizeAppInstanceId2(claims.app_instance_id)
|
|
1239
|
-
};
|
|
1240
|
-
}
|
|
1241
|
-
async function loadRequiredIdentity(paths) {
|
|
1242
|
-
const identity = await loadIdentity(paths);
|
|
1243
|
-
if (!identity?.link_id) {
|
|
1244
|
-
throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
|
|
1145
|
+
const localToken = await consumeAppConnectToken(token, paths);
|
|
1146
|
+
if (localToken) {
|
|
1147
|
+
return { kind: "app-connect", accountId: null, scopes: [], appInstanceId: null };
|
|
1245
1148
|
}
|
|
1246
|
-
|
|
1149
|
+
throw new LinkHttpError(401, "auth_invalid", "Token is invalid or expired");
|
|
1247
1150
|
}
|
|
1248
1151
|
function readBearerToken(value) {
|
|
1249
1152
|
const trimmed = value.trim();
|
|
@@ -1251,67 +1154,6 @@ function readBearerToken(value) {
|
|
|
1251
1154
|
const token = trimmed.slice(7).trim();
|
|
1252
1155
|
return token || null;
|
|
1253
1156
|
}
|
|
1254
|
-
function normalizeScopes(value) {
|
|
1255
|
-
if (Array.isArray(value)) return value.filter((i) => typeof i === "string").map((i) => i.trim()).filter(Boolean);
|
|
1256
|
-
if (typeof value === "string") return value.split(/\s+/u).map((i) => i.trim()).filter(Boolean);
|
|
1257
|
-
return [];
|
|
1258
|
-
}
|
|
1259
|
-
function normalizeAppInstanceId2(value) {
|
|
1260
|
-
if (typeof value !== "string") return null;
|
|
1261
|
-
const trimmed = value.trim();
|
|
1262
|
-
return /^appi_[A-Za-z0-9_-]{16,96}$/u.test(trimmed) ? trimmed : null;
|
|
1263
|
-
}
|
|
1264
|
-
async function verifyAppConnectToken(token, options) {
|
|
1265
|
-
const segments = token.split(".");
|
|
1266
|
-
if (segments.length !== 3) {
|
|
1267
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token is malformed");
|
|
1268
|
-
}
|
|
1269
|
-
const [encodedHeader, encodedPayload, encodedSignature] = segments;
|
|
1270
|
-
const header = decodeJwtPart(encodedHeader);
|
|
1271
|
-
const payload = decodeJwtPart(encodedPayload);
|
|
1272
|
-
if (header.alg !== "ES256" || header.typ !== "JWT") {
|
|
1273
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token algorithm is unsupported");
|
|
1274
|
-
}
|
|
1275
|
-
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)) {
|
|
1276
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token claims are invalid");
|
|
1277
|
-
}
|
|
1278
|
-
const jwks = await getJwks(options.config.serverBaseUrl);
|
|
1279
|
-
const key = jwks.find((k) => k.kid === header.kid) ?? jwks[0];
|
|
1280
|
-
if (!key) {
|
|
1281
|
-
throw new LinkHttpError(503, "app_connect_jwks_unavailable", "App connect token key is unavailable");
|
|
1282
|
-
}
|
|
1283
|
-
const publicKey = createPublicKey({ key, format: "jwk" });
|
|
1284
|
-
const ok = verify(
|
|
1285
|
-
"sha256",
|
|
1286
|
-
Buffer.from(`${encodedHeader}.${encodedPayload}`),
|
|
1287
|
-
{ key: publicKey, dsaEncoding: "ieee-p1363" },
|
|
1288
|
-
Buffer.from(base64UrlToBase64(encodedSignature), "base64")
|
|
1289
|
-
);
|
|
1290
|
-
if (!ok) {
|
|
1291
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token signature is invalid");
|
|
1292
|
-
}
|
|
1293
|
-
return payload;
|
|
1294
|
-
}
|
|
1295
|
-
async function getJwks(serverBaseUrl) {
|
|
1296
|
-
if (cachedJwks && cachedJwks.expiresAt > Date.now()) return cachedJwks.keys;
|
|
1297
|
-
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}/api/v1/app-connect/jwks.json`, {
|
|
1298
|
-
headers: { accept: "application/json" }
|
|
1299
|
-
});
|
|
1300
|
-
if (!response.ok) {
|
|
1301
|
-
throw new LinkHttpError(503, "app_connect_jwks_unavailable", "Unable to load app connect JWKS");
|
|
1302
|
-
}
|
|
1303
|
-
const payload = await response.json();
|
|
1304
|
-
const keys = Array.isArray(payload.keys) ? payload.keys : [];
|
|
1305
|
-
cachedJwks = { keys, expiresAt: Date.now() + 5 * 60 * 1e3 };
|
|
1306
|
-
return keys;
|
|
1307
|
-
}
|
|
1308
|
-
function decodeJwtPart(value) {
|
|
1309
|
-
return JSON.parse(Buffer.from(base64UrlToBase64(value), "base64").toString("utf8"));
|
|
1310
|
-
}
|
|
1311
|
-
function base64UrlToBase64(value) {
|
|
1312
|
-
const n = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
1313
|
-
return n + "=".repeat((4 - n.length % 4) % 4);
|
|
1314
|
-
}
|
|
1315
1157
|
function readAppInstanceIdHeader(ctx) {
|
|
1316
1158
|
const value = ctx.get("x-hermes-app-instance-id").trim();
|
|
1317
1159
|
return /^appi_[A-Za-z0-9_-]{16,96}$/u.test(value) ? value : null;
|
|
@@ -1516,6 +1358,135 @@ function createStatisticsRouter(options) {
|
|
|
1516
1358
|
// src/http/routes/bootstrap.ts
|
|
1517
1359
|
import Router3 from "@koa/router";
|
|
1518
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) {
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
if (typeof value !== "string") {
|
|
1469
|
+
return null;
|
|
1470
|
+
}
|
|
1471
|
+
const host = value.trim().replace(/^\[/u, "").replace(/\]$/u, "");
|
|
1472
|
+
if (!host) {
|
|
1473
|
+
return null;
|
|
1474
|
+
}
|
|
1475
|
+
if (!isUsableLanIpv4(host)) {
|
|
1476
|
+
return null;
|
|
1477
|
+
}
|
|
1478
|
+
return host;
|
|
1479
|
+
}
|
|
1480
|
+
function isUsableLanIpv4(value) {
|
|
1481
|
+
const parts = value.split(".").map((part) => Number.parseInt(part, 10));
|
|
1482
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
const [first, second, , fourth] = parts;
|
|
1486
|
+
const privateRange = first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
|
|
1487
|
+
return privateRange && fourth !== 0 && fourth !== 255;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1519
1490
|
// src/network/topology.ts
|
|
1520
1491
|
import os5 from "os";
|
|
1521
1492
|
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;
|
|
@@ -2240,7 +2211,7 @@ function createConversationsRouter(options) {
|
|
|
2240
2211
|
|
|
2241
2212
|
// src/http/routes/pairing.ts
|
|
2242
2213
|
import Router7 from "@koa/router";
|
|
2243
|
-
import
|
|
2214
|
+
import path7 from "path";
|
|
2244
2215
|
function readString4(body, ...keys) {
|
|
2245
2216
|
if (!body || typeof body !== "object") return null;
|
|
2246
2217
|
for (const key of keys) {
|
|
@@ -2267,10 +2238,10 @@ async function readJsonBody4(req) {
|
|
|
2267
2238
|
});
|
|
2268
2239
|
}
|
|
2269
2240
|
function pairingSessionPath(sessionId, paths) {
|
|
2270
|
-
return
|
|
2241
|
+
return path7.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.json`);
|
|
2271
2242
|
}
|
|
2272
2243
|
function pairingClaimPath(sessionId, paths) {
|
|
2273
|
-
return
|
|
2244
|
+
return path7.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
|
|
2274
2245
|
}
|
|
2275
2246
|
async function readPairingSession(sessionId, paths) {
|
|
2276
2247
|
const record = await readJsonFile(pairingSessionPath(sessionId, paths));
|
|
@@ -2315,35 +2286,18 @@ function createPairingRouter(options) {
|
|
|
2315
2286
|
if (!sessionId || !claimToken) {
|
|
2316
2287
|
throw new LinkHttpError(400, "pairing_claim_invalid", "session_id and claim_token are required");
|
|
2317
2288
|
}
|
|
2318
|
-
const [identity,
|
|
2289
|
+
const [identity, localSession] = await Promise.all([
|
|
2319
2290
|
loadIdentity(paths),
|
|
2320
|
-
loadConfig(paths),
|
|
2321
2291
|
readPairingSession(sessionId, paths)
|
|
2322
2292
|
]);
|
|
2323
2293
|
if (!identity?.link_id) throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
|
|
2324
2294
|
if (!localSession) throw new LinkHttpError(404, "pairing_session_not_found", "Pairing session was not found");
|
|
2325
2295
|
if (isPairingSessionExpired(localSession)) throw new LinkHttpError(404, "pairing_session_expired", "Pairing session has expired");
|
|
2326
2296
|
if (localSession.link_id !== identity.link_id) throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim does not match this Link");
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
const resp = await fetch(`${config.serverBaseUrl.replace(/\/+$/u, "")}/api/v1/link-pairings/${sessionId}/claim/verify`, {
|
|
2330
|
-
method: "POST",
|
|
2331
|
-
headers: { accept: "application/json", "content-type": "application/json" },
|
|
2332
|
-
body: JSON.stringify({ claim_token: claimToken, app_instance_id: readString4(body, "app_instance_id", "appInstanceId") ?? void 0 })
|
|
2333
|
-
});
|
|
2334
|
-
if (!resp.ok) {
|
|
2335
|
-
const errBody = await resp.json().catch(() => ({}));
|
|
2336
|
-
throw new LinkHttpError(resp.status, "pairing_claim_verify_failed", String(errBody.message ?? "Pairing claim verification failed"));
|
|
2337
|
-
}
|
|
2338
|
-
verified = await resp.json();
|
|
2339
|
-
} catch (err) {
|
|
2340
|
-
if (err instanceof LinkHttpError) throw err;
|
|
2341
|
-
throw new LinkHttpError(503, "pairing_server_unreachable", `Unable to verify pairing claim: ${err.message}`);
|
|
2297
|
+
if (localSession.code !== claimToken) {
|
|
2298
|
+
throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim token does not match");
|
|
2342
2299
|
}
|
|
2343
|
-
|
|
2344
|
-
throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim does not match this Link");
|
|
2345
|
-
}
|
|
2346
|
-
const appInstanceId = typeof verified.appInstanceId === "string" ? verified.appInstanceId : null;
|
|
2300
|
+
const appInstanceId = readString4(body, "app_instance_id", "appInstanceId");
|
|
2347
2301
|
const deviceLabel = readString4(body, "device_label", "deviceLabel") ?? "HermesPilot App";
|
|
2348
2302
|
const devicePlatform = readString4(body, "device_platform", "devicePlatform") ?? "unknown";
|
|
2349
2303
|
const session = await createDeviceSession(
|
|
@@ -2601,9 +2555,9 @@ function openSqliteDatabase(filePath, options = {}) {
|
|
|
2601
2555
|
|
|
2602
2556
|
// src/storage/link-database.ts
|
|
2603
2557
|
import { mkdir as mkdir5 } from "fs/promises";
|
|
2604
|
-
import
|
|
2558
|
+
import path8 from "path";
|
|
2605
2559
|
async function initLinkDatabase(paths) {
|
|
2606
|
-
await mkdir5(
|
|
2560
|
+
await mkdir5(path8.dirname(paths.databaseFile), { recursive: true, mode: 448 });
|
|
2607
2561
|
const db = openDb(paths);
|
|
2608
2562
|
try {
|
|
2609
2563
|
db.exec(`
|
|
@@ -2699,35 +2653,35 @@ function openDb(paths) {
|
|
|
2699
2653
|
|
|
2700
2654
|
// src/conversations/service.ts
|
|
2701
2655
|
import { EventEmitter as EventEmitter2 } from "events";
|
|
2702
|
-
import
|
|
2656
|
+
import crypto4 from "crypto";
|
|
2703
2657
|
|
|
2704
2658
|
// src/conversations/store.ts
|
|
2705
2659
|
import { mkdir as mkdir6, readFile as readFile4, rm as rm4, writeFile as writeFile2 } from "fs/promises";
|
|
2706
|
-
import
|
|
2707
|
-
import
|
|
2660
|
+
import path9 from "path";
|
|
2661
|
+
import crypto2 from "crypto";
|
|
2708
2662
|
function createConversationId() {
|
|
2709
|
-
return `conv_${
|
|
2663
|
+
return `conv_${crypto2.randomUUID().replaceAll("-", "")}`;
|
|
2710
2664
|
}
|
|
2711
2665
|
function createMessageId() {
|
|
2712
|
-
return `msg_${
|
|
2666
|
+
return `msg_${crypto2.randomUUID().replaceAll("-", "")}`;
|
|
2713
2667
|
}
|
|
2714
2668
|
function createRunId() {
|
|
2715
|
-
return `run_${
|
|
2669
|
+
return `run_${crypto2.randomUUID().replaceAll("-", "")}`;
|
|
2716
2670
|
}
|
|
2717
2671
|
function conversationDir(paths, conversationId) {
|
|
2718
|
-
return
|
|
2672
|
+
return path9.join(paths.conversationsDir, conversationId);
|
|
2719
2673
|
}
|
|
2720
2674
|
function manifestPath(paths, conversationId) {
|
|
2721
|
-
return
|
|
2675
|
+
return path9.join(conversationDir(paths, conversationId), "manifest.json");
|
|
2722
2676
|
}
|
|
2723
2677
|
function snapshotPath(paths, conversationId) {
|
|
2724
|
-
return
|
|
2678
|
+
return path9.join(conversationDir(paths, conversationId), "snapshot.json");
|
|
2725
2679
|
}
|
|
2726
2680
|
function eventsPath(paths, conversationId) {
|
|
2727
|
-
return
|
|
2681
|
+
return path9.join(conversationDir(paths, conversationId), "events.json");
|
|
2728
2682
|
}
|
|
2729
2683
|
function blobPath(paths, blobId) {
|
|
2730
|
-
return
|
|
2684
|
+
return path9.join(paths.blobsDir, blobId);
|
|
2731
2685
|
}
|
|
2732
2686
|
async function readJson(filePath, fallback) {
|
|
2733
2687
|
try {
|
|
@@ -2738,7 +2692,7 @@ async function readJson(filePath, fallback) {
|
|
|
2738
2692
|
}
|
|
2739
2693
|
}
|
|
2740
2694
|
async function writeJson(filePath, data) {
|
|
2741
|
-
await mkdir6(
|
|
2695
|
+
await mkdir6(path9.dirname(filePath), { recursive: true, mode: 448 });
|
|
2742
2696
|
await writeFile2(filePath, JSON.stringify(data), { mode: 384 });
|
|
2743
2697
|
}
|
|
2744
2698
|
function assertValidConversationId(id) {
|
|
@@ -2795,8 +2749,8 @@ async function listConversationIds(paths) {
|
|
|
2795
2749
|
|
|
2796
2750
|
// src/conversations/blobs.ts
|
|
2797
2751
|
import { mkdir as mkdir7, readFile as readFile5, rm as rm5, writeFile as writeFile3 } from "fs/promises";
|
|
2798
|
-
import
|
|
2799
|
-
import
|
|
2752
|
+
import path10 from "path";
|
|
2753
|
+
import crypto3 from "crypto";
|
|
2800
2754
|
var MAX_BLOB_UPLOAD_BYTES2 = 50 * 1024 * 1024;
|
|
2801
2755
|
function blobManifestPath(paths, blobId) {
|
|
2802
2756
|
return `${blobPath(paths, blobId)}.json`;
|
|
@@ -2804,7 +2758,7 @@ function blobManifestPath(paths, blobId) {
|
|
|
2804
2758
|
function normalizeMime(mime, filename) {
|
|
2805
2759
|
if (mime && mime !== "application/octet-stream") return mime;
|
|
2806
2760
|
if (filename) {
|
|
2807
|
-
const ext =
|
|
2761
|
+
const ext = path10.extname(filename).toLowerCase();
|
|
2808
2762
|
const mimeMap = {
|
|
2809
2763
|
".jpg": "image/jpeg",
|
|
2810
2764
|
".jpeg": "image/jpeg",
|
|
@@ -2824,7 +2778,7 @@ function normalizeMime(mime, filename) {
|
|
|
2824
2778
|
}
|
|
2825
2779
|
function sanitizeFilename(filename, fallback) {
|
|
2826
2780
|
if (!filename) return fallback;
|
|
2827
|
-
return
|
|
2781
|
+
return path10.basename(filename).replace(/[^\w.\-]/gu, "_").slice(0, 255) || fallback;
|
|
2828
2782
|
}
|
|
2829
2783
|
async function writeBlob(paths, conversationId, input) {
|
|
2830
2784
|
assertValidConversationId(conversationId);
|
|
@@ -2834,9 +2788,9 @@ async function writeBlob(paths, conversationId, input) {
|
|
|
2834
2788
|
if (input.bytes.byteLength > MAX_BLOB_UPLOAD_BYTES2) {
|
|
2835
2789
|
throw new LinkHttpError(413, "blob_too_large", "Blob is too large");
|
|
2836
2790
|
}
|
|
2837
|
-
const id = `blob_${
|
|
2791
|
+
const id = `blob_${crypto3.randomUUID().replaceAll("-", "")}`;
|
|
2838
2792
|
const filePath = blobPath(paths, id);
|
|
2839
|
-
await mkdir7(
|
|
2793
|
+
await mkdir7(path10.dirname(filePath), { recursive: true, mode: 448 });
|
|
2840
2794
|
await writeFile3(filePath, input.bytes, { mode: 384 });
|
|
2841
2795
|
const blob = {
|
|
2842
2796
|
id,
|
|
@@ -3209,7 +3163,7 @@ var ConversationService = class extends EventEmitter2 {
|
|
|
3209
3163
|
return { deleted_count: count };
|
|
3210
3164
|
}
|
|
3211
3165
|
async prepareClearAllConversationPlan() {
|
|
3212
|
-
const planId = `plan_${
|
|
3166
|
+
const planId = `plan_${crypto4.randomUUID().replaceAll("-", "")}`;
|
|
3213
3167
|
const ids = await listConversationIds(this.paths);
|
|
3214
3168
|
const activeIds = [];
|
|
3215
3169
|
for (const id of ids) {
|
|
@@ -3287,7 +3241,7 @@ import { promisify as promisify3 } from "util";
|
|
|
3287
3241
|
|
|
3288
3242
|
// src/hermes/config.ts
|
|
3289
3243
|
import os6 from "os";
|
|
3290
|
-
import
|
|
3244
|
+
import path11 from "path";
|
|
3291
3245
|
import YAML from "yaml";
|
|
3292
3246
|
var PROFILE_PERMISSION_TOOLSETS = [
|
|
3293
3247
|
{
|
|
@@ -3417,19 +3371,19 @@ var profileApiServerPortAssignmentQueue = Promise.resolve();
|
|
|
3417
3371
|
function resolveHermesProfilesDir() {
|
|
3418
3372
|
const hermesHome = process.env.HERMES_HOME?.trim();
|
|
3419
3373
|
if (hermesHome) {
|
|
3420
|
-
return
|
|
3421
|
-
resolveDefaultHermesRoot(
|
|
3374
|
+
return path11.join(
|
|
3375
|
+
resolveDefaultHermesRoot(path11.resolve(hermesHome)),
|
|
3422
3376
|
"profiles"
|
|
3423
3377
|
);
|
|
3424
3378
|
}
|
|
3425
|
-
return
|
|
3379
|
+
return path11.join(os6.homedir(), ".hermes", "profiles");
|
|
3426
3380
|
}
|
|
3427
3381
|
function isValidProfileName(value) {
|
|
3428
3382
|
return typeof value === "string" && /^[a-zA-Z0-9._-]{1,64}$/.test(value);
|
|
3429
3383
|
}
|
|
3430
3384
|
function resolveDefaultHermesRoot(hermesHome) {
|
|
3431
|
-
if (
|
|
3432
|
-
return
|
|
3385
|
+
if (path11.basename(path11.dirname(hermesHome)) === "profiles") {
|
|
3386
|
+
return path11.dirname(path11.dirname(hermesHome));
|
|
3433
3387
|
}
|
|
3434
3388
|
return hermesHome;
|
|
3435
3389
|
}
|
|
@@ -3641,7 +3595,6 @@ export {
|
|
|
3641
3595
|
LINK_VERSION,
|
|
3642
3596
|
DAEMON_LOG_FILE,
|
|
3643
3597
|
resolveRuntimePaths,
|
|
3644
|
-
readJsonFile,
|
|
3645
3598
|
writeJsonFile,
|
|
3646
3599
|
loadConfig,
|
|
3647
3600
|
saveConfig,
|
|
@@ -3658,5 +3611,6 @@ export {
|
|
|
3658
3611
|
readRecentLogEntries,
|
|
3659
3612
|
readRecentGatewayLogEntries,
|
|
3660
3613
|
bootstrapWithRelay,
|
|
3614
|
+
generateAppConnectToken,
|
|
3661
3615
|
startLinkService
|
|
3662
3616
|
};
|
package/dist/cli/index.js
CHANGED
|
@@ -10,11 +10,11 @@ import {
|
|
|
10
10
|
disableAutostart,
|
|
11
11
|
enableAutostart,
|
|
12
12
|
ensureIdentity,
|
|
13
|
+
generateAppConnectToken,
|
|
13
14
|
getAutostartStatus,
|
|
14
15
|
loadConfig,
|
|
15
16
|
loadIdentity,
|
|
16
17
|
normalizeLanHost,
|
|
17
|
-
readJsonFile,
|
|
18
18
|
readRecentGatewayLogEntries,
|
|
19
19
|
readRecentLogEntries,
|
|
20
20
|
resolveRuntimePaths,
|
|
@@ -22,10 +22,10 @@ import {
|
|
|
22
22
|
saveConfig,
|
|
23
23
|
startLinkService,
|
|
24
24
|
writeJsonFile
|
|
25
|
-
} from "../chunk-
|
|
25
|
+
} from "../chunk-QL5SEM7J.js";
|
|
26
26
|
|
|
27
27
|
// src/cli/index.ts
|
|
28
|
-
import { mkdir as
|
|
28
|
+
import { mkdir as mkdir3 } from "fs/promises";
|
|
29
29
|
import qrcode from "qrcode-terminal";
|
|
30
30
|
|
|
31
31
|
// src/daemon/process.ts
|
|
@@ -190,42 +190,9 @@ async function runDaemonSupervisor(options) {
|
|
|
190
190
|
await cleanup();
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
// src/
|
|
194
|
-
import crypto from "crypto";
|
|
193
|
+
// src/pairing/preflight.ts
|
|
195
194
|
import path2 from "path";
|
|
196
|
-
|
|
197
|
-
var TOKEN_EXPIRY_MS = 5 * 60 * 1e3;
|
|
198
|
-
function tokensFilePath(paths) {
|
|
199
|
-
return path2.join(paths.homeDir, TOKENS_FILE);
|
|
200
|
-
}
|
|
201
|
-
async function readTokens(paths) {
|
|
202
|
-
const raw = await readJsonFile(tokensFilePath(paths));
|
|
203
|
-
if (!Array.isArray(raw)) return [];
|
|
204
|
-
const now = /* @__PURE__ */ new Date();
|
|
205
|
-
return raw.filter(isValidToken).filter((t) => new Date(t.expiresAt) > now);
|
|
206
|
-
}
|
|
207
|
-
function isValidToken(value) {
|
|
208
|
-
if (!value || typeof value !== "object") return false;
|
|
209
|
-
const t = value;
|
|
210
|
-
return typeof t.token === "string" && typeof t.createdAt === "string" && typeof t.expiresAt === "string";
|
|
211
|
-
}
|
|
212
|
-
async function saveTokens(tokens, paths) {
|
|
213
|
-
await writeJsonFile(tokensFilePath(paths), tokens);
|
|
214
|
-
}
|
|
215
|
-
async function generateAppConnectToken(paths) {
|
|
216
|
-
const runtimePaths = paths ?? resolveRuntimePaths();
|
|
217
|
-
const now = /* @__PURE__ */ new Date();
|
|
218
|
-
const token = {
|
|
219
|
-
token: crypto.randomBytes(32).toString("base64url"),
|
|
220
|
-
createdAt: now.toISOString(),
|
|
221
|
-
usedAt: null,
|
|
222
|
-
expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS).toISOString()
|
|
223
|
-
};
|
|
224
|
-
const tokens = await readTokens(runtimePaths);
|
|
225
|
-
tokens.push(token);
|
|
226
|
-
await saveTokens(tokens, runtimePaths);
|
|
227
|
-
return token;
|
|
228
|
-
}
|
|
195
|
+
import { mkdir as mkdir2 } from "fs/promises";
|
|
229
196
|
|
|
230
197
|
// src/runtime/browser.ts
|
|
231
198
|
import { spawn as spawn2 } from "child_process";
|
|
@@ -261,28 +228,48 @@ async function spawnDetached(command2, args2) {
|
|
|
261
228
|
}
|
|
262
229
|
|
|
263
230
|
// src/pairing/preflight.ts
|
|
231
|
+
function pairingSessionPath(sessionId, paths) {
|
|
232
|
+
return path2.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.json`);
|
|
233
|
+
}
|
|
264
234
|
async function runPairingPreflight(options) {
|
|
265
235
|
const token = await generateAppConnectToken(options.paths);
|
|
266
|
-
const
|
|
267
|
-
const
|
|
236
|
+
const localApiUrl = `http://127.0.0.1:${options.config.port}`;
|
|
237
|
+
const sessionId = `ps_${token.token.slice(0, 16)}`;
|
|
238
|
+
const session = {
|
|
239
|
+
session_id: sessionId,
|
|
240
|
+
code: token.token,
|
|
241
|
+
link_id: options.identity.link_id ?? "",
|
|
242
|
+
display_name: "Hermes Link",
|
|
243
|
+
local_api_url: localApiUrl,
|
|
244
|
+
server_base_url: options.config.serverBaseUrl,
|
|
245
|
+
relay_base_url: options.config.relayBaseUrl,
|
|
246
|
+
preferred_urls: [localApiUrl],
|
|
247
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
248
|
+
expires_at: token.expiresAt
|
|
249
|
+
};
|
|
250
|
+
await mkdir2(options.paths.pairingDir, { recursive: true, mode: 448 }).catch(() => void 0);
|
|
251
|
+
await writeJsonFile(pairingSessionPath(sessionId, options.paths), session);
|
|
252
|
+
const pairingUrl = buildPairingUrl({
|
|
268
253
|
linkId: options.identity.link_id ?? "",
|
|
269
254
|
installId: options.identity.install_id,
|
|
270
255
|
connectToken: token.token,
|
|
271
|
-
port: options.config.port
|
|
256
|
+
port: options.config.port,
|
|
257
|
+
localApiUrl
|
|
272
258
|
});
|
|
273
259
|
if (options.openBrowser !== false) {
|
|
274
260
|
await openSystemBrowser(pairingUrl).catch(() => void 0);
|
|
275
261
|
}
|
|
276
262
|
return { pairingUrl, connectToken: token.token };
|
|
277
263
|
}
|
|
278
|
-
function buildPairingUrl(
|
|
264
|
+
function buildPairingUrl(params) {
|
|
279
265
|
const qs = new URLSearchParams({
|
|
280
266
|
link_id: params.linkId,
|
|
281
267
|
install_id: params.installId,
|
|
282
268
|
connect_token: params.connectToken,
|
|
283
|
-
port: String(params.port)
|
|
269
|
+
port: String(params.port),
|
|
270
|
+
local_url: params.localApiUrl
|
|
284
271
|
});
|
|
285
|
-
return
|
|
272
|
+
return `hermesapp://pair?${qs.toString()}`;
|
|
286
273
|
}
|
|
287
274
|
|
|
288
275
|
// src/cli/index.ts
|
|
@@ -308,7 +295,7 @@ async function main() {
|
|
|
308
295
|
return;
|
|
309
296
|
}
|
|
310
297
|
const paths = resolveRuntimePaths();
|
|
311
|
-
await
|
|
298
|
+
await mkdir3(paths.homeDir, { recursive: true, mode: 448 });
|
|
312
299
|
switch (command) {
|
|
313
300
|
case "start":
|
|
314
301
|
await cmdStart(paths);
|
package/dist/http/app.js
CHANGED