dsh-zen-remote 1.1.16 → 2.0.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/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +10051 -4777
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +363 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1792 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +164 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +151 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +251 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- package/src/virtual-id.ts +59 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure access control for the relay invoke and stream routes (2.0.0
|
|
3
|
+
* desktop-client).
|
|
4
|
+
*
|
|
5
|
+
* PER-METHOD ALLOWLIST, not a generic id scan. The first draft extracted
|
|
6
|
+
* whatever session ids it could find in `args.request` and checked those —
|
|
7
|
+
* which a hostile client defeats with a DECOY: DSH's gateway validates only
|
|
8
|
+
* the top-level argument names (`assertExactArguments`) and zod silently
|
|
9
|
+
* strips unknown fields inside `request`, so padding the arguments with an
|
|
10
|
+
* extra shared `sessionId` gets through the share-table check while the
|
|
11
|
+
* method's REAL ownership field (`agentId`, `request.parentSessionId`, …)
|
|
12
|
+
* goes unchecked. The fix: every invokable method is registered below with
|
|
13
|
+
* EXACTLY the fields that locate its session, authorization looks at those
|
|
14
|
+
* fields and nothing else, and any method not in the table is refused
|
|
15
|
+
* (`forbidden-method`) before an id is ever read. The stream route
|
|
16
|
+
* ({@link decideStream}) reads the SAME table: only `stream: true` entries
|
|
17
|
+
* may ride it, and the field rules are identical.
|
|
18
|
+
*
|
|
19
|
+
* Field verification (T22a-fix, against the 0.2.0-rc.1 wire inventory in the
|
|
20
|
+
* review's keys.txt and the local `typert.remote-client.js` — the spike
|
|
21
|
+
* proved those files byte-identical across 0.1.7/0.2.0, docs/spike-relay.md
|
|
22
|
+
* §2.3):
|
|
23
|
+
*
|
|
24
|
+
* - `request.address` exists only on `session/follow` and `session/page`;
|
|
25
|
+
* its zod schema is a union of EXACTLY two shapes —
|
|
26
|
+
* `{kind:'session', sessionId}` and `{kind:'subagent', parentSessionId,
|
|
27
|
+
* childSessionId, mode}` — and DSH's `validateAddress` re-checks that the
|
|
28
|
+
* child belongs to the parent, so judging a subagent call by the parent id
|
|
29
|
+
* is authoritative. Any other kind, or a missing id, is a refusal.
|
|
30
|
+
* - every other method registered before T31 carries `request.sessionId`.
|
|
31
|
+
*
|
|
32
|
+
* T31 additions, each verified against the 0.2.0 sources before registering:
|
|
33
|
+
*
|
|
34
|
+
* - `session/create` carries `request.workspaceId` — a WORKSPACE id, never
|
|
35
|
+
* share-checked (workspaces do not live in the share table): it must be
|
|
36
|
+
* present, and the relay route validates it against the server's live
|
|
37
|
+
* workspace list before forwarding (relay-server.ts). The new session is
|
|
38
|
+
* auto-shared there, which is what makes the entry safe at all.
|
|
39
|
+
* - `session/fork` carries `request.sessionId` (the SOURCE session); the
|
|
40
|
+
* forked child is auto-shared after the call succeeds.
|
|
41
|
+
* - `subagents/prompt` (`request.parentSessionId`) and
|
|
42
|
+
* `subagents/interruptByParent` (TOP-LEVEL `parentSessionId`) are judged
|
|
43
|
+
* by the parent: DSH re-validates the parent-child link itself
|
|
44
|
+
* (`authorizeLineage` on both delivery paths of prompt; the user-authority
|
|
45
|
+
* check inside `interrupt`), so an unshared child can no more be reached
|
|
46
|
+
* than an unshared parent — it is refused server-side by DSH.
|
|
47
|
+
* - `fileUploads/upload` and `fileReferences/list` carry a TOP-LEVEL
|
|
48
|
+
* `agentId`: the gateway's `agent` lookup resolves it through the agent
|
|
49
|
+
* registry keyed by SESSION id (dsh-agent registers wire `agentId`,
|
|
50
|
+
* wireTypeSymbol `SessionId`), so it IS the session id and shares its
|
|
51
|
+
* check. A shared `request.sessionId` padded next to it buys nothing —
|
|
52
|
+
* only the registered field is read.
|
|
53
|
+
*
|
|
54
|
+
* T41a additions (the panel long tail where the session id hides in another
|
|
55
|
+
* argument), each verified against the 0.2.0 sources before registering:
|
|
56
|
+
*
|
|
57
|
+
* - the `agentId` group grows: `goals/get|edit|pause|resume|clear`,
|
|
58
|
+
* `commands/list|execute`, `agentPresets/select`,
|
|
59
|
+
* `sessionReferenceResolver/candidates` and the whole `terminal/*` agent
|
|
60
|
+
* half — every one takes `agent` as its first parameter, wire `agentId`,
|
|
61
|
+
* source `lookup: 'agent'`, wireTypeSymbol `SessionId` (the same shape
|
|
62
|
+
* T31 verified for fileUploads/fileReferences).
|
|
63
|
+
* - `workspaceFiles/list|changes|read|readBytes|stat` carry a TOP-LEVEL
|
|
64
|
+
* `workspaceFileScopeId`: the host registers the `workspaceFileScope`
|
|
65
|
+
* lookup with wireTypeSymbol `SessionId` and resolves it by session —
|
|
66
|
+
* `sessions.get(sessionId).header.cwd` is only the BASE for relative
|
|
67
|
+
* paths (dsh-api-workspace-files lib/index.js, the lookup registration),
|
|
68
|
+
* not a containment: absolute paths anywhere the server process can read
|
|
69
|
+
* are served (see the registry note below). The field itself is
|
|
70
|
+
* share-checked like any session id.
|
|
71
|
+
* - `terminal/list` and `terminal/retain` take a plain TOP-LEVEL json
|
|
72
|
+
* `sessionId` (source `'json'`, not a lookup) — same check.
|
|
73
|
+
* - `sessionFeedback/record` carries `request.sessionId` — a plain B-type.
|
|
74
|
+
* - `sessionReferenceResolver/candidates` (@ mentions) answers with EVERY
|
|
75
|
+
* server session — title, cwd, and a ready-made `dsh-session:` mention
|
|
76
|
+
* — so its result travels only through the row filter that drops
|
|
77
|
+
* inaccessible sessions (relay-server.ts, the session/list discipline).
|
|
78
|
+
* - `subagents/prompt` (`request.parentSessionId` +
|
|
79
|
+
* `request.childSessionId`) and `subagents/interruptByParent` (TOP-LEVEL
|
|
80
|
+
* `parentSessionId` + `childSessionId`) now claim BOTH ids: the parent is
|
|
81
|
+
* share-checked directly, the child through the injected `parentOf`
|
|
82
|
+
* inheritance (`store.isAccessible(id, parentOf)` — a subagent session
|
|
83
|
+
* never enters the table, it borrows its ancestor's share). A child that
|
|
84
|
+
* does not descend from the claimed parent fails its own check, so the
|
|
85
|
+
* "shared parent + foreign child" decoy refuses here before DSH's own
|
|
86
|
+
* lineage validation is ever reached.
|
|
87
|
+
*
|
|
88
|
+
* PROMPT TEXT REFERENCES ARE CHECKED TOO: a prompt whose text carries a
|
|
89
|
+
* canonical `dsh-session:<base64url(id)>` address makes DSH inject that
|
|
90
|
+
* session's content (`prepareDirectMessages` → `readSurface`, with no
|
|
91
|
+
* access check of its own), so the relay refuses any address naming an
|
|
92
|
+
* inaccessible session wherever such a text can enter (relay-server.ts,
|
|
93
|
+
* scanning by the shared rule in session-reference.ts, T41a-fix2): the
|
|
94
|
+
* `session/prompt` / `subagents/prompt` content, a `session/updateQueue`
|
|
95
|
+
* EDIT's replacement content, and EVERY string of a `commands/execute`
|
|
96
|
+
* call (a command handler steers its raw input in as a user message —
|
|
97
|
+
* `/plan <text>` does exactly that). The client restores its virtual ids
|
|
98
|
+
* inside those same positions before the call travels (intercept.ts).
|
|
99
|
+
*
|
|
100
|
+
* TERMINALS ARE DELIBERATE (SPEC user story 45, explicit user request):
|
|
101
|
+
* remote terminals open SERVER-side, so a paired desktop client working a
|
|
102
|
+
* shared session gets a real PTY in the server's workspace — a shell
|
|
103
|
+
* running as the server user WITHOUT the agent sandbox or approval
|
|
104
|
+
* restrictions (RT dsh-api-terminal-controller create), the session cwd
|
|
105
|
+
* being only the starting directory. The authorization boundary is exactly
|
|
106
|
+
* the standing one — the session must be shared AND the caller must be a
|
|
107
|
+
* paired desktop application client (the gateway's device auth + relay
|
|
108
|
+
* secret), a TRUSTED DEVICE: the shared-only rule constrains session data,
|
|
109
|
+
* not the machine. No extra switch is added on top; closing the session's
|
|
110
|
+
* remote access closes its terminals with it (the stream route kills
|
|
111
|
+
* `terminal/follow` / `terminal/retain` with `unshared`, like every other
|
|
112
|
+
* session-scoped stream). The terminal ids and attachment ids inside these
|
|
113
|
+
* calls are CLIENT-generated (`WebTerminalId` / `TerminalAttachmentId` type
|
|
114
|
+
* symbols, distinct from `SessionId`) and pass through untouched.
|
|
115
|
+
*
|
|
116
|
+
* Pure functions: no I/O, no clock, the share-table lookup is injected.
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The argument fields that can locate a session, as registered per method.
|
|
121
|
+
* A field named without a dot is TOP-LEVEL (`agentId`, `parentSessionId`,
|
|
122
|
+
* `childSessionId`, `sessionId`, `workspaceFileScopeId`); the `request.*`
|
|
123
|
+
* forms live inside the `request` object. Every field except
|
|
124
|
+
* `request.workspaceId` must yield an id that passes the share-table check
|
|
125
|
+
* (top-level `sessionId` / `workspaceFileScopeId` ARE session ids — the
|
|
126
|
+
* wire type symbols say so — and inherit ancestor shares like any other);
|
|
127
|
+
* `request.workspaceId` must merely be present (its validation is the relay
|
|
128
|
+
* route's workspace probe, not this table).
|
|
129
|
+
*/
|
|
130
|
+
type SessionField =
|
|
131
|
+
| 'request.sessionId'
|
|
132
|
+
| 'request.address'
|
|
133
|
+
| 'request.parentSessionId'
|
|
134
|
+
| 'request.childSessionId'
|
|
135
|
+
| 'request.workspaceId'
|
|
136
|
+
| 'parentSessionId'
|
|
137
|
+
| 'childSessionId'
|
|
138
|
+
| 'sessionId'
|
|
139
|
+
| 'workspaceFileScopeId'
|
|
140
|
+
| 'agentId'
|
|
141
|
+
|
|
142
|
+
/** Which standing filter the caller must apply to a global stream's frames
|
|
143
|
+
* (`src/relay-filter.ts` owns both implementations; the `$zr/events`
|
|
144
|
+
* forwarding subscription is filtered by relay-server.ts itself — it needs
|
|
145
|
+
* the per-eventId registry, not a pure frame function). */
|
|
146
|
+
export type StreamFilter = 'workspace' | 'control'
|
|
147
|
+
|
|
148
|
+
/** Which standing filter the caller must apply to an invoke result before it
|
|
149
|
+
* travels: `session-list` narrows the unscoped `session/list` items
|
|
150
|
+
* (relay-filter.ts), `session-reference-candidates` drops the @-mention
|
|
151
|
+
* candidate rows whose session is not accessible (relay-server.ts — the host
|
|
152
|
+
* lists EVERY server session with title, cwd and a ready-made mention, so
|
|
153
|
+
* unshared rows must never leave the box), and `model-catalog` empties the
|
|
154
|
+
* `session/modelCatalog` `failures` array (T52-fix — relay-filter.ts: the
|
|
155
|
+
* host's per-group error texts may carry endpoint or credential details, and
|
|
156
|
+
* the client discards `failures` anyway). */
|
|
157
|
+
export type InvokeFilter = 'session-list' | 'session-reference-candidates' | 'model-catalog'
|
|
158
|
+
|
|
159
|
+
interface RelayMethod {
|
|
160
|
+
/** Every field that carries session ownership for this method. ALL of
|
|
161
|
+
* them must exist, be strings, and pass the share-table check. Global
|
|
162
|
+
* entries (`workspace/follow`, `session/control`, `session/list`,
|
|
163
|
+
* `session/modelCatalog`) own no field — reachability is enforced by
|
|
164
|
+
* filtering their output instead (for modelCatalog that filter only
|
|
165
|
+
* scrubs the `failures` array; the groups themselves carry no session
|
|
166
|
+
* data). */
|
|
167
|
+
fields: SessionField[]
|
|
168
|
+
/** Stream-delivered methods (`mode: 'stream'` in the wire inventory):
|
|
169
|
+
* callable ONLY through the streaming route — an invoke carrying one is
|
|
170
|
+
* refused, and so is a stream carrying a non-stream method. */
|
|
171
|
+
stream?: boolean
|
|
172
|
+
/** Set on the two GLOBAL streams: every frame of such a subscription must
|
|
173
|
+
* go through the named filter before it is written. */
|
|
174
|
+
streamFilter?: StreamFilter
|
|
175
|
+
/** Set on the invoke methods whose RESULT needs a standing filter. */
|
|
176
|
+
resultFilter?: InvokeFilter
|
|
177
|
+
/** The one EVENT-FORWARDING entry (T32, `$zr/events`): the stream route
|
|
178
|
+
* opens the gateway's `$events` wire stream (never `gw.stream`, spike §2.1
|
|
179
|
+
* 坑 1) and filters frames by the waterfall's `agentId`; the answer route
|
|
180
|
+
* matches `eventId`s against what was forwarded. Like the global reads it
|
|
181
|
+
* claims no session field — access is judged per frame / per answer. */
|
|
182
|
+
events?: true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The registry. Deliberately small: everything not listed here — plugin and
|
|
187
|
+
* account management, settings, credentials, the methods located by other
|
|
188
|
+
* ids (`schedule/update|delete|history`, `goals/create|complete`,
|
|
189
|
+
* `fileUploads/list|resolve`, `agentPresets/list|read`,
|
|
190
|
+
* `permissionPresets/*`, `officeToPdf/*`, `dynamicCordisRunner/*`) — is
|
|
191
|
+
* refused by default.
|
|
192
|
+
*
|
|
193
|
+
* T31 registered the five session-creating / agent-scoped calls after
|
|
194
|
+
* verifying their ownership story (see the field notes above): creations
|
|
195
|
+
* auto-share their result (relay-server.ts), the subagents calls are judged
|
|
196
|
+
* by the parent DSH itself re-validates, and the two `agentId` methods are
|
|
197
|
+
* located by the session id that name resolves to. T41a grew the agentId
|
|
198
|
+
* family and added the workspaceFiles / terminal / feedback / goals /
|
|
199
|
+
* commands / preset-switch groups on the same verified shape.
|
|
200
|
+
*
|
|
201
|
+
* The unscoped reads are registered as FILTERED controlled paths (T22b):
|
|
202
|
+
* `workspace/follow` and `session/control` stream globally but every frame
|
|
203
|
+
* passes `streamFilter` first; `session/list` invokes but its items pass
|
|
204
|
+
* `resultFilter` first. Nothing unlisted ever reaches the gateway.
|
|
205
|
+
*/
|
|
206
|
+
const RELAY_METHODS: Record<string, RelayMethod> = {
|
|
207
|
+
// session/* — ownership via the address envelope
|
|
208
|
+
'session/follow': { fields: ['request.address'], stream: true },
|
|
209
|
+
'session/page': { fields: ['request.address'] },
|
|
210
|
+
// session/* — ownership via request.sessionId
|
|
211
|
+
'session/projections': { fields: ['request.sessionId'] },
|
|
212
|
+
'session/prompt': { fields: ['request.sessionId'] },
|
|
213
|
+
'session/cancel': { fields: ['request.sessionId'] },
|
|
214
|
+
'session/rename': { fields: ['request.sessionId'] },
|
|
215
|
+
'session/selectModel': { fields: ['request.sessionId'] },
|
|
216
|
+
'session/updateQueue': { fields: ['request.sessionId'] },
|
|
217
|
+
'session/attachment': { fields: ['request.sessionId'] },
|
|
218
|
+
// session/* — creation and forking (T31): the route validates the workspace
|
|
219
|
+
// / source session and auto-shares the NEW session from the result
|
|
220
|
+
'session/create': { fields: ['request.workspaceId'] },
|
|
221
|
+
'session/fork': { fields: ['request.sessionId'] },
|
|
222
|
+
// subagents (T31, both ids since T41a) — the parent locates the call and
|
|
223
|
+
// DSH re-validates the parent-child link on every path; the child id is
|
|
224
|
+
// ALSO share-checked through the parentOf inheritance, so a foreign child
|
|
225
|
+
// refuses before DSH is ever asked
|
|
226
|
+
'subagents/prompt': { fields: ['request.parentSessionId', 'request.childSessionId'] },
|
|
227
|
+
'subagents/interruptByParent': { fields: ['parentSessionId', 'childSessionId'] },
|
|
228
|
+
// attachments and @ references (T31) — the top-level agentId resolves
|
|
229
|
+
// through the agent registry keyed by session id, so it IS the session id
|
|
230
|
+
'fileUploads/upload': { fields: ['agentId'] },
|
|
231
|
+
'fileReferences/list': { fields: ['agentId'] },
|
|
232
|
+
// goal panel (T41a) — top-level agentId, same lookup shape as fileUploads;
|
|
233
|
+
// goals/create|complete stay closed (the UI's goal bar uses only these five)
|
|
234
|
+
'goals/get': { fields: ['agentId'] },
|
|
235
|
+
'goals/edit': { fields: ['agentId'] },
|
|
236
|
+
'goals/pause': { fields: ['agentId'] },
|
|
237
|
+
'goals/resume': { fields: ['agentId'] },
|
|
238
|
+
'goals/clear': { fields: ['agentId'] },
|
|
239
|
+
// slash commands + the preset switches they drive (T41a) — top-level agentId
|
|
240
|
+
'commands/list': { fields: ['agentId'] },
|
|
241
|
+
'commands/execute': { fields: ['agentId'] },
|
|
242
|
+
'agentPresets/select': { fields: ['agentId'] },
|
|
243
|
+
// The @ resolver's answer lists EVERY server session with title, cwd and a
|
|
244
|
+
// ready-made mention — it travels only through the row filter that drops
|
|
245
|
+
// inaccessible sessions (relay-server.ts), like session/list.
|
|
246
|
+
'sessionReferenceResolver/candidates': { fields: ['agentId'], resultFilter: 'session-reference-candidates' },
|
|
247
|
+
// session feedback (T41a) — plain request.sessionId
|
|
248
|
+
'sessionFeedback/record': { fields: ['request.sessionId'] },
|
|
249
|
+
// file tree and previews (T41a) — the top-level workspaceFileScopeId IS the
|
|
250
|
+
// session id (host lookup wireTypeSymbol SessionId), but the session cwd is
|
|
251
|
+
// only the BASE for relative paths: an absolute path is served whenever the
|
|
252
|
+
// server process can read it, with no containment to the workspace (RT
|
|
253
|
+
// dsh-api-workspace-files lib/index.js ~168-170 "including paths outside the
|
|
254
|
+
// workspace … not a read-containment restriction", ~420 / ~479 "files
|
|
255
|
+
// outside it are allowed"). That exposure matches the terminal trust
|
|
256
|
+
// premise below (SPEC user story 45) and is deliberately accepted.
|
|
257
|
+
// changes is the tree's live stream.
|
|
258
|
+
'workspaceFiles/list': { fields: ['workspaceFileScopeId'] },
|
|
259
|
+
'workspaceFiles/changes': { fields: ['workspaceFileScopeId'], stream: true },
|
|
260
|
+
'workspaceFiles/read': { fields: ['workspaceFileScopeId'] },
|
|
261
|
+
'workspaceFiles/readBytes': { fields: ['workspaceFileScopeId'] },
|
|
262
|
+
'workspaceFiles/stat': { fields: ['workspaceFileScopeId'] },
|
|
263
|
+
// terminal (T41a) — opens SERVER-side by explicit user request (SPEC story
|
|
264
|
+
// 45). What the client gets is a shell running AS THE SERVER USER, WITHOUT
|
|
265
|
+
// the agent sandbox or approval restrictions (RT dsh-api-terminal-controller
|
|
266
|
+
// create: "Allocate a user shell … without Agent sandbox or approval
|
|
267
|
+
// restrictions"); the session cwd is only the starting directory. "Only
|
|
268
|
+
// shared sessions are accessible" constrains SESSION data, not this machine
|
|
269
|
+
// — a paired desktop application client is treated as a trusted device, and
|
|
270
|
+
// no extra switch is added on top. follow/retain are streams; the terminal
|
|
271
|
+
// and attachment ids in these calls are client-generated and pass through.
|
|
272
|
+
'terminal/environment': { fields: ['agentId'] },
|
|
273
|
+
'terminal/shells': { fields: ['agentId'] },
|
|
274
|
+
'terminal/create': { fields: ['agentId'] },
|
|
275
|
+
'terminal/write': { fields: ['agentId'] },
|
|
276
|
+
'terminal/resize': { fields: ['agentId'] },
|
|
277
|
+
'terminal/rename': { fields: ['agentId'] },
|
|
278
|
+
'terminal/close': { fields: ['agentId'] },
|
|
279
|
+
'terminal/follow': { fields: ['agentId'], stream: true },
|
|
280
|
+
// the retain/list half locates by a plain top-level json sessionId
|
|
281
|
+
'terminal/list': { fields: ['sessionId'] },
|
|
282
|
+
'terminal/retain': { fields: ['sessionId'], stream: true },
|
|
283
|
+
// session/* — the global control stream and the unscoped list
|
|
284
|
+
'session/control': { fields: [], stream: true, streamFilter: 'control' },
|
|
285
|
+
'session/list': { fields: [], resultFilter: 'session-list' },
|
|
286
|
+
// T52: the global model catalog — NO parameters at all (RT
|
|
287
|
+
// dsh-api-session-controller lib/typert.remote-client.js:1014-1027,
|
|
288
|
+
// `parameters: []`), and its result is the server's provider directory
|
|
289
|
+
// `{default, routableProviders, groups, failures}` — group/model ids and
|
|
290
|
+
// display names only, nothing session-scoped (the schema is at
|
|
291
|
+
// lib/typert.remote-client.js:489-518). Field-less like session/list; the
|
|
292
|
+
// result filter (T52-fix) only EMPTIES `failures` — the host's per-group
|
|
293
|
+
// error texts may carry endpoint or credential details the client never
|
|
294
|
+
// shows (it keeps its own local failures instead).
|
|
295
|
+
'session/modelCatalog': { fields: [], resultFilter: 'model-catalog' },
|
|
296
|
+
// job/*
|
|
297
|
+
'job/list': { fields: ['request.sessionId'], stream: true },
|
|
298
|
+
'job/follow': { fields: ['request.sessionId'], stream: true },
|
|
299
|
+
'job/kill': { fields: ['request.sessionId'] },
|
|
300
|
+
// skills, message feedback
|
|
301
|
+
'skills/list': { fields: ['request.sessionId'] },
|
|
302
|
+
'messageFeedback/list': { fields: ['request.sessionId'] },
|
|
303
|
+
'messageFeedback/put': { fields: ['request.sessionId'] },
|
|
304
|
+
'messageFeedback/delete': { fields: ['request.sessionId'] },
|
|
305
|
+
// schedule (list only — update/delete/history wait for P4)
|
|
306
|
+
'schedule/list': { fields: ['request.sessionId'] },
|
|
307
|
+
// workspace session-scoped mutations
|
|
308
|
+
'workspace/pinSession': { fields: ['request.sessionId'] },
|
|
309
|
+
'workspace/unpinSession': { fields: ['request.sessionId'] },
|
|
310
|
+
'workspace/archiveSession': { fields: ['request.sessionId'] },
|
|
311
|
+
'workspace/unarchiveSession': { fields: ['request.sessionId'] },
|
|
312
|
+
// workspace — the global follow stream
|
|
313
|
+
'workspace/follow': { fields: [], stream: true, streamFilter: 'workspace' },
|
|
314
|
+
// the forwarded-event subscription (T32): special entry, no session fields —
|
|
315
|
+
// its frames are judged per `agentId`, its answers per forwarded `eventId`
|
|
316
|
+
'$zr/events': { fields: [], stream: true, events: true },
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** One invoke decision: allow (optionally through a standing result filter),
|
|
320
|
+
* or the reason that goes into the 403 body. */
|
|
321
|
+
export type InvokeDenyReason = 'no-session' | 'not-shared' | 'forbidden-method'
|
|
322
|
+
|
|
323
|
+
export type InvokeDecision = { allow: true; filter?: InvokeFilter } | { allow: false; reason: InvokeDenyReason }
|
|
324
|
+
|
|
325
|
+
/** One stream decision: allow (global streams carry a `streamFilter`, scoped
|
|
326
|
+
* streams list the session ids the subscription depends on — the relay kills
|
|
327
|
+
* the stream and counts viewers with them, and the event subscription sets
|
|
328
|
+
* `events`), or the 403 reason. */
|
|
329
|
+
export type StreamDecision =
|
|
330
|
+
| { allow: true; filter?: StreamFilter; sessionIds: string[]; events?: true }
|
|
331
|
+
| { allow: false; reason: InvokeDenyReason }
|
|
332
|
+
|
|
333
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
334
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** An OWNED session id: present and a non-empty string, anything else means
|
|
338
|
+
* "the field does not locate a session" (an empty id is garbage, not one). */
|
|
339
|
+
function ownedId(value: unknown): string | undefined {
|
|
340
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The session ids one registered method's arguments claim, read STRICTLY
|
|
345
|
+
* along the registered fields (this replaces the old generic
|
|
346
|
+
* `extractSessionIds` scan — that scan is exactly the decoy hole).
|
|
347
|
+
*
|
|
348
|
+
* `request.address` contributes its session id by kind: `session` →
|
|
349
|
+
* `sessionId`, `subagent` → `parentSessionId` (the child never enters the
|
|
350
|
+
* share table; DSH validates the parent-child link server-side). The
|
|
351
|
+
* top-level fields (`agentId`, `parentSessionId`, `childSessionId`,
|
|
352
|
+
* `sessionId`, `workspaceFileScopeId`) read the argument object itself —
|
|
353
|
+
* all of them are session ids on the wire and share-check alike.
|
|
354
|
+
* `request.workspaceId` contributes NO id — it must be present, but its
|
|
355
|
+
* authorization is the route's workspace probe plus the auto-share of
|
|
356
|
+
* the created session, not this table. Any other kind, or a missing id,
|
|
357
|
+
* makes the method unauthorizable.
|
|
358
|
+
*/
|
|
359
|
+
const TOP_LEVEL_FIELDS: ReadonlySet<string> = new Set([
|
|
360
|
+
'agentId',
|
|
361
|
+
'parentSessionId',
|
|
362
|
+
'childSessionId',
|
|
363
|
+
'sessionId',
|
|
364
|
+
'workspaceFileScopeId',
|
|
365
|
+
])
|
|
366
|
+
|
|
367
|
+
function claimedSessionIds(entry: RelayMethod, args: unknown): { ids: string[] } | { reason: InvokeDenyReason } {
|
|
368
|
+
// Field-less entries (the global reads) claim nothing by construction —
|
|
369
|
+
// their safety comes from output filtering, and their wire shape is their
|
|
370
|
+
// own (`session/list` takes `_request`, not `request`).
|
|
371
|
+
if (entry.fields.length === 0) return { ids: [] }
|
|
372
|
+
if (!isPlainObject(args)) return { reason: 'no-session' }
|
|
373
|
+
const ids: string[] = []
|
|
374
|
+
for (const field of entry.fields) {
|
|
375
|
+
// Top-level fields read the argument object itself.
|
|
376
|
+
if (TOP_LEVEL_FIELDS.has(field)) {
|
|
377
|
+
const id = ownedId(args[field])
|
|
378
|
+
if (id === undefined) return { reason: 'no-session' }
|
|
379
|
+
ids.push(id)
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
const request = args.request
|
|
383
|
+
if (!isPlainObject(request)) return { reason: 'no-session' }
|
|
384
|
+
if (field === 'request.sessionId' || field === 'request.parentSessionId' || field === 'request.childSessionId') {
|
|
385
|
+
const id = ownedId(request[field.slice('request.'.length)])
|
|
386
|
+
if (id === undefined) return { reason: 'no-session' }
|
|
387
|
+
ids.push(id)
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
if (field === 'request.workspaceId') {
|
|
391
|
+
// Present and a non-empty string, but never share-checked — see the
|
|
392
|
+
// SessionField note.
|
|
393
|
+
if (ownedId(request.workspaceId) === undefined) return { reason: 'no-session' }
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
// 'request.address'
|
|
397
|
+
const address = request.address
|
|
398
|
+
if (!isPlainObject(address)) return { reason: 'no-session' }
|
|
399
|
+
if (address.kind === 'session') {
|
|
400
|
+
const id = ownedId(address.sessionId)
|
|
401
|
+
if (id === undefined) return { reason: 'no-session' }
|
|
402
|
+
ids.push(id)
|
|
403
|
+
} else if (address.kind === 'subagent') {
|
|
404
|
+
const id = ownedId(address.parentSessionId)
|
|
405
|
+
if (id === undefined) return { reason: 'no-session' }
|
|
406
|
+
ids.push(id)
|
|
407
|
+
} else {
|
|
408
|
+
// Unknown kind — not an address DSH would accept either.
|
|
409
|
+
return { reason: 'no-session' }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return { ids }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Decide one relayed invoke against the per-method allowlist:
|
|
417
|
+
*
|
|
418
|
+
* 1. the method must be registered AND invoke-delivered (a `stream: true`
|
|
419
|
+
* entry refuses the invoke route) — anything else is `forbidden-method`;
|
|
420
|
+
* 2. its registered fields must all yield an owned session id — else
|
|
421
|
+
* `no-session`;
|
|
422
|
+
* 3. every claimed id must pass `isAccessible` — one unreachable id refuses
|
|
423
|
+
* the whole call (`not-shared`): an unshared session must not become
|
|
424
|
+
* readable through a shared one riding in the same arguments.
|
|
425
|
+
*
|
|
426
|
+
* A method registered with `resultFilter` allows with that marker attached;
|
|
427
|
+
* the caller filters the result before it travels (never the reverse — the
|
|
428
|
+
* filter is an OUTPUT discipline, the access check above stays input-only).
|
|
429
|
+
*/
|
|
430
|
+
export function decideInvoke(
|
|
431
|
+
namespace: string,
|
|
432
|
+
method: string,
|
|
433
|
+
args: unknown,
|
|
434
|
+
isAccessible: (sessionId: string) => boolean,
|
|
435
|
+
): InvokeDecision {
|
|
436
|
+
const entry = RELAY_METHODS[`${namespace}/${method}`]
|
|
437
|
+
if (entry === undefined || entry.stream === true) return { allow: false, reason: 'forbidden-method' }
|
|
438
|
+
const claimed = claimedSessionIds(entry, args)
|
|
439
|
+
if ('reason' in claimed) return { allow: false, reason: claimed.reason }
|
|
440
|
+
for (const id of claimed.ids) {
|
|
441
|
+
if (!isAccessible(id)) return { allow: false, reason: 'not-shared' }
|
|
442
|
+
}
|
|
443
|
+
return entry.resultFilter !== undefined ? { allow: true, filter: entry.resultFilter } : { allow: true }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Decide one relayed STREAM subscription against the same table:
|
|
448
|
+
*
|
|
449
|
+
* 1. the method must be registered AND stream-delivered — an invoke-only
|
|
450
|
+
* method riding the stream route is `forbidden-method`, exactly like a
|
|
451
|
+
* stream method riding the invoke route;
|
|
452
|
+
* 2. a global entry (`streamFilter` set) allows unconditionally — its frames
|
|
453
|
+
* are filtered per frame, so there is nothing to check up front;
|
|
454
|
+
* 3. any other entry follows the {@link decideInvoke} field rules verbatim:
|
|
455
|
+
* all registered fields must yield owned ids and every id must be
|
|
456
|
+
* accessible, and the claimed ids ride back to the caller, which kills the
|
|
457
|
+
* subscription when one of them stops being shared.
|
|
458
|
+
*/
|
|
459
|
+
export function decideStream(
|
|
460
|
+
namespace: string,
|
|
461
|
+
method: string,
|
|
462
|
+
args: unknown,
|
|
463
|
+
isAccessible: (sessionId: string) => boolean,
|
|
464
|
+
): StreamDecision {
|
|
465
|
+
const entry = RELAY_METHODS[`${namespace}/${method}`]
|
|
466
|
+
if (entry === undefined || entry.stream !== true) return { allow: false, reason: 'forbidden-method' }
|
|
467
|
+
if (entry.streamFilter !== undefined) return { allow: true, filter: entry.streamFilter, sessionIds: [] }
|
|
468
|
+
if (entry.events === true) return { allow: true, sessionIds: [], events: true }
|
|
469
|
+
const claimed = claimedSessionIds(entry, args)
|
|
470
|
+
if ('reason' in claimed) return { allow: false, reason: claimed.reason }
|
|
471
|
+
for (const id of claimed.ids) {
|
|
472
|
+
if (!isAccessible(id)) return { allow: false, reason: 'not-shared' }
|
|
473
|
+
}
|
|
474
|
+
return { allow: true, sessionIds: claimed.ids }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The `$events/result` answer body (T32), as the client sends it:
|
|
479
|
+
* `{ eventId, result }`. `result` is the Remote event OUTCOME and travels
|
|
480
|
+
* VERBATIM — dsh-api-gateway's `parseRemoteEventResult` is the validator
|
|
481
|
+
* (exactly `{clientId,eventId,outcome}` up there; kinds `next` / `result`
|
|
482
|
+
* with optional JSON `value` / `rejected` with `{name,message,code?,details?}`),
|
|
483
|
+
* and a malformed one comes back as the gateway's own 200 error envelope, so
|
|
484
|
+
* re-validating here would only invent a second dialect for the same refusal.
|
|
485
|
+
* `eventId` ownership (forwarded on a live subscription, session still
|
|
486
|
+
* reachable) is the ROUTE's check — it needs the handler's registry.
|
|
487
|
+
*/
|
|
488
|
+
export interface EventResultBody {
|
|
489
|
+
eventId: string
|
|
490
|
+
result: Record<string, unknown>
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function parseEventResultBody(body: unknown): EventResultBody | undefined {
|
|
494
|
+
if (!isPlainObject(body)) return undefined
|
|
495
|
+
const { eventId, result } = body
|
|
496
|
+
if (typeof eventId !== 'string' || eventId === '') return undefined
|
|
497
|
+
if (!isPlainObject(result)) return undefined
|
|
498
|
+
return { eventId, result }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The plain-HTTP panel long tail (T41b): the session-page surfaces that do
|
|
503
|
+
* NOT ride the typert gateway but GET `/api/...` routes registered as exact
|
|
504
|
+
* Fetch routes on the host's connection service (RT
|
|
505
|
+
* dsh-client-ui-deliverables/lib/index.js — `ctx.connection.fetch.register`
|
|
506
|
+
* at ~49-58 and ~84-90; the shared handler they dispatch through is
|
|
507
|
+
* `createSharedFetchHandler('/api')`, dsh-client-connection/lib/index.js
|
|
508
|
+
* ~608-637). Each entry names the query parameter that locates the session —
|
|
509
|
+
* the same per-route registration discipline as RELAY_METHODS, and the same
|
|
510
|
+
* refusal defaults: a route not in this table answers `unknown-route`
|
|
511
|
+
* (404) before any id is read, and a registered route is judged ONLY along
|
|
512
|
+
* its declared field.
|
|
513
|
+
*
|
|
514
|
+
* Deliberately ABSENT (the known limitations of the HTTP interception, kept
|
|
515
|
+
* beside the registry that makes the contrast true):
|
|
516
|
+
*
|
|
517
|
+
* - `/api/session.export` (session log download) — an anchor-click download,
|
|
518
|
+
* not a fetch call at all, and a full ZIP of the session's server-side log
|
|
519
|
+
* is not a body worth re-streaming through the relay. Remote sessions have
|
|
520
|
+
* export DISABLED instead (T51): the sub-client's own backend refuses
|
|
521
|
+
* `/api/session.export` for a virtual id with 403 `remote-unsupported`
|
|
522
|
+
* before the local route is ever reached (fetch-route-intercept.ts), and
|
|
523
|
+
* the UI entry is hidden on the same html attribute
|
|
524
|
+
* (remote-session.css.ts) — the host route's answer for a foreign id
|
|
525
|
+
* never happens.
|
|
526
|
+
* - `/api/present.host` — takes NO parameters and reports the machine the
|
|
527
|
+
* SERVER process runs on; forwarding it would read server-desktop facts
|
|
528
|
+
* into a panel whose actions stay hidden remotely, so it is not relayed.
|
|
529
|
+
* - `/api/changes.open` and `/api/present.open` — POST actions that open
|
|
530
|
+
* files ON THE SERVER MACHINE (Finder / applications); meaningless from a
|
|
531
|
+
* sub-client, so the UI entries are hidden remotely (remote-session.css)
|
|
532
|
+
* and the routes are not registered here.
|
|
533
|
+
*
|
|
534
|
+
* The one HTTP route that USED to be absent is no longer (T51):
|
|
535
|
+
* `/api/session/uploadFileBinary` (attachment upload, RT
|
|
536
|
+
* dsh-client-file-upload) — its Blob/stream branch runs inside a Web Worker
|
|
537
|
+
* over XHR / Worker-scoped fetch, which neither a `window.fetch` wrapper nor
|
|
538
|
+
* the GET passthrough this table serves can carry. The sub-client's own
|
|
539
|
+
* backend instead wraps that entry in the host's `fetchRoutes` map
|
|
540
|
+
* (fetch-route-intercept.ts) and forwards the raw byte stream through the
|
|
541
|
+
* DEDICATED binary channel `POST relay/v1/upload`, whose query is decided by
|
|
542
|
+
* {@link decideUploadQuery} below — the same parse-once normalization, share
|
|
543
|
+
* table and refusal codes as everything else here.
|
|
544
|
+
*/
|
|
545
|
+
export interface RelayHttpRoute {
|
|
546
|
+
/** The query parameter carrying the owning session id. */
|
|
547
|
+
sessionField: 'sessionId'
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export const RELAY_HTTP_ROUTES: Readonly<Record<string, RelayHttpRoute>> = {
|
|
551
|
+
'changes.summary': { sessionField: 'sessionId' },
|
|
552
|
+
'changes.diff': { sessionField: 'sessionId' },
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** One plain-HTTP decision: allow — carrying the NORMALIZED query string
|
|
556
|
+
* that is the only thing the caller may put on the synthetic Request URL —
|
|
557
|
+
* or the reason for the answer: `unknown-route` (not in
|
|
558
|
+
* {@link RELAY_HTTP_ROUTES}) answers 404, `no-session` (the field is missing
|
|
559
|
+
* or duplicated) and `bad-query` (a whitelisted coordinate repeats or is not
|
|
560
|
+
* a decimal non-negative integer string) answer 400, and `not-shared`
|
|
561
|
+
* answers 403 like every other session-scoped refusal. */
|
|
562
|
+
export type HttpDenyReason = 'unknown-route' | 'no-session' | 'bad-query' | 'not-shared'
|
|
563
|
+
|
|
564
|
+
export type HttpDecision = { allow: true; query: string } | { allow: false; reason: HttpDenyReason }
|
|
565
|
+
|
|
566
|
+
/** True only for OWN registry keys: a bare route string from the wire is
|
|
567
|
+
* indexed into a plain object, so `constructor` / `__proto__` must never
|
|
568
|
+
* resolve through the prototype chain the way a bare `table[route]` would. */
|
|
569
|
+
function isRegisteredHttpRoute(route: string): boolean {
|
|
570
|
+
return Object.prototype.hasOwnProperty.call(RELAY_HTTP_ROUTES, route)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** A decimal non-negative integer string, exactly what the serving routes'
|
|
574
|
+
* own coordinate parser accepts (RT dsh-client-ui-deliverables
|
|
575
|
+
* lib/index.js `NUMERIC`). */
|
|
576
|
+
const DECIMAL = /^\d+$/
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The one value of a whitelisted query parameter, or `null` when it repeats
|
|
580
|
+
* or is not a decimal non-negative integer string (a 400, not a pass-through
|
|
581
|
+
* of whichever duplicate the URL parser happens to keep). `undefined` = the
|
|
582
|
+
* parameter is legitimately absent.
|
|
583
|
+
*/
|
|
584
|
+
function singleCoordinate(params: URLSearchParams, name: string): string | undefined | null {
|
|
585
|
+
const values = params.getAll(name)
|
|
586
|
+
if (values.length === 0) return undefined
|
|
587
|
+
const value = values.length === 1 ? values[0] : undefined
|
|
588
|
+
if (value === undefined || !DECIMAL.test(value)) return null
|
|
589
|
+
return value
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Decide one relayed `/api` GET against the per-route allowlist, and
|
|
594
|
+
* NORMALIZE the query. The two halves of this route used to disagree about
|
|
595
|
+
* parsing: `URLSearchParams` keeps `\t` / `\r` / `\n` inside key names
|
|
596
|
+
* (`session\tId` is one parameter here) while the WHATWG URL parser strips
|
|
597
|
+
* control characters before the request URL is built — so a query whose
|
|
598
|
+
* checkable `sessionId` named a shared session could DISPATCH as two
|
|
599
|
+
* parameters, with the serving route's own `get('sessionId')` reading the
|
|
600
|
+
* first one, a secret (reviewer-confirmed 200). The fix is structural: this
|
|
601
|
+
* function parses ONCE, takes only the whitelisted parameters — `sessionId`
|
|
602
|
+
* exactly once (its id must pass the share table), `seq` / `index` at most
|
|
603
|
+
* once each and decimal non-negative integers — and returns the rebuilt,
|
|
604
|
+
* fixed-order query string; the caller composes the synthetic URL from THAT
|
|
605
|
+
* string alone, never from raw input. Unknown parameters are dropped.
|
|
606
|
+
*/
|
|
607
|
+
export function decideHttpRoute(
|
|
608
|
+
route: unknown,
|
|
609
|
+
query: unknown,
|
|
610
|
+
isAccessible: (sessionId: string) => boolean,
|
|
611
|
+
): HttpDecision {
|
|
612
|
+
if (typeof route !== 'string' || !isRegisteredHttpRoute(route)) return { allow: false, reason: 'unknown-route' }
|
|
613
|
+
if (typeof query !== 'string') return { allow: false, reason: 'no-session' }
|
|
614
|
+
const raw = new URLSearchParams(query)
|
|
615
|
+
const values = raw.getAll('sessionId')
|
|
616
|
+
const id = values.length === 1 ? values[0] : undefined
|
|
617
|
+
if (id === undefined || id === '') return { allow: false, reason: 'no-session' }
|
|
618
|
+
const seq = singleCoordinate(raw, 'seq')
|
|
619
|
+
const index = singleCoordinate(raw, 'index')
|
|
620
|
+
if (seq === null || index === null) return { allow: false, reason: 'bad-query' }
|
|
621
|
+
if (!isAccessible(id)) return { allow: false, reason: 'not-shared' }
|
|
622
|
+
const normalized = new URLSearchParams()
|
|
623
|
+
normalized.set('sessionId', id)
|
|
624
|
+
if (seq !== undefined) normalized.set('seq', seq)
|
|
625
|
+
if (index !== undefined) normalized.set('index', index)
|
|
626
|
+
return { allow: true, query: normalized.toString() }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The binary upload channel's query (T51), decided with the SAME
|
|
631
|
+
* parse-once discipline as {@link decideHttpRoute}: the wire string is
|
|
632
|
+
* parsed exactly once here, only `sessionId` and `name` are kept, and the
|
|
633
|
+
* caller composes the synthetic Request URL from the RETURNED string alone.
|
|
634
|
+
* `URLSearchParams` keeps `\t` / `\r` / `\n` inside key names while the
|
|
635
|
+
* WHATWG URL parser strips control characters — the differential that was
|
|
636
|
+
* T41b-fix's authorization bypass on the GET routes — so the check and the
|
|
637
|
+
* dispatch must see one parse.
|
|
638
|
+
*
|
|
639
|
+
* `sessionId` exactly once, a non-empty string, and share-checked like any
|
|
640
|
+
* other session id; a repeat is `no-session` (400). `name` (the file's
|
|
641
|
+
* display name — the host route passes it to its own leaf-name sanitizer,
|
|
642
|
+
* RT dsh-client-file-upload lib/index.js `handleFileUploadHttp`) at most
|
|
643
|
+
* once; a repeat is `bad-query` (400) and an empty value is dropped, the
|
|
644
|
+
* same "absent" the host route reads when the browser sent none. Everything
|
|
645
|
+
* else is discarded before an id is read.
|
|
646
|
+
*/
|
|
647
|
+
export type UploadQueryDecision = { allow: true; sessionId: string; name: string | undefined; query: string } | {
|
|
648
|
+
allow: false
|
|
649
|
+
reason: 'no-session' | 'bad-query' | 'not-shared'
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function decideUploadQuery(
|
|
653
|
+
query: unknown,
|
|
654
|
+
isAccessible: (sessionId: string) => boolean,
|
|
655
|
+
): UploadQueryDecision {
|
|
656
|
+
if (typeof query !== 'string') return { allow: false, reason: 'no-session' }
|
|
657
|
+
const raw = new URLSearchParams(query)
|
|
658
|
+
const values = raw.getAll('sessionId')
|
|
659
|
+
const id = values.length === 1 ? values[0] : undefined
|
|
660
|
+
if (id === undefined || id === '') return { allow: false, reason: 'no-session' }
|
|
661
|
+
const names = raw.getAll('name')
|
|
662
|
+
if (names.length > 1) return { allow: false, reason: 'bad-query' }
|
|
663
|
+
const name = names.length === 1 && names[0] !== '' ? names[0] : undefined
|
|
664
|
+
if (!isAccessible(id)) return { allow: false, reason: 'not-shared' }
|
|
665
|
+
const normalized = new URLSearchParams()
|
|
666
|
+
normalized.set('sessionId', id)
|
|
667
|
+
if (name !== undefined) normalized.set('name', name)
|
|
668
|
+
return { allow: true, sessionId: id, name, query: normalized.toString() }
|
|
669
|
+
}
|