dsh-zen-remote 2.2.1 → 2.2.3

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 (51) hide show
  1. package/README.md +23 -5
  2. package/README.zh-CN.md +23 -5
  3. package/docs/interface.md +1 -1
  4. package/docs/plugin-bridge.md +1 -1
  5. package/docs/remote-access.en.md +13 -1
  6. package/docs/remote-access.md +14 -2
  7. package/lib/adapters/dsh-better-sidebar/index.js +25 -13
  8. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  9. package/lib/adapters/dsh-better-sidebar/methods.js +30 -7
  10. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
  11. package/lib/admin-routes.js +3 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/index.js +16 -1
  14. package/lib/index.js.map +1 -1
  15. package/lib/intercept-shape.js +10 -4
  16. package/lib/intercept-shape.js.map +1 -1
  17. package/lib/intercept.js +47 -12
  18. package/lib/intercept.js.map +1 -1
  19. package/lib/invoke-binary.js +126 -0
  20. package/lib/invoke-binary.js.map +1 -0
  21. package/lib/owner-peer.js +72 -0
  22. package/lib/owner-peer.js.map +1 -0
  23. package/lib/relay-server.js +18 -10
  24. package/lib/relay-server.js.map +1 -1
  25. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +16 -11
  26. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  27. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +16 -7
  28. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
  29. package/lib/types/admin-routes.d.ts +2 -0
  30. package/lib/types/admin-routes.d.ts.map +1 -1
  31. package/lib/types/index.d.ts.map +1 -1
  32. package/lib/types/intercept-shape.d.ts.map +1 -1
  33. package/lib/types/intercept.d.ts +11 -0
  34. package/lib/types/intercept.d.ts.map +1 -1
  35. package/lib/types/invoke-binary.d.ts +41 -0
  36. package/lib/types/invoke-binary.d.ts.map +1 -0
  37. package/lib/types/owner-peer.d.ts +57 -0
  38. package/lib/types/owner-peer.d.ts.map +1 -0
  39. package/lib/types/relay-server.d.ts +8 -0
  40. package/lib/types/relay-server.d.ts.map +1 -1
  41. package/llms.txt +1 -1
  42. package/package.json +36 -36
  43. package/src/adapters/dsh-better-sidebar/index.ts +25 -13
  44. package/src/adapters/dsh-better-sidebar/methods.ts +30 -7
  45. package/src/admin-routes.ts +5 -2
  46. package/src/index.ts +16 -1
  47. package/src/intercept-shape.ts +10 -3
  48. package/src/intercept.ts +55 -9
  49. package/src/invoke-binary.ts +134 -0
  50. package/src/owner-peer.ts +81 -0
  51. package/src/relay-server.ts +27 -11
package/src/intercept.ts CHANGED
@@ -84,6 +84,7 @@ import type { RelayClient, RelayClock, RelayState } from './relay-client.js'
84
84
  import { checkGatewayShape } from './intercept-shape.js'
85
85
  import type { GatewayShapeCheck } from './intercept-shape.js'
86
86
  import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
87
+ import { INVOKE_BINARY_RESULT_METHODS, extractResultAttachments, restoreInvokeBinary, type ResultAttachment } from './invoke-binary.js'
87
88
  import {
88
89
  createControlMerger,
89
90
  createWorkspaceMerger,
@@ -240,7 +241,7 @@ export const CLIENT_METHOD_FIELDS: Readonly<Record<string, readonly SessionField
240
241
  * envelope WE construct carries `details: {}` (the host rpcFailure's
241
242
  * fallback). */
242
243
  export type DispatchEnvelope =
243
- | { ok: true; value: unknown }
244
+ | { ok: true; value: unknown; attachments?: ResultAttachment[] }
244
245
  | { ok: false; error: { code: string; message?: string; details: Record<string, unknown> } }
245
246
 
246
247
  /**
@@ -2699,7 +2700,26 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2699
2700
  // The session answered: any closed-session entry for it is stale
2700
2701
  // (re-shared and served again) — the banner may go.
2701
2702
  clearClosed(virtuals)
2702
- return { ok: true, value: rewriteResult(endpoint, value, serverId) }
2703
+ // 2026-10-14: a binary-result method's answer arrives marker-encoded
2704
+ // (invoke-binary.ts) — the relay's JSON channel cannot carry a typed
2705
+ // array, so the server marked it and the caller's own zod validation
2706
+ // needs the real Uint8Array back.
2707
+ const restored = INVOKE_BINARY_RESULT_METHODS.has(endpoint) ? restoreInvokeBinary(value) : value
2708
+ // 2026-10-14 (root cause, live-probed): dispatchRpc's envelope contract
2709
+ // carries Uint8Arrays in the ATTACHMENTS side channel (dsh-api-gateway
2710
+ // encodeRpcResult) — value positions become null and the renderer
2711
+ // re-attaches by path. A raw typed array in the value dies on the
2712
+ // background→browser bridge (JSON), which is exactly the remote image
2713
+ // preview's "expected Uint8Array" zod failure. Restore, then re-extract.
2714
+ if (INVOKE_BINARY_RESULT_METHODS.has(endpoint)) {
2715
+ const extracted = extractResultAttachments(restored)
2716
+ return {
2717
+ ok: true,
2718
+ value: rewriteResult(endpoint, extracted.value, serverId),
2719
+ ...(extracted.attachments.length > 0 ? { attachments: extracted.attachments } : {}),
2720
+ }
2721
+ }
2722
+ return { ok: true, value: rewriteResult(endpoint, restored, serverId) }
2703
2723
  } catch (error) {
2704
2724
  const code = error instanceof RelayError ? error.code : 'internal'
2705
2725
  recordFailure(endpoint, code)
@@ -3192,6 +3212,9 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3192
3212
  peer: unknown,
3193
3213
  signal: AbortSignal | undefined,
3194
3214
  control: unknown,
3215
+ // DSH 0.2.1-alpha.3: the mux's uplink window, passed through verbatim
3216
+ // (undefined on older hosts and on the 5-argument wireStream.open path).
3217
+ uplinkWindow?: unknown,
3195
3218
  ): unknown {
3196
3219
  if (!installed) {
3197
3220
  return (chainTarget.openWireStream as (...callArgs: unknown[]) => unknown).call(
@@ -3202,6 +3225,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3202
3225
  peer,
3203
3226
  signal,
3204
3227
  control,
3228
+ uplinkWindow,
3205
3229
  )
3206
3230
  }
3207
3231
  counters.openWireStream += 1
@@ -3215,7 +3239,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3215
3239
  if (endpoint === '$events') {
3216
3240
  const open = chainTarget.openWireStream as (this: unknown, ...callArgs: unknown[]) => unknown
3217
3241
  return (async (): Promise<unknown> => {
3218
- const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control)
3242
+ const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow)
3219
3243
  if (!isAsyncIterable(local)) return local
3220
3244
  return mergedEventsStream({ local, relay, signal, recordFailure, log, clock, summarySync })
3221
3245
  })()
@@ -3236,6 +3260,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3236
3260
  peer,
3237
3261
  signal,
3238
3262
  control,
3263
+ uplinkWindow,
3239
3264
  )
3240
3265
  }
3241
3266
  // The T23b-2 global merge. The 0.2.0 openWireStream is an ASYNC
@@ -3249,7 +3274,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3249
3274
  return (async (): Promise<unknown> => {
3250
3275
  // The uplink rides to the LOCAL method verbatim (it may consume
3251
3276
  // it); the remote leg carries no uplink.
3252
- const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control)
3277
+ const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow)
3253
3278
  if (!isAsyncIterable(local)) return local
3254
3279
  return mergedGlobalStream({
3255
3280
  endpoint,
@@ -3275,6 +3300,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3275
3300
  peer,
3276
3301
  signal,
3277
3302
  control,
3303
+ uplinkWindow,
3278
3304
  )
3279
3305
  }
3280
3306
  const verdict = validate(endpoint, virtuals)
@@ -3379,6 +3405,11 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3379
3405
  export interface SelfCheckOptions {
3380
3406
  /** How long the probe may wait for its first frame; default 5000 ms. */
3381
3407
  timeoutMs?: number
3408
+ /** The owner Peer the probe opens as. DSH 0.2.1-alpha.3 removed
3409
+ * `gateway.operatorPeer()` and requires a live Peer on every stream; see
3410
+ * owner-peer.ts. Undefined falls back to `operatorPeer()` where it still
3411
+ * exists (≤ alpha.2), else to no Peer. */
3412
+ getPeer?: () => unknown
3382
3413
  }
3383
3414
 
3384
3415
  /**
@@ -3410,7 +3441,15 @@ export async function behaviorSelfCheck(raw: object, options?: SelfCheckOptions)
3410
3441
  const wireStream = gateway.wireStream
3411
3442
  const open = isPlainObject(wireStream) ? wireStream.open : undefined
3412
3443
  if (typeof open !== 'function') return { ok: false, reason: 'wireStream.open disappeared before the self-check' }
3413
- const operatorPeer = typeof gateway.operatorPeer === 'function' ? gateway.operatorPeer : undefined
3444
+ // ≤ 0.2.1-alpha.2 only: alpha.3 deleted operatorPeer (and connection.operator).
3445
+ const legacyOperatorPeer = typeof gateway.operatorPeer === 'function' ? gateway.operatorPeer : undefined
3446
+ let peer: unknown
3447
+ try {
3448
+ peer = options?.getPeer?.()
3449
+ } catch (error) {
3450
+ return { ok: false, reason: `the self-check could not obtain an owner Peer: ${messageOf(error)}` }
3451
+ }
3452
+ if (peer === undefined && legacyOperatorPeer !== undefined) peer = legacyOperatorPeer.call(raw)
3414
3453
  const controller = new AbortController()
3415
3454
  // NOT unref'd: an unref'd timer lets the process exit (or a test runner
3416
3455
  // judge the loop empty) before the timeout ever fires when this probe is
@@ -3438,7 +3477,7 @@ export async function behaviorSelfCheck(raw: object, options?: SelfCheckOptions)
3438
3477
  'workspace/follow',
3439
3478
  payload,
3440
3479
  undefined,
3441
- typeof operatorPeer === 'function' ? operatorPeer.call(raw) : undefined,
3480
+ peer,
3442
3481
  controller.signal,
3443
3482
  ),
3444
3483
  ),
@@ -3482,6 +3521,10 @@ export interface RunSelfCheckOptions {
3482
3521
  retryDelayMs?: number
3483
3522
  /** Failure logging, wired to the context logger by index.ts. */
3484
3523
  log?: (format: string, ...args: unknown[]) => void
3524
+ /** Success logging (one line once the probe passes). */
3525
+ info?: (format: string, ...args: unknown[]) => void
3526
+ /** Forwarded to {@link behaviorSelfCheck}. */
3527
+ getPeer?: () => unknown
3485
3528
  }
3486
3529
 
3487
3530
  /**
@@ -3501,7 +3544,7 @@ export async function runSelfCheck(raw: object, options: RunSelfCheckOptions): P
3501
3544
  const { handle, log } = options
3502
3545
  const retryDelayMs = options.retryDelayMs ?? 3_000
3503
3546
  const stillInstalled = (): boolean => handle.diagnostics().installed
3504
- let result = await behaviorSelfCheck(raw)
3547
+ let result = await behaviorSelfCheck(raw, { getPeer: options.getPeer })
3505
3548
  if (!result.ok && stillInstalled()) {
3506
3549
  await new Promise<void>((resolve) => {
3507
3550
  const timer = setTimeout(resolve, retryDelayMs)
@@ -3509,11 +3552,14 @@ export async function runSelfCheck(raw: object, options: RunSelfCheckOptions): P
3509
3552
  // loop-empty judgement) open for the retry delay.
3510
3553
  if (typeof timer.unref === 'function') timer.unref()
3511
3554
  })
3512
- if (stillInstalled()) result = await behaviorSelfCheck(raw)
3555
+ if (stillInstalled()) result = await behaviorSelfCheck(raw, { getPeer: options.getPeer })
3513
3556
  }
3514
3557
  if (!stillInstalled()) return result
3515
3558
  handle.noteSelfCheck(result)
3516
- if (result.ok) return result
3559
+ if (result.ok) {
3560
+ options.info?.('client intercept self-check passed; interception active')
3561
+ return result
3562
+ }
3517
3563
  handle.uninstall()
3518
3564
  log?.('client intercept self-check failed (%s), interception removed', result.reason)
3519
3565
  return result
@@ -0,0 +1,134 @@
1
+ /**
2
+ * invoke 二进制载荷的线上标记(2026-10-14 修复):`v1/invoke` 是纯 JSON
3
+ * 通道,而 `workspaceFiles/readBytes` 的结果带 `data: Uint8Array`——直接
4
+ * `JSON.stringify` 会把 typed array 打成普通对象,子客户端侧的 zod 校验
5
+ * (expected "Uint8Array")当场拒绝,远程会话的图片预览报「读取失败」。
6
+ *
7
+ * 两端共用同一份规则:服务端在应答前对已知二进制方法的结果做深度标记
8
+ * (typed array → `{ $zr-u8: <base64> }`),子客户端在 forwardInvoke 拿回
9
+ * 值后还原成真 `Uint8Array`。两端都按同一个 `INVOKE_BINARY_RESULT_METHODS`
10
+ * 常量开关——单一来源,不可能漂移(这是登记表同步硬约束之外的第三张
11
+ * 「小表」,但它只此一处定义)。
12
+ *
13
+ * 纯逻辑:不 import DSH、不碰 I/O。
14
+ */
15
+
16
+ /** 标记键。线上只会出现服务端写入的标记;客户端只认这个键。 */
17
+ export const BINARY_MARKER = '$zr-u8'
18
+
19
+ /** 结果里带二进制的方法(`namespace/method`)。请求侧参数不带二进制
20
+ * (readBytes 的参数是 scope/paths),故只有结果方向需要标记。 */
21
+ export const INVOKE_BINARY_RESULT_METHODS: ReadonlySet<string> = new Set(['workspaceFiles/readBytes'])
22
+
23
+ /** 深度上限:与其余 walker(share-store 祖先链、会话引用扫描)一致。 */
24
+ const MAX_DEPTH = 16
25
+
26
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
27
+ value !== null && typeof value === 'object' && !Array.isArray(value) && !isTypedArray(value)
28
+
29
+ const isTypedArray = (value: unknown): value is ArrayBufferView =>
30
+ ArrayBuffer.isView(value) === true && !(value instanceof DataView)
31
+
32
+ /** 深度遍历,把 typed array(含 Buffer)换成 `{ $zr-u8: base64 }` 标记。 */
33
+ export function markInvokeBinary(value: unknown, depth = 0): unknown {
34
+ if (depth >= MAX_DEPTH) return value
35
+ if (isTypedArray(value)) return { [BINARY_MARKER]: Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64') }
36
+ if (Array.isArray(value)) {
37
+ let changed = false
38
+ const out: unknown[] = []
39
+ for (const item of value) {
40
+ const marked = markInvokeBinary(item, depth + 1)
41
+ if (marked !== item) changed = true
42
+ out.push(marked)
43
+ }
44
+ return changed ? out : value
45
+ }
46
+ if (isPlainObject(value)) {
47
+ let changed = false
48
+ const out: Record<string, unknown> = {}
49
+ for (const [key, item] of Object.entries(value)) {
50
+ const marked = markInvokeBinary(item, depth + 1)
51
+ if (marked !== item) changed = true
52
+ out[key] = marked
53
+ }
54
+ return changed ? out : value
55
+ }
56
+ return value
57
+ }
58
+
59
+ /** 深度遍历,把 `{ $zr-u8: base64 }` 标记还原成 `Uint8Array`。
60
+ * 标记只会是本模块的 `markInvokeBinary` 写下的(键名即凭据),base64
61
+ * 恒有效;万一混入同键的怪值,`Buffer.from(..., 'base64')` 的宽容解码
62
+ * 也不会抛。非标记对象原样返回(同一引用,不惊动浅比较的调用方)。 */
63
+ export function restoreInvokeBinary(value: unknown, depth = 0): unknown {
64
+ if (depth >= MAX_DEPTH) return value
65
+ if (Array.isArray(value)) {
66
+ let changed = false
67
+ const out: unknown[] = value.map((item) => {
68
+ const restored = restoreInvokeBinary(item, depth + 1)
69
+ if (restored !== item) changed = true
70
+ return restored
71
+ })
72
+ return changed ? out : value
73
+ }
74
+ if (isPlainObject(value)) {
75
+ const marked = value[BINARY_MARKER]
76
+ // 空串也是合法标记(base64 空串 = 0 字节,mark 自己写下的);
77
+ // 只排除非字符串与带兄弟键的诱饵形状。
78
+ if (typeof marked === 'string' && Object.keys(value).length === 1) {
79
+ return new Uint8Array(Buffer.from(marked, 'base64'))
80
+ }
81
+ let changed = false
82
+ const out: Record<string, unknown> = {}
83
+ for (const [key, item] of Object.entries(value)) {
84
+ const restored = restoreInvokeBinary(item, depth + 1)
85
+ if (restored !== item) changed = true
86
+ out[key] = restored
87
+ }
88
+ return changed ? out : value
89
+ }
90
+ return value
91
+ }
92
+
93
+ /** dispatchRpc 信封的附件旁路形状(dsh-api-gateway 的 encodeRpcResult):
94
+ * Uint8Array 不进 JSON 值,抽到 `attachments: [{path, bytes}]`,原位置
95
+ * 置 null,渲染层按 path 回填。远程路径必须产出同样的契约,否则
96
+ * typed array 在后台→浏览器的桥上被 JSON 压扁(2026-10-14 实机定位)。 */
97
+ export interface ResultAttachment {
98
+ path: (string | number)[]
99
+ bytes: Uint8Array
100
+ }
101
+
102
+ /** 把(已还原的)结果值里的 Uint8Array 抽成附件;值内原位置置 null。
103
+ * 没有 typed array 时 value 原引用返回、attachments 为空数组。 */
104
+ export function extractResultAttachments(value: unknown): { value: unknown; attachments: ResultAttachment[] } {
105
+ const attachments: ResultAttachment[] = []
106
+ const walk = (input: unknown, path: (string | number)[], depth: number): unknown => {
107
+ if (depth >= MAX_DEPTH) return input
108
+ if (isTypedArray(input)) {
109
+ attachments.push({ path, bytes: input as Uint8Array })
110
+ return null
111
+ }
112
+ if (Array.isArray(input)) {
113
+ let changed = false
114
+ const out: unknown[] = input.map((item, index) => {
115
+ const extracted = walk(item, [...path, index], depth + 1)
116
+ if (extracted !== item) changed = true
117
+ return extracted
118
+ })
119
+ return changed ? out : input
120
+ }
121
+ if (isPlainObject(input)) {
122
+ let changed = false
123
+ const out: Record<string, unknown> = {}
124
+ for (const [key, item] of Object.entries(input)) {
125
+ const extracted = walk(item, [...path, key], depth + 1)
126
+ if (extracted !== item) changed = true
127
+ out[key] = extracted
128
+ }
129
+ return changed ? out : input
130
+ }
131
+ return input
132
+ }
133
+ return { value: walk(value, [], 0), attachments }
134
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The owner Peer this plugin speaks as when it calls the typert gateway on
3
+ * its own behalf (relay routes, admin projections, the intercept
4
+ * self-check, the `$events` relay subscription).
5
+ *
6
+ * DSH 0.2.1-alpha.3 removed the gateway's implicit operator fallback
7
+ * (`gateway.operatorPeer()`, `connection.operator` and the `OperatorPeer`
8
+ * export are gone): every gateway call — `invoke`, `stream`,
9
+ * `openWireStream`, `dispatchRpc` — now asserts a live Peer, and `$events` /
10
+ * `$events/result` additionally demand `principal.kind === 'owner'` and
11
+ * `interactions === 'answer'`, with the result answered by the SAME Peer that
12
+ * opened the stream. The sanctioned in-process door is
13
+ * `connection.createPeer({ principal: { kind: 'owner' }, interactions:
14
+ * 'answer' })` (docs/upgrade-guide/v0.2.1-alpha.2/operator-connect), whose
15
+ * Peer is disposed with the Context that read `connection`.
16
+ *
17
+ * Resolution order: a cached `createPeer` Peer (recreated once its scope is
18
+ * disposed), else `connection.browserPeer` (also an owner/answer Peer),
19
+ * else `undefined` — the pre-alpha.3 behavior, where the gateway itself
20
+ * defaulted a missing Peer to its operator.
21
+ */
22
+
23
+ /** Minimal structural view of a typert `PeerScope`. */
24
+ export interface PeerLike {
25
+ readonly signal?: AbortSignal
26
+ }
27
+
28
+ /** The connection-service members this module reads. */
29
+ export interface PeerConnectionLike {
30
+ createPeer?: (options: { principal: { kind: 'owner' }; interactions?: 'answer' | 'none' }) => unknown
31
+ browserPeer?: unknown
32
+ }
33
+
34
+ function isLive(peer: unknown): boolean {
35
+ if (peer === null || typeof peer !== 'object') return false
36
+ const signal = (peer as PeerLike).signal
37
+ return !(signal instanceof AbortSignal) || !signal.aborted
38
+ }
39
+
40
+ /**
41
+ * Build a lazy, caching owner-Peer source over a connection-service reader.
42
+ * The reader runs per call (the service may be absent or restarted). The
43
+ * cache is keyed on the Peer's LIVENESS, not on the connection object's
44
+ * identity: `ctx.reflect.get('connection')` hands out a fresh proxy per read,
45
+ * so an identity key would mint a new Peer per call — and the gateway
46
+ * refuses a `$events/result` from any Peer but the one that opened
47
+ * `$events` (found by the alpha.3 live smoke). A restarted connection
48
+ * service disposes its Peers, which is exactly when a new one is minted.
49
+ * @param getConnection - live reader of `ctx.connection` (may return undefined).
50
+ * @returns a function answering the Peer to pass, or undefined to let the
51
+ * host default it (pre-alpha.3 hosts).
52
+ */
53
+ export function createOwnerPeerSource(getConnection: () => unknown): () => unknown {
54
+ let cached: unknown
55
+ return () => {
56
+ let connection: PeerConnectionLike | undefined
57
+ try {
58
+ const value = getConnection()
59
+ connection = value !== null && typeof value === 'object' ? (value as PeerConnectionLike) : undefined
60
+ } catch {
61
+ connection = undefined
62
+ }
63
+ if (connection === undefined) return undefined
64
+ if (typeof connection.createPeer === 'function') {
65
+ if (isLive(cached)) return cached
66
+ cached = connection.createPeer({ principal: { kind: 'owner' }, interactions: 'answer' })
67
+ return cached
68
+ }
69
+ const browser = connection.browserPeer
70
+ return browser !== null && typeof browser === 'object' ? browser : undefined
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Attach `peer` to one gateway request object when a Peer is available;
76
+ * otherwise return the request unchanged (pre-alpha.3 shape).
77
+ */
78
+ export function withPeer<T extends object>(request: T, getPeer: (() => unknown) | undefined): T & { peer?: unknown } {
79
+ const peer = getPeer?.()
80
+ return peer === undefined ? request : { ...request, peer }
81
+ }
@@ -26,6 +26,7 @@
26
26
  * a 404.
27
27
  */
28
28
 
29
+ import { withPeer } from './owner-peer.js'
29
30
  import { randomBytes, timingSafeEqual } from 'node:crypto'
30
31
  import { Readable, Transform } from 'node:stream'
31
32
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
@@ -48,6 +49,7 @@ import { routeMethodsOf, routeMaxBodyOf } from './plugin-bridge.js'
48
49
  import { createPluginTunnel, validatePluginCall, type PluginTunnelRoutes } from './plugin-tunnel.js'
49
50
  import type { PluginLoopback } from './plugin-loopback.js'
50
51
  import { isLoopbackAuthError } from './plugin-loopback.js'
52
+ import { INVOKE_BINARY_RESULT_METHODS, markInvokeBinary } from './invoke-binary.js'
51
53
  import type { NormalizedAdapter, AdapterVerdict } from './plugin-adapter-core.js'
52
54
  import { matchAdapterRoute, locateAdapterIds, adapterOwnsRegistryEntry, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
53
55
 
@@ -207,12 +209,13 @@ const JOB_CACHE_LIMIT = 1024
207
209
  * before its typert dispatch — and returns the `{ok,…}` envelope itself.
208
210
  */
209
211
  export interface RelayGateway {
210
- invoke(call: { namespace: string; method: string; args: unknown; signal?: AbortSignal }): Promise<unknown>
212
+ invoke(call: { namespace: string; method: string; args: unknown; signal?: AbortSignal; peer?: unknown }): Promise<unknown>
211
213
  stream(call: {
212
214
  namespace: string
213
215
  method: string
214
216
  args: unknown
215
217
  signal?: AbortSignal
218
+ peer?: unknown
216
219
  }): Promise<AsyncIterable<unknown>>
217
220
  /** The host's wire adapter, shared by the WebSocket mux and the local
218
221
  * transports. `open` is the async 0.2.0 method. */
@@ -267,6 +270,12 @@ export interface RelayHandlerOptions {
267
270
  * synthetic Request to the same exact-fetch route table the browser's
268
271
  * `/api` transport uses — no loopback HTTP, no login state. */
269
272
  getApiFetch?: () => ((request: Request) => Promise<Response>) | undefined
273
+ /** The owner Peer every gateway call is made as (DSH 0.2.1-alpha.3+
274
+ * requires one; `$events` and `$events/result` need an owner Peer with
275
+ * `interactions: 'answer'`, the SAME one for both). Built by
276
+ * {@link createOwnerPeerSource}; absent or answering undefined keeps the
277
+ * pre-alpha.3 behavior (the host defaults the Peer). */
278
+ getPeer?: () => unknown
270
279
  /** Stream heartbeat interval in ms (a `{"type":"ping"}` line that keeps
271
280
  * reverse proxies from timing the idle stream away); defaults to 15000.
272
281
  * Tests inject a small value. */
@@ -874,12 +883,12 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
874
883
  const cached = recentJobs.get(sessionId)
875
884
  if (cached !== undefined && cached.has(jobId)) return cached.get(jobId) === sessionId
876
885
  try {
877
- const iterable = await gateway.stream({
886
+ const iterable = await gateway.stream(withPeer({
878
887
  namespace: 'job',
879
888
  method: 'list',
880
889
  args: { request: { sessionId } },
881
890
  signal,
882
- })
891
+ }, options.getPeer))
883
892
  for await (const frame of iterable) {
884
893
  rememberJobs(sessionId, frame)
885
894
  const jobs = recentJobs.get(sessionId)
@@ -905,7 +914,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
905
914
  */
906
915
  const workspaceExists = async (workspaceId: string, signal: AbortSignal): Promise<boolean> => {
907
916
  try {
908
- const iterable = await gateway.stream({ namespace: 'workspace', method: 'follow', args: {}, signal })
917
+ const iterable = await gateway.stream(withPeer({ namespace: 'workspace', method: 'follow', args: {}, signal }, options.getPeer))
909
918
  for await (const frame of iterable) {
910
919
  if (!isPlainObject(frame) || frame.type !== 'baseline') return false
911
920
  const value = isPlainObject(frame.value) ? frame.value : undefined
@@ -938,7 +947,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
938
947
  '$events',
939
948
  { args: {} },
940
949
  undefined,
941
- undefined,
950
+ options.getPeer?.(),
942
951
  signal,
943
952
  )) as unknown
944
953
  if (
@@ -1216,7 +1225,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1216
1225
  '$events/result',
1217
1226
  { args: { clientId: owner.clientId, eventId: originalEventId, outcome: parsed.result } },
1218
1227
  hangUp.signal,
1219
- undefined,
1228
+ options.getPeer?.(),
1220
1229
  )) as unknown
1221
1230
  responseJson(res, 200, isPlainObject(envelope) ? envelope : { ok: false, error: { code: 'internal' } })
1222
1231
  } catch (error) {
@@ -1340,7 +1349,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1340
1349
  responseJson(res, 403, { ok: false, error: { code: 'not-shared' } })
1341
1350
  return
1342
1351
  }
1343
- const value = await gateway.invoke({ namespace, method, args, signal: hangUp.signal })
1352
+ const value = await gateway.invoke(withPeer({ namespace, method, args, signal: hangUp.signal }, options.getPeer))
1344
1353
  // T31: a session created or forked THROUGH the relay is shared
1345
1354
  // automatically — the client only ever sees shared sessions, so an
1346
1355
  // unshared result would be born unreachable. Re-sharing (a fork the
@@ -1363,7 +1372,14 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1363
1372
  : decision.filter === 'model-catalog'
1364
1373
  ? filterModelCatalogResult(value)
1365
1374
  : value
1366
- responseJson(res, 200, { ok: true, value: travels })
1375
+ // 2026-10-14: a binary-result method (workspaceFiles/readBytes) cannot
1376
+ // ride JSON.stringify — typed arrays collapse into plain objects and
1377
+ // the sub-client's own zod validation rejects the answer (remote image
1378
+ // preview: "expected Uint8Array"). The marker round-trip
1379
+ // (invoke-binary.ts) is restored on the client half (intercept.ts).
1380
+ const wire =
1381
+ INVOKE_BINARY_RESULT_METHODS.has(`${namespace}/${method}`) ? markInvokeBinary(travels) : travels
1382
+ responseJson(res, 200, { ok: true, value: wire })
1367
1383
  } catch (error) {
1368
1384
  // The gateway's own failures (unknown namespace, absent service) are
1369
1385
  // business answers, not transport errors: they ride a 200 envelope
@@ -2463,12 +2479,12 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
2463
2479
  const syncProjections = async (sessionId: string): Promise<void> => {
2464
2480
  let result: unknown
2465
2481
  try {
2466
- result = await gateway.invoke({
2482
+ result = await gateway.invoke(withPeer({
2467
2483
  namespace: 'session',
2468
2484
  method: 'projections',
2469
2485
  args: { request: { sessionId } },
2470
2486
  signal: hangUp.signal,
2471
- })
2487
+ }, options.getPeer))
2472
2488
  } catch {
2473
2489
  return // projections unavailable — the next live projection frame will flow anyway
2474
2490
  }
@@ -2588,7 +2604,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
2588
2604
  const iterable =
2589
2605
  eventsSub !== undefined
2590
2606
  ? await openEventsStream(hangUp.signal)
2591
- : await gateway.stream({ namespace, method, args, signal: hangUp.signal })
2607
+ : await gateway.stream(withPeer({ namespace, method, args, signal: hangUp.signal }, options.getPeer))
2592
2608
  for await (const frame of iterable) {
2593
2609
  if (finished || clientGone) break
2594
2610
  if (namespace === 'job' && method === 'list') rememberJobs(ownerSessionIdOf(args), frame)