dsh-remote-tunnel-easy 1.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/index.js ADDED
@@ -0,0 +1,792 @@
1
+ /**
2
+ * dsh-remote-tunnel-easy node half — tunnel + Settings-page QR + same-session
3
+ * handoff for the DSH Web API.
4
+ *
5
+ * On every start this bundle:
6
+ * 1. spawns `cloudflared tunnel --no-autoupdate --url http://127.0.0.1:<ephemeral>`
7
+ * where the ephemeral port is a loopback edge proxy this bundle also
8
+ * spawns, forwarding every request and upgrade to the harness target,
9
+ * 2. extracts the random trycloudflare.com URL from cloudflared's stderr,
10
+ * 3. resolves this process's launch token through the connection service's
11
+ * authenticatedUrl,
12
+ * 4. computes the combined URL + a QR matrix and publishes them to a
13
+ * loopback-only bridge the Settings page card renders — the dsh web
14
+ * terminal prints nothing,
15
+ * 5. seeds the harness's most recently active session into the served index
16
+ * page itself, so ANY entry path — the QR link, the handoff route, or a
17
+ * manually typed tunnel URL — lands inside the exact session the desktop
18
+ * is working in: same workspace, same chat.
19
+ *
20
+ * The edge proxy exists because the harness /api browser-trust fence
21
+ * (packages/client/connection/src/api-request-trust.ts) requires an attached
22
+ * Origin to equal the request Host, and browser-auth binds the session cookie
23
+ * to the Host authority. cloudflared rewrote Host to the loopback target, but
24
+ * the phone browser's Origin stayed `https://<tunnel>` — the app loaded
25
+ * (token login and cookie succeeded over the rewritten Host) while every
26
+ * /api call 403'd: sessions lists rendered empty through the tunnel. The
27
+ * proxy equalizes the pair per request — Host → `localhost:3080`, Origin →
28
+ * `http://localhost:3080` — and passes WebSocket upgrades through the same
29
+ * rewrite, so tunneled traffic is indistinguishable from desktop traffic.
30
+ *
31
+ * Seeding rides the webserver's index-injection table, applied by the static
32
+ * fallback owner on every index render. Two gates keep it surgical:
33
+ * - Host gate: the seed script reads the page's own location and no-ops on
34
+ * loopback/lan origins, so desktop use of the app is never touched. The
35
+ * session id is resolved on the Host at render time (cached, refreshed
36
+ * on a short interval), so the phone browser makes no /api call for the
37
+ * seed itself.
38
+ * - One-shot gate: the script stamps sessionStorage after seeding, so the
39
+ * write happens once per tab visit; afterwards the app's own navigation
40
+ * is authoritative and refreshes keep whatever the user chose.
41
+ *
42
+ * No database, no Firebase, no persisted state. A tree without
43
+ * webServer/connection/sessionController keeps the rows pending, matching
44
+ * the lan-access bundle contract. Disposal kills the cloudflared child, the
45
+ * proxy, and the refresh timer.
46
+ */
47
+
48
+ import { installSettingsSection, settingsNamespace, SettingsConflictError } from '@deepseek-ai/dsh-settings'
49
+ import z from '@deepseek-ai/schemastery'
50
+ import { resolveCloudflared } from './lib/cloudflared.js'
51
+ import { encodeQrMatrix } from './lib/qrcode.js'
52
+ import {
53
+ locateProfileDir, dependencySpec, classifySpec, currentVersion, isNewer,
54
+ fetchLatestVersion, runPnpmAdd, PACKAGE_NAME,
55
+ } from './lib/update.js'
56
+
57
+ const TOKEN_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
58
+ const HANDOFF_PATH = '/remote-handoff'
59
+ /** Settings namespace the browser card edits (Settings → Plugins → configurable). */
60
+ const REMOTE_HANDOFF_NS = settingsNamespace('remote-handoff')
61
+ /** Loopback-only HTTP bridge the card reads config + tunnel status through. */
62
+ const BRIDGE_PREFIX = '/api/dsh-remote-handoff-settings'
63
+ /** How often the Host re-resolves the active session for index seeding. */
64
+ const SESSION_REFRESH_MS = 15_000
65
+
66
+ /**
67
+ * Bundle config schema. The same object the Settings page edits: `tunnelTarget`
68
+ * is the local harness the quick tunnel forwards to (empty means "auto" — the
69
+ * running webserver's loopback URL), `cloudflaredPath` names the cloudflared
70
+ * binary (empty means "probe PATH, else auto-download"; any non-empty value is
71
+ * an override that disables both), `autoInstallCloudflared` gates the lazy
72
+ * download, and `sessionHandoff` gates the token-gated handoff route and index
73
+ * seed. The QR/link it emits is never printed to the terminal — the browser
74
+ * card fetches it from the loopback bridge and draws the matrix.
75
+ */
76
+ const Config = z.object({
77
+ tunnelTarget: z.string().default(''),
78
+ cloudflaredPath: z.string().default(''),
79
+ autoInstallCloudflared: z.boolean().default(true),
80
+ sessionHandoff: z.boolean().default(true),
81
+ })
82
+
83
+ /** Launch token of this process, extracted from the authenticated index URL. */
84
+ function launchToken(connection) {
85
+ const authenticated = connection.authenticatedUrl('http://localhost:3080')
86
+ return new URL(authenticated).searchParams.get('token') ?? ''
87
+ }
88
+
89
+ /**
90
+ * Resolve the tunnel upstream from config: an explicit `tunnelTarget` wins,
91
+ * otherwise the running webserver's canonical loopback URL. `webServer` port
92
+ * is the same source `web-app` uses for its own `dsh web` URL, so the auto
93
+ * value can never drift from where the harness actually listens.
94
+ */
95
+ function resolveTarget(config, webServer) {
96
+ if (typeof config.tunnelTarget === 'string' && config.tunnelTarget !== '') return config.tunnelTarget
97
+ return `http://127.0.0.1:${String(webServer.port)}`
98
+ }
99
+
100
+ function apply(ctx, config) {
101
+ // The live settings source: composition `config` until a settings provider
102
+ // attaches, then its resolved scope (see installSettingsSection below).
103
+ let current = () => config ?? {}
104
+
105
+ // Tunnel status shared with the loopback bridge. The browser card polls
106
+ // /status to draw the QR and link; nothing is ever written to the terminal.
107
+ const status = {
108
+ state: 'starting',
109
+ tunnelUrl: null,
110
+ accessUrl: null,
111
+ matrix: null,
112
+ error: null,
113
+ phase: null,
114
+ resolvedTarget: null,
115
+ }
116
+ /** Registered regenerate handler; replaced by the tunnel effect once ready. */
117
+ let regenerate = null
118
+
119
+ // Self-update state shared with the loopback bridge. The card polls
120
+ // /update-status and drives /check-update + /update. Nothing is printed here.
121
+ const updateStatus = {
122
+ state: 'idle', // idle | checking | available | up-to-date | updating | updated | failed
123
+ currentVersion: currentVersion(),
124
+ latestVersion: null,
125
+ output: null,
126
+ error: null,
127
+ requiresRestart: false,
128
+ }
129
+ let updateInFlight = false
130
+
131
+ /** Locate the owning profile and classify its dependency spec for this package. */
132
+ const resolveInstall = () => {
133
+ const dir = locateProfileDir()
134
+ const spec = dependencySpec(dir)
135
+ return { dir, spec, kind: classifySpec(spec) }
136
+ }
137
+
138
+ /** Poll the registry and compare versions; surfaces "available" / "up-to-date" / "failed". */
139
+ const checkUpdate = async () => {
140
+ if (updateInFlight) return { ok: false, code: 'busy', message: 'update already in progress' }
141
+ updateStatus.state = 'checking'
142
+ updateStatus.error = null
143
+ try {
144
+ const latest = await fetchLatestVersion()
145
+ updateStatus.latestVersion = latest
146
+ updateStatus.currentVersion = currentVersion()
147
+ const current = updateStatus.currentVersion
148
+ updateStatus.state = current !== undefined && isNewer(latest, current) ? 'available' : 'up-to-date'
149
+ return { ok: true, value: { ...updateStatus } }
150
+ } catch (err) {
151
+ updateStatus.state = 'failed'
152
+ updateStatus.error = err instanceof Error ? err.message : String(err)
153
+ return { ok: false, code: 'failed', message: updateStatus.error }
154
+ }
155
+ }
156
+
157
+ /** Install the newest version inside the owning profile, then report "restart required". */
158
+ const updateNow = async () => {
159
+ if (updateInFlight) return { ok: false, code: 'busy', message: 'update already in progress' }
160
+ updateInFlight = true
161
+ try {
162
+ const { dir, spec, kind } = resolveInstall()
163
+ if (dir === undefined) {
164
+ updateStatus.state = 'failed'
165
+ updateStatus.error = 'owning profile not found — run dsh plugin --profile web add dsh-remote-tunnel-easy to reinstall'
166
+ return { ok: false, code: 'failed', message: updateStatus.error }
167
+ }
168
+ const latest = await fetchLatestVersion()
169
+ updateStatus.latestVersion = latest
170
+ updateStatus.currentVersion = currentVersion()
171
+ if (updateStatus.currentVersion !== undefined && !isNewer(latest, updateStatus.currentVersion)) {
172
+ updateStatus.state = 'up-to-date'
173
+ return { ok: true, value: { ...updateStatus } }
174
+ }
175
+ if (kind === 'local') {
176
+ updateStatus.state = 'failed'
177
+ updateStatus.error = `installed via "${spec}" — update that checkout, or re-add from the registry`
178
+ return { ok: false, code: 'local-install', message: updateStatus.error }
179
+ }
180
+ updateStatus.state = 'updating'
181
+ updateStatus.error = null
182
+ const result = await runPnpmAdd(dir)
183
+ updateStatus.state = 'updated'
184
+ updateStatus.requiresRestart = true
185
+ updateStatus.output = result.output ?? null
186
+ return { ok: true, value: { ...updateStatus } }
187
+ } catch (err) {
188
+ updateStatus.state = 'failed'
189
+ updateStatus.error = err instanceof Error ? err.message : String(err)
190
+ return { ok: false, code: 'failed', message: updateStatus.error }
191
+ } finally {
192
+ updateInFlight = false
193
+ }
194
+ }
195
+
196
+ installSettingsSection(ctx, REMOTE_HANDOFF_NS, Config, config ?? {}, {
197
+ setSource: (source) => { current = source },
198
+ onChange: () => {},
199
+ })
200
+
201
+ ctx.inject(['webServer', 'settings'], (sctx) => {
202
+ sctx.effect(() => {
203
+ const disposers = makeBridgeRoutes(sctx.settings, () => status, () => regenerate, () => ({ checkUpdate, updateNow, updateStatus })).map((route) => sctx.webServer.register(route))
204
+ return () => { disposers.forEach((dispose) => dispose()) }
205
+ }, 'dsh-remote-tunnel-easy: settings + status bridge')
206
+ })
207
+
208
+ ctx.effect(() => {
209
+ // Resolve the startup config once: tunnel target, cloudflared path, and the
210
+ // handoff switch are read when the tunnel/handoff/seed registrations begin,
211
+ // matching the original startup-time semantics. Live settings edits update
212
+ // `current` for the bridge, but do not restart an already-running tunnel
213
+ // (an explicit "Regenerate" is the only restart trigger).
214
+ const startup = current()
215
+ const disposers = [registerHandoff(ctx, startup), registerIndexSeed(ctx, startup)]
216
+ let torn = false
217
+ let active = null
218
+ let inFlight = false
219
+
220
+ const resetStatus = () => {
221
+ status.state = 'starting'
222
+ status.tunnelUrl = null
223
+ status.accessUrl = null
224
+ status.matrix = null
225
+ status.error = null
226
+ }
227
+
228
+ const resolveBinary = async () => {
229
+ status.phase = 'downloading cloudflared…'
230
+ return resolveCloudflared(startup, (phase) => { status.phase = phase })
231
+ }
232
+
233
+ // Start a tunnel to the resolved target using the resolved binary path.
234
+ const launch = async () => {
235
+ const target = resolveTarget(startup, ctx.webServer)
236
+ status.resolvedTarget = target
237
+ const binary = await resolveBinary().finally(() => { status.phase = null })
238
+ status.phase = 'starting tunnel…'
239
+ return startTunnel(ctx, { ...startup, tunnelTarget: target }, status, binary.path)
240
+ }
241
+
242
+ // Replace the live tunnel with a fresh one (regenerate). Safe against
243
+ // concurrent calls: a second request while one is in flight is rejected.
244
+ regenerate = async () => {
245
+ if (torn) return { ok: false, code: 'disposed', message: 'plugin is disposed' }
246
+ if (inFlight) return { ok: false, code: 'busy', message: 'already regenerating' }
247
+ inFlight = true
248
+ try {
249
+ if (active !== null) { active(); active = null }
250
+ resetStatus()
251
+ active = await launch()
252
+ return { ok: true, value: { state: 'starting' } }
253
+ } catch (err) {
254
+ status.state = 'failed'
255
+ status.phase = null
256
+ status.error = err instanceof Error ? err.message : String(err)
257
+ console.error(`dsh-remote-tunnel-easy: ${err.message}`)
258
+ return { ok: false, code: 'failed', message: status.error }
259
+ } finally {
260
+ inFlight = false
261
+ }
262
+ }
263
+
264
+ void launch().then((disposer) => { active = disposer }).catch((err) => {
265
+ status.state = 'failed'
266
+ status.phase = null
267
+ status.error = err instanceof Error ? err.message : String(err)
268
+ console.error(`dsh-remote-tunnel-easy: ${err.message}`)
269
+ })
270
+
271
+ return () => {
272
+ torn = true
273
+ regenerate = null
274
+ if (active !== null) active()
275
+ disposers.forEach((dispose) => { dispose() })
276
+ }
277
+ }, 'dsh-remote-tunnel-easy: tunnel + QR + session handoff')
278
+ }
279
+
280
+ const MAX_JSON_BODY_BYTES = 64 * 1024
281
+
282
+ /** Only requests originating from this machine's loopback are served. */
283
+ function isLoopbackRequest(request) {
284
+ const address = request.socket.remoteAddress
285
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
286
+ const host = request.headers.host
287
+ if (typeof host !== 'string') return false
288
+ let hostUrl
289
+ try {
290
+ hostUrl = new URL('http://' + host)
291
+ } catch {
292
+ return false
293
+ }
294
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
295
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
296
+ const origin = request.headers.origin
297
+ if (origin === undefined) return true
298
+ try {
299
+ return new URL(origin).host === hostUrl.host
300
+ } catch {
301
+ return false
302
+ }
303
+ }
304
+
305
+ function writeJson(res, statusCode, body) {
306
+ const payload = JSON.stringify(body)
307
+ res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
308
+ res.end(payload)
309
+ }
310
+
311
+ async function readJsonBody(req) {
312
+ const chunks = []
313
+ let size = 0
314
+ for await (const chunk of req) {
315
+ const buffer = chunk
316
+ size += buffer.length
317
+ if (size > MAX_JSON_BODY_BYTES) return undefined
318
+ chunks.push(buffer)
319
+ }
320
+ try {
321
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
322
+ } catch {
323
+ return undefined
324
+ }
325
+ }
326
+
327
+ /** Project a settings descriptor to the wire view the card consumes. */
328
+ function toView(descriptor) {
329
+ return {
330
+ ns: String(descriptor.ns),
331
+ schema: descriptor.schema,
332
+ value: descriptor.value,
333
+ ...(descriptor.base === undefined ? {} : { base: descriptor.base }),
334
+ ...(descriptor.user === undefined ? {} : { user: descriptor.user }),
335
+ revision: descriptor.revision,
336
+ }
337
+ }
338
+
339
+ /** Build the loopback-only bridge routes (settings describe/mutate + tunnel status + regenerate + self-update). */
340
+ function makeBridgeRoutes(settings, getStatus, getRegenerate, getUpdate) {
341
+ const allowlisted = () =>
342
+ settings
343
+ .describe({ redactSecrets: true })
344
+ .filter((descriptor) => String(descriptor.ns) === String(REMOTE_HANDOFF_NS))
345
+ .map((descriptor) => String(descriptor.ns))
346
+
347
+ const handlers = {
348
+ async describe() {
349
+ const descriptors = settings.describe({ redactSecrets: true })
350
+ return {
351
+ ok: true,
352
+ value: {
353
+ namespaces: allowlisted()
354
+ .map((ns) => descriptors.find((descriptor) => String(descriptor.ns) === ns))
355
+ .filter((descriptor) => descriptor !== undefined)
356
+ .map(toView),
357
+ writable: settings.writable !== false,
358
+ },
359
+ }
360
+ },
361
+ async mutate(request) {
362
+ const body = request
363
+ if (body === null || typeof body !== 'object' || typeof body.ns !== 'string' || !Array.isArray(body.ops)) {
364
+ return { ok: false, code: 'settings-rejected', message: 'malformed bridge settings request' }
365
+ }
366
+ const { ns } = body
367
+ if (!allowlisted().includes(ns)) {
368
+ return { ok: false, code: 'settings-not-exposed', message: `settings namespace "${ns}" is not exposed` }
369
+ }
370
+ const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
371
+ try {
372
+ await settings.mutate(settingsNamespace(ns), body.ops, expectedRevision)
373
+ } catch (error) {
374
+ if (error instanceof SettingsConflictError) {
375
+ return { ok: false, code: 'settings-conflict', message: error.message }
376
+ }
377
+ const message = error instanceof Error ? error.message : String(error)
378
+ return { ok: false, code: 'internal', message }
379
+ }
380
+ const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === ns)
381
+ if (descriptor === undefined) {
382
+ return { ok: false, code: 'internal', message: `settings namespace "${ns}" was disposed after the mutate` }
383
+ }
384
+ return { ok: true, value: toView(descriptor) }
385
+ },
386
+ async status() {
387
+ const s = getStatus()
388
+ return {
389
+ ok: true,
390
+ value: {
391
+ state: s.state,
392
+ ...(s.tunnelUrl === null ? {} : { tunnelUrl: s.tunnelUrl }),
393
+ ...(s.accessUrl === null ? {} : { accessUrl: s.accessUrl }),
394
+ ...(s.matrix === null ? {} : { matrix: s.matrix }),
395
+ ...(s.error === null ? {} : { error: s.error }),
396
+ ...(s.phase === null ? {} : { phase: s.phase }),
397
+ ...(s.resolvedTarget === null ? {} : { resolvedTarget: s.resolvedTarget }),
398
+ },
399
+ }
400
+ },
401
+ async regenerate() {
402
+ // Serialize through the effect's mutex; concurrent clicks fail with "busy".
403
+ if (typeof getRegenerate() !== 'function') {
404
+ return { ok: false, code: 'not-ready', message: 'regenerate is not available yet' }
405
+ }
406
+ return getRegenerate()()
407
+ },
408
+ async updateStatusHandler() {
409
+ const { updateStatus: u } = getUpdate()
410
+ return { ok: true, value: { ...u } }
411
+ },
412
+ async checkUpdate() {
413
+ return getUpdate().checkUpdate()
414
+ },
415
+ async doUpdate() {
416
+ return getUpdate().updateNow()
417
+ },
418
+ }
419
+
420
+ const guard = (req, res) => {
421
+ if (!isLoopbackRequest(req)) {
422
+ writeJson(res, 403, { error: 'loopback requests only' })
423
+ return false
424
+ }
425
+ if (req.method !== 'POST') {
426
+ writeJson(res, 405, { error: `method not allowed: ${req.method ?? ''}` })
427
+ return false
428
+ }
429
+ return true
430
+ }
431
+
432
+ return [
433
+ {
434
+ kind: 'exact',
435
+ path: `${BRIDGE_PREFIX}/describe`,
436
+ handler: async (req, res) => {
437
+ if (!guard(req, res)) return
438
+ writeJson(res, 200, await handlers.describe())
439
+ },
440
+ },
441
+ {
442
+ kind: 'exact',
443
+ path: `${BRIDGE_PREFIX}/mutate`,
444
+ handler: async (req, res) => {
445
+ if (!guard(req, res)) return
446
+ const body = await readJsonBody(req)
447
+ if (body === undefined) {
448
+ writeJson(res, 400, { ok: false, code: 'settings-rejected', message: 'malformed JSON body' })
449
+ return
450
+ }
451
+ writeJson(res, 200, await handlers.mutate(body))
452
+ },
453
+ },
454
+ {
455
+ kind: 'exact',
456
+ path: `${BRIDGE_PREFIX}/status`,
457
+ handler: async (req, res) => {
458
+ if (!guard(req, res)) return
459
+ writeJson(res, 200, await handlers.status())
460
+ },
461
+ },
462
+ {
463
+ kind: 'exact',
464
+ path: `${BRIDGE_PREFIX}/regenerate`,
465
+ handler: async (req, res) => {
466
+ if (!guard(req, res)) return
467
+ writeJson(res, 200, await handlers.regenerate())
468
+ },
469
+ },
470
+ {
471
+ kind: 'exact',
472
+ path: `${BRIDGE_PREFIX}/update-status`,
473
+ handler: async (req, res) => {
474
+ if (!guard(req, res)) return
475
+ writeJson(res, 200, await handlers.updateStatusHandler())
476
+ },
477
+ },
478
+ {
479
+ kind: 'exact',
480
+ path: `${BRIDGE_PREFIX}/check-update`,
481
+ handler: async (req, res) => {
482
+ if (!guard(req, res)) return
483
+ writeJson(res, 200, await handlers.checkUpdate())
484
+ },
485
+ },
486
+ {
487
+ kind: 'exact',
488
+ path: `${BRIDGE_PREFIX}/update`,
489
+ handler: async (req, res) => {
490
+ if (!guard(req, res)) return
491
+ writeJson(res, 200, await handlers.doUpdate())
492
+ },
493
+ },
494
+ ]
495
+ }
496
+
497
+ /**
498
+ * Encode `text` into a QR matrix using the vendored encoder (see lib/qrcode.js).
499
+ * Returns the module count and a count×count grid of 0/1 cells the browser
500
+ * renders — nothing is drawn here. The encoder is shipped in-tree so the QR
501
+ * step needs no runtime dependency (a `link:`/`file:` install cannot require a
502
+ * dependency package from inside the checkout).
503
+ */
504
+ function qrMatrix(text) {
505
+ return encodeQrMatrix(text)
506
+ }
507
+
508
+ /**
509
+ * Pick the session the QR should open: the newest ordinary, non-blank
510
+ * summary by updatedAt (the sort is explicit because the wire's
511
+ * activity ordering is a courtesy, not a contract). Blank sessions are
512
+ * skipped so a just-created empty session never wins; subagent rows are
513
+ * skipped because the client selects them through their parent's catalog,
514
+ * not as bare session ids.
515
+ * @returns session id, or undefined when no ordinary session exists.
516
+ */
517
+ export function pickActiveSession(summaries) {
518
+ const candidates = summaries.filter(s => s.blank !== true && s.origin === undefined)
519
+ if (candidates.length === 0) return undefined
520
+ candidates.sort((a, b) => b.updatedAt - a.updatedAt)
521
+ return candidates[0]?.sessionId
522
+ }
523
+
524
+ /** Length-safe constant-time equality of two token strings via their SHA-256 digests. */
525
+ function tokensMatch(candidate, expected) {
526
+ const crypto = process.getBuiltinModule('node:crypto')
527
+ const a = crypto.createHash('sha256').update(candidate).digest()
528
+ const b = crypto.createHash('sha256').update(expected).digest()
529
+ return crypto.timingSafeEqual(a, b)
530
+ }
531
+
532
+ /** The handoff page: seed the persisted selection, then continue the normal login. */
533
+ function handoffHtml(sessionId, continueUrl) {
534
+ // Stringify twice: the inner JSON is the storage cell text, the outer
535
+ // literal embeds it (and the URL) without any markup-breaking characters.
536
+ const cell = JSON.stringify({ sessionId }).replaceAll('<', '\\u003c')
537
+ return [
538
+ '<!doctype html><html><head><meta charset="utf-8"><title>Opening harness…</title></head><body>',
539
+ `<script>try{localStorage.setItem('dsh.sessions.current',${JSON.stringify(cell)})}catch(e){}`,
540
+ `location.replace(${JSON.stringify(continueUrl)})</script>`,
541
+ `<noscript><a href="${continueUrl.replaceAll('"', '&quot;')}">Continue to the harness</a></noscript>`,
542
+ '</body></html>',
543
+ ].join('')
544
+ }
545
+
546
+ /**
547
+ * Serve the token-gated handoff over a plain webServer route (no fence, no
548
+ * cookie requirement): the launch token in the query is the credential, the
549
+ * same secret that guards the index. Prefix match so both /remote-handoff
550
+ * and /remote-handoff/ reach the handler — the exact match refused the
551
+ * trailing slash, which phones downloaded as a 404 .txt. Every scan
552
+ * re-resolves the live active session; plain index visits never seed here
553
+ * (the index injection covers them), so desktop use is untouched.
554
+ */
555
+ function registerHandoff(ctx, config) {
556
+ if (config.sessionHandoff === false) return () => {}
557
+ const expected = launchToken(ctx.connection)
558
+ const handler = async (req, res) => {
559
+ const url = new URL(req.url ?? '/', 'http://dsh.invalid')
560
+ const token = url.searchParams.get('token') ?? ''
561
+ if (expected === '' || !tokensMatch(token, expected)) {
562
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
563
+ res.end('not found\n')
564
+ return
565
+ }
566
+ let sessionId
567
+ try {
568
+ const value = await ctx.sessionController.list({}, AbortSignal.timeout(5000))
569
+ sessionId = pickActiveSession(value.items ?? [])
570
+ } catch {
571
+ // List unavailable (e.g. during boot): fall through to the plain login
572
+ // — the QR still works, it just opens the app's default state.
573
+ }
574
+ const continueUrl = `/?token=${encodeURIComponent(token)}`
575
+ if (sessionId === undefined) {
576
+ res.writeHead(303, { 'cache-control': 'no-store', location: continueUrl, 'referrer-policy': 'no-referrer' })
577
+ res.end()
578
+ return
579
+ }
580
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
581
+ res.end(handoffHtml(sessionId, continueUrl))
582
+ }
583
+ // Prefix match (not exact) so both /remote-handoff and /remote-handoff/…
584
+ // reach the handler — the webserver's exact match refuses the trailing
585
+ // slash, which phones previously downloaded as a 404 .txt. The handler
586
+ // ignores the pathname and parses the query itself, and no other route
587
+ // begins with /remote-handoff, so the wider match cannot shadow anything.
588
+ return ctx.effect(
589
+ () => ctx.webServer.register({ kind: 'prefix', path: HANDOFF_PATH, handler }),
590
+ `dsh-remote-tunnel-easy: ${HANDOFF_PATH} handoff route`,
591
+ )
592
+ }
593
+
594
+ /**
595
+ * Seed the app's persisted selection directly into the served index page so
596
+ * ANY tunnel entry (QR link, handoff, or a manually typed tunnel URL) opens
597
+ * the harness's active session. The Host resolves the session id at render
598
+ * time through a short-interval cache; the injected script applies it only
599
+ * for non-loopback origins (desktop localhost is never touched) and only
600
+ * once per browser tab (sessionStorage flag), after which the app's own
601
+ * navigation is authoritative.
602
+ */
603
+ function registerIndexSeed(ctx, config) {
604
+ if (config.sessionHandoff === false) return () => {}
605
+ let cachedSessionId
606
+ const refresh = async () => {
607
+ try {
608
+ const value = await ctx.sessionController.list({}, AbortSignal.timeout(5000))
609
+ cachedSessionId = pickActiveSession(value.items ?? [])
610
+ } catch {
611
+ // List unavailable (e.g. mid-boot): keep the previous value; an
612
+ // undefined cache simply renders without the seed.
613
+ }
614
+ }
615
+ void refresh()
616
+ const timer = setInterval(() => { void refresh() }, SESSION_REFRESH_MS)
617
+ const row = () => ({
618
+ kind: 'script',
619
+ placement: 'head',
620
+ // Loopback gate: desktop origins the app is normally served on
621
+ // (localhost names, 127/8, ::1) never seed, so LAN-IP desktop use via
622
+ // the lan-access bundle and plain localhost use are both untouched.
623
+ text: [
624
+ 'try{(function(){',
625
+ 'var h=location.hostname;',
626
+ 'var loopback=(h==="localhost"||h.slice(-10)===".localhost"||h.startsWith("127.")||h==="::1"||h==="[::1]");',
627
+ 'if(loopback)return;',
628
+ 'if(sessionStorage.getItem("__dsh_qr_seeded"))return;',
629
+ 'var sid=globalThis.__DSH_QR_SESSION__;',
630
+ 'if(!sid)return;',
631
+ 'sessionStorage.setItem("__dsh_qr_seeded","1");',
632
+ "localStorage.setItem('dsh.sessions.current',",
633
+ 'JSON.stringify({sessionId:sid}));',
634
+ '})()}catch(e){}',
635
+ ].join(''),
636
+ })
637
+ ctx.on('webserver/index-inject', (table) => {
638
+ if (cachedSessionId !== undefined) {
639
+ table.push({ kind: 'global', name: '__DSH_QR_SESSION__', value: cachedSessionId })
640
+ table.push(row())
641
+ }
642
+ })
643
+ return () => clearInterval(timer)
644
+ }
645
+
646
+ /**
647
+ * Loopback edge proxy between cloudflared and the harness. Equalizes the
648
+ * Host/Origin pair the /api trust fence checks (see the module docblock) and
649
+ * forwards everything else untouched. Binds 127.0.0.1 only — the tunnel is
650
+ * the sole remote client, and no LAN attacker can reach the rewrite without
651
+ * first possessing the loopback boundary. WebSocket upgrades are piped raw
652
+ * after the same header rewrite.
653
+ * @param config - bundle config with `tunnelTarget` already resolved to the upstream.
654
+ * @returns disposer closing the server, its sockets, and tracked upstreams.
655
+ */
656
+ function startEdgeProxy(config) {
657
+ const http = process.getBuiltinModule('node:http')
658
+ const target = new URL(config.tunnelTarget ?? 'http://localhost:3080')
659
+ const upstreamAuthority = target.host
660
+ const webSocketKey = 'sec-websocket-key'
661
+
662
+ const server = http.createServer((req, res) => {
663
+ // cloudflared → proxy is a fixed loopback hop carrying public-request
664
+ // headers only; replace (never merge) the two fence-checked values.
665
+ req.headers.host = upstreamAuthority
666
+ req.headers.origin = `http://${upstreamAuthority}`
667
+ // setHost:false keeps the rewritten Host header verbatim — the options
668
+ // host otherwise overrides it and the fence sees the wrong authority.
669
+ const upstream = http.request({ host: target.hostname, port: target.port, path: req.url, method: req.method, headers: req.headers, setHost: false }, (up) => {
670
+ res.writeHead(up.statusCode, up.headers)
671
+ up.pipe(res)
672
+ })
673
+ upstream.on('error', () => {
674
+ if (res.headersSent) { res.destroy(); return }
675
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
676
+ res.end('harness upstream unreachable\n')
677
+ })
678
+ req.pipe(upstream)
679
+ })
680
+
681
+ server.on('upgrade', (req, socket) => {
682
+ req.headers.host = upstreamAuthority
683
+ req.headers.origin = `http://${upstreamAuthority}`
684
+ // setHost:false keeps the rewritten Host verbatim (see the GET path).
685
+ const upstream = http.request({
686
+ host: target.hostname,
687
+ port: target.port,
688
+ path: req.url,
689
+ headers: { ...req.headers, connection: 'Upgrade', upgrade: req.headers.upgrade },
690
+ setHost: false,
691
+ })
692
+ upstream.on('upgrade', (upRes, upSocket, upHead) => {
693
+ // Tunnel to tunnel: forward the 101 and raw bytes both ways.
694
+ const reply = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage}`]
695
+ for (const [name, value] of Object.entries(upRes.headers)) {
696
+ if (value !== undefined) reply.push(`${name}: ${Array.isArray(value) ? value.join(', ') : value}`)
697
+ }
698
+ socket.write(`${reply.join('\r\n')}\r\n\r\n`)
699
+ if (upHead.length > 0) socket.write(upHead)
700
+ upSocket.pipe(socket)
701
+ socket.pipe(upSocket)
702
+ const die = () => { socket.destroy(); upSocket.destroy() }
703
+ socket.on('error', die)
704
+ upSocket.on('error', die)
705
+ socket.on('close', die)
706
+ upSocket.on('close', die)
707
+ })
708
+ upstream.on('error', () => { socket.destroy() })
709
+ upstream.end()
710
+ })
711
+
712
+ return new Promise((resolve) => {
713
+ server.listen(0, '127.0.0.1', () => {
714
+ resolve({
715
+ port: server.address().port,
716
+ dispose: () => {
717
+ server.closeAllConnections()
718
+ server.close()
719
+ },
720
+ })
721
+ })
722
+ })
723
+ }
724
+
725
+ /**
726
+ * Run cloudflared for the fiber's lifetime and publish the QR matrix + link
727
+ * into the `status` store once the random URL appears on stderr. Nothing is
728
+ * printed to the terminal — the browser card reads /status and draws the QR.
729
+ * cloudflared points at the loopback edge proxy (not the harness directly) so
730
+ * tunneled requests arrive fence-clean. Reconnect loops are unnecessary:
731
+ * quick tunnels live for the child's lifetime and the effect dies with the
732
+ * plugin.
733
+ */
734
+ async function startTunnel(ctx, config, status, cloudflaredBin) {
735
+ const cp = process.getBuiltinModule('node:child_process')
736
+ const upstream = await startEdgeProxy(config)
737
+ const child = cp.spawn(cloudflaredBin, [
738
+ 'tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${String(upstream.port)}`,
739
+ ], { windowsHide: true })
740
+
741
+ let published = false
742
+
743
+ const publish = async (tunnelUrl) => {
744
+ const token = launchToken(ctx.connection) ?? ''
745
+ const accessUrl = `${tunnelUrl}${HANDOFF_PATH}?token=${encodeURIComponent(token)}`
746
+ // Lazy-load the bundled QR vendor so a tunnel failure never prevents the
747
+ // route tree from mounting. Only the matrix is computed here; the browser
748
+ // renders it inside the Settings card.
749
+ const { count, modules } = await qrMatrix(accessUrl)
750
+ status.state = 'ready'
751
+ status.phase = null
752
+ status.tunnelUrl = tunnelUrl
753
+ status.accessUrl = accessUrl
754
+ status.matrix = { count, modules }
755
+ }
756
+
757
+ child.stderr.setEncoding('utf8')
758
+ child.stderr.on('data', (chunk) => {
759
+ const match = TOKEN_RE.exec(chunk)
760
+ if (match !== null && !published) {
761
+ published = true
762
+ void publish(match[0]).catch((err) => {
763
+ status.state = 'failed'
764
+ status.error = err instanceof Error ? err.message : String(err)
765
+ console.error(`dsh-remote-tunnel-easy: ${err.message}`)
766
+ })
767
+ }
768
+ })
769
+ child.on('exit', (code) => {
770
+ if (!published) {
771
+ status.state = 'failed'
772
+ status.error = `cloudflared exited early (code ${String(code)}) — no tunnel URL`
773
+ }
774
+ })
775
+
776
+ const retry = setTimeout(() => {
777
+ if (!published) {
778
+ status.state = 'failed'
779
+ status.error = 'no tunnel URL after 60s — cloudflared may be offline'
780
+ }
781
+ }, 60_000)
782
+
783
+ return () => {
784
+ clearTimeout(retry)
785
+ child.kill()
786
+ upstream.dispose()
787
+ }
788
+ }
789
+
790
+ export const name = 'dsh-remote-tunnel-easy'
791
+ export const inject = ['webServer', 'connection', 'sessionController']
792
+ export { apply, startEdgeProxy, Config }