@bulolo/hermes-link 0.2.0 → 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-CCKWZNLX.js → chunk-QL5SEM7J.js} +211 -254
- 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
|
|
@@ -12,8 +12,11 @@ import path2 from "path";
|
|
|
12
12
|
import pino from "pino";
|
|
13
13
|
|
|
14
14
|
// src/constants.ts
|
|
15
|
+
import { createRequire } from "module";
|
|
16
|
+
var _require = createRequire(import.meta.url);
|
|
17
|
+
var _pkg = _require("../package.json");
|
|
15
18
|
var LINK_COMMAND = "hermeslink";
|
|
16
|
-
var LINK_VERSION =
|
|
19
|
+
var LINK_VERSION = _pkg.version;
|
|
17
20
|
var LINK_DEFAULT_PORT = 52379;
|
|
18
21
|
var LINK_RUNTIME_DIR_NAME = ".hermeslink";
|
|
19
22
|
var DEFAULT_LOG_FILE = "hermeslink.log";
|
|
@@ -797,9 +800,6 @@ async function dismissUpdate(paths) {
|
|
|
797
800
|
await updateLinkState({ updateDismissedAt: (/* @__PURE__ */ new Date()).toISOString() }, runtimePaths);
|
|
798
801
|
}
|
|
799
802
|
|
|
800
|
-
// src/http/auth.ts
|
|
801
|
-
import { createPublicKey, verify } from "crypto";
|
|
802
|
-
|
|
803
803
|
// src/security/credentials.ts
|
|
804
804
|
import { randomBytes, randomUUID as randomUUID2, timingSafeEqual, createHash } from "crypto";
|
|
805
805
|
var ACCESS_TOKEN_TTL_MS = 15 * 60 * 1e3;
|
|
@@ -1072,133 +1072,50 @@ async function renameDeviceById(deviceId, label, paths = resolveRuntimePaths())
|
|
|
1072
1072
|
return formatDeviceListItem(device);
|
|
1073
1073
|
}
|
|
1074
1074
|
|
|
1075
|
-
// src/
|
|
1076
|
-
import
|
|
1077
|
-
import
|
|
1078
|
-
|
|
1079
|
-
var
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
const value = await readJsonFile(paths.identityFile);
|
|
1089
|
-
if (value === null) {
|
|
1090
|
-
return null;
|
|
1091
|
-
}
|
|
1092
|
-
return linkIdentitySchema.parse(value);
|
|
1093
|
-
}
|
|
1094
|
-
async function ensureIdentity(paths = resolveRuntimePaths()) {
|
|
1095
|
-
const existing = await loadIdentity(paths);
|
|
1096
|
-
if (existing) {
|
|
1097
|
-
return existing;
|
|
1098
|
-
}
|
|
1099
|
-
await mkdir4(paths.homeDir, { recursive: true, mode: 448 });
|
|
1100
|
-
await chmod2(paths.homeDir, 448).catch(() => void 0);
|
|
1101
|
-
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
1102
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1103
|
-
const identity = {
|
|
1104
|
-
install_id: `install_${randomUUID3().replaceAll("-", "")}`,
|
|
1105
|
-
link_id: null,
|
|
1106
|
-
public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
1107
|
-
private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
1108
|
-
created_at: now,
|
|
1109
|
-
updated_at: now
|
|
1110
|
-
};
|
|
1111
|
-
await writeJsonFile(paths.identityFile, identity);
|
|
1112
|
-
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);
|
|
1113
1088
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
link_id: linkId,
|
|
1119
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1120
|
-
};
|
|
1121
|
-
await writeJsonFile(paths.identityFile, next);
|
|
1122
|
-
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";
|
|
1123
1093
|
}
|
|
1124
|
-
function
|
|
1125
|
-
|
|
1094
|
+
async function saveTokens(tokens, paths) {
|
|
1095
|
+
await writeJsonFile(tokensFilePath(paths), tokens);
|
|
1126
1096
|
}
|
|
1127
|
-
function
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
lanHost: null,
|
|
1136
|
-
serverBaseUrl: "https://hermes-server.catwiki.ai",
|
|
1137
|
-
relayBaseUrl: "https://hermes-relay.catwiki.ai",
|
|
1138
|
-
appConnectTokenIssuer: "https://hermes-server.catwiki.ai",
|
|
1139
|
-
appConnectTokenAudience: "hermes-link",
|
|
1140
|
-
language: "auto",
|
|
1141
|
-
logLevel: "warn"
|
|
1142
|
-
};
|
|
1143
|
-
async function loadConfig(paths = resolveRuntimePaths()) {
|
|
1144
|
-
const existing = await readJsonFile(paths.configFile);
|
|
1145
|
-
const language = normalizeConfiguredLanguage(existing?.language);
|
|
1146
|
-
const lanHost = normalizeLanHost(existing?.lanHost);
|
|
1147
|
-
const logLevel = normalizeLogLevel(existing?.logLevel ?? process.env.HERMESLINK_LOG_LEVEL);
|
|
1148
|
-
return {
|
|
1149
|
-
...defaultLinkConfig,
|
|
1150
|
-
...existing ?? {},
|
|
1151
|
-
language,
|
|
1152
|
-
lanHost,
|
|
1153
|
-
logLevel
|
|
1154
|
-
};
|
|
1155
|
-
}
|
|
1156
|
-
async function saveConfig(patch, paths = resolveRuntimePaths()) {
|
|
1157
|
-
const current = await loadConfig(paths);
|
|
1158
|
-
const next = {
|
|
1159
|
-
...current,
|
|
1160
|
-
...patch,
|
|
1161
|
-
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()
|
|
1162
1105
|
};
|
|
1163
|
-
await
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
if (language === "zh-CN" || language === "en" || language === "auto") {
|
|
1168
|
-
return language;
|
|
1169
|
-
}
|
|
1170
|
-
return defaultLinkConfig.language;
|
|
1171
|
-
}
|
|
1172
|
-
function normalizeLogLevel(level) {
|
|
1173
|
-
if (level === "debug" || level === "info" || level === "warn" || level === "error") {
|
|
1174
|
-
return level;
|
|
1175
|
-
}
|
|
1176
|
-
return defaultLinkConfig.logLevel;
|
|
1177
|
-
}
|
|
1178
|
-
function normalizeLanHost(value) {
|
|
1179
|
-
if (value === null || value === void 0) {
|
|
1180
|
-
return null;
|
|
1181
|
-
}
|
|
1182
|
-
if (typeof value !== "string") {
|
|
1183
|
-
return null;
|
|
1184
|
-
}
|
|
1185
|
-
const host = value.trim().replace(/^\[/u, "").replace(/\]$/u, "");
|
|
1186
|
-
if (!host) {
|
|
1187
|
-
return null;
|
|
1188
|
-
}
|
|
1189
|
-
if (!isUsableLanIpv4(host)) {
|
|
1190
|
-
return null;
|
|
1191
|
-
}
|
|
1192
|
-
return host;
|
|
1106
|
+
const tokens = await readTokens(runtimePaths);
|
|
1107
|
+
tokens.push(token);
|
|
1108
|
+
await saveTokens(tokens, runtimePaths);
|
|
1109
|
+
return token;
|
|
1193
1110
|
}
|
|
1194
|
-
function
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
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];
|
|
1202
1119
|
}
|
|
1203
1120
|
|
|
1204
1121
|
// src/core/errors.ts
|
|
@@ -1213,7 +1130,6 @@ var LinkHttpError = class extends Error {
|
|
|
1213
1130
|
};
|
|
1214
1131
|
|
|
1215
1132
|
// src/http/auth.ts
|
|
1216
|
-
var cachedJwks = null;
|
|
1217
1133
|
async function authenticateRequest(ctx, paths = resolveRuntimePaths()) {
|
|
1218
1134
|
const token = readBearerToken(ctx.get("authorization"));
|
|
1219
1135
|
if (!token) {
|
|
@@ -1226,21 +1142,11 @@ async function authenticateRequest(ctx, paths = resolveRuntimePaths()) {
|
|
|
1226
1142
|
if (token.startsWith("hpat_")) {
|
|
1227
1143
|
throw new LinkHttpError(401, "device_access_token_invalid", "Device access token is invalid or expired");
|
|
1228
1144
|
}
|
|
1229
|
-
const
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
kind: "app-connect",
|
|
1233
|
-
accountId: typeof claims.sub === "string" ? claims.sub : null,
|
|
1234
|
-
scopes: normalizeScopes(claims.scope),
|
|
1235
|
-
appInstanceId: normalizeAppInstanceId2(claims.app_instance_id)
|
|
1236
|
-
};
|
|
1237
|
-
}
|
|
1238
|
-
async function loadRequiredIdentity(paths) {
|
|
1239
|
-
const identity = await loadIdentity(paths);
|
|
1240
|
-
if (!identity?.link_id) {
|
|
1241
|
-
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 };
|
|
1242
1148
|
}
|
|
1243
|
-
|
|
1149
|
+
throw new LinkHttpError(401, "auth_invalid", "Token is invalid or expired");
|
|
1244
1150
|
}
|
|
1245
1151
|
function readBearerToken(value) {
|
|
1246
1152
|
const trimmed = value.trim();
|
|
@@ -1248,67 +1154,6 @@ function readBearerToken(value) {
|
|
|
1248
1154
|
const token = trimmed.slice(7).trim();
|
|
1249
1155
|
return token || null;
|
|
1250
1156
|
}
|
|
1251
|
-
function normalizeScopes(value) {
|
|
1252
|
-
if (Array.isArray(value)) return value.filter((i) => typeof i === "string").map((i) => i.trim()).filter(Boolean);
|
|
1253
|
-
if (typeof value === "string") return value.split(/\s+/u).map((i) => i.trim()).filter(Boolean);
|
|
1254
|
-
return [];
|
|
1255
|
-
}
|
|
1256
|
-
function normalizeAppInstanceId2(value) {
|
|
1257
|
-
if (typeof value !== "string") return null;
|
|
1258
|
-
const trimmed = value.trim();
|
|
1259
|
-
return /^appi_[A-Za-z0-9_-]{16,96}$/u.test(trimmed) ? trimmed : null;
|
|
1260
|
-
}
|
|
1261
|
-
async function verifyAppConnectToken(token, options) {
|
|
1262
|
-
const segments = token.split(".");
|
|
1263
|
-
if (segments.length !== 3) {
|
|
1264
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token is malformed");
|
|
1265
|
-
}
|
|
1266
|
-
const [encodedHeader, encodedPayload, encodedSignature] = segments;
|
|
1267
|
-
const header = decodeJwtPart(encodedHeader);
|
|
1268
|
-
const payload = decodeJwtPart(encodedPayload);
|
|
1269
|
-
if (header.alg !== "ES256" || header.typ !== "JWT") {
|
|
1270
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token algorithm is unsupported");
|
|
1271
|
-
}
|
|
1272
|
-
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)) {
|
|
1273
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token claims are invalid");
|
|
1274
|
-
}
|
|
1275
|
-
const jwks = await getJwks(options.config.serverBaseUrl);
|
|
1276
|
-
const key = jwks.find((k) => k.kid === header.kid) ?? jwks[0];
|
|
1277
|
-
if (!key) {
|
|
1278
|
-
throw new LinkHttpError(503, "app_connect_jwks_unavailable", "App connect token key is unavailable");
|
|
1279
|
-
}
|
|
1280
|
-
const publicKey = createPublicKey({ key, format: "jwk" });
|
|
1281
|
-
const ok = verify(
|
|
1282
|
-
"sha256",
|
|
1283
|
-
Buffer.from(`${encodedHeader}.${encodedPayload}`),
|
|
1284
|
-
{ key: publicKey, dsaEncoding: "ieee-p1363" },
|
|
1285
|
-
Buffer.from(base64UrlToBase64(encodedSignature), "base64")
|
|
1286
|
-
);
|
|
1287
|
-
if (!ok) {
|
|
1288
|
-
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token signature is invalid");
|
|
1289
|
-
}
|
|
1290
|
-
return payload;
|
|
1291
|
-
}
|
|
1292
|
-
async function getJwks(serverBaseUrl) {
|
|
1293
|
-
if (cachedJwks && cachedJwks.expiresAt > Date.now()) return cachedJwks.keys;
|
|
1294
|
-
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}/api/v1/app-connect/jwks.json`, {
|
|
1295
|
-
headers: { accept: "application/json" }
|
|
1296
|
-
});
|
|
1297
|
-
if (!response.ok) {
|
|
1298
|
-
throw new LinkHttpError(503, "app_connect_jwks_unavailable", "Unable to load app connect JWKS");
|
|
1299
|
-
}
|
|
1300
|
-
const payload = await response.json();
|
|
1301
|
-
const keys = Array.isArray(payload.keys) ? payload.keys : [];
|
|
1302
|
-
cachedJwks = { keys, expiresAt: Date.now() + 5 * 60 * 1e3 };
|
|
1303
|
-
return keys;
|
|
1304
|
-
}
|
|
1305
|
-
function decodeJwtPart(value) {
|
|
1306
|
-
return JSON.parse(Buffer.from(base64UrlToBase64(value), "base64").toString("utf8"));
|
|
1307
|
-
}
|
|
1308
|
-
function base64UrlToBase64(value) {
|
|
1309
|
-
const n = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
1310
|
-
return n + "=".repeat((4 - n.length % 4) % 4);
|
|
1311
|
-
}
|
|
1312
1157
|
function readAppInstanceIdHeader(ctx) {
|
|
1313
1158
|
const value = ctx.get("x-hermes-app-instance-id").trim();
|
|
1314
1159
|
return /^appi_[A-Za-z0-9_-]{16,96}$/u.test(value) ? value : null;
|
|
@@ -1513,6 +1358,135 @@ function createStatisticsRouter(options) {
|
|
|
1513
1358
|
// src/http/routes/bootstrap.ts
|
|
1514
1359
|
import Router3 from "@koa/router";
|
|
1515
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
|
+
|
|
1516
1490
|
// src/network/topology.ts
|
|
1517
1491
|
import os5 from "os";
|
|
1518
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;
|
|
@@ -2237,7 +2211,7 @@ function createConversationsRouter(options) {
|
|
|
2237
2211
|
|
|
2238
2212
|
// src/http/routes/pairing.ts
|
|
2239
2213
|
import Router7 from "@koa/router";
|
|
2240
|
-
import
|
|
2214
|
+
import path7 from "path";
|
|
2241
2215
|
function readString4(body, ...keys) {
|
|
2242
2216
|
if (!body || typeof body !== "object") return null;
|
|
2243
2217
|
for (const key of keys) {
|
|
@@ -2264,10 +2238,10 @@ async function readJsonBody4(req) {
|
|
|
2264
2238
|
});
|
|
2265
2239
|
}
|
|
2266
2240
|
function pairingSessionPath(sessionId, paths) {
|
|
2267
|
-
return
|
|
2241
|
+
return path7.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.json`);
|
|
2268
2242
|
}
|
|
2269
2243
|
function pairingClaimPath(sessionId, paths) {
|
|
2270
|
-
return
|
|
2244
|
+
return path7.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
|
|
2271
2245
|
}
|
|
2272
2246
|
async function readPairingSession(sessionId, paths) {
|
|
2273
2247
|
const record = await readJsonFile(pairingSessionPath(sessionId, paths));
|
|
@@ -2312,35 +2286,18 @@ function createPairingRouter(options) {
|
|
|
2312
2286
|
if (!sessionId || !claimToken) {
|
|
2313
2287
|
throw new LinkHttpError(400, "pairing_claim_invalid", "session_id and claim_token are required");
|
|
2314
2288
|
}
|
|
2315
|
-
const [identity,
|
|
2289
|
+
const [identity, localSession] = await Promise.all([
|
|
2316
2290
|
loadIdentity(paths),
|
|
2317
|
-
loadConfig(paths),
|
|
2318
2291
|
readPairingSession(sessionId, paths)
|
|
2319
2292
|
]);
|
|
2320
2293
|
if (!identity?.link_id) throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
|
|
2321
2294
|
if (!localSession) throw new LinkHttpError(404, "pairing_session_not_found", "Pairing session was not found");
|
|
2322
2295
|
if (isPairingSessionExpired(localSession)) throw new LinkHttpError(404, "pairing_session_expired", "Pairing session has expired");
|
|
2323
2296
|
if (localSession.link_id !== identity.link_id) throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim does not match this Link");
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
const resp = await fetch(`${config.serverBaseUrl.replace(/\/+$/u, "")}/api/v1/link-pairings/${sessionId}/claim/verify`, {
|
|
2327
|
-
method: "POST",
|
|
2328
|
-
headers: { accept: "application/json", "content-type": "application/json" },
|
|
2329
|
-
body: JSON.stringify({ claim_token: claimToken, app_instance_id: readString4(body, "app_instance_id", "appInstanceId") ?? void 0 })
|
|
2330
|
-
});
|
|
2331
|
-
if (!resp.ok) {
|
|
2332
|
-
const errBody = await resp.json().catch(() => ({}));
|
|
2333
|
-
throw new LinkHttpError(resp.status, "pairing_claim_verify_failed", String(errBody.message ?? "Pairing claim verification failed"));
|
|
2334
|
-
}
|
|
2335
|
-
verified = await resp.json();
|
|
2336
|
-
} catch (err) {
|
|
2337
|
-
if (err instanceof LinkHttpError) throw err;
|
|
2338
|
-
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");
|
|
2339
2299
|
}
|
|
2340
|
-
|
|
2341
|
-
throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim does not match this Link");
|
|
2342
|
-
}
|
|
2343
|
-
const appInstanceId = typeof verified.appInstanceId === "string" ? verified.appInstanceId : null;
|
|
2300
|
+
const appInstanceId = readString4(body, "app_instance_id", "appInstanceId");
|
|
2344
2301
|
const deviceLabel = readString4(body, "device_label", "deviceLabel") ?? "HermesPilot App";
|
|
2345
2302
|
const devicePlatform = readString4(body, "device_platform", "devicePlatform") ?? "unknown";
|
|
2346
2303
|
const session = await createDeviceSession(
|
|
@@ -2598,9 +2555,9 @@ function openSqliteDatabase(filePath, options = {}) {
|
|
|
2598
2555
|
|
|
2599
2556
|
// src/storage/link-database.ts
|
|
2600
2557
|
import { mkdir as mkdir5 } from "fs/promises";
|
|
2601
|
-
import
|
|
2558
|
+
import path8 from "path";
|
|
2602
2559
|
async function initLinkDatabase(paths) {
|
|
2603
|
-
await mkdir5(
|
|
2560
|
+
await mkdir5(path8.dirname(paths.databaseFile), { recursive: true, mode: 448 });
|
|
2604
2561
|
const db = openDb(paths);
|
|
2605
2562
|
try {
|
|
2606
2563
|
db.exec(`
|
|
@@ -2696,35 +2653,35 @@ function openDb(paths) {
|
|
|
2696
2653
|
|
|
2697
2654
|
// src/conversations/service.ts
|
|
2698
2655
|
import { EventEmitter as EventEmitter2 } from "events";
|
|
2699
|
-
import
|
|
2656
|
+
import crypto4 from "crypto";
|
|
2700
2657
|
|
|
2701
2658
|
// src/conversations/store.ts
|
|
2702
2659
|
import { mkdir as mkdir6, readFile as readFile4, rm as rm4, writeFile as writeFile2 } from "fs/promises";
|
|
2703
|
-
import
|
|
2704
|
-
import
|
|
2660
|
+
import path9 from "path";
|
|
2661
|
+
import crypto2 from "crypto";
|
|
2705
2662
|
function createConversationId() {
|
|
2706
|
-
return `conv_${
|
|
2663
|
+
return `conv_${crypto2.randomUUID().replaceAll("-", "")}`;
|
|
2707
2664
|
}
|
|
2708
2665
|
function createMessageId() {
|
|
2709
|
-
return `msg_${
|
|
2666
|
+
return `msg_${crypto2.randomUUID().replaceAll("-", "")}`;
|
|
2710
2667
|
}
|
|
2711
2668
|
function createRunId() {
|
|
2712
|
-
return `run_${
|
|
2669
|
+
return `run_${crypto2.randomUUID().replaceAll("-", "")}`;
|
|
2713
2670
|
}
|
|
2714
2671
|
function conversationDir(paths, conversationId) {
|
|
2715
|
-
return
|
|
2672
|
+
return path9.join(paths.conversationsDir, conversationId);
|
|
2716
2673
|
}
|
|
2717
2674
|
function manifestPath(paths, conversationId) {
|
|
2718
|
-
return
|
|
2675
|
+
return path9.join(conversationDir(paths, conversationId), "manifest.json");
|
|
2719
2676
|
}
|
|
2720
2677
|
function snapshotPath(paths, conversationId) {
|
|
2721
|
-
return
|
|
2678
|
+
return path9.join(conversationDir(paths, conversationId), "snapshot.json");
|
|
2722
2679
|
}
|
|
2723
2680
|
function eventsPath(paths, conversationId) {
|
|
2724
|
-
return
|
|
2681
|
+
return path9.join(conversationDir(paths, conversationId), "events.json");
|
|
2725
2682
|
}
|
|
2726
2683
|
function blobPath(paths, blobId) {
|
|
2727
|
-
return
|
|
2684
|
+
return path9.join(paths.blobsDir, blobId);
|
|
2728
2685
|
}
|
|
2729
2686
|
async function readJson(filePath, fallback) {
|
|
2730
2687
|
try {
|
|
@@ -2735,7 +2692,7 @@ async function readJson(filePath, fallback) {
|
|
|
2735
2692
|
}
|
|
2736
2693
|
}
|
|
2737
2694
|
async function writeJson(filePath, data) {
|
|
2738
|
-
await mkdir6(
|
|
2695
|
+
await mkdir6(path9.dirname(filePath), { recursive: true, mode: 448 });
|
|
2739
2696
|
await writeFile2(filePath, JSON.stringify(data), { mode: 384 });
|
|
2740
2697
|
}
|
|
2741
2698
|
function assertValidConversationId(id) {
|
|
@@ -2792,8 +2749,8 @@ async function listConversationIds(paths) {
|
|
|
2792
2749
|
|
|
2793
2750
|
// src/conversations/blobs.ts
|
|
2794
2751
|
import { mkdir as mkdir7, readFile as readFile5, rm as rm5, writeFile as writeFile3 } from "fs/promises";
|
|
2795
|
-
import
|
|
2796
|
-
import
|
|
2752
|
+
import path10 from "path";
|
|
2753
|
+
import crypto3 from "crypto";
|
|
2797
2754
|
var MAX_BLOB_UPLOAD_BYTES2 = 50 * 1024 * 1024;
|
|
2798
2755
|
function blobManifestPath(paths, blobId) {
|
|
2799
2756
|
return `${blobPath(paths, blobId)}.json`;
|
|
@@ -2801,7 +2758,7 @@ function blobManifestPath(paths, blobId) {
|
|
|
2801
2758
|
function normalizeMime(mime, filename) {
|
|
2802
2759
|
if (mime && mime !== "application/octet-stream") return mime;
|
|
2803
2760
|
if (filename) {
|
|
2804
|
-
const ext =
|
|
2761
|
+
const ext = path10.extname(filename).toLowerCase();
|
|
2805
2762
|
const mimeMap = {
|
|
2806
2763
|
".jpg": "image/jpeg",
|
|
2807
2764
|
".jpeg": "image/jpeg",
|
|
@@ -2821,7 +2778,7 @@ function normalizeMime(mime, filename) {
|
|
|
2821
2778
|
}
|
|
2822
2779
|
function sanitizeFilename(filename, fallback) {
|
|
2823
2780
|
if (!filename) return fallback;
|
|
2824
|
-
return
|
|
2781
|
+
return path10.basename(filename).replace(/[^\w.\-]/gu, "_").slice(0, 255) || fallback;
|
|
2825
2782
|
}
|
|
2826
2783
|
async function writeBlob(paths, conversationId, input) {
|
|
2827
2784
|
assertValidConversationId(conversationId);
|
|
@@ -2831,9 +2788,9 @@ async function writeBlob(paths, conversationId, input) {
|
|
|
2831
2788
|
if (input.bytes.byteLength > MAX_BLOB_UPLOAD_BYTES2) {
|
|
2832
2789
|
throw new LinkHttpError(413, "blob_too_large", "Blob is too large");
|
|
2833
2790
|
}
|
|
2834
|
-
const id = `blob_${
|
|
2791
|
+
const id = `blob_${crypto3.randomUUID().replaceAll("-", "")}`;
|
|
2835
2792
|
const filePath = blobPath(paths, id);
|
|
2836
|
-
await mkdir7(
|
|
2793
|
+
await mkdir7(path10.dirname(filePath), { recursive: true, mode: 448 });
|
|
2837
2794
|
await writeFile3(filePath, input.bytes, { mode: 384 });
|
|
2838
2795
|
const blob = {
|
|
2839
2796
|
id,
|
|
@@ -3206,7 +3163,7 @@ var ConversationService = class extends EventEmitter2 {
|
|
|
3206
3163
|
return { deleted_count: count };
|
|
3207
3164
|
}
|
|
3208
3165
|
async prepareClearAllConversationPlan() {
|
|
3209
|
-
const planId = `plan_${
|
|
3166
|
+
const planId = `plan_${crypto4.randomUUID().replaceAll("-", "")}`;
|
|
3210
3167
|
const ids = await listConversationIds(this.paths);
|
|
3211
3168
|
const activeIds = [];
|
|
3212
3169
|
for (const id of ids) {
|
|
@@ -3284,7 +3241,7 @@ import { promisify as promisify3 } from "util";
|
|
|
3284
3241
|
|
|
3285
3242
|
// src/hermes/config.ts
|
|
3286
3243
|
import os6 from "os";
|
|
3287
|
-
import
|
|
3244
|
+
import path11 from "path";
|
|
3288
3245
|
import YAML from "yaml";
|
|
3289
3246
|
var PROFILE_PERMISSION_TOOLSETS = [
|
|
3290
3247
|
{
|
|
@@ -3414,19 +3371,19 @@ var profileApiServerPortAssignmentQueue = Promise.resolve();
|
|
|
3414
3371
|
function resolveHermesProfilesDir() {
|
|
3415
3372
|
const hermesHome = process.env.HERMES_HOME?.trim();
|
|
3416
3373
|
if (hermesHome) {
|
|
3417
|
-
return
|
|
3418
|
-
resolveDefaultHermesRoot(
|
|
3374
|
+
return path11.join(
|
|
3375
|
+
resolveDefaultHermesRoot(path11.resolve(hermesHome)),
|
|
3419
3376
|
"profiles"
|
|
3420
3377
|
);
|
|
3421
3378
|
}
|
|
3422
|
-
return
|
|
3379
|
+
return path11.join(os6.homedir(), ".hermes", "profiles");
|
|
3423
3380
|
}
|
|
3424
3381
|
function isValidProfileName(value) {
|
|
3425
3382
|
return typeof value === "string" && /^[a-zA-Z0-9._-]{1,64}$/.test(value);
|
|
3426
3383
|
}
|
|
3427
3384
|
function resolveDefaultHermesRoot(hermesHome) {
|
|
3428
|
-
if (
|
|
3429
|
-
return
|
|
3385
|
+
if (path11.basename(path11.dirname(hermesHome)) === "profiles") {
|
|
3386
|
+
return path11.dirname(path11.dirname(hermesHome));
|
|
3430
3387
|
}
|
|
3431
3388
|
return hermesHome;
|
|
3432
3389
|
}
|
|
@@ -3638,7 +3595,6 @@ export {
|
|
|
3638
3595
|
LINK_VERSION,
|
|
3639
3596
|
DAEMON_LOG_FILE,
|
|
3640
3597
|
resolveRuntimePaths,
|
|
3641
|
-
readJsonFile,
|
|
3642
3598
|
writeJsonFile,
|
|
3643
3599
|
loadConfig,
|
|
3644
3600
|
saveConfig,
|
|
@@ -3655,5 +3611,6 @@ export {
|
|
|
3655
3611
|
readRecentLogEntries,
|
|
3656
3612
|
readRecentGatewayLogEntries,
|
|
3657
3613
|
bootstrapWithRelay,
|
|
3614
|
+
generateAppConnectToken,
|
|
3658
3615
|
startLinkService
|
|
3659
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