dsh-zen-remote 2.3.3 → 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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/plugin-bridge.md +14 -7
- package/docs/remote-access.en.md +8 -8
- package/docs/remote-access.md +10 -10
- package/lib/adapters/dsh-better-sidebar/index.js +21 -8
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/surface.js +79 -0
- package/lib/adapters/dsh-better-sidebar/surface.js.map +1 -0
- package/lib/client.js +1 -1
- package/lib/index.js +103 -10
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +23 -1
- package/lib/intercept.js.map +1 -1
- package/lib/merge-streams.js +29 -2
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-adapter-core.js +69 -13
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-bridge.js +179 -12
- package/lib/plugin-bridge.js.map +1 -1
- package/lib/plugin-route-intercept.js +631 -79
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +84 -12
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/relay-server.js +65 -47
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/surface.d.ts +23 -0
- package/lib/types/adapters/dsh-better-sidebar/surface.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +14 -3
- package/lib/types/adapters/types.d.ts.map +1 -1
- package/lib/types/index.d.ts +2 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +19 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +12 -0
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +24 -11
- package/lib/types/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-bridge.d.ts +93 -5
- package/lib/types/plugin-bridge.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +52 -3
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +27 -3
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +2 -25
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/upload-limits.d.ts +67 -0
- package/lib/types/upload-limits.d.ts.map +1 -0
- package/lib/types/wizard/proxy-snippets.d.ts +4 -3
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/upload-limits.js +67 -0
- package/lib/upload-limits.js.map +1 -0
- package/lib/wizard/proxy-snippets.js +5 -4
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/package.json +2 -2
- package/scripts/check-upload-limits.mjs +167 -0
- package/src/adapters/dsh-better-sidebar/index.ts +21 -9
- package/src/adapters/dsh-better-sidebar/surface.ts +90 -0
- package/src/adapters/types.ts +14 -3
- package/src/client-data/wizard.ts +1 -1
- package/src/index.ts +99 -12
- package/src/intercept.ts +47 -1
- package/src/merge-streams.ts +41 -1
- package/src/plugin-adapter-core.ts +69 -14
- package/src/plugin-bridge.ts +228 -14
- package/src/plugin-route-intercept.ts +677 -82
- package/src/plugin-tunnel.ts +95 -13
- package/src/relay-server.ts +60 -48
- package/src/upload-limits.ts +69 -0
- package/src/wizard/proxy-snippets.ts +5 -4
package/src/plugin-tunnel.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
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
|
-
|
|
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
|
package/src/relay-server.ts
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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,
|
|
1896
|
-
//
|
|
1897
|
-
// the
|
|
1898
|
-
//
|
|
1899
|
-
|
|
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 (
|
|
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
|
|
51
|
-
* client body cap
|
|
52
|
-
*
|
|
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
|
|
80
|
+
' client_max_body_size 512m;',
|
|
80
81
|
' proxy_read_timeout 3600s;',
|
|
81
82
|
' proxy_send_timeout 3600s;',
|
|
82
83
|
' }',
|