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.
Files changed (111) hide show
  1. package/AGENTS.md +15 -3
  2. package/README.md +12 -4
  3. package/README.zh-CN.md +12 -4
  4. package/docs/interface.md +18 -1
  5. package/docs/plugin-bridge.md +1 -1
  6. package/docs/remote-access.en.md +10 -3
  7. package/docs/remote-access.md +10 -3
  8. package/lib/adapters/dsh-better-sidebar/index.js +28 -7
  9. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  10. package/lib/adapters/dsh-better-sidebar/methods.js +2 -0
  11. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
  12. package/lib/client-routes.js +3 -0
  13. package/lib/client-routes.js.map +1 -1
  14. package/lib/client.js +1164 -193
  15. package/lib/fetch-route-intercept.js +1 -1
  16. package/lib/index.js +73 -16
  17. package/lib/index.js.map +1 -1
  18. package/lib/intercept.js +13 -0
  19. package/lib/intercept.js.map +1 -1
  20. package/lib/invoke-binary.js +8 -2
  21. package/lib/invoke-binary.js.map +1 -1
  22. package/lib/lan-gate-server.cjs +109 -2
  23. package/lib/owner-peer.js +23 -0
  24. package/lib/owner-peer.js.map +1 -1
  25. package/lib/plugin-adapter-core.js +2 -2
  26. package/lib/relay-access.js +24 -2
  27. package/lib/relay-access.js.map +1 -1
  28. package/lib/relay-client.js +2 -2
  29. package/lib/relay-server.js +103 -100
  30. package/lib/relay-server.js.map +1 -1
  31. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  32. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  33. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
  34. package/lib/types/client/MobileAttachButton.d.ts +5 -2
  35. package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
  36. package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
  37. package/lib/types/client/MobileHome.d.ts.map +1 -1
  38. package/lib/types/client/RemoteFileDownload.d.ts +57 -0
  39. package/lib/types/client/RemoteFileDownload.d.ts.map +1 -0
  40. package/lib/types/client/attach-upload.d.ts +32 -0
  41. package/lib/types/client/attach-upload.d.ts.map +1 -1
  42. package/lib/types/client/effects/remote-drop.d.ts +27 -0
  43. package/lib/types/client/effects/remote-drop.d.ts.map +1 -0
  44. package/lib/types/client/index.d.ts.map +1 -1
  45. package/lib/types/client/locales.d.ts +11 -0
  46. package/lib/types/client/locales.d.ts.map +1 -1
  47. package/lib/types/client/remote-file-save.d.ts +112 -0
  48. package/lib/types/client/remote-file-save.d.ts.map +1 -0
  49. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  50. package/lib/types/client/styles/compat.css.d.ts +1 -1
  51. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  52. package/lib/types/client/styles/header.css.d.ts +1 -1
  53. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  54. package/lib/types/client/styles/home.css.d.ts +1 -1
  55. package/lib/types/client/styles/home.css.d.ts.map +1 -1
  56. package/lib/types/client/styles/layout.css.d.ts +1 -1
  57. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  58. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  59. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  60. package/lib/types/client-data/remote-drop.d.ts +99 -0
  61. package/lib/types/client-data/remote-drop.d.ts.map +1 -0
  62. package/lib/types/client-data/settings-form.d.ts +7 -0
  63. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  64. package/lib/types/client-routes.d.ts +6 -0
  65. package/lib/types/client-routes.d.ts.map +1 -1
  66. package/lib/types/index.d.ts.map +1 -1
  67. package/lib/types/intercept.d.ts.map +1 -1
  68. package/lib/types/invoke-binary.d.ts +4 -1
  69. package/lib/types/invoke-binary.d.ts.map +1 -1
  70. package/lib/types/owner-peer.d.ts +9 -0
  71. package/lib/types/owner-peer.d.ts.map +1 -1
  72. package/lib/types/plugin-adapter-core.d.ts +1 -1
  73. package/lib/types/relay-access.d.ts.map +1 -1
  74. package/lib/types/relay-server.d.ts +10 -7
  75. package/lib/types/relay-server.d.ts.map +1 -1
  76. package/package.json +37 -37
  77. package/pwa/inject.js +20 -9
  78. package/pwa/sw.js +97 -21
  79. package/scripts/check-attach-upload.mjs +37 -0
  80. package/scripts/check-remote-drop.mjs +114 -0
  81. package/scripts/check-settings-form.mjs +3 -2
  82. package/scripts/check-upload-endpoint.mjs +14 -3
  83. package/src/adapters/dsh-better-sidebar/index.ts +27 -7
  84. package/src/adapters/dsh-better-sidebar/methods.ts +2 -0
  85. package/src/client/MobileAttachButton.tsx +14 -9
  86. package/src/client/MobileAttachChips.tsx +4 -0
  87. package/src/client/MobileHome.tsx +79 -12
  88. package/src/client/RemoteFileDownload.tsx +173 -0
  89. package/src/client/attach-upload.ts +47 -0
  90. package/src/client/effects/remote-drop.ts +269 -0
  91. package/src/client/index.tsx +47 -0
  92. package/src/client/locales.ts +32 -0
  93. package/src/client/remote-file-save.ts +232 -0
  94. package/src/client/settings/SettingsSection.tsx +61 -1
  95. package/src/client/styles/compat.css.ts +43 -12
  96. package/src/client/styles/header.css.ts +63 -15
  97. package/src/client/styles/home.css.ts +39 -1
  98. package/src/client/styles/layout.css.ts +24 -7
  99. package/src/client/styles/remote-session.css.ts +38 -0
  100. package/src/client-data/remote-drop.ts +127 -0
  101. package/src/client-data/settings-form.ts +10 -0
  102. package/src/client-routes.ts +8 -0
  103. package/src/fetch-route-intercept.ts +1 -1
  104. package/src/index.ts +71 -18
  105. package/src/intercept.ts +13 -0
  106. package/src/invoke-binary.ts +8 -2
  107. package/src/owner-peer.ts +32 -0
  108. package/src/plugin-adapter-core.ts +2 -2
  109. package/src/relay-access.ts +24 -2
  110. package/src/relay-client.ts +2 -2
  111. 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 100 MiB (relay-server.ts explains
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
- const getApiFetch = (): ((request: Request) => Promise<Response>) | undefined => {
771
- const connection = relayCtx.reflect.get('connection') as
772
- | { createSharedFetchHandler?: (channel: string) => { fetch?: (request: Request) => Promise<Response> } }
773
- | undefined
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',
@@ -17,8 +17,14 @@
17
17
  export const BINARY_MARKER = '$zr-u8'
18
18
 
19
19
  /** 结果里带二进制的方法(`namespace/method`)。请求侧参数不带二进制
20
- * (readBytes 的参数是 scope/paths),故只有结果方向需要标记。 */
21
- export const INVOKE_BINARY_RESULT_METHODS: ReadonlySet<string> = new Set(['workspaceFiles/readBytes'])
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: 100 MiB — the relay's own
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 = 100 * 1024 * 1024
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)
@@ -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/*`, `officeToPdf/*`, `dynamicCordisRunner/*`) — is
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
@@ -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 five minutes. The large
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 = 300_000
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
@@ -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: 100 MiB
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 — the host's HTTP server keeps its default 300 s
86
- * request deadline, and the sub-client's own round-trip budget caps at the
87
- * same 300 s (relay-client.ts, the T31-fix rule) — so a 100 MiB body needs
88
- * roughly 2.8 Mbps sustained end to end; slower links fail the call (the
89
- * client-side refusal keeps the connection state) rather than hanging.
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 = 100 * 1024 * 1024
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} (100 MiB). Tests inject a small value so the
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 below aborts the SAME controller when it trips, so an
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 onClientGone = (): void => {
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
- res.off('close', onClientGone)
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