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.
@@ -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 JSON.stringify(frame)
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 an invite code into a durable agent identity.
2
+ * First-run enrollment: turn a relay address into a durable agent identity.
3
3
  *
4
- * Until now an identity could only be provisioned by whoever owns the relay
5
- * (`relay/admin.py agent-register --write-config`). That does not distribute:
6
- * somebody else installs DSH plus this connector on their own computer and has
7
- * to be handed a credential by the operator, out of band.
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
- * Enrollment is deliberately *not* automatic against an open endpoint: an
16
- * invite code must be present, because the relay is on the public internet.
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 { enrollEndpoint } from './dlp.js'
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(invite && relay) }
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
- if (!inviteCode || !String(inviteCode).trim()) {
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 Error('mobile-link: a relay URL is required to enroll (--relay <url>)')
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 fetchImpl(enrollEndpoint(relayUrl), {
76
- method: 'POST',
77
- headers: { 'content-type': 'application/json' },
78
- body: JSON.stringify({ inviteCode: String(inviteCode).trim(), name: agentName }),
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
- throw new Error(`mobile-link: cannot reach the relay at ${relayUrl}: ${messageOf(error)}`)
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 Error(`mobile-link: the relay answered HTTP ${response.status} to /agents/enroll`)
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
- throw new Error(describeEnrollFailure(body, response.status))
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
- await writeState(path, {
95
- relayUrl,
96
- agentId: body.agentId,
97
- agentSecret: body.agentSecret,
98
- agentName: body.agentName || agentName,
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
- const parts = ['dsh-mobile-link enroll', `--relay ${relayUrl || '<relay-url>'}`, '--invite <code>']
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
  }