dsh-zen-remote 2.3.2 → 2.3.4

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 (73) hide show
  1. package/README.md +178 -282
  2. package/README.zh-CN.md +179 -283
  3. package/docs/interface.md +1 -1
  4. package/docs/plugin-bridge.md +14 -7
  5. package/docs/remote-access.en.md +8 -8
  6. package/docs/remote-access.md +10 -10
  7. package/lib/adapters/dsh-better-sidebar/index.js +30 -10
  8. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  9. package/lib/adapters/dsh-better-sidebar/surface.js +79 -0
  10. package/lib/adapters/dsh-better-sidebar/surface.js.map +1 -0
  11. package/lib/client.js +1 -1
  12. package/lib/index.js +103 -10
  13. package/lib/index.js.map +1 -1
  14. package/lib/intercept.js +23 -1
  15. package/lib/intercept.js.map +1 -1
  16. package/lib/merge-streams.js +29 -2
  17. package/lib/merge-streams.js.map +1 -1
  18. package/lib/plugin-adapter-core.js +69 -13
  19. package/lib/plugin-adapter-core.js.map +1 -1
  20. package/lib/plugin-bridge.js +179 -12
  21. package/lib/plugin-bridge.js.map +1 -1
  22. package/lib/plugin-route-intercept.js +631 -79
  23. package/lib/plugin-route-intercept.js.map +1 -1
  24. package/lib/plugin-tunnel.js +84 -12
  25. package/lib/plugin-tunnel.js.map +1 -1
  26. package/lib/relay-server.js +65 -47
  27. package/lib/relay-server.js.map +1 -1
  28. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  29. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  30. package/lib/types/adapters/dsh-better-sidebar/surface.d.ts +23 -0
  31. package/lib/types/adapters/dsh-better-sidebar/surface.d.ts.map +1 -0
  32. package/lib/types/adapters/types.d.ts +14 -3
  33. package/lib/types/adapters/types.d.ts.map +1 -1
  34. package/lib/types/index.d.ts +2 -2
  35. package/lib/types/index.d.ts.map +1 -1
  36. package/lib/types/intercept.d.ts +19 -0
  37. package/lib/types/intercept.d.ts.map +1 -1
  38. package/lib/types/merge-streams.d.ts +12 -0
  39. package/lib/types/merge-streams.d.ts.map +1 -1
  40. package/lib/types/plugin-adapter-core.d.ts +24 -11
  41. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  42. package/lib/types/plugin-bridge.d.ts +93 -5
  43. package/lib/types/plugin-bridge.d.ts.map +1 -1
  44. package/lib/types/plugin-route-intercept.d.ts +52 -3
  45. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  46. package/lib/types/plugin-tunnel.d.ts +27 -3
  47. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  48. package/lib/types/relay-server.d.ts +2 -25
  49. package/lib/types/relay-server.d.ts.map +1 -1
  50. package/lib/types/upload-limits.d.ts +67 -0
  51. package/lib/types/upload-limits.d.ts.map +1 -0
  52. package/lib/types/wizard/proxy-snippets.d.ts +4 -3
  53. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  54. package/lib/upload-limits.js +67 -0
  55. package/lib/upload-limits.js.map +1 -0
  56. package/lib/wizard/proxy-snippets.js +5 -4
  57. package/lib/wizard/proxy-snippets.js.map +1 -1
  58. package/package.json +14 -14
  59. package/scripts/check-upload-limits.mjs +167 -0
  60. package/src/adapters/dsh-better-sidebar/index.ts +30 -11
  61. package/src/adapters/dsh-better-sidebar/surface.ts +90 -0
  62. package/src/adapters/types.ts +14 -3
  63. package/src/client-data/wizard.ts +1 -1
  64. package/src/index.ts +99 -12
  65. package/src/intercept.ts +47 -1
  66. package/src/merge-streams.ts +41 -1
  67. package/src/plugin-adapter-core.ts +69 -14
  68. package/src/plugin-bridge.ts +228 -14
  69. package/src/plugin-route-intercept.ts +677 -82
  70. package/src/plugin-tunnel.ts +95 -13
  71. package/src/relay-server.ts +60 -48
  72. package/src/upload-limits.ts +69 -0
  73. package/src/wizard/proxy-snippets.ts +5 -4
@@ -28,45 +28,124 @@ import { connect as tlsConnect } from 'node:tls'
28
28
  import type { IncomingMessage, ServerResponse } from 'node:http'
29
29
  import type { Socket } from 'node:net'
30
30
  import { pluginBridgeError, responseJson } from './http.js'
31
- import { routeUpgradesOf } from './plugin-bridge.js'
31
+ import { adapterPathMatches, adapterTailShapeViolation, locateAdapterIds, type NormalizedAdapterRoute } from './plugin-adapter-core.js'
32
+ import { routeLocatorOf, routeMaxBodyOf, routeMethodsOf, routePrefixOf, routeUpgradesOf } from './plugin-bridge.js'
32
33
  import type { BridgeRegistry, NormalizedBridgeRoute, NormalizedDeclaration } from './plugin-bridge.js'
33
34
  import type { PluginLoopback } from './plugin-loopback.js'
34
35
 
35
36
  /** The shared validation verdict for the two plugin routes (PB-3, reused by
36
37
  * the tunnel open in PB-10): the whole chain from the coordinate headers to
37
38
  * the consent wall, minus each route's own extras (the method check for
38
- * `v1/plugin`, the upgrade mark here). First failure wins, in this order. */
39
+ * `v1/plugin`, the upgrade mark here). First failure wins, in this order.
40
+ * `bufferedBody` carries a json-body route's request bytes (PB-24: the share
41
+ * check must read the located id, so the body is consumed by validating) —
42
+ * the loopback sends THIS buffer, never a re-read of the request. */
39
43
  export type PluginCallValidation =
40
- | { ok: true; declaration: NormalizedDeclaration; route: NormalizedBridgeRoute; params: URLSearchParams; sessionId: string; normalizedQuery: string }
44
+ | { ok: true; declaration: NormalizedDeclaration; route: NormalizedBridgeRoute; params: URLSearchParams; sessionId: string; normalizedQuery: string; bufferedBody?: Buffer }
41
45
  | { ok: false; status: number; code: string }
42
46
 
47
+ /** The json-body locator's body reader (PB-24): the caller owns the request
48
+ * stream — `v1/plugin` buffers it bounded by the route's `maxBody` (413 past
49
+ * the cap, mirroring the adapter half's discipline), the tunnel open omits
50
+ * the reader entirely (an open request is empty-bodied by contract, so a
51
+ * body-locating upgrade route can never be validated and refuses). */
52
+ export type PluginCallBodyReader = (maxBody: number) => Promise<
53
+ | { kind: 'body'; buffered: Buffer; parsed?: unknown }
54
+ | { kind: 'over' }
55
+ | { kind: 'corrupt' }
56
+ >
57
+
58
+ /** The cooperative route through the adapter core's matcher (PB-23): the
59
+ * path rule is ONE rule on both halves — {@link adapterPathMatches} decides,
60
+ * fed the declaration route converted to its shape (exact by default;
61
+ * `prefix: true` becomes the adapter's `match: 'prefix'`). A hand-copied
62
+ * predicate here could drift from the adapter segment's rule; the conversion
63
+ * cannot. Only `path` and `match` are actually read by the matcher. */
64
+ function asAdapterRoute(route: NormalizedBridgeRoute): NormalizedAdapterRoute {
65
+ return {
66
+ path: route.path,
67
+ match: routePrefixOf(route) ? 'prefix' : 'exact',
68
+ methods: routeMethodsOf(route),
69
+ maxBody: routeMaxBodyOf(route),
70
+ stream: route.stream,
71
+ upgrade: routeUpgradesOf(route),
72
+ locator: routeLocatorOf(route),
73
+ local: false,
74
+ }
75
+ }
76
+
43
77
  /** Validate one plugin-bridge call's coordinates — the parse-once query
44
78
  * discipline (the re-serialization of THIS parse is the only string the
45
79
  * tunnel's upgrade request may carry), the share table with its ancestor
46
80
  * walk, and the live consent wall. `bridge.getConsent()` throwing reads as
47
- * unknown consent, and unknown consent stays home. */
48
- export function validatePluginCall(
81
+ * unknown consent, and unknown consent stays home. The share wall reads the
82
+ * route's EFFECTIVE locator (PB-24): the query convention — `sessionParam`
83
+ * folded in by {@link routeLocatorOf} — keeps its exactly-once rule, while a
84
+ * declaration-v2 locator re-locates over the SAME adapter-core rule the
85
+ * sub-client classified with and share-checks EVERY hit; a json-body route's
86
+ * body is consumed by validating (the reader above), and the verdict carries
87
+ * the bytes for the loopback. */
88
+ export async function validatePluginCall(
49
89
  bridge: { registry: BridgeRegistry; getConsent: () => Record<string, boolean> },
50
90
  coords: { pluginName: string; pluginVersion: string; pluginDigest: string; routePath: string; rawQuery: string },
51
91
  isAccessible: (sessionId: string) => boolean,
52
- ): PluginCallValidation {
92
+ readJsonBody?: PluginCallBodyReader,
93
+ ): Promise<PluginCallValidation> {
53
94
  const declaration = bridge.registry.get(coords.pluginName)
54
95
  if (declaration === undefined) return { ok: false, status: 404, code: 'plugin-unknown' }
55
96
  if (declaration.version !== coords.pluginVersion || declaration.digest !== coords.pluginDigest) {
56
97
  return { ok: false, status: 409, code: 'plugin-mismatch' }
57
98
  }
58
- const route = declaration.routes.find((candidate) => candidate.path === coords.routePath)
99
+ // First hit in the declaration's (path-sorted) order wins. PB-23: an
100
+ // exact-declared route matches equality alone; a `prefix: true` route also
101
+ // admits every deeper RAW path (`x-zr-path` is matched undecoded, the
102
+ // module-wide discipline) — the SAME rule the adapter segment applies, so
103
+ // the sub-client's prefix forwarding (PB-21) is checked here against the
104
+ // declaration instead of being trusted.
105
+ const route = declaration.routes.find((candidate) => adapterPathMatches(asAdapterRoute(candidate), coords.routePath))
59
106
  if (route === undefined) return { ok: false, status: 404, code: 'route-undeclared' }
107
+ // PB-23b: a prefix hit's variable tail carries the declaration-side shape
108
+ // red lines itself (`?`, `#`, `..` — encoded forms included — and
109
+ // whitespace; the shared rule, the adapter segment's too). 400
110
+ // `bad-request`, the coordinate-shape code this chain already answers.
111
+ if (adapterTailShapeViolation(asAdapterRoute(route), coords.routePath)) {
112
+ return { ok: false, status: 400, code: 'bad-request' }
113
+ }
60
114
  let params: URLSearchParams
61
115
  try {
62
116
  params = new URLSearchParams(coords.rawQuery)
63
117
  } catch {
64
118
  return { ok: false, status: 400, code: 'bad-query' }
65
119
  }
66
- const ids = params.getAll(route.sessionParam)
67
- const sessionId = ids.length === 1 ? ids[0] : undefined
68
- if (sessionId === undefined || sessionId === '') return { ok: false, status: 400, code: 'bad-query' }
69
- if (!isAccessible(sessionId)) return { ok: false, status: 403, code: 'not-shared' }
120
+ const locator = routeLocatorOf(route)
121
+ let sessionId: string
122
+ let bufferedBody: Buffer | undefined
123
+ if (locator.in === 'query') {
124
+ const ids = params.getAll(locator.name)
125
+ const queryId = ids.length === 1 ? ids[0] : undefined
126
+ if (queryId === undefined || queryId === '') return { ok: false, status: 400, code: 'bad-query' }
127
+ if (!isAccessible(queryId)) return { ok: false, status: 403, code: 'not-shared' }
128
+ sessionId = queryId
129
+ } else {
130
+ let parsedBody: unknown
131
+ if (locator.in === 'json-body') {
132
+ if (readJsonBody === undefined) return { ok: false, status: 400, code: 'bad-request' }
133
+ const read = await readJsonBody(routeMaxBodyOf(route))
134
+ if (read.kind === 'over') return { ok: false, status: 413, code: 'body-too-large' }
135
+ if (read.kind === 'corrupt') return { ok: false, status: 400, code: 'bad-query' }
136
+ bufferedBody = read.buffered
137
+ parsedBody = read.parsed
138
+ }
139
+ const hits = locateAdapterIds(asAdapterRoute(route), coords.routePath, params, parsedBody)
140
+ // The sub-client only forwards a classified-remote request, so a v2
141
+ // locator call that locates NOTHING is malformed here — the adapter
142
+ // half's rule verbatim.
143
+ if (hits.length === 0) return { ok: false, status: 400, code: 'bad-request' }
144
+ for (const hit of hits) {
145
+ if (!isAccessible(hit.value)) return { ok: false, status: 403, code: 'not-shared' }
146
+ }
147
+ sessionId = hits[0].value
148
+ }
70
149
  if (route.scope === 'server') {
71
150
  let consent: Record<string, boolean> = {}
72
151
  try {
@@ -76,7 +155,7 @@ export function validatePluginCall(
76
155
  }
77
156
  if (consent[coords.pluginName] !== true) return { ok: false, status: 403, code: 'consent-off' }
78
157
  }
79
- return { ok: true, declaration, route, params, sessionId, normalizedQuery: params.toString() }
158
+ return { ok: true, declaration, route, params, sessionId, normalizedQuery: params.toString(), ...(bufferedBody !== undefined ? { bufferedBody } : {}) }
80
159
  }
81
160
 
82
161
  /** Largest single downstream data LINE (base64 will inflate by 4/3 — the cap
@@ -257,7 +336,10 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
257
336
  pluginBridgeError(res, 400, 'bad-request')
258
337
  return
259
338
  }
260
- const validation = validatePluginCall(bridge, coords, options.isAccessible)
339
+ // No body reader here: an open request is empty-bodied by contract, so
340
+ // a json-body-locator route can never be validated and answers
341
+ // bad-request inside the shared chain.
342
+ const validation = await validatePluginCall(bridge, coords, options.isAccessible)
261
343
  if (!validation.ok) {
262
344
  pluginBridgeError(res, validation.status, validation.code)
263
345
  return
@@ -50,8 +50,9 @@ import { createPluginTunnel, validatePluginCall, type PluginTunnelRoutes } from
50
50
  import type { PluginLoopback } from './plugin-loopback.js'
51
51
  import { isLoopbackAuthError } from './plugin-loopback.js'
52
52
  import { INVOKE_BINARY_RESULT_METHODS, markInvokeBinary } from './invoke-binary.js'
53
+ import { MAX_INVOKE_BODY_BYTES, MAX_UPLOAD_BYTES } from './upload-limits.js'
53
54
  import type { NormalizedAdapter, AdapterVerdict } from './plugin-adapter-core.js'
54
- import { matchAdapterRoute, locateAdapterIds, adapterOwnsRegistryEntry, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
55
+ import { matchAdapterRoute, adapterTailShapeViolation, locateAdapterIds, adapterOwnsRegistryEntry, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
55
56
 
56
57
  /**
57
58
  * Registration prefix on the host webServer. Deliberately WITHOUT the
@@ -67,50 +68,16 @@ const RELAY_PROTOCOL = 1
67
68
  /** Request body ceiling for the POST routes other than invoke. */
68
69
  const MAX_BODY_BYTES = 1024 * 1024
69
70
 
70
- /**
71
- * Body ceiling for the binary upload channel alone (T51): a shared session's
72
- * non-image file attachments. DSH's own upload route has NO byte cap — the
73
- * host stores the stream verbatim (`saveFileStreamVerbatim` →
74
- * `publishImmutableObjectStream`, RT dsh-attachment-local lib/index.js
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: 500 MiB
77
- * per upload, checked first against `Content-Length` (refused before a byte
78
- * moves) and then counted while the stream is pumped (a chunked body has no
79
- * length header — the sub-client's undici forbids hand-set length headers,
80
- * so the streaming count is the path its uploads normally take). Past the
81
- * cap the forward is aborted, the rest of the request is drained and the
82
- * answer is 413 `payload-too-large`.
83
- *
84
- * SLOW-LINK BOUND (T51-fix, for the docs): a single legitimate upload is
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.
93
- */
94
- export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
71
+ // The binary upload channel's ceiling lives in upload-limits.ts with the
72
+ // plugin's other upload numbers (its full sizing rationale is there too);
73
+ // re-exported here so the established `lib/relay-server.js` import path —
74
+ // the tests, the fetch-route intercept — keeps working.
75
+ export { MAX_UPLOAD_BYTES }
95
76
 
96
77
  /** The `/api` route the upload channel dispatches into, verbatim from the
97
78
  * host's own registration (RT dsh-client-file-upload lib/index.js:73). */
98
79
  export const UPLOAD_HTTP_PATH = '/api/session/uploadFileBinary'
99
80
 
100
- /**
101
- * Body ceiling for the invoke route alone: the prompt routes carry inline
102
- * images in their content blocks (`session/prompt` / `subagents/prompt` take
103
- * base64 `image` parts; DSH admits one image up to 20 MiB by default —
104
- * `maxImageBytes`, dsh-attachment-local — which base64 inflates to ~27 MiB),
105
- * so one full-size inline image plus its envelope needs 32 MiB. It is NOT
106
- * sized for `fileUploads/upload`: the UI's real file uploads ride the HTTP
107
- * route `api/session/uploadFileBinary` (T41b), not this JSON relay. The
108
- * buffer only ever grows to the REAL body size; the cap decides
109
- * accept/refuse, and a body that outgrows it is drained and answered 413
110
- * like any other oversized read.
111
- */
112
- const MAX_INVOKE_BODY_BYTES = 32 * 1024 * 1024
113
-
114
81
  /** Error messages forwarded to the client are clipped to this many chars. */
115
82
  const MAX_MESSAGE_CHARS = 500
116
83
 
@@ -1634,6 +1601,15 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1634
1601
  relayPluginError(res, 409, 'plugin-mismatch')
1635
1602
  return
1636
1603
  }
1604
+ // PB-23b: the prefix tail's shape red line — the SAME shared rule
1605
+ // the cooperative chain applies inside validatePluginCall (400
1606
+ // `bad-request`), so an illegal tail (`?`, `#`, `..` in any
1607
+ // encoding, whitespace) never reaches the loopback through either
1608
+ // half.
1609
+ if (adapterTailShapeViolation(adapterRoute, routePath)) {
1610
+ relayPluginError(res, 400, 'bad-request')
1611
+ return
1612
+ }
1637
1613
  // Whole-machine data by definition: the live consent wall, the
1638
1614
  // same knob the cooperative server-scope routes read.
1639
1615
  let adapterConsent: Record<string, boolean> = {}
@@ -1892,17 +1868,48 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1892
1868
  // check refuses these stale coordinates below.
1893
1869
  }
1894
1870
  // ---- the validation chain, in order, first failure answers --------
1895
- // Steps 1/2/3 and 4-6 (registry, version+digest, exact route, the
1896
- // parse-once query, the share table, the consent wall) are SHARED with
1897
- // the tunnel open route — see validatePluginCall. This route's own
1898
- // extras are the method check (3b) and the body gate (3c) below.
1899
- const validation = validatePluginCall(
1871
+ // Steps 1/2/3 and 4-6 (registry, version+digest, the declared-route
1872
+ // match — exact, or a prefix route's deeper RAW paths — the parse-once
1873
+ // query, the share table, the consent wall) are SHARED with the tunnel
1874
+ // open route — see validatePluginCall. This route's own extras are the
1875
+ // method check (3b) and the body gate (3c) below. PB-24: a json-body
1876
+ // route's body is consumed BY validating (the share wall reads the
1877
+ // located id), bounded by the route's maxBody — the loopback then
1878
+ // sends exactly that share-checked buffer, never a re-read.
1879
+ const validation = await validatePluginCall(
1900
1880
  bridge,
1901
1881
  { pluginName, pluginVersion, pluginDigest, routePath, rawQuery },
1902
1882
  isAccessible,
1883
+ async (maxBody) => {
1884
+ const chunks: Buffer[] = []
1885
+ let size = 0
1886
+ try {
1887
+ for await (const chunk of req) {
1888
+ size += (chunk as Buffer).length
1889
+ if (size > maxBody) return { kind: 'over' as const }
1890
+ chunks.push(Buffer.from(chunk as Uint8Array))
1891
+ }
1892
+ } catch {
1893
+ // A read error (the client hung up mid-body) folds into the
1894
+ // corrupt verdict — readJsonObject's `error` fold — never a
1895
+ // throw into the host webserver's outer catch.
1896
+ return { kind: 'corrupt' as const }
1897
+ }
1898
+ const buffered = Buffer.concat(chunks)
1899
+ if (buffered.length === 0) return { kind: 'body' as const, buffered }
1900
+ try {
1901
+ return { kind: 'body' as const, buffered, parsed: JSON.parse(buffered.toString('utf8')) }
1902
+ } catch {
1903
+ return { kind: 'corrupt' as const }
1904
+ }
1905
+ },
1903
1906
  )
1904
1907
  if (!validation.ok) {
1905
1908
  relayPluginError(res, validation.status, validation.code)
1909
+ // The reader's over-limit stop leaves the request half-read — the
1910
+ // tail drains after the answer (the T51 discipline; only the reader
1911
+ // can produce this code on this path).
1912
+ if (validation.code === 'body-too-large') void drainUpload(req, 0, drainDeadlineMs)
1906
1913
  return
1907
1914
  }
1908
1915
  const route = validation.route
@@ -1918,10 +1925,11 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1918
1925
  // `maxBody` refuses BEFORE a byte moves (the T51 pre-check
1919
1926
  // discipline — the tail is drained after the answer); a chunked
1920
1927
  // body carries no length header and is counted while it streams,
1921
- // in the pump below.
1928
+ // in the pump below. A json-body route skips this gate: its body
1929
+ // was already consumed and bounded by validating.
1922
1930
  const maxBody = routeMaxBodyOf(route)
1923
1931
  const declaredLength = Number(headerValue(req, 'content-length'))
1924
- if (!bodylessMethod && Number.isFinite(declaredLength) && declaredLength > maxBody) {
1932
+ if (!bodylessMethod && validation.bufferedBody === undefined && Number.isFinite(declaredLength) && declaredLength > maxBody) {
1925
1933
  relayPluginError(res, 413, 'body-too-large')
1926
1934
  await drainUpload(req, 0, drainDeadlineMs)
1927
1935
  return
@@ -1970,7 +1978,11 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1970
1978
  // reads the recorded error first.
1971
1979
  const bodyAbort = new AbortController()
1972
1980
  const bodySignal = AbortSignal.any([hangUp.signal, bodyAbort.signal])
1973
- if (!bodylessMethod) {
1981
+ if (validation.bufferedBody !== undefined) {
1982
+ // PB-24: the json-body route's bytes were consumed by validating —
1983
+ // the loopback sends exactly the share-checked buffer.
1984
+ bodyStream = Readable.from(validation.bufferedBody)
1985
+ } else if (!bodylessMethod) {
1974
1986
  let pumped = 0
1975
1987
  const gate = new Transform({
1976
1988
  transform(chunk: Buffer, _encoding, callback) {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The one home of every upload/body ceiling this plugin enforces (the
3
+ * upload unification, 2026-10-28). Three paths move bytes through the
4
+ * plugin, each with its own number, and the numbers used to live beside
5
+ * their enforcement sites — where they drifted: the docs still said
6
+ * 100 MiB, the nginx snippet capped at 128m under a 500 MiB relay, and
7
+ * the better-sidebar adapter hand-copied the relay budget (kept in step
8
+ * only by a paired test). Defining them here once makes each number
9
+ * greppable to exactly one place; scripts/check-upload-limits.mjs pins
10
+ * the rest — no second MiB-scale constant definition anywhere in src/,
11
+ * and the docs / nginx snippet stay in sync with MAX_UPLOAD_BYTES.
12
+ *
13
+ * Pure constants, ZERO imports (the virtual-id.ts precedent): the relay
14
+ * server, the phone-upload route, the sub-client's fetch-route intercept,
15
+ * the adapter core and the adapters themselves all import from here — an
16
+ * adapter may only lean on pure utility modules, so anything less pure
17
+ * would have kept the hand-copies alive.
18
+ */
19
+
20
+ /**
21
+ * Body ceiling for the binary upload channel alone (T51): a shared session's
22
+ * non-image file attachments. DSH's own upload route has NO byte cap — the
23
+ * host stores the stream verbatim (`saveFileStreamVerbatim` →
24
+ * `publishImmutableObjectStream`, RT dsh-attachment-local lib/index.js
25
+ * ~699-716; the 20 MiB `maxImageBytes` there covers INLINE images only), so
26
+ * there is no host number to mirror and this relay picks its own: 500 MiB
27
+ * per upload, checked first against `Content-Length` (refused before a byte
28
+ * moves) and then counted while the stream is pumped (a chunked body has no
29
+ * length header — the sub-client's undici forbids hand-set length headers,
30
+ * so the streaming count is the path its uploads normally take). Past the
31
+ * cap the forward is aborted, the rest of the request is drained and the
32
+ * answer is 413 `payload-too-large`.
33
+ *
34
+ * SLOW-LINK BOUND (T51-fix, for the docs): a single legitimate upload is
35
+ * also bounded in TIME. The sub-client's own round-trip budget now caps at
36
+ * 15 minutes (relay-client.ts, the T31-fix rule), but the HOST's HTTP server
37
+ * keeps Node's default 300 s request deadline — that is the binding limit:
38
+ * a 500 MiB body needs roughly 14 Mbps sustained end to end, and a slower
39
+ * link fails the call (the client-side refusal keeps the connection state)
40
+ * rather than hanging. Raising the cap past what that deadline can carry
41
+ * would only move the failure, so 500 MiB is the honest ceiling for the
42
+ * current transport.
43
+ */
44
+ export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
45
+
46
+ /**
47
+ * Body cap when the plugin row sets no `maxUploadBytes`. This governs ONLY
48
+ * the phone composer's attachment upload (`/_dsh/mobile-nav/upload`) — the
49
+ * remote relay channel reads {@link MAX_UPLOAD_BYTES} and must never be
50
+ * sized from this: `maxUploadBytes` is a user-facing knob for phone picks
51
+ * (default 20 MB), and wiring it into the relay would drop remote
52
+ * attachments from 500 MiB back down to it (a regression the README's
53
+ * 500 MiB promise forbids).
54
+ */
55
+ export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
56
+
57
+ /**
58
+ * Body ceiling for the relay's invoke route alone: the prompt routes carry
59
+ * inline images in their content blocks (`session/prompt` / `subagents/prompt`
60
+ * take base64 `image` parts; DSH admits one image up to 20 MiB by default —
61
+ * `maxImageBytes`, dsh-attachment-local — which base64 inflates to ~27 MiB),
62
+ * so one full-size inline image plus its envelope needs 32 MiB. It is NOT
63
+ * sized for `fileUploads/upload`: the UI's real file uploads ride the HTTP
64
+ * route `api/session/uploadFileBinary` (T41b), not this JSON relay. The
65
+ * buffer only ever grows to the REAL body size; the cap decides
66
+ * accept/refuse, and a body that outgrows it is drained and answered 413
67
+ * like any other oversized read.
68
+ */
69
+ export const MAX_INVOKE_BODY_BYTES = 32 * 1024 * 1024
@@ -47,9 +47,10 @@ function validatedParts(input: SnippetInput): { domain: string; upstream: string
47
47
  * nginx: a complete server block. The WebSocket upgrade map sits outside
48
48
  * the server (http-level context) — the snippet says so in a comment, the
49
49
  * user pastes it into the http/server scope their setup offers. Long
50
- * idle tunnels need the 3600s read/send timeouts, uploads the 128m
51
- * client body cap, and streaming the buffering switch — all three are
52
- * the shapes DSH's relay traffic actually needs.
50
+ * idle tunnels need the 3600s read/send timeouts, uploads the 512m
51
+ * client body cap (nginx's `m` is MiB — 512m covers the relay's 500 MiB
52
+ * upload ceiling with headroom), and streaming the buffering switch — all
53
+ * three are the shapes DSH's relay traffic actually needs.
53
54
  */
54
55
  export function nginxSnippet(input: SnippetInput): SnippetResult {
55
56
  const parts = validatedParts(input)
@@ -76,7 +77,7 @@ export function nginxSnippet(input: SnippetInput): SnippetResult {
76
77
  ' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
77
78
  ' proxy_set_header X-Forwarded-Proto $scheme;',
78
79
  ' proxy_buffering off;',
79
- ' client_max_body_size 128m;',
80
+ ' client_max_body_size 512m;',
80
81
  ' proxy_read_timeout 3600s;',
81
82
  ' proxy_send_timeout 3600s;',
82
83
  ' }',