dsh-remote-bridge 2.8.1 → 2.9.0

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.en.md CHANGED
@@ -9,7 +9,7 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <img alt="Version" src="https://img.shields.io/badge/version-v2.8.1-087f8c">
12
+ <img alt="Version" src="https://img.shields.io/badge/version-v2.9.0-087f8c">
13
13
  <img alt="DSH plugin" src="https://img.shields.io/badge/DeepSeek_Harness-plugin-1f2328">
14
14
  <img alt="Platforms" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-586069">
15
15
  <img alt="License" src="https://img.shields.io/badge/license-AGPL--3.0-2da44e">
@@ -402,7 +402,26 @@ inside the zero-change set, and 4 slots retained with 0 removed.
402
402
 
403
403
  ## Project
404
404
 
405
- - Version: `v2.8.1`
405
+ - Version: `v2.9.0`
406
+ - **v2.9.0 makes teammates (sub-sessions) readable**: the phone's team panel could list members and
407
+ sub-sessions but could not open a single one, because `sessions.list` filters out every row with
408
+ `origin === 'subagent'` (the bridge's own `dsh-0.2-bridge.ts:2327`, with a comment explaining that
409
+ exposing them "only yields rows that crash on tap"), so any sub-session id hitting `/messages` gets a
410
+ hard 404. Even past that visibility check the kernel's `validateAddress()` demands the
411
+ `{ kind:'subagent', parentSessionId, childSessionId, mode }` discriminated union instead of a plain
412
+ session address. This release adds a **read-only narrow port guarded by membership**:
413
+ `GET /api/v1/chat/sessions/{leadSessionId}/subagents/{subagentId}/messages`. Authorization takes two
414
+ steps — (1) the Lead session must be visible to the device (reusing `requireSession`, not a single rule
415
+ relaxed); (2) membership must be vouched for by a projection the kernel itself publishes
416
+ (`subagentCatalog[].id` or `agentTeam.members[].id`). Only then is history read directly, deliberately
417
+ **without** calling `requireSession(subagentId)` — removing the `origin` filter would put "crash on tap"
418
+ rows back into the list, while loosening `requireSession` would backdoor every session; the narrow port
419
+ has the cleanest boundary. Read-only (non-GET always 405), and `SUBAGENT_NOT_FOUND` deliberately does
420
+ not distinguish "does not exist" from "not your teammate", otherwise id existence becomes a probe
421
+ surface. New capability bit `subagent-history-v1` (it must be separate from `history-v1`, which always
422
+ 404s on sub-session ids and therefore cannot advertise this). The same release re-checked DSH
423
+ 0.2.1-rc.2 → rc.5: 18 of the 19 packages the bridge depends on are byte-identical, the only diff being
424
+ a single KDoc comment.
406
425
  - **v2.7.1 is a defect-fix release**: the kernel's `sessionController.resolveAgent()` returns an
407
426
  `{ agent }` / `{ error }` **envelope**, not the Agent itself (`dsh-api-session-controller/lib/index.js:365-368`
408
427
  `liveAgent()` and `:221-246` `resolve()` both have that shape). The bridge used to hand the envelope
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <img alt="Version" src="https://img.shields.io/badge/version-v2.8.1-087f8c">
12
+ <img alt="Version" src="https://img.shields.io/badge/version-v2.9.0-087f8c">
13
13
  <img alt="DSH plugin" src="https://img.shields.io/badge/DeepSeek_Harness-plugin-1f2328">
14
14
  <img alt="Platforms" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-586069">
15
15
  <img alt="License" src="https://img.shields.io/badge/license-AGPL--3.0-2da44e">
@@ -433,7 +433,21 @@ DSH **0.2.1-rc.3** 隔离实例上**实跑验过**,不是静态推断:插件
433
433
 
434
434
  ## 项目信息
435
435
 
436
- - 当前版本:`v2.8.1`
436
+ - 当前版本:`v2.9.0`
437
+ - **v2.9.0 是团队成员(子会话)可读版**:手机端的团队面板此前只能看到成员与子会话的名字,点开读不到
438
+ 任何内容 —— 因为 `sessions.list` 会把 `origin === 'subagent'` 的行整批滤掉(桥自己的
439
+ `dsh-0.2-bridge.ts:2327`,注释写明「暴露出来只会得到点开就崩的行」),子会话 id 打 `/messages`
440
+ 必然 404;即使绕过可见性,内核 `validateAddress()` 也要求用
441
+ `{ kind:'subagent', parentSessionId, childSessionId, mode }` 判别联合寻址,而非普通会话地址。
442
+ 这一版新增**受成员关系校验的只读窄口**
443
+ `GET /api/v1/chat/sessions/{leadSessionId}/subagents/{subagentId}/messages`:授权两步 ——
444
+ ① Lead 会话必须对设备可见(沿用 `requireSession`,规则一行不放宽);② 成员关系必须由内核自己发布的
445
+ 投影背书(`subagentCatalog[].id` 或 `agentTeam.members[].id`)。两步都过才直读历史,刻意不再调
446
+ `requireSession(subagentId)` —— 拆 `origin` 过滤会把「点开就崩」的行放回列表,放宽 `requireSession`
447
+ 等于给所有会话开后门,窄口的边界最清晰。只读(非 GET 一律 405),错误码 `SUBAGENT_NOT_FOUND`
448
+ 刻意不区分「不存在」与「不是成员」否则 id 是否存在就成了探测面。新增能力位 `subagent-history-v1`
449
+ (必须独立于 `history-v1`,后者对子会话永远 404 探测不到)。同版复核了 DSH 0.2.1-rc.2 → rc.5:
450
+ 桥依赖的 19 个包中 18 个零差异,唯一差异是一行 KDoc 注释。
437
451
  - **v2.7.1 是缺陷修复版**:内核 `sessionController.resolveAgent()` 返回的是 `{ agent }` / `{ error }`
438
452
  **包装对象**而非 Agent 本体(`dsh-api-session-controller/lib/index.js:365-368` 的 `liveAgent()` 与
439
453
  `:221-246` 的 `resolve()` 都是这个形状),桥此前把包装对象直接当 Agent 传给 `fileUploads.upload()`,
@@ -1,7 +1,7 @@
1
1
  asyncapi: 3.0.0
2
2
  info:
3
3
  title: DeepSeek Harness Mobile Events
4
- version: 2.8.1
4
+ version: 2.9.0
5
5
  description: |
6
6
  Live file and authorized DSH chat events. Reconnect clients must refresh
7
7
  REST state; unbounded replay is not provided.
@@ -154,6 +154,13 @@ channels:
154
154
  > is missing from the set will start echoing a stale `requestId` from
155
155
  > whatever request happened to be in flight when it fired.
156
156
 
157
+ > **子会话历史不走 WS**(2026-10-30):团队成员 / 子会话的历史只能经
158
+ > REST `GET /chat/sessions/{leadSessionId}/subagents/{subagentId}/messages`
159
+ > 读取,本协议**没有**对应帧,也不打算加。原因是普通 `history` 帧对子会话
160
+ > id 必然 404(子会话被 `sessions.list` 刻意滤掉),而那条窄口的授权靠
161
+ > 「Lead 可见 + 内核投影背书成员关系」两步,与 WS 连接级的会话白名单不是
162
+ > 一回事;硬塞进 WS 只会让两套授权语义打架。详见 `docs/project/API.md`。
163
+
157
164
  Rules:
158
165
 
159
166
  - Absent or empty string ⇒ **no** `requestId` key in the response at all
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: DeepSeek Harness Mobile API
4
- version: 2.8.1
4
+ version: 2.9.0
5
5
  description: Versioned file and chat BFF for mobile and third-party DSH clients. HTTP and HTTPS are both supported.
6
6
  servers:
7
7
  - url: http://127.0.0.1:3090/api/v1
@@ -24,7 +24,7 @@ paths:
24
24
  properties:
25
25
  ok: { type: boolean, const: true }
26
26
  version: { type: string, const: v1 }
27
- pluginVersion: { type: string, const: 2.8.1 }
27
+ pluginVersion: { type: string, const: 2.9.0 }
28
28
  /debug/host-services:
29
29
  get:
30
30
  operationId: hostServices
@@ -989,6 +989,80 @@ paths:
989
989
  responses:
990
990
  '202': { description: Prompt accepted by DSH. }
991
991
  '409': { $ref: '#/components/responses/Error' }
992
+ /chat/sessions/{leadSessionId}/subagents/{subagentId}/messages:
993
+ parameters:
994
+ - name: leadSessionId
995
+ in: path
996
+ required: true
997
+ description: |
998
+ The session that owns the team (the Lead). Its visibility is checked with the
999
+ ordinary session rules -- the narrow endpoint never relaxes them.
1000
+ schema: { type: string }
1001
+ - name: subagentId
1002
+ in: path
1003
+ required: true
1004
+ description: |
1005
+ A subagent / teammate session id. It is *not* required to be listed by
1006
+ `GET /chat/sessions` (subagent sessions are filtered out of that list);
1007
+ membership is verified against the Lead's own `subagentCatalog` /
1008
+ `agentTeam.members` projection instead.
1009
+ schema: { type: string }
1010
+ get:
1011
+ operationId: listSubagentMessages
1012
+ description: |
1013
+ Read the history of a subagent (teammate) session -- a read-only narrow endpoint.
1014
+
1015
+ Why it exists: `GET /chat/sessions/{sessionId}/messages` resolves visibility via
1016
+ the session list, and subagent sessions are deliberately filtered out of that list
1017
+ (the kernel refuses to activate them as ordinary sessions, so exposing them yields
1018
+ rows that crash on open). Every subagent id therefore 404s on the ordinary path.
1019
+
1020
+ Authorization has two steps, both required: (1) the Lead session must be visible to
1021
+ this device under the unchanged rules; (2) membership must be backed by the kernel's
1022
+ own projection for that Lead (`subagentCatalog[].id` or `agentTeam.members[].id`).
1023
+ Guessing a session id does not work -- a miss is a 404.
1024
+
1025
+ Read-only: any non-GET method is `405 METHOD_NOT_ALLOWED`. Writing to a teammate
1026
+ session would blur who is directing whom; the official Web UI likewise only writes
1027
+ through the Lead's model tools.
1028
+ parameters:
1029
+ - name: beforeSeq
1030
+ in: query
1031
+ schema: { type: integer, minimum: 0 }
1032
+ - name: maxMessages
1033
+ in: query
1034
+ schema: { type: integer, minimum: 1 }
1035
+ responses:
1036
+ '200':
1037
+ description: |
1038
+ Identical shape and byte budget as `listMessages`: the oldest events may be
1039
+ dropped (with `hasMore` forced to true) and oversized strings inside one event
1040
+ may be head/tail clamped. `truncated`/`bytes` appear only when something was
1041
+ actually clamped. `historyFormatVersion` is always present.
1042
+ content:
1043
+ application/json:
1044
+ schema:
1045
+ type: object
1046
+ required: [events, hasMore]
1047
+ properties:
1048
+ events:
1049
+ type: array
1050
+ items: { type: object, additionalProperties: true }
1051
+ hasMore: { type: boolean }
1052
+ projections: { type: object, additionalProperties: true }
1053
+ bytes: { type: integer, minimum: 0 }
1054
+ truncated: { type: boolean }
1055
+ historyFormatVersion: { type: integer, minimum: 1 }
1056
+ '404':
1057
+ description: |
1058
+ `SUBAGENT_NOT_FOUND` -- either the subagent is not a member of this team, or
1059
+ the Lead's projections were unavailable (membership unprovable). The two cases
1060
+ are deliberately indistinguishable so the existence of a session id is not
1061
+ turned into a probing surface.
1062
+ content:
1063
+ application/json:
1064
+ schema: { $ref: '#/components/schemas/Error' }
1065
+ '405': { $ref: '#/components/responses/Error' }
992
1066
  /chat/sessions/{sessionId}/attachments:
993
1067
  parameters:
994
1068
  - name: sessionId
@@ -541,6 +541,27 @@ class DshWorkspaceClient(
541
541
 
542
542
  fun listMessages(sessionId: String): JsonElement = executeJsonElement("chat/sessions/$sessionId/messages", "GET", null)
543
543
 
544
+ /**
545
+ * 读**子会话(团队成员)**的历史 —— 受成员关系校验的只读窄口。
546
+ *
547
+ * 不能复用 [listMessages]:那条路径靠会话列表判定可见性,而子会话被
548
+ * `sessions.list` 刻意滤掉(内核拒绝把它们当普通会话激活),所以任何子会话 id
549
+ * 打进去都是 404。这条窄口改成两步授权:Lead 会话必须可见 + 内核投影
550
+ * (`subagentCatalog` / `agentTeam.members`)必须背书成员关系。只读。
551
+ */
552
+ fun listSubagentMessages(
553
+ leadSessionId: String,
554
+ subagentId: String,
555
+ beforeSeq: Int? = null,
556
+ maxMessages: Int? = null,
557
+ ): JsonElement {
558
+ val query = buildString {
559
+ if (beforeSeq != null) append(if (isEmpty()) "?" else "&").append("beforeSeq=").append(beforeSeq)
560
+ if (maxMessages != null) append(if (isEmpty()) "?" else "&").append("maxMessages=").append(maxMessages)
561
+ }
562
+ return executeJsonElement("chat/sessions/$leadSessionId/subagents/$subagentId/messages$query", "GET", null)
563
+ }
564
+
544
565
  fun renameSession(sessionId: String, title: String) {
545
566
  val body = json.encodeToString(RenameRequest.serializer(), RenameRequest(title))
546
567
  executeUnit("chat/sessions/$sessionId", "PATCH", body)
package/lib/index.d.ts CHANGED
@@ -785,6 +785,23 @@ interface AttachmentStoreLike {
785
785
  data: Uint8Array;
786
786
  }>;
787
787
  }
788
+ /**
789
+ * 内核的 `SessionAddress` 判别联合(`dsh-api-session-controller` 的
790
+ * types.d.ts:406-414)。普通会话用 `kind:'session'`,**子会话必须**用
791
+ * `kind:'subagent'` 并带上父会话 id —— 内核在 `validateAddress()` 里对
792
+ * `{kind:'session'}` 且 header.origin==='subagent' 直接抛
793
+ * `session/agent-busy: "subagent Sessions require their durable parent address"`。
794
+ * 也就是说:子会话历史一直是可读的,只是必须按内核要的地址形状去问。
795
+ */
796
+ type SessionAddressLike = {
797
+ kind: 'session';
798
+ sessionId: string;
799
+ } | {
800
+ kind: 'subagent';
801
+ parentSessionId: string;
802
+ childSessionId: string;
803
+ mode: 'one-shot' | 'continuable' | 'unknown';
804
+ };
788
805
  interface SessionControllerLike {
789
806
  list(request: {
790
807
  cursor?: string;
@@ -808,10 +825,7 @@ interface SessionControllerLike {
808
825
  * a well-formed but *empty* page — a silent failure, not an error.
809
826
  */
810
827
  follow(request: {
811
- address: {
812
- kind: 'session';
813
- sessionId: string;
814
- };
828
+ address: SessionAddressLike;
815
829
  maxMessages?: number;
816
830
  /**
817
831
  * Assistant presentation frames are opt-in: without `assistantStream: true`
@@ -860,10 +874,7 @@ interface SessionControllerLike {
860
874
  * `beforeSeq` is optional and acts as an earlier boundary.
861
875
  */
862
876
  page(request: {
863
- address: {
864
- kind: 'session';
865
- sessionId: string;
866
- };
877
+ address: SessionAddressLike;
867
878
  throughSeq: number;
868
879
  beforeSeq?: number;
869
880
  maxMessages?: number;
@@ -1286,12 +1297,20 @@ interface DshApiProxy {
1286
1297
  sessionId: string;
1287
1298
  agentPreset?: string;
1288
1299
  }>>;
1300
+ /**
1301
+ * 读历史。`parentSessionId` + `subagentMode` 是本桥的**扩展字段**(内核契约里
1302
+ * 没有):带上它们表示「这是 parentSessionId 名下的子会话」,底层据此把地址
1303
+ * 拼成 `kind:'subagent'`(内核只认这个形状,拿 childSessionId 冒充普通
1304
+ * sessionId 会被 validateAddress() 拒掉)。不带就是普通会话,行为一字不变。
1305
+ */
1289
1306
  history(request: {
1290
1307
  rpcId: string;
1291
1308
  payload: {
1292
1309
  sessionId: string;
1293
1310
  beforeSeq?: number;
1294
1311
  maxMessages?: number;
1312
+ parentSessionId?: string;
1313
+ subagentMode?: 'one-shot' | 'continuable' | 'unknown';
1295
1314
  };
1296
1315
  }): Promise<RpcResponse<{
1297
1316
  events: unknown[];
@@ -1928,6 +1947,51 @@ declare class DshChatAdapter {
1928
1947
  truncated?: boolean;
1929
1948
  historyFormatVersion?: number;
1930
1949
  }>;
1950
+ /**
1951
+ * 读**子会话**(subagent / 团队成员)的历史 —— 受成员关系校验的只读窄口。
1952
+ *
1953
+ * ## 为什么需要一条单独的路径
1954
+ *
1955
+ * 内核 0.2.x 把子会话也列在 `sessions.list` 里、并标 `origin: 'subagent'`,
1956
+ * 而桥在 `dsh-0.2-bridge.ts` 的 `sessions.list` 把它们**整批滤掉**(内核拒绝
1957
+ * 把子会话当普通会话激活,暴露出来只会得到「点开就崩」的行)。`requireSession()`
1958
+ * 的可见性判定正是建立在 `listSessions()` 上的,于是任何 `{subagentId}` 打进来
1959
+ * 都是 404 —— 手机端「团队面板里点成员/子会话看内容」因此根本走不通。
1960
+ *
1961
+ * 三条路可选:拆掉 `origin` 过滤(把「点开就崩」的行放回列表,回归风险最大)、
1962
+ * 放宽 `requireSession`(等于给所有会话开一条后门,最危险)、或者**单开一条
1963
+ * 自带授权规则的窄口**。本实现选第三条:授权判定不依赖会话列表,改用**内核自己
1964
+ * 发布的投影**来证明成员关系。
1965
+ *
1966
+ * ## 授权是怎么成立的(两步,缺一不可)
1967
+ *
1968
+ * ① **Lead 必须可见**:`requireSession(leadSessionId)` 走的是完全没变的既有规则
1969
+ * (设备 scope + 授权根 + 归档状态)。子会话能读,前提是它的 Lead 本来就能读。
1970
+ * ② **成员关系必须由内核背书**:读 Lead 的会话投影,要求 `subagentId` 出现在
1971
+ * `subagentCatalog[].id`(或 `agentTeam.members[].id`,两者在内核里指向同一批
1972
+ * Session)里。这份目录是内核为这个 Lead 亲手发布的,不是我们维护的白名单,
1973
+ * 所以「猜一个 sessionId 来读别人会话」在这里不成立 —— 猜不中就是 404。
1974
+ *
1975
+ * 通过这两步之后才直读历史。注意 ③ **刻意不再调 `requireSession`**:那正是会
1976
+ * 必然 404 的那一步,授权已在 ①② 完成,③ 纯粹是取数据。
1977
+ *
1978
+ * ## 为什么只读
1979
+ *
1980
+ * 写子会话(给成员发消息、改它的模型/权限)会让「谁在指挥谁」变得含混:官方 Web 端
1981
+ * 同样不允许人类直接写成员会话,一切经由 Lead 的模型工具。这条窄口只开放 `events`
1982
+ * 读取,`POST` 一律 405。
1983
+ */
1984
+ subagentHistory(principal: Principal, leadSessionId: string, subagentId: string, options: {
1985
+ beforeSeq?: number;
1986
+ maxMessages?: number;
1987
+ }): Promise<{
1988
+ events: unknown[];
1989
+ hasMore: boolean;
1990
+ projections?: unknown;
1991
+ bytes?: number;
1992
+ truncated?: boolean;
1993
+ historyFormatVersion?: number;
1994
+ }>;
1931
1995
  prompt(principal: Principal, sessionId: string, parts: PromptContentPart[], mode: 'queue' | 'steer', clientTimeZone?: string, locale?: HandoffLocale): Promise<{
1932
1996
  accepted: true;
1933
1997
  command?: unknown;
package/lib/index.js CHANGED
@@ -2998,22 +2998,30 @@ function createDsh02ApiProxy(host, options = {}) {
2998
2998
  create: (request) => call("sessions.create", (signal) => host.sessionController.create(request.payload)),
2999
2999
  history: (request) => call("sessions.history", async (signal) => {
3000
3000
  const { sessionId, maxMessages, beforeSeq } = request.payload;
3001
+ const parentSessionId = request.payload.parentSessionId;
3002
+ const subagentMode = request.payload.subagentMode;
3003
+ const address = typeof parentSessionId === "string" && parentSessionId !== "" ? {
3004
+ kind: "subagent",
3005
+ parentSessionId,
3006
+ childSessionId: sessionId,
3007
+ mode: subagentMode === "one-shot" || subagentMode === "continuable" ? subagentMode : "unknown"
3008
+ } : {
3009
+ kind: "session",
3010
+ sessionId
3011
+ };
3001
3012
  const controller = new AbortController();
3002
3013
  if (signal.aborted) controller.abort();
3003
3014
  else signal.addEventListener("abort", () => controller.abort(), { once: true });
3004
3015
  try {
3005
3016
  const snapshot = (await host.sessionController.follow({
3006
- address: {
3007
- kind: "session",
3008
- sessionId
3009
- },
3017
+ address,
3010
3018
  ...maxMessages === void 0 ? {} : { maxMessages }
3011
3019
  }, controller.signal)[Symbol.asyncIterator]().next()).value;
3012
3020
  if (snapshot === void 0 || snapshot.type !== "snapshot") return {
3013
3021
  events: [],
3014
3022
  hasMore: false
3015
3023
  };
3016
- const projections = projectionsOf(snapshot) ?? await readSessionProjections(host, sessionId, controller.signal);
3024
+ const projections = projectionsOf(snapshot) ?? (address.kind === "session" ? await readSessionProjections(host, sessionId, controller.signal) : void 0);
3017
3025
  const withProjections = (payload) => projections === void 0 ? payload : {
3018
3026
  ...payload,
3019
3027
  projections
@@ -3023,10 +3031,7 @@ function createDsh02ApiProxy(host, options = {}) {
3023
3031
  hasMore: snapshot.hasMore === true
3024
3032
  });
3025
3033
  const page = await host.sessionController.page({
3026
- address: {
3027
- kind: "session",
3028
- sessionId
3029
- },
3034
+ address,
3030
3035
  throughSeq: snapshot.cursor,
3031
3036
  beforeSeq,
3032
3037
  ...maxMessages === void 0 ? {} : { maxMessages }
@@ -3294,7 +3299,7 @@ function createDsh02ApiProxy(host, options = {}) {
3294
3299
  }
3295
3300
  //#endregion
3296
3301
  //#region src/shared/version.ts
3297
- const PLUGIN_VERSION = "2.8.1";
3302
+ const PLUGIN_VERSION = "2.9.0";
3298
3303
  //#endregion
3299
3304
  //#region src/host/capabilities.ts
3300
3305
  /**
@@ -3358,6 +3363,7 @@ const BRIDGE_CAPABILITIES = [
3358
3363
  "session-archive-v1",
3359
3364
  "session-archive-list-v1",
3360
3365
  "history-v1",
3366
+ "subagent-history-v1",
3361
3367
  "turn-cancel-v1",
3362
3368
  "session-config-v1",
3363
3369
  "commands-v1",
@@ -3911,6 +3917,85 @@ var DshChatAdapter = class {
3911
3917
  historyFormatVersion: 1
3912
3918
  };
3913
3919
  }
3920
+ /**
3921
+ * 读**子会话**(subagent / 团队成员)的历史 —— 受成员关系校验的只读窄口。
3922
+ *
3923
+ * ## 为什么需要一条单独的路径
3924
+ *
3925
+ * 内核 0.2.x 把子会话也列在 `sessions.list` 里、并标 `origin: 'subagent'`,
3926
+ * 而桥在 `dsh-0.2-bridge.ts` 的 `sessions.list` 把它们**整批滤掉**(内核拒绝
3927
+ * 把子会话当普通会话激活,暴露出来只会得到「点开就崩」的行)。`requireSession()`
3928
+ * 的可见性判定正是建立在 `listSessions()` 上的,于是任何 `{subagentId}` 打进来
3929
+ * 都是 404 —— 手机端「团队面板里点成员/子会话看内容」因此根本走不通。
3930
+ *
3931
+ * 三条路可选:拆掉 `origin` 过滤(把「点开就崩」的行放回列表,回归风险最大)、
3932
+ * 放宽 `requireSession`(等于给所有会话开一条后门,最危险)、或者**单开一条
3933
+ * 自带授权规则的窄口**。本实现选第三条:授权判定不依赖会话列表,改用**内核自己
3934
+ * 发布的投影**来证明成员关系。
3935
+ *
3936
+ * ## 授权是怎么成立的(两步,缺一不可)
3937
+ *
3938
+ * ① **Lead 必须可见**:`requireSession(leadSessionId)` 走的是完全没变的既有规则
3939
+ * (设备 scope + 授权根 + 归档状态)。子会话能读,前提是它的 Lead 本来就能读。
3940
+ * ② **成员关系必须由内核背书**:读 Lead 的会话投影,要求 `subagentId` 出现在
3941
+ * `subagentCatalog[].id`(或 `agentTeam.members[].id`,两者在内核里指向同一批
3942
+ * Session)里。这份目录是内核为这个 Lead 亲手发布的,不是我们维护的白名单,
3943
+ * 所以「猜一个 sessionId 来读别人会话」在这里不成立 —— 猜不中就是 404。
3944
+ *
3945
+ * 通过这两步之后才直读历史。注意 ③ **刻意不再调 `requireSession`**:那正是会
3946
+ * 必然 404 的那一步,授权已在 ①② 完成,③ 纯粹是取数据。
3947
+ *
3948
+ * ## 为什么只读
3949
+ *
3950
+ * 写子会话(给成员发消息、改它的模型/权限)会让「谁在指挥谁」变得含混:官方 Web 端
3951
+ * 同样不允许人类直接写成员会话,一切经由 Lead 的模型工具。这条窄口只开放 `events`
3952
+ * 读取,`POST` 一律 405。
3953
+ */
3954
+ async subagentHistory(principal, leadSessionId, subagentId, options) {
3955
+ await this.requireSession(principal, leadSessionId);
3956
+ const baseline = unwrapDsh(await this.api.sessions.projections({
3957
+ rpcId: randomUUID(),
3958
+ payload: { sessionId: leadSessionId }
3959
+ }));
3960
+ if (baseline === null || baseline === void 0) throw new ApiError(404, "SUBAGENT_NOT_FOUND", "Session projections are unavailable, cannot verify membership.");
3961
+ const values = baseline.values ?? {};
3962
+ const members = /* @__PURE__ */ new Set();
3963
+ const modes = /* @__PURE__ */ new Map();
3964
+ const catalog = values["subagentCatalog"];
3965
+ if (Array.isArray(catalog)) for (const entry of catalog) {
3966
+ if (typeof entry !== "object" || entry === null) continue;
3967
+ const record = entry;
3968
+ const id = typeof record["id"] === "string" ? record["id"] : record["sessionId"];
3969
+ if (typeof id !== "string" || id === "") continue;
3970
+ members.add(id);
3971
+ const mode = record["mode"];
3972
+ if (mode === "one-shot" || mode === "continuable" || mode === "unknown") modes.set(id, mode);
3973
+ }
3974
+ const team = values["agentTeam"];
3975
+ if (typeof team === "object" && team !== null) {
3976
+ const teamMembers = team["members"];
3977
+ if (Array.isArray(teamMembers)) for (const entry of teamMembers) {
3978
+ if (typeof entry !== "object" || entry === null) continue;
3979
+ const id = entry["id"];
3980
+ if (typeof id === "string" && id !== "") members.add(id);
3981
+ }
3982
+ }
3983
+ if (!members.has(subagentId)) throw new ApiError(404, "SUBAGENT_NOT_FOUND", "The session is not a member of this team.");
3984
+ const payload = {
3985
+ sessionId: subagentId,
3986
+ parentSessionId: leadSessionId,
3987
+ subagentMode: modes.get(subagentId) ?? "unknown",
3988
+ ...options.beforeSeq === void 0 ? {} : { beforeSeq: options.beforeSeq },
3989
+ ...options.maxMessages === void 0 ? {} : { maxMessages: options.maxMessages }
3990
+ };
3991
+ return {
3992
+ ...applyHistoryBudget(unwrapDsh(await this.api.sessions.history({
3993
+ rpcId: randomUUID(),
3994
+ payload
3995
+ }))),
3996
+ historyFormatVersion: 1
3997
+ };
3998
+ }
3914
3999
  async prompt(principal, sessionId, parts, mode, clientTimeZone, locale) {
3915
4000
  await this.requireSession(principal, sessionId);
3916
4001
  const hasText = parts.some((part) => part.type === "text" && part.text.trim() !== "");
@@ -4267,7 +4352,7 @@ var DshChatAdapter = class {
4267
4352
  const key = approvalKey(payload.sessionId, payload.approvalId);
4268
4353
  const previous = this.pendingApprovals.get(key);
4269
4354
  this.pendingApprovals.set(key, {
4270
- rpcId: frame.rpcId,
4355
+ rpcId: typeof payload.rpcId === "string" && payload.rpcId.length > 0 ? payload.rpcId : frame.rpcId,
4271
4356
  sessionId: payload.sessionId,
4272
4357
  approvalId: payload.approvalId,
4273
4358
  toolName: redactApprovalText(payload.toolName, 120),
@@ -9991,6 +10076,22 @@ var ApiRouter = class {
9991
10076
  return;
9992
10077
  }
9993
10078
  }
10079
+ const subagentHistoryRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/subagents\/([^/]+)\/messages$/);
10080
+ if (subagentHistoryRoute !== null) {
10081
+ const leadSessionId = decodePathSegment(subagentHistoryRoute[1]);
10082
+ const subagentId = decodePathSegment(subagentHistoryRoute[2]);
10083
+ if (method === "GET") {
10084
+ this.options.auth.requireScope(principal, "chat.read");
10085
+ const beforeSeq = optionalInteger(url.searchParams.get("beforeSeq"));
10086
+ const maxMessages = optionalInteger(url.searchParams.get("maxMessages"));
10087
+ sendJson(res, 200, await this.options.chat.subagentHistory(principal, leadSessionId, subagentId, {
10088
+ ...beforeSeq === void 0 ? {} : { beforeSeq },
10089
+ ...maxMessages === void 0 ? {} : { maxMessages }
10090
+ }));
10091
+ return;
10092
+ }
10093
+ throw new ApiError(405, "METHOD_NOT_ALLOWED", "Subagent history is read-only.");
10094
+ }
9994
10095
  const attachmentRoute = pathname.match(/^\/api\/v1\/attachments\/([^/]+)$/);
9995
10096
  if (attachmentRoute !== null && method === "GET") {
9996
10097
  this.options.auth.requireScope(principal, "files.read");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-bridge",
3
- "version": "2.8.1",
3
+ "version": "2.9.0",
4
4
  "description": "Versioned REST bridge for DeepSeek Harness: remote access for trusted clients (chat, files, devices) plus a Phone-settings panel inside the DSH WebUI.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.19.0",