dsh-plugin-mobile-link 0.7.0 → 0.8.1
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 +27 -12
- package/cordis.patch.yml +5 -0
- package/lib/cli.js +11 -13
- package/lib/client.js +1453 -91
- package/lib/dlp.js +15 -0
- package/lib/enroll.js +249 -35
- package/lib/handoff.js +1 -1
- package/lib/hello.js +1 -1
- package/lib/history/fold-summary.js +85 -32
- package/lib/history/fold.js +11 -1
- package/lib/history/question-record.js +186 -0
- package/lib/i18n.js +177 -0
- package/lib/image-originals.js +121 -0
- package/lib/image-variants.js +315 -0
- package/lib/index.js +59 -2
- package/lib/journal.js +2 -0
- package/lib/link.js +274 -17
- package/lib/router.js +70 -8
- package/lib/routes.js +147 -0
- package/lib/session-digest.js +24 -10
- package/lib/session-scope.js +89 -0
- package/lib/state.js +3 -3
- package/lib/status.js +42 -11
- package/package.json +1 -1
package/lib/dlp.js
CHANGED
|
@@ -193,6 +193,21 @@ export function pairCodeRegenerateEndpoint(relayUrl) {
|
|
|
193
193
|
return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'pair/code/regenerate')
|
|
194
194
|
}
|
|
195
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
|
+
|
|
196
211
|
/** The relay's self-service enrollment endpoint (`POST /agents/enroll`). */
|
|
197
212
|
export function enrollEndpoint(relayUrl) {
|
|
198
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-go/internal/httpapi), 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
|
}
|
package/lib/handoff.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* 连接器写给**进程外**消费者的交接文件。
|
|
3
3
|
*
|
|
4
4
|
* 为什么需要它:跑在 DSH 宿主进程之外的脚本(`scripts/dev/dsh-probe.mjs`、`verify-simulator.sh`、
|
|
5
|
-
* `
|
|
5
|
+
* `ios/DSHMobile/UITestHarness/host.mjs` 等)与 iOS 的集成测试辅助都没有进程内上下文,拿不到注入的服务;
|
|
6
6
|
* 它们需要一个本机可读的"带凭据本机地址"。契约见
|
|
7
7
|
* `specs/001-connector-host-compat/contracts/out-of-process-handoff.md`。
|
|
8
8
|
*
|
package/lib/hello.js
CHANGED
|
@@ -65,7 +65,7 @@ export function hostVersion() {
|
|
|
65
65
|
* - `qr-pairing` this connector renders the pairing QR itself (the `svg`
|
|
66
66
|
* field of `POST /mobile-link/pair-code`), so the DSH client
|
|
67
67
|
* needs no QR library of its own
|
|
68
|
-
* - `self-enroll` this connector can register itself
|
|
68
|
+
* - `self-enroll` this connector can register itself without an invite code
|
|
69
69
|
* - `git` git status / diff / log / show can be read from the work
|
|
70
70
|
* tree on this computer (see git.js)
|
|
71
71
|
* - `background-transfer`
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
|
|
29
29
|
import { FOLD_RECORD_TYPE, isHumanMessage } from './fold.js'
|
|
30
30
|
import { bytesOf, trimRecordForWire } from './records.js'
|
|
31
|
+
import {
|
|
32
|
+
isQuestionTool, questionRecordFor, resultTextOf,
|
|
33
|
+
} from './question-record.js'
|
|
31
34
|
import { truncateSafe } from '../unicode.js'
|
|
32
35
|
|
|
33
36
|
/// `_link/hello` 声明的新能力位。老客户端不认识它,继续用 `fold: true`。
|
|
@@ -198,19 +201,7 @@ export function previewOf(args) {
|
|
|
198
201
|
return truncateSafe(flat, SUMMARY_PREVIEW_CHARS)
|
|
199
202
|
}
|
|
200
203
|
|
|
201
|
-
///
|
|
202
|
-
function resultTextOf(data) {
|
|
203
|
-
const parts = []
|
|
204
|
-
const blocks = [data?.message?.content, data?.content].find((value) => Array.isArray(value)) ?? []
|
|
205
|
-
for (const block of blocks) {
|
|
206
|
-
if (typeof block?.text === 'string' && block.text.length > 0) parts.push(block.text)
|
|
207
|
-
}
|
|
208
|
-
for (const key of ['output', 'result', 'stdout', 'stderr', 'text']) {
|
|
209
|
-
if (typeof data?.[key] === 'string' && data[key].length > 0) parts.push(data[key])
|
|
210
|
-
}
|
|
211
|
-
return parts.join('\n')
|
|
212
|
-
}
|
|
213
|
-
|
|
204
|
+
/// 工具结果是否失败。正文只用来量字节数(`resultTextOf`,与作答记录共用),不下发。
|
|
214
205
|
function isErrorResult(data) {
|
|
215
206
|
if (data?.message?.isError === true || data?.isError === true) return true
|
|
216
207
|
const error = data?.error ?? data?.message?.error
|
|
@@ -307,36 +298,54 @@ export function projectTurnUnit(events) {
|
|
|
307
298
|
const out = []
|
|
308
299
|
const summaries = []
|
|
309
300
|
let group = null
|
|
301
|
+
/// 本轮的提问调用(callId → 调用信息)。跨分组保留:调用与结果之间可能夹着
|
|
302
|
+
/// 一条中间讲话,按组记账会丢掉配对。
|
|
303
|
+
const questionCalls = new Map()
|
|
304
|
+
/// 已结算、等摘要落位后再下发的记录(作答记录与图片结果,seq 升序)。
|
|
305
|
+
/// 它们都产生在过程段内部:摘要的 seq 是段起点,必须排在它们之前,
|
|
306
|
+
/// 否则一页记录会倒序。
|
|
307
|
+
let pendingRecords = []
|
|
310
308
|
|
|
311
309
|
const openGroup = (event) => {
|
|
312
310
|
if (group !== null) return
|
|
313
311
|
group = { startSeq: event.seq, endSeq: event.seq, time: event.time, steps: [], calls: new Map() }
|
|
314
312
|
}
|
|
315
313
|
|
|
314
|
+
/// 段内产生的可见记录排在它所属的过程摘要**之后**(摘要的 seq 是段起点,
|
|
315
|
+
/// 记录的是结果 seq),所以分组还开着时先攒着,flush 时一次放出。
|
|
316
|
+
const emitPending = () => {
|
|
317
|
+
if (pendingRecords.length === 0) return
|
|
318
|
+
pendingRecords.sort((lhs, rhs) => lhs.seq - rhs.seq)
|
|
319
|
+
for (const pending of pendingRecords) out.push(record(pending))
|
|
320
|
+
pendingRecords = []
|
|
321
|
+
}
|
|
322
|
+
|
|
316
323
|
const flush = () => {
|
|
317
324
|
const current = group
|
|
318
325
|
group = null
|
|
319
|
-
if (current
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
startSeq: current.startSeq,
|
|
325
|
-
endSeq: current.endSeq,
|
|
326
|
-
startedAt,
|
|
327
|
-
durationMs,
|
|
328
|
-
endReason,
|
|
329
|
-
activities,
|
|
330
|
-
groups: [{
|
|
326
|
+
if (current !== null && current.steps.length > 0) {
|
|
327
|
+
const activities = countActivities(current.steps)
|
|
328
|
+
const summary = {
|
|
329
|
+
v: SUMMARY_FOLD_VERSION,
|
|
330
|
+
turn,
|
|
331
331
|
startSeq: current.startSeq,
|
|
332
332
|
endSeq: current.endSeq,
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
333
|
+
startedAt,
|
|
334
|
+
durationMs,
|
|
335
|
+
endReason,
|
|
336
|
+
activities,
|
|
337
|
+
groups: [{
|
|
338
|
+
startSeq: current.startSeq,
|
|
339
|
+
endSeq: current.endSeq,
|
|
340
|
+
activities: { ...activities },
|
|
341
|
+
toolCount: current.steps.filter((step) => step.kind === 'tools').length,
|
|
342
|
+
steps: current.steps,
|
|
343
|
+
}],
|
|
344
|
+
}
|
|
345
|
+
summaries.push(summary)
|
|
346
|
+
out.push(record({ type: FOLD_RECORD_TYPE, seq: current.startSeq, time: current.time, data: summary }))
|
|
337
347
|
}
|
|
338
|
-
|
|
339
|
-
out.push(record({ type: FOLD_RECORD_TYPE, seq: current.startSeq, time: current.time, data: summary }))
|
|
348
|
+
emitPending()
|
|
340
349
|
}
|
|
341
350
|
|
|
342
351
|
for (const event of input) {
|
|
@@ -431,12 +440,36 @@ export function projectTurnUnit(events) {
|
|
|
431
440
|
}
|
|
432
441
|
group.steps.push(step)
|
|
433
442
|
const callId = event?.data?.callId
|
|
434
|
-
if (typeof callId === 'string')
|
|
443
|
+
if (typeof callId === 'string') {
|
|
444
|
+
group.calls.set(callId, step)
|
|
445
|
+
if (isQuestionTool(event?.data?.name)) {
|
|
446
|
+
questionCalls.set(callId, {
|
|
447
|
+
callId,
|
|
448
|
+
name: event.data.name,
|
|
449
|
+
arguments: event.data.arguments,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
}
|
|
435
453
|
group.endSeq = event.seq
|
|
436
454
|
break
|
|
437
455
|
}
|
|
438
456
|
case 'tool/result': {
|
|
439
457
|
const callId = event?.data?.message?.toolCallId ?? event?.data?.toolCallId ?? event?.data?.callId
|
|
458
|
+
// 作答记录先取:即使结果在摘要里找不到对应步骤(调用在上一段/上一页),
|
|
459
|
+
// 已经结算的答案仍然要下发(SR-02.16)。
|
|
460
|
+
const call = questionCalls.get(callId)
|
|
461
|
+
if (call !== undefined) {
|
|
462
|
+
const question = questionRecordFor({
|
|
463
|
+
call,
|
|
464
|
+
resultText: resultTextOf(event?.data),
|
|
465
|
+
seq: event.seq,
|
|
466
|
+
time: event.time,
|
|
467
|
+
})
|
|
468
|
+
if (question !== null) {
|
|
469
|
+
if (group === null) out.push(record(question))
|
|
470
|
+
else pendingRecords.push(question)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
440
473
|
const step = group?.calls.get(callId)
|
|
441
474
|
// 结果找不到对应调用(日志被裁剪、旧格式)就丢掉:摘要里不凭空造一步。
|
|
442
475
|
if (step === undefined) break
|
|
@@ -445,6 +478,26 @@ export function projectTurnUnit(events) {
|
|
|
445
478
|
step.resultSeq = event.seq
|
|
446
479
|
if (text !== '') step.bytes = Buffer.byteLength(text, 'utf8')
|
|
447
480
|
group.endSeq = event.seq
|
|
481
|
+
// A tool can deliver a picture (send_image, read_image). The process
|
|
482
|
+
// summary deliberately drops tool-result text, but dropping the image
|
|
483
|
+
// would erase the only user-visible deliverable of that turn. Keep a
|
|
484
|
+
// metadata-only result record; the client renders its image card.
|
|
485
|
+
const images = blocksOf(event).filter((block) => block?.type === 'image')
|
|
486
|
+
if (images.length > 0) {
|
|
487
|
+
pendingRecords.push({
|
|
488
|
+
type: 'tool/result',
|
|
489
|
+
seq: event.seq,
|
|
490
|
+
time: event.time,
|
|
491
|
+
data: {
|
|
492
|
+
...(Number.isInteger(event?.data?.turn) ? { turn: event.data.turn } : {}),
|
|
493
|
+
...(Number.isInteger(event?.data?.step) ? { step: event.data.step } : {}),
|
|
494
|
+
message: {
|
|
495
|
+
toolCallId: callId,
|
|
496
|
+
content: images.map(attachmentBlockForWire),
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
})
|
|
500
|
+
}
|
|
448
501
|
break
|
|
449
502
|
}
|
|
450
503
|
default: {
|
package/lib/history/fold.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* (现有 `_link/sessionWindow`),取回原事件即可,不需要新端点。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { questionRecordsOfEvents } from './question-record.js'
|
|
21
|
+
|
|
20
22
|
/// 客户端按这个类型画过程节点;以 `+` 开头,不会与宿主事件类型相撞。
|
|
21
23
|
export const FOLD_RECORD_TYPE = '+link/fold'
|
|
22
24
|
|
|
@@ -177,8 +179,16 @@ export function foldProcessRuns(records) {
|
|
|
177
179
|
}
|
|
178
180
|
flush()
|
|
179
181
|
|
|
182
|
+
// 作答记录是用户自己的输入,不是过程:折叠把它的调用与结果收进摘要后,
|
|
183
|
+
// 单独把它放回页里(SR-02.16)。按 seq 合并不是"重排页面",而是让摘要
|
|
184
|
+
// (起点 seq)与记录(结果 seq)落在原来的先后关系上;稳定排序保证同 seq 的
|
|
185
|
+
// 两类记录不互换。
|
|
186
|
+
const merged = out
|
|
187
|
+
.concat(questionRecordsOfEvents(events).map((entry) => entry.record))
|
|
188
|
+
.sort((lhs, rhs) => lhs.seq - rhs.seq)
|
|
189
|
+
|
|
180
190
|
return {
|
|
181
|
-
records:
|
|
191
|
+
records: merged.map((event) => ({ type: 'event', event })),
|
|
182
192
|
folds,
|
|
183
193
|
}
|
|
184
194
|
}
|