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.
- package/AGENTS.md +15 -5
- package/CHANGELOG.md +71 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +10 -4
- package/docs/remote-access.en.md +6 -0
- package/docs/remote-access.md +6 -0
- package/lan-gate.mjs +7 -0
- package/lib/client-pairing.js +10 -0
- package/lib/client-pairing.js.map +1 -1
- package/lib/client-routes.js +183 -94
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +505 -78
- package/lib/debug-redact.js +253 -0
- package/lib/debug-redact.js.map +1 -0
- package/lib/debug-report.js +60 -0
- package/lib/debug-report.js.map +1 -0
- package/lib/fetch-route-intercept.js +23 -0
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +293 -45
- package/lib/index.js.map +1 -1
- package/lib/log-ring.js +76 -0
- package/lib/log-ring.js.map +1 -0
- package/lib/types/client/effects/remote-drop.d.ts +20 -8
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +8 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/misc.css.d.ts +1 -1
- package/lib/types/client/styles/misc.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +64 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +3 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-pairing.d.ts +8 -0
- package/lib/types/client-pairing.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +23 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/debug-redact.d.ts +68 -0
- package/lib/types/debug-redact.d.ts.map +1 -0
- package/lib/types/debug-report.d.ts +58 -0
- package/lib/types/debug-report.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +26 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +7 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/log-ring.d.ts +57 -0
- package/lib/types/log-ring.d.ts.map +1 -0
- package/package.json +3 -2
- package/pwa/app.css +13 -2
- package/scripts/check-debug-report.mjs +277 -0
- package/scripts/check-remote-drop.mjs +130 -0
- package/scripts/check-tablet-safe-area.mjs +49 -0
- package/src/client/effects/remote-drop.ts +271 -24
- package/src/client/locales.ts +20 -4
- package/src/client/settings/SettingsSection.tsx +120 -3
- package/src/client/styles/misc.css.ts +38 -0
- package/src/client-data/remote-drop.ts +103 -0
- package/src/client-data/settings-form.ts +3 -0
- package/src/client-pairing.ts +11 -0
- package/src/client-routes.ts +196 -90
- package/src/debug-redact.ts +290 -0
- package/src/debug-report.ts +93 -0
- package/src/fetch-route-intercept.ts +29 -0
- package/src/index.ts +282 -43
- package/src/log-ring.ts +101 -0
|
@@ -125,4 +125,42 @@ export const MISC_CSS = ` /* ---------- hero composer on mobile ----------
|
|
|
125
125
|
display: none !important;
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
/* ---------- tablet standalone PWA: pay the safe area above 1023px ---------- */
|
|
130
|
+
/* The gateway serves viewport-fit=cover to every non-desktop device (see
|
|
131
|
+
coverViewport in lan-gate-server), phone-chrome only manages the meta at
|
|
132
|
+
<=1023px, and every compensation rule in layout.css lives behind the same
|
|
133
|
+
1023px breakpoint and the data-mobile-nav frame marker MobileNavOverlay
|
|
134
|
+
only sets there. A wide tablet's home-screen PWA — iPad landscape, the
|
|
135
|
+
12.9"/13" iPad portrait at exactly 1024, Android tablets — therefore
|
|
136
|
+
painted under the status bar with nothing paying the inset: the top row
|
|
137
|
+
of controls sat in the system area and was hard to tap.
|
|
138
|
+
|
|
139
|
+
Anchor: the official AppFrame (ui-layout AppFrame.tsx) is the direct
|
|
140
|
+
parent of the [data-shell-overlay] layer — the same element
|
|
141
|
+
MobileNavOverlay's findFrame() resolves — and the frame already pads
|
|
142
|
+
ITSELF for the Windows titlebar (padding-top: --dsh-windows-titlebar-height
|
|
143
|
+
in AppFrame.module.css), so padding the frame is the host's own pattern
|
|
144
|
+
for system bars.
|
|
145
|
+
|
|
146
|
+
Structural no-op everywhere else, by construction:
|
|
147
|
+
- display-mode: standalone matches only an installed PWA; desktop
|
|
148
|
+
browsers report browser.
|
|
149
|
+
- The Electron app never receives this stylesheet at all (the
|
|
150
|
+
isDesktopShell gate in client/index.tsx) and never gets cover.
|
|
151
|
+
- env() insets are hard 0 in every desktop browser, so even a browser
|
|
152
|
+
that somehow matched the media query computes 0px padding; on iPads
|
|
153
|
+
the bottom inset is 0 too (no home indicator) — the bottom line only
|
|
154
|
+
ever pays out on Android tablets with a gesture/nav bar.
|
|
155
|
+
box-sizing is load-bearing (the S2.1 lesson, see the frame rule in
|
|
156
|
+
layout.css): the official frame is height:100% content-box, and padding
|
|
157
|
+
without border-box would grow it past the viewport instead of reserving
|
|
158
|
+
the strip. Pinned by scripts/check-tablet-safe-area.mjs. */
|
|
159
|
+
@media (min-width: 1024px) and (display-mode: standalone) {
|
|
160
|
+
:has(> [data-shell-overlay]) {
|
|
161
|
+
box-sizing: border-box !important;
|
|
162
|
+
padding-top: var(--mnav-sat) !important;
|
|
163
|
+
padding-bottom: var(--mnav-sab) !important;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
128
166
|
`
|
|
@@ -125,3 +125,106 @@ export function decideRemoteDrop(input: RemoteDropInput): RemoteDropDecision {
|
|
|
125
125
|
export function rewrapFiles(files: Iterable<File>): File[] {
|
|
126
126
|
return Array.from(files, (file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified }))
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// AU-1: the intake decision — the same three gates extended to the picker
|
|
131
|
+
// (hidden file input's `change`) and paste entries. issue #49: the drop guard
|
|
132
|
+
// alone left those two entries on the zero-copy path chip, so a REMOTE
|
|
133
|
+
// session's attachment named a file on the sub-client's disk. The decision
|
|
134
|
+
// keeps the drop semantics frozen in `decideRemoteDrop` (its truth table is a
|
|
135
|
+
// regression pin) and answers the two new entries here, where the fix's
|
|
136
|
+
// minimal-intervention rule can be expressed: only a file that CARRIES a path
|
|
137
|
+
// and is NOT an image takes the bad zero-copy branch (addFiles), so only
|
|
138
|
+
// those are worth intercepting — pathless and image files already upload
|
|
139
|
+
// bytes and must stay untouched.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** Which entry an intake item arrived through. `drop` keeps the redispatch
|
|
143
|
+
* semantics of {@link decideRemoteDrop} (any file intervenes); `picker` /
|
|
144
|
+
* `paste` intervene only on the path-carrying non-image files. */
|
|
145
|
+
export type RemoteIntakeEntry = 'drop' | 'picker' | 'paste'
|
|
146
|
+
|
|
147
|
+
/** One intake item, reduced to what the decision needs. `hasPath` is the
|
|
148
|
+
* bridge's `pathFor(file) !== ''` — the same fact the host's zero-copy
|
|
149
|
+
* branch keys on; `isImage` is `file.type` starting with `image/` (the
|
|
150
|
+
* host's own image classification). A `paste` item with no file behind it
|
|
151
|
+
* never becomes a descriptor (the effect only collects `kind: 'file'`
|
|
152
|
+
* clipboard items), so plain-text paste arrives as an EMPTY list. */
|
|
153
|
+
export interface RemoteIntakeDescriptor {
|
|
154
|
+
entry: RemoteIntakeEntry
|
|
155
|
+
isDirectory: boolean
|
|
156
|
+
hasPath: boolean
|
|
157
|
+
isImage: boolean
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Everything the intake decision may look at — the same three gates as
|
|
161
|
+
* {@link decideRemoteDrop}, injectable so no DOM is needed. */
|
|
162
|
+
export interface RemoteIntakeInput {
|
|
163
|
+
/** `__DSH_HOST_PATHS__` exists — the Electron preload's zero-copy bridge. */
|
|
164
|
+
hasHostPaths: boolean
|
|
165
|
+
/** The currently open session id, or `undefined` when no signal. */
|
|
166
|
+
currentSessionId: string | undefined
|
|
167
|
+
/** `event.isTrusted` — the re-entry guard for our redispatched events. */
|
|
168
|
+
trusted: boolean
|
|
169
|
+
/** Descriptor per collected file. `undefined` means the caller could not
|
|
170
|
+
* even enumerate the items — with no inventory there is nothing safe to
|
|
171
|
+
* judge, so this passes through (today's behavior). */
|
|
172
|
+
items: readonly RemoteIntakeDescriptor[] | undefined
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type RemoteIntakeAction = 'passthrough' | 'redirect' | 'block-directories'
|
|
176
|
+
|
|
177
|
+
/** Stable machine-readable why, one per branch, ordered by the gate sequence
|
|
178
|
+
* in {@link decideRemoteIntake}. */
|
|
179
|
+
export type RemoteIntakeReason =
|
|
180
|
+
| 'untrusted-event'
|
|
181
|
+
| 'no-host-paths'
|
|
182
|
+
| 'not-remote-session'
|
|
183
|
+
| 'no-entry-api'
|
|
184
|
+
| 'contains-directory'
|
|
185
|
+
| 'no-files'
|
|
186
|
+
| 'no-path-or-image-only'
|
|
187
|
+
| 'remote-path-files'
|
|
188
|
+
|
|
189
|
+
export interface RemoteIntakeDecision {
|
|
190
|
+
action: RemoteIntakeAction
|
|
191
|
+
reason: RemoteIntakeReason
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The three gates, then the intervention rule. The gates are decideRemoteDrop's
|
|
196
|
+
* verbatim (all must affirmatively pass — a missing signal reads as local and
|
|
197
|
+
* never intervenes); the rule after them:
|
|
198
|
+
* 1. `items === undefined` — no inventory, pass through (`no-entry-api`;
|
|
199
|
+
* deciding blind could eat a working flow);
|
|
200
|
+
* 2. any directory — `block-directories`, ahead of every redirect: a folder
|
|
201
|
+
* cannot be fixed by single-file upload, the whole intake is stopped with
|
|
202
|
+
* a toast (same semantics as the drop branch);
|
|
203
|
+
* 3. no items at all — passthrough (`no-files`; the plain-text paste shape);
|
|
204
|
+
* 4. otherwise — `redirect` when ANY item is a `drop` item (the frozen
|
|
205
|
+
* "any file intervenes" semantics) or carries a path while not being an
|
|
206
|
+
* image (the zero-copy bad branch — exactly what issue #49 broke on);
|
|
207
|
+
* `no-path-or-image-only` passthrough when none does.
|
|
208
|
+
*
|
|
209
|
+
* Drop-entry parity pin: a `drop` item's intervention semantics here are
|
|
210
|
+
* kept EQUIVALENT to `decideRemoteDrop` — same gates, directory rule and
|
|
211
|
+
* "any file intervenes" — with exactly ONE deliberate divergence: when
|
|
212
|
+
* `items === undefined`, `decideRemoteDrop` redispatches (a drop always has
|
|
213
|
+
* a Files payload worth re-shaping) while this function passes through
|
|
214
|
+
* (`no-entry-api` — an intake caller that cannot even enumerate its items
|
|
215
|
+
* must not be intercepted blind). If either function's gate sequence or
|
|
216
|
+
* drop-entry rule changes, update the other in the same commit; the truth
|
|
217
|
+
* tables in scripts/check-remote-drop.mjs pin both sides.
|
|
218
|
+
*/
|
|
219
|
+
export function decideRemoteIntake(input: RemoteIntakeInput): RemoteIntakeDecision {
|
|
220
|
+
if (!input.trusted) return { action: 'passthrough', reason: 'untrusted-event' }
|
|
221
|
+
if (!input.hasHostPaths) return { action: 'passthrough', reason: 'no-host-paths' }
|
|
222
|
+
if (!isRemoteSessionId(input.currentSessionId)) return { action: 'passthrough', reason: 'not-remote-session' }
|
|
223
|
+
if (input.items === undefined) return { action: 'passthrough', reason: 'no-entry-api' }
|
|
224
|
+
if (input.items.some((item) => item.isDirectory)) return { action: 'block-directories', reason: 'contains-directory' }
|
|
225
|
+
if (input.items.length === 0) return { action: 'passthrough', reason: 'no-files' }
|
|
226
|
+
const intervenes = input.items.some((item) => item.entry === 'drop' || (item.hasPath && !item.isImage))
|
|
227
|
+
return intervenes
|
|
228
|
+
? { action: 'redirect', reason: 'remote-path-files' }
|
|
229
|
+
: { action: 'passthrough', reason: 'no-path-or-image-only' }
|
|
230
|
+
}
|
|
@@ -97,6 +97,9 @@ export const CLIENT_CLAIM_ROUTE = '/_dsh/zen-remote/client/claim'
|
|
|
97
97
|
export const CLIENT_STATUS_ROUTE = '/_dsh/zen-remote/client/status'
|
|
98
98
|
/** T43: one immediate reconnect — answered 409 unless the client is offline. */
|
|
99
99
|
export const CLIENT_RECONNECT_ROUTE = '/_dsh/zen-remote/client/reconnect'
|
|
100
|
+
/** CU-4: the redacted diagnostic report (GET, both roles, machine-local only
|
|
101
|
+
* — a gateway-forwarded fetch is refused server-side). */
|
|
102
|
+
export const CLIENT_DEBUG_REPORT_ROUTE = '/_dsh/zen-remote/client/debug-report'
|
|
100
103
|
|
|
101
104
|
/** The lightweight client-facing config route (host half, both roles): the
|
|
102
105
|
* settings page's FALLBACK role probe (T17) — it registers wherever a
|
package/src/client-pairing.ts
CHANGED
|
@@ -39,6 +39,17 @@ function isHttpAllowedHost(hostname: string): boolean {
|
|
|
39
39
|
return isAllowedIpv4(hostname)
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Whether one hostname sits in a private/loopback network — the same verdict
|
|
44
|
+
* {@link isHttpAllowedHost} applies inside `normalizeServerUrl`, exported for
|
|
45
|
+
* the debug report's serverUrl shaper (CU-2): the one place outside the
|
|
46
|
+
* normalizer that needs the private-network fact, kept here so the address
|
|
47
|
+
* classes can never drift apart from what pairing actually accepts.
|
|
48
|
+
*/
|
|
49
|
+
export function isPrivateNetworkHost(hostname: string): boolean {
|
|
50
|
+
return isHttpAllowedHost(hostname)
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
/** Dotted-quad check against the allowed ranges. The hostname comes from a
|
|
43
54
|
* parsed URL, so it is already canonical (the WHATWG parser folds exotic
|
|
44
55
|
* spellings like `010.0x0A.0.3` into plain decimal before we ever see it). */
|
package/src/client-routes.ts
CHANGED
|
@@ -25,10 +25,13 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
28
|
+
import { homedir } from 'node:os'
|
|
28
29
|
import type { AdminAdmission } from './admin-routes.js'
|
|
29
30
|
import { classifyClaimResponse, classifyProbe, CLAIM_PATH, normalizeServerUrl, RELAY_PING_PATH } from './client-pairing.js'
|
|
30
31
|
import type { ClaimOutcome, ProbeState } from './client-pairing.js'
|
|
31
|
-
import { unwrapVolatile } from './config.js'
|
|
32
|
+
import { readFileConfig, resolveConfig, unwrapVolatile } from './config.js'
|
|
33
|
+
import { assembleDebugReport } from './debug-report.js'
|
|
34
|
+
import type { LogRingEntry } from './log-ring.js'
|
|
32
35
|
import type { FetchRouteInterceptDiagnostics } from './fetch-route-intercept.js'
|
|
33
36
|
import type { CurrentSessionRead, InterceptDiagnostics } from './intercept.js'
|
|
34
37
|
import type { PluginRouteInterceptDiagnostics } from './plugin-route-intercept.js'
|
|
@@ -37,6 +40,7 @@ import type { BridgeStatus } from './plugin-bridge.js'
|
|
|
37
40
|
import { RELAY_HTTP_ROUTES } from './relay-access.js'
|
|
38
41
|
import { relayCredentialsDigest, RelayError } from './relay-client.js'
|
|
39
42
|
import type { RelayClient, RelayHttpResult } from './relay-client.js'
|
|
43
|
+
import { resolveDshVersion } from './relay-server.js'
|
|
40
44
|
import { fromVirtual } from './virtual-id.js'
|
|
41
45
|
|
|
42
46
|
/** Prefix all client routes live under (one webServer prefix registration). */
|
|
@@ -80,6 +84,15 @@ export const CLIENT_NETWORK_HINT_ROUTE = `${CLIENT_ROUTE_PREFIX}/network-hint`
|
|
|
80
84
|
* closed:{}}` conclusion. It carries no token and no server address. */
|
|
81
85
|
export const CLIENT_REMOTE_STATUS_ROUTE = `${CLIENT_ROUTE_PREFIX}/remote-status`
|
|
82
86
|
|
|
87
|
+
/** GET: the redacted diagnostic report (CU-3, SPEC 方案 C) — `_meta`,
|
|
88
|
+
* `config`, `status`, optional `gateway`, `logs` plates, assembled by
|
|
89
|
+
* {@link assembleDebugReport} and safe to paste into a GitHub issue. Mounted
|
|
90
|
+
* on BOTH roles (each answers its own report); a request that arrived
|
|
91
|
+
* through the gateway is refused BEFORE dispatch (the wizard's via wall) —
|
|
92
|
+
* diagnostics are a machine-local export, and the gateway must never be able
|
|
93
|
+
* to fetch this machine's report for its own device table either. */
|
|
94
|
+
export const CLIENT_DEBUG_REPORT_ROUTE = `${CLIENT_ROUTE_PREFIX}/debug-report`
|
|
95
|
+
|
|
83
96
|
/** POST `{sessionId: <虚拟 id>}`: close one remote session from THIS machine
|
|
84
97
|
* (T34) — the backend forwards the ORIGINAL id through the relay's
|
|
85
98
|
* `POST relay/v1/unshare`, so the server closes it with reason `'client'`. */
|
|
@@ -172,6 +185,20 @@ export interface ClientHandlerOptions {
|
|
|
172
185
|
* a client handler WITHOUT the sink answers the same 404 (no sink, no
|
|
173
186
|
* route). */
|
|
174
187
|
onCurrentSession?: (read: CurrentSessionRead) => void
|
|
188
|
+
/** CU-3, the debug-report route's inputs — all optional; the route answers
|
|
189
|
+
* its 404-shaped refusal without them (an unwired composition must not
|
|
190
|
+
* pretend to have diagnostics). Wired on BOTH role mounts.
|
|
191
|
+
*
|
|
192
|
+
* The log ring's snapshot (CU-1), oldest first. */
|
|
193
|
+
getLogSnapshot?: () => readonly LogRingEntry[]
|
|
194
|
+
/** The gateway child's summary plate (HOST mount only): liveness, port,
|
|
195
|
+
* device and share counts — counts, never tokens. May be async (it probes
|
|
196
|
+
* the gateway's own status). */
|
|
197
|
+
getGatewaySummary?: () => Record<string, unknown> | Promise<Record<string, unknown>>
|
|
198
|
+
/** The profile context's display name, when the composition has one. */
|
|
199
|
+
getProfileName?: () => string | undefined
|
|
200
|
+
/** This package's own version, read at module load in src/index.ts. */
|
|
201
|
+
getPluginVersion?: () => string
|
|
175
202
|
}
|
|
176
203
|
|
|
177
204
|
export type ClientHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>
|
|
@@ -396,6 +423,103 @@ function remoteStatusStateOf(relay: RelayClient | undefined): 'online' | 'offlin
|
|
|
396
423
|
}
|
|
397
424
|
}
|
|
398
425
|
|
|
426
|
+
/**
|
|
427
|
+
* The status route's body, computed exactly as the CLIENT_STATUS_ROUTE
|
|
428
|
+
* branch always has — extracted (CU-3) so the debug-report route can embed
|
|
429
|
+
* the same diagnostics aggregation without a second implementation. All
|
|
430
|
+
* reads are live per call: volatile row fields, the interception getters,
|
|
431
|
+
* the relay client's current state. May await one bounded probe or connect
|
|
432
|
+
* (never longer than the probe timeout).
|
|
433
|
+
*/
|
|
434
|
+
async function statusBodyOf(options: ClientHandlerOptions): Promise<Record<string, unknown>> {
|
|
435
|
+
const fetchImpl: FetchLike = options.fetchImpl ?? ((url, init) => fetch(url, init))
|
|
436
|
+
// The typert interception's view (T23b-1) rides on EVERY status
|
|
437
|
+
// answer, whatever the connection state — the settings surface needs
|
|
438
|
+
// it to explain a refused install even while unpaired. The interface
|
|
439
|
+
// compat verdict (T42) appears once there is something to report: a
|
|
440
|
+
// stored comparison from the relay client's last handshake, or any
|
|
441
|
+
// incompatible call the interceptor recorded. Absent otherwise —
|
|
442
|
+
// nothing to show, and the settings block presence-gates on exactly
|
|
443
|
+
// that.
|
|
444
|
+
const intercept = options.getIntercept?.()
|
|
445
|
+
const fetchRouteIntercept = options.getFetchRouteIntercept?.()
|
|
446
|
+
const serviceProbe = options.getServiceProbe?.()
|
|
447
|
+
const pluginRouteIntercept = options.getPluginRouteIntercept?.()
|
|
448
|
+
const compatRelay = options.getRelayClient?.()
|
|
449
|
+
const compat =
|
|
450
|
+
compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
|
|
451
|
+
? {
|
|
452
|
+
identical: compatRelay?.compat?.identical ?? [],
|
|
453
|
+
different: compatRelay?.compat?.different ?? [],
|
|
454
|
+
unavailable: compatRelay?.compat?.unavailable ?? [],
|
|
455
|
+
incompatibleCalls: intercept?.incompatibleCalls ?? [],
|
|
456
|
+
}
|
|
457
|
+
: undefined
|
|
458
|
+
const withDiagnostics = (body: Record<string, unknown>): Record<string, unknown> => {
|
|
459
|
+
// PB-2: the per-plugin bridge verdict rides EVERY status answer —
|
|
460
|
+
// the settings surface explains a not-bridged plugin whatever the
|
|
461
|
+
// connection state (unpaired reads all-offline, the honest verdict).
|
|
462
|
+
let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
|
|
463
|
+
if (intercept !== undefined) next = { ...next, intercept }
|
|
464
|
+
if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
|
|
465
|
+
if (serviceProbe !== undefined) next = { ...next, serviceProbe }
|
|
466
|
+
if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
|
|
467
|
+
return compat === undefined ? next : { ...next, compat }
|
|
468
|
+
}
|
|
469
|
+
// The ROW decides its own two shapes first, per request (both fields
|
|
470
|
+
// are volatile { get() } wrappers — only the row object apply()
|
|
471
|
+
// received is a live source): without both credentials nothing is
|
|
472
|
+
// paired, and a stored address that no longer normalizes must never
|
|
473
|
+
// see the token (T16-fix).
|
|
474
|
+
const row = options.getRowConfig()
|
|
475
|
+
const record = row !== null && typeof row === 'object' && !Array.isArray(row) ? row as Record<string, unknown> : {}
|
|
476
|
+
const serverUrl = stringOrEmpty(unwrapVolatile(record.serverUrl)).trim()
|
|
477
|
+
const token = stringOrEmpty(unwrapVolatile(record.deviceToken))
|
|
478
|
+
if (token === '' || serverUrl === '') {
|
|
479
|
+
return withDiagnostics({ state: 'unpaired' })
|
|
480
|
+
}
|
|
481
|
+
const normalized = normalizeServerUrl(serverUrl)
|
|
482
|
+
if (!normalized.ok) {
|
|
483
|
+
return withDiagnostics({ state: 'invalid-url' })
|
|
484
|
+
}
|
|
485
|
+
const relay = options.getRelayClient?.()
|
|
486
|
+
if (relay === undefined) {
|
|
487
|
+
// T16 shape: no relay client wired — the ping probe is the answer.
|
|
488
|
+
const state = await probe(fetchImpl, normalized.url, token)
|
|
489
|
+
return withDiagnostics({ state, serverUrl: normalized.url })
|
|
490
|
+
}
|
|
491
|
+
// The cached verdict is trusted only over the EXACT credentials the
|
|
492
|
+
// handshake earned it with: an online state whose digest no longer
|
|
493
|
+
// matches the row (address edited, re-pair) is an answer about a
|
|
494
|
+
// DIFFERENT server and falls through to a live connect instead
|
|
495
|
+
// (T23a-fix). The token never enters this body — the digest is a
|
|
496
|
+
// one-way sha256 prefix computed on both sides.
|
|
497
|
+
if (
|
|
498
|
+
relay.state === 'online' &&
|
|
499
|
+
relay.handshakeInfo !== undefined &&
|
|
500
|
+
relay.lastHandshakeDigest !== undefined &&
|
|
501
|
+
relay.lastHandshakeDigest === relayCredentialsDigest(normalized.url, token)
|
|
502
|
+
) {
|
|
503
|
+
return withDiagnostics(deviceStatusBody(relay.state, relay.handshakeInfo, normalized.url, relay.deviceNameSyncing))
|
|
504
|
+
}
|
|
505
|
+
// Everything else — never connected, offline, connecting, revoked,
|
|
506
|
+
// stale credentials — gets ONE live connect bounded by the probe
|
|
507
|
+
// timeout; its FRESH result is the answer, never the possibly stale
|
|
508
|
+
// cached state. A failed attempt additionally carries the relay
|
|
509
|
+
// client's reconnect machinery readout (T43): when the next
|
|
510
|
+
// automatic retry fires and which code failed last — both free of
|
|
511
|
+
// credential material by contract.
|
|
512
|
+
try {
|
|
513
|
+
const info = await withProbeTimeout(relay.connect())
|
|
514
|
+
return withDiagnostics(deviceStatusBody('online', info, normalized.url, relay.deviceNameSyncing))
|
|
515
|
+
} catch (error) {
|
|
516
|
+
const body: Record<string, unknown> = { state: probeStateOfRelayError(error), serverUrl: normalized.url }
|
|
517
|
+
if (relay.nextRetryAt !== null && relay.nextRetryAt !== undefined) body.nextRetryAt = relay.nextRetryAt
|
|
518
|
+
if (relay.lastError !== undefined) body.lastError = relay.lastError
|
|
519
|
+
return withDiagnostics(body)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
399
523
|
/**
|
|
400
524
|
* Build the client route handler for one plugin row. The returned handler
|
|
401
525
|
* owns the full response lifecycle of every request under
|
|
@@ -416,10 +540,24 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
416
540
|
const route = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
417
541
|
const method = req.method ?? 'GET'
|
|
418
542
|
try {
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
543
|
+
// CU-3: the debug-report route's via wall, BEFORE any dispatch (the
|
|
544
|
+
// wizard's rule): a request wearing the gateway's marker arrived from
|
|
545
|
+
// a remote device, and a forwarded request presents as same-origin
|
|
546
|
+
// (the gateway rewrites Origin/Host) — the marker is the only honest
|
|
547
|
+
// signal. The report is a machine-local export; the gateway must
|
|
548
|
+
// never be able to fetch this machine's diagnostics either.
|
|
549
|
+
if (route === CLIENT_DEBUG_REPORT_ROUTE && req.headers['x-zen-remote-via'] !== undefined) {
|
|
550
|
+
responseJson(res, 403, {
|
|
551
|
+
ok: false,
|
|
552
|
+
error: { code: 'via-gateway', message: '诊断报告只能在本机导出' },
|
|
553
|
+
})
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
// The host mount's least-exposure wall (T41a-fix2): remote-status and
|
|
557
|
+
// the debug report (CU-3, both roles answer their own) exist here,
|
|
558
|
+
// and both sit BEHIND the admission wall like every other route. The
|
|
559
|
+
// shape of the refusal matches the unknown-path fallthrough.
|
|
560
|
+
if (options.remoteStatusOnly === true && route !== CLIENT_REMOTE_STATUS_ROUTE && route !== CLIENT_DEBUG_REPORT_ROUTE) {
|
|
423
561
|
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } })
|
|
424
562
|
return
|
|
425
563
|
}
|
|
@@ -485,95 +623,63 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
485
623
|
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
|
|
486
624
|
return
|
|
487
625
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
const fetchRouteIntercept = options.getFetchRouteIntercept?.()
|
|
498
|
-
const serviceProbe = options.getServiceProbe?.()
|
|
499
|
-
const pluginRouteIntercept = options.getPluginRouteIntercept?.()
|
|
500
|
-
const compatRelay = options.getRelayClient?.()
|
|
501
|
-
const compat =
|
|
502
|
-
compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
|
|
503
|
-
? {
|
|
504
|
-
identical: compatRelay?.compat?.identical ?? [],
|
|
505
|
-
different: compatRelay?.compat?.different ?? [],
|
|
506
|
-
unavailable: compatRelay?.compat?.unavailable ?? [],
|
|
507
|
-
incompatibleCalls: intercept?.incompatibleCalls ?? [],
|
|
508
|
-
}
|
|
509
|
-
: undefined
|
|
510
|
-
const withDiagnostics = (body: Record<string, unknown>): Record<string, unknown> => {
|
|
511
|
-
// PB-2: the per-plugin bridge verdict rides EVERY status answer —
|
|
512
|
-
// the settings surface explains a not-bridged plugin whatever the
|
|
513
|
-
// connection state (unpaired reads all-offline, the honest verdict).
|
|
514
|
-
let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
|
|
515
|
-
if (intercept !== undefined) next = { ...next, intercept }
|
|
516
|
-
if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
|
|
517
|
-
if (serviceProbe !== undefined) next = { ...next, serviceProbe }
|
|
518
|
-
if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
|
|
519
|
-
return compat === undefined ? next : { ...next, compat }
|
|
520
|
-
}
|
|
521
|
-
// The ROW decides its own two shapes first, per request (both fields
|
|
522
|
-
// are volatile { get() } wrappers — only the row object apply()
|
|
523
|
-
// received is a live source): without both credentials nothing is
|
|
524
|
-
// paired, and a stored address that no longer normalizes must never
|
|
525
|
-
// see the token (T16-fix).
|
|
526
|
-
const row = options.getRowConfig()
|
|
527
|
-
const record = row !== null && typeof row === 'object' && !Array.isArray(row) ? row as Record<string, unknown> : {}
|
|
528
|
-
const serverUrl = stringOrEmpty(unwrapVolatile(record.serverUrl)).trim()
|
|
529
|
-
const token = stringOrEmpty(unwrapVolatile(record.deviceToken))
|
|
530
|
-
if (token === '' || serverUrl === '') {
|
|
531
|
-
responseJson(res, 200, withDiagnostics({ state: 'unpaired' }))
|
|
532
|
-
return
|
|
533
|
-
}
|
|
534
|
-
const normalized = normalizeServerUrl(serverUrl)
|
|
535
|
-
if (!normalized.ok) {
|
|
536
|
-
responseJson(res, 200, withDiagnostics({ state: 'invalid-url' }))
|
|
537
|
-
return
|
|
538
|
-
}
|
|
539
|
-
const relay = options.getRelayClient?.()
|
|
540
|
-
if (relay === undefined) {
|
|
541
|
-
// T16 shape: no relay client wired — the ping probe is the answer.
|
|
542
|
-
const state = await probe(fetchImpl, normalized.url, token)
|
|
543
|
-
responseJson(res, 200, withDiagnostics({ state, serverUrl: normalized.url }))
|
|
626
|
+
responseJson(res, 200, await statusBodyOf(options))
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
if (route === CLIENT_DEBUG_REPORT_ROUTE) {
|
|
630
|
+
// Unwired composition (no log ring handed over): the same shape an
|
|
631
|
+
// unknown path gets — an old host must not pretend to have a report.
|
|
632
|
+
const logs = options.getLogSnapshot?.()
|
|
633
|
+
if (logs === undefined) {
|
|
634
|
+
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } })
|
|
544
635
|
return
|
|
545
636
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
// DIFFERENT server and falls through to a live connect instead
|
|
550
|
-
// (T23a-fix). The token never enters this body — the digest is a
|
|
551
|
-
// one-way sha256 prefix computed on both sides.
|
|
552
|
-
if (
|
|
553
|
-
relay.state === 'online' &&
|
|
554
|
-
relay.handshakeInfo !== undefined &&
|
|
555
|
-
relay.lastHandshakeDigest !== undefined &&
|
|
556
|
-
relay.lastHandshakeDigest === relayCredentialsDigest(normalized.url, token)
|
|
557
|
-
) {
|
|
558
|
-
responseJson(res, 200, withDiagnostics(deviceStatusBody(relay.state, relay.handshakeInfo, normalized.url, relay.deviceNameSyncing)))
|
|
637
|
+
if (method !== 'GET') {
|
|
638
|
+
res.setHeader('Allow', 'GET')
|
|
639
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
|
|
559
640
|
return
|
|
560
641
|
}
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
642
|
+
// The picked config summary (CU-2's construction layer): field by
|
|
643
|
+
// NAME, never a spread — deviceToken and the other secret row fields
|
|
644
|
+
// are structurally absent; the assembler's deep redactor is the
|
|
645
|
+
// second layer behind this. serverUrl travels to the assembler raw
|
|
646
|
+
// and comes out as a shape (scheme + address class + port).
|
|
647
|
+
const row = options.getRowConfig()
|
|
648
|
+
const record = row !== null && typeof row === 'object' && !Array.isArray(row) ? row as Record<string, unknown> : {}
|
|
649
|
+
const values = resolveConfig(record, readFileConfig(), process.env).values
|
|
650
|
+
const rawMaxUpload: unknown = unwrapVolatile(record.maxUploadBytes)
|
|
651
|
+
const maxUploadBytes = typeof rawMaxUpload === 'number' && Number.isFinite(rawMaxUpload) && rawMaxUpload > 0
|
|
652
|
+
? rawMaxUpload
|
|
653
|
+
: undefined
|
|
654
|
+
const rawProfile = options.getProfileName?.()
|
|
655
|
+
const report = assembleDebugReport({
|
|
656
|
+
meta: {
|
|
657
|
+
generatedAt: new Date().toISOString(),
|
|
658
|
+
pluginVersion: options.getPluginVersion?.() ?? 'unknown',
|
|
659
|
+
dshVersion: resolveDshVersion(),
|
|
660
|
+
platform: `${process.platform} ${process.arch}`,
|
|
661
|
+
role: String(values.role),
|
|
662
|
+
...(rawProfile === undefined ? {} : { profileName: rawProfile }),
|
|
663
|
+
},
|
|
664
|
+
configSummary: {
|
|
665
|
+
role: values.role,
|
|
666
|
+
host: values.host,
|
|
667
|
+
port: values.port,
|
|
668
|
+
idleHours: values.idleHours,
|
|
669
|
+
maxUploadBytes,
|
|
670
|
+
autoShareNewSessions: values.autoShareNewSessions,
|
|
671
|
+
pluginBridgeConsent: values.pluginBridgeConsent,
|
|
672
|
+
pushSummary: values.pushSummary,
|
|
673
|
+
pushTurnEnd: values.pushTurnEnd,
|
|
674
|
+
pushTool: values.pushTool,
|
|
675
|
+
serverUrl: stringOrEmpty(unwrapVolatile(record.serverUrl)).trim(),
|
|
676
|
+
},
|
|
677
|
+
statusBody: await statusBodyOf(options),
|
|
678
|
+
...(options.getGatewaySummary === undefined ? {} : { gatewaySummary: await options.getGatewaySummary() }),
|
|
679
|
+
logs,
|
|
680
|
+
home: homedir(),
|
|
681
|
+
})
|
|
682
|
+
responseJson(res, 200, report)
|
|
577
683
|
return
|
|
578
684
|
}
|
|
579
685
|
if (route === CLIENT_RECONNECT_ROUTE) {
|