dsh-plugin-mobile-link 0.3.1 → 0.4.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 CHANGED
@@ -38,11 +38,32 @@ plugins/mobile-link/
38
38
 
39
39
  ## Install
40
40
 
41
+ The connector runs in whichever host runs your DSH. There are two supported hosts today —
42
+ **the official desktop app** and **the command-line web host** — and each keeps its own
43
+ profile, so you install the plugin into the one you actually use. The connector discovers
44
+ the local DSH by itself in both (the host hands it the authenticated loopback address);
45
+ you never type an address or a token.
46
+
47
+ **Command-line web host**
48
+
41
49
  ```bash
42
50
  dsh plugin --profile web add dsh-plugin-mobile-link
43
51
  ```
44
52
 
45
- Then restart DSH (or reload the profile). From a checkout the same command takes
53
+ **Official desktop app** — install through the app's own plugin page, or with the CLI that
54
+ the app ships (`…/Resources/runtime/cli/bin/dsh`, installable from its settings). The npm
55
+ `dsh` refuses this profile on purpose (`profile "desktop" is managed exclusively by the
56
+ Electron application`). Two prerequisites matter in practice: let the app start once so it
57
+ initializes its profile, and **quit it completely** before installing — otherwise the
58
+ profile is locked.
59
+
60
+ ```bash
61
+ # quoting the app's bundled CLI; see docs/ONBOARDING.md for the section titles
62
+ "<DeepSeek Harness.app>/Contents/Resources/runtime/cli/bin/dsh" \
63
+ plugin --profile desktop add dsh-plugin-mobile-link
64
+ ```
65
+
66
+ Then restart the host (or reload the profile). From a checkout the same command takes
46
67
  a path — the leading `./` matters, because DSH anchors a relative spec against
47
68
  your current directory instead of resolving it inside the profile:
48
69
 
@@ -86,10 +107,15 @@ plugin's config block (`cordis.patch.yml`), not by editing this file.
86
107
  | `relayUrl` | from `agent.json` | Relay base URL; a bare deployment dials `<relay>/link/agent` |
87
108
  | `agentId` / `agentSecret` | from `agent.json` | Override the enrolled identity |
88
109
  | `stateFile` | `~/.dsh/mobile-link/agent.json` | Where the identity lives |
89
- | `dshUrl` | discovery | Explicit local DSH base URL |
90
- | `endpointFile` | `$DSH_HOME/desktop-shell/endpoint.json` | DSH endpoint handoff file |
110
+ | `dshUrl` | discovery | Explicit local DSH base URL (usually unnecessary — the host hands the connector its own address) |
91
111
  | `heartbeatMs` | `20000` | DLP ping interval |
92
112
 
113
+ The connector does **not** read an endpoint handoff file any more: discovery happens inside
114
+ the host process (`ctx.inject(['webServer','connection'])`), which is what makes one npm
115
+ package work on both the desktop app and the command-line host. It **writes** one instead —
116
+ `$DSH_HOME/mobile-link/endpoint.json`, mode 0600 — for tools that run *outside* the host
117
+ (our dev/test scripts and the iOS integration helpers).
118
+
93
119
  Address resolution order: this config block, then `DSH_RELAY_URL` (or
94
120
  `DSH_MOBILE_LINK_RELAY`), then the enrolled identity file, then the repository
95
121
  placeholder `wss://relay.example.com/dsh-link` — the placeholder being the
package/cordis.patch.yml CHANGED
@@ -21,7 +21,6 @@
21
21
  # agentSecret overrides ~/.dsh/mobile-link/agent.json
22
22
  # stateFile agent identity file (default ~/.dsh/mobile-link/agent.json)
23
23
  # dshUrl explicit local DSH base URL override (default: discovery)
24
- # endpointFile DSH endpoint handoff file (default $DSH_HOME/desktop-shell/endpoint.json)
25
24
  #
26
25
  # Address resolution order: this config block, then DSH_RELAY_URL (or
27
26
  # DSH_MOBILE_LINK_RELAY), then the enrolled identity file, then the repository
package/lib/cli.js CHANGED
@@ -66,9 +66,14 @@ async function main() {
66
66
  The invite may also arrive as ${INVITE_ENV}. Exits non-zero on failure.
67
67
 
68
68
  cli.js --relay <ws url> --agent-id <id> --agent-secret <secret>
69
- [--state-file <path>] [--dsh-url <url>] [--endpoint-file <path>]
69
+ [--state-file <path>] [--dsh-url <url>]
70
70
  [--status-file <path>] [--heartbeat-ms <n>] [--log-level debug|info|warn]
71
71
 
72
+ Note: run this inside a DSH host (plugin) when you can. A standalone process has no
73
+ in-process context, so on the official desktop app it cannot learn the local address by
74
+ itself — pass one explicitly with --dsh-url (a tokenized URL only stays valid until the
75
+ host restarts). Supported hosts: the official desktop app and the command-line web host.
76
+
72
77
  cli.js --mint-pair-code --relay <ws url> --agent-id <id> --agent-secret <secret>
73
78
  [--ttl-ms <n>] print one pairing code as JSON and exit
74
79
  `)
@@ -106,7 +111,7 @@ async function main() {
106
111
  process.stdout.write('\n' + qrTerminal(minted.qrPayload) + '\n')
107
112
  process.stdout.write(`配对码 ${minted.code}(约 ${minutes} 分钟有效,一次性)\n`)
108
113
  process.stdout.write('也可以在这台电脑上打开 http://127.0.0.1:<DSH 端口>/mobile-link/qr 取新码;\n'
109
- + '端口在 DSH 启动时打印,也在 ~/.dsh/desktop-shell/endpoint.json 里。\n')
114
+ + '端口在 DSH 启动时打印;连接器还会把带令牌地址写进 ~/.dsh/mobile-link/endpoint.json。\n')
110
115
  } catch (error) {
111
116
  process.stdout.write('(这次没能预先申请配对码:' + (error?.message ?? error) + ')\n'
112
117
  + '重启 DSH 后,在这台电脑的浏览器里打开 http://127.0.0.1:<DSH 端口>/mobile-link/qr,'
@@ -148,7 +153,6 @@ async function main() {
148
153
  agentSecret: args.agentSecret,
149
154
  stateFile: typeof args.stateFile === 'string' ? args.stateFile : undefined,
150
155
  dshUrl: typeof args.dshUrl === 'string' ? args.dshUrl : undefined,
151
- endpointFile: typeof args.endpointFile === 'string' ? args.endpointFile : undefined,
152
156
  heartbeatMs: Number(args.heartbeatMs) || 20000,
153
157
  logger,
154
158
  })
package/lib/dlp.js CHANGED
@@ -11,8 +11,28 @@ export const MAX_FRAME_BYTES = 32 * 1024 * 1024
11
11
 
12
12
  export const DEVICE_TO_AGENT = new Set(['req', 'open', 'cancel', 'eventResult', 'ping', 'hello'])
13
13
  export const AGENT_TO_DEVICE = new Set(['res', 'item', 'end', 'streamError', 'event', 'hostStatus', 'pong', 'error'])
14
- /** Relay-internal control frames carrying device lifecycle (see relay/NOTES.md). */
14
+ /** Relay-internal control frames carrying device lifecycle (see docs/RELAY-NOTES.md). */
15
15
  export const RELAY_CONTROL = new Set(['deviceAttach', 'deviceDetach'])
16
+ /**
17
+ * Agent-side control frames: sent by the connector to the relay and **never
18
+ * forwarded to a device** (they are not part of DLP — a phone neither sends nor
19
+ * receives them). `notify` is the "a run finished / something needs you" ping the
20
+ * relay turns into an APNs push when the device is offline (R-1 C-02/C-06).
21
+ */
22
+ export const AGENT_CONTROL = new Set([
23
+ 'notify',
24
+ // 后台大文件上传(`PUT /files/up`)与下载(`GET /files/down`)的桥接帧。
25
+ 'fsPutBegin', 'fsPutChunk', 'fsPutEnd', 'fsPutAck', 'fsPutDone',
26
+ 'fsGetBegin', 'fsGetChunk', 'fsGetEnd', 'fsGetAck',
27
+ 'fsErr',
28
+ ])
29
+ /**
30
+ * Control frames the relay sends **to** the connector. Same family as
31
+ * `AGENT_CONTROL` but the opposite direction, which is why it is its own set:
32
+ * that one is what this end *produces*, and a router that switched on the union
33
+ * of the two would dispatch its own outgoing frames if they ever came back.
34
+ */
35
+ export const RELAY_TO_AGENT_CONTROL = new Set(['fsGetCancel'])
16
36
 
17
37
  const ID_FRAMES = new Set(['req', 'open', 'cancel', 'res', 'item', 'end', 'streamError', 'eventResult'])
18
38
 
package/lib/dsh-client.js CHANGED
@@ -2,9 +2,10 @@
2
2
  * Client for the *local* DSH instance.
3
3
  *
4
4
  * Responsibilities (spec §4.1):
5
- * 1. Discover the endpoint: `$DSH_HOME/desktop-shell/endpoint.json`, then
6
- * `$DSH_WEB_URL`, then `http://127.0.0.1:54499`. The file changes on every
7
- * DSH restart, so it is re-read on every (re)connect.
5
+ * 1. Discover the endpoint as an ordered candidate list: explicit config, then the
6
+ * host's own in-process resolver, then `DSH_WEB_URL`, then `http://127.0.0.1:54499`.
7
+ * The authenticated value changes on every host restart, so it is re-resolved on
8
+ * every (re)connect.
8
9
  * 2. Exchange `?token=` for a `dsh-auth-*` cookie without following the 303.
9
10
  * The cookie is bound to the authority, so the client always dials
10
11
  * `127.0.0.1:<port>` and never rewrites `Host` (see NOTES.md on spec §7).
@@ -13,9 +14,7 @@
13
14
  */
14
15
 
15
16
  import { EventEmitter } from 'node:events'
16
- import { readFile } from 'node:fs/promises'
17
17
 
18
- import { defaultEndpointFile, defaultShellLogFile } from './state.js'
19
18
  import { connect as wsConnect } from './ws.js'
20
19
 
21
20
  export class DshUnavailable extends Error {}
@@ -42,56 +41,123 @@ function parseBase(raw, { forceLoopback = true } = {}) {
42
41
  }
43
42
 
44
43
  /**
45
- * Resolve where the local DSH web server lives.
46
- * @returns {Promise<{base: string, wsBase: string, port: number, token: string, source: string}>}
44
+ * Whether this answer is the binary form of an RPC result.
45
+ *
46
+ * A result that carries bytes **cannot** be JSON, so a Host that supports them
47
+ * answers `multipart/form-data` instead. A client that only reads JSON sees an
48
+ * unparsable body and reports `bad-response` — which is exactly how a Host
49
+ * generation that started doing this broke every file read at once.
47
50
  */
48
- export async function discoverEndpoint({
49
- endpointFile = defaultEndpointFile(),
50
- shellLogFile = defaultShellLogFile(),
51
- explicitUrl,
52
- } = {}) {
51
+ function isBinaryAnswer(response) {
52
+ const type = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
53
+ return type === 'multipart/form-data'
54
+ }
55
+
56
+ /**
57
+ * Decode that form into the envelope shape the JSON path also produces.
58
+ *
59
+ * Field for field the same contract the Host's own client implements: a
60
+ * `metadata` part holds the envelope — with `null` wherever the bytes were —
61
+ * plus one attachment per binary field, naming the part that carries it and the
62
+ * path to walk from `result.value` to that `null`.
63
+ */
64
+ async function decodeBinaryAnswer(response) {
65
+ const body = await response.formData()
66
+ const metadata = body.get('metadata')
67
+ if (typeof metadata !== 'string') throw new TypeError('binary answer without metadata')
68
+ const envelope = JSON.parse(metadata)
69
+ const root = { value: envelope?.result?.value }
70
+ for (const attachment of envelope?.attachments ?? []) {
71
+ const part = attachment?.part
72
+ const bytes = typeof part === 'string' ? body.get(part) : null
73
+ if (!bytes || typeof bytes.arrayBuffer !== 'function') {
74
+ throw new TypeError('binary answer with an unreadable part')
75
+ }
76
+ let parent = root
77
+ let key = 'value'
78
+ for (const segment of attachment?.path ?? []) {
79
+ const value = parent[key]
80
+ if (typeof value !== 'object' || value === null) {
81
+ throw new TypeError('binary answer with an unreachable path')
82
+ }
83
+ parent = value
84
+ key = segment
85
+ }
86
+ parent[key] = new Uint8Array(await bytes.arrayBuffer())
87
+ }
88
+ return envelope
89
+ }
90
+
91
+ /**
92
+ * Resolve **all** ways this process might reach the local DSH, in the order they
93
+ * must be tried (contract: `contracts/endpoint-discovery.md` §1).
94
+ *
95
+ * Pure and injectable on purpose: it only *produces candidates*. Authentication
96
+ * happens in `DshClient`, because "this candidate works" must be decided by an
97
+ * actual token → cookie exchange, not by the shape of the value.
98
+ *
99
+ * @param {object} [options]
100
+ * @param {string} [options.explicitUrl] user-configured address (may carry a token)
101
+ * @param {() => string | undefined} [options.hostService] in-process resolver: the host
102
+ * process hands us its current authenticated loopback URL (desktop + web hosts)
103
+ * @param {NodeJS.ProcessEnv} [options.env]
104
+ * @returns {Array<{base: string, wsBase: string, port: number, token: string, source: string}>}
105
+ */
106
+ export function discoverCandidates({ explicitUrl, hostService, env = process.env } = {}) {
107
+ const candidates = []
53
108
  if (explicitUrl) {
54
109
  const parsed = parseBase(explicitUrl, { forceLoopback: false })
55
110
  parsed.source = 'config'
56
- return parsed
57
- }
58
- try {
59
- const raw = JSON.parse(await readFile(endpointFile, 'utf8'))
60
- const url = typeof raw?.url === 'string' && raw.url ? raw.url : `http://127.0.0.1:${raw?.port ?? FALLBACK_PORT}`
61
- const parsed = parseBase(url)
62
- if (!parsed.port) parsed.port = Number(raw?.port) || FALLBACK_PORT
63
- parsed.base = `http://127.0.0.1:${parsed.port}`
64
- parsed.wsBase = `ws://127.0.0.1:${parsed.port}`
65
- parsed.token = parsed.token || url.searchParams?.get?.('token') || ''
66
- parsed.source = `endpoint.json:${endpointFile}`
67
- return parsed
68
- } catch {
69
- /* fall through to the log, the environment and the built-in default */
111
+ candidates.push(parsed)
70
112
  }
71
- // The handoff file can be missing while the host is very much alive: the
72
- // desktop shell deletes it when it quits, and a bare `dsh web --port 0` never
73
- // writes one at all. The shell's own log keeps every launch line, so the last
74
- // one that still answers is the live endpoint. This is the same second source
75
- // `test/tools/host.mjs` reads for the same reason.
76
- try {
77
- const log = await readFile(shellLogFile, 'utf8')
78
- const matches = log.match(/http:\/\/127\.0\.0\.1:(\d+)\/\?token=([A-Za-z0-9_-]+)/g)
79
- if (matches && matches.length) {
80
- const parsed = parseBase(matches[matches.length - 1])
81
- parsed.source = `dsh-shell.log:${shellLogFile}`
82
- return parsed
113
+ if (typeof hostService === 'function') {
114
+ try {
115
+ const raw = hostService()
116
+ if (typeof raw === 'string' && raw !== '') {
117
+ const parsed = parseBase(raw)
118
+ parsed.source = 'host-service'
119
+ candidates.push(parsed)
120
+ }
121
+ } catch {
122
+ /* the service is not usable in this host; fall through to the older sources */
83
123
  }
84
- } catch {
85
- /* fall through to the environment and the built-in default */
86
124
  }
87
- if (process.env.DSH_WEB_URL) {
88
- const parsed = parseBase(process.env.DSH_WEB_URL)
125
+ if (env.DSH_WEB_URL) {
126
+ const parsed = parseBase(env.DSH_WEB_URL)
89
127
  parsed.source = 'DSH_WEB_URL'
90
- return parsed
128
+ candidates.push(parsed)
129
+ }
130
+ const fallback = parseBase(`http://127.0.0.1:${FALLBACK_PORT}`)
131
+ fallback.source = 'default'
132
+ candidates.push(fallback)
133
+ return candidates
134
+ }
135
+
136
+ /**
137
+ * The first candidate, for callers that only need one address.
138
+ * @returns {Promise<{base: string, wsBase: string, port: number, token: string, source: string}>}
139
+ */
140
+ export async function discoverEndpoint(options = {}) {
141
+ return discoverCandidates(options)[0]
142
+ }
143
+
144
+ /**
145
+ * Classify why discovery/authentication failed, for the status payload and the
146
+ * user-facing hint table (`contracts/status-endpoint.md` §3).
147
+ *
148
+ * The rule is "the candidate that got furthest": one that carried a token and
149
+ * still failed says more about the situation than the bare fallback port.
150
+ *
151
+ * @param {{source?: string} | undefined} endpoint
152
+ * @returns {'config' | 'host-service' | 'DSH_WEB_URL' | 'unreachable'}
153
+ */
154
+ function kindFor(endpoint) {
155
+ switch (endpoint?.source) {
156
+ case 'config': return 'config'
157
+ case 'host-service': return 'host-service'
158
+ case 'DSH_WEB_URL': return 'DSH_WEB_URL'
159
+ default: return 'unreachable'
91
160
  }
92
- const parsed = parseBase(`http://127.0.0.1:${FALLBACK_PORT}`)
93
- parsed.source = 'default'
94
- return parsed
95
161
  }
96
162
 
97
163
  function cookieFrom(response) {
@@ -105,10 +171,11 @@ function cookieFrom(response) {
105
171
  }
106
172
 
107
173
  export class DshClient extends EventEmitter {
108
- constructor({ endpointFile, explicitUrl, logger = console, fetchImpl = fetch, connectImpl = wsConnect } = {}) {
174
+ constructor({ explicitUrl, hostService, logger = console, fetchImpl = fetch, connectImpl = wsConnect, env = process.env } = {}) {
109
175
  super()
110
- this.endpointFile = endpointFile || defaultEndpointFile()
111
176
  this.explicitUrl = explicitUrl
177
+ this.hostService = typeof hostService === 'function' ? hostService : undefined
178
+ this.env = env
112
179
  this.logger = logger
113
180
  this.fetchImpl = fetchImpl
114
181
  this.connectImpl = connectImpl
@@ -119,25 +186,59 @@ export class DshClient extends EventEmitter {
119
186
  this.muxReady = false
120
187
  this.pendingOpens = []
121
188
  this.lastError = undefined
189
+ /** Failure classification for the status payload; see contracts/status-endpoint.md §3. */
190
+ this.errorKind = undefined
122
191
  }
123
192
 
124
- /** Re-read endpoint discovery and re-do the token -> cookie exchange. */
193
+ /**
194
+ * Wire (or replace) the in-process resolver after construction.
195
+ *
196
+ * The plugin learns about the host's services asynchronously — `ctx.inject`
197
+ * callbacks fire once the service exists — so the agent cannot pass this in
198
+ * the constructor.
199
+ *
200
+ * @param {(() => string | undefined) | undefined} resolver
201
+ */
202
+ setHostService(resolver) {
203
+ this.hostService = typeof resolver === 'function' ? resolver : undefined
204
+ return this
205
+ }
206
+
207
+ /**
208
+ * Re-read endpoint discovery and re-do the token -> cookie exchange.
209
+ *
210
+ * Candidates are tried in order and the first one that *authenticates* wins:
211
+ * a host that can hand us its current URL beats the environment variable and
212
+ * the built-in default, while an older source is still used if the new one
213
+ * turns out not to work (constitution §IV).
214
+ */
125
215
  async refresh({ force = false } = {}) {
126
- const endpoint = await discoverEndpoint({ endpointFile: this.endpointFile, explicitUrl: this.explicitUrl })
127
- if (force || !this.endpoint || this.endpoint.base !== endpoint.base) this.cookie = ''
128
- this.endpoint = endpoint
129
- if (!this.cookie) await this.authenticate()
130
- return this.endpoint
216
+ const candidates = discoverCandidates({ explicitUrl: this.explicitUrl, hostService: this.hostService, env: this.env })
217
+ let lastError
218
+ let attemptedWithToken
219
+ for (const candidate of candidates) {
220
+ if (force || !this.endpoint || this.endpoint.base !== candidate.base) this.cookie = ''
221
+ this.endpoint = candidate
222
+ try {
223
+ await this.authenticate()
224
+ this.errorKind = undefined
225
+ return this.endpoint
226
+ } catch (error) {
227
+ lastError = error
228
+ if (candidate.token) attemptedWithToken = candidate
229
+ }
230
+ }
231
+ this.cookie = ''
232
+ this.errorKind = kindFor(attemptedWithToken ?? this.endpoint)
233
+ throw lastError ?? new DshUnavailable('DSH is not reachable (no candidate answered)')
131
234
  }
132
235
 
133
236
  async authenticate() {
134
- const endpoint = this.endpoint ?? await discoverEndpoint({
135
- endpointFile: this.endpointFile, explicitUrl: this.explicitUrl,
136
- })
137
- this.endpoint = endpoint
237
+ const endpoint = this.endpoint
238
+ if (!endpoint) throw new DshUnavailable('no DSH endpoint discovered yet')
138
239
  if (!endpoint.token) {
139
240
  throw new DshUnavailable(
140
- `no DSH launch token found (looked at ${this.endpoint.source}); restart DSH or set dshUrl in the plugin config`,
241
+ `no DSH launch token found (looked at ${endpoint.source}); restart the host or set dshUrl in the plugin config`,
141
242
  )
142
243
  }
143
244
  const url = `${endpoint.base}/?token=${encodeURIComponent(endpoint.token)}`
@@ -152,7 +253,7 @@ export class DshClient extends EventEmitter {
152
253
  throw new DshUnavailable(`DSH auth handshake returned HTTP ${response.status} without a dsh-auth cookie`)
153
254
  }
154
255
  this.cookie = cookie
155
- this.logger.debug?.(`mobile-link: authenticated with DSH at ${endpoint.base}`)
256
+ this.logger.debug?.(`mobile-link: authenticated with DSH at ${endpoint.base} (source: ${endpoint.source})`)
156
257
  return cookie
157
258
  }
158
259
 
@@ -189,7 +290,7 @@ export class DshClient extends EventEmitter {
189
290
  }
190
291
  let json
191
292
  try {
192
- json = await response.json()
293
+ json = isBinaryAnswer(response) ? await decodeBinaryAnswer(response) : await response.json()
193
294
  } catch {
194
295
  return { ok: false, error: { code: 'gateway/bad-response', message: `DSH returned HTTP ${response.status}`, details: {} } }
195
296
  }