dsh-zen-remote 2.2.4 → 2.2.6
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 -3
- package/README.md +12 -4
- package/README.zh-CN.md +12 -4
- package/docs/interface.md +18 -1
- package/docs/plugin-bridge.md +1 -1
- package/docs/remote-access.en.md +10 -3
- package/docs/remote-access.md +10 -3
- package/lib/adapters/dsh-better-sidebar/index.js +28 -7
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/methods.js +2 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
- package/lib/client-routes.js +3 -0
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1164 -193
- package/lib/fetch-route-intercept.js +1 -1
- package/lib/index.js +73 -16
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +13 -0
- package/lib/intercept.js.map +1 -1
- package/lib/invoke-binary.js +8 -2
- package/lib/invoke-binary.js.map +1 -1
- package/lib/lan-gate-server.cjs +109 -2
- package/lib/owner-peer.js +23 -0
- package/lib/owner-peer.js.map +1 -1
- package/lib/plugin-adapter-core.js +2 -2
- package/lib/relay-access.js +24 -2
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +2 -2
- package/lib/relay-server.js +103 -100
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
- package/lib/types/client/MobileAttachButton.d.ts +5 -2
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/RemoteFileDownload.d.ts +57 -0
- package/lib/types/client/RemoteFileDownload.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +32 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -1
- package/lib/types/client/effects/remote-drop.d.ts +27 -0
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +11 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-file-save.d.ts +112 -0
- package/lib/types/client/remote-file-save.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/compat.css.d.ts +1 -1
- package/lib/types/client/styles/compat.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/home.css.d.ts +1 -1
- package/lib/types/client/styles/home.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +99 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +7 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +6 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/invoke-binary.d.ts +4 -1
- package/lib/types/invoke-binary.d.ts.map +1 -1
- package/lib/types/owner-peer.d.ts +9 -0
- package/lib/types/owner-peer.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +1 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +10 -7
- package/lib/types/relay-server.d.ts.map +1 -1
- package/package.json +37 -37
- package/pwa/inject.js +20 -9
- package/pwa/sw.js +97 -21
- package/scripts/check-attach-upload.mjs +37 -0
- package/scripts/check-remote-drop.mjs +114 -0
- package/scripts/check-settings-form.mjs +3 -2
- package/scripts/check-upload-endpoint.mjs +14 -3
- package/src/adapters/dsh-better-sidebar/index.ts +27 -7
- package/src/adapters/dsh-better-sidebar/methods.ts +2 -0
- package/src/client/MobileAttachButton.tsx +14 -9
- package/src/client/MobileAttachChips.tsx +4 -0
- package/src/client/MobileHome.tsx +79 -12
- package/src/client/RemoteFileDownload.tsx +173 -0
- package/src/client/attach-upload.ts +47 -0
- package/src/client/effects/remote-drop.ts +269 -0
- package/src/client/index.tsx +47 -0
- package/src/client/locales.ts +32 -0
- package/src/client/remote-file-save.ts +232 -0
- package/src/client/settings/SettingsSection.tsx +61 -1
- package/src/client/styles/compat.css.ts +43 -12
- package/src/client/styles/header.css.ts +63 -15
- package/src/client/styles/home.css.ts +39 -1
- package/src/client/styles/layout.css.ts +24 -7
- package/src/client/styles/remote-session.css.ts +38 -0
- package/src/client-data/remote-drop.ts +127 -0
- package/src/client-data/settings-form.ts +10 -0
- package/src/client-routes.ts +8 -0
- package/src/fetch-route-intercept.ts +1 -1
- package/src/index.ts +71 -18
- package/src/intercept.ts +13 -0
- package/src/invoke-binary.ts +8 -2
- package/src/owner-peer.ts +32 -0
- package/src/plugin-adapter-core.ts +2 -2
- package/src/relay-access.ts +24 -2
- package/src/relay-client.ts +2 -2
- package/src/relay-server.ts +114 -90
|
@@ -59,7 +59,7 @@ export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary'
|
|
|
59
59
|
* lib/index.js:488). */
|
|
60
60
|
export const SESSION_EXPORT_PATH = '/api/session.export'
|
|
61
61
|
|
|
62
|
-
/** The remote upload cap, the relay's own
|
|
62
|
+
/** The remote upload cap, the relay's own 500 MiB (relay-server.ts explains
|
|
63
63
|
* why the host has no number to mirror). Interpolated into the local
|
|
64
64
|
* oversize refusal so the card names the real limit. */
|
|
65
65
|
const UPLOAD_CAP_MIB = Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))
|
package/src/index.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* the package.json dsh.client declaration.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { createOwnerPeerSource } from './owner-peer.js'
|
|
19
|
-
import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises'
|
|
18
|
+
import { createOwnerPeerSource, createSharedApiFetchGetter } from './owner-peer.js'
|
|
19
|
+
import { lstat, mkdir, open, realpath, rm, writeFile } from 'node:fs/promises'
|
|
20
20
|
import type { FileHandle } from 'node:fs/promises'
|
|
21
21
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
22
22
|
import { randomBytes } from 'node:crypto'
|
|
@@ -307,9 +307,27 @@ async function ensureManagedDirectory(workspace: string, path: string): Promise<
|
|
|
307
307
|
if (!entry.isDirectory()) throw new UploadError(400, 'path-escape', `upload path is not a directory: ${path}`)
|
|
308
308
|
const canonical = await realpath(path)
|
|
309
309
|
ensurePathInside(workspace, canonical)
|
|
310
|
+
await ensureSelfIgnoring(canonical)
|
|
310
311
|
return canonical
|
|
311
312
|
}
|
|
312
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Drop a self-ignoring `.gitignore` (`*` + `!.gitignore`) next to the
|
|
316
|
+
* upload directory's creation, so dropped files never surface as untracked
|
|
317
|
+
* entries in the session workspace — an agent's `git add -A` would
|
|
318
|
+
* otherwise commit the user's uploads. `wx` keeps a pre-existing
|
|
319
|
+
* (user-customized) file untouched; in a non-git workspace the file is an
|
|
320
|
+
* inert no-op. Best-effort only: a read-only directory still receives the
|
|
321
|
+
* upload route's own error, never a new one from here.
|
|
322
|
+
*/
|
|
323
|
+
async function ensureSelfIgnoring(directory: string): Promise<void> {
|
|
324
|
+
try {
|
|
325
|
+
await writeFile(join(directory, '.gitignore'), '*\n!.gitignore\n', { flag: 'wx', mode: 0o644 })
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (!isErrnoCode(error, 'EEXIST') && !isErrnoCode(error, 'EROFS') && !isErrnoCode(error, 'EACCES')) throw error
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
313
331
|
interface UploadRoot {
|
|
314
332
|
/** Canonical directory the bytes are written into. */
|
|
315
333
|
writeRoot: string
|
|
@@ -492,6 +510,39 @@ export async function handleUpload(
|
|
|
492
510
|
*/
|
|
493
511
|
export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
494
512
|
const effective = resolveConfig(config, readFileConfig(), process.env)
|
|
513
|
+
// RD-6 probe fact: whether the fetch-route inject callback (below, in the
|
|
514
|
+
// client-half wiring) ever fired. On a composition where `connection` or
|
|
515
|
+
// `fileUploads` never becomes available the callback stays silently
|
|
516
|
+
// PENDING (cordis waits forever, printing nothing) and every fetch-route
|
|
517
|
+
// diagnostic stays undefined — this flag is the one honest signal of that
|
|
518
|
+
// state. Declared this early because probes on BOTH role branches read it.
|
|
519
|
+
let fetchRouteInjectSeen = false
|
|
520
|
+
/** The client branch's fetch-route diagnostics getter, assigned once that
|
|
521
|
+
* branch declares it; the probe reads it lazily through this slot so both
|
|
522
|
+
* role mounts can share one probe factory. */
|
|
523
|
+
let fetchRouteDiagnosticsRef: (() => unknown) | undefined
|
|
524
|
+
/** RD-6: build the lazy service probe against a context whose own inject
|
|
525
|
+
* already resolved (the client-routes mounts qualify on both roles). One
|
|
526
|
+
* reflection per process: `connection` / `fileUploads` presence plus the
|
|
527
|
+
* fetchRoutes table's shape — enough to name the missing service. */
|
|
528
|
+
const uploadServiceProbe = (probeCtx: Context): (() => Record<string, unknown>) => {
|
|
529
|
+
let cached: Record<string, unknown> | undefined
|
|
530
|
+
return () => {
|
|
531
|
+
if (cached !== undefined) return cached
|
|
532
|
+
const reflect = (probeCtx as Context & { reflect?: { get(name: string): unknown } }).reflect
|
|
533
|
+
const connection = reflect?.get('connection')
|
|
534
|
+
const fileUploads = reflect?.get('fileUploads')
|
|
535
|
+
const fetchRoute = fetchRouteDiagnosticsRef?.()
|
|
536
|
+
cached = {
|
|
537
|
+
uploadInjectSeen: fetchRouteInjectSeen,
|
|
538
|
+
connectionService: connection === undefined ? 'absent' : 'present',
|
|
539
|
+
fileUploadsService: fileUploads === undefined ? 'absent' : 'present',
|
|
540
|
+
...(connection === undefined ? {} : { connectionShape: checkFetchRouteShape(connection) }),
|
|
541
|
+
...(fetchRoute === undefined ? {} : { fetchRoute }),
|
|
542
|
+
}
|
|
543
|
+
return cached
|
|
544
|
+
}
|
|
545
|
+
}
|
|
495
546
|
// The plugin bridge's zenRemote service (PB-1), provided on BOTH roles
|
|
496
547
|
// before any role branch: a plugin that wants its panel data forwarded in
|
|
497
548
|
// remote sessions declares that against THIS process whichever role it
|
|
@@ -766,23 +817,13 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
766
817
|
// the registry dsh-client-ui-deliverables' changes routes register
|
|
767
818
|
// into), so a synthetic Request reaches the serving functions directly:
|
|
768
819
|
// no loopback HTTP, no login state. A composition without the service
|
|
769
|
-
// answers the relay route 501 instead.
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const handler = connection?.createSharedFetchHandler?.('/api')
|
|
775
|
-
const fetch = handler?.fetch
|
|
776
|
-
return typeof fetch === 'function' ? (request) => fetch.call(handler, request) : undefined
|
|
777
|
-
}
|
|
778
|
-
// The server's interface fingerprints (T42): recomputed at EVERY
|
|
779
|
-
// handshake, deliberately uncached — the typert loader registers
|
|
780
|
-
// package by package asynchronously, so a client that auto-reconnects
|
|
781
|
-
// right after a server restart would otherwise handshake a half-empty
|
|
782
|
-
// registry and pin those throwaway values for the row's lifetime.
|
|
783
|
-
// ~7 ms per computation against a full registry; a failed compute
|
|
784
|
-
// lands as the empty map inside the relay handler's own guard.
|
|
820
|
+
// answers the relay route 501 instead. DSH 0.2.1-rc.3: the handler's
|
|
821
|
+
// fetch takes the requesting Peer as a second argument and refuses
|
|
822
|
+
// everything but the owner (a missing peer there throws TypeError,
|
|
823
|
+
// surfacing as a 502 on every relayed /api call) — the dispatcher
|
|
824
|
+
// forwards the cached owner Peer below; pre-rc.3 hosts ignore it.
|
|
785
825
|
const relayPeer = createOwnerPeerSource(() => relayCtx.reflect.get('connection'))
|
|
826
|
+
const getApiFetch = createSharedApiFetchGetter(() => relayCtx.reflect.get('connection'), relayPeer)
|
|
786
827
|
relayCtx.effect(() => {
|
|
787
828
|
// The plugin bridge's loopback facts (PB-3), both read PER REQUEST
|
|
788
829
|
// through the reflection layer: the web server may have restarted on
|
|
@@ -830,6 +871,14 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
830
871
|
// snapshotted from the resolveConfig call above.
|
|
831
872
|
serverName: () => resolveConfig(config, readFileConfig(), process.env).values.serverName,
|
|
832
873
|
dshVersion: resolveDshVersion(),
|
|
874
|
+
// The server's interface fingerprints (T42): recomputed at EVERY
|
|
875
|
+
// handshake, deliberately uncached — the typert loader registers
|
|
876
|
+
// package by package asynchronously, so a client that
|
|
877
|
+
// auto-reconnects right after a server restart would otherwise
|
|
878
|
+
// handshake a half-empty registry and pin those throwaway values
|
|
879
|
+
// for the row's lifetime. ~7 ms per computation against a full
|
|
880
|
+
// registry; a failed compute lands as the empty map inside the
|
|
881
|
+
// relay handler's own guard.
|
|
833
882
|
fingerprints: () => computeFingerprints(relayCtx),
|
|
834
883
|
// PB-2: the plugin bridge table, rebuilt FRESH at every handshake
|
|
835
884
|
// from the PB-1 registry and the row's consent map — both live:
|
|
@@ -909,6 +958,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
909
958
|
admit: (req) => clientCtx.connection.admit(req),
|
|
910
959
|
getRowConfig: () => config,
|
|
911
960
|
remoteStatusOnly: true,
|
|
961
|
+
getServiceProbe: uploadServiceProbe(clientCtx),
|
|
912
962
|
}),
|
|
913
963
|
}), 'dsh-zen-remote: client routes (host)')
|
|
914
964
|
})
|
|
@@ -1121,7 +1171,9 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1121
1171
|
}
|
|
1122
1172
|
return undefined
|
|
1123
1173
|
}
|
|
1174
|
+
fetchRouteDiagnosticsRef = fetchRouteDiagnostics
|
|
1124
1175
|
ctx.inject(['connection', 'fileUploads'], (connectionCtx) => {
|
|
1176
|
+
fetchRouteInjectSeen = true
|
|
1125
1177
|
const logWarn = (...args: Parameters<Context['logger']['warn']>): void => { ctx.logger?.warn(...args) }
|
|
1126
1178
|
const logInfo = (...args: Parameters<Context['logger']['info']>): void => { ctx.logger?.info(...args) }
|
|
1127
1179
|
const proxy = (connectionCtx as Context & { connection?: unknown }).connection
|
|
@@ -1232,6 +1284,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1232
1284
|
getRelayClient: () => relayClient,
|
|
1233
1285
|
getIntercept: interceptDiagnostics,
|
|
1234
1286
|
getFetchRouteIntercept: fetchRouteDiagnostics,
|
|
1287
|
+
getServiceProbe: uploadServiceProbe(clientCtx),
|
|
1235
1288
|
getPluginRouteIntercept: pluginRouteDiagnostics,
|
|
1236
1289
|
getPluginBridgeStatus: pluginBridgeStatus,
|
|
1237
1290
|
onCurrentSession,
|
package/src/intercept.ts
CHANGED
|
@@ -183,6 +183,14 @@ export const CLIENT_METHOD_FIELDS: Readonly<Record<string, readonly SessionField
|
|
|
183
183
|
'workspaceFiles/read': ['workspaceFileScopeId'],
|
|
184
184
|
'workspaceFiles/readBytes': ['workspaceFileScopeId'],
|
|
185
185
|
'workspaceFiles/stat': ['workspaceFileScopeId'],
|
|
186
|
+
// Office document preview (2.2.6) — client side of relay-access.ts's entry:
|
|
187
|
+
// `render` is located by the SAME `workspaceFileScopeId` lookup (the session
|
|
188
|
+
// id). It MUST stay identical to the server table
|
|
189
|
+
// (test/intercept.test.cjs pins the equality), otherwise a produced
|
|
190
|
+
// Word/Excel/PPT preview refuses locally with `remote-unsupported` before
|
|
191
|
+
// the relay is ever asked. `generation` stays unregistered: no virtual id
|
|
192
|
+
// rides it, so it answers locally (see the server table's note).
|
|
193
|
+
'officeToPdf/render': ['workspaceFileScopeId'],
|
|
186
194
|
// terminal (T41a) — opens SERVER-side by explicit user request (SPEC story
|
|
187
195
|
// 45); the boundary is the standing one (shared session + paired desktop
|
|
188
196
|
// client), no extra switch. The terminal and attachment ids in these calls
|
|
@@ -319,6 +327,11 @@ export const REMOTE_READ_METHODS: ReadonlySet<string> = new Set([
|
|
|
319
327
|
'workspaceFiles/read',
|
|
320
328
|
'workspaceFiles/readBytes',
|
|
321
329
|
'workspaceFiles/stat',
|
|
330
|
+
// officeToPdf (2.2.6): a pure read — it converts one already-readable file
|
|
331
|
+
// into PDF bytes on the server (writing only its own scratch space, like
|
|
332
|
+
// any preview). Offline it answers `remote-offline`, never the write
|
|
333
|
+
// message.
|
|
334
|
+
'officeToPdf/render',
|
|
322
335
|
'terminal/environment',
|
|
323
336
|
'terminal/shells',
|
|
324
337
|
'terminal/list',
|
package/src/invoke-binary.ts
CHANGED
|
@@ -17,8 +17,14 @@
|
|
|
17
17
|
export const BINARY_MARKER = '$zr-u8'
|
|
18
18
|
|
|
19
19
|
/** 结果里带二进制的方法(`namespace/method`)。请求侧参数不带二进制
|
|
20
|
-
* (readBytes 的参数是 scope/paths),故只有结果方向需要标记。
|
|
21
|
-
|
|
20
|
+
* (readBytes 的参数是 scope/paths),故只有结果方向需要标记。
|
|
21
|
+
* `officeToPdf/render`(2.2.6)返回的 `data` 是整份 PDF 字节:同一个
|
|
22
|
+
* JSON 通道会把 typed array 压扁,客户端严格编解码当场拒绝——登记进来
|
|
23
|
+
* 后服务端按同一标记写出、客户端按同一标记还原。 */
|
|
24
|
+
export const INVOKE_BINARY_RESULT_METHODS: ReadonlySet<string> = new Set([
|
|
25
|
+
'workspaceFiles/readBytes',
|
|
26
|
+
'officeToPdf/render',
|
|
27
|
+
])
|
|
22
28
|
|
|
23
29
|
/** 深度上限:与其余 walker(share-store 祖先链、会话引用扫描)一致。 */
|
|
24
30
|
const MAX_DEPTH = 16
|
package/src/owner-peer.ts
CHANGED
|
@@ -79,3 +79,35 @@ export function withPeer<T extends object>(request: T, getPeer: (() => unknown)
|
|
|
79
79
|
const peer = getPeer?.()
|
|
80
80
|
return peer === undefined ? request : { ...request, peer }
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the `/api` shared-fetch dispatcher reader for the relay server.
|
|
85
|
+
* DSH 0.2.1-rc.3's `HostConnectionService.createSharedFetchHandler('/api').fetch(request, peer)`
|
|
86
|
+
* checks `peer.principal.kind !== 'owner'` directly (returning 403 or throwing
|
|
87
|
+
* TypeError when `peer` is undefined), so the dispatcher forwards the owner
|
|
88
|
+
* Peer from `getPeer()` as the second argument. Pre-rc.3 hosts ignore the
|
|
89
|
+
* extra argument.
|
|
90
|
+
*/
|
|
91
|
+
export function createSharedApiFetchGetter(
|
|
92
|
+
getConnection: () => unknown,
|
|
93
|
+
getPeer: () => unknown = createOwnerPeerSource(getConnection),
|
|
94
|
+
): () => ((request: Request) => Promise<Response>) | undefined {
|
|
95
|
+
return () => {
|
|
96
|
+
let connection:
|
|
97
|
+
| {
|
|
98
|
+
createSharedFetchHandler?: (channel: string) => {
|
|
99
|
+
fetch?: (request: Request, peer?: unknown) => Promise<Response>
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
| undefined
|
|
103
|
+
try {
|
|
104
|
+
const value = getConnection()
|
|
105
|
+
connection = value !== null && typeof value === 'object' ? (value as typeof connection) : undefined
|
|
106
|
+
} catch {
|
|
107
|
+
connection = undefined
|
|
108
|
+
}
|
|
109
|
+
const handler = connection?.createSharedFetchHandler?.('/api')
|
|
110
|
+
const fetch = handler?.fetch
|
|
111
|
+
return typeof fetch === 'function' ? (request) => fetch.call(handler, request, getPeer()) : undefined
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -163,13 +163,13 @@ export interface NormalizedAdapter {
|
|
|
163
163
|
const PLUGIN_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u
|
|
164
164
|
const MAX_ROUTES = 64
|
|
165
165
|
|
|
166
|
-
/** The body cap an ADAPTER route may declare:
|
|
166
|
+
/** The body cap an ADAPTER route may declare: 500 MiB — the relay's own
|
|
167
167
|
* upload budget (lib/relay-server.js `MAX_UPLOAD_BYTES`), which the
|
|
168
168
|
* better-sidebar upload route rides. Wider than the cooperative
|
|
169
169
|
* declaration's 64 MiB because raw media uploads are exactly what an
|
|
170
170
|
* adapter exists to carry; test/adapter-better-sidebar.test.cjs pins the
|
|
171
171
|
* two numbers together. */
|
|
172
|
-
export const MAX_ADAPTER_BODY_BYTES =
|
|
172
|
+
export const MAX_ADAPTER_BODY_BYTES = 500 * 1024 * 1024
|
|
173
173
|
|
|
174
174
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
175
175
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
package/src/relay-access.ts
CHANGED
|
@@ -190,8 +190,7 @@ interface RelayMethod {
|
|
|
190
190
|
* account management, settings, credentials, the methods located by other
|
|
191
191
|
* ids (`schedule/update|delete|history`, `goals/create|complete`,
|
|
192
192
|
* `fileUploads/list|resolve`, `agentPresets/list|read`,
|
|
193
|
-
* `permissionPresets/*`, `
|
|
194
|
-
* refused by default.
|
|
193
|
+
* `permissionPresets/*`, `dynamicCordisRunner/*`) — is refused by default.
|
|
195
194
|
*
|
|
196
195
|
* T31 registered the five session-creating / agent-scoped calls after
|
|
197
196
|
* verifying their ownership story (see the field notes above): creations
|
|
@@ -263,6 +262,29 @@ const RELAY_METHODS: Record<string, RelayMethod> = {
|
|
|
263
262
|
'workspaceFiles/read': { fields: ['workspaceFileScopeId'] },
|
|
264
263
|
'workspaceFiles/readBytes': { fields: ['workspaceFileScopeId'] },
|
|
265
264
|
'workspaceFiles/stat': { fields: ['workspaceFileScopeId'] },
|
|
265
|
+
// Office document preview (2.2.6, remote Word/Excel/PPT). The document
|
|
266
|
+
// preview's Office renderer does NOT convert on the client: it asks the
|
|
267
|
+
// HOST's LibreOffice to turn doc/docx/xls/xlsx/ppt/pptx into PDF
|
|
268
|
+
// (RT dsh-client-ui-sidebar-documentpreview lib/client.js ~6101: the
|
|
269
|
+
// office face is built from `ctx.remote.officeToPdf` +
|
|
270
|
+
// `ctx.remote.workspaceFiles`), so a remote session without this entry
|
|
271
|
+
// fails the preview with `remote-unsupported` — the exact
|
|
272
|
+
// 「此功能暂不支持远程会话」 a client sees when it opens a produced Word
|
|
273
|
+
// file. `render` carries the SAME `workspaceFileScope` lookup as the
|
|
274
|
+
// workspaceFiles group (RT dsh-office-to-pdf lib/typert.remote-client.js:
|
|
275
|
+
// parameter 0 name `workspaceFileScope`, wire `workspaceFileScopeId`,
|
|
276
|
+
// typeSymbol SessionId) and reads under the same permissive rule
|
|
277
|
+
// (absolute paths anywhere the server process can read) — the trust
|
|
278
|
+
// premise accepted above for previews applies unchanged.
|
|
279
|
+
// `officeToPdf/generation` (zero-arity, RT same file `parameters: []`)
|
|
280
|
+
// stays UNREGISTERED on purpose: a field-less call carries no virtual id,
|
|
281
|
+
// so the client's interception never routes it here — it answers from the
|
|
282
|
+
// LOCAL renderer, which is all its cache-invalidation use needs.
|
|
283
|
+
// The RESULT of render carries `data: Uint8Array` (complete PDF bytes), so
|
|
284
|
+
// the method is ALSO registered in invoke-binary.ts's
|
|
285
|
+
// INVOKE_BINARY_RESULT_METHODS — without that marker the JSON channel
|
|
286
|
+
// flattens the typed array and the client's strict codec rejects it.
|
|
287
|
+
'officeToPdf/render': { fields: ['workspaceFileScopeId'] },
|
|
266
288
|
// terminal (T41a) — opens SERVER-side by explicit user request (SPEC story
|
|
267
289
|
// 45). What the client gets is a shell running AS THE SERVER USER, WITHOUT
|
|
268
290
|
// the agent sandbox or approval restrictions (RT dsh-api-terminal-controller
|
package/src/relay-client.ts
CHANGED
|
@@ -76,7 +76,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
78
|
* The invoke round-trip budget (T31-fix): the configured request timeout
|
|
79
|
-
* plus 2 s per MiB of request body, capped at
|
|
79
|
+
* plus 2 s per MiB of request body, capped at fifteen minutes. The large
|
|
80
80
|
* bodies are the prompt routes' INLINE images — `session/prompt` /
|
|
81
81
|
* `subagents/prompt` content blocks carry base64 `image` data, one image up
|
|
82
82
|
* to 20 MiB by default (`maxImageBytes`, dsh-attachment-local) or ~27 MiB
|
|
@@ -84,7 +84,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
|
|
84
84
|
* base seconds. The cap never lands below the configured base timeout.
|
|
85
85
|
*/
|
|
86
86
|
const INVOKE_TIMEOUT_PER_MIB_MS = 2_000
|
|
87
|
-
const INVOKE_TIMEOUT_CAP_MS =
|
|
87
|
+
const INVOKE_TIMEOUT_CAP_MS = 900_000
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* How often an ONLINE client re-runs the handshake purely to refresh the two
|
package/src/relay-server.ts
CHANGED
|
@@ -73,7 +73,7 @@ const MAX_BODY_BYTES = 1024 * 1024
|
|
|
73
73
|
* host stores the stream verbatim (`saveFileStreamVerbatim` →
|
|
74
74
|
* `publishImmutableObjectStream`, RT dsh-attachment-local lib/index.js
|
|
75
75
|
* ~699-716; the 20 MiB `maxImageBytes` there covers INLINE images only), so
|
|
76
|
-
* there is no host number to mirror and this relay picks its own:
|
|
76
|
+
* there is no host number to mirror and this relay picks its own: 500 MiB
|
|
77
77
|
* per upload, checked first against `Content-Length` (refused before a byte
|
|
78
78
|
* moves) and then counted while the stream is pumped (a chunked body has no
|
|
79
79
|
* length header — the sub-client's undici forbids hand-set length headers,
|
|
@@ -82,13 +82,16 @@ const MAX_BODY_BYTES = 1024 * 1024
|
|
|
82
82
|
* answer is 413 `payload-too-large`.
|
|
83
83
|
*
|
|
84
84
|
* SLOW-LINK BOUND (T51-fix, for the docs): a single legitimate upload is
|
|
85
|
-
* also bounded in TIME
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* roughly
|
|
89
|
-
* client-side refusal keeps the connection state)
|
|
85
|
+
* also bounded in TIME. The sub-client's own round-trip budget now caps at
|
|
86
|
+
* 15 minutes (relay-client.ts, the T31-fix rule), but the HOST's HTTP server
|
|
87
|
+
* keeps Node's default 300 s request deadline — that is the binding limit:
|
|
88
|
+
* a 500 MiB body needs roughly 14 Mbps sustained end to end, and a slower
|
|
89
|
+
* link fails the call (the client-side refusal keeps the connection state)
|
|
90
|
+
* rather than hanging. Raising the cap past what that deadline can carry
|
|
91
|
+
* would only move the failure, so 500 MiB is the honest ceiling for the
|
|
92
|
+
* current transport.
|
|
90
93
|
*/
|
|
91
|
-
export const MAX_UPLOAD_BYTES =
|
|
94
|
+
export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
|
|
92
95
|
|
|
93
96
|
/** The `/api` route the upload channel dispatches into, verbatim from the
|
|
94
97
|
* host's own registration (RT dsh-client-file-upload lib/index.js:73). */
|
|
@@ -286,7 +289,7 @@ export interface RelayHandlerOptions {
|
|
|
286
289
|
* defaults to 5000. Tests inject a small value. */
|
|
287
290
|
endDrainTimeoutMs?: number
|
|
288
291
|
/** The binary upload channel's byte cap (T51); defaults to
|
|
289
|
-
* {@link MAX_UPLOAD_BYTES} (
|
|
292
|
+
* {@link MAX_UPLOAD_BYTES} (500 MiB). Tests inject a small value so the
|
|
290
293
|
* Content-Length and streaming-count refusals stay cheap to drive. */
|
|
291
294
|
uploadCapBytes?: number
|
|
292
295
|
/** How long an upload refusal's tail drain may run before the socket is
|
|
@@ -424,6 +427,100 @@ function headerValue(req: IncomingMessage, name: string): string | undefined {
|
|
|
424
427
|
return typeof value === 'string' ? value : undefined
|
|
425
428
|
}
|
|
426
429
|
|
|
430
|
+
/** The streaming upload pump behind `v1/upload`: the client's chunks are
|
|
431
|
+
* re-emitted into a
|
|
432
|
+
* synthetic body verbatim, counted on the way through, with real
|
|
433
|
+
* backpressure (the socket pauses whenever the consumer is not pulling).
|
|
434
|
+
* Past the cap the pump errors — the consumer's reader throws mid-write —
|
|
435
|
+
* and the route answers its own 413 before draining the tail. Returned as
|
|
436
|
+
* accessors so the route can judge the cap AFTER the consumer settles. */
|
|
437
|
+
interface PumpedUploadBody {
|
|
438
|
+
body: ReadableStream<Uint8Array>
|
|
439
|
+
received: () => number
|
|
440
|
+
tooLarge: () => boolean
|
|
441
|
+
/** Detach the socket listeners once the route is done with the body. */
|
|
442
|
+
detach: () => void
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function pumpUploadBody(
|
|
446
|
+
req: IncomingMessage,
|
|
447
|
+
res: ServerResponse,
|
|
448
|
+
cap: number,
|
|
449
|
+
hangUp: AbortController,
|
|
450
|
+
): PumpedUploadBody {
|
|
451
|
+
let received = 0
|
|
452
|
+
let tooLarge = false
|
|
453
|
+
const onClientGone = (): void => {
|
|
454
|
+
if (!res.writableEnded) hangUp.abort()
|
|
455
|
+
}
|
|
456
|
+
res.on('close', onClientGone)
|
|
457
|
+
const body = new ReadableStream<Uint8Array>({
|
|
458
|
+
start: (controller) => {
|
|
459
|
+
req.pause()
|
|
460
|
+
req.on('data', (chunk: Buffer) => {
|
|
461
|
+
if (tooLarge) return
|
|
462
|
+
received += chunk.byteLength
|
|
463
|
+
if (received > cap) {
|
|
464
|
+
tooLarge = true
|
|
465
|
+
req.pause()
|
|
466
|
+
hangUp.abort()
|
|
467
|
+
try {
|
|
468
|
+
controller.error(new Error('payload-too-large'))
|
|
469
|
+
} catch {
|
|
470
|
+
// The consumer may have cancelled first.
|
|
471
|
+
}
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
controller.enqueue(new Uint8Array(chunk))
|
|
476
|
+
} catch {
|
|
477
|
+
req.destroy()
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0) req.pause()
|
|
481
|
+
})
|
|
482
|
+
req.on('end', () => {
|
|
483
|
+
if (tooLarge) return
|
|
484
|
+
try {
|
|
485
|
+
controller.close()
|
|
486
|
+
} catch {
|
|
487
|
+
// Already errored by the consumer's cancel.
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
req.on('error', () => {
|
|
491
|
+
if (tooLarge) return
|
|
492
|
+
try {
|
|
493
|
+
controller.error(new Error('upload stream failed'))
|
|
494
|
+
} catch {
|
|
495
|
+
// Already errored.
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
req.on('close', () => {
|
|
499
|
+
if (tooLarge) return
|
|
500
|
+
try {
|
|
501
|
+
controller.close()
|
|
502
|
+
} catch {
|
|
503
|
+
// A hang-up destroy after a clean end — nothing to close.
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
},
|
|
507
|
+
pull: () => {
|
|
508
|
+
req.resume()
|
|
509
|
+
},
|
|
510
|
+
cancel: () => {
|
|
511
|
+
req.pause()
|
|
512
|
+
},
|
|
513
|
+
})
|
|
514
|
+
return {
|
|
515
|
+
body,
|
|
516
|
+
received: () => received,
|
|
517
|
+
tooLarge: () => tooLarge,
|
|
518
|
+
detach: () => {
|
|
519
|
+
res.off('close', onClientGone)
|
|
520
|
+
},
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
427
524
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
428
525
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
429
526
|
}
|
|
@@ -2102,83 +2199,10 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2102
2199
|
return
|
|
2103
2200
|
}
|
|
2104
2201
|
// A hang-up cancels the forward mid-stream, like invoke's — and the
|
|
2105
|
-
// cap pump
|
|
2106
|
-
// oversized upload dies at the host side the moment it is judged.
|
|
2202
|
+
// cap pump (pumpUploadBody) aborts the SAME controller when it trips,
|
|
2203
|
+
// so an oversized upload dies at the host side the moment it is judged.
|
|
2107
2204
|
const hangUp = new AbortController()
|
|
2108
|
-
const
|
|
2109
|
-
if (!res.writableEnded) hangUp.abort()
|
|
2110
|
-
}
|
|
2111
|
-
res.on('close', onClientGone)
|
|
2112
|
-
// The counting pump: the client's chunks are re-emitted into the
|
|
2113
|
-
// synthetic body verbatim, counted on the way through, with real
|
|
2114
|
-
// backpressure (the socket pauses whenever the host handler is not
|
|
2115
|
-
// pulling). Past the cap the pump errors — the host's reader throws
|
|
2116
|
-
// out of `requestBodyChunks` and its upload dies mid-write — and the
|
|
2117
|
-
// answer below is the relay's own 413 (written before the tail drain),
|
|
2118
|
-
// never the host's internal failure envelope. `tooLarge` also detaches
|
|
2119
|
-
// the pump from the socket: the tail belongs to drainUpload's bound,
|
|
2120
|
-
// which continues from the pump's own count.
|
|
2121
|
-
let received = 0
|
|
2122
|
-
let tooLarge = false
|
|
2123
|
-
const body = new ReadableStream<Uint8Array>({
|
|
2124
|
-
start: (controller) => {
|
|
2125
|
-
req.pause()
|
|
2126
|
-
req.on('data', (chunk: Buffer) => {
|
|
2127
|
-
if (tooLarge) return
|
|
2128
|
-
received += chunk.byteLength
|
|
2129
|
-
if (received > uploadCapBytes) {
|
|
2130
|
-
tooLarge = true
|
|
2131
|
-
req.pause()
|
|
2132
|
-
hangUp.abort()
|
|
2133
|
-
try {
|
|
2134
|
-
controller.error(new Error('payload-too-large'))
|
|
2135
|
-
} catch {
|
|
2136
|
-
// The host consumer may have cancelled first.
|
|
2137
|
-
}
|
|
2138
|
-
return
|
|
2139
|
-
}
|
|
2140
|
-
try {
|
|
2141
|
-
controller.enqueue(new Uint8Array(chunk))
|
|
2142
|
-
} catch {
|
|
2143
|
-
req.destroy()
|
|
2144
|
-
return
|
|
2145
|
-
}
|
|
2146
|
-
if (controller.desiredSize !== null && controller.desiredSize <= 0) req.pause()
|
|
2147
|
-
})
|
|
2148
|
-
req.on('end', () => {
|
|
2149
|
-
if (tooLarge) return
|
|
2150
|
-
try {
|
|
2151
|
-
controller.close()
|
|
2152
|
-
} catch {
|
|
2153
|
-
// Already errored by the consumer's cancel.
|
|
2154
|
-
}
|
|
2155
|
-
})
|
|
2156
|
-
req.on('error', () => {
|
|
2157
|
-
if (tooLarge) return
|
|
2158
|
-
try {
|
|
2159
|
-
controller.error(new Error('upload stream failed'))
|
|
2160
|
-
} catch {
|
|
2161
|
-
// Already errored.
|
|
2162
|
-
}
|
|
2163
|
-
})
|
|
2164
|
-
req.on('close', () => {
|
|
2165
|
-
if (tooLarge) return
|
|
2166
|
-
try {
|
|
2167
|
-
controller.close()
|
|
2168
|
-
} catch {
|
|
2169
|
-
// A hang-up destroy after a clean end — nothing to close.
|
|
2170
|
-
}
|
|
2171
|
-
})
|
|
2172
|
-
},
|
|
2173
|
-
pull: () => {
|
|
2174
|
-
req.resume()
|
|
2175
|
-
},
|
|
2176
|
-
cancel: () => {
|
|
2177
|
-
// The host consumer stopped reading (its own error path): stop
|
|
2178
|
-
// pulling from the client too — the drain below finishes the wire.
|
|
2179
|
-
req.pause()
|
|
2180
|
-
},
|
|
2181
|
-
})
|
|
2205
|
+
const pump = pumpUploadBody(req, res, uploadCapBytes, hangUp)
|
|
2182
2206
|
let request: Request
|
|
2183
2207
|
try {
|
|
2184
2208
|
// The URL is composed from the DECISION's normalized query alone,
|
|
@@ -2188,7 +2212,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2188
2212
|
const init: RequestInit = {
|
|
2189
2213
|
method: 'POST',
|
|
2190
2214
|
headers: { 'content-type': 'application/octet-stream' },
|
|
2191
|
-
body,
|
|
2215
|
+
body: pump.body,
|
|
2192
2216
|
signal: hangUp.signal,
|
|
2193
2217
|
}
|
|
2194
2218
|
// `duplex: 'half'` is mandatory for a streaming request body; the
|
|
@@ -2215,10 +2239,10 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2215
2239
|
// wire, and the sub-client reconstructs the exact Response its UI
|
|
2216
2240
|
// would have seen locally.
|
|
2217
2241
|
const bodyText = await upstream.text()
|
|
2218
|
-
if (tooLarge) {
|
|
2242
|
+
if (pump.tooLarge()) {
|
|
2219
2243
|
res.setHeader('Connection', 'close')
|
|
2220
2244
|
responseJson(res, 413, PAYLOAD_TOO_LARGE)
|
|
2221
|
-
await drainUpload(req, received, drainDeadlineMs)
|
|
2245
|
+
await drainUpload(req, pump.received(), drainDeadlineMs)
|
|
2222
2246
|
return
|
|
2223
2247
|
}
|
|
2224
2248
|
const contentType = upstream.headers.get('content-type') ?? undefined
|
|
@@ -2232,15 +2256,15 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2232
2256
|
// cap verdict outranks it: the abort the pump sent INTO the dispatch
|
|
2233
2257
|
// surfaces here as a throw, and the client still gets its 413
|
|
2234
2258
|
// (before the tail drain, like every oversize answer).
|
|
2235
|
-
if (tooLarge) {
|
|
2259
|
+
if (pump.tooLarge()) {
|
|
2236
2260
|
res.setHeader('Connection', 'close')
|
|
2237
2261
|
responseJson(res, 413, PAYLOAD_TOO_LARGE)
|
|
2238
|
-
await drainUpload(req, received, drainDeadlineMs)
|
|
2262
|
+
await drainUpload(req, pump.received(), drainDeadlineMs)
|
|
2239
2263
|
return
|
|
2240
2264
|
}
|
|
2241
2265
|
responseJson(res, 502, { ok: false, error: { code: 'internal' } })
|
|
2242
2266
|
} finally {
|
|
2243
|
-
|
|
2267
|
+
pump.detach()
|
|
2244
2268
|
// The upload is over — answered, refused mid-flight, or its client
|
|
2245
2269
|
// gone: the budget slot goes back (T51-fix).
|
|
2246
2270
|
const remaining = (uploadsByDevice.get(device) ?? 1) - 1
|