dsh-zen-remote 2.3.6 → 2.3.7

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 (66) hide show
  1. package/AGENTS.md +15 -5
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +2 -2
  5. package/docs/interface.md +10 -4
  6. package/docs/remote-access.en.md +6 -0
  7. package/docs/remote-access.md +6 -0
  8. package/lan-gate.mjs +7 -0
  9. package/lib/client-pairing.js +10 -0
  10. package/lib/client-pairing.js.map +1 -1
  11. package/lib/client-routes.js +183 -94
  12. package/lib/client-routes.js.map +1 -1
  13. package/lib/client.js +505 -78
  14. package/lib/debug-redact.js +253 -0
  15. package/lib/debug-redact.js.map +1 -0
  16. package/lib/debug-report.js +60 -0
  17. package/lib/debug-report.js.map +1 -0
  18. package/lib/fetch-route-intercept.js +23 -0
  19. package/lib/fetch-route-intercept.js.map +1 -1
  20. package/lib/index.js +293 -45
  21. package/lib/index.js.map +1 -1
  22. package/lib/log-ring.js +76 -0
  23. package/lib/log-ring.js.map +1 -0
  24. package/lib/types/client/effects/remote-drop.d.ts +20 -8
  25. package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
  26. package/lib/types/client/locales.d.ts +8 -1
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  29. package/lib/types/client/styles/misc.css.d.ts +1 -1
  30. package/lib/types/client/styles/misc.css.d.ts.map +1 -1
  31. package/lib/types/client-data/remote-drop.d.ts +64 -0
  32. package/lib/types/client-data/remote-drop.d.ts.map +1 -1
  33. package/lib/types/client-data/settings-form.d.ts +3 -0
  34. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  35. package/lib/types/client-pairing.d.ts +8 -0
  36. package/lib/types/client-pairing.d.ts.map +1 -1
  37. package/lib/types/client-routes.d.ts +23 -0
  38. package/lib/types/client-routes.d.ts.map +1 -1
  39. package/lib/types/debug-redact.d.ts +68 -0
  40. package/lib/types/debug-redact.d.ts.map +1 -0
  41. package/lib/types/debug-report.d.ts +58 -0
  42. package/lib/types/debug-report.d.ts.map +1 -0
  43. package/lib/types/fetch-route-intercept.d.ts +26 -0
  44. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  45. package/lib/types/index.d.ts +7 -1
  46. package/lib/types/index.d.ts.map +1 -1
  47. package/lib/types/log-ring.d.ts +57 -0
  48. package/lib/types/log-ring.d.ts.map +1 -0
  49. package/package.json +3 -2
  50. package/pwa/app.css +13 -2
  51. package/scripts/check-debug-report.mjs +277 -0
  52. package/scripts/check-remote-drop.mjs +130 -0
  53. package/scripts/check-tablet-safe-area.mjs +49 -0
  54. package/src/client/effects/remote-drop.ts +271 -24
  55. package/src/client/locales.ts +20 -4
  56. package/src/client/settings/SettingsSection.tsx +120 -3
  57. package/src/client/styles/misc.css.ts +38 -0
  58. package/src/client-data/remote-drop.ts +103 -0
  59. package/src/client-data/settings-form.ts +3 -0
  60. package/src/client-pairing.ts +11 -0
  61. package/src/client-routes.ts +196 -90
  62. package/src/debug-redact.ts +290 -0
  63. package/src/debug-report.ts +93 -0
  64. package/src/fetch-route-intercept.ts +29 -0
  65. package/src/index.ts +282 -43
  66. package/src/log-ring.ts +101 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * CU-2: the debug report's redactor (SPEC 方案 C). Pure functions — no DSH
3
+ * imports, no I/O — that turn free text and addresses into shapes an issue
4
+ * report can carry: absolute paths lose everything but their leaf, home
5
+ * paths fold to `~`, virtual session ids keep only the server id and the
6
+ * first 8 characters of the session id, long secret-shaped runs become
7
+ * `<token>`, and URL hostnames degrade to their address class. The report
8
+ * assembler (debug-report.ts) runs every string it emits through
9
+ * {@link redactText}; the check script pins the truth table, including the
10
+ * "an injected path/hostname/token can never be recovered from the output"
11
+ * assertion.
12
+ *
13
+ * The passes are ordered so they cannot un-do each other: URLs are lifted
14
+ * out first (pass 0, a sentinel in their place) and restored last in
15
+ * degraded form, so no text-wide pass can merge a URL's port and path into
16
+ * a token run; then virtual ids (a ≥32-char id would otherwise die as a
17
+ * token run), the home fold BEFORE the token pass (a long home path is
18
+ * itself a ≥32 run), the token pass, and the path passes (UNC, drive
19
+ * letter, Unix). Path leaves never contain whitespace, and Unix paths must
20
+ * start at a boundary (string start, whitespace, `(`, `~`) so relative
21
+ * paths and `a/b`-style words stay untouched.
22
+ *
23
+ * Deliberately ZERO relative imports: the check script imports this file
24
+ * straight off src/ under Node's strip-only type stripping (the same seam
25
+ * scripts/check-remote-drop.mjs uses for client-data/remote-drop.ts), and a
26
+ * `.js` specifier cannot resolve there. The address-class predicate below
27
+ * therefore restates the pairing normalizer's private-network ranges; the
28
+ * two are pinned against each other by a lib-level test in
29
+ * test/client-routes.test.cjs so they cannot drift apart silently.
30
+ */
31
+ /** The address classes {@link serverUrlShape} reports. Deliberately coarse:
32
+ * enough to triage "can the two machines even see each other", never the
33
+ * hostname itself. */
34
+ export type ServerUrlClass = 'loopback' | 'lan-ip' | 'public-ip' | 'domain'
35
+
36
+ /** One pass's replacement result for a matched run of the token pass. */
37
+ const TOKEN_PLACEHOLDER = '<token>'
38
+
39
+ /** The private-network address ranges the pairing normalizer admits for
40
+ * plain http (client-pairing.ts's `isPrivateNetworkHost`), restated here
41
+ * because this module must import nothing (see the header). IPv4: `10/8`,
42
+ * `172.16/12`, `192.168/16`, `100.64/10` (CGNAT/Tailscale); IPv6: `fc00::/7`
43
+ * and `fe80::/10` by their first group. Loopback and `*.local` are decided
44
+ * by the caller, not here. */
45
+ function isLanAddress(host: string): boolean {
46
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
47
+ if (ipv4 !== null) {
48
+ const a = Number(ipv4[1])
49
+ const b = Number(ipv4[2])
50
+ if (a === 10) return true
51
+ if (a === 172 && b >= 16 && b <= 31) return true
52
+ if (a === 192 && b === 168) return true
53
+ if (a === 100 && b >= 64 && b <= 127) return true
54
+ return false
55
+ }
56
+ const firstGroup = /^([0-9a-fA-F]{1,4}):/u.exec(host)
57
+ if (firstGroup !== null) {
58
+ const first = parseInt(firstGroup[1], 16)
59
+ return (first >= 0xfc00 && first <= 0xfdff) || (first >= 0xfe80 && first <= 0xfebf)
60
+ }
61
+ return false
62
+ }
63
+
64
+ /** Which class one hostname belongs to. Loopback first (it is also in the
65
+ * private ranges — order matters), then the remaining private ranges as
66
+ * `lan-ip`, then numeric literals as public addresses, everything else as
67
+ * `domain` (the "cannot tell" bucket, per SPEC). IPv6 brackets are
68
+ * stripped first — classification is bracket-free. */
69
+ function classifyHost(bracketed: string): ServerUrlClass {
70
+ const host = bracketed.startsWith('[') && bracketed.endsWith(']') ? bracketed.slice(1, -1) : bracketed
71
+ if (host === 'localhost' || host === '::1' || host.startsWith('127.')) return 'loopback'
72
+ if (host.endsWith('.local')) return 'domain'
73
+ if (isLanAddress(host)) return 'lan-ip'
74
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return 'public-ip'
75
+ if (host.includes(':')) return 'public-ip'
76
+ return 'domain'
77
+ }
78
+
79
+ /**
80
+ * One server address's SHAPE: `scheme://class[:port]` — never the hostname.
81
+ * `http://192.168.1.10:3080` → `http://lan-ip:3080`; `https://myserver.example.com`
82
+ * → `https://domain`; the scheme's default port is omitted. Unset input
83
+ * answers `'unset'`, unparsable input `'invalid'`.
84
+ */
85
+ export function serverUrlShape(url: unknown): string {
86
+ if (typeof url !== 'string' || url.trim() === '') return 'unset'
87
+ let parsed: URL
88
+ try {
89
+ parsed = new URL(url.trim())
90
+ } catch {
91
+ return 'invalid'
92
+ }
93
+ const scheme = parsed.protocol === 'https:' ? 'https' : parsed.protocol === 'http:' ? 'http' : parsed.protocol.replace(/:$/u, '')
94
+ const port = parsed.port
95
+ const defaultPort = (scheme === 'https' && port === '443') || (scheme === 'http' && port === '80')
96
+ return `${scheme}://${classifyHost(parsed.hostname)}${port !== '' && !defaultPort ? `:${port}` : ''}`
97
+ }
98
+
99
+ /**
100
+ * A bind address's shape for the config plate (`host`): the same address
101
+ * classes as {@link serverUrlShape}, plus `wildcard` for the all-interfaces
102
+ * binds (`0.0.0.0` / `::`) — the one bind fact triage actually needs. Unset
103
+ * input answers `'unset'`. A raw address never reaches the report.
104
+ */
105
+ export function bindHostShape(host: unknown): string {
106
+ if (typeof host !== 'string' || host.trim() === '') return 'unset'
107
+ const value = host.trim()
108
+ if (value === '0.0.0.0' || value === '::' || value === '[::]') return 'wildcard'
109
+ return classifyHost(value)
110
+ }
111
+
112
+ /** The token pass: any run of ≥32 base64/base64url-ish characters (hex
113
+ * secrets, base64 keys, JWT segments — `-` and `_` included so a base64url
114
+ * signature dies whole instead of leaving a half) — long enough that real
115
+ * prose never trips it, short enough to catch every credential shape this
116
+ * plugin handles. */
117
+ const TOKEN_RUN = /[A-Za-z0-9+/=_-]{32,}/gu
118
+
119
+ /** The same run, as a CONTAINS test for one URL query value / path segment
120
+ * (non-global: `test` must not carry `lastIndex` between calls). */
121
+ const HAS_TOKEN_RUN = /[A-Za-z0-9+/=_-]{32,}/u
122
+
123
+ /** The same run without `/`, for one URL path segment at a time — a long
124
+ * path is not a secret, a long SEGMENT is. */
125
+ const HAS_TOKEN_SEGMENT = /[A-Za-z0-9+=_-]{32,}/u
126
+
127
+ /** Pass 0's URL candidates: a scheme this report can meet, then everything
128
+ * up to whitespace, a quote or an angle bracket. Trailing sentence
129
+ * punctuation is peeled back off afterwards ({@link URL_TRAILING}). */
130
+ const URL_CANDIDATE = /(?:https?|wss?|ftp):\/\/[^\s"'<>\u0000]+/gu
131
+
132
+ /** Sentence punctuation that ends a URL in prose (`see http://x/y).`) — `]`
133
+ * is deliberately absent: it closes a bracketed IPv6 host. */
134
+ const URL_TRAILING = /[.,;:!?)}'"]+$/u
135
+
136
+ /** The in-text stand-in for one extracted URL. NUL never survives into the
137
+ * text (pass 0 replaces any NUL the input carried), so a sentinel can only
138
+ * be one this function placed; nothing in it trips a later pass (no `zr~`,
139
+ * no slash, no 32-run, no drive-letter shape). */
140
+ const URL_SENTINEL = /\u0000zr-url:(\d+)\u0000/gu
141
+
142
+ /** One URL's path, redacted per segment: virtual ids truncate, and a segment
143
+ * that carries a secret-shaped run dies whole. Ordinary routes
144
+ * (`/_dsh/zen-remote/relay/v1/stream`) pass untouched. */
145
+ function redactUrlPath(path: string): string {
146
+ return path
147
+ .replace(VIRTUAL_ID, (_match: string, serverId: string, id: string) => `zr~${serverId}~${id.replace(/^session-/, '').slice(0, 8)}`)
148
+ .split('/')
149
+ .map((segment) => (HAS_TOKEN_SEGMENT.test(segment) ? TOKEN_PLACEHOLDER : segment))
150
+ .join('/')
151
+ }
152
+
153
+ /** One URL's query + fragment (`?…` / `#…`), redacted per parameter: the key
154
+ * is kept, a value carrying a secret-shaped run becomes `<token>` (a bare
155
+ * parameter without `=` likewise). Virtual ids truncate first, so a
156
+ * `sessionId=zr~…` value stays recognisable. This is the URL's only secret
157
+ * pass — the URL was extracted before the text-wide token pass ran. */
158
+ function redactUrlQuery(tail: string): string {
159
+ return tail.replace(/([?&#;])([^?&#;]*)/gu, (_match: string, separator: string, pair: string) => {
160
+ const truncated = pair.replace(VIRTUAL_ID, (_m: string, serverId: string, id: string) => `zr~${serverId}~${id.replace(/^session-/, '').slice(0, 8)}`)
161
+ const eq = truncated.indexOf('=')
162
+ if (eq === -1) return `${separator}${HAS_TOKEN_RUN.test(truncated) ? TOKEN_PLACEHOLDER : truncated}`
163
+ const value = truncated.slice(eq + 1)
164
+ return `${separator}${truncated.slice(0, eq)}=${HAS_TOKEN_RUN.test(value) ? TOKEN_PLACEHOLDER : value}`
165
+ })
166
+ }
167
+
168
+ /**
169
+ * One extracted URL in its reportable form: userinfo dropped whole to
170
+ * `<token>@`, host degraded to its address class, port and path KEPT (the
171
+ * SPEC's "端口与 path 保留"), path segments and query values carrying a
172
+ * secret-shaped run replaced by `<token>`.
173
+ *
174
+ * The userinfo boundary is the LAST `@` before the first `?`/`#` (RFC 3986:
175
+ * the authority cannot run past them), so a password holding `/`
176
+ * (`bob:ab/cd@host`) is still userinfo. The cost — a path that itself holds
177
+ * `@` loses everything before it to `<token>@` — is over-redaction, the
178
+ * safe direction.
179
+ */
180
+ function degradeUrl(raw: string): string {
181
+ const schemeEnd = raw.indexOf('://')
182
+ const scheme = raw.slice(0, schemeEnd)
183
+ const rest = raw.slice(schemeEnd + 3)
184
+ const queryAt = rest.search(/[?#]/u)
185
+ const head = queryAt === -1 ? rest : rest.slice(0, queryAt)
186
+ const tail = queryAt === -1 ? '' : rest.slice(queryAt)
187
+ const at = head.lastIndexOf('@')
188
+ const afterUser = at === -1 ? head : head.slice(at + 1)
189
+ const hostPort = /^(\[[^\]]*\]|[^/:]*)(:\d+)?/u.exec(afterUser)
190
+ const host = hostPort?.[1] ?? ''
191
+ const port = hostPort?.[2] ?? ''
192
+ const path = afterUser.slice(hostPort?.[0].length ?? 0)
193
+ return `${scheme}://${at === -1 ? '' : `${TOKEN_PLACEHOLDER}@`}${classifyHost(host)}${port}${redactUrlPath(path)}${redactUrlQuery(tail)}`
194
+ }
195
+
196
+ /** The UNC pass: `\\server\share\…\leaf` → `<路径:leaf>`. Server and share
197
+ * are mandatory and never kept; inner segments may hold spaces
198
+ * (`Shared Docs`), the leaf may not — so trailing prose stays outside. */
199
+ const UNC_PATH = /(?<![\w\\])\\\\[^\\/:*?"<>|\s]+\\[^\\/:*?"<>|\r\n]+(?:\\[^\\/:*?"<>|\r\n]+)*\\([^\\/:*?"<>|\s]+)/gu
200
+
201
+ /** A bare UNC root (`\\server\share`, no leaf beyond the share): nothing in
202
+ * it is safe to keep. Runs after {@link UNC_PATH}. */
203
+ const UNC_ROOT = /(?<![\w\\])\\\\[^\\/:*?"<>|\s]+\\[^\\/:*?"<>|\s]+/gu
204
+
205
+ /** The virtual-id pass: `zr~<serverId>~<id>` keeps the server id and the
206
+ * session id's first 8 characters (the debug report needs "same server?
207
+ * same session?" — not the full id). */
208
+ const VIRTUAL_ID = /zr~([^~\s]{1,80})~([^~\s]{1,80})/gu
209
+
210
+ /** The Windows absolute-path pass: drive letter, colon, separator, then path
211
+ * segments — reduced to `<路径:leaf>`. The drive letter must not follow a
212
+ * word character (`https:/` is not a drive); inner segments may hold spaces
213
+ * (`Program Files`), the leaf may not — so `D:\x\plan.txt failed` keeps
214
+ * `failed` outside the placeholder. */
215
+ const WINDOWS_PATH = /(?<!\w)([A-Za-z]:[\\/](?:[^\\/:*?"<>|\r\n]+[\\/])*)([^\\/:*?"<>|\s]+)/gu
216
+
217
+ /** The Unix absolute-path pass, same reduction. The boundary lookbehind
218
+ * (string start, whitespace, `(`, `~`, `>`) keeps relative paths, URL path
219
+ * segments and `a/b` words out; `~` is excluded so a home-folded path
220
+ * (`~/work/x.txt`) is never re-matched. */
221
+ const UNIX_PATH = /(?<![\w:/.\\~])(\/(?:[^/\s]+\/)*)([^/\s]+)/gu
222
+
223
+ /**
224
+ * Redact one free-text value: home paths fold to `~` (their relative tail is
225
+ * kept — that is the one shape the report intentionally preserves), absolute
226
+ * paths reduce to `<路径:leaf>`, URL hostnames degrade to their address
227
+ * class, virtual session ids truncate, secret-shaped runs become
228
+ * `<token>`. Non-strings pass through unchanged.
229
+ */
230
+ export function redactText(text: string, context: { home?: string }): string {
231
+ if (typeof text !== 'string' || text === '') return text
232
+ // 0. URLs out first, each replaced by a sentinel and restored LAST in
233
+ // degraded form — so no text-wide pass can merge a URL's port and path
234
+ // into one token run (the "ws://…:3080/_dsh/…" port loss), and the
235
+ // userinfo/query handling lives in one place (degradeUrl). Any NUL the
236
+ // input carried is neutralized first: a sentinel can only be ours.
237
+ const urls: string[] = []
238
+ let out = text.replaceAll('\u0000', '\uFFFD').replace(URL_CANDIDATE, (match: string) => {
239
+ const trailing = URL_TRAILING.exec(match)?.[0] ?? ''
240
+ urls.push(match.slice(0, match.length - trailing.length))
241
+ return `\u0000zr-url:${urls.length - 1}\u0000${trailing}`
242
+ })
243
+ // 1. virtual ids: a ≥32-char id would otherwise die as a token run. The
244
+ // id's `session-` label is stripped before the cut — the ids are all
245
+ // `session-<uuid>`, so the head 8 of the label would carry nothing.
246
+ out = out.replace(VIRTUAL_ID, (_match: string, serverId: string, id: string) => `zr~${serverId}~${id.replace(/^session-/, '').slice(0, 8)}`)
247
+ // 2. the home directory folds to `~` BEFORE the token pass — a long home
248
+ // path is itself a ≥32 run and would otherwise die whole, losing the
249
+ // `~/Downloads/x.bin` shape the SPEC promises (folding only removes
250
+ // information, so running it early is safe). Guarded: never fold the
251
+ // filesystem root; a separator the fold leaves behind is normalized, so a
252
+ // Windows home path reads `~/doc.txt` like its Unix counterpart.
253
+ const home = context.home
254
+ if (typeof home === 'string' && home.length > 1) {
255
+ out = out.split(home).join('~').replaceAll('~\\', '~/')
256
+ }
257
+ // 3. secret-shaped runs.
258
+ out = out.replace(TOKEN_RUN, () => TOKEN_PLACEHOLDER)
259
+ // 4. UNC paths, then bare UNC roots — before the drive-letter pass.
260
+ out = out.replace(UNC_PATH, (_match: string, leaf: string) => `<路径:${leaf}>`)
261
+ out = out.replace(UNC_ROOT, () => '<路径>')
262
+ // 5. Windows absolute paths → <路径:leaf>.
263
+ out = out.replace(WINDOWS_PATH, (_match: string, _prefix: string, leaf: string) => `<路径:${leaf}>`)
264
+ // 6. Unix absolute paths → <路径:leaf> (the leaf class already excludes
265
+ // whitespace).
266
+ out = out.replace(UNIX_PATH, (_match: string, _prefix: string, leaf: string) => `<路径:${leaf}>`)
267
+ // 7. URLs back in, degraded: userinfo → `<token>@`, host → address class,
268
+ // port and path kept, secret-shaped path segments / query values →
269
+ // `<token>`.
270
+ return out.replace(URL_SENTINEL, (_match: string, index: string) => degradeUrl(urls[Number(index)] ?? ''))
271
+ }
272
+
273
+ /**
274
+ * Deep-redact one JSON-ready value: every string (at any depth) through
275
+ * {@link redactText}; keys are kept as-is (plugin names, field names —
276
+ * public identifiers), numbers/booleans/null pass through. Returns new
277
+ * containers; the input is never mutated.
278
+ */
279
+ export function redactValue(value: unknown, context: { home?: string }): unknown {
280
+ if (typeof value === 'string') return redactText(value, context)
281
+ if (Array.isArray(value)) return value.map((item) => redactValue(item, context))
282
+ if (value !== null && typeof value === 'object') {
283
+ const out: Record<string, unknown> = {}
284
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
285
+ out[key] = redactValue(item, context)
286
+ }
287
+ return out
288
+ }
289
+ return value
290
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * CU-2: the debug report's assembler (SPEC 方案 C). Pure module — no DSH
3
+ * imports, no I/O: everything dynamic (the log ring's snapshot, the resolved
4
+ * config, the status route's body, the gateway's summary, the profile name)
5
+ * arrives as plain input, so the check script and the tests can drive it
6
+ * with poisoned fixtures and assert nothing recoverable comes out.
7
+ *
8
+ * Two redaction layers meet here, per SPEC:
9
+ * 1. construction — the report's own fields are picked by NAME; nothing is
10
+ * ever spread into it. (The wiring in client-routes.ts picks the config
11
+ * summary fields; a secret that sneaks into `configSummary` anyway is
12
+ * still caught by layer 2.)
13
+ * 2. output — every string, at any depth, runs through `redactText`
14
+ * (debug-redact.ts). The two exceptions are `config.serverUrl` and
15
+ * `config.host`, which are not strings but SHAPES (`https://domain`,
16
+ * `http://lan-ip:3080`, `loopback`, `wildcard`) — assigning them after
17
+ * the deep pass keeps the shapers' own labels from being re-classified.
18
+ */
19
+
20
+ import { bindHostShape, redactText, redactValue, serverUrlShape } from './debug-redact.js'
21
+ import type { LogRingEntry } from './log-ring.js'
22
+
23
+ /** The `_meta` plate's scalars, all construction-time facts. */
24
+ export interface DebugReportMetaInput {
25
+ /** When the report was assembled (ISO string; the caller stamps it). */
26
+ generatedAt: string
27
+ /** This package's own version (module-load read in src/index.ts). */
28
+ pluginVersion: string
29
+ /** The host DSH's version (`resolveDshVersion`). */
30
+ dshVersion: string
31
+ /** e.g. `darwin arm64` — `process.platform` + `process.arch`. */
32
+ platform: string
33
+ /** The row's effective role: `'host'` or `'client'`. */
34
+ role: string
35
+ /** The profile context's display name, when the composition has one. */
36
+ profileName?: string
37
+ }
38
+
39
+ /** One debug report's input. `configSummary` is the PICKED config subset
40
+ * (field → value); `serverUrl` inside it is special-cased into a shape. */
41
+ export interface DebugReportInput {
42
+ meta: DebugReportMetaInput
43
+ configSummary: Record<string, unknown>
44
+ /** The `/client/status` diagnostics body, as the status route would have
45
+ * answered (the assembler only redacts it). */
46
+ statusBody: Record<string, unknown>
47
+ /** The gateway child's summary (host role only): liveness, port, device
48
+ * and share counts — counts, never tokens. */
49
+ gatewaySummary?: Record<string, unknown>
50
+ /** The log ring's snapshot, oldest first. */
51
+ logs: readonly LogRingEntry[]
52
+ /** The user home directory, the redactor's fold point for `~`. */
53
+ home: string
54
+ }
55
+
56
+ /**
57
+ * Assemble the report's five plates: `_meta`, `config`, `status`,
58
+ * `gateway` (only when a summary was supplied), `logs`. Every string is
59
+ * redacted; nothing in the output can carry a path (beyond a leaf), a
60
+ * hostname (beyond its address class), or a secret-shaped run.
61
+ */
62
+ export function assembleDebugReport(input: DebugReportInput): Record<string, unknown> {
63
+ const context = { home: input.home }
64
+ const config: Record<string, unknown> = {}
65
+ for (const [key, value] of Object.entries(input.configSummary)) {
66
+ if (key === 'serverUrl' || key === 'host') continue
67
+ config[key] = redactValue(value, context)
68
+ }
69
+ // Both addresses are SHAPES, assigned after the deep pass so the shapers'
70
+ // own labels are never re-classified: the bind host keeps only its class
71
+ // (a bare IP must not reach the report), serverUrl its scheme+class+port.
72
+ if ('host' in input.configSummary) config.host = bindHostShape(input.configSummary.host)
73
+ config.serverUrl = serverUrlShape(input.configSummary.serverUrl)
74
+ const report: Record<string, unknown> = {
75
+ _meta: {
76
+ generatedAt: redactText(input.meta.generatedAt, context),
77
+ pluginVersion: redactText(input.meta.pluginVersion, context),
78
+ dshVersion: redactText(input.meta.dshVersion, context),
79
+ platform: redactText(input.meta.platform, context),
80
+ role: redactText(input.meta.role, context),
81
+ ...(input.meta.profileName === undefined ? {} : { profile: redactText(input.meta.profileName, context) }),
82
+ },
83
+ config,
84
+ status: redactValue(input.statusBody, context),
85
+ ...(input.gatewaySummary === undefined ? {} : { gateway: redactValue(input.gatewaySummary, context) }),
86
+ logs: input.logs.map((entry) => ({
87
+ t: entry.t,
88
+ level: entry.level,
89
+ msg: redactText(entry.msg, context),
90
+ })),
91
+ }
92
+ return report
93
+ }
@@ -91,6 +91,35 @@ function isRecord(value: unknown): value is Record<string, unknown> {
91
91
  return typeof value === 'object' && value !== null
92
92
  }
93
93
 
94
+ /**
95
+ * Whether the FALLBACK install may fire on this tick (SPEC §2, the RD-6
96
+ * machine-local failure: `ctx.inject(['connection','fileUploads'])` silently
97
+ * stays PENDING on some compositions — the callback never runs, the wrap
98
+ * never installs, and every remote upload dies on the local backend with no
99
+ * diagnostic beyond "inject not seen"). All four facts must line up:
100
+ * - `injectSeen === false` — the inject callback has NOT fired (if it had,
101
+ * the primary path either installed or recorded a refusal itself);
102
+ * - `handleSet === false` — no install happened yet, via EITHER path (the
103
+ * double-install guard, checked again at the moment of installation);
104
+ * - `refused === false` — no install was refused yet (a refusal is a
105
+ * verdict, not a pending state; retrying it would only re-learn it);
106
+ * - `servicesPresent` — the reflection layer answers BOTH `connection` and
107
+ * `fileUploads`, i.e. the services exist and only the inject callback is
108
+ * stuck. Without them there is nothing to wrap and the inject may still
109
+ * be legitimately waiting (normal startup ordering) — the fallback keeps
110
+ * out of the way and inject stays the first choice.
111
+ * Pure judgment over injectable state, so the whole truth table is
112
+ * assertable in Node.
113
+ */
114
+ export function shouldFallbackInstall(state: {
115
+ injectSeen: boolean
116
+ handleSet: boolean
117
+ refused: boolean
118
+ servicesPresent: boolean
119
+ }): boolean {
120
+ return !state.injectSeen && !state.handleSet && !state.refused && state.servicesPresent
121
+ }
122
+
94
123
  /**
95
124
  * Check the RAW connection service instance against the shape the wrap
96
125
  * depends on. Pure inspection — nothing is mutated, so a failed check