@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.10

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.
Files changed (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +993 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +79 -39
  25. package/lib/types/client/locales.d.ts +32 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +42 -22
  28. package/lib/types/client/mobile-adapt.d.ts +49 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +1002 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +82 -39
  91. package/src/client/locales.ts +42 -22
  92. package/src/client/mobile-adapt.ts +954 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17658
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -149
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -561
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -63
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -821
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -685
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -268
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1342
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -0,0 +1,145 @@
1
+ /**
2
+ * LAN bind toggle: the managed cordis patch block that pins the web
3
+ * server's bind. On = a block binding 0.0.0.0; off = the same block binding
4
+ * 127.0.0.1. The block takes effect on the next `dsh web` start (the live
5
+ * patch watcher cannot rebind a running listener, and on this harness line
6
+ * the user patch layer cannot evaluate webStartup-dependent expressions
7
+ * reliably - static values are the only dependable form), so the plugin
8
+ * re-asserts the block at every boot and the settings card reports the
9
+ * running bind honestly.
10
+ *
11
+ * Same-id patch rows REPLACE the row config wholesale, so the block carries
12
+ * the official web-app webserver row's full config (bind + compression)
13
+ * with the bind values materialized. The CLI's --host 0.0.0.0 guard stays
14
+ * intact: deliberate LAN exposure happens through this configuration layer
15
+ * only. Until the user flips the toggle once, the plugin never touches the
16
+ * patch file.
17
+ *
18
+ * The block discipline (markers, atomic write, strip-and-rewrite) follows
19
+ * the dsh-LAN reference implementation (MIT).
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
23
+ import { dirname, join, resolve, sep } from 'node:path'
24
+ import { dshHome } from './dsh-home.ts'
25
+
26
+ /** Profile names safe to interpolate into the patch path: one path segment, no traversal. */
27
+ const PROFILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
28
+
29
+ export const LAN_BIND_BLOCK_BEGIN = '# --- remote-web-ui lan-bind block (managed - do not edit) ---'
30
+ export const LAN_BIND_BLOCK_END = '# --- end remote-web-ui lan-bind block ---'
31
+
32
+ /** The two bind hosts the managed block pins. */
33
+ export type LanBindHost = '0.0.0.0' | '127.0.0.1'
34
+
35
+ /**
36
+ * The absolute path of the profile patch file this toggle manages. The value
37
+ * is config-controlled (and the DSH_PROFILE env fallback bypasses schema
38
+ * validation entirely), so the path is guarded twice: the profile must be a
39
+ * single safe path segment, and the resolved file must stay under the
40
+ * profiles directory.
41
+ */
42
+ export function profilePatchFile(profile: string, home: string = dshHome()): string {
43
+ if (!PROFILE_PATTERN.test(profile)) {
44
+ throw new Error(`remote-web-ui: unsafe lan-bind profile ${JSON.stringify(profile)}`)
45
+ }
46
+ const file = resolve(join(home, 'profiles', profile, 'cordis.patch.yml'))
47
+ const root = resolve(join(home, 'profiles')) + sep
48
+ if (!file.startsWith(root)) {
49
+ throw new Error(`remote-web-ui: lan-bind profile ${JSON.stringify(profile)} escapes the profiles directory`)
50
+ }
51
+ return file
52
+ }
53
+
54
+ function readPatchContent(file: string): string {
55
+ if (!existsSync(file)) return ''
56
+ return readFileSync(file, 'utf8')
57
+ }
58
+
59
+ /** Escape one literal string for embedding into a RegExp pattern. */
60
+ function escapeRegex(text: string): string {
61
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
62
+ }
63
+
64
+ /** Remove the managed block (and its markers) from patch content. */
65
+ export function stripManagedBlock(content: string): string {
66
+ const begin = escapeRegex(LAN_BIND_BLOCK_BEGIN)
67
+ const end = escapeRegex(LAN_BIND_BLOCK_END)
68
+ const pattern = new RegExp(`\\r?\\n?${begin}[\\s\\S]*?${end}\\r?\\n?`, 'g')
69
+ return content.replace(pattern, '\n')
70
+ }
71
+
72
+ /**
73
+ * Render the managed block for one bind state. The values are static: the
74
+ * user patch layer has no reliable lazy service evaluation, and the plugin
75
+ * re-asserts the block at every boot so CLI flags (--port, --host) win by
76
+ * rewriting it before the next start.
77
+ */
78
+ export function managedBlock(host: LanBindHost, port: number): string {
79
+ return [
80
+ LAN_BIND_BLOCK_BEGIN,
81
+ '- id: webserver',
82
+ " name: '@deepseek-ai/dsh-host-webserver'",
83
+ ' config:',
84
+ ` host: '${host}'`,
85
+ ` port: ${String(port)}`,
86
+ ' compression: gzip',
87
+ ' compressionLevel: 1',
88
+ ' compressionThresholdBytes: 1024',
89
+ LAN_BIND_BLOCK_END,
90
+ '',
91
+ ].join('\n')
92
+ }
93
+
94
+ /** The bind state a managed block pins (undefined when there is no block). */
95
+ export interface ManagedBindState {
96
+ host: LanBindHost | (string & {})
97
+ port: number | undefined
98
+ }
99
+
100
+ /**
101
+ * Parse the block's pinned bind out of patch content. A block whose values
102
+ * were hand-edited reports the literals as-is so the card can surface them
103
+ * instead of silently claiming one of the two known states.
104
+ */
105
+ export function managedBindOf(content: string): ManagedBindState | undefined {
106
+ const begin = content.indexOf(LAN_BIND_BLOCK_BEGIN)
107
+ if (begin === -1) return undefined
108
+ const end = content.indexOf(LAN_BIND_BLOCK_END, begin)
109
+ const block = end === -1 ? content.slice(begin) : content.slice(begin, end)
110
+ const hostMatch = /host:\s*'([^']+)'/.exec(block)
111
+ const portMatch = /port:\s*(\d+)/.exec(block)
112
+ return {
113
+ host: hostMatch?.[1] ?? '',
114
+ port: portMatch !== null ? Number(portMatch[1]) : undefined,
115
+ }
116
+ }
117
+
118
+ /** Full file-level state for the settings card and the boot re-assert. */
119
+ export function lanBindState(profile: string, home: string = dshHome()): { blockPresent: boolean; host?: string; port?: number } {
120
+ const state = managedBindOf(readPatchContent(profilePatchFile(profile, home)))
121
+ if (state === undefined) return { blockPresent: false }
122
+ return { blockPresent: true, host: state.host, port: state.port }
123
+ }
124
+
125
+ /**
126
+ * Write (or rewrite) the managed block with the given bind. The rest of the
127
+ * patch file is preserved; the block is rewritten atomically (unique temp
128
+ * file + rename, so concurrent writers can never rename a half-written
129
+ * file) with the original file's permissions preserved. A hand-truncated
130
+ * unterminated block (BEGIN marker without END, which stripManagedBlock
131
+ * cannot match) is truncated at its BEGIN marker first so the rewrite can
132
+ * never stack a second webserver row onto the orphan.
133
+ */
134
+ export function writeLanBind(host: LanBindHost, port: number, profile: string, home: string = dshHome()): void {
135
+ const file = profilePatchFile(profile, home)
136
+ const stripped = stripManagedBlock(readPatchContent(file))
137
+ const orphanBegin = stripped.indexOf(LAN_BIND_BLOCK_BEGIN)
138
+ const base = (orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd()
139
+ const content = `${base}\n\n${managedBlock(host, port)}`
140
+ const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600
141
+ mkdirSync(dirname(file), { recursive: true })
142
+ const temp = `${file}.remote-web-ui-tmp-${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
143
+ writeFileSync(temp, content, { mode })
144
+ renameSync(temp, file)
145
+ }
@@ -12,6 +12,7 @@ import { request as httpRequest } from 'node:http'
12
12
  import { connect } from 'node:net'
13
13
  import type { Duplex } from 'node:stream'
14
14
  import { writeJson } from './http.ts'
15
+ import type { InnerAuth } from './inner-auth.ts'
15
16
 
16
17
  /** WebSocket handshake headers forwarded to the loopback upstream. */
17
18
  const WS_FORWARD_HEADERS = [
@@ -37,12 +38,30 @@ const HTTP_FORWARD_RESPONSE_HEADERS = [
37
38
  * @param res - the outer response.
38
39
  * @param port - local webServer port.
39
40
  * @param upstreamPath - path + query on 127.0.0.1 (must start with `/`).
41
+ * @param auth - when given, the process's inner browser-auth credential is
42
+ * attached so the connection plugin's /api route (fence + browser auth,
43
+ * authority-bound cookie, no loopback exemption on this cohort) accepts
44
+ * the re-issued request; a 401 answer invalidates the cached credential.
40
45
  */
41
46
  export function proxyLoopbackHttp(
42
47
  req: IncomingMessage,
43
48
  res: ServerResponse,
44
49
  port: number,
45
50
  upstreamPath: string,
51
+ auth?: InnerAuth,
52
+ ): void {
53
+ void Promise.resolve(auth?.ready())
54
+ .catch(() => undefined)
55
+ .then((cookie) => { pipeLoopbackHttp(req, res, port, upstreamPath, auth, typeof cookie === 'string' ? cookie : undefined) })
56
+ }
57
+
58
+ function pipeLoopbackHttp(
59
+ req: IncomingMessage,
60
+ res: ServerResponse,
61
+ port: number,
62
+ upstreamPath: string,
63
+ auth: InnerAuth | undefined,
64
+ cookie: string | undefined,
46
65
  ): void {
47
66
  const headers: Record<string, string> = {
48
67
  host: `127.0.0.1:${String(port)}`,
@@ -54,6 +73,7 @@ export function proxyLoopbackHttp(
54
73
  if (typeof contentLength === 'string') headers['content-length'] = contentLength
55
74
  const accept = req.headers.accept
56
75
  if (typeof accept === 'string') headers.accept = accept
76
+ if (cookie !== undefined) headers.cookie = cookie
57
77
 
58
78
  const upstream = httpRequest({
59
79
  host: '127.0.0.1',
@@ -62,6 +82,10 @@ export function proxyLoopbackHttp(
62
82
  method: req.method,
63
83
  headers,
64
84
  }, (upstreamRes) => {
85
+ // The cached credential went stale (secret rotation, credential store
86
+ // reset): drop it so the next request re-redeems. The in-flight response
87
+ // still pipes through untouched.
88
+ if (upstreamRes.statusCode === 401 && cookie !== undefined) auth?.invalidate()
65
89
  const out: OutgoingHttpHeaders = {}
66
90
  for (const name of HTTP_FORWARD_RESPONSE_HEADERS) {
67
91
  const value = upstreamRes.headers[name]
@@ -87,6 +111,8 @@ export function proxyLoopbackHttp(
87
111
  * @param head - bytes already read past the handshake.
88
112
  * @param port - local webServer port.
89
113
  * @param upstreamPath - path + query on 127.0.0.1.
114
+ * @param cookie - the inner browser-auth credential for the handshake, when
115
+ * the upstream route enforces it (the gateway event-stream mux does).
90
116
  */
91
117
  export function proxyLoopbackUpgrade(
92
118
  req: IncomingMessage,
@@ -94,6 +120,7 @@ export function proxyLoopbackUpgrade(
94
120
  head: Buffer,
95
121
  port: number,
96
122
  upstreamPath: string,
123
+ cookie?: string,
97
124
  ): void {
98
125
  const lines = [
99
126
  `GET ${upstreamPath} HTTP/1.1`,
@@ -101,6 +128,7 @@ export function proxyLoopbackUpgrade(
101
128
  'Upgrade: websocket',
102
129
  'Connection: Upgrade',
103
130
  ]
131
+ if (cookie !== undefined) lines.push(`Cookie: ${cookie}`)
104
132
  for (const name of WS_FORWARD_HEADERS) {
105
133
  const value = req.headers[name]
106
134
  if (value === undefined) continue
@@ -109,6 +137,12 @@ export function proxyLoopbackUpgrade(
109
137
  const handshake = `${lines.join('\r\n')}\r\n\r\n`
110
138
 
111
139
  const upstream = connect(port, '127.0.0.1')
140
+ // A half-open tunnel path (dead-but-alive edge connection) would otherwise
141
+ // let the phone's mux socket sit stale for minutes: keepalive probes the
142
+ // pipe periodically and destroys both legs on the first real gap, so the
143
+ // client's reconnect + stream re-baseline re-syncs the feed in seconds.
144
+ ;(socket as unknown as import('node:net').Socket).setKeepAlive(true, 20_000)
145
+ upstream.setKeepAlive(true, 20_000)
112
146
  const tearDown = (): void => {
113
147
  upstream.destroy()
114
148
  socket.destroy()
@@ -201,4 +201,4 @@ describe('PairingService device persistence', () => {
201
201
  const deviceId = result.ok ? result.deviceId : ''
202
202
  expect(saved[deviceId]?.userAgent).toBe('Mozilla/5.0 TestPhone')
203
203
  })
204
- })
204
+ })
package/src/pairing.ts CHANGED
@@ -7,8 +7,12 @@
7
7
  * Security invariants:
8
8
  * - One active token at a time; `issue()` replaces it, so a refreshed QR
9
9
  * immediately invalidates the previous link.
10
- * - A token is consumed by the first successful `accept()` — reuse is
11
- * refused with `'used'`.
10
+ * - A token stays re-usable until it expires or is replaced: the first
11
+ * successful `accept()` marks it consumed, but the same link may pair
12
+ * again within the window (each re-accept mints a fresh device session).
13
+ * Mobile flows routinely split across cookie contexts (camera preview to
14
+ * in-app browser to the system browser), and the later context must be
15
+ * able to complete its own pairing from the same link.
12
16
  * - Tokens expire; `accept()` on an expired token is refused like an
13
17
  * unknown one (no oracle for validity).
14
18
  * - `stop()` revokes every device session and clears the token, so paired
@@ -51,8 +55,16 @@ export interface TokenRecord {
51
55
  address?: string
52
56
  }
53
57
 
54
- /** Default idle-expiry window: 7 days without heartbeat or a gated request. */
55
- export const DEFAULT_IDLE_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000
58
+ /**
59
+ * Default idle-expiry window: 30 days without a heartbeat or a gated
60
+ * request. The reopen service worker refreshes lastSeenAt on every
61
+ * navigation it serves, so the window only runs out through genuine
62
+ * disuse; 30 days matches the browser-credential lifetimes the surrounding
63
+ * flow was built around, while the effective device lifetime stays shorter
64
+ * than the 365-day cookie because of this sweep. Override per deployment
65
+ * through the idleExpireMs config.
66
+ */
67
+ export const DEFAULT_IDLE_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000
56
68
 
57
69
  /** Cap on the persisted/displayed User-Agent string. */
58
70
  const MAX_USER_AGENT_CHARS = 180
@@ -379,8 +391,10 @@ export class PairingService {
379
391
  */
380
392
  accept(token: string, userAgent?: string): AcceptResult {
381
393
  const record = this.tokens.get(token)
382
- if (record === undefined || record.consumed || this.stopped || this.clock.now() > record.expiresAt) {
383
- return { ok: false, code: record?.consumed === true ? 'used' : 'invalid' }
394
+ // A consumed token stays a valid bearer credential until expiry or
395
+ // replacement (see the module doc): re-accept mints a fresh device.
396
+ if (record === undefined || this.stopped || this.clock.now() > record.expiresAt) {
397
+ return { ok: false, code: 'invalid' }
384
398
  }
385
399
  record.consumed = true
386
400
  const deviceId = this.clock.randomToken()
package/src/remote-api.ts CHANGED
@@ -1,21 +1,21 @@
1
1
  /**
2
2
  * The remote desktop data channel: `/remote` is this plugin's own prefix, so
3
- * the paired-device cookie is the access control (exactly like `/m/api`).
4
- * After that gate, every fenced same-origin path the browser rewrote here is
5
- * re-issued to 127.0.0.1 as a loopback-shaped request so sibling plugin
6
- * fences (and the connection plugin's `/api`) accept it — no `--trusted-host`
7
- * and no per-plugin pairing consult.
3
+ * the paired-device cookie is the access control. After that gate, every
4
+ * fenced same-origin path the browser rewrote here is re-issued to 127.0.0.1
5
+ * as a loopback-shaped request so sibling plugin fences (and the connection
6
+ * plugin's `/api`) accept it — no `--trusted-host` and no per-plugin pairing
7
+ * consult.
8
8
  *
9
9
  * Security model:
10
10
  * - While `requirePairingForLan` is on (default), every request must carry a
11
11
  * live paired-device cookie, enforced before any bytes are forwarded and
12
12
  * before any host call. With the policy off, the cookie gate is skipped
13
- * (the loopback-only denials below still apply).
14
- * - The SDK's loopback-only privileged methods (native dialogs, the settings
15
- * plane, credentials — the `PRIVILEGED_METHODS` set of client-connection)
16
- * are denied here. The set is pinned by tests/remote-contract.spec.ts.
17
- * - `/api/pair/*`, `/api/update/*`, `/api/plugin-manager/*`,
18
- * `/api/dsh-desktop-launcher/*` and `/api/dsh-web-ui-settings/*` stay physically local.
13
+ * (the local-only denials below still apply).
14
+ * - A paired remote desktop is a full-control credential: the browser half
15
+ * flips the official UI into host mode (the transport ownsHost hook), so
16
+ * the configuration plane (settings, credentials, presets, deliverables)
17
+ * rides this channel like every other call. The four control planes in
18
+ * LOCAL_ONLY_PREFIXES stay physically local.
19
19
  * - Everything else is HTTP- or WebSocket-proxied to the local port with
20
20
  * Host rewritten, Origin and cookies dropped, and a synthetic same-origin
21
21
  * browser marker added after authentication. Plugin loopback fences then
@@ -28,24 +28,27 @@ import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
28
28
  import type { PairingService } from './pairing.ts'
29
29
  import { readCookie } from './gate.ts'
30
30
  import { writeJson } from './http.ts'
31
+ import type { InnerAuth } from './inner-auth.ts'
31
32
  import { proxyLoopbackHttp, proxyLoopbackUpgrade } from './loopback-proxy.ts'
32
33
  import {
33
- DESKTOP_LAUNCHER_PATH,
34
- LOOPBACK_ONLY_METHODS,
35
- PLUGIN_MANAGER_PATH,
34
+ REMOTE_DEVICE_HEADER,
35
+ REMOTE_DEVICE_QUERY,
36
36
  REMOTE_PREFIX,
37
37
  REMOTE_UPGRADE_PATHS,
38
- WEB_UI_SETTINGS_BRIDGE_PATH,
38
+ localOnlyDenial,
39
39
  } from './remote-methods.ts'
40
40
 
41
41
  export {
42
42
  DESKTOP_LAUNCHER_PATH,
43
- LOOPBACK_ONLY_METHODS,
43
+ LOCAL_ONLY_PREFIXES,
44
44
  PLUGIN_MANAGER_PATH,
45
45
  REMOTE_API_PATHS,
46
+ REMOTE_DEVICE_HEADER,
47
+ REMOTE_DEVICE_QUERY,
46
48
  REMOTE_PREFIX,
47
49
  REMOTE_UPGRADE_PATHS,
48
50
  WEB_UI_SETTINGS_BRIDGE_PATH,
51
+ localOnlyDenial,
49
52
  } from './remote-methods.ts'
50
53
  export { REMOTE_API_PREFIX } from './remote-methods.ts'
51
54
 
@@ -76,6 +79,13 @@ export interface RemoteApiDeps {
76
79
  * edit takes effect without a restart. Defaults to true.
77
80
  */
78
81
  requirePairingForLan?: boolean | (() => boolean)
82
+ /**
83
+ * The process's inner browser-auth credential attached to re-issued
84
+ * requests (the connection plugin's /api route enforces that cookie and
85
+ * the pairing gate above already ran). Undefined keeps the previous
86
+ * cookie-less behavior — the inner route then answers 401 on this cohort.
87
+ */
88
+ auth?: InnerAuth
79
89
  }
80
90
 
81
91
  /** One SDK-shaped error envelope (keeps the desktop client's parse path intact). */
@@ -104,31 +114,28 @@ export function innerPathOf(pathname: string): string | undefined {
104
114
  }
105
115
 
106
116
  /**
107
- * Whether a paired inner path must stay physically local.
117
+ * Whether a paired inner path must stay physically local (delegates to the
118
+ * shared LOCAL_ONLY_PREFIXES table).
108
119
  * @returns a denial message, or undefined when the path may be proxied.
109
120
  */
110
121
  export function loopbackOnlyDenial(innerPath: string): string | undefined {
111
- if (innerPath === '/api/pair' || innerPath.startsWith('/api/pair/')) {
112
- return 'pairing endpoints stay loopback-only and stay unreachable from a paired remote desktop'
113
- }
114
- if (innerPath === '/api/update' || innerPath.startsWith('/api/update/')) {
115
- return 'update endpoints stay loopback-only and stay unreachable from a paired remote desktop'
116
- }
117
- if (innerPath === PLUGIN_MANAGER_PATH || innerPath.startsWith(`${PLUGIN_MANAGER_PATH}/`)) {
118
- return 'plugin-manager stays loopback-only and stays unreachable from a paired remote desktop'
119
- }
120
- if (innerPath === DESKTOP_LAUNCHER_PATH || innerPath.startsWith(`${DESKTOP_LAUNCHER_PATH}/`)) {
121
- return 'desktop-launcher endpoints stay loopback-only and stay unreachable from a paired remote desktop'
122
- }
123
- if (innerPath === WEB_UI_SETTINGS_BRIDGE_PATH || innerPath.startsWith(`${WEB_UI_SETTINGS_BRIDGE_PATH}/`)) {
124
- return 'settings-bridge endpoints stay loopback-only and stay unreachable from a paired remote desktop'
125
- }
126
- if (!innerPath.startsWith('/api/')) return undefined
127
- const method = innerPath.slice('/api/'.length)
128
- if (method !== '' && !method.includes('/') && LOOPBACK_ONLY_METHODS.has(method)) {
129
- return `${method} is loopback-only and stays unreachable from a paired remote desktop`
130
- }
131
- return undefined
122
+ return localOnlyDenial(innerPath)
123
+ }
124
+
125
+ /**
126
+ * Resolve a live device credential for a gated HTTP request: the pairing
127
+ * cookie first, then the cookieless header the boot patch attaches. Unknown
128
+ * or revoked ids are a no-op - a stale id never re-arms a device.
129
+ * @returns the touched device id, or undefined when neither credential is live.
130
+ */
131
+ export function pairedDeviceIdOf(req: IncomingMessage, service: PairingService): string | undefined {
132
+ const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName)
133
+ const headerDevice = typeof req.headers[REMOTE_DEVICE_HEADER] === 'string'
134
+ ? (req.headers[REMOTE_DEVICE_HEADER] as string)
135
+ : undefined
136
+ const id = cookieDevice ?? headerDevice
137
+ if (id === undefined) return undefined
138
+ return service.touchDevice(id) ? id : undefined
132
139
  }
133
140
 
134
141
  /**
@@ -145,9 +152,8 @@ export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
145
152
  // (a stale client rewrite must not 403); loopback-only denials stay below.
146
153
  const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
147
154
  if (require) {
148
- const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
149
- const paired = deviceId !== undefined && service.touchDevice(deviceId)
150
- if (!paired) {
155
+ const paired = pairedDeviceIdOf(req, service)
156
+ if (paired === undefined) {
151
157
  req.resume()
152
158
  envelopeError(res, 403, 'invalid-request', 'unpaired', 'this device is not paired with the desktop')
153
159
  return
@@ -175,7 +181,7 @@ export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
175
181
  return
176
182
  }
177
183
 
178
- proxyLoopbackHttp(req, res, port, `${inner}${url.search}`)
184
+ proxyLoopbackHttp(req, res, port, `${inner}${url.search}`, deps.auth)
179
185
  }
180
186
 
181
187
  return [{ kind: 'prefix', path: REMOTE_PREFIX, handler }]
@@ -209,8 +215,15 @@ export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute
209
215
  const handlerFor = (fallbackPath: string) => (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
210
216
  const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
211
217
  if (require) {
212
- const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
213
- if (deviceId === undefined || !service.touchDevice(deviceId)) {
218
+ // WebSocket handshakes cannot carry headers from the Web API, so the
219
+ // cookieless credential rides the query; the cookie stays the primary.
220
+ let queryDevice: string | undefined
221
+ try {
222
+ queryDevice = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get(REMOTE_DEVICE_QUERY) ?? undefined
223
+ } catch { /* fall through to the cookie */ }
224
+ const deviceId = pairedDeviceIdOf(req, service)
225
+ const paired = deviceId ?? (queryDevice !== undefined && service.touchDevice(queryDevice) ? queryDevice : undefined)
226
+ if (paired === undefined) {
214
227
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
215
228
  socket.destroy()
216
229
  return
@@ -223,7 +236,16 @@ export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute
223
236
  socket.destroy()
224
237
  return
225
238
  }
226
- proxyLoopbackUpgrade(req, socket, head, port, inner)
239
+ // The inner handshake needs the browser-auth credential (the gateway
240
+ // event-stream route enforces it); resolving it is async, so the
241
+ // handshake bytes are written once it settles. A missing credential
242
+ // proceeds as before — the gateway route then refuses.
243
+ void Promise.resolve(deps.auth?.ready())
244
+ .catch(() => undefined)
245
+ .then((cookie) => {
246
+ if (socket.destroyed) return
247
+ proxyLoopbackUpgrade(req, socket, head, port, inner, typeof cookie === 'string' ? cookie : undefined)
248
+ })
227
249
  }
228
250
 
229
251
  return REMOTE_UPGRADE_PATHS.map((path) => ({
@@ -10,8 +10,15 @@
10
10
  * (hooks + pending unpaired signal) instead of patching twice.
11
11
  *
12
12
  * The rewrite decisions are generated from REMOTE_CHANNEL_RULES, the same
13
- * data the browser patch consumes — the two cannot drift apart. The script
14
- * self-skips on loopback origins and never throws.
13
+ * data the browser patch consumes — the two cannot drift apart. On this
14
+ * 0.1.2-alpha.2 line the "configuration plane is local" behavior lives in
15
+ * the browser (client plugins branch on connection.isLoopback), so the
16
+ * script also flips the official UI into host mode by installing the
17
+ * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
18
+ * connection plugin reads it: the paired remote desktop gets the full
19
+ * settings/credentials/presets surface, and every call still rides the
20
+ * gated /remote channel. The script self-skips on loopback origins and
21
+ * never throws.
15
22
  * @module @linxin666/dsh-remote-web-ui/remote-channel-boot
16
23
  */
17
24
 
@@ -32,7 +39,23 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
32
39
  'var w=window,loc=w.location,h=loc.hostname;' +
33
40
  // Loopback origins keep the original paths (mirrors isLoopbackHostname).
34
41
  "if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
42
+ // Host mode: the paired remote desktop presents itself as the machine
43
+ // owner, so the official UI keeps its full configuration surface (the
44
+ // settings mirror, document controller, and deliverables open actions
45
+ // all branch on connection.isLoopback). Must run before any boot entry.
46
+ 'try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}' +
35
47
  'var R=' + json + ';' +
48
+ // The cookieless device credential: read lazily per call - the
49
+ // /pair-app capture script sets it in head AFTER this boot script ran,
50
+ // so a parse-time read would always see null.
51
+ 'function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}' +
52
+ 'function att(init){' +
53
+ 'var dv=rdv();' +
54
+ 'if(dv===null)return init;' +
55
+ 'var h=init&&init.headers;' +
56
+ 'if(typeof Headers!=="undefined"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}' +
57
+ 'if(typeof h==="object"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}' +
58
+ 'return init}' +
36
59
  'function sf(p){' +
37
60
  'if(p.indexOf(R.pairPrefix)===0)return false;' +
38
61
  'if(p.indexOf(R.updatePrefix)===0)return false;' +
@@ -80,7 +103,7 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
80
103
  'var next=new URL(url);' +
81
104
  'next.pathname=rp(url.pathname);' +
82
105
  'var target=typeof input==="string"||input instanceof URL?next.toString():new Request(next,input);' +
83
- 'return Promise.resolve(of.call(w,target,init)).then(function(res){' +
106
+ 'return Promise.resolve(of.call(w,target,att(init))).then(function(res){' +
84
107
  // denied() is sync-false for non-403 and a promise otherwise.
85
108
  'void Promise.resolve(denied(res)).then(signal);' +
86
109
  'return res})}' +
@@ -92,6 +115,8 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
92
115
  'if(o!==""&&o===loc.origin&&sw(p.pathname)){' +
93
116
  'var nx=new URL(p);' +
94
117
  'nx.pathname=rp(p.pathname);' +
118
+ 'var dvv=rdv();' +
119
+ 'if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);' +
95
120
  'return protocols!==undefined?new OW(nx,protocols):new OW(nx)}' +
96
121
  'return protocols!==undefined?new OW(url,protocols):new OW(url)};' +
97
122
  'w.WebSocket.prototype=OW.prototype;' +
@@ -103,6 +128,8 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
103
128
  'if(so(p)&&sf(p.pathname)){' +
104
129
  'var nx=new URL(p);' +
105
130
  'nx.pathname=rp(p.pathname);' +
131
+ 'var dvv=rdv();' +
132
+ 'if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);' +
106
133
  'return new OE(nx,cfg)}' +
107
134
  'return new OE(url,cfg)};' +
108
135
  'w.EventSource.prototype=OE.prototype}' +
@@ -123,9 +150,49 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
123
150
  'for(var i=0;i<restores.length;i++)restores[i]();' +
124
151
  'try{delete w[' + seat + ']}catch(e){w[' + seat + ']=undefined}};' +
125
152
  'w[' + seat + ']=seat' +
153
+ // The boot watchdog rides the same IIFE and skip conditions: it needs
154
+ // the non-loopback gate above (the desktop boots over loopback without
155
+ // a tunnel in the path) and the try/catch fail-closed envelope.
156
+ buildBootWatchdogScript() +
126
157
  '}catch(e){}' +
127
158
  '})();'
128
159
  }
129
160
 
161
+ /** Boot-watchdog latch: one self-reload per session while the boot is broken. */
162
+ export const BOOT_WATCHDOG_KEY = 'dsh-remote-boot-reload'
163
+
164
+ /** Total wait for the app's first conversation surface before the reload. */
165
+ const BOOT_WATCHDOG_WAIT_MS = 15_000
166
+
167
+ /** Watchdog poll cadence. */
168
+ const BOOT_WATCHDOG_POLL_MS = 1_000
169
+
170
+ /**
171
+ * Build the boot-watchdog script fragment. A remote boot has no recovery
172
+ * path of its own: the SPA mounts nothing when a boot-critical request dies
173
+ * (a tunnel-edge 429 under burst, a dropped stream, a boot-order race), and
174
+ * the phone then shows a permanently blank shell. The watchdog polls for the
175
+ * app's conversation surface and reloads once when it never appears; the
176
+ * sessionStorage latch keeps a genuinely broken deployment from looping,
177
+ * and a successful boot clears the latch so a later failure can recover.
178
+ */
179
+ export function buildBootWatchdogScript(waitMs = BOOT_WATCHDOG_WAIT_MS, pollMs = BOOT_WATCHDOG_POLL_MS): string {
180
+ const key = JSON.stringify(BOOT_WATCHDOG_KEY)
181
+ return (
182
+ // Leading semicolon: the preceding seat assignment ends without one,
183
+ // and the fragment must parse when spliced into the IIFE.
184
+ ';function wBoot(){try{return !!(w.document&&w.document.querySelector&&(w.document.querySelector("[data-conversation-scroll]")||w.document.querySelector("[data-slot=\\"conversation\\"]")))}catch(e){return false}}' +
185
+ 'function wTick(n){try{' +
186
+ 'if(wBoot()){try{w.sessionStorage.removeItem(' + key + ')}catch(e){}return}' +
187
+ 'if(n<' + waitMs + '){w.setTimeout(function(){wTick(n+' + pollMs + ')},' + pollMs + ');return}' +
188
+ 'var done=false;try{done=w.sessionStorage.getItem(' + key + ')==="1"}catch(e){}' +
189
+ 'if(done)return;' +
190
+ 'try{w.sessionStorage.setItem(' + key + ',"1")}catch(e){}' +
191
+ 'w.location.reload()' +
192
+ '}catch(e){}}' +
193
+ 'if(typeof w.setTimeout==="function")w.setTimeout(function(){wTick(0)},' + pollMs + ');'
194
+ )
195
+ }
196
+
130
197
  /** The script this plugin contributes; built once from the live rules. */
131
198
  export const REMOTE_CHANNEL_BOOT_SCRIPT: string = buildRemoteChannelBootScript()
@@ -9,6 +9,10 @@
9
9
  /** The gated mirror prefix (must match src/remote-methods.ts). */
10
10
  export const REMOTE_PREFIX = '/remote'
11
11
 
12
+ import { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY } from './remote-methods.ts'
13
+
14
+ export { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY }
15
+
12
16
  /** Connection-plugin method prefix under the gated channel. */
13
17
  export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`
14
18
 
@@ -24,6 +28,12 @@ export interface RemoteChannelRules {
24
28
  readonly gitPrefix: string
25
29
  readonly petPrefix: string
26
30
  readonly wsPaths: readonly string[]
31
+ /** Header carrying the cookieless device credential on gated fetches. */
32
+ readonly deviceHeader: string
33
+ /** sessionStorage key holding the device credential (set by /pair-app). */
34
+ readonly deviceKey: string
35
+ /** Query parameter carrying it on WebSocket upgrades. */
36
+ readonly deviceQuery: string
27
37
  }
28
38
 
29
39
  /** The live rule set. */
@@ -37,13 +47,18 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
37
47
  sidebarPrefix: '/sidebar/',
38
48
  gitPrefix: '/git/',
39
49
  petPrefix: '/pet/',
50
+ // The official 0.1.2-alpha.2 line opens exactly one stream socket (the
51
+ // Typert gateway mux, /api/remote.mux); the legacy /api/events.* paths
52
+ // were removed by the SDK and stay absent here.
40
53
  wsPaths: [
41
- '/api/events.mux',
42
- '/api/events.host',
54
+ '/api/remote.mux',
43
55
  '/sidebar/ws/terminal',
44
56
  '/sidebar/ws/agent-terminals',
45
57
  '/api/dsh-ssh/terminal',
46
58
  ],
59
+ deviceHeader: REMOTE_DEVICE_HEADER,
60
+ deviceKey: 'dsh-remote-device',
61
+ deviceQuery: REMOTE_DEVICE_QUERY,
47
62
  }
48
63
 
49
64
  /** The window global the boot patch publishes its seat under. */