dsh-plugin-mobile-link 0.6.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -13
- package/cordis.patch.yml +11 -0
- package/lib/cli.js +3 -4
- package/lib/client.js +560 -75
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +33 -1
- package/lib/enroll.js +249 -35
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +23 -1
- package/lib/history/compress.js +40 -0
- package/lib/history/coverage.js +21 -0
- package/lib/history/fold-summary.js +472 -0
- package/lib/history/fold.js +184 -0
- package/lib/history/session-snapshot.js +75 -24
- package/lib/history/summary-window.js +168 -0
- package/lib/history.js +171 -9
- package/lib/index.js +38 -2
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +238 -11
- package/lib/logs.js +242 -0
- package/lib/pairing.js +1 -1
- package/lib/router.js +171 -5
- package/lib/routes.js +147 -0
- package/lib/session-digest.js +73 -4
- package/lib/state.js +6 -6
- package/lib/status.js +30 -2
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_link/dirListing`:`workspaceFiles/list` 的同形增强副本,本机补 `mtimeMs`。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要它:宿主 `workspaceFiles/list` 的条目只有 `name/type/size?`(宿主类型见
|
|
5
|
+
* `@deepseek-ai/dsh-api-workspace-files` 的 `WorkspaceDirectoryEntry`),
|
|
6
|
+
* 没有任何时间字段;`workspaceFiles/stat` 的 `version` 是 opaque token(类型注释明确
|
|
7
|
+
* "never parsed"),推不出时间。用户要的是"哪个文件最近被动过",所以由电脑侧补。
|
|
8
|
+
*
|
|
9
|
+
* 三条纪律(与 `session-digest.js` 同款):
|
|
10
|
+
* 1. **清单的所有者仍是宿主**:连接器只往条目上加一个字段,不删、不改、不重排;
|
|
11
|
+
* 宿主报错时错误信封逐字透传。
|
|
12
|
+
* 2. **拿不到就原样返回**:root 缺失/相对/越界、单条目 stat 失败、超出总预算——
|
|
13
|
+
* 一律退回未增强的原始清单。装饰永不弄坏清单,目录本身一定能打开。
|
|
14
|
+
* 3. **不提速、不重试**:一个条目最多 stat 一次;没有缓存(mtime 会变,缓存就要失效
|
|
15
|
+
* 逻辑,而列目录本来就是低频动作)。
|
|
16
|
+
*
|
|
17
|
+
* 符号链接用 `fs.stat`(跟随链接)报**目标**的时间——宿主列的 `type`/`size` 本来就是
|
|
18
|
+
* 按目标解析的(类型注释:"A symlink reports the type of its destination"),
|
|
19
|
+
* `lstat` 报的链接自身时间只在重指时变化,回答不了"内容什么时候改的"。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { stat } from 'node:fs/promises'
|
|
23
|
+
import { isAbsolute, resolve, sep } from 'node:path'
|
|
24
|
+
|
|
25
|
+
/** 方法名(设备发起;`_link/` 前缀 = 连接器自答,永不转发宿主)。 */
|
|
26
|
+
export const DIR_LISTING_METHOD = '_link/dirListing'
|
|
27
|
+
|
|
28
|
+
/** 宿主的清单方法:连接器只读它,不改它。 */
|
|
29
|
+
const HOST_LIST_METHOD = 'workspaceFiles/list'
|
|
30
|
+
|
|
31
|
+
/** 可调的上限。`statBudgetMs` 是全部条目补时间的总预算(见 §2.2 失败语义表)。 */
|
|
32
|
+
export const LIMITS = Object.freeze({
|
|
33
|
+
statBudgetMs: 2000,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export function isDirListingMethod(method) {
|
|
37
|
+
return method === DIR_LISTING_METHOD
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 给一份宿主目录清单补上 `mtimeMs`。
|
|
42
|
+
*
|
|
43
|
+
* 认两种输入形状(与 `digestSessionList` 同款):宿主信封 `{ ok, value: { entries } }`
|
|
44
|
+
* 以及直接给的 `{ entries }`。**返回的始终是同形状的值**,调用方不必再猜信封在哪一层。
|
|
45
|
+
* 结构不是预期的样子就原样返回入参——装饰不许把"看不懂"变成"改掉"。
|
|
46
|
+
*
|
|
47
|
+
* @param {object} options
|
|
48
|
+
* @param {{ rpc: (method: string, args: object) => Promise<unknown> }} options.dsh 宿主 RPC
|
|
49
|
+
* @param {object} options.args 设备发来的参数(`workspaceFileScopeId` / `path` / `root`)
|
|
50
|
+
* @param {{ debug?: (message: string) => void, warn?: (message: string) => void }} [options.logger]
|
|
51
|
+
* @param {(path: string) => Promise<{ mtimeMs: number }>} [options.statImpl] 可注入,供单测
|
|
52
|
+
* @param {number} [options.budgetMs] 总预算,缺省 `LIMITS.statBudgetMs`
|
|
53
|
+
* @returns {Promise<unknown>} 增强后的同形值;降级时是原始值
|
|
54
|
+
*/
|
|
55
|
+
export async function dirListing({ dsh, args, logger, statImpl = stat, budgetMs = LIMITS.statBudgetMs }) {
|
|
56
|
+
const request = args ?? {}
|
|
57
|
+
const result = await dsh.rpc(HOST_LIST_METHOD, {
|
|
58
|
+
workspaceFileScopeId: request.workspaceFileScopeId,
|
|
59
|
+
path: typeof request.path === 'string' ? request.path : '.',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const entries = listingEntries(result)
|
|
63
|
+
if (entries === null) {
|
|
64
|
+
logger?.debug?.('dir-listing: host answered a shape this connector does not decorate')
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const directory = resolveDirectory(request.root, entries.path)
|
|
69
|
+
if (directory === null) {
|
|
70
|
+
// root 缺失 / 非绝对 / 宿主回的 path 越出 root:不是错误,只是补不了时间。
|
|
71
|
+
logger?.debug?.(`dir-listing: cannot resolve the listed directory from root ${JSON.stringify(request.root)}`)
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const decorated = await withBudget(
|
|
76
|
+
Promise.all(entries.items.map((entry) => decorate(entry, directory, statImpl))),
|
|
77
|
+
budgetMs
|
|
78
|
+
)
|
|
79
|
+
if (decorated === null) {
|
|
80
|
+
logger?.warn?.(`dir-listing: stat budget of ${budgetMs}ms ran out, answering the plain listing`)
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
return replaceEntries(result, decorated)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 取出清单本体(`{ entries: [...], path }`),形状不对时返回 null。
|
|
88
|
+
*
|
|
89
|
+
* 信封可能是 `result` 本身,也可能是 `result.value`——按"哪一层有 entries 数组"判定,
|
|
90
|
+
* 与 `digestSessionList` 同一读法。
|
|
91
|
+
*/
|
|
92
|
+
function listingEntries(result) {
|
|
93
|
+
if (result === null || typeof result !== 'object') return null
|
|
94
|
+
if (result.ok !== true) return null
|
|
95
|
+
const container = Array.isArray(result.entries) ? result : result.value
|
|
96
|
+
if (container === null || typeof container !== 'object' || !Array.isArray(container.entries)) return null
|
|
97
|
+
return { path: container.path, items: container.entries }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 按原形状放回增强后的条目数组。 */
|
|
101
|
+
function replaceEntries(result, items) {
|
|
102
|
+
if (Array.isArray(result.entries)) return { ...result, entries: items }
|
|
103
|
+
return { ...result, value: { ...result.value, entries: items } }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 宿主回的 `path` 词法解析成绝对目录;解析不了(要求降级)时返回 null。
|
|
108
|
+
*
|
|
109
|
+
* - `path` 可以是相对的(`""` 表根、`"docs"`)也可以是绝对的(App/宿主都有先例)。
|
|
110
|
+
* - 结果必须位于 root 之内:`resolved === root || resolved.startsWith(root + sep)`。
|
|
111
|
+
* 只比 `startsWith(root)` 会把 `/a/b-sibling` 当成 `/a/b` 的子路径。
|
|
112
|
+
* - **不做 realpath 越界拦截**(设计 §2.2 决策 1):符号链接的目标可以合法地在工作区
|
|
113
|
+
* 之外,宿主今天已经按目标报 `type`/`size`,这里保持同一语义。
|
|
114
|
+
*/
|
|
115
|
+
function resolveDirectory(root, echoedPath) {
|
|
116
|
+
if (typeof root !== 'string' || !isAbsolute(root)) return null
|
|
117
|
+
const base = resolve(root)
|
|
118
|
+
const echoed = typeof echoedPath === 'string' ? echoedPath : ''
|
|
119
|
+
const resolved = isAbsolute(echoed) ? resolve(echoed) : resolve(base, echoed)
|
|
120
|
+
if (resolved !== base && !resolved.startsWith(base + sep)) return null
|
|
121
|
+
return resolved
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 一条条目:stat 成功且时间可用就加 `mtimeMs`,其余情况原样返回。
|
|
126
|
+
*
|
|
127
|
+
* 条目名逐段校验:含 `/`、`\` 或等于 `.` / `..` 的名字直接跳过(宿主契约说是 basename,
|
|
128
|
+
* 这里是纵深防御,防的是异常回包,不是防用户)。跳过的意思是"不给它补时间",
|
|
129
|
+
* **不是把条目从清单里删掉**——清单的所有者是宿主。
|
|
130
|
+
*/
|
|
131
|
+
async function decorate(entry, directory, statImpl) {
|
|
132
|
+
if (entry === null || typeof entry !== 'object') return entry
|
|
133
|
+
if (typeof entry.name !== 'string' || !isPlainName(entry.name)) return entry
|
|
134
|
+
try {
|
|
135
|
+
const info = await statImpl(resolve(directory, entry.name))
|
|
136
|
+
const mtimeMs = info?.mtimeMs
|
|
137
|
+
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return entry
|
|
138
|
+
const rounded = Math.round(mtimeMs)
|
|
139
|
+
if (!Number.isFinite(rounded)) return entry
|
|
140
|
+
return { ...entry, mtimeMs: rounded }
|
|
141
|
+
} catch {
|
|
142
|
+
// 断链、权限、竞态删除:这个条目没有时间,其余条目照旧。
|
|
143
|
+
return entry
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isPlainName(name) {
|
|
148
|
+
return name.length > 0 && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 给整批 stat 一个总预算。超时返回 `null`(调用方据此降级),
|
|
153
|
+
* **不取消**后台的 stat——它们会自然收尾,取消反而要给每条挂一个 signal。
|
|
154
|
+
*/
|
|
155
|
+
async function withBudget(work, budgetMs) {
|
|
156
|
+
if (!(budgetMs > 0)) return await work
|
|
157
|
+
let timer
|
|
158
|
+
const expiry = new Promise((resolve) => {
|
|
159
|
+
timer = setTimeout(() => resolve(null), budgetMs)
|
|
160
|
+
})
|
|
161
|
+
try {
|
|
162
|
+
const outcome = await Promise.race([work, expiry])
|
|
163
|
+
return outcome === null ? null : outcome
|
|
164
|
+
} finally {
|
|
165
|
+
clearTimeout(timer)
|
|
166
|
+
}
|
|
167
|
+
}
|
package/lib/dlp.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* Wire contract: docs/RELAY-PROTOCOL.md §3.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { repairLoneSurrogatesWithCount } from './unicode.js'
|
|
10
|
+
|
|
9
11
|
/** Frame ceiling aligned with DSH's image-attachment limit (spec §5). */
|
|
10
12
|
export const MAX_FRAME_BYTES = 32 * 1024 * 1024
|
|
11
13
|
|
|
@@ -40,8 +42,23 @@ export const RELAY_TO_AGENT_CONTROL = new Set(['fsGetCancel'])
|
|
|
40
42
|
|
|
41
43
|
const ID_FRAMES = new Set(['req', 'open', 'cancel', 'res', 'item', 'end', 'streamError', 'eventResult'])
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 出站帧的唯一序列化出口。
|
|
47
|
+
*
|
|
48
|
+
* 为什么要过一遍代理项修复:宿主会给我们**截断过**的预览(`turnOutline` 的
|
|
49
|
+
* prompt/response 截到 50/120 个码元),截断点落在 emoji 中间时字符串里就有
|
|
50
|
+
* 落单代理项,`JSON.stringify` 会忠实写成 `"\ud83d…"`。中转能把它修回 U+FFFD,
|
|
51
|
+
* 但解码方(iOS `JSONDecoder`)遇到这种转义会整条拒绝,所以生产端先把帧修好。
|
|
52
|
+
*/
|
|
43
53
|
export function encodeFrame(frame) {
|
|
44
|
-
return
|
|
54
|
+
return encodeFrameChecked(frame).text
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `encodeFrame` 的带诊断版本:`repaired` 是这条帧里修掉的落单代理项条数。 */
|
|
58
|
+
export function encodeFrameChecked(frame) {
|
|
59
|
+
const raw = JSON.stringify(frame)
|
|
60
|
+
const { text, repaired } = repairLoneSurrogatesWithCount(raw)
|
|
61
|
+
return { text, repaired }
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
/**
|
|
@@ -176,6 +193,21 @@ export function pairCodeRegenerateEndpoint(relayUrl) {
|
|
|
176
193
|
return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'pair/code/regenerate')
|
|
177
194
|
}
|
|
178
195
|
|
|
196
|
+
/** List every paired device for this agent (`GET /devices`, agent secret). */
|
|
197
|
+
export function deviceListEndpoint(relayUrl) {
|
|
198
|
+
return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'devices')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Revoke one paired device for this agent (`POST /devices/revoke`, agent secret). */
|
|
202
|
+
export function deviceRevokeEndpoint(relayUrl) {
|
|
203
|
+
return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'devices/revoke')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Backfill the machine MAC for an already enrolled agent (`POST /agents/machine`). */
|
|
207
|
+
export function agentMachineEndpoint(relayUrl) {
|
|
208
|
+
return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'agents/machine')
|
|
209
|
+
}
|
|
210
|
+
|
|
179
211
|
/** The relay's self-service enrollment endpoint (`POST /agents/enroll`). */
|
|
180
212
|
export function enrollEndpoint(relayUrl) {
|
|
181
213
|
return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'agents/enroll')
|
package/lib/enroll.js
CHANGED
|
@@ -1,24 +1,136 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* First-run enrollment: turn
|
|
2
|
+
* First-run enrollment: turn a relay address into a durable agent identity.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* So the relay grew `POST /agents/enroll` (relay/api.py), which redeems a
|
|
10
|
-
* one-time invite code for a fresh `agentId` / `agentSecret`. This module is the
|
|
11
|
-
* connector half: one call, then the identity is written to the same
|
|
4
|
+
* An identity used to require an invite code from the relay operator. Enrollment
|
|
5
|
+
* is now open: `POST /agents/enroll` (relay-go/internal/httpapi) creates the
|
|
6
|
+
* account and computer, rate-limited by source. This module is the connector
|
|
7
|
+
* half: one call, then the identity is written to the same
|
|
12
8
|
* `~/.dsh/mobile-link/agent.json` (mode 0600) that `agent-register` writes, so
|
|
13
9
|
* nothing downstream can tell the two apart.
|
|
14
10
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* The optional `inviteCode` path remains only as a compatibility shim for old
|
|
12
|
+
* deployments and old callers; new installs do not use it.
|
|
17
13
|
*/
|
|
18
14
|
|
|
19
|
-
import {
|
|
15
|
+
import { execFileSync } from 'node:child_process'
|
|
16
|
+
import { hostname, networkInterfaces } from 'node:os'
|
|
17
|
+
|
|
18
|
+
import { agentMachineEndpoint, enrollEndpoint, isPlaceholderRelayUrl, normalizeRelayUrl } from './dlp.js'
|
|
20
19
|
import { DEFAULT_AGENT_NAME, INVITE_ENV, defaultStatePath, readState, writeState } from './state.js'
|
|
21
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Normalize a MAC-48 address to uppercase colon form.
|
|
23
|
+
* Returns '' for anything that is not a MAC.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeMacAddress(value) {
|
|
26
|
+
const compact = String(value ?? '').trim().replace(/[-.:\s]/g, '').toUpperCase()
|
|
27
|
+
if (!/^[0-9A-F]{12}$/.test(compact)) return ''
|
|
28
|
+
return compact.match(/.{2}/g).join(':')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pick the computer's primary physical interface MAC.
|
|
33
|
+
*
|
|
34
|
+
* This is an *identifier*, not an authenticator: a MAC can be spoofed. It is
|
|
35
|
+
* used only so the relay can associate repeat enrollments of the same machine.
|
|
36
|
+
* Virtual/VPN interfaces are skipped (they change or reflect tunnels), and
|
|
37
|
+
* common primary interface names win over the rest. If nothing is found the
|
|
38
|
+
* field is omitted rather than inventing a value.
|
|
39
|
+
*/
|
|
40
|
+
export function primaryMacAddress(interfaces) {
|
|
41
|
+
// macOS 的 Node 经常只拿到 02:00:00:00:00:00(系统隐私/沙箱占位),
|
|
42
|
+
// 而 networksetup 仍能读到真实硬件地址;优先向它要,读不到再走接口枚举。
|
|
43
|
+
if (interfaces === undefined && process.platform === 'darwin') {
|
|
44
|
+
const mac = darwinMacAddress()
|
|
45
|
+
if (mac) return mac
|
|
46
|
+
}
|
|
47
|
+
const virtual = /^(lo|docker|veth|br-|virbr|vmnet|vboxnet|utun|tun|tap|wg|tailscale|zt|bridge|awdl|llw)/i
|
|
48
|
+
const preferred = /^(en0|eth0|wlan0|wi-?fi|ethernet)$/i
|
|
49
|
+
const seen = new Set()
|
|
50
|
+
const candidates = []
|
|
51
|
+
for (const [name, entries] of Object.entries(interfaces ?? networkInterfaces())) {
|
|
52
|
+
if (!entries || virtual.test(name)) continue
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!entry || entry.internal) continue
|
|
55
|
+
const mac = normalizeMacAddress(entry.mac)
|
|
56
|
+
if (!mac || mac === '00:00:00:00:00:00' || mac === '02:00:00:00:00:00' || seen.has(mac)) continue
|
|
57
|
+
seen.add(mac)
|
|
58
|
+
candidates.push({ name, mac, preferred: preferred.test(name) ? 0 : 1 })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
candidates.sort((a, b) => a.preferred - b.preferred || a.name.localeCompare(b.name))
|
|
62
|
+
return candidates[0]?.mac ?? ''
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// macOS 的硬件端口表是唯一能稳定拿到真实 MAC 的入口:Node 的
|
|
66
|
+
/// `os.networkInterfaces()` 在部分系统上被隐私层替换成 02:00:00:00:00:00。
|
|
67
|
+
function darwinMacAddress() {
|
|
68
|
+
const activeDevice = darwinDefaultInterface()
|
|
69
|
+
let output
|
|
70
|
+
try {
|
|
71
|
+
output = execFileSync('/usr/sbin/networksetup', ['-listallhardwareports'], {
|
|
72
|
+
encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
73
|
+
})
|
|
74
|
+
} catch {
|
|
75
|
+
return ''
|
|
76
|
+
}
|
|
77
|
+
const blocks = []
|
|
78
|
+
let current = {}
|
|
79
|
+
const flush = () => {
|
|
80
|
+
if (current.mac) blocks.push(current)
|
|
81
|
+
current = {}
|
|
82
|
+
}
|
|
83
|
+
for (const line of output.split('\n')) {
|
|
84
|
+
const match = line.match(/^\s*(Hardware Port|Device|Ethernet Address):\s*(.+?)\s*$/)
|
|
85
|
+
if (!match) continue
|
|
86
|
+
if (match[1] === 'Hardware Port') {
|
|
87
|
+
flush()
|
|
88
|
+
current.port = match[2]
|
|
89
|
+
}
|
|
90
|
+
if (match[1] === 'Device') current.device = match[2]
|
|
91
|
+
if (match[1] === 'Ethernet Address') current.mac = normalizeMacAddress(match[2])
|
|
92
|
+
}
|
|
93
|
+
flush()
|
|
94
|
+
const active = blocks.find((block) => block.device === activeDevice)
|
|
95
|
+
if (active?.mac) return active.mac
|
|
96
|
+
const preferred = /(wi-?fi|ethernet|lan)/i
|
|
97
|
+
const chosen = blocks.find((block) => preferred.test(block.port ?? block.device ?? ''))
|
|
98
|
+
return chosen?.mac ?? blocks[0]?.mac ?? ''
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// 默认路由对应的接口:优先用它,避免机器同时插网线和 Wi-Fi 时选到未使用的网口。
|
|
102
|
+
function darwinDefaultInterface() {
|
|
103
|
+
try {
|
|
104
|
+
const output = execFileSync('/sbin/route', ['-n', 'get', 'default'], {
|
|
105
|
+
encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
106
|
+
})
|
|
107
|
+
return output.match(/^\s*interface:\s*(\S+)/m)?.[1] ?? ''
|
|
108
|
+
} catch {
|
|
109
|
+
return ''
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A failed enrollment, tagged with a machine-readable code.
|
|
115
|
+
*
|
|
116
|
+
* The CLI prints `.message`; the panel maps `.code` to Chinese copy. Keeping both
|
|
117
|
+
* on one error is deliberate: the relay's three invite failures need different
|
|
118
|
+
* advice, and the UI must not re-parse English text to find out which one it got.
|
|
119
|
+
*/
|
|
120
|
+
export class EnrollError extends Error {
|
|
121
|
+
constructor(code, message, { status, retryable = false, stateFile } = {}) {
|
|
122
|
+
super(message)
|
|
123
|
+
this.name = 'EnrollError'
|
|
124
|
+
this.code = code
|
|
125
|
+
this.status = status
|
|
126
|
+
this.retryable = Boolean(retryable)
|
|
127
|
+
if (stateFile) this.stateFile = stateFile
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** How long a relay may take to answer an enrollment before it is called stuck. */
|
|
132
|
+
export const ENROLL_TIMEOUT_MS = 15000
|
|
133
|
+
|
|
22
134
|
/** Where the invite code may come from: explicit argument, then environment. */
|
|
23
135
|
export function inviteFrom({ inviteCode, env = process.env } = {}) {
|
|
24
136
|
const value = typeof inviteCode === 'string' && inviteCode.trim() ? inviteCode : env?.[INVITE_ENV]
|
|
@@ -40,7 +152,7 @@ export async function enrollPlan({ stateFile, inviteCode, relayUrl, env = proces
|
|
|
40
152
|
}
|
|
41
153
|
const invite = inviteFrom({ inviteCode, env })
|
|
42
154
|
const relay = relayUrl || env?.DSH_MOBILE_LINK_RELAY || ''
|
|
43
|
-
return { needed: true, stateFile: path, invite, relayUrl: relay, canEnroll: Boolean(
|
|
155
|
+
return { needed: true, stateFile: path, invite, relayUrl: relay, canEnroll: Boolean(relay) }
|
|
44
156
|
}
|
|
45
157
|
|
|
46
158
|
/**
|
|
@@ -58,55 +170,156 @@ export async function enrollPlan({ stateFile, inviteCode, relayUrl, env = proces
|
|
|
58
170
|
* @returns {Promise<{agentId: string, agentSecret: string, agentName: string, relayUrl: string, stateFile: string}>}
|
|
59
171
|
*/
|
|
60
172
|
export async function enrollAgent({
|
|
61
|
-
relayUrl, inviteCode, name, stateFile, deviceName,
|
|
62
|
-
fetchImpl = fetch, logger = undefined,
|
|
173
|
+
relayUrl, inviteCode, name, stateFile, deviceName, macAddress,
|
|
174
|
+
fetchImpl = fetch, logger = undefined, timeoutMs = ENROLL_TIMEOUT_MS,
|
|
63
175
|
} = {}) {
|
|
64
|
-
|
|
65
|
-
throw new Error('mobile-link: an invite code is required to enroll (--invite <code>)')
|
|
66
|
-
}
|
|
176
|
+
const invite = typeof inviteCode === 'string' ? inviteCode.trim() : ''
|
|
67
177
|
if (!relayUrl || !String(relayUrl).trim()) {
|
|
68
|
-
throw new
|
|
178
|
+
throw new EnrollError('enroll/bad-request', 'mobile-link: a relay URL is required to enroll (--relay <url>)')
|
|
179
|
+
}
|
|
180
|
+
// Validate before the network call: a typo in the relay address should not
|
|
181
|
+
// look like a rejected invite, and the placeholder shipped in the repository
|
|
182
|
+
// must never be dialed as if it were a deployment.
|
|
183
|
+
try {
|
|
184
|
+
normalizeRelayUrl(relayUrl)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new EnrollError('enroll/invalid-relay',
|
|
187
|
+
`mobile-link: invalid relay URL ${JSON.stringify(relayUrl)}: ${messageOf(error)}`)
|
|
69
188
|
}
|
|
189
|
+
if (isPlaceholderRelayUrl(relayUrl)) {
|
|
190
|
+
throw new EnrollError('enroll/placeholder-relay',
|
|
191
|
+
'mobile-link: the relay address is the repository placeholder; use the address your provider gave you')
|
|
192
|
+
}
|
|
193
|
+
|
|
70
194
|
const path = stateFile || defaultStatePath()
|
|
71
|
-
const agentName = (name || deviceName || '').trim() || DEFAULT_AGENT_NAME
|
|
195
|
+
const agentName = (name || deviceName || '').trim() || hostname().trim() || DEFAULT_AGENT_NAME
|
|
196
|
+
const machineMac = normalizeMacAddress(macAddress ?? primaryMacAddress())
|
|
197
|
+
const waitMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
|
|
198
|
+
? Number(timeoutMs)
|
|
199
|
+
: ENROLL_TIMEOUT_MS
|
|
200
|
+
|
|
201
|
+
// `Promise.race` rather than relying on the signal alone: an injected fetch
|
|
202
|
+
// (or a runtime that does not honour AbortSignal) must still surface a
|
|
203
|
+
// timeout instead of leaving the panel on "登记中" forever.
|
|
204
|
+
const controller = new AbortController()
|
|
205
|
+
let timer
|
|
206
|
+
const timeout = new Promise((_, reject) => {
|
|
207
|
+
timer = setTimeout(() => {
|
|
208
|
+
controller.abort()
|
|
209
|
+
reject(new EnrollError('enroll/timeout',
|
|
210
|
+
`mobile-link: the relay did not answer within ${waitMs} ms`, { retryable: true }))
|
|
211
|
+
}, waitMs)
|
|
212
|
+
// Deliberately NOT unref'd: if an injected fetch holds no handle, an unref'd
|
|
213
|
+
// timer lets Node decide the event loop is empty and exit with the promise
|
|
214
|
+
// still pending (caught by the Linux L2 run). The timer is cleared on every
|
|
215
|
+
// settled fetch, so it only keeps the process alive while a request is
|
|
216
|
+
// genuinely outstanding.
|
|
217
|
+
})
|
|
72
218
|
|
|
73
219
|
let response
|
|
74
220
|
try {
|
|
75
|
-
response = await
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
221
|
+
response = await Promise.race([
|
|
222
|
+
fetchImpl(enrollEndpoint(relayUrl), {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: { 'content-type': 'application/json' },
|
|
225
|
+
// 有邀请码就带上(老的邀请码发放方式仍可用);没有就是开放登记。
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
...(invite ? { inviteCode: invite } : {}),
|
|
228
|
+
name: agentName,
|
|
229
|
+
...(machineMac ? { macAddress: machineMac } : {}),
|
|
230
|
+
}),
|
|
231
|
+
signal: controller.signal,
|
|
232
|
+
}),
|
|
233
|
+
timeout,
|
|
234
|
+
])
|
|
80
235
|
} catch (error) {
|
|
81
|
-
|
|
236
|
+
if (error instanceof EnrollError) throw error
|
|
237
|
+
throw new EnrollError('enroll/unreachable',
|
|
238
|
+
`mobile-link: cannot reach the relay at ${relayUrl}: ${messageOf(error)}`, { retryable: true })
|
|
239
|
+
} finally {
|
|
240
|
+
clearTimeout(timer)
|
|
82
241
|
}
|
|
83
242
|
|
|
84
243
|
let body
|
|
85
244
|
try {
|
|
86
245
|
body = await response.json()
|
|
87
246
|
} catch {
|
|
88
|
-
throw new
|
|
247
|
+
throw new EnrollError('enroll/bad-response',
|
|
248
|
+
`mobile-link: the relay answered HTTP ${response.status} to /agents/enroll`,
|
|
249
|
+
{ status: response.status })
|
|
89
250
|
}
|
|
90
251
|
if (!response.ok || body?.ok !== true || !body.agentId || !body.agentSecret) {
|
|
91
|
-
|
|
252
|
+
const code = body?.error?.code
|
|
253
|
+
throw new EnrollError(typeof code === 'string' && code ? code : 'enroll/relay-error',
|
|
254
|
+
describeEnrollFailure(body, response.status), {
|
|
255
|
+
status: response.status,
|
|
256
|
+
retryable: code === 'enroll/rate-limited' || code === 'enroll/store-error' || code === 'internal/error',
|
|
257
|
+
})
|
|
92
258
|
}
|
|
93
259
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
260
|
+
try {
|
|
261
|
+
await writeState(path, {
|
|
262
|
+
relayUrl,
|
|
263
|
+
agentId: body.agentId,
|
|
264
|
+
agentSecret: body.agentSecret,
|
|
265
|
+
agentName: body.agentName || agentName,
|
|
266
|
+
})
|
|
267
|
+
} catch (error) {
|
|
268
|
+
// The code has already been redeemed at this point. Say so explicitly:
|
|
269
|
+
// retrying with the same code will fail with "already used", and the person
|
|
270
|
+
// needs to know the identity could not be saved (and where).
|
|
271
|
+
throw new EnrollError('enroll/state-write',
|
|
272
|
+
`mobile-link: the identity was redeemed but could not be saved to ${path}: ${messageOf(error)}`,
|
|
273
|
+
{ stateFile: path })
|
|
274
|
+
}
|
|
100
275
|
logger?.info?.(`mobile-link: enrolled as ${body.agentId} (${body.agentName || agentName})`)
|
|
101
276
|
return {
|
|
102
277
|
agentId: body.agentId,
|
|
103
278
|
agentSecret: body.agentSecret,
|
|
104
279
|
agentName: body.agentName || agentName,
|
|
280
|
+
macAddress: body.macAddress || machineMac,
|
|
105
281
|
relayUrl,
|
|
106
282
|
stateFile: path,
|
|
107
283
|
}
|
|
108
284
|
}
|
|
109
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Backfill the MAC for an identity enrolled before the relay stored one.
|
|
288
|
+
*
|
|
289
|
+
* Runs on every connector start, authenticated with the agent secret. The relay
|
|
290
|
+
* only fills an empty slot, so a later network/interface change cannot rewrite
|
|
291
|
+
* the machine index. An old relay answers 404: that is "not supported", not a
|
|
292
|
+
* connection failure, and startup must continue.
|
|
293
|
+
*/
|
|
294
|
+
export async function reportMachineAddress({
|
|
295
|
+
relayUrl, agentSecret, macAddress, fetchImpl = fetch, timeoutMs = 8000,
|
|
296
|
+
} = {}) {
|
|
297
|
+
const mac = normalizeMacAddress(macAddress ?? primaryMacAddress())
|
|
298
|
+
if (!mac || !relayUrl || !agentSecret) return { ok: false, skipped: 'missing-mac-or-identity' }
|
|
299
|
+
const controller = new AbortController()
|
|
300
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
301
|
+
try {
|
|
302
|
+
const response = await fetchImpl(agentMachineEndpoint(relayUrl), {
|
|
303
|
+
method: 'POST',
|
|
304
|
+
headers: {
|
|
305
|
+
'content-type': 'application/json',
|
|
306
|
+
authorization: `Bearer ${agentSecret}`,
|
|
307
|
+
},
|
|
308
|
+
body: JSON.stringify({ macAddress: mac }),
|
|
309
|
+
signal: controller.signal,
|
|
310
|
+
})
|
|
311
|
+
let body
|
|
312
|
+
try { body = await response.json() } catch { body = undefined }
|
|
313
|
+
if (response.status === 404) return { ok: false, unsupported: true, macAddress: mac }
|
|
314
|
+
if (!response.ok || body?.ok !== true) {
|
|
315
|
+
throw new Error(body?.error?.message ?? `mobile-link: the relay rejected the machine MAC (HTTP ${response.status})`)
|
|
316
|
+
}
|
|
317
|
+
return { ok: true, macAddress: body.macAddress || mac, updated: body.updated === true }
|
|
318
|
+
} finally {
|
|
319
|
+
clearTimeout(timer)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
110
323
|
/**
|
|
111
324
|
* Turn the relay's failure into something a person can act on.
|
|
112
325
|
*
|
|
@@ -132,7 +345,8 @@ export function describeEnrollFailure(body, status) {
|
|
|
132
345
|
|
|
133
346
|
/** The one-liner a fresh install runs, quoted for copy/paste. */
|
|
134
347
|
export function enrollCommand(relayUrl, name = undefined) {
|
|
135
|
-
|
|
348
|
+
// 开放登记后命令不再必须带激活码;老的邀请码仍可用 `--invite` 传。
|
|
349
|
+
const parts = ['dsh-mobile-link enroll', `--relay ${relayUrl || '<relay-url>'}`]
|
|
136
350
|
if (name) parts.push(`--name ${JSON.stringify(name)}`)
|
|
137
351
|
return parts.join(' ')
|
|
138
352
|
}
|