Merge remote-tracking branch 'origin/master' into worktree/forge-code-review-gaps-2d9d2e
This commit is contained in:
commit
b607b769e0
64 files changed
+811
-305
No files matched your search
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/liveshare.md
|
||||
/liveshare:
|
||||
en: 196b06ffccedbdef
|
||||
zh: 0ca8df19400afc40
|
||||
en: bcec4b0a3d28942b
|
||||
zh: 2d3271b6abe51c75
|
||||
/liveshare/enabling-and-visibility:
|
||||
en: f6144f8e81359a9c
|
||||
zh: 1924869ac7475b59
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 6b1346e73ab6eab9
|
||||
zh: 888cfc08c6bc8522
|
||||
/liveshare/constraining-a-shared-session:
|
||||
en: 289e8955320935f6
|
||||
zh: 7038c4283a4f33c6
|
||||
en: 7607ed3f99df06cc
|
||||
zh: 290c9b8ed064da00
|
||||
/liveshare/viewer-states:
|
||||
en: b5b4d4d83dbb58c2
|
||||
zh: 498f26938dc872df
|
||||
en: b8bcb009e419fa25
|
||||
zh: 31c4662b52e99ea7
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](liveshare.zh.md)
|
||||
|
||||
The opt-in LiveShare bundle (`@deepseek-ai/dsh-experimental-liveshare`) lets one owner share a single Session with an external viewer through the separate Liveshare server. A viewer never receives the owner's tools: the owner host reads each viewer request itself, dispatches it as that viewer's own Peer, whose `share-viewer` principal the Gateway decides through the [remote access](../../packages/api/remote-access/README.md) policy this package registers, and constrains everything a shared Session runs on the viewer's behalf to the workspace the share granted. The [package README](../../packages/experimental/liveshare/README.md) owns configuration, the client faces, and the limitations; the [Owner note](../../.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.md) owns the security posture behind them.
|
||||
The opt-in LiveShare bundle (`@deepseek-ai/dsh-experimental-liveshare`) lets one owner share one or more Sessions with an external viewer through the separate Liveshare server. A viewer never receives the owner's tools: the owner host reads each viewer request itself, dispatches it as that viewer's own Peer, whose `share-viewer` principal the Gateway decides through the [remote access](../../packages/api/remote-access/README.md) policy this package registers, and constrains everything a shared Session runs on the viewer's behalf to the workspace the share granted. The [package README](../../packages/experimental/liveshare/README.md) owns configuration, the client faces, and the limitations; the [Owner note](../../.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.md) owns the security posture behind them.
|
||||
|
||||
A share starts with the Session it was created for, and `liveshareShares.addSessions` adds more source Sessions to it. Every source is served under the share's ACL, tier, lifetime, fork switch, and exposed endpoints; the Relay registration and the viewer page keep the first source, and a reader reaches the others through the Session list the policy now binds. Every source must belong to the share's one Workspace: the Host refuses an addition it cannot place there, so all readers stay inside one workspace root. Stopping the share ends all of its sources.
|
||||
|
||||
Source: [`packages/experimental/liveshare/src/host/fork-enforcement.ts`](../../packages/experimental/liveshare/src/host/fork-enforcement.ts)
|
||||
|
||||
@@ -38,7 +40,7 @@ Every share lets its viewer drive the *source* Session through the normal prompt
|
||||
|
||||
The installation reports whether the source Session is actually confined. A deployment without the `read` tool, or a scope that refuses that replacement's registration, leaves the guard in place and reports the refusal; a tool whose own replacement failed stays refused while the rest of the surface runs, and the reader refuses the prompt with `source-write-unavailable`: no viewer-started turn of a shared Session is driven against the owner's own tools because a limit could not be installed.
|
||||
|
||||
Each operation re-reads the Relay grants of the shares that keep the Session drivable, and each reading is synchronous with the tool call it authorizes, so input queued past the grant that admitted it cannot act on that stale admission. The layer is per Session: several shares over one Session share the one installation, and a share that narrows or ends changes what the layer permits — its bounded write is refused once no active share may write, and the layer is released once no share is left. The Host does not attribute a running turn to the viewer that admitted it, so ending one share's grant stops the Session's running turn rather than singling out that viewer's work; the in-memory viewer-start marker only decides which tool surface that turn runs.
|
||||
Each operation re-reads the Relay grants of the shares that keep the Session drivable, and each reading is synchronous with the tool call it authorizes, so input queued past the grant that admitted it cannot act on that stale admission. The layer is per Session: several shares over one Session share the one installation, a share that carries several source Sessions installs one layer per source, and a share that narrows or ends changes what the layer permits — its bounded write is refused once no active share may write, and the layer is released once no share is left. The Host does not attribute a running turn to the viewer that admitted it, so ending one share's grant stops the Session's running turn rather than singling out that viewer's work; the in-memory viewer-start marker only decides which tool surface that turn runs.
|
||||
|
||||
Confined file tools resolve every path their own arguments declare, check that the granted root contains each result, and delegate with the target’s canonical path. A pathless `glob` or `grep` searches the Session’s working directory, which the replacement supplies and checks like a named path; a `present` call checks every file it lists; the tools with no local file effect are delegated unchanged. Bounded edits run the same containment check, use `fs/edit-intent`, and publish the version `editText` returns; bounded writes use `fs/write-intent` and publish the version `writeText` returns. After the asynchronous intent wait, the Host resolves the current Session sandbox policy and rechecks share authority. It passes that policy as the fifth `editText` or `writeText` argument, after `signal`, so the sandboxing backend fences the mutation by it. Containment is checked by path, so a directory inside the grant that a local process replaces with a symlink between the check and the open can redirect the operation; anchoring operations to a directory handle is deferred to a separate change.
|
||||
|
||||
@@ -50,7 +52,7 @@ A viewer's source prompt and source stop both travel the standard Gateway dispat
|
||||
|
||||
A viewer read that ends in a refusal names its own condition rather than one failure for every cause: the Liveshare server answers a missing link, a share the owner stopped, an expired share, an owner whose machine holds no route, an expired sign-in, a reader who has not asked, a request still waiting, a request the owner refused, and a notification the owner never received each with their own status and code, and a page that cannot reach the Liveshare server states that itself. `VIEWER_STATE_ANSWERS` in `@deepseek-ai/dsh-experimental-liveshare-protocol` is the whole vocabulary, and the [shared protocol](../../packages/experimental/liveshare-protocol/README.md) owns the answer each condition carries. No answer widens what a viewer may read: an offline owner, a stopped share, and a refused request all deny.
|
||||
|
||||
The workspace feed a viewer's page renders is filtered through the same policy as its session list: a workspace is delivered while it holds the shared Session or a branch that viewer made from it, and its membership and its manual order keep only those. A workspace that loses them leaves the feed when that workspace or the workspace order next changes, and the stream then replaces the reader's baseline; an access change alone sends no frame. A reader therefore sees the owner's workspace names and grouping around the one Session it was granted, never the rest of the owner's workspaces.
|
||||
The workspace feed a viewer's page renders is filtered through the same policy as its session list: a workspace is delivered while it holds the shared Session or a branch that viewer made from it, and its membership and its manual order keep only those. A workspace that loses them leaves the feed when that workspace or the workspace order next changes, and the stream then replaces the reader's baseline; an access change alone sends no frame. A reader therefore sees the owner's workspace names and grouping around the Sessions it was granted, never the rest of the owner's workspaces.
|
||||
|
||||
Viewer streams bind the opening actor across polling, replacement, and cancellation. Session notifications, control baselines, and workspace membership expose only the source and that actor’s children: the Gateway forwards a reader only the `api-session/*` notifications the policy allows on those Sessions, and the Session and Workspace controllers filter their lists and streams through the same policy; interactive approvals and questions stay on the Host. Refused reads open no upstream subscription. Expiry and revocation cancel affected pending calls and streams; unpolled streams are reclaimed after 120 seconds. Worker IPC responses and cancellations must match the request’s token, share id, Liveshare server id, and original socket or worker generation; late answers cannot settle a replacement request.
|
||||
|
||||
@@ -80,6 +82,13 @@ The Gateway decides each call through remote access, so a `share-viewer` Peer is
|
||||
*/
|
||||
@Remote('create') create(request: CreateShareRequest): Promise<CreatedShare>
|
||||
|
||||
/**
|
||||
* Add source Sessions to one share this host already publishes.
|
||||
* @param request - Share and the Sessions to add.
|
||||
* @returns The source Sessions the share now serves.
|
||||
*/
|
||||
@Remote('addSessions') addSessions(request: AddShareSessionsRequest): Promise<ShareSessions>
|
||||
|
||||
/**
|
||||
* Refresh titles and Liveshare server authority, then list this host's shares.
|
||||
* @returns Every share this host publishes.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](liveshare.md) | 中文
|
||||
|
||||
可选的 LiveShare bundle(`@deepseek-ai/dsh-experimental-liveshare`)让一位所有者通过独立的 Liveshare 服务端 把一个会话分享给外部 viewer。viewer 永远拿不到所有者的工具:所有者宿主机自己读取 viewer 的每个请求,以该 viewer 自己的 Peer 分派它,Gateway 依据本包注册的 [remote access](../../packages/api/remote-access/README.zh.md) 策略判定该 Peer 的 `share-viewer` principal,并把共享会话代表 viewer 运行的一切限制在分享所授予的工作区内。[包 README](../../packages/experimental/liveshare/README.zh.md)负责配置、客户端界面与限制;[Owner 说明](../../.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md)负责其背后的安全姿态。
|
||||
可选的 LiveShare bundle(`@deepseek-ai/dsh-experimental-liveshare`)让一位所有者通过独立的 Liveshare 服务端把一个或多个会话分享给外部 viewer。viewer 永远拿不到所有者的工具:所有者宿主机自己读取 viewer 的每个请求,以该 viewer 自己的 Peer 分派它,Gateway 依据本包注册的 [remote access](../../packages/api/remote-access/README.zh.md) 策略判定该 Peer 的 `share-viewer` principal,并把共享会话代表 viewer 运行的一切限制在分享所授予的工作区内。[包 README](../../packages/experimental/liveshare/README.zh.md)负责配置、客户端界面与限制;[Owner 说明](../../.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md)负责其背后的安全姿态。
|
||||
|
||||
分享以创建它时的会话来开始,`liveshareShares.addSessions` 可以为它增加更多源会话。每个源会话都按该分享的 ACL、档位、有效期、fork 开关与暴露的插件端点提供服务;Relay 注册与 viewer 页面保留第一个源会话,读者通过策略现在绑定的会话列表访问其余源会话。每个源会话必须属于该分享的同一个工作区:Host 会拒绝无法放进该工作区的源,因此所有读者都停留在同一个工作区根目录内。停止该分享会结束它的全部源会话。
|
||||
|
||||
源码:[`packages/experimental/liveshare/src/host/fork-enforcement.ts`](../../packages/experimental/liveshare/src/host/fork-enforcement.ts)
|
||||
|
||||
@@ -38,7 +40,7 @@ fork 创建查看者拥有的新会话,源会话保持不变。Fork 授权检
|
||||
|
||||
安装结果报告源会话是否真正受到限制。没有 `read` 工具的部署或拒绝该替代注册的作用域会保留守卫并报告失败;某个工具自己的替代注册失败时,该工具保持拒绝,其余工具面照常运行。读取处理器以 `source-write-unavailable` 拒绝消息:限制未安装时,共享会话中查看者发起的轮次不会用所有者工具驱动。
|
||||
|
||||
每次操作都会重新读取维持该会话可驱动的分享的 Relay 授权,且读取与其授权的工具调用在同一同步步骤内完成,因此排队超过其准入授权的输入无法凭过期准入行动。执行层按会话安装:同一会话的多个分享共用一个安装;分享收窄或结束时改变该层允许的操作——没有任何活跃分享可写时拒绝有界写入,会话不再有分享时释放执行层。Host 不把正在运行的轮次归属给准入它的查看者,因此结束某个分享的授权会停止该会话正在运行的轮次,而不是只挑出该查看者的工作;内存中的查看者轮次标记只决定该轮次运行哪套工具面。
|
||||
每次操作都会重新读取维持该会话可驱动的分享的 Relay 授权,且读取与其授权的工具调用在同一同步步骤内完成,因此排队超过其准入授权的输入无法凭过期准入行动。执行层按会话安装:同一会话的多个分享共用一个安装,一条携带多个源会话的分享则为每个源会话各安装一层;分享收窄或结束时改变该层允许的操作——没有任何活跃分享可写时拒绝有界写入,会话不再有分享时释放执行层。Host 不把正在运行的轮次归属给准入它的查看者,因此结束某个分享的授权会停止该会话正在运行的轮次,而不是只挑出该查看者的工作;内存中的查看者轮次标记只决定该轮次运行哪套工具面。
|
||||
|
||||
受限文件工具解析其自身参数声明的每个路径,检查获授根目录包含每个目标,再以目标的规范路径委托。无路径的 `glob` 或 `grep` 搜索会话的工作目录,由替代定义补齐并按命名路径一样检查;`present` 调用检查它列出的每个文件;无本地文件副作用的工具原样委托。有界编辑执行同样的包含检查,使用 `fs/edit-intent`,并发布 `editText` 返回的版本;有界写入使用 `fs/write-intent`,并发布 `writeText` 返回的版本。异步意图等待后,Host 解析当前会话沙箱策略并重新检查分享授权。该策略作为 `editText` 或 `writeText` 的第五个参数,位于 `signal` 之后,由沙箱后端据此限制变更。包含关系按路径检查,若本地进程在检查与打开之间把获授范围内的目录换成符号链接,操作可能被改指别处;基于目录句柄锚定操作留待单独变更引入。
|
||||
|
||||
@@ -50,7 +52,7 @@ fork 创建查看者拥有的新会话,源会话保持不变。Fork 授权检
|
||||
|
||||
一次以拒绝结束的 viewer 读取会指明它自己的状态,而不是让所有原因共用一种失败:链接不存在、owner 已停止分享、分享已过期、owner 的机器没有路由、登录已过期、读者尚未申请、申请仍在等待、申请被 owner 拒绝、owner 从未收到通知,每种都有各自的 HTTP 状态与错误码;页面联系不上 Liveshare 服务端时由页面自己陈述。`@deepseek-ai/dsh-experimental-liveshare-protocol` 中的 `VIEWER_STATE_ANSWERS` 就是这份词汇表,每种状态对应的答案由 [共享协议](../../packages/experimental/liveshare-protocol/README.zh.md) 维护。没有任何一种答案会扩大 viewer 能读到的内容:owner 离线、分享已停止、申请被拒,三者都是拒绝。
|
||||
|
||||
viewer 页面渲染所用的工作区流与会话列表经同一策略过滤:只要工作区还持有共享会话或该 viewer 自己创建的分支就会下发,其成员与手工顺序只保留这些。失去它们的工作区要到该工作区或工作区顺序下次变化时才离开工作区流,届时流替换读者的基线;仅访问变化不会发送帧。因此读者看到的是围绕它被授予的那一个会话的 owner 工作区名称与分组,而不是 owner 其余的工作区。
|
||||
viewer 页面渲染所用的工作区流与会话列表经同一策略过滤:只要工作区还持有共享会话或该 viewer 自己创建的分支就会下发,其成员与手工顺序只保留这些。失去它们的工作区要到该工作区或工作区顺序下次变化时才离开工作区流,届时流替换读者的基线;仅访问变化不会发送帧。因此读者看到的是围绕它被授予的每个会话的 owner 工作区名称与分组,而不是 owner 其余的工作区。
|
||||
|
||||
查看流在轮询、替换与取消时均绑定打开它的主体。会话通知、控制基线与工作区成员只暴露源会话及该主体的分叉会话:Gateway 只向读者转发策略在这些会话上允许的 `api-session/*` 通知,Session 与 Workspace 控制器也经同一策略过滤其列表和流;交互审批与提问保留在 Host。被拒绝的读取不创建上游订阅。过期与撤权取消受影响的未决调用和流;120 秒未轮询的流被回收。Worker IPC 响应与取消必须匹配请求 token、分享 ID、Liveshare 服务端 ID 及原始 socket 或 worker generation;迟到响应不能结算替换请求。
|
||||
|
||||
@@ -80,6 +82,13 @@ The Gateway decides each call through remote access, so a `share-viewer` Peer is
|
||||
*/
|
||||
@Remote('create') create(request: CreateShareRequest): Promise<CreatedShare>
|
||||
|
||||
/**
|
||||
* Add source Sessions to one share this host already publishes.
|
||||
* @param request - Share and the Sessions to add.
|
||||
* @returns The source Sessions the share now serves.
|
||||
*/
|
||||
@Remote('addSessions') addSessions(request: AddShareSessionsRequest): Promise<ShareSessions>
|
||||
|
||||
/**
|
||||
* Refresh titles and Liveshare server authority, then list this host's shares.
|
||||
* @returns Every share this host publishes.
|
||||
|
||||
@@ -27,11 +27,11 @@
|
||||
en: 42b24e06e416eb1d
|
||||
zh: 45f7e2003eb3ae2f
|
||||
/deepseek-ai-dsh-experimental-liveshare/use-this-package/sign-in-and-manage-shares:
|
||||
en: a23d0162e0efa6a4
|
||||
zh: 0d01659cfeb94640
|
||||
en: e1bad148f61e1e12
|
||||
zh: 0197ba5379c21cb2
|
||||
/deepseek-ai-dsh-experimental-liveshare/use-this-package/session-tiers:
|
||||
en: 77301142e621a96a
|
||||
zh: 2afe3fc6d274675e
|
||||
en: 2052fd7c4e9c3f98
|
||||
zh: 5ef2ac5ad9ec7990
|
||||
/deepseek-ai-dsh-experimental-liveshare/understand-the-implementation:
|
||||
en: 74c336962ca47ab5
|
||||
zh: ae3628a038bd4791
|
||||
@@ -45,8 +45,8 @@
|
||||
en: 22a9da7d4b23a015
|
||||
zh: 5700901ba5f655c8
|
||||
/deepseek-ai-dsh-experimental-liveshare/known-limitations-and-deferred-work:
|
||||
en: f869e5ce246876e7
|
||||
zh: 3a2464f5a292e837
|
||||
en: d51c0895166207f0
|
||||
zh: 82cd69842c8f74d6
|
||||
/deepseek-ai-dsh-experimental-liveshare/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 8ec3e73528ddb1a6
|
||||
@@ -73,7 +73,7 @@ The Liveshare server address is set on first use: enter it in the Share dialog's
|
||||
|
||||
The Share dialog starts a short-lived authorization transaction on the Owner. A popup returns a single-use code only to the expected window, origin, and transaction. The Owner redeems it with its private verifier and saves the credential bound to that Liveshare server; the normal browser login flow never stores that credential in browser storage or a URL. Expired, cancelled, replayed, or mismatched transactions fail closed. Changing the Liveshare server during code redemption, including a change away and back, refuses login before credential storage. After storage commits, probes and share claims retain that operation’s Liveshare server and credential identity. Publishing without verified identity is refused, including when the Liveshare server does not support identity binding.
|
||||
|
||||
Share management is the `liveshareShares` Typert Remote: the share list, creation, stopping, tier and fork changes, endpoint selection, the Liveshare server state, the owner identity, the audit trail the Liveshare server holds, and both Liveshare server checks are methods on the mounted namespace. The sign-in round trip and credential storage are the only same-origin HTTP routes the owner page still calls.
|
||||
Share management is the `liveshareShares` Typert Remote: the share list, creation, stopping, tier and fork changes, endpoint selection, the Liveshare server state, the owner identity, the audit trail the Liveshare server holds, and both Liveshare server checks are methods on the mounted namespace. `liveshareShares.addSessions` adds more source Sessions to an existing share, under that share's ACL, tier, lifetime, fork switch, and exposed endpoints; every source must belong to the same Workspace, and an addition the Host cannot place there is refused. The Share dialog does not offer it yet, so callers use the Remote directly. The sign-in round trip and credential storage are the only same-origin HTTP routes the owner page still calls.
|
||||
|
||||
The owner admits viewers by organization link; the deployment's `LIVESHARE_ALLOWED_SUBJECTS` gates every share and narrows an organization-link share to the subjects it lists. Stored `selected` or `approval` shares keep their original ACL and access summary, and the dialog no longer creates them; `liveshareShares.create` keeps `accessMode` and `allowedSubjectIds` for those records and direct Remote callers. Manage Shares lists stored links, tiers, availability, and the Liveshare server's audit responses, and it can change a share's tier, turn its fork switch on or off, and stop a share. Failed operations remain visible as errors; the UI re-reads the Owner's response rather than treating a click as success. The Share dialog edits an existing share too: its tier, fork switch, and plugin endpoints are picked there and submitted by one save that sits outside the scrolling fields and re-reads the Owner's record; unsaved picks are stated, and closing first warns that they would be discarded.
|
||||
|
||||
@@ -88,6 +88,8 @@ The owner admits viewers by organization link; the deployment's `LIVESHARE_ALLOW
|
||||
|
||||
Both tiers drive the shared Session; what separates them is writing. The sandbox is mounted on the Session for as long as any share over it is active, but it confines only the turns a share viewer starts: a turn the owner starts keeps the Session's own tools, including `bash` and its unrestricted filesystem tools, and an owner's steering input inside a viewer's turn stays confined with it. The decision is kept in Host memory per turn and never written to the Session log; stopping every share or letting them all expire releases the layer and restores the Session's own tools. Forking is not a tier: the share carries its own switch, independent of the tier, and a viewer it admits forks a completed source prefix into a child that runs under the read-only ceiling, or under the bounded-write ceiling when the share is at the `write` tier.
|
||||
|
||||
A share that carries several source Sessions keeps the first one as its Relay registration and page target. The added Sessions are bound by the `share-viewer` policy, so the reader's Session list offers them; the page opens the first source again after a reload. Stopping the share ends every source and revokes the forks they authorized.
|
||||
|
||||
**Shared PTC programs are not sandboxed to the share.** `run_code` is admitted at both tiers because a viewer may need programmatic tool use, but the program runs under the owner's standing file policy rather than a share tier: it can read and write files outside the shared workspace, and the tier does not narrow that. The viewer's nested `tools.*` calls still pass the same guard and path checks as any other call, so `bash` stays refused and file tools stay inside the grant. Share only with people you trust until a PTC sandbox can be bounded to a share.
|
||||
|
||||
Attachment uploads are neither a tier nor part of the fork switch: they follow visibility. An admitted viewer may attach uploaded files to the shared Session and to the forks it created through this share, whichever tier the share carries and whether or not it allows forking. The owner has no separate attachment switch.
|
||||
@@ -158,6 +160,8 @@ Viewer prompts enter the shared Session as ordinary user input, so they extend t
|
||||
- The owner cannot refuse attachment uploads for an admitted viewer: a viewer that may read a Session may also attach files to it, independent of the tier and of the fork switch. This is an accepted risk.
|
||||
- Viewer file operations are confined by a path check against the granted root, so a local process that swaps a directory inside the grant for a symlink between the check and the open can redirect them; directory-handle anchoring is deferred to a separate change. Viewer tools are limited to the confined read and search surface (`read`, `read_image`, `glob`, `grep`, `present`), the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`), the PTC transport `run_code`, and, at the write tier, bounded `edit` and `write`; `bash`, `pwsh`, `cua`, `plugin_manager`, the `schedule_*` tools, and no other name is granted. A `run_code` program is not bounded by the share: [Session tiers](#session-tiers) states what its program may reach.
|
||||
- The viewer has no separate workspace-file editing interface and no DSH core management authority; an installed plugin the owner opens can still change what that plugin owns.
|
||||
- `liveshareShares.addSessions` has no Share-dialog entry, and a viewer page reload returns to the share's first source; added sources are reached from the reader's Session list.
|
||||
- All source Sessions of a share must belong to one Workspace; an addition from another Workspace, or one no Workspace lists, is refused.
|
||||
- Endpoint capabilities enumerate installed plugins' Remote methods from the live Typert registry in [`shareable-catalog.ts`](src/host/shareable-catalog.ts); DSH core service namespaces are excluded. There is no sandbox around a method call and no read/write classification: granting a method trusts that installed plugin's code, which runs in the Host process. A selected method is callable by every authenticated share viewer on the resources the policy binds, across the `global`, `session`, and `workspace` resource kinds.
|
||||
- The owner selects installed-plugin methods in the share panel through a global switch and per-plugin rows. The global switch is `none` (open nothing), `custom` (follow the per-plugin list), or `all` (open every method the live catalog offers). Each plugin is then off, all its methods, or a subset; a partial selection stores an explicit method list, so methods the catalog adds later stay closed until the owner saves again. Plugin rows use the package's localized title when one exists and otherwise fall back to the package segment or namespace; search and bounded scrolling keep a large catalog usable. The selection is share-wide: every admitted viewer may call a selected method on the resources the policy binds, and per-viewer method levels are not established. Method authorization is independent of the session tier; the core share roles still decide Session and Workspace calls. A selection naming a method the catalog no longer offers exposes nothing but stays stored until the owner saves again.
|
||||
- A Workspace that stops holding a Session the reader can see leaves the reader's Workspace feed only at that Workspace's or the Workspace order's next change ([remote access limitation](../../api/remote-access/README.md#known-limitations-and-deferred-work)).
|
||||
|
||||
@@ -73,7 +73,7 @@ Liveshare 服务端地址在首次使用时设置:在分享弹窗的地址输
|
||||
|
||||
分享对话框在 Owner 上创建短期授权事务。弹窗只向预期窗口、来源和事务返回一次性代码。Owner 使用私有校验值兑换代码并保存绑定该 Liveshare 服务端的凭据;正常浏览器登录流程不会把该凭据存入浏览器存储或 URL。过期、取消、重放或不匹配的事务均拒绝。代码兑换期间更改 Liveshare 服务端,包括切换后再切回,会在保存凭据前拒绝登录。存储提交后,探测和分享认领保留该操作的 Liveshare 服务端与凭据身份。未验证身份时禁止发布,Liveshare 服务端不支持身份绑定时也一样。
|
||||
|
||||
分享管理本身走 `liveshareShares` Typert Remote:分享列表、创建、停止、档位与分叉开关变更、端点选择、Liveshare 服务端状态、所有者身份、Liveshare 服务端保存的审计以及两项服务端检查都是挂载命名空间上的方法。登录往返与凭据保存是所有者页面仍然调用的仅有的同源 HTTP 路由。
|
||||
分享管理本身走 `liveshareShares` Typert Remote:分享列表、创建、停止、档位与分叉开关变更、端点选择、Liveshare 服务端状态、所有者身份、Liveshare 服务端保存的审计以及两项服务端检查都是挂载命名空间上的方法。登录往返与凭据保存是所有者页面仍然调用的仅有的同源 HTTP 路由。`liveshareShares.addSessions` 可以向已有分享增加更多源会话,这些源会话沿用该分享的 ACL、档位、有效期、fork 开关与暴露的插件端点;所有源会话必须属于同一个工作区,Host 会拒绝无法放进该工作区的源。分享对话框暂不提供该入口,调用方直接使用 Remote。
|
||||
|
||||
所有者通过组织链接接纳查看者;部署的 `LIVESHARE_ALLOWED_SUBJECTS` 作用于每份分享,把组织链接分享的准入收窄到它列出的主体。已存储的 `selected` 或 `approval` 分享保留原有 ACL 与访问摘要,弹窗不再创建这类分享;`liveshareShares.create` 的 `accessMode` 与 `allowedSubjectIds` 只保留给这些记录与直接 Remote 调用方。管理分享页面列出保存的链接、档位、可用状态及 Liveshare 服务端的审计响应,并可修改分享档位、开关「允许分叉会话」以及停止分享。失败操作显示错误;界面重新读取 Owner 响应,不把点击本身当作成功。分享弹窗也能修改已有分享:档位、分叉开关与插件端点在弹窗里选择,由滚动区之外的一次保存一并提交,并在保存后回读 Owner 的记录;未保存的选择会明确标出,关闭前先提示它们会被丢弃。
|
||||
|
||||
@@ -88,6 +88,8 @@ Liveshare 服务端地址在首次使用时设置:在分享弹窗的地址输
|
||||
|
||||
两档都会驱动共享会话,区别在于能否写入。沙箱挂在会话上,只要该会话还有分享活跃就会安装,但它只约束分享查看者发起的轮次:所有者自己发起的轮次保留会话原生工具,包括 `bash` 与不受限的文件工具;所有者在查看者轮次中追加的输入仍留在该受限轮次内。该判定只存在于 Host 内存、按轮次重算,不写入会话日志;所有分享停止或全部过期后执行层被释放,会话恢复自己的工具。分叉会话不是一个档位:分享自带独立开关,与档位无关;被允许的查看者可从已完成的源前缀分叉出会话,分叉会话运行在只读上限下;分享处于 `write` 档时改为受约束的写上限。
|
||||
|
||||
携带多个源会话的分享仍以第一个源会话作为 Relay 注册与页面目标。新增的源会话由 `share-viewer` 策略绑定,读者的会话列表因此会提供它们;页面刷新后仍打开第一个源会话。停止该分享会结束全部源会话,并撤销它们授权过的 fork。
|
||||
|
||||
**共享出去的 PTC 程序并不受分享沙箱约束。** 两档都准入 `run_code`,因为查看者可能需要用程序调用工具;但程序运行在所有者自己的常驻文件策略下,而不是分享档位下:它可以读写共享工作区之外的文件,档位不会收窄它。查看者发起的嵌套 `tools.*` 调用仍走与其它调用相同的守卫与路径检查,因此 `bash` 仍被拒绝、文件工具仍限制在获授范围内。在 PTC 可以被收窄到分享范围之前,只分享给可信任的人。
|
||||
|
||||
附件上传既不是档位,也不属于分叉开关:它跟随可见性。无论分享处于哪个档位、是否允许分叉,已准入的查看者都可以向共享会话以及自己通过本分享分叉出的会话上传附件。所有者没有单独的附件开关。
|
||||
@@ -158,6 +160,8 @@ viewer 的 prompt 以普通用户输入进入共享会话,因此扩展的是
|
||||
- 所有者无法拒绝已准入查看者的附件上传:能读取某条会话的查看者也可以向它附加文件,与档位和分叉开关无关。这是已接受的风险。
|
||||
- 查看者的文件操作按获授根目录做路径检查,若本地进程在检查与打开之间把获授范围内的目录换成符号链接,读写可能被改指别处;目录句柄锚定留待单独变更引入。查看者工具仅限受限的读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)、PTC 传输工具 `run_code`,以及 write 档下的有界 `edit` 与 `write`;不授予 `bash`、`pwsh`、`cua`、`plugin_manager`、`schedule_*` 及其他任何未列出的工具。`run_code` 程序不受分享范围约束,[会话档位](#session-tiers) 一节说明其程序可以触及什么。
|
||||
- 查看页没有独立工作区文件编辑界面,也没有 DSH 核心管理权限;所有者开放的已安装插件方法仍可改变该插件自己拥有的状态。
|
||||
- `liveshareShares.addSessions` 没有分享对话框入口,查看页刷新后会回到分享的第一个源会话;新增源会话需从读者的会话列表进入。
|
||||
- 一条分享的所有源会话必须属于同一个工作区;来自其他工作区、或不属于任何工作区的源会被拒绝。
|
||||
- 端点能力从 Typert registry 枚举已安装插件的 Remote 方法(见 [`shareable-catalog.ts`](src/host/shareable-catalog.ts)),DSH 核心服务命名空间被排除。方法调用不经过沙箱,也没有读写分类:开放一个方法即信任该已安装插件的代码,它在 Host 进程中执行。被选中的方法会在 policy 绑定的资源上被任意已认证分享读者调用,覆盖 `global`、`session`、`workspace` 三种资源种类。
|
||||
- owner 在分享面板里通过全局开关和按插件行选择已安装插件的方法。全局开关为“不可用”(全部关闭)、“自定义”(按插件列表)或“全部允许”(开放当前目录全部方法)。每个插件再设为不可用、全部方法或部分方法;部分选择保存显式方法列表,因此目录之后新增的方法不会自动开放,直到 owner 再次保存。插件行优先显示包内本地化名称,取不到时回退到包名末段或 namespace;搜索与限高滚动让大目录仍可操作。选择是分享级的:每位已准入查看者都可以在 policy 绑定的资源上调用被选中的方法,尚未建立逐查看者方法级别。方法授权独立于会话档位,Session 与 Workspace 调用仍由核心分享角色决定。目录不再提供的方法即使留在记录里也不暴露。
|
||||
- 工作区不再包含读者可见的会话后,要到该工作区或工作区顺序下次变化时才离开读者的工作区订阅([remote access 限制](../../api/remote-access/README.zh.md#known-limitations-and-deferred-work))。
|
||||
|
||||
@@ -81,33 +81,39 @@ export function LiveshareManageSharesDialog({ shares, onOpenShare, onClose, t }:
|
||||
{loaded && shareList.length === 0 ? <p className={css.noRequests}>{t('manage.empty')}</p> : null}
|
||||
{shareList.length === 0 ? null : (
|
||||
<ul className={css.outgoingList}>
|
||||
{shareList.map(share => (
|
||||
<li key={share.shareId} className={css.outgoingItem}>
|
||||
<div className={css.managerRow}>
|
||||
<strong title={share.sessionTitle}>{share.sessionTitle}</strong>
|
||||
<span className={share.online ? css.online : css.offline}>
|
||||
{t(share.online ? 'dialog.share.online' : 'dialog.share.offline')}
|
||||
</span>
|
||||
<div className={css.managerActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={onOpenShare === undefined}
|
||||
onClick={() => { onOpenShare?.(share.sessionId, share.sessionTitle) }}
|
||||
>
|
||||
{t('manage.open')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setStopTarget(share); setStopAcknowledged(false) }}
|
||||
>
|
||||
{t('manage.cancelShare')}
|
||||
</Button>
|
||||
{shareList.map((share) => {
|
||||
const primary = share.sessionIds?.[0] ?? share.sessionId ?? ''
|
||||
const open = primary === '' || onOpenShare === undefined
|
||||
? undefined
|
||||
: () => { onOpenShare(primary, share.sessionTitle) }
|
||||
return (
|
||||
<li key={share.shareId} className={css.outgoingItem}>
|
||||
<div className={css.managerRow}>
|
||||
<strong title={share.sessionTitle}>{share.sessionTitle}</strong>
|
||||
<span className={share.online ? css.online : css.offline}>
|
||||
{t(share.online ? 'dialog.share.online' : 'dialog.share.offline')}
|
||||
</span>
|
||||
<div className={css.managerActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={open === undefined}
|
||||
onClick={open}
|
||||
>
|
||||
{t('manage.open')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setStopTarget(share); setStopAcknowledged(false) }}
|
||||
>
|
||||
{t('manage.cancelShare')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{error === '' ? null : <div className={css.error} role="alert">{error}</div>}
|
||||
|
||||
@@ -207,7 +207,8 @@ export function useShareEdit({
|
||||
try {
|
||||
const listed = await shares.list()
|
||||
if (cancelled || !listed.ok) return
|
||||
const existing = listed.value.shares.find(share => share.sessionId === sessionId)
|
||||
const existing = listed.value.shares.find(share =>
|
||||
share.sessionId === sessionId || share.sessionIds.includes(sessionId))
|
||||
if (existing !== undefined) {
|
||||
onLink(existing.link)
|
||||
setConfirmedAccess(readAccess(existing.access))
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {
|
||||
AddShareSessionsRequest,
|
||||
CreatedShare,
|
||||
CreateShareRequest,
|
||||
OwnerIdentityRequest,
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
ShareCapabilities,
|
||||
ShareCapabilityOffers,
|
||||
SharePermissions,
|
||||
ShareSessions,
|
||||
StopShareRequest,
|
||||
StopShareResult,
|
||||
TestPublishRequest,
|
||||
@@ -40,6 +42,13 @@ export interface OwnerShares {
|
||||
*/
|
||||
create(request: CreateShareRequest): Promise<RemoteResult<CreatedShare>>
|
||||
|
||||
/**
|
||||
* Add source Sessions to one share this host already publishes.
|
||||
* @param request - Share and the Sessions to add.
|
||||
* @returns The source Sessions the share now serves.
|
||||
*/
|
||||
addSessions(request: AddShareSessionsRequest): Promise<RemoteResult<ShareSessions>>
|
||||
|
||||
/**
|
||||
* Refresh titles and Relay authority, then list this host's shares.
|
||||
* @returns Every share this host publishes.
|
||||
|
||||
@@ -12,7 +12,10 @@ export type { ViewerPluginPolicy }
|
||||
/** One owner-created share listed by the global management UI. */
|
||||
export interface OutgoingShare {
|
||||
readonly shareId: string
|
||||
readonly sessionId: string
|
||||
/** Primary source Session, kept for Hosts that predate `sessionIds`. */
|
||||
readonly sessionId?: string
|
||||
/** Every source Session this share serves, primary first; the authoritative list. */
|
||||
readonly sessionIds?: readonly string[]
|
||||
readonly sessionTitle: string
|
||||
readonly link: string
|
||||
readonly createdAt: string
|
||||
|
||||
@@ -236,6 +236,7 @@ export const VIEWER_ENDPOINT_CLASSES = {
|
||||
'liveshareShares/audit': 'owner-only',
|
||||
'liveshareShares/capabilities': 'owner-only',
|
||||
'liveshareShares/create': 'owner-only',
|
||||
'liveshareShares/addSessions': 'owner-only',
|
||||
'liveshareShares/identity': 'owner-only',
|
||||
'liveshareShares/list': 'owner-only',
|
||||
'liveshareShares/relay': 'owner-only',
|
||||
|
||||
@@ -51,8 +51,8 @@ export interface ForkViewer {
|
||||
/** What the Relay knows about the share a fork request arrived through. */
|
||||
export interface ForkShareState {
|
||||
readonly shareId: string
|
||||
/** Session this share points at; a fork may only ever fork this one. */
|
||||
readonly sessionId: string
|
||||
/** Source Sessions this share serves, primary first; a fork may only ever fork one of them. */
|
||||
readonly sessionIds: readonly string[]
|
||||
/**
|
||||
* Whether the owner allowed forking at all.
|
||||
*
|
||||
@@ -91,7 +91,7 @@ export interface ForkShareState {
|
||||
/** One fork attempt, already carried over the Relay channel with trusted metadata. */
|
||||
export interface ForkRequest {
|
||||
readonly shareId: string
|
||||
/** Session the caller believes it is forking; must equal the share's own session. */
|
||||
/** Session the caller believes it is forking; must be one of the share's sources. */
|
||||
readonly sourceSessionId: string
|
||||
/** Completed prefix to fork at; absent forks the whole committed history. */
|
||||
readonly atSeq?: number
|
||||
@@ -203,12 +203,12 @@ export function decideFork(
|
||||
if (!share.viewerAdmitted) {
|
||||
return { ok: false, code: 'viewer-not-authorized', message: 'viewer may not fork this share' }
|
||||
}
|
||||
// The share names its own session, so a caller cannot point a fork at another session it can name.
|
||||
if (request.sourceSessionId !== share.sessionId) {
|
||||
// The share names its own sources, so a caller cannot point a fork at another session it can name.
|
||||
if (!share.sessionIds.includes(request.sourceSessionId)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'source-session-mismatch',
|
||||
message: 'the requested session is not the session this share carries',
|
||||
message: 'the requested session is not one this share carries',
|
||||
}
|
||||
}
|
||||
return { ok: true, boundary: request.atSeq ?? 0, ceiling: READ_ONLY_FORK_CEILING, requestKey: request.requestKey }
|
||||
|
||||
@@ -21,7 +21,15 @@ import { isRecord } from './wire-json.ts'
|
||||
/** One share as the owner state file stores it. */
|
||||
export interface PersistedOwnerShare {
|
||||
readonly shareId: string
|
||||
readonly sessionId: string
|
||||
/**
|
||||
* Every source Session this share serves, primary first.
|
||||
*
|
||||
* Absent on records written before a share could carry more than one source; such a record reads its
|
||||
* legacy `sessionId` instead.
|
||||
*/
|
||||
readonly sessionIds?: readonly string[]
|
||||
/** Legacy single source from a record written before `sessionIds`; read only, never written. */
|
||||
readonly sessionId?: string
|
||||
readonly sessionTitle: string
|
||||
/** Mode this share was created with; the retired `approval` spelling reads back as `selected`. */
|
||||
readonly accessMode: 'open' | 'selected' | 'approval'
|
||||
@@ -109,7 +117,7 @@ export function loadOwnerState(file: string): LoadedOwnerState {
|
||||
const relay = normalizeRelayUrl(value.relay)
|
||||
return [value.shareId, {
|
||||
shareId: value.shareId,
|
||||
sessionId: value.sessionId,
|
||||
sessionIds: restoredSessionIds(value),
|
||||
sessionTitle: value.sessionTitle,
|
||||
accessMode: storedAccessMode(value.accessMode),
|
||||
link: `${relay}/?shareId=${encodeURIComponent(value.shareId)}`,
|
||||
@@ -161,7 +169,7 @@ export function writeOwnerState(
|
||||
): Promise<void> {
|
||||
const persisted: PersistedOwnerShare[] = [...shares.values()].map(share => ({
|
||||
shareId: share.shareId,
|
||||
sessionId: share.sessionId,
|
||||
sessionIds: [...share.sessionIds],
|
||||
sessionTitle: share.sessionTitle,
|
||||
accessMode: share.accessMode,
|
||||
relay: share.relay,
|
||||
@@ -250,7 +258,9 @@ export function storedAccessMode(mode: 'open' | 'selected' | 'approval'): 'open'
|
||||
function isPersistedOwnerShare(value: unknown): value is PersistedOwnerShare {
|
||||
return isRecord(value)
|
||||
&& typeof value.shareId === 'string' && value.shareId !== ''
|
||||
&& typeof value.sessionId === 'string'
|
||||
&& (value.sessionIds === undefined
|
||||
? typeof value.sessionId === 'string' && value.sessionId !== ''
|
||||
: isSessionIdList(value.sessionIds))
|
||||
&& typeof value.sessionTitle === 'string'
|
||||
&& (value.accessMode === 'open' || value.accessMode === 'selected' || value.accessMode === 'approval')
|
||||
&& typeof value.relay === 'string' && value.relay !== ''
|
||||
@@ -268,6 +278,32 @@ function isPersistedOwnerShare(value: unknown): value is PersistedOwnerShare {
|
||||
&& (value.sessionMode === undefined || value.sessionMode === 'view' || value.sessionMode === 'write')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one stored source list names non-empty Session ids with the primary first.
|
||||
* @param value - Parsed field.
|
||||
* @param primary - The record's own primary Session id.
|
||||
* @returns True when the field is a usable source list.
|
||||
*/
|
||||
function isSessionIdList(value: unknown): value is readonly string[] {
|
||||
return Array.isArray(value)
|
||||
&& value.length > 0
|
||||
&& value.every(id => typeof id === 'string' && id !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a stored source list, deriving it from the legacy field for records written before it existed.
|
||||
* @param value - Stored share record, already validated.
|
||||
* @returns The source list, primary first.
|
||||
*/
|
||||
function restoredSessionIds(value: PersistedOwnerShare): readonly string[] {
|
||||
if (value.sessionIds !== undefined) return [...value.sessionIds]
|
||||
/* v8 ignore next 3 -- the validator admits a record only with one of the two fields. */
|
||||
if (value.sessionId === undefined) {
|
||||
throw new Error('liveshare-share-host: a stored share carries no source Session')
|
||||
}
|
||||
return [value.sessionId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one stored share's per-plugin policies.
|
||||
* @param value - Stored share record.
|
||||
|
||||
@@ -12,12 +12,15 @@ import { base64Url } from './base64-url.ts'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type { ShareRpcActor } from '@deepseek-ai/dsh-experimental-liveshare-protocol'
|
||||
import type { Workspace } from '@deepseek-ai/dsh-workspace/types'
|
||||
import type {
|
||||
AddShareSessionsRequest,
|
||||
OwnerRelayState,
|
||||
OwnerShareView,
|
||||
ShareAccess,
|
||||
ShareAudit,
|
||||
ShareAuditEntryView,
|
||||
ShareSessions,
|
||||
} from '../share-api-types.ts'
|
||||
import type { ForkRegistry } from './fork-registry.ts'
|
||||
import type { StoredOwnerCredential } from './owner-credential-file.ts'
|
||||
@@ -27,6 +30,7 @@ import { normalizeRelayUrl } from './relay-url.ts'
|
||||
import type { RelayAuthority } from './relay-authority.ts'
|
||||
import type { OwnerShare } from './owner-types.ts'
|
||||
import type { SourceAccessApplier } from './source-access.ts'
|
||||
import { primarySessionIdOf } from './source-sessions.ts'
|
||||
import type { OwnerShareOperations } from './share-operations.ts'
|
||||
import type { TunnelSupervision } from './tunnel-supervision.ts'
|
||||
import { readViewerPluginPolicies } from './viewer-capability-selection.ts'
|
||||
@@ -106,6 +110,46 @@ function readShareAudit(body: string): ShareAudit | undefined {
|
||||
return { events }
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse source Sessions the Host cannot place in the share's one workspace.
|
||||
*
|
||||
* A share's readers run file tools against each source Session's granted workspace, so sources from
|
||||
* different workspaces would widen the reach beyond one root. The check runs before the record changes:
|
||||
* a refused addition leaves the stored share and its layers untouched.
|
||||
* @param share - Share the additions are about to join.
|
||||
* @param additions - Sessions not yet served by the share.
|
||||
* @param workspaces - Workspaces this host holds, or undefined when none are resolvable.
|
||||
*/
|
||||
function assertOneWorkspace(
|
||||
share: OwnerShare,
|
||||
additions: readonly string[],
|
||||
workspaces: readonly Pick<Workspace, 'id' | 'sessionIds'>[] | undefined,
|
||||
): void {
|
||||
if (workspaces === undefined) {
|
||||
throw new RemoteError('liveshareShares/workspace-unavailable',
|
||||
'this host cannot resolve workspaces, so it cannot add source Sessions', {})
|
||||
}
|
||||
const workspaceOf = (sessionId: string): string | undefined =>
|
||||
workspaces.find(workspace => workspace.sessionIds.some(id => String(id) === sessionId))?.id
|
||||
const primary = share.sessionIds[0]
|
||||
const required = primary === undefined ? undefined : workspaceOf(primary)
|
||||
if (primary === undefined || required === undefined) {
|
||||
throw new RemoteError('liveshareShares/session-workspace-unknown',
|
||||
'the share has no workspace to place added Sessions in', { sessionId: primary ?? '' })
|
||||
}
|
||||
for (const sessionId of [...share.sessionIds, ...additions]) {
|
||||
const actual = workspaceOf(sessionId)
|
||||
if (actual === undefined) {
|
||||
throw new RemoteError('liveshareShares/session-workspace-unknown',
|
||||
`session ${sessionId} is not in a workspace`, { sessionId })
|
||||
}
|
||||
if (actual !== required) {
|
||||
throw new RemoteError('liveshareShares/session-workspace-mismatch',
|
||||
`session ${sessionId} is not in the share's workspace`, { sessionId, workspaceId: required })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one stored share onto the owner list's wire record.
|
||||
*
|
||||
@@ -117,7 +161,8 @@ function readShareAudit(body: string): ShareAudit | undefined {
|
||||
function ownerShareView(share: OwnerShare): OwnerShareView {
|
||||
return {
|
||||
shareId: share.shareId,
|
||||
sessionId: share.sessionId,
|
||||
sessionId: primarySessionIdOf(share),
|
||||
sessionIds: share.sessionIds,
|
||||
sessionTitle: share.sessionTitle,
|
||||
link: share.link,
|
||||
createdAt: share.createdAt,
|
||||
@@ -161,6 +206,8 @@ export interface OwnerShareOperationDeps {
|
||||
readonly viewerRead: ViewerReadService
|
||||
/** The platform catalog a plugin policy is accepted against. */
|
||||
readonly offeredCapabilities: () => readonly ShareablePluginCatalog[]
|
||||
/** Workspaces this host holds, or undefined when the composition mounts no Workspace registry. */
|
||||
readonly workspaces: () => readonly Pick<Workspace, 'id' | 'sessionIds'>[] | undefined
|
||||
/** Reads and adopted states of the Relay's authority over this host's shares. */
|
||||
readonly relay: RelayAuthority
|
||||
/** Worker registration, expiry timers, and persistence channels. */
|
||||
@@ -180,7 +227,7 @@ export interface OwnerShareOperationDeps {
|
||||
/** Install the access layer one share's source Session needs. */
|
||||
readonly applySourceAccess: SourceAccessApplier
|
||||
/** Whether one share's source Session carries a usable confined layer. */
|
||||
readonly sourceConfined: (shareId: string) => boolean
|
||||
readonly sourceConfined: (shareId: string, sessionId: string) => boolean
|
||||
/** Tell remote access that reader answers may have changed. */
|
||||
readonly accessChanged: () => void
|
||||
/** Stop whatever turn one share's Session is running. */
|
||||
@@ -283,7 +330,7 @@ export function createOwnerShareOperations(deps: OwnerShareOperationDeps): Owner
|
||||
const gatewayToken = base64Url(randomBytes(32))
|
||||
const share = {
|
||||
shareId,
|
||||
sessionId: request.sessionId,
|
||||
sessionIds: [request.sessionId],
|
||||
sessionTitle,
|
||||
accessMode,
|
||||
relay,
|
||||
@@ -316,7 +363,7 @@ export function createOwnerShareOperations(deps: OwnerShareOperationDeps): Owner
|
||||
const link = `${relay}/?shareId=${encodeURIComponent(shareId)}`
|
||||
ownerShares.set(shareId, {
|
||||
shareId,
|
||||
sessionId: request.sessionId,
|
||||
sessionIds: [request.sessionId],
|
||||
sessionTitle,
|
||||
accessMode,
|
||||
link,
|
||||
@@ -344,8 +391,61 @@ export function createOwnerShareOperations(deps: OwnerShareOperationDeps): Owner
|
||||
tunnel.scheduleShareExpiry(ownerShares.get(shareId) as OwnerShare)
|
||||
// A share created in write mode installs its access layer before the owner is told the share exists:
|
||||
// a request admitted on the strength of this answer must never arrive ahead of the layer judging it.
|
||||
await applySourceAccess(ownerShares.get(shareId) as OwnerShare, undefined, 'publish')
|
||||
return { link, shareId, sessionId: request.sessionId, sessionMode, access }
|
||||
await applySourceAccess(ownerShares.get(shareId) as OwnerShare, request.sessionId, undefined, 'publish')
|
||||
return {
|
||||
link, shareId, sessionId: request.sessionId, sessionIds: [request.sessionId], sessionMode, access,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Order: validate -> resolve the share -> add each new source -> persist (failure restores the
|
||||
* previous record) -> install each added source's layer -> accessChanged.
|
||||
*
|
||||
* The Relay registration carries the primary source only and does not change, so no Relay call is
|
||||
* made: every added Session is served through the same registration, ACL, tier, lifetime, and fork
|
||||
* switch. A Session the share already serves is ignored, so a repeated call is idempotent. Adding a
|
||||
* source never removes one; stopping the share ends all of them.
|
||||
*/
|
||||
async addSessions(request: AddShareSessionsRequest): Promise<ShareSessions> {
|
||||
if (request.shareId === '') {
|
||||
throw new RemoteError('liveshareShares/missing-share-id', 'addSessions requires a shareId', {})
|
||||
}
|
||||
const requested = [...new Set(request.sessionIds)]
|
||||
if (requested.length === 0) {
|
||||
throw new RemoteError('liveshareShares/missing-session-ids',
|
||||
'addSessions requires at least one non-empty sessionId', {})
|
||||
}
|
||||
if (requested.some(sessionId => sessionId === '')) {
|
||||
throw new RemoteError('liveshareShares/missing-session-ids',
|
||||
'addSessions requires at least one non-empty sessionId', {})
|
||||
}
|
||||
const current = ownerShares.get(request.shareId)
|
||||
if (current === undefined) {
|
||||
throw new RemoteError('liveshareShares/share-not-found', `share ${request.shareId} is not published`, {
|
||||
shareId: request.shareId,
|
||||
})
|
||||
}
|
||||
const additions = requested.filter(sessionId => !current.sessionIds.includes(sessionId))
|
||||
if (additions.length === 0) return { shareId: current.shareId, sessionIds: current.sessionIds }
|
||||
assertOneWorkspace(current, additions, deps.workspaces())
|
||||
const next: OwnerShare = {
|
||||
...current,
|
||||
sessionIds: [...current.sessionIds, ...additions],
|
||||
revision: current.revision + 1,
|
||||
}
|
||||
ownerShares.set(current.shareId, next)
|
||||
try {
|
||||
await persistOwnerState()
|
||||
} catch {
|
||||
ownerShares.set(current.shareId, current)
|
||||
throw new RemoteError('liveshareShares/owner-state-unavailable',
|
||||
'the owner state file could not be written', {})
|
||||
}
|
||||
// An answer that admits a reader must not arrive before the layer that confines what it drives.
|
||||
// A source that is not live yet gets its layer when the reader opens it, as the primary does.
|
||||
for (const sessionId of additions) await applySourceAccess(next, sessionId, undefined, 'publish')
|
||||
accessChanged()
|
||||
return { shareId: next.shareId, sessionIds: next.sessionIds }
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -413,7 +513,7 @@ export function createOwnerShareOperations(deps: OwnerShareOperationDeps): Owner
|
||||
await forks.revokeShare(shareId, new Date().toISOString())
|
||||
ownerShares.delete(shareId)
|
||||
// A Session no share names any more loses its sandbox, which restores its own tools.
|
||||
reconcileSourceAccess(share.sessionId)
|
||||
for (const sessionId of share.sessionIds) reconcileSourceAccess(sessionId)
|
||||
accessChanged()
|
||||
tunnel.clearShareExpiry(shareId)
|
||||
try {
|
||||
@@ -483,7 +583,9 @@ export function createOwnerShareOperations(deps: OwnerShareOperationDeps): Owner
|
||||
// A share that already names this state may still have no usable layer: its Session was not
|
||||
// live when it was set, or this host restarted before one could be installed. Asking again
|
||||
// installs it rather than answering as if the capability were already in place.
|
||||
if (!sourceConfined(shareId)) await applySourceAccess(current, undefined, 'mode')
|
||||
for (const sessionId of current.sessionIds) {
|
||||
if (!sourceConfined(shareId, sessionId)) await applySourceAccess(current, sessionId, undefined, 'mode')
|
||||
}
|
||||
return {
|
||||
shareId, sessionMode: current.sessionMode, allowsFork: current.allowsFork,
|
||||
...(current.expiresAt === undefined ? {} : { expiresAt: current.expiresAt }),
|
||||
@@ -522,7 +624,7 @@ export function createOwnerShareOperations(deps: OwnerShareOperationDeps): Owner
|
||||
// in force: a request admitted on the strength of the owner's answer must never arrive before the
|
||||
// layer that judges it exists, and a narrowed tier must stop the writes the wider one allowed
|
||||
// without ending the turn itself.
|
||||
await applySourceAccess(next, undefined, 'mode')
|
||||
for (const sessionId of next.sessionIds) await applySourceAccess(next, sessionId, undefined, 'mode')
|
||||
return {
|
||||
shareId, sessionMode: next.sessionMode, allowsFork: next.allowsFork,
|
||||
...(next.expiresAt === undefined ? {} : { expiresAt: next.expiresAt }),
|
||||
@@ -752,7 +854,7 @@ export function createOwnerShareOperations(deps: OwnerShareOperationDeps): Owner
|
||||
const share = tunnel.shareSpec({
|
||||
// The probe assigns the throwaway id it waits on; every other field is the real one.
|
||||
shareId: '',
|
||||
sessionId: request.sessionId,
|
||||
sessionIds: [request.sessionId],
|
||||
sessionTitle: request.sessionId,
|
||||
accessMode: 'open',
|
||||
relay,
|
||||
|
||||
@@ -28,7 +28,14 @@ export interface ShareAccessSummary {
|
||||
/** One share this host keeps in its owner state, as the worker and the client both read it. */
|
||||
export interface OwnerShare {
|
||||
readonly shareId: string
|
||||
readonly sessionId: string
|
||||
/**
|
||||
* Every source Session this share serves, primary first.
|
||||
*
|
||||
* The first entry is the Session the Relay registers and the viewer page opens first; read it through
|
||||
* `primarySessionIdOf` rather than keeping a second id field. The share's tier, fork switch, lifetime,
|
||||
* ACL, and exposed endpoints apply to all sources, and a viewer sees each source plus its own forks.
|
||||
*/
|
||||
readonly sessionIds: readonly string[]
|
||||
readonly sessionTitle: string
|
||||
readonly accessMode: 'open' | 'selected'
|
||||
readonly link: string
|
||||
|
||||
@@ -331,7 +331,7 @@ export function createRelayAuthority(deps: RelayAuthorityDeps): RelayAuthority {
|
||||
if (admissionNarrowed(previous, state)) deps.cancelShareTurn(shareId)
|
||||
// The Session's sandbox follows its shares: a narrowed tier re-installs the read-only layer, and a
|
||||
// deadline that just passed releases it.
|
||||
deps.reconcileSourceAccess(share.sessionId)
|
||||
for (const sessionId of share.sessionIds) deps.reconcileSourceAccess(sessionId)
|
||||
}
|
||||
deps.accessChanged()
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export interface Config {
|
||||
export function publishedShareState(share: OwnerShare): ForkShareState {
|
||||
return {
|
||||
shareId: share.shareId,
|
||||
sessionId: share.sessionId,
|
||||
sessionIds: share.sessionIds,
|
||||
allowsFork: share.allowsFork,
|
||||
viewerAdmitted: true,
|
||||
stopped: false,
|
||||
@@ -274,8 +274,8 @@ export async function apply(ctx: Context, config: Config): Promise<() => Promise
|
||||
}
|
||||
|
||||
/** @see SourceAccess.confined */
|
||||
function sourceConfined(shareId: string): boolean {
|
||||
return sourceAccess.confined(shareId)
|
||||
function sourceConfined(shareId: string, sessionId: string): boolean {
|
||||
return sourceAccess.confined(shareId, sessionId)
|
||||
}
|
||||
|
||||
const accessFacts: ViewerAccessFacts = {
|
||||
@@ -364,7 +364,7 @@ export async function apply(ctx: Context, config: Config): Promise<() => Promise
|
||||
const viewerRead: ViewerReadService = createViewerReadService({
|
||||
connection,
|
||||
...(gateway === undefined ? {} : { gateway }),
|
||||
sharedSessionId: shareId => ownerShares.get(shareId)?.sessionId,
|
||||
sharedSessionIds: shareId => ownerShares.get(shareId)?.sessionIds,
|
||||
forks,
|
||||
// The Relay admitted this viewer before the request reached this host, so admission is a fact
|
||||
// here; the share's own switch, session, and expiry are what the fork decision reads.
|
||||
@@ -383,11 +383,11 @@ export async function apply(ctx: Context, config: Config): Promise<() => Promise
|
||||
writeRefusal: (viewer, action, sessionId, concrete) => readerWriteRefusal(accessFacts, viewer, action, sessionId, concrete),
|
||||
accessChanged,
|
||||
// The same record the confinement answer reads, so a refusal can say why the layer is missing.
|
||||
sourceRefusal: (shareId) => {
|
||||
sourceRefusal: (shareId, sessionId) => {
|
||||
const share = ownerShares.get(shareId)
|
||||
/* v8 ignore next -- the refusal is only read for a share this host still publishes. */
|
||||
if (share === undefined) return undefined
|
||||
return sourceAccess.refusalOf(shareId)
|
||||
return sourceAccess.refusalOf(shareId, sessionId)
|
||||
},
|
||||
prepareSource: sessionId => sourceAccess.prepareSourceSession(sessionId, 'prompt'),
|
||||
prepareSourceForReader: (shareId) => { sourceAccess.prepareSourceForReader(shareId) },
|
||||
@@ -439,10 +439,10 @@ export async function apply(ctx: Context, config: Config): Promise<() => Promise
|
||||
// A shared Session is the other case this listener covers: it must carry the layer that confines an
|
||||
// externally driven turn before it is published, and a resumed one reaches this listener with its own
|
||||
// scope. A Session no published share names installs nothing here.
|
||||
const published = [...ownerShares.values()].find(candidate => candidate.sessionId === childSessionId)
|
||||
const published = [...ownerShares.values()].find(candidate => candidate.sessionIds.includes(childSessionId))
|
||||
const arriving = agent.session
|
||||
if (published !== undefined) {
|
||||
await sourceAccess.apply(published, { context: agent.ctx, session: arriving }, 'publish')
|
||||
await sourceAccess.apply(published, childSessionId, { context: agent.ctx, session: arriving }, 'publish')
|
||||
}
|
||||
const record = forks.find(childSessionId)
|
||||
if (record === undefined) return
|
||||
@@ -468,7 +468,7 @@ export async function apply(ctx: Context, config: Config): Promise<() => Promise
|
||||
if (share === undefined || !relay.viewerAdmitted(origin.shareId, origin.actor)) return false
|
||||
// Both tiers drive the shared Session; what the tier permits is decided per tool by the confined
|
||||
// layer, which refuses the write name for a view-tier turn.
|
||||
if (sessionId === share.sessionId) return relay.drivableModeOf(origin.shareId) !== undefined
|
||||
if (share.sessionIds.includes(sessionId)) return relay.drivableModeOf(origin.shareId) !== undefined
|
||||
const child = forks.find(sessionId)
|
||||
return child !== undefined && child.shareId === origin.shareId
|
||||
&& identityKeyOf(child.creator) === identityKeyOf(origin.actor)
|
||||
@@ -520,7 +520,8 @@ export async function apply(ctx: Context, config: Config): Promise<() => Promise
|
||||
for (const share of ownerShares.values()) tunnel.scheduleShareExpiry(share)
|
||||
// A host that starts with write-mode shares installs their access layers before serving any request for
|
||||
// them, so a restored share is never briefly writable without the layer that judges its turns.
|
||||
void Promise.all([...ownerShares.values()].map(share => sourceAccess.apply(share, undefined, 'restore')))
|
||||
void Promise.all([...ownerShares.values()].flatMap(share =>
|
||||
share.sessionIds.map(sessionId => sourceAccess.apply(share, sessionId, undefined, 'restore'))))
|
||||
// Reconnecting a share, refreshing its title, and probing or claiming at the Relay all need an address
|
||||
// the owner saved. A host that has none still serves the sharing surfaces and stops its shares' turns
|
||||
// on their deadlines, but contacts no Relay: there is no address to ask, and the dialog states that.
|
||||
@@ -570,6 +571,8 @@ export async function apply(ctx: Context, config: Config): Promise<() => Promise
|
||||
forks,
|
||||
viewerRead,
|
||||
offeredCapabilities,
|
||||
/* v8 ignore next -- the Host reads the registry lazily; the consuming operation is covered in owner-share-operations. */
|
||||
workspaces: () => ctx.get('workspaceRegistry')?.list(),
|
||||
relay,
|
||||
tunnel,
|
||||
effectiveRelay: () => effectiveRelay,
|
||||
@@ -578,7 +581,7 @@ export async function apply(ctx: Context, config: Config): Promise<() => Promise
|
||||
bumpRelayRevision: () => { relayRevision += 1 },
|
||||
storedCredential: () => storedCredential,
|
||||
persistOwnerState,
|
||||
applySourceAccess: (share, ready, trigger) => sourceAccess.apply(share, ready, trigger),
|
||||
applySourceAccess: (share, sessionId, ready, trigger) => sourceAccess.apply(share, sessionId, ready, trigger),
|
||||
sourceConfined,
|
||||
accessChanged,
|
||||
cancelShareTurn: (shareId) => { sourceAccess.cancelShareTurn(shareId) },
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
AddShareSessionsRequest,
|
||||
CreateShareRequest,
|
||||
CreatedShare,
|
||||
OwnerIdentityRequest,
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
ShareCapabilities,
|
||||
ShareCapabilityOffers,
|
||||
SharePermissions,
|
||||
ShareSessions,
|
||||
StopShareRequest,
|
||||
StopShareResult,
|
||||
TestPublishRequest,
|
||||
@@ -44,6 +46,13 @@ export interface OwnerShareOperations {
|
||||
*/
|
||||
create(request: CreateShareRequest): Promise<CreatedShare>
|
||||
|
||||
/**
|
||||
* Add source Sessions to one share this host publishes.
|
||||
* @param request - Share and the Sessions to add.
|
||||
* @returns The source Sessions the share now serves.
|
||||
*/
|
||||
addSessions(request: AddShareSessionsRequest): Promise<ShareSessions>
|
||||
|
||||
/**
|
||||
* Refresh titles and Relay authority, then list this host's shares.
|
||||
* @returns Every share this host publishes.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {
|
||||
AddShareSessionsRequest,
|
||||
CreateShareRequest,
|
||||
CreatedShare,
|
||||
OwnerIdentityRequest,
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
ShareCapabilities,
|
||||
ShareCapabilityOffers,
|
||||
SharePermissions,
|
||||
ShareSessions,
|
||||
StopShareRequest,
|
||||
StopShareResult,
|
||||
TestPublishRequest,
|
||||
@@ -56,6 +58,16 @@ export class LiveshareShares extends TypertRemoteService {
|
||||
@Remote('create')
|
||||
create(request: CreateShareRequest): Promise<CreatedShare> { return this.operations.create(request) }
|
||||
|
||||
/**
|
||||
* Add source Sessions to one share this host already publishes.
|
||||
* @param request - Share and the Sessions to add.
|
||||
* @returns The source Sessions the share now serves.
|
||||
*/
|
||||
@Remote('addSessions')
|
||||
addSessions(request: AddShareSessionsRequest): Promise<ShareSessions> {
|
||||
return this.operations.addSessions(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh titles and Liveshare server authority, then list this host's shares.
|
||||
* @returns Every share this host publishes.
|
||||
|
||||
@@ -27,6 +27,7 @@ import { isExpired, type ViewerSessionMode } from './fork-policy.ts'
|
||||
import { describeSharedTool, sharedToolProfile } from './shared-tools.ts'
|
||||
import type { ForkRegistry } from './fork-registry.ts'
|
||||
import type { RelayAuthority } from './relay-authority.ts'
|
||||
import { primarySessionIdOf } from './source-sessions.ts'
|
||||
|
||||
/** Owner share shape this module reads; the host owns the full record. */
|
||||
import type { OwnerShare } from './owner-types.ts'
|
||||
@@ -62,6 +63,7 @@ export type WriteAccessOutcome =
|
||||
/** Installs the access layer one share's source Session needs. */
|
||||
export type SourceAccessApplier = (
|
||||
share: OwnerShare,
|
||||
sessionId: string,
|
||||
ready: { readonly context: Context; readonly session: Session } | undefined,
|
||||
trigger: WriteAccessTrigger,
|
||||
) => Promise<WriteAccessOutcome>
|
||||
@@ -147,35 +149,40 @@ export interface SourceAccessDeps {
|
||||
/** The confined access layer and turn control one host's source Sessions carry. */
|
||||
export interface SourceAccess {
|
||||
/**
|
||||
* Install the access layer one share's source Session needs.
|
||||
* Install the access layer one source Session of a share needs.
|
||||
* @param share - Share the layer is being installed for.
|
||||
* @param sessionId - Source Session of that share the layer confines.
|
||||
* @param ready - The announced agent's own scope and session, when the caller has them.
|
||||
* @param trigger - Where this attempt came from, named in the diagnostic a refusal logs.
|
||||
* @returns What this attempt did.
|
||||
*/
|
||||
apply(
|
||||
share: OwnerShare,
|
||||
sessionId: string,
|
||||
ready?: { readonly context: Context; readonly session: Session },
|
||||
trigger?: WriteAccessTrigger,
|
||||
): Promise<WriteAccessOutcome>
|
||||
/**
|
||||
* Whether one share's source Session carries a usable confined access layer right now.
|
||||
* Whether one source Session of a share carries a usable confined access layer right now.
|
||||
* @param shareId - Share the request arrived through.
|
||||
* @param sessionId - Source Session asked about.
|
||||
* @returns True only while the layer is installed and confined.
|
||||
*/
|
||||
confined(shareId: string): boolean
|
||||
confined(shareId: string, sessionId: string): boolean
|
||||
/**
|
||||
* Why one share's source Session is not confined for driving, or undefined while it is.
|
||||
* Why one source Session of a share is not confined for driving, or undefined while it is.
|
||||
* @param shareId - Share the question is asked about.
|
||||
* @returns The refusal code, or undefined while the source Session is confined for this share.
|
||||
* @param sessionId - Source Session asked about.
|
||||
* @returns The refusal code, or undefined while that source Session is confined for this share.
|
||||
*/
|
||||
confinementRefusal(shareId: string): string | undefined
|
||||
confinementRefusal(shareId: string, sessionId: string): string | undefined
|
||||
/**
|
||||
* The recorded installation's own refusal code for one share's Session.
|
||||
* The recorded installation's own refusal code for one source Session.
|
||||
* @param shareId - Share whose source Session is asked about.
|
||||
* @param sessionId - Source Session asked about.
|
||||
* @returns The refusal, or undefined when none was recorded.
|
||||
*/
|
||||
refusalOf(shareId: string): string | undefined
|
||||
refusalOf(shareId: string, sessionId: string): string | undefined
|
||||
/**
|
||||
* Stop child work whose grant is being narrowed or removed.
|
||||
* @param shareId - Share whose children are stopped.
|
||||
@@ -244,7 +251,8 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
* @returns The published, unexpired shares over it, in insertion order.
|
||||
*/
|
||||
const activeSharesOf = (sessionId: string): OwnerShare[] =>
|
||||
[...ownerShares.values()].filter(share => share.sessionId === sessionId && !isExpired(share.expiresAt, Date.now()))
|
||||
[...ownerShares.values()].filter(share => share.sessionIds.includes(sessionId)
|
||||
&& !isExpired(share.expiresAt, Date.now()))
|
||||
|
||||
/**
|
||||
* Access layers this host installed, keyed by the SESSION they sandbox.
|
||||
@@ -276,7 +284,7 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
if (process.env.LIVESHARE_VIEWER_DEBUG === '1') {
|
||||
console.warn(
|
||||
`[liveshare] source access not installed trigger=${trigger} share=${share.shareId}`
|
||||
+ ` session=${share.sessionId} reason=${reason}`,
|
||||
+ ` session=${primarySessionIdOf(share)} reason=${reason}`,
|
||||
)
|
||||
}
|
||||
return { kind: 'refused', reason }
|
||||
@@ -337,6 +345,7 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
*/
|
||||
const applySourceAccess = async (
|
||||
share: OwnerShare,
|
||||
sessionId: string,
|
||||
ready?: { readonly context: Context; readonly session: Session },
|
||||
trigger: WriteAccessTrigger = 'prompt',
|
||||
): Promise<WriteAccessOutcome> => {
|
||||
@@ -344,20 +353,20 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
// Session share the one installation. An installation that could not confine anything is replaced
|
||||
// rather than kept, so a Session whose layer failed once — because it was not live yet, for instance
|
||||
// — can still get one.
|
||||
const wanted = sessionNeedsWrite(share.sessionId) ? 'write' : 'view'
|
||||
const active = activeSharesOf(share.sessionId)
|
||||
const recorded = sourceAccess.get(share.sessionId)
|
||||
if (active.length > 0 && sourceAccessUsable(share.sessionId, wanted) && recorded !== undefined) {
|
||||
const wanted = sessionNeedsWrite(sessionId) ? 'write' : 'view'
|
||||
const active = activeSharesOf(sessionId)
|
||||
const recorded = sourceAccess.get(sessionId)
|
||||
if (active.length > 0 && sourceAccessUsable(sessionId, wanted) && recorded !== undefined) {
|
||||
return { kind: 'kept', installation: recorded }
|
||||
}
|
||||
recorded?.dispose()
|
||||
sourceAccess.delete(share.sessionId)
|
||||
sourceAccess.delete(sessionId)
|
||||
// Every service is read through the context lookup: this bundle declares only `connection`, so a
|
||||
// property access for another service is a Cordis injection error at runtime, casts or not.
|
||||
const agents = deps.ctx.get('agents') as
|
||||
| { get?: (id: string) => { readonly ctx?: unknown; readonly session?: unknown } | undefined }
|
||||
| undefined
|
||||
const live = ready === undefined ? agents?.get?.(share.sessionId) : undefined
|
||||
const live = ready === undefined ? agents?.get?.(sessionId) : undefined
|
||||
const session = (ready?.session ?? live?.session) as Session | undefined
|
||||
const liveContext = (ready?.context ?? live?.ctx) as Context | undefined
|
||||
const fs = deps.ctx.get('fs') as ForkEnforcementFs | undefined
|
||||
@@ -388,23 +397,23 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
// for one of them left a write share with no layer at all, which no later path retried.
|
||||
const current = ownerShares.get(share.shareId)
|
||||
if (current === undefined) return refusedSourceAccess(share, trigger, 'share-gone')
|
||||
if (current.sessionId !== share.sessionId) return refusedSourceAccess(share, trigger, 'share-reassigned')
|
||||
if (!current.sessionIds.includes(sessionId)) return refusedSourceAccess(share, trigger, 'share-reassigned')
|
||||
// The Session still has to be shared at some tier, and the tier the caller asked for has to still be
|
||||
// one this Session needs: a share removed while the workspace resolved installs nothing.
|
||||
const needsWrite = sessionNeedsWrite(share.sessionId)
|
||||
const needsWrite = sessionNeedsWrite(sessionId)
|
||||
if (needsWrite !== (wanted === 'write')) return refusedSourceAccess(share, trigger, 'share-mode-changed')
|
||||
// Every share over the Session stopped or expired while the workspace resolved: nothing remains that
|
||||
// could drive it, so no layer is installed.
|
||||
if (activeSharesOf(share.sessionId).length === 0) return refusedSourceAccess(share, trigger, 'share-inactive')
|
||||
if (activeSharesOf(sessionId).length === 0) return refusedSourceAccess(share, trigger, 'share-inactive')
|
||||
// Another installation can finish while the workspace resolves. Publication below is synchronous.
|
||||
const concurrent = sourceAccess.get(share.sessionId)
|
||||
if (sourceAccessUsable(share.sessionId, wanted) && concurrent !== undefined) {
|
||||
const concurrent = sourceAccess.get(sessionId)
|
||||
if (sourceAccessUsable(sessionId, wanted) && concurrent !== undefined) {
|
||||
return { kind: 'kept', installation: concurrent }
|
||||
}
|
||||
concurrent?.dispose()
|
||||
sourceAccess.delete(share.sessionId)
|
||||
sourceAccess.delete(sessionId)
|
||||
const installation = installSourceSessionAccess({
|
||||
sessionId: share.sessionId,
|
||||
sessionId,
|
||||
context: liveContext,
|
||||
scope: scopeKey,
|
||||
grantedRoot: granted,
|
||||
@@ -417,20 +426,20 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
// The write replacement is registered once for the Session, whenever any share over it may write.
|
||||
// Whether writing is still allowed is read from the Session's shares when a write runs, so a share
|
||||
// that narrows to view stops writing on the layer that is already mounted.
|
||||
...(needsWrite ? { write: { mayWrite: () => sessionWritable(share.sessionId) } } : {}),
|
||||
...(needsWrite ? { write: { mayWrite: () => sessionWritable(sessionId) } } : {}),
|
||||
// Every operation re-reads the grants of the shares that keep this Session drivable, so an input
|
||||
// queued past the grant that admitted it cannot act on the stale admission.
|
||||
refreshAuthorization: async () => {
|
||||
for (const current of activeSharesOf(share.sessionId)) await relay.refreshRelayAccess(current.shareId)
|
||||
for (const current of activeSharesOf(sessionId)) await relay.refreshRelayAccess(current.shareId)
|
||||
},
|
||||
})
|
||||
// Recorded whether or not it is usable: the guard it installed still has to be released, and the
|
||||
// reader asks this record whether the source Session is confined before it drives any prompt.
|
||||
sourceAccess.set(share.sessionId, installation)
|
||||
sourceAccess.set(sessionId, installation)
|
||||
deps.accessChanged()
|
||||
liveContext.effect(() => () => {
|
||||
installation.dispose()
|
||||
if (sourceAccess.get(share.sessionId) === installation) sourceAccess.delete(share.sessionId)
|
||||
if (sourceAccess.get(sessionId) === installation) sourceAccess.delete(sessionId)
|
||||
deps.accessChanged()
|
||||
}, 'liveshare: source enforcement lifetime')
|
||||
const registrationFailure = installation.registrationFailure
|
||||
@@ -452,7 +461,7 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
} else if (process.env.LIVESHARE_VIEWER_DEBUG === '1') {
|
||||
console.warn(
|
||||
`[liveshare] source access installed trigger=${trigger} share=${share.shareId}`
|
||||
+ ` session=${share.sessionId} tier=${wanted}`,
|
||||
+ ` session=${sessionId} tier=${wanted}`,
|
||||
)
|
||||
}
|
||||
return { kind: 'installed', installation }
|
||||
@@ -464,10 +473,11 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
* A prompt that drives that Session is refused unless this holds, so a share whose layer could not be
|
||||
* installed — or whose Session is not live yet — is never driven against the owner's own tools.
|
||||
* @param shareId Share the request arrived through.
|
||||
* @param sessionId Source Session asked about.
|
||||
* @returns True only while the layer is installed and confined.
|
||||
*/
|
||||
function sourceConfined(shareId: string): boolean {
|
||||
return sourceConfinementRefusal(shareId) === undefined
|
||||
function sourceConfined(shareId: string, sessionId: string): boolean {
|
||||
return sourceConfinementRefusal(shareId, sessionId) === undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,9 +487,10 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
* carried no layer instead of only that it did not. Both tiers drive the Session, so both need the
|
||||
* confined read; only the write tier needs the confined write beside it.
|
||||
* @param shareId - Share the question is asked about.
|
||||
* @returns The refusal code, or undefined while the source Session is confined for this share.
|
||||
* @param sessionId - Source Session asked about.
|
||||
* @returns The refusal code, or undefined while that source Session is confined for this share.
|
||||
*/
|
||||
function sourceConfinementRefusal(shareId: string): string | undefined {
|
||||
function sourceConfinementRefusal(shareId: string, sessionId: string): string | undefined {
|
||||
const share = ownerShares.get(shareId)
|
||||
if (share === undefined) return 'share-unknown'
|
||||
// The share's own answer is part of it: a share whose deadline passed is not confined for driving,
|
||||
@@ -487,18 +498,18 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
// which one the share carries and drives nothing.
|
||||
const mode = relay.drivableModeOf(shareId)
|
||||
if (mode === undefined) return 'share-mode-unknown'
|
||||
const access = sourceAccess.get(share.sessionId)
|
||||
const access = sourceAccess.get(sessionId)
|
||||
if (access === undefined) return 'layer-absent'
|
||||
if (!access.readConfined) return 'layer-read-unconfined'
|
||||
if (mode === 'write' && !access.writeConfined) return 'layer-write-unconfined'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The recorded installation's own refusal code for one share's Session. */
|
||||
function refusalOf(shareId: string): string | undefined {
|
||||
/** The recorded installation's own refusal code for one source Session. */
|
||||
function refusalOf(shareId: string, sessionId: string): string | undefined {
|
||||
const share = ownerShares.get(shareId)
|
||||
if (share === undefined) return undefined
|
||||
return sourceAccess.get(share.sessionId)?.refusal
|
||||
return sourceAccess.get(sessionId)?.refusal
|
||||
}
|
||||
|
||||
/** Stop child work whose grant is being narrowed or removed. */
|
||||
@@ -540,10 +551,12 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
const agents = deps.ctx.get('agents') as
|
||||
| { get?: (id: string) => LiveAgent | undefined }
|
||||
| undefined
|
||||
const live = agents?.get?.(share.sessionId)
|
||||
if (live === undefined || live.status !== 'running') return
|
||||
console.warn(`[liveshare] cancelling source turn share=${shareId}`)
|
||||
live.cancel?.({ kind: 'user' }, { keepInbox: true })
|
||||
for (const sessionId of share.sessionIds) {
|
||||
const live = agents?.get?.(sessionId)
|
||||
if (live === undefined || live.status !== 'running') continue
|
||||
console.warn(`[liveshare] cancelling source turn share=${shareId} session=${sessionId}`)
|
||||
live.cancel?.({ kind: 'user' }, { keepInbox: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -570,7 +583,7 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
if (sourceAccessUsable(sessionId, wanted)) return
|
||||
// Any share over the Session installs what the whole Session needs: the installation reads the tier
|
||||
// from the shares, not from the record it was asked with.
|
||||
void applySourceAccess(target, undefined, 'mode')
|
||||
void applySourceAccess(target, sessionId, undefined, 'mode')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -591,10 +604,10 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
// The share's own record says whether a layer is needed; the Relay's narrowing tier only says whether
|
||||
// one may already be driven. Requiring the narrowing answer here would refuse to prepare exactly the
|
||||
// share whose Relay answer has not arrived yet, which is the one a reader is waiting on.
|
||||
const share = [...ownerShares.values()].find(candidate => candidate.sessionId === sessionId)
|
||||
const share = [...ownerShares.values()].find(candidate => candidate.sessionIds.includes(sessionId))
|
||||
if (share === undefined) return
|
||||
if (sourceAccessUsable(sessionId, share.sessionMode)) return
|
||||
await applySourceAccess(share, undefined, trigger)
|
||||
await applySourceAccess(share, sessionId, undefined, trigger)
|
||||
}
|
||||
|
||||
/** One preparation in flight per Session, so repeated reader requests wait on the same activation. */
|
||||
@@ -619,7 +632,7 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
// Nothing to prepare while the layer is usable. Otherwise the share's own record says whether one is
|
||||
// needed, because the Relay's narrowing tier may not have arrived yet: a Reader request must not be
|
||||
// the one that waits for it, and every reason this host declines to prepare is worth naming.
|
||||
const confinement = sourceConfinementRefusal(shareId)
|
||||
const confinement = sourceConfinementRefusal(shareId, primarySessionIdOf(share))
|
||||
if (confinement === undefined) return
|
||||
if (confinement !== 'layer-absent' && confinement !== 'layer-read-unconfined' && confinement !== 'layer-write-unconfined') {
|
||||
// A tier the Relay has not stated is still a layer this host has to be ready with, because the
|
||||
@@ -629,22 +642,23 @@ export function createSourceAccess(deps: SourceAccessDeps): SourceAccess {
|
||||
if (process.env.LIVESHARE_VIEWER_DEBUG === '1') {
|
||||
console.warn(
|
||||
`[liveshare] source not prepared trigger=reader share=${shareId}`
|
||||
+ ` session=${share.sessionId} reason=${confinement}`,
|
||||
+ ` session=${primarySessionIdOf(share)} reason=${confinement}`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if (preparingSources.has(share.sessionId)) return
|
||||
const started = prepareSourceSession(share.sessionId, 'reader')
|
||||
const primary = primarySessionIdOf(share)
|
||||
if (preparingSources.has(primary)) return
|
||||
const started = prepareSourceSession(primary, 'reader')
|
||||
.catch((error: unknown) => {
|
||||
// A failed preparation changes nothing: the share stays refused and the reader keeps reading.
|
||||
console.warn(
|
||||
`[liveshare] preparing the source failed share=${shareId}: ${errorMessage(error)}`,
|
||||
)
|
||||
})
|
||||
.finally(() => { preparingSources.delete(share.sessionId) })
|
||||
preparingSources.set(share.sessionId, started)
|
||||
.finally(() => { preparingSources.delete(primary) })
|
||||
preparingSources.set(primary, started)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The source-Session list shared by share records and fork state.
|
||||
*
|
||||
* A share carries one or more source Sessions, primary first. Code that needs the single Session a
|
||||
* wire field or a page target names reads {@link primarySessionIdOf} instead of keeping a second copy
|
||||
* of that id on the record.
|
||||
* @module @deepseek-ai/dsh-experimental-liveshare/src/host/source-sessions
|
||||
*/
|
||||
|
||||
/** Anything that carries a share's source Session ids, primary first. */
|
||||
export interface SourceSessionIds {
|
||||
readonly sessionIds: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the primary source Session of a share.
|
||||
* @param share - Record whose source list is nonempty.
|
||||
* @returns The first source Session id.
|
||||
*/
|
||||
export function primarySessionIdOf(share: SourceSessionIds): string {
|
||||
const [primary] = share.sessionIds
|
||||
/* v8 ignore next 3 -- every stored and constructed share carries at least one source. */
|
||||
if (primary === undefined) {
|
||||
throw new Error('a share must carry at least one source Session')
|
||||
}
|
||||
return primary
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { relayRefusalStatus } from '../owner-gateway/relay-refusal.ts'
|
||||
import type { RelayConnectivityView, RelayPublishView } from '../share-api-types.ts'
|
||||
import { parseViewerRequest } from '../viewer-channel.ts'
|
||||
import type { ViewerSessionMode } from './fork-policy.ts'
|
||||
import { primarySessionIdOf } from './source-sessions.ts'
|
||||
import type { PendingRelayRemoval } from './owner-persistence.ts'
|
||||
import {
|
||||
ownerCredentialState,
|
||||
@@ -56,7 +57,7 @@ interface PendingShare {
|
||||
/** Fields one gateway registration needs from a share, before it is stored as an OwnerShare. */
|
||||
type ShareRegistrationSource = Pick<
|
||||
OwnerShare,
|
||||
'shareId' | 'sessionId' | 'sessionTitle' | 'accessMode' | 'relay' | 'gatewayToken' | 'allowedSubjectIds'
|
||||
'shareId' | 'sessionIds' | 'sessionTitle' | 'accessMode' | 'relay' | 'gatewayToken' | 'allowedSubjectIds'
|
||||
> & {
|
||||
readonly expiresAt?: string
|
||||
readonly allowsFork: boolean
|
||||
@@ -438,17 +439,17 @@ export function createTunnelSupervision(deps: TunnelSupervisionDeps): TunnelSupe
|
||||
expiryTimers.delete(share.shareId)
|
||||
const at = share.expiresAt === undefined ? undefined : Date.parse(share.expiresAt)
|
||||
if (at === undefined || !Number.isFinite(at)) return
|
||||
armShareExpiry(share.shareId, share.sessionId, at - Date.now())
|
||||
armShareExpiry(share.shareId, share.sessionIds, at - Date.now())
|
||||
}
|
||||
|
||||
/** One wait for a deadline, re-armed when the deadline is further away than a single timeout may wait. */
|
||||
const armShareExpiry = (shareId: string, sessionId: string, delayMs: number): void => {
|
||||
const armShareExpiry = (shareId: string, sessionIds: readonly string[], delayMs: number): void => {
|
||||
const timer = setTimeout(() => {
|
||||
expiryTimers.delete(shareId)
|
||||
const at = deps.ownerShares.get(shareId)?.expiresAt
|
||||
const deadline = at === undefined ? undefined : Date.parse(at)
|
||||
if (deadline !== undefined && Number.isFinite(deadline) && deadline > Date.now()) {
|
||||
armShareExpiry(shareId, sessionId, deadline - Date.now())
|
||||
armShareExpiry(shareId, sessionIds, deadline - Date.now())
|
||||
return
|
||||
}
|
||||
// The deadline passed, so this share can no longer drive anything: its running turn is stopped and
|
||||
@@ -458,7 +459,9 @@ export function createTunnelSupervision(deps: TunnelSupervisionDeps): TunnelSupe
|
||||
deps.cancelChildTurns(shareId)
|
||||
deps.viewerRead().closeShare(shareId)
|
||||
// A Session whose shares have all passed their deadlines gets its own tools back.
|
||||
deps.reconcileSourceAccess(sessionId)
|
||||
for (const sessionId of deps.ownerShares.get(shareId)?.sessionIds ?? sessionIds) {
|
||||
deps.reconcileSourceAccess(sessionId)
|
||||
}
|
||||
deps.accessChanged()
|
||||
}, Math.max(0, Math.min(delayMs, MAX_EXPIRY_TIMER_MS)))
|
||||
// An armed deadline is not work the process owes anyone: it must never hold a host open by itself.
|
||||
@@ -551,7 +554,7 @@ export function createTunnelSupervision(deps: TunnelSupervisionDeps): TunnelSupe
|
||||
const titles = deps.ctx.get('sessionTitle') as SessionTitleLookup | undefined
|
||||
if (sessions === undefined || titles === undefined) return
|
||||
for (const share of [...deps.ownerShares.values()]) {
|
||||
const session = sessions.get(share.sessionId)
|
||||
const session = sessions.get(primarySessionIdOf(share))
|
||||
if (session === undefined) continue
|
||||
const snapshot = titles.get(session)
|
||||
const title = refreshedShareTitle(share.sessionTitle, snapshot?.title)
|
||||
@@ -604,7 +607,7 @@ export function createTunnelSupervision(deps: TunnelSupervisionDeps): TunnelSupe
|
||||
const ownerCredential = registrableCredentialForRelay(share.relay, credential)
|
||||
return {
|
||||
shareId: share.shareId,
|
||||
sessionId: share.sessionId,
|
||||
sessionId: primarySessionIdOf(share),
|
||||
sessionTitle: share.sessionTitle,
|
||||
// A share loaded from a state file written before the allowlist mode was renamed keeps that
|
||||
// mode: the stored ACL still decides who may read the share, and the Relay accepts the name.
|
||||
|
||||
@@ -65,11 +65,12 @@ export interface ViewerAccessFacts {
|
||||
*/
|
||||
readonly viewerAdmitted: (shareId: string, actor: ShareRpcActor) => boolean
|
||||
/**
|
||||
* Whether the share's source Session carries a usable confined access layer.
|
||||
* Whether one source Session of the share carries a usable confined access layer.
|
||||
* @param shareId - Share whose source Session is asked about.
|
||||
* @param sessionId - Source Session asked about.
|
||||
* @returns True only while the layer is installed and confined.
|
||||
*/
|
||||
readonly sourceConfined: (shareId: string) => boolean
|
||||
readonly sourceConfined: (shareId: string, sessionId: string) => boolean
|
||||
/** Forks this host created, and the per-child prompt decision. */
|
||||
readonly forks: Pick<ForkRegistry, 'find' | 'listForShare' | 'allowsPrompt'>
|
||||
/**
|
||||
@@ -405,8 +406,8 @@ export function readerWriteRefusal(
|
||||
const serving = servingShare(facts, viewer.shareId, viewer.actor)
|
||||
if ('refusal' in serving) return serving.refusal
|
||||
const mode = drivableMode(serving.share)
|
||||
if (sessionId === serving.share.sessionId) {
|
||||
return sourceWriteRefusal(mode, facts.sourceConfined(viewer.shareId))
|
||||
if (serving.share.sessionIds.includes(sessionId)) {
|
||||
return sourceWriteRefusal(mode, facts.sourceConfined(viewer.shareId, sessionId))
|
||||
}
|
||||
const child = ownFork(facts, viewer, sessionId)
|
||||
if (child === undefined) return foreignWriteRefusal(facts, viewer, sessionId)
|
||||
@@ -428,7 +429,7 @@ function explainReaderRefusal(facts: ViewerAccessFacts, request: RemoteAccessReq
|
||||
const serving = servingShare(facts, viewer.shareId, viewer.actor)
|
||||
if ('refusal' in serving) return serving.refusal
|
||||
for (const resource of request.resources) {
|
||||
if (resource.kind !== 'session' || resource.id === serving.share.sessionId) continue
|
||||
if (resource.kind !== 'session' || serving.share.sessionIds.includes(resource.id)) continue
|
||||
if (request.action === 'session/fork') return 'source-session-mismatch'
|
||||
if (request.action === 'session/prompt' || request.action === 'session/cancel') {
|
||||
return foreignWriteRefusal(facts, viewer, resource.id)
|
||||
@@ -449,27 +450,31 @@ export function createViewerAccessPolicy(facts: ViewerAccessFacts): RemoteAccess
|
||||
if ('refusal' in serving) return []
|
||||
const { share } = serving
|
||||
const mode = drivableMode(share)
|
||||
const source = SessionId(share.sessionId)
|
||||
const sources = share.sessionIds.map(id => SessionId(id))
|
||||
// Revoked forks stay bound for reading, so the reader's snapshot states them as read-only.
|
||||
const children = facts.forks.listForShare(viewer.shareId)
|
||||
.filter(child => identityKeyOf(child.creator) === identityKeyOf(viewer.actor))
|
||||
const visible = new Set<string>([share.sessionId, ...children.map(child => child.childSessionId)])
|
||||
const visible = new Set<string>([...share.sessionIds, ...children.map(child => child.childSessionId)])
|
||||
/** Workspaces holding a Session the reader can see; only those may carry a declaration. */
|
||||
const memberWorkspaces: Workspace['id'][] = []
|
||||
const bindings: RemoteAccessBinding[] = [
|
||||
{ role: VIEWER_GLOBAL_ROLE, resource: { kind: 'global' } },
|
||||
{ role: VIEWER_SESSION_READ_ROLE, resource: { kind: 'session', id: source } },
|
||||
{
|
||||
role: VIEWER_FORK_ROLE,
|
||||
resource: { kind: 'session', id: source },
|
||||
condition: () => answer(share.allowsFork ? undefined : 'fork-not-permitted'),
|
||||
},
|
||||
{
|
||||
role: VIEWER_SOURCE_WRITE_ROLE,
|
||||
resource: { kind: 'session', id: source },
|
||||
condition: () => answer(sourceWriteRefusal(mode, facts.sourceConfined(viewer.shareId))),
|
||||
},
|
||||
]
|
||||
for (const source of sources) {
|
||||
bindings.push(
|
||||
{ role: VIEWER_SESSION_READ_ROLE, resource: { kind: 'session', id: source } },
|
||||
{
|
||||
role: VIEWER_FORK_ROLE,
|
||||
resource: { kind: 'session', id: source },
|
||||
condition: () => answer(share.allowsFork ? undefined : 'fork-not-permitted'),
|
||||
},
|
||||
{
|
||||
role: VIEWER_SOURCE_WRITE_ROLE,
|
||||
resource: { kind: 'session', id: source },
|
||||
condition: () => answer(sourceWriteRefusal(mode, facts.sourceConfined(viewer.shareId, source))),
|
||||
},
|
||||
)
|
||||
}
|
||||
for (const child of children) {
|
||||
const id = SessionId(child.childSessionId)
|
||||
bindings.push(
|
||||
@@ -512,7 +517,7 @@ export function createViewerAccessPolicy(facts: ViewerAccessFacts): RemoteAccess
|
||||
bindings.push(
|
||||
{ role: declarationRole('liveshare:viewer-method-global', 'global', exposed), resource: { kind: 'global' } },
|
||||
)
|
||||
for (const id of [source, ...children.map(child => SessionId(child.childSessionId))]) {
|
||||
for (const id of [...sources, ...children.map(child => SessionId(child.childSessionId))]) {
|
||||
bindings.push({ role: declarationRole('liveshare:viewer-method-session', 'session', exposed),
|
||||
resource: { kind: 'session', id } })
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ export function createViewerDispatch(input: ViewerDispatchDeps): ViewerDispatch
|
||||
const deps = input.deps
|
||||
const fork = input.fork
|
||||
const lifecycle = input.lifecycle
|
||||
const sessionOf = (shareId: string): string | undefined => deps.sharedSessionId(shareId)
|
||||
const sessionsOf = (shareId: string): readonly string[] | undefined => deps.sharedSessionIds(shareId)
|
||||
|
||||
|
||||
const dispatch = async (
|
||||
@@ -195,10 +195,9 @@ export function createViewerDispatch(input: ViewerDispatchDeps): ViewerDispatch
|
||||
path: string,
|
||||
body: unknown,
|
||||
actor: ShareRpcActor,
|
||||
source: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ViewerAnswer> => {
|
||||
const source = sessionOf(shareId)
|
||||
if (source === undefined) return { status: 403, body: { error: 'share-not-found' } }
|
||||
const refusedBefore = lifecycle.shareRefusal(shareId, actor)
|
||||
if (refusedBefore !== undefined) return refusedBefore
|
||||
const before = lifecycle.accessRefusal(shareId, actor, action, source, true)
|
||||
@@ -231,9 +230,9 @@ export function createViewerDispatch(input: ViewerDispatchDeps): ViewerDispatch
|
||||
// field is a path or a credential.
|
||||
prepared,
|
||||
session: 'known',
|
||||
...(deps.sourceRefusal?.(shareId) === undefined
|
||||
...(deps.sourceRefusal?.(shareId, source) === undefined
|
||||
? {}
|
||||
: { reason: deps.sourceRefusal(shareId) }),
|
||||
: { reason: deps.sourceRefusal(shareId, source) }),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -333,15 +332,17 @@ export function createViewerDispatch(input: ViewerDispatchDeps): ViewerDispatch
|
||||
// boundary need state a synchronous policy does not read.
|
||||
if (endpoint === 'session/fork') return fork().answerFork(shareId, target, body, actor, signal)
|
||||
if (endpoint === 'session/prompt' || endpoint === 'session/cancel') {
|
||||
// A request naming the share's own source Session is decided by the source path whatever the mode,
|
||||
// so its refusal names the share's rule rather than the fork registry's "not a fork of a share".
|
||||
const source = sessionOf(shareId)
|
||||
if (source !== undefined && requestedSessionId(body) === source) {
|
||||
return answerSourceWrite(shareId, endpoint, target, body, actor, signal)
|
||||
// A request naming one of the share's own source Sessions is decided by the source path whatever the
|
||||
// mode, so its refusal names the share's rule rather than the fork registry's "not a fork of a share".
|
||||
const sources = sessionsOf(shareId)
|
||||
if (sources === undefined) return { status: 403, body: { error: 'share-not-found' } }
|
||||
const requested = requestedSessionId(body)
|
||||
if (requested !== undefined && sources.includes(requested)) {
|
||||
return answerSourceWrite(shareId, endpoint, target, body, actor, requested, signal)
|
||||
}
|
||||
return answerChildWrite(shareId, endpoint, target, body, actor, signal)
|
||||
}
|
||||
if (sessionOf(shareId) === undefined) return { status: 404, body: { error: 'share-not-found' } }
|
||||
if (sessionsOf(shareId) === undefined) return { status: 404, body: { error: 'share-not-found' } }
|
||||
// A share this host still holds is not the same as a share that may still serve: its deadline, a stop,
|
||||
// and a grant that ended are decided here, before the owner's gateway reads anything for this viewer.
|
||||
const refused = lifecycle.shareRefusal(shareId, actor)
|
||||
@@ -366,7 +367,7 @@ export function createViewerDispatch(input: ViewerDispatchDeps): ViewerDispatch
|
||||
actor: ShareRpcActor,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ViewerAnswer> => {
|
||||
if (sessionOf(shareId) === undefined) return { status: 404, body: { error: 'share-not-found' } }
|
||||
if (sessionsOf(shareId) === undefined) return { status: 404, body: { error: 'share-not-found' } }
|
||||
// Refused before the opener runs, so a share that may not serve this viewer costs no upstream
|
||||
// subscription — and a stream that can never be read is never opened in the first place.
|
||||
const refused = lifecycle.shareRefusal(shareId, actor)
|
||||
|
||||
@@ -45,11 +45,11 @@ export interface ViewerReadDeps {
|
||||
/** Typert gateway; absent in profiles without the api gateway. */
|
||||
readonly gateway?: ViewerReadGateway
|
||||
/**
|
||||
* Session the named share currently points at.
|
||||
* Source Sessions the named share currently serves.
|
||||
* @param shareId - Share the Relay addressed.
|
||||
* @returns The shared Session id, or undefined when the share is gone.
|
||||
* @returns The source Session ids, primary first, or undefined when the share is gone.
|
||||
*/
|
||||
readonly sharedSessionId: (shareId: string) => string | undefined
|
||||
readonly sharedSessionIds: (shareId: string) => readonly string[] | undefined
|
||||
/** Forked children this Host created, so a viewer's list can show exactly its own. */
|
||||
readonly forks?: ForkRegistry
|
||||
/**
|
||||
@@ -104,14 +104,14 @@ export interface ViewerReadDeps {
|
||||
* A `write` share drives that Session with the viewer's input, so the prompt is refused unless the host
|
||||
* installed the layer that confines it. Absent means no layer this host can vouch for.
|
||||
*/
|
||||
readonly sourceConfined?: (shareId: string) => boolean
|
||||
readonly sourceConfined?: (shareId: string, sessionId: string) => boolean
|
||||
/**
|
||||
* Why the share's source Session carries no usable layer, when the host recorded a reason.
|
||||
*
|
||||
* Diagnostics for the refusal's body: a capability the backend lacks reads differently from a layer that
|
||||
* was never attempted, and the owner or an integrator needs to tell those apart without a debugger.
|
||||
*/
|
||||
readonly sourceRefusal?: (shareId: string) => string | undefined
|
||||
readonly sourceRefusal?: (shareId: string, sessionId: string) => string | undefined
|
||||
/**
|
||||
* Resolve or resume one Session through the owner's own service, without executing anything.
|
||||
*
|
||||
|
||||
@@ -101,7 +101,10 @@ export interface ViewerPluginPolicy {
|
||||
/** One owner-created share as the management panel lists it. */
|
||||
export interface OwnerShareView {
|
||||
readonly shareId: string
|
||||
/** Primary source Session, for callers that predate `sessionIds`; derived from its first entry. */
|
||||
readonly sessionId: string
|
||||
/** Every source Session this share serves, primary first; the authoritative list. */
|
||||
readonly sessionIds: readonly string[]
|
||||
readonly sessionTitle: string
|
||||
readonly link: string
|
||||
readonly createdAt: string
|
||||
@@ -149,11 +152,27 @@ export interface CreateShareRequest {
|
||||
export interface CreatedShare {
|
||||
readonly link: string
|
||||
readonly shareId: string
|
||||
/** Primary source Session, for callers that predate `sessionIds`; derived from its first entry. */
|
||||
readonly sessionId: string
|
||||
/** Every source Session this share serves, primary first; the authoritative list. */
|
||||
readonly sessionIds: readonly string[]
|
||||
readonly sessionMode: 'view' | 'write'
|
||||
readonly access: ShareAccess
|
||||
}
|
||||
|
||||
/** One request to add source Sessions to an existing share. */
|
||||
export interface AddShareSessionsRequest {
|
||||
readonly shareId: string
|
||||
/** Sessions to add; ids already served by the share are ignored. */
|
||||
readonly sessionIds: readonly string[]
|
||||
}
|
||||
|
||||
/** The source Sessions one share serves after an addition. */
|
||||
export interface ShareSessions {
|
||||
readonly shareId: string
|
||||
readonly sessionIds: readonly string[]
|
||||
}
|
||||
|
||||
/** One stop request. */
|
||||
export interface StopShareRequest {
|
||||
readonly shareId: string
|
||||
@@ -280,6 +299,17 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
'liveshareShares/share-not-found': { readonly shareId: string }
|
||||
/** A share-writing request named no share. */
|
||||
'liveshareShares/missing-share-id': Record<string, never>
|
||||
/** An addition named no usable Session id. */
|
||||
'liveshareShares/missing-session-ids': Record<string, never>
|
||||
/** The composition mounts no Workspace registry, so source placement cannot be checked. */
|
||||
'liveshareShares/workspace-unavailable': Record<string, never>
|
||||
/** A source Session belongs to no Workspace. */
|
||||
'liveshareShares/session-workspace-unknown': { readonly sessionId: string }
|
||||
/** A source Session belongs to a different Workspace than the share's other sources. */
|
||||
'liveshareShares/session-workspace-mismatch': {
|
||||
readonly sessionId: string
|
||||
readonly workspaceId: string
|
||||
}
|
||||
/** A creation or publish check named no Session. */
|
||||
'liveshareShares/missing-session-id': Record<string, never>
|
||||
/** A permission update named neither the tier nor the fork switch. */
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('owner LIVESHARE_DEBUG_READS switch', () => {
|
||||
const read = createViewerReadService({
|
||||
connection: readerConnection().connection,
|
||||
gateway: singleValueGateway(),
|
||||
sharedSessionId: () => 'session-1',
|
||||
sharedSessionIds: () => ['session-1'],
|
||||
})
|
||||
try {
|
||||
const opened = await read.answer(SHARE, {
|
||||
|
||||
@@ -18,6 +18,16 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('owner share Remote', () => {
|
||||
it('delegates an addition to the operations implementation', async () => {
|
||||
const addSessions = vi.fn(async () => ({ shareId: 'share-one', sessionIds: ['session-two'] }))
|
||||
ctx = new Context()
|
||||
const remote = new LiveshareShares(ctx, { addSessions } as never)
|
||||
|
||||
await expect(remote.addSessions({ shareId: 'share-one', sessionIds: ['session-two'] }))
|
||||
.resolves.toEqual({ shareId: 'share-one', sessionIds: ['session-two'] })
|
||||
expect(addSessions).toHaveBeenCalledWith({ shareId: 'share-one', sessionIds: ['session-two'] })
|
||||
})
|
||||
|
||||
it('reads one share’s Relay audit trail through the operations implementation', async () => {
|
||||
const audit = vi.fn(async () => ({ events: [] }))
|
||||
ctx = new Context()
|
||||
|
||||
@@ -20,11 +20,11 @@ const translate: LiveshareManageSharesDialogProps['t'] = (key, params) => {
|
||||
|
||||
const ROWS = [
|
||||
{
|
||||
shareId: 'one', sessionId: 'session-one', sessionTitle: 'First session', link: 'https://relay.test/?shareId=one',
|
||||
shareId: 'one', sessionIds: ['session-one'], sessionTitle: 'First session', link: 'https://relay.test/?shareId=one',
|
||||
createdAt: '2026-09-05T00:00:00Z', online: true,
|
||||
},
|
||||
{
|
||||
shareId: 'two', sessionId: 'session-two', sessionTitle: 'Second session', link: 'https://relay.test/?shareId=two',
|
||||
shareId: 'two', sessionIds: ['session-two'], sessionTitle: 'Second session', link: 'https://relay.test/?shareId=two',
|
||||
createdAt: '2026-09-05T00:00:00Z', online: false,
|
||||
},
|
||||
]
|
||||
@@ -64,6 +64,33 @@ describe('Liveshare global share manager', () => {
|
||||
expect(opened).toEqual(['session-one:First session'])
|
||||
})
|
||||
|
||||
it('opens a share from a Host that reports only sessionId', async () => {
|
||||
const opened: string[] = []
|
||||
await showManager(
|
||||
async () => accepted({
|
||||
shares: [{ ...ROWS[0], sessionIds: undefined, sessionId: 'session-one' }] as never,
|
||||
}),
|
||||
(sessionId, title) => { opened.push(`${sessionId}:${title}`) },
|
||||
)
|
||||
const opens = screen.getAllByRole('button', { name: 'Manage' })
|
||||
await act(async () => { fireEvent.click(opens[0]!) })
|
||||
expect(opened).toEqual(['session-one:First session'])
|
||||
})
|
||||
|
||||
it('disables opening a share neither session field names', async () => {
|
||||
const opened: string[] = []
|
||||
await showManager(
|
||||
async () => accepted({
|
||||
shares: [{ ...ROWS[0], sessionIds: undefined, sessionId: undefined }] as never,
|
||||
}),
|
||||
(sessionId) => { opened.push(sessionId) },
|
||||
)
|
||||
const [open] = screen.getAllByRole('button', { name: 'Manage' })
|
||||
expect(open).toHaveProperty('disabled', true)
|
||||
await act(async () => { fireEvent.click(open!) })
|
||||
expect(opened).toEqual([])
|
||||
})
|
||||
|
||||
it('stops one share from its own row after the owner acknowledges the link loss', async () => {
|
||||
const host = await showManager(async () => accepted({ shares: ROWS as never }), undefined, () => {}, {
|
||||
stop: async () => accepted({ ok: true }),
|
||||
|
||||
@@ -22,7 +22,7 @@ function file(): string {
|
||||
/** One persisted share with every optional fact stated. */
|
||||
const share: OwnerShare = {
|
||||
shareId: 'share-one',
|
||||
sessionId: 'session-one',
|
||||
sessionIds: ['session-one'],
|
||||
sessionTitle: 'Review the migration',
|
||||
accessMode: 'selected',
|
||||
link: 'https://relay.example/?shareId=share-one',
|
||||
@@ -66,7 +66,7 @@ describe('owner state round trip', () => {
|
||||
])
|
||||
expect(loaded.shares.get('share-one')).toMatchObject({
|
||||
shareId: 'share-one',
|
||||
sessionId: 'session-one',
|
||||
sessionIds: ['session-one'],
|
||||
// The retired `approval` spelling reads back as the mode this build uses.
|
||||
accessMode: 'selected',
|
||||
link: 'https://relay.example/?shareId=share-one',
|
||||
@@ -137,6 +137,21 @@ describe('owner state round trip', () => {
|
||||
expect(loadOwnerState(path).shares.get('share-one')?.viewerPluginAccess).toBe('custom')
|
||||
})
|
||||
|
||||
it('restores every source Session a share carries', async () => {
|
||||
const path = file()
|
||||
const multi: OwnerShare = { ...share, sessionIds: ['session-one', 'session-two'] }
|
||||
await writeOwnerState(path, undefined, 'instance-one', new Map([[multi.shareId, multi]]), new Map())
|
||||
expect(loadOwnerState(path).shares.get(multi.shareId)?.sessionIds)
|
||||
.toEqual(['session-one', 'session-two'])
|
||||
})
|
||||
|
||||
it('reads a record written before a share could carry several sources', async () => {
|
||||
const path = file()
|
||||
const { sessionIds: _sessionIds, ...legacy } = share
|
||||
writeFileSync(path, JSON.stringify({ instanceId: 'instance-one', shares: [{ ...legacy, sessionId: 'session-one' }] }))
|
||||
expect(loadOwnerState(path).shares.get(share.shareId)?.sessionIds).toEqual(['session-one'])
|
||||
})
|
||||
|
||||
it('writes a share that states no optional fact as the plain record it is', async () => {
|
||||
const path = file()
|
||||
const { access: _access, expiresAt: _expiresAt, viewerPlugins: _plugins, ...minimal } = share
|
||||
|
||||
@@ -15,7 +15,7 @@ const RELAY = 'https://relay.example.test/liveshare'
|
||||
function storedShare(overrides: Partial<OwnerShare> = {}): OwnerShare {
|
||||
return {
|
||||
shareId: 'share-1',
|
||||
sessionId: 'session-1',
|
||||
sessionIds: ['session-1'],
|
||||
sessionTitle: 'Session',
|
||||
accessMode: 'open',
|
||||
link: `${RELAY}/?shareId=share-1`,
|
||||
@@ -56,6 +56,7 @@ interface Harness {
|
||||
readonly viewerRead: { closeShare: ReturnType<typeof vi.fn>; cancelPendingCalls: ReturnType<typeof vi.fn> }
|
||||
readonly forks: { revokeShare: ReturnType<typeof vi.fn> }
|
||||
readonly offeredCapabilities: ReturnType<typeof vi.fn>
|
||||
readonly workspaces: ReturnType<typeof vi.fn>
|
||||
readonly persistOwnerState: ReturnType<typeof vi.fn>
|
||||
readonly applySourceAccess: ReturnType<typeof vi.fn>
|
||||
readonly accessChanged: ReturnType<typeof vi.fn>
|
||||
@@ -94,6 +95,9 @@ function harness(): Harness {
|
||||
const viewerRead = { closeShare: vi.fn(), cancelPendingCalls: vi.fn() }
|
||||
const forks = { revokeShare: vi.fn(async () => {}) }
|
||||
const offeredCapabilities = vi.fn(() => [])
|
||||
const workspaces = vi.fn(() => [
|
||||
{ id: 'workspace-1', sessionIds: ['session-1', 'session-2'] },
|
||||
])
|
||||
const persistOwnerState = vi.fn(async () => {})
|
||||
const applySourceAccess = vi.fn(async () => {})
|
||||
const accessChanged = vi.fn()
|
||||
@@ -112,6 +116,7 @@ function harness(): Harness {
|
||||
forks,
|
||||
viewerRead,
|
||||
offeredCapabilities,
|
||||
workspaces,
|
||||
relay,
|
||||
tunnel,
|
||||
effectiveRelay,
|
||||
@@ -128,7 +133,7 @@ function harness(): Harness {
|
||||
reconcileSourceAccess,
|
||||
} as never)
|
||||
return {
|
||||
operations, ownerShares, pendingRemovals, tunnel, relay, viewerRead, forks, offeredCapabilities,
|
||||
operations, ownerShares, pendingRemovals, tunnel, relay, viewerRead, forks, offeredCapabilities, workspaces,
|
||||
persistOwnerState, applySourceAccess, accessChanged, cancelShareTurn, cancelChildTurns, reconcileSourceAccess,
|
||||
selectRelayAddress, bumpRelayRevision, sourceConfined, storedCredential, effectiveRelay,
|
||||
}
|
||||
@@ -149,11 +154,11 @@ describe('creating a share', () => {
|
||||
allowedSubjectIds: ['alice'], sessionMode: 'write', allowsFork: true,
|
||||
})
|
||||
|
||||
expect(created).toMatchObject({ sessionId: 'session-1', sessionMode: 'write' })
|
||||
expect(created).toMatchObject({ sessionIds: ['session-1'], sessionMode: 'write' })
|
||||
expect(created.link).toMatch(/^https:\/\/relay\.example\.test\/liveshare\/\?shareId=/u)
|
||||
const stored = h.ownerShares.get(created.shareId)
|
||||
expect(stored).toMatchObject({
|
||||
sessionId: 'session-1', sessionTitle: 'My Session', accessMode: 'selected', relay: RELAY,
|
||||
sessionIds: ['session-1'], sessionTitle: 'My Session', accessMode: 'selected', relay: RELAY,
|
||||
allowedSubjectIds: ['alice'], sessionMode: 'write', allowsFork: true, online: true, revision: 1,
|
||||
})
|
||||
expect(Date.parse(stored?.expiresAt ?? '')).toBeGreaterThanOrEqual(now + 10 * 60_000)
|
||||
@@ -162,10 +167,121 @@ describe('creating a share', () => {
|
||||
expect(h.tunnel.waitForShare).toHaveBeenCalledWith(created.shareId, 15_000)
|
||||
expect(h.persistOwnerState).toHaveBeenCalledTimes(1)
|
||||
expect(h.tunnel.scheduleShareExpiry).toHaveBeenCalledWith(stored)
|
||||
expect(h.applySourceAccess).toHaveBeenCalledWith(stored, undefined, 'publish')
|
||||
expect(h.applySourceAccess).toHaveBeenCalledWith(stored, 'session-1', undefined, 'publish')
|
||||
expect(h.ownerShares.has('kept')).toBe(true)
|
||||
})
|
||||
|
||||
it('adds source Sessions to one share', async () => {
|
||||
const h = harness()
|
||||
h.ownerShares.set('share-1', storedShare())
|
||||
await expect(h.operations.addSessions({ shareId: 'share-1', sessionIds: ['session-2', 'session-2'] }))
|
||||
.resolves.toEqual({ shareId: 'share-1', sessionIds: ['session-1', 'session-2'] })
|
||||
expect(h.ownerShares.get('share-1')).toMatchObject({
|
||||
sessionIds: ['session-1', 'session-2'],
|
||||
revision: 2,
|
||||
})
|
||||
expect(h.persistOwnerState).toHaveBeenCalledTimes(1)
|
||||
expect(h.applySourceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionIds: ['session-1', 'session-2'] }), 'session-2', undefined, 'publish')
|
||||
expect(h.accessChanged).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a Session from another workspace', async () => {
|
||||
const h = harness()
|
||||
h.ownerShares.set('share-1', storedShare())
|
||||
h.workspaces.mockReturnValue([
|
||||
{ id: 'workspace-1', sessionIds: ['session-1'] },
|
||||
{ id: 'workspace-2', sessionIds: ['session-3'] },
|
||||
])
|
||||
await expect(h.operations.addSessions({ shareId: 'share-1', sessionIds: ['session-3'] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/session-workspace-mismatch' })
|
||||
expect(h.ownerShares.get('share-1')).toMatchObject({ sessionIds: ['session-1'] })
|
||||
expect(h.persistOwnerState).not.toHaveBeenCalled()
|
||||
expect(h.applySourceAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a share whose primary source belongs to no workspace', async () => {
|
||||
const h = harness()
|
||||
h.ownerShares.set('share-1', storedShare())
|
||||
h.workspaces.mockReturnValue([{ id: 'workspace-1', sessionIds: ['session-2'] }])
|
||||
await expect(h.operations.addSessions({ shareId: 'share-1', sessionIds: ['session-2'] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/session-workspace-unknown' })
|
||||
expect(h.persistOwnerState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a share that lists no source Session', async () => {
|
||||
const h = harness()
|
||||
h.ownerShares.set('share-1', storedShare({ sessionIds: [] }))
|
||||
h.workspaces.mockReturnValue([{ id: 'workspace-1', sessionIds: ['session-2'] }])
|
||||
await expect(h.operations.addSessions({ shareId: 'share-1', sessionIds: ['session-2'] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/session-workspace-unknown' })
|
||||
expect(h.persistOwnerState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a Session that belongs to no workspace', async () => {
|
||||
const h = harness()
|
||||
h.ownerShares.set('share-1', storedShare())
|
||||
h.workspaces.mockReturnValue([{ id: 'workspace-1', sessionIds: ['session-1'] }])
|
||||
await expect(h.operations.addSessions({ shareId: 'share-1', sessionIds: ['session-9'] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/session-workspace-unknown' })
|
||||
expect(h.persistOwnerState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to add when the host cannot resolve workspaces', async () => {
|
||||
const h = harness()
|
||||
h.ownerShares.set('share-1', storedShare())
|
||||
h.workspaces.mockReturnValue(undefined)
|
||||
await expect(h.operations.addSessions({ shareId: 'share-1', sessionIds: ['session-2'] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/workspace-unavailable' })
|
||||
expect(h.persistOwnerState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to add while the stored sources already span workspaces', async () => {
|
||||
const h = harness()
|
||||
h.ownerShares.set('share-1', storedShare({ sessionIds: ['session-1', 'session-2'] }))
|
||||
h.workspaces.mockReturnValue([
|
||||
{ id: 'workspace-1', sessionIds: ['session-1', 'session-3'] },
|
||||
{ id: 'workspace-2', sessionIds: ['session-2'] },
|
||||
])
|
||||
await expect(h.operations.addSessions({ shareId: 'share-1', sessionIds: ['session-3'] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/session-workspace-mismatch' })
|
||||
expect(h.persistOwnerState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is idempotent when every named Session is already served', async () => {
|
||||
const h = harness()
|
||||
h.ownerShares.set('share-1', storedShare())
|
||||
await expect(h.operations.addSessions({ shareId: 'share-1', sessionIds: ['session-1'] }))
|
||||
.resolves.toEqual({ shareId: 'share-1', sessionIds: ['session-1'] })
|
||||
expect(h.persistOwnerState).not.toHaveBeenCalled()
|
||||
expect(h.applySourceAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an unknown share or an empty source list', async () => {
|
||||
const h = harness()
|
||||
await expect(h.operations.addSessions({ shareId: 'missing', sessionIds: ['session-2'] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/share-not-found' })
|
||||
h.ownerShares.set('share-1', storedShare())
|
||||
await expect(h.operations.addSessions({ shareId: '', sessionIds: ['session-2'] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/missing-share-id' })
|
||||
await expect(h.operations.addSessions({ shareId: 'share-1', sessionIds: [] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/missing-session-ids' })
|
||||
await expect(h.operations.addSessions({ shareId: 'share-1', sessionIds: [''] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/missing-session-ids' })
|
||||
expect(h.persistOwnerState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the record when an addition cannot be written', async () => {
|
||||
const h = harness()
|
||||
const share = storedShare()
|
||||
h.ownerShares.set(share.shareId, share)
|
||||
h.persistOwnerState.mockRejectedValue(new Error('disk full'))
|
||||
await expect(h.operations.addSessions({ shareId: share.shareId, sessionIds: ['session-2'] }))
|
||||
.rejects.toMatchObject({ code: 'liveshareShares/owner-state-unavailable' })
|
||||
expect(h.ownerShares.get(share.shareId)).toBe(share)
|
||||
expect(h.applySourceAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the session id and open/view defaults', async () => {
|
||||
const h = harness()
|
||||
const created = await h.operations.create({ sessionId: 'session-2', sessionTitle: '' })
|
||||
@@ -310,7 +426,7 @@ describe('stopping a share', () => {
|
||||
expect(h.cancelShareTurn).toHaveBeenCalledWith(share.shareId)
|
||||
expect(h.cancelChildTurns).toHaveBeenCalledWith(share.shareId)
|
||||
// A Session no share names any more loses its sandbox.
|
||||
expect(h.reconcileSourceAccess).toHaveBeenCalledWith(share.sessionId)
|
||||
expect(h.reconcileSourceAccess).toHaveBeenCalledWith(share.sessionIds[0])
|
||||
expect(h.forks.revokeShare).toHaveBeenCalledWith(share.shareId, expect.any(String))
|
||||
expect(h.ownerShares.has(share.shareId)).toBe(false)
|
||||
expect(h.accessChanged).toHaveBeenCalled()
|
||||
@@ -362,7 +478,7 @@ describe('updating share permissions', () => {
|
||||
expect(h.persistOwnerState).toHaveBeenCalledTimes(1)
|
||||
// oxlint-disable-next-line typescript/no-unsafe-member-access, typescript/no-unsafe-return -- typed test doubles and mock calls.
|
||||
expect(h.tunnel.send.mock.calls.map(call => call[0].type)).toEqual(['remove-share', 'add-share'])
|
||||
expect(h.applySourceAccess).toHaveBeenCalledWith(expect.objectContaining({ sessionMode: 'view' }), undefined, 'mode')
|
||||
expect(h.applySourceAccess).toHaveBeenCalledWith(expect.objectContaining({ sessionMode: 'view' }), 'session-1', undefined, 'mode')
|
||||
})
|
||||
|
||||
it('keeps the stored value for a switch the request omits', async () => {
|
||||
@@ -379,7 +495,7 @@ describe('updating share permissions', () => {
|
||||
h.sourceConfined.mockReturnValue(false)
|
||||
await expect(h.operations.updatePermissions({ shareId: 'share-1', sessionMode: 'view' }))
|
||||
.resolves.toEqual({ shareId: 'share-1', sessionMode: 'view', allowsFork: false })
|
||||
expect(h.applySourceAccess).toHaveBeenCalledWith(expect.objectContaining({ shareId: 'share-1' }), undefined, 'mode')
|
||||
expect(h.applySourceAccess).toHaveBeenCalledWith(expect.objectContaining({ shareId: 'share-1' }), 'session-1', undefined, 'mode')
|
||||
expect(h.persistOwnerState).not.toHaveBeenCalled()
|
||||
expect(h.tunnel.send).not.toHaveBeenCalled()
|
||||
|
||||
@@ -744,7 +860,7 @@ describe('Relay checks', () => {
|
||||
h.tunnel.probeRelayPublish.mockResolvedValue({ ok: true, blocking: false })
|
||||
await expect(h.operations.testPublish({ sessionId: 'session-1' })).resolves.toEqual({ ok: true, blocking: false })
|
||||
expect(h.tunnel.shareSpec).toHaveBeenCalledWith(expect.objectContaining({
|
||||
shareId: '', sessionId: 'session-1', sessionTitle: 'session-1', accessMode: 'open', relay: RELAY,
|
||||
shareId: '', sessionIds: ['session-1'], sessionTitle: 'session-1', accessMode: 'open', relay: RELAY,
|
||||
allowedSubjectIds: [], allowsFork: false, sessionMode: 'view',
|
||||
}), { relay: RELAY, credential: 'owner-credential' })
|
||||
expect(h.tunnel.probeRelayPublish).toHaveBeenCalledWith(RELAY, expect.objectContaining({ shareId: '' }))
|
||||
|
||||
@@ -75,9 +75,11 @@ export function ownerSharesDouble(overrides: OwnerSharesOverrides = {}): OwnerSh
|
||||
link: `${DOUBLE_RELAY}/?shareId=created`,
|
||||
shareId: 'created',
|
||||
sessionId: 'session',
|
||||
sessionIds: ['session'],
|
||||
sessionMode: 'view',
|
||||
access: OPEN_ACCESS,
|
||||
}),
|
||||
addSessions: async request => accepted({ shareId: request.shareId, sessionIds: request.sessionIds }),
|
||||
list: async () => accepted({ shares: [] }),
|
||||
stop: async () => accepted({ ok: true }),
|
||||
updatePermissions: async request => accepted({
|
||||
@@ -105,6 +107,7 @@ export function ownerSharesDouble(overrides: OwnerSharesOverrides = {}): OwnerSh
|
||||
}
|
||||
const shares: OwnerShares = {
|
||||
create: async request => record('create', request, () => base.create(request)),
|
||||
addSessions: async request => record('addSessions', request, () => base.addSessions(request)),
|
||||
list: async () => record('list', undefined, () => base.list()),
|
||||
stop: async request => record('stop', request, () => base.stop(request)),
|
||||
updatePermissions: async request => record('updatePermissions', request, () => base.updatePermissions(request)),
|
||||
|
||||
@@ -27,7 +27,7 @@ const translate: LiveshareShareDialogProps['t'] = (key, params) => {
|
||||
return result
|
||||
}
|
||||
const existing = {
|
||||
shareId: 'existing', sessionId: 'session', sessionTitle: 'Acceptance session',
|
||||
shareId: 'existing', sessionIds: ['session'], sessionTitle: 'Acceptance session',
|
||||
link: 'https://relay.example.test/?shareId=existing', createdAt: '2026-09-05T00:00:00Z', online: true,
|
||||
sessionMode: 'view',
|
||||
allowsFork: false,
|
||||
@@ -98,6 +98,7 @@ async function showDialog(
|
||||
link: existing.link,
|
||||
shareId: 'existing',
|
||||
sessionId: 'session',
|
||||
sessionIds: ['session'],
|
||||
sessionMode: request.sessionMode ?? 'view',
|
||||
access: {
|
||||
mode: 'open',
|
||||
@@ -1210,7 +1211,7 @@ describe('Liveshare dialog stored-record branches', () => {
|
||||
// A Host that predates the permission field answers without it; the dialog must state that
|
||||
// rather than assume the narrowest tier.
|
||||
const answer: CreatedShare = {
|
||||
link: existing.link, shareId: 'existing', sessionId: 'session', sessionMode: 'view', access: OPEN_ACCESS,
|
||||
link: existing.link, shareId: 'existing', sessionId: 'session', sessionIds: ['session'], sessionMode: 'view', access: OPEN_ACCESS,
|
||||
}
|
||||
delete (answer as { sessionMode?: CreatedShare['sessionMode'] }).sessionMode
|
||||
return accepted(answer)
|
||||
@@ -1234,7 +1235,7 @@ describe('Liveshare dialog stored-record branches', () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
pending.resolve(accepted({
|
||||
link: existing.link, shareId: 'existing', sessionId: 'session', sessionMode: 'view', access: OPEN_ACCESS,
|
||||
link: existing.link, shareId: 'existing', sessionId: 'session', sessionIds: ['session'], sessionMode: 'view', access: OPEN_ACCESS,
|
||||
}))
|
||||
await act(async () => { await pending.promise })
|
||||
})
|
||||
@@ -1256,7 +1257,7 @@ describe('Liveshare dialog stored-record branches', () => {
|
||||
await showControlled({
|
||||
overrides: {
|
||||
create: async () => accepted({
|
||||
link: '', shareId: 'existing', sessionId: 'session', sessionMode: 'view', access: OPEN_ACCESS,
|
||||
link: '', shareId: 'existing', sessionId: 'session', sessionIds: ['session'], sessionMode: 'view', access: OPEN_ACCESS,
|
||||
}),
|
||||
list: async () => {
|
||||
calls += 1
|
||||
|
||||
@@ -26,7 +26,7 @@ afterEach(() => {
|
||||
|
||||
const share: ForkShareState = {
|
||||
shareId: 'share-1',
|
||||
sessionId: 'session-shared',
|
||||
sessionIds: ['session-shared'],
|
||||
sessionMode: 'view',
|
||||
allowsFork: true,
|
||||
viewerAdmitted: true,
|
||||
@@ -99,7 +99,7 @@ function service(
|
||||
})
|
||||
const read = createViewerReadService({
|
||||
connection: owner.connection,
|
||||
sharedSessionId: () => 'session-shared',
|
||||
sharedSessionIds: () => ['session-shared'],
|
||||
forkStateOf: state,
|
||||
forks,
|
||||
writeRefusal: writeRefusalOver(forks, state),
|
||||
@@ -130,7 +130,7 @@ describe('cold fork child', () => {
|
||||
const owner = ownerConnection(() => Promise.resolve(Response.json({ result: { ok: true, value: { sessionId: child } } })))
|
||||
const read = createViewerReadService({
|
||||
connection: owner.connection,
|
||||
sharedSessionId: () => 'session-shared',
|
||||
sharedSessionIds: () => ['session-shared'],
|
||||
forkStateOf: () => ({ ...share, sessionMode }),
|
||||
forks,
|
||||
enforceChild: (_childSessionId, options) => {
|
||||
@@ -203,7 +203,7 @@ describe('cold fork child', () => {
|
||||
})
|
||||
const read = createViewerReadService({
|
||||
connection: owner.connection,
|
||||
sharedSessionId: () => 'session-shared',
|
||||
sharedSessionIds: () => ['session-shared'],
|
||||
forkStateOf: publishedShare,
|
||||
forks,
|
||||
writeRefusal: writeRefusalOver(forks, publishedShare),
|
||||
|
||||
@@ -38,7 +38,7 @@ afterEach(() => {
|
||||
|
||||
const share: ForkShareState = {
|
||||
shareId: 'share-1',
|
||||
sessionId: 'session-shared',
|
||||
sessionIds: ['session-shared'],
|
||||
allowsFork: true,
|
||||
viewerAdmitted: true,
|
||||
stopped: false,
|
||||
@@ -102,7 +102,7 @@ function service(options: {
|
||||
)
|
||||
const read = createViewerReadService({
|
||||
connection: spawned.owner.connection,
|
||||
sharedSessionId: () => 'session-shared',
|
||||
sharedSessionIds: () => ['session-shared'],
|
||||
forkStateOf: options.state ?? (() => share),
|
||||
forks,
|
||||
// A host that installs the child's limit by default, as the share host does; a test overrides it to
|
||||
|
||||
@@ -19,7 +19,7 @@ const creator = { providerId: 'feishu', tenantId: 'tenant-a', subjectId: 'ou_vie
|
||||
|
||||
const share: ForkShareState = {
|
||||
shareId: 'share-1',
|
||||
sessionId: 'session-shared',
|
||||
sessionIds: ['session-shared'],
|
||||
// The view tier with the share's own fork switch on: forking is no longer a tier of its own.
|
||||
sessionMode: 'view',
|
||||
allowsFork: true,
|
||||
@@ -114,7 +114,7 @@ async function service(childCreator = creator): Promise<{ read: ReturnType<typeo
|
||||
ctx.remoteAccess.registerPolicy('share-viewer', createViewerAccessPolicy(accessFacts))
|
||||
const read = createViewerReadService({
|
||||
connection: owner(ctx.remoteAccess).connection,
|
||||
sharedSessionId: () => 'session-shared',
|
||||
sharedSessionIds: () => ['session-shared'],
|
||||
forkStateOf: () => share,
|
||||
forks,
|
||||
writeRefusal: (viewer, action, sessionId, concrete) => readerWriteRefusal(accessFacts, viewer, action, sessionId, concrete),
|
||||
|
||||
@@ -20,7 +20,7 @@ const request: ForkRequest = {
|
||||
function share(overrides: Partial<ForkShareState> = {}): ForkShareState {
|
||||
return {
|
||||
shareId: 'share-1',
|
||||
sessionId: 'session-1',
|
||||
sessionIds: ['session-1'],
|
||||
allowsFork: true,
|
||||
viewerAdmitted: true,
|
||||
stopped: false,
|
||||
@@ -85,6 +85,15 @@ describe('read-only fork authorization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forks any Session the share lists as one of its sources', () => {
|
||||
const multi = share({ sessionIds: ['session-1', 'session-2'] })
|
||||
expect(decideFork(multi, { ...request, sourceSessionId: 'session-2' }, 0)).toMatchObject({ ok: true })
|
||||
expect(decideFork(multi, { ...request, sourceSessionId: 'session-3' }, 0)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'source-session-mismatch',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a caller that names another session', () => {
|
||||
// A viewer that can name a session id must not be able to fork one the share does not carry.
|
||||
expect(decideFork(share(), { ...request, sourceSessionId: 'session-2' }, 0)).toMatchObject({
|
||||
|
||||
@@ -12,7 +12,7 @@ const OWNER = { providerId: 'owner-p', tenantId: 'owner-t', subjectId: 'owner',
|
||||
|
||||
function storedShare(overrides: Partial<OwnerShare> = {}): OwnerShare {
|
||||
return {
|
||||
shareId: 'share-1', sessionId: 'session-1', sessionTitle: 'Session', accessMode: 'open',
|
||||
shareId: 'share-1', sessionIds: ['session-1'], sessionTitle: 'Session', accessMode: 'open',
|
||||
link: `${RELAY}/?shareId=share-1`, relay: RELAY, gatewayToken: 'gateway-token',
|
||||
allowedSubjectIds: [], revision: 1, createdAt: '2026-01-01T00:00:00.000Z',
|
||||
allowsFork: false, sessionMode: 'view', online: true, ...overrides,
|
||||
@@ -152,7 +152,7 @@ describe('relay access adoption', () => {
|
||||
const h = harness()
|
||||
const admitted = { providerId: 'p', tenantId: 't', subjectId: 'alice' }
|
||||
const revoked = { providerId: 'p', tenantId: 't', subjectId: 'bob' }
|
||||
h.ownerShares.set('share-1', storedShare({ sessionMode: 'write', allowsFork: true, sessionId: 'session-1' }))
|
||||
h.ownerShares.set('share-1', storedShare({ sessionMode: 'write', allowsFork: true }))
|
||||
h.authority.applyRelayAccess('share-1', frame({
|
||||
version: 1, sessionMode: 'write', allowsFork: true, allowedSubjectIds: ['alice', 'bob'],
|
||||
}))
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('Session header Share action', () => {
|
||||
it('returns to the share dialog when the manager opens this same Session', async () => {
|
||||
const { shares } = ownerSharesDouble({
|
||||
list: async () => accepted({ shares: [{
|
||||
shareId: 'share', sessionId: SESSION, sessionTitle: 'Review the migration',
|
||||
shareId: 'share', sessionIds: [SESSION], sessionTitle: 'Review the migration',
|
||||
link: 'https://relay.test/?shareId=share', createdAt: '2026-09-05T00:00:00Z', online: true,
|
||||
}] as never }),
|
||||
})
|
||||
@@ -79,7 +79,7 @@ describe('Session header Share action', () => {
|
||||
setManagedShareOpener(open)
|
||||
const { shares } = ownerSharesDouble({
|
||||
list: async () => accepted({ shares: [{
|
||||
shareId: 'other', sessionId: 'other-session', sessionTitle: 'Other session',
|
||||
shareId: 'other', sessionIds: ['other-session'], sessionTitle: 'Other session',
|
||||
link: 'https://relay.test/?shareId=other', createdAt: '2026-09-05T00:00:00Z', online: true,
|
||||
}] as never }),
|
||||
})
|
||||
|
||||
@@ -140,17 +140,17 @@ describe('configuration guards', () => {
|
||||
// A record restored from an older owner state carries no tier; drop it after typing the record so
|
||||
// the published facts show the field absent rather than defaulted.
|
||||
const legacy: OwnerShare = {
|
||||
shareId: 'share-1', sessionId: 'session-1', sessionTitle: 'Session', accessMode: 'open',
|
||||
shareId: 'share-1', sessionIds: ['session-1'], sessionTitle: 'Session', accessMode: 'open',
|
||||
gatewayToken: 'token', allowedSubjectIds: [], createdAt: '2026-01-01T00:00:00Z',
|
||||
allowsFork: false, online: false, link: '', relay: RELAY, revision: 0, sessionMode: 'view',
|
||||
}
|
||||
delete (legacy as { sessionMode?: OwnerShare['sessionMode'] }).sessionMode
|
||||
expect(publishedShareState(legacy)).toEqual({
|
||||
shareId: 'share-1', sessionId: 'session-1', allowsFork: false, viewerAdmitted: true,
|
||||
shareId: 'share-1', sessionIds: ['session-1'], allowsFork: false, viewerAdmitted: true,
|
||||
stopped: false, sessionMode: undefined,
|
||||
})
|
||||
expect(publishedShareState({
|
||||
shareId: 'share-2', sessionId: 'session-2', sessionTitle: 'Session', accessMode: 'open',
|
||||
shareId: 'share-2', sessionIds: ['session-2'], sessionTitle: 'Session', accessMode: 'open',
|
||||
gatewayToken: 'token', allowedSubjectIds: [], createdAt: '2026-01-01T00:00:00Z',
|
||||
allowsFork: true, online: true, link: '', relay: RELAY, revision: 0,
|
||||
sessionMode: 'write', expiresAt: '2026-01-02T00:00:00Z',
|
||||
|
||||
@@ -18,8 +18,9 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
function share(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const sessionId = (overrides.sessionId as string | undefined) ?? 'session-1'
|
||||
return {
|
||||
shareId: 'share-1', sessionId: 'session-1', sessionTitle: 'Session', accessMode: 'open',
|
||||
shareId: 'share-1', sessionId, sessionIds: [sessionId], sessionTitle: 'Session', accessMode: 'open',
|
||||
relay: 'https://relay.example.test/liveshare', gatewayToken: 'token', allowedSubjectIds: [],
|
||||
createdAt: '2026-01-01T00:00:00Z', revision: 1, allowsFork: false, sessionMode: 'view', online: true,
|
||||
...overrides,
|
||||
@@ -109,11 +110,11 @@ function harness(options: Options = {}) {
|
||||
describe('source layer installation refusals', () => {
|
||||
it('refuses when the Session is not live or has no scope', async () => {
|
||||
const h = harness()
|
||||
const missing = await h.access.apply(share({ sessionId: 'other' }) as never)
|
||||
const missing = await h.access.apply(share({ sessionId: 'other' }) as never, 'other')
|
||||
expect(missing).toEqual({ kind: 'refused', reason: 'no-live-session' })
|
||||
|
||||
const unscoped = harness({ noScope: true })
|
||||
expect(await unscoped.access.apply(share() as never)).toEqual({ kind: 'refused', reason: 'no-session-scope' })
|
||||
expect(await unscoped.access.apply(share() as never, 'session-1')).toEqual({ kind: 'refused', reason: 'no-session-scope' })
|
||||
})
|
||||
|
||||
it('names each host service the layer cannot work without', async () => {
|
||||
@@ -121,21 +122,21 @@ describe('source layer installation refusals', () => {
|
||||
['fs', 'no-filesystem'], ['tools', 'no-tool-registry'], ['sandboxPolicy', 'no-sandbox-policy'],
|
||||
] as const) {
|
||||
const h = harness({ services: ['fs', 'tools', 'sandboxPolicy'].filter(name => name !== service) as never })
|
||||
expect(await h.access.apply(share() as never)).toEqual({ kind: 'refused', reason })
|
||||
expect(await h.access.apply(share() as never, 'session-1')).toEqual({ kind: 'refused', reason })
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a Session without a granted root or working directory, or a root that will not resolve', async () => {
|
||||
const noRoot = harness({ session: { id: 'session-1', header: { cwd: ROOT } } })
|
||||
noRoot.sandboxPolicy.resolve.mockReturnValue({} as never)
|
||||
expect(await noRoot.access.apply(share() as never)).toEqual({ kind: 'refused', reason: 'no-workspace-root' })
|
||||
expect(await noRoot.access.apply(share() as never, 'session-1')).toEqual({ kind: 'refused', reason: 'no-workspace-root' })
|
||||
|
||||
const noCwd = harness({ session: { id: 'session-1', header: {} } })
|
||||
expect(await noCwd.access.apply(share() as never)).toEqual({ kind: 'refused', reason: 'no-session-cwd' })
|
||||
expect(await noCwd.access.apply(share() as never, 'session-1')).toEqual({ kind: 'refused', reason: 'no-session-cwd' })
|
||||
|
||||
const unresolved = harness()
|
||||
unresolved.fs.resolve.mockRejectedValueOnce(new Error('missing'))
|
||||
expect(await unresolved.access.apply(share() as never)).toEqual({ kind: 'refused', reason: 'workspace-unresolved' })
|
||||
expect(await unresolved.access.apply(share() as never, 'session-1')).toEqual({ kind: 'refused', reason: 'workspace-unresolved' })
|
||||
})
|
||||
|
||||
it('refuses a share removed or reassigned while the workspace resolved', async () => {
|
||||
@@ -144,27 +145,27 @@ describe('source layer installation refusals', () => {
|
||||
h.ownerShares.clear()
|
||||
return `${ROOT}/file`
|
||||
})
|
||||
expect(await h.access.apply(share() as never)).toEqual({ kind: 'refused', reason: 'share-gone' })
|
||||
expect(await h.access.apply(share() as never, 'session-1')).toEqual({ kind: 'refused', reason: 'share-gone' })
|
||||
|
||||
const reassigned = harness()
|
||||
reassigned.ownerShares.set('share-1', share({ sessionId: 'other-session' }))
|
||||
expect(await reassigned.access.apply(share() as never)).toEqual({ kind: 'refused', reason: 'share-reassigned' })
|
||||
expect(await reassigned.access.apply(share() as never, 'session-1')).toEqual({ kind: 'refused', reason: 'share-reassigned' })
|
||||
})
|
||||
|
||||
it('installs a usable layer, keeps it for the same tier, and reports its refusal codes', async () => {
|
||||
const h = harness()
|
||||
h.ownerShares.set('share-1', share())
|
||||
const installed = await h.access.apply(share() as never)
|
||||
const installed = await h.access.apply(share() as never, 'session-1')
|
||||
expect(installed).toMatchObject({ kind: 'installed', installation: { readConfined: true } })
|
||||
expect(h.access.confined('share-1')).toBe(true)
|
||||
expect(h.access.confinementRefusal('share-1')).toBeUndefined()
|
||||
expect(h.access.confined('share-1', 'session-1')).toBe(true)
|
||||
expect(h.access.confinementRefusal('share-1', 'session-1')).toBeUndefined()
|
||||
|
||||
const kept = await h.access.apply(share() as never)
|
||||
const kept = await h.access.apply(share() as never, 'session-1')
|
||||
expect(kept).toMatchObject({ kind: 'kept' })
|
||||
|
||||
expect(h.access.confinementRefusal('absent')).toBe('share-unknown')
|
||||
expect(h.access.confinementRefusal('absent', 'session-1')).toBe('share-unknown')
|
||||
h.relay.drivableModeOf.mockReturnValue(undefined)
|
||||
expect(h.access.confinementRefusal('share-1')).toBe('share-mode-unknown')
|
||||
expect(h.access.confinementRefusal('share-1', 'session-1')).toBe('share-mode-unknown')
|
||||
})
|
||||
|
||||
it('reports the refusal recorded on an installation that confined no read', async () => {
|
||||
@@ -172,11 +173,11 @@ describe('source layer installation refusals', () => {
|
||||
h.ownerShares.set('share-1', share())
|
||||
h.registered.clear()
|
||||
h.tools.get.mockReturnValue(undefined)
|
||||
const outcome = await h.access.apply(share() as never)
|
||||
const outcome = await h.access.apply(share() as never, 'session-1')
|
||||
expect(outcome).toMatchObject({ kind: 'installed', installation: { readConfined: false, refusal: 'tool-unavailable' } })
|
||||
expect(h.access.confined('share-1')).toBe(false)
|
||||
expect(h.access.refusalOf('share-1')).toBe('tool-unavailable')
|
||||
expect(h.access.confinementRefusal('share-1')).toBe('layer-read-unconfined')
|
||||
expect(h.access.confined('share-1', 'session-1')).toBe(false)
|
||||
expect(h.access.refusalOf('share-1', 'session-1')).toBe('tool-unavailable')
|
||||
expect(h.access.confinementRefusal('share-1', 'session-1')).toBe('layer-read-unconfined')
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('source session not confined'))
|
||||
})
|
||||
|
||||
@@ -190,14 +191,14 @@ describe('source layer installation refusals', () => {
|
||||
h.registered.set(definition.name, definition)
|
||||
return () => { h.registered.delete(definition.name) }
|
||||
})
|
||||
const outcome = await h.access.apply(share({ sessionMode: 'write' }) as never)
|
||||
const outcome = await h.access.apply(share({ sessionMode: 'write' }) as never, 'session-1')
|
||||
expect(outcome).toMatchObject({ kind: 'installed', installation: { readConfined: true, writeConfined: false } })
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('write not confined'))
|
||||
|
||||
const clean = harness({ mode: 'write' })
|
||||
clean.ownerShares.set('share-1', share({ sessionMode: 'write' }))
|
||||
clean.registered.set('write', { name: 'write', description: 'write', parameters: {}, output: { schema: {}, render: () => [] } })
|
||||
await clean.access.apply(share({ sessionMode: 'write' }) as never)
|
||||
await clean.access.apply(share({ sessionMode: 'write' }) as never, 'session-1')
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('source access installed'))
|
||||
})
|
||||
|
||||
@@ -207,7 +208,7 @@ describe('source layer installation refusals', () => {
|
||||
h.tools.register.mockImplementation((definition: { name: string }) => {
|
||||
throw definition.name === 'read' ? 'plain read failure' : new Error('unexpected')
|
||||
})
|
||||
const outcome = await h.access.apply(share() as never)
|
||||
const outcome = await h.access.apply(share() as never, 'session-1')
|
||||
expect(outcome).toMatchObject({ kind: 'installed', installation: { readConfined: false, registrationFailure: { tool: 'read' } } })
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('plain read failure'))
|
||||
})
|
||||
@@ -216,7 +217,7 @@ describe('source layer installation refusals', () => {
|
||||
const h = harness({ mode: 'write' })
|
||||
h.ownerShares.set('share-1', share({ sessionMode: 'write' }))
|
||||
h.registered.set('write', { name: 'write', description: 'write', parameters: {}, output: { schema: {}, render: () => [] } })
|
||||
await h.access.apply(share({ sessionMode: 'write' }) as never)
|
||||
await h.access.apply(share({ sessionMode: 'write' }) as never, 'session-1')
|
||||
const write = h.scoped.get('write') as { execute: (args: unknown, exec: unknown) => Promise<unknown> }
|
||||
await expect(write.execute({ file_path: 'file', content: 'text' }, {
|
||||
arguments: { file_path: 'file' }, agent: { id: 'session-1', session: h.session }, signal: undefined,
|
||||
@@ -227,20 +228,20 @@ describe('source layer installation refusals', () => {
|
||||
|
||||
it('reports the refusal code a share with no recorded layer carries', () => {
|
||||
const h = harness()
|
||||
expect(h.access.refusalOf('absent')).toBeUndefined()
|
||||
expect(h.access.confinementRefusal('absent')).toBe('share-unknown')
|
||||
expect(h.access.refusalOf('absent', 'session-1')).toBeUndefined()
|
||||
expect(h.access.confinementRefusal('absent', 'session-1')).toBe('share-unknown')
|
||||
})
|
||||
|
||||
it('names the refused guard in debug mode', async () => {
|
||||
vi.stubEnv('LIVESHARE_VIEWER_DEBUG', '1')
|
||||
const h = harness()
|
||||
await h.access.apply(share({ sessionId: 'other' }) as never)
|
||||
await h.access.apply(share({ sessionId: 'other' }) as never, 'other')
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('source access not installed'))
|
||||
})
|
||||
|
||||
it('refuses a live context without a session scope', async () => {
|
||||
const h = harness({ noScope: true })
|
||||
expect(await h.access.apply(share() as never)).toEqual({ kind: 'refused', reason: 'no-session-scope' })
|
||||
expect(await h.access.apply(share() as never, 'session-1')).toEqual({ kind: 'refused', reason: 'no-session-scope' })
|
||||
})
|
||||
|
||||
it('refuses a tier that changed while the workspace resolved, and keeps a concurrent layer', async () => {
|
||||
@@ -251,7 +252,7 @@ describe('source layer installation refusals', () => {
|
||||
h.ownerShares.set('share-1', share({ sessionMode: 'view' }))
|
||||
return `${ROOT}/file`
|
||||
})
|
||||
expect(await h.access.apply(share({ sessionMode: 'write' }) as never))
|
||||
expect(await h.access.apply(share({ sessionMode: 'write' }) as never, 'session-1'))
|
||||
.toEqual({ kind: 'refused', reason: 'share-mode-changed' })
|
||||
|
||||
const concurrent = harness()
|
||||
@@ -260,10 +261,10 @@ describe('source layer installation refusals', () => {
|
||||
concurrent.fs.resolve.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
release = () => { resolve(`${ROOT}/file`) }
|
||||
}))
|
||||
const slow = concurrent.access.apply(share() as never)
|
||||
const slow = concurrent.access.apply(share() as never, 'session-1')
|
||||
await Promise.resolve()
|
||||
// Another attempt finishes first and records the layer.
|
||||
await concurrent.access.apply(share() as never)
|
||||
await concurrent.access.apply(share() as never, 'session-1')
|
||||
release?.()
|
||||
expect(await slow).toMatchObject({ kind: 'kept' })
|
||||
})
|
||||
@@ -272,10 +273,10 @@ describe('source layer installation refusals', () => {
|
||||
const h = harness({ mode: 'write' })
|
||||
h.ownerShares.set('share-1', share({ sessionMode: 'write' }))
|
||||
h.registered.delete('write')
|
||||
await h.access.apply(share({ sessionMode: 'write' }) as never)
|
||||
expect(h.access.confinementRefusal('share-1')).toBe('layer-write-unconfined')
|
||||
await h.access.apply(share({ sessionMode: 'write' }) as never, 'session-1')
|
||||
expect(h.access.confinementRefusal('share-1', 'session-1')).toBe('layer-write-unconfined')
|
||||
h.registered.set('write', { name: 'write', description: 'write', parameters: {}, output: { schema: {}, render: () => [] } })
|
||||
expect(await h.access.apply(share({ sessionMode: 'write' }) as never)).toMatchObject({ kind: 'installed' })
|
||||
expect(await h.access.apply(share({ sessionMode: 'write' }) as never, 'session-1')).toMatchObject({ kind: 'installed' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -297,16 +298,16 @@ describe('turn and tool helpers', () => {
|
||||
const h = harness()
|
||||
const granted = share()
|
||||
h.ownerShares.set('share-1', granted)
|
||||
await h.access.apply(granted as never)
|
||||
expect(h.access.confined('share-1')).toBe(true)
|
||||
await h.access.apply(granted as never, 'session-1')
|
||||
expect(h.access.confined('share-1', 'session-1')).toBe(true)
|
||||
|
||||
h.ownerShares.clear()
|
||||
h.access.reconcileSessionAccess('session-1')
|
||||
expect(h.access.confinementRefusal('share-1')).toBe('share-unknown')
|
||||
expect(h.access.confinementRefusal('share-1', 'session-1')).toBe('share-unknown')
|
||||
// A Session with neither shares nor a layer reconciles to nothing.
|
||||
h.access.reconcileSessionAccess('session-1')
|
||||
h.ownerShares.set('share-1', granted)
|
||||
expect(h.access.confinementRefusal('share-1')).toBe('layer-absent')
|
||||
expect(h.access.confinementRefusal('share-1', 'session-1')).toBe('layer-absent')
|
||||
})
|
||||
|
||||
it('releases a layer whose only share passes its deadline', async () => {
|
||||
@@ -314,7 +315,7 @@ describe('turn and tool helpers', () => {
|
||||
const expiring = share({ expiresAt: '2000-01-01T00:00:00.000Z' })
|
||||
h.ownerShares.set('share-1', expiring)
|
||||
// A record past its deadline installs nothing, so the Session keeps its own tools.
|
||||
expect(await h.access.apply(expiring as never)).toEqual({ kind: 'refused', reason: 'share-inactive' })
|
||||
expect(await h.access.apply(expiring as never, 'session-1')).toEqual({ kind: 'refused', reason: 'share-inactive' })
|
||||
})
|
||||
|
||||
it('re-installs the read-only layer when the last write share narrows to view', async () => {
|
||||
@@ -322,12 +323,12 @@ describe('turn and tool helpers', () => {
|
||||
const writable = share({ sessionMode: 'write' })
|
||||
h.ownerShares.set('share-1', writable)
|
||||
h.registered.set('write', { name: 'write', description: 'write', parameters: {}, output: { schema: {}, render: () => [] } })
|
||||
expect(await h.access.apply(writable as never)).toMatchObject({ kind: 'installed', installation: { writeConfined: true } })
|
||||
expect(await h.access.apply(writable as never, 'session-1')).toMatchObject({ kind: 'installed', installation: { writeConfined: true } })
|
||||
|
||||
h.relay.drivableModeOf.mockReturnValue('view')
|
||||
h.ownerShares.set('share-1', share({ sessionMode: 'view' }))
|
||||
h.access.reconcileSessionAccess('session-1')
|
||||
await vi.waitFor(() => { expect(h.access.confined('share-1')).toBe(true) })
|
||||
await vi.waitFor(() => { expect(h.access.confined('share-1', 'session-1')).toBe(true) })
|
||||
// The write name is gone from the Session's layer, and so is the bounded write behind it.
|
||||
expect(h.scoped.has('write')).toBe(false)
|
||||
expect(h.scoped.has('read')).toBe(true)
|
||||
@@ -336,7 +337,7 @@ describe('turn and tool helpers', () => {
|
||||
it('does not prepare a layer that is already usable', async () => {
|
||||
const h = harness()
|
||||
h.ownerShares.set('share-1', share())
|
||||
await h.access.apply(share() as never)
|
||||
await h.access.apply(share() as never, 'session-1')
|
||||
await expect(h.access.prepareSourceSession('session-1', 'prompt')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -434,7 +435,7 @@ describe('preparation', () => {
|
||||
h.ownerShares.set('share-1', share())
|
||||
h.agents.get.mockReturnValue(h.live)
|
||||
await h.access.prepareSourceSession('session-1', 'prompt')
|
||||
expect(h.access.confined('share-1')).toBe(true)
|
||||
expect(h.access.confined('share-1', 'session-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('prepares from a reader request only for a share that needs it and is not already preparing', async () => {
|
||||
@@ -459,6 +460,6 @@ describe('preparation', () => {
|
||||
unlive.agents.get.mockReturnValue(undefined)
|
||||
unlive.access.prepareSourceForReader('share-1')
|
||||
await vi.waitFor(() => { expect(vi.mocked(console.warn).mock.calls.length).toBeGreaterThan(0) })
|
||||
expect(unlive.access.confined('share-1')).toBe(false)
|
||||
expect(unlive.access.confined('share-1', 'session-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -33,7 +33,7 @@ afterEach(() => {
|
||||
function ownerShare(mode: OwnerShare['sessionMode']): OwnerShare {
|
||||
return {
|
||||
shareId: 'share-1',
|
||||
sessionId: source,
|
||||
sessionIds: [source],
|
||||
sessionTitle: 'Shared',
|
||||
accessMode: 'open',
|
||||
link: 'https://relay.example.test/liveshare/s/share-1',
|
||||
@@ -115,7 +115,7 @@ function service(mode: OwnerShare['sessionMode'], options: {
|
||||
}
|
||||
const read = createViewerReadService({
|
||||
connection: owner.connection,
|
||||
sharedSessionId: () => source,
|
||||
sharedSessionIds: () => [source],
|
||||
forkStateOf: shareState,
|
||||
sourceConfined,
|
||||
viewerStillAdmitted: (_shareId, actor) => admitted(actor),
|
||||
@@ -134,7 +134,7 @@ describe('the share state one request is decided against', () => {
|
||||
it('carries the mode the owner set', () => {
|
||||
expect(publishedShareState(ownerShare('write'))).toMatchObject({
|
||||
shareId: 'share-1',
|
||||
sessionId: source,
|
||||
sessionIds: [source],
|
||||
allowsFork: true,
|
||||
viewerAdmitted: true,
|
||||
stopped: false,
|
||||
|
||||
@@ -33,7 +33,7 @@ beforeEach(() => {
|
||||
|
||||
function share(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
return {
|
||||
shareId: 'share-1', sessionId: 'session-1', sessionTitle: 'Session', accessMode: 'open',
|
||||
shareId: 'share-1', sessionId: 'session-1', sessionIds: ['session-1'], sessionTitle: 'Session', accessMode: 'open',
|
||||
link: `${RELAY}/?shareId=share-1`, relay: RELAY, gatewayToken: 'gateway-token',
|
||||
allowedSubjectIds: [], revision: 1, createdAt: '2026-01-01T00:00:00Z',
|
||||
allowsFork: false, sessionMode: 'view', online: false, ...overrides,
|
||||
@@ -105,9 +105,9 @@ function harness(options: HarnessOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function spec(overrides: Partial<TunnelShareSpec> = {}): TunnelShareSpec {
|
||||
function spec(overrides: Partial<TunnelShareSpec> = {}): TunnelShareSpec & { readonly sessionIds: readonly string[] } {
|
||||
return {
|
||||
shareId: 'share-1', sessionId: 'session-1', sessionTitle: 'Session', accessMode: 'open',
|
||||
shareId: 'share-1', sessionId: 'session-1', sessionIds: ['session-1'], sessionTitle: 'Session', accessMode: 'open',
|
||||
relay: RELAY, gatewayToken: 'gateway-token', ownerDisplayName: 'Owner', allowedSubjectIds: [],
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ interface FixtureOptions {
|
||||
readonly admitted?: boolean
|
||||
readonly confined?: boolean
|
||||
readonly ceilings?: readonly string[]
|
||||
/** Additional source Sessions the share carries; the primary is always `source`. */
|
||||
readonly sourceIds?: readonly string[]
|
||||
}
|
||||
|
||||
const roots: Context[] = []
|
||||
@@ -101,7 +103,7 @@ function facts(
|
||||
const mode = options.mode === undefined ? 'write' : options.mode
|
||||
const share: ForkShareState = {
|
||||
shareId: 'share',
|
||||
sessionId: 'source',
|
||||
sessionIds: options.sourceIds ?? ['source'],
|
||||
allowsFork: options.allowsFork ?? true,
|
||||
viewerAdmitted: true,
|
||||
stopped: options.stopped ?? false,
|
||||
@@ -199,6 +201,14 @@ describe('share reader access policy', () => {
|
||||
expect(decide('session/rename', session('other-child'))).toEqual(deny('liveshare/read-only'))
|
||||
})
|
||||
|
||||
it('binds every source Session a multi-source share carries', async () => {
|
||||
const { decide } = await fixture({ sourceIds: ['source', 'second'] })
|
||||
for (const action of SESSION_READS) expect(decide(action, session('second'))).toEqual(ALLOW)
|
||||
expect(decide('session/prompt', session('second'), CALL)).toEqual(ALLOW)
|
||||
expect(decide('session/fork', session('second'), CALL)).toEqual(ALLOW)
|
||||
expect(decide('session/page', session('private'))).toEqual(deny('liveshare/read-only'))
|
||||
})
|
||||
|
||||
it('forwards Session notifications about the shared Session and the reader’s own forks only', async () => {
|
||||
const { decide } = await fixture()
|
||||
for (const id of ['source', 'own-child', 'revoked-child']) {
|
||||
|
||||
@@ -141,8 +141,8 @@ describe('viewer page resources', () => {
|
||||
let stopped = false
|
||||
const read = createViewerReadService({
|
||||
connection: readerConnection,
|
||||
sharedSessionId: () => 'source',
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionMode: 'view',
|
||||
sharedSessionIds: () => ['source'],
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionIds: ['source'], sessionMode: 'view',
|
||||
allowsFork: false, viewerAdmitted: true, stopped }),
|
||||
readAsset: async (path, peer) => {
|
||||
expect(peer).toBe(readerPeer)
|
||||
@@ -156,7 +156,7 @@ describe('viewer page resources', () => {
|
||||
expect((await read.answer('share', { op: 'asset', path: '' }, alice)).status).toBe(403)
|
||||
expect(asked).toEqual(['assets/a.js'])
|
||||
read.close()
|
||||
const pageless = createViewerReadService({ sharedSessionId: () => 'source' })
|
||||
const pageless = createViewerReadService({ sharedSessionIds: () => ['source'] })
|
||||
expect((await pageless.answer('share', { op: 'asset', path: '' }, alice)).status).toBe(503)
|
||||
pageless.close()
|
||||
})
|
||||
@@ -170,8 +170,8 @@ function recordingService() {
|
||||
const asked: string[] = []
|
||||
const read = createViewerReadService({
|
||||
connection: readerConnection,
|
||||
sharedSessionId: () => 'source',
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionMode: 'view',
|
||||
sharedSessionIds: () => ['source'],
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionIds: ['source'], sessionMode: 'view',
|
||||
allowsFork: false, viewerAdmitted: true, stopped: false }),
|
||||
readAsset: async (path) => {
|
||||
asked.push(path)
|
||||
@@ -226,8 +226,8 @@ describe('viewer page top-level files', () => {
|
||||
const ctx = ownerContext({ webServer: indexed })
|
||||
const read = createViewerReadService({
|
||||
connection: readerConnection,
|
||||
sharedSessionId: () => 'source',
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionMode: 'view',
|
||||
sharedSessionIds: () => ['source'],
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionIds: ['source'], sessionMode: 'view',
|
||||
allowsFork: false, viewerAdmitted: true, stopped: false }),
|
||||
readAsset: (path, peer) => readViewerAsset(ctx, path, peer),
|
||||
})
|
||||
@@ -300,8 +300,8 @@ describe('viewer page resource diagnostics', () => {
|
||||
*/
|
||||
const service = (debug: boolean | undefined) => createViewerReadService({
|
||||
connection: readerConnection,
|
||||
sharedSessionId: () => 'source',
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionMode: 'view',
|
||||
sharedSessionIds: () => ['source'],
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionIds: ['source'], sessionMode: 'view',
|
||||
allowsFork: false, viewerAdmitted: true, stopped: false }),
|
||||
...(debug === undefined ? {} : { debug }),
|
||||
readAsset: async () => ({
|
||||
|
||||
@@ -30,9 +30,9 @@ function harness(mode: 'view' | 'write' = 'view') {
|
||||
return Response.json({ result: { ok: true, value: { items: [] } } })
|
||||
})
|
||||
const read = createViewerReadService({
|
||||
sharedSessionId: () => 'source',
|
||||
sharedSessionIds: () => ['source'],
|
||||
sourceConfined: () => true,
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionMode: mode,
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionIds: ['source'], sessionMode: mode,
|
||||
allowsFork: mode === 'write', viewerAdmitted: true, stopped: false }),
|
||||
viewerStillAdmitted: (_shareId, actor) => allowed.has(identityKeyOf(actor)),
|
||||
refreshAdmission: (shareId, signal) => refresh(shareId, signal),
|
||||
|
||||
@@ -46,7 +46,7 @@ async function collect(read: ReturnType<typeof createViewerReadService>, endpoin
|
||||
|
||||
describe('viewer authority', () => {
|
||||
it('does not let another actor poll, replace, or cancel a stream', async () => {
|
||||
const read = reader({ sharedSessionId: () => 'source', gateway: gateway([{ private: 'alice' }]) })
|
||||
const read = reader({ sharedSessionIds: () => ['source'], gateway: gateway([{ private: 'alice' }]) })
|
||||
const open = { op: 'stream-open' as const, id: 1, endpoint: 'session/follow',
|
||||
payload: { args: { request: { address: { kind: 'session', sessionId: 'source' } } } } }
|
||||
expect((await read.answer('share', open, alice)).status).toBe(200)
|
||||
@@ -66,7 +66,7 @@ describe('viewer authority', () => {
|
||||
creator: alice, createdAt: '2026-09-11T00:00:00.000Z',
|
||||
})
|
||||
const block = { asOfSeq: 0, values: {} }
|
||||
const read = reader({ sharedSessionId: () => 'source', forks, gateway: gateway([
|
||||
const read = reader({ sharedSessionIds: () => ['source'], forks, gateway: gateway([
|
||||
{ type: 'baseline', value: { projections: { source: block, 'alice-fork': { asOfSeq: 1, values: { title: 'Mine' } } } } },
|
||||
{ type: 'projection', sessionId: 'alice-fork', seq: 2, changes: [{ key: 'title', val: 'Renamed' }] },
|
||||
]) })
|
||||
|
||||
@@ -28,9 +28,9 @@ function harness() {
|
||||
return Response.json({ result: { ok: true, value: { accepted: true } } })
|
||||
})
|
||||
const read = createViewerReadService({
|
||||
sharedSessionId: () => 'source',
|
||||
sharedSessionIds: () => ['source'],
|
||||
viewerStillAdmitted: () => true,
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionMode: 'write',
|
||||
forkStateOf: () => ({ shareId: 'share', sessionId: 'source', sessionIds: ['source'], sessionMode: 'write',
|
||||
allowsFork: true, viewerAdmitted: true, stopped: false }),
|
||||
connection: readers.connection,
|
||||
refreshAdmission: async () => {
|
||||
|
||||
@@ -129,7 +129,7 @@ async function readerHarness(creator: ShareRpcActor, target: 'fork' | 'source' =
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
const admitted = new Set([identityKeyOf(alice), identityKeyOf(bob)])
|
||||
const share: ForkShareState = { shareId: 'share', sessionId: sourceSession, sessionMode: 'view',
|
||||
const share: ForkShareState = { shareId: 'share', sessionIds: [sourceSession], sessionMode: 'view',
|
||||
allowsFork: true, viewerAdmitted: true, stopped: false }
|
||||
const accessFacts: ViewerAccessFacts = {
|
||||
share: shareId => shareId === 'share' ? share : undefined,
|
||||
@@ -184,7 +184,7 @@ async function readerHarness(creator: ShareRpcActor, target: 'fork' | 'source' =
|
||||
return peer
|
||||
},
|
||||
},
|
||||
sharedSessionId: () => sourceSession,
|
||||
sharedSessionIds: () => [sourceSession],
|
||||
forkStateOf: () => share,
|
||||
viewerStillAdmitted: (_shareId, actor) => admitted.has(identityKeyOf(actor)),
|
||||
forks,
|
||||
|
||||
@@ -72,7 +72,7 @@ type Mock = ReturnType<typeof vi.fn>
|
||||
interface DepsStub {
|
||||
connection?: { createSharedFetchHandler: Mock }
|
||||
gateway?: { wireStream: { open: Mock; failure: Mock } }
|
||||
sharedSessionId: Mock
|
||||
sharedSessionIds: Mock
|
||||
forks?: { allowsPrompt: Mock }
|
||||
prepareSource?: Mock
|
||||
prepareSourceForReader?: Mock
|
||||
@@ -112,7 +112,7 @@ function harness(): Harness {
|
||||
const deps = {
|
||||
connection: { createSharedFetchHandler: vi.fn(() => handler) },
|
||||
gateway: { wireStream: { open: gateway.open, failure: gateway.failure } },
|
||||
sharedSessionId: vi.fn(() => 'session-1'),
|
||||
sharedSessionIds: vi.fn(() => ['session-1']),
|
||||
forks: { allowsPrompt: vi.fn(() => ({ ok: true as const })) },
|
||||
prepareSource: vi.fn(async () => {}),
|
||||
prepareSourceForReader: vi.fn(),
|
||||
@@ -241,7 +241,7 @@ describe('ordinary viewer calls', () => {
|
||||
|
||||
it('answers a share that no longer exists before dispatch', async () => {
|
||||
const h = harness()
|
||||
h.deps.sharedSessionId.mockReturnValue(undefined)
|
||||
h.deps.sharedSessionIds.mockReturnValue(undefined)
|
||||
await expect(h.dispatch.answer('share-1', call('/api/session/list'), viewer))
|
||||
.resolves.toEqual({ status: 404, body: { error: 'share-not-found' } })
|
||||
expect(h.handler.fetch).not.toHaveBeenCalled()
|
||||
@@ -302,12 +302,12 @@ describe('source Session writes', () => {
|
||||
.resolves.toMatchObject({ status: 200 })
|
||||
expect(h.deps.prepareSource).not.toHaveBeenCalled()
|
||||
|
||||
// The share is read once to classify the request and again when the source path resolves it.
|
||||
h.deps.sharedSessionId.mockReturnValueOnce('session-1').mockReturnValueOnce(undefined)
|
||||
// A share this host no longer publishes is refused before any source path resolves it.
|
||||
h.deps.sharedSessionIds.mockReturnValue(undefined)
|
||||
await expect(h.dispatch.answer('share-1', call('/api/session/prompt', envelope('session-1')), viewer))
|
||||
.resolves.toEqual({ status: 403, body: { error: 'share-not-found' } })
|
||||
|
||||
h.deps.sharedSessionId.mockReturnValue('session-1')
|
||||
h.deps.sharedSessionIds.mockReturnValue(['session-1'])
|
||||
h.lifecycle.shareRefusal!.mockReturnValueOnce(refused)
|
||||
await expect(h.dispatch.answer('share-1', call('/api/session/prompt', envelope('session-1')), viewer))
|
||||
.resolves.toEqual(refused)
|
||||
@@ -526,11 +526,11 @@ describe('viewer child writes', () => {
|
||||
describe('viewer streams', () => {
|
||||
it('refuses an open for a missing share, a refusing share, or no gateway', async () => {
|
||||
const h = harness()
|
||||
h.deps.sharedSessionId.mockReturnValue(undefined)
|
||||
h.deps.sharedSessionIds.mockReturnValue(undefined)
|
||||
await expect(h.dispatch.answer('share-1', { op: 'stream-open', id: 1, endpoint: 'session/follow', payload: {} }, viewer))
|
||||
.resolves.toEqual({ status: 404, body: { error: 'share-not-found' } })
|
||||
|
||||
h.deps.sharedSessionId.mockReturnValue('session-1')
|
||||
h.deps.sharedSessionIds.mockReturnValue(['session-1'])
|
||||
h.lifecycle.shareRefusal!.mockReturnValueOnce(refused)
|
||||
await expect(h.dispatch.answer('share-1', { op: 'stream-open', id: 1, endpoint: 'session/follow', payload: {} }, viewer))
|
||||
.resolves.toEqual(refused)
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('viewer forwarded interactions', () => {
|
||||
}
|
||||
ctx.remoteAccess.registerPolicy('share-viewer', createViewerAccessPolicy({
|
||||
share: shareId => shareId === 'share'
|
||||
? { shareId, sessionId: 'source', allowsFork: true, viewerAdmitted: true, stopped: false, sessionMode: 'view' }
|
||||
? { shareId, sessionId: 'source', sessionIds: ['source'], allowsFork: true, viewerAdmitted: true, stopped: false, sessionMode: 'view' }
|
||||
: undefined,
|
||||
viewerAdmitted: () => true,
|
||||
sourceConfined: () => false,
|
||||
@@ -68,7 +68,7 @@ describe('viewer forwarded interactions', () => {
|
||||
onTestFinished(stop)
|
||||
const readers = readerConnection()
|
||||
const read = createViewerReadService({
|
||||
gateway: ctx.typertGateway, sharedSessionId: () => 'source', connection: readers.connection,
|
||||
gateway: ctx.typertGateway, sharedSessionIds: () => ['source'], connection: readers.connection,
|
||||
})
|
||||
onTestFinished(async () => {
|
||||
read.close()
|
||||
@@ -139,7 +139,7 @@ describe('viewer forwarded interactions', () => {
|
||||
return Response.json({ result: await gatewayRpc('$events/result', body.payload, request.signal, peer) })
|
||||
})
|
||||
const read = createViewerReadService({
|
||||
gateway: ctx.typertGateway, sharedSessionId: () => 'source', connection: readers.connection,
|
||||
gateway: ctx.typertGateway, sharedSessionIds: () => ['source'], connection: readers.connection,
|
||||
})
|
||||
onTestFinished(async () => {
|
||||
read.close()
|
||||
|
||||
@@ -11,7 +11,7 @@ const viewer: ShareRpcActor = { providerId: 'p', tenantId: 't', subjectId: 'alic
|
||||
const other: ShareRpcActor = { providerId: 'p', tenantId: 't', subjectId: 'bob' }
|
||||
|
||||
const SHARE: ForkShareState = {
|
||||
shareId: 'share-1', sessionId: 'session-1', allowsFork: true, viewerAdmitted: true, stopped: false,
|
||||
shareId: 'share-1', sessionIds: ['session-1'], allowsFork: true, viewerAdmitted: true, stopped: false,
|
||||
}
|
||||
|
||||
const forkAnswer = (sessionId = 'child-1', rpcId = 'rpc-1'): ViewerAnswer => ({
|
||||
|
||||
@@ -20,7 +20,7 @@ import { readerConnection } from './viewer-read-peers.ts'
|
||||
export function createPrivacyOwnerReader(dispatch: (request: Request) => Promise<Response>): PrivacyOwnerReader {
|
||||
const actor = { providerId: 'oidc', tenantId: 'fixture-tenant', subjectId: 'reader' }
|
||||
const share: ForkShareState = {
|
||||
shareId: 'share-1', sessionId: 'fx-alpha', viewerAdmitted: true,
|
||||
shareId: 'share-1', sessionIds: ['fx-alpha'], viewerAdmitted: true,
|
||||
stopped: false, allowsFork: true, sessionMode: 'write',
|
||||
}
|
||||
const owner = new Context()
|
||||
@@ -69,7 +69,7 @@ export function createPrivacyOwnerReader(dispatch: (request: Request) => Promise
|
||||
})
|
||||
})
|
||||
const reader = createViewerReadService({
|
||||
sharedSessionId: () => 'fx-alpha',
|
||||
sharedSessionIds: () => ['fx-alpha'],
|
||||
viewerStillAdmitted: () => true,
|
||||
forkStateOf: () => share,
|
||||
connection: readers.connection,
|
||||
|
||||
@@ -18,7 +18,7 @@ const otherViewer = { ...viewer, subjectId: 'ou_other' }
|
||||
function share(extra: Partial<ForkShareState> = {}): ForkShareState {
|
||||
return {
|
||||
shareId: 'share-1',
|
||||
sessionId: 'session-1',
|
||||
sessionIds: ['session-1'],
|
||||
allowsFork: false,
|
||||
viewerAdmitted: true,
|
||||
stopped: false,
|
||||
@@ -68,7 +68,7 @@ function service(options: {
|
||||
const read = createViewerReadService({
|
||||
connection: readers.connection,
|
||||
gateway: gateway(signals),
|
||||
sharedSessionId: () => 'session-1',
|
||||
sharedSessionIds: () => ['session-1'],
|
||||
forkStateOf: options.state ?? (() => share()),
|
||||
...(options.admitted === undefined ? {} : { viewerStillAdmitted: options.admitted }),
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ function reader(fetch?: ReaderFetch, admitted: (subjectId: string) => boolean =
|
||||
const read = createViewerReadService({
|
||||
connection: peers.connection,
|
||||
gateway,
|
||||
sharedSessionId: () => 'source',
|
||||
sharedSessionIds: () => ['source'],
|
||||
viewerStillAdmitted: (_shareId, actor) => admitted(actor.subjectId),
|
||||
})
|
||||
onTestFinished(() => { read.close() })
|
||||
|
||||
@@ -28,7 +28,7 @@ function ownerFixture() {
|
||||
: denied
|
||||
return Response.json({ type: 'server-response', rpcId: 'rpc', result })
|
||||
})
|
||||
const read = createViewerReadService({ connection: readers.connection, sharedSessionId: () => 'shared-session' })
|
||||
const read = createViewerReadService({ connection: readers.connection, sharedSessionIds: () => ['shared-session'] })
|
||||
onTestFinished(async () => {
|
||||
read.close()
|
||||
await readers.dispose()
|
||||
|
||||
@@ -68,8 +68,8 @@ async function harness(mode: 'view' | 'write' = 'view') {
|
||||
await ctx.plugin(SettingsController)
|
||||
await ctx.plugin(CommandsFixture)
|
||||
const commands = ctx.get('commandsFixture') as CommandsFixture
|
||||
const share: ForkShareState = { shareId: 'share', sessionId: 'shared-source',
|
||||
sessionMode: mode, allowsFork: mode !== 'view', viewerAdmitted: true, stopped: false }
|
||||
const share: ForkShareState = { shareId: 'share',
|
||||
sessionIds: ['shared-source'], sessionMode: mode, allowsFork: mode !== 'view', viewerAdmitted: true, stopped: false }
|
||||
ctx.remoteAccess.registerPolicy('share-viewer', createViewerAccessPolicy({
|
||||
share: shareId => shareId === share.shareId ? share : undefined,
|
||||
viewerAdmitted: () => true,
|
||||
@@ -92,7 +92,7 @@ async function harness(mode: 'view' | 'write' = 'view') {
|
||||
})
|
||||
})
|
||||
const reader = createViewerReadService({
|
||||
sharedSessionId: () => share.sessionId,
|
||||
sharedSessionIds: () => share.sessionIds,
|
||||
viewerStillAdmitted: () => true,
|
||||
forkStateOf: () => share,
|
||||
connection: readers.connection,
|
||||
|
||||
@@ -30,7 +30,7 @@ function burstGateway(count: number): NonNullable<ViewerReadDeps['gateway']> {
|
||||
describe('Liveshare viewer stream batching', () => {
|
||||
it('[P0] answers a queued burst in bounded batches, in order and without losing a value', async () => {
|
||||
const read = createViewerReadService({
|
||||
connection: readerConnection().connection, gateway: burstGateway(BURST), sharedSessionId: () => 'session-1',
|
||||
connection: readerConnection().connection, gateway: burstGateway(BURST), sharedSessionIds: () => ['session-1'],
|
||||
})
|
||||
expect((await read.answer('share-1', { op: 'stream-open', id: 1, endpoint: 'session/follow',
|
||||
payload: { args: { request: { address: { kind: 'session', sessionId: 'session-1' } } } } }, READER)).status).toBe(200)
|
||||
@@ -74,7 +74,7 @@ describe('Liveshare viewer stream batching', () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
const read = createViewerReadService({ connection: readerConnection().connection, gateway, sharedSessionId: () => 'session-1' })
|
||||
const read = createViewerReadService({ connection: readerConnection().connection, gateway, sharedSessionIds: () => ['session-1'] })
|
||||
expect((await read.answer('share-1', { op: 'stream-open', id: 1, endpoint: 'session/follow',
|
||||
payload: { args: { request: { address: { kind: 'session', sessionId: 'session-1' } } } } }, READER)).status).toBe(200)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ function recordingGateway() {
|
||||
}
|
||||
|
||||
function service(gateway: ViewerReadGateway, readers: ReaderConnection = readerConnection()) {
|
||||
return createViewerReadService({ connection: readers.connection, gateway, sharedSessionId: () => 'session-1' })
|
||||
return createViewerReadService({ connection: readers.connection, gateway, sharedSessionIds: () => ['session-1'] })
|
||||
}
|
||||
|
||||
/** One gateway whose stream rejects on its first poll, as a broken owner-side read does. */
|
||||
|
||||
@@ -42,7 +42,7 @@ function gateway(signals: AbortSignal[]): NonNullable<ViewerReadDeps['gateway']>
|
||||
async function openedStream(id: number) {
|
||||
const signals: AbortSignal[] = []
|
||||
const read = createViewerReadService({
|
||||
connection: readerConnection().connection, gateway: gateway(signals), sharedSessionId: () => 'session-1',
|
||||
connection: readerConnection().connection, gateway: gateway(signals), sharedSessionIds: () => ['session-1'],
|
||||
})
|
||||
const opened = await read.answer('share-1', { op: 'stream-open', id, endpoint: 'session/follow',
|
||||
payload: { args: { request: { address: { kind: 'session', sessionId: 'session-1' } } } } }, viewer)
|
||||
@@ -88,7 +88,7 @@ describe('Liveshare viewer stream reclamation', () => {
|
||||
vi.useFakeTimers()
|
||||
const signals: AbortSignal[] = []
|
||||
const read = createViewerReadService({
|
||||
connection: readerConnection().connection, gateway: gateway(signals), sharedSessionId: () => 'session-1',
|
||||
connection: readerConnection().connection, gateway: gateway(signals), sharedSessionIds: () => ['session-1'],
|
||||
})
|
||||
// Two shares one page reads side by side: the closed page abandons both, and both are reclaimed.
|
||||
for (const shareId of ['share-1', 'share-2']) {
|
||||
|
||||
@@ -84,7 +84,7 @@ async function harness(mode: ViewerSessionMode = 'write') {
|
||||
await forks.record({ shareId: 'share', sourceSessionId: source, childSessionId: child,
|
||||
creator: actor, boundary: 0, createdAt: '2026-10-12T00:00:00Z' })
|
||||
forks.installEnforcement(child, READ_ONLY_FORK_CEILING)
|
||||
const share: ForkShareState = { shareId: 'share', sessionId: source, sessionMode: mode,
|
||||
const share: ForkShareState = { shareId: 'share', sessionIds: [source], sessionMode: mode,
|
||||
allowsFork: mode !== 'view', viewerAdmitted: true, stopped: false }
|
||||
const accessFacts: ViewerAccessFacts = {
|
||||
share: shareId => shareId === 'share' ? share : undefined,
|
||||
@@ -99,7 +99,7 @@ async function harness(mode: ViewerSessionMode = 'write') {
|
||||
const dispatch = vi.fn((request: Request, peer: PeerScope) => shared.fetch(request, peer))
|
||||
const peers: PeerScope[] = []
|
||||
const reader = createViewerReadService({
|
||||
sharedSessionId: () => source, forks,
|
||||
sharedSessionIds: () => [source], forks,
|
||||
forkStateOf: () => share,
|
||||
sourceConfined: () => true, viewerStillAdmitted: () => true,
|
||||
connection: {
|
||||
|
||||
@@ -1854,6 +1854,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [{ name: 'request', description: 'Session, title, access, lifetime, and both switches the owner chose.' }],
|
||||
returns: 'The created share\'s link and stored values.',
|
||||
},
|
||||
{
|
||||
signature: '@Remote(\'addSessions\') addSessions(request: AddShareSessionsRequest): Promise<ShareSessions>',
|
||||
description: 'Add source Sessions to one share this host already publishes.',
|
||||
parameters: [{ name: 'request', description: 'Share and the Sessions to add.' }],
|
||||
returns: 'The source Sessions the share now serves.',
|
||||
},
|
||||
{
|
||||
signature: '@Remote(\'list\') list(): Promise<OwnerShareList>',
|
||||
description: 'Refresh titles and Liveshare server authority, then list this host\'s shares.',
|
||||
@@ -5620,6 +5626,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'AdapterRegistrationHandle',
|
||||
declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AddShareSessionsRequest',
|
||||
declaration: 'export interface AddShareSessionsRequest {\n readonly shareId: string;\n readonly sessionIds: readonly string[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'AdmittedPromptContentPart',
|
||||
declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n} | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};',
|
||||
@@ -6234,7 +6244,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CreatedShare',
|
||||
declaration: 'export interface CreatedShare {\n readonly link: string;\n readonly shareId: string;\n readonly sessionId: string;\n readonly sessionMode: \'view\' | \'write\';\n readonly access: ShareAccess;\n}',
|
||||
declaration: 'export interface CreatedShare {\n readonly link: string;\n readonly shareId: string;\n readonly sessionId: string;\n readonly sessionIds: readonly string[];\n readonly sessionMode: \'view\' | \'write\';\n readonly access: ShareAccess;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CreatedWorktree',
|
||||
@@ -7642,7 +7652,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'OwnerShareView',
|
||||
declaration: 'export interface OwnerShareView {\n readonly shareId: string;\n readonly sessionId: string;\n readonly sessionTitle: string;\n readonly link: string;\n readonly createdAt: string;\n readonly expiresAt?: string;\n readonly allowsFork: boolean;\n readonly sessionMode: \'view\' | \'write\';\n readonly viewerPluginAccess?: ViewerPluginAccess;\n readonly viewerPlugins: readonly ViewerPluginPolicy[];\n readonly online: boolean;\n readonly access?: ShareAccess;\n}',
|
||||
declaration: 'export interface OwnerShareView {\n readonly shareId: string;\n readonly sessionId: string;\n readonly sessionIds: readonly string[];\n readonly sessionTitle: string;\n readonly link: string;\n readonly createdAt: string;\n readonly expiresAt?: string;\n readonly allowsFork: boolean;\n readonly sessionMode: \'view\' | \'write\';\n readonly viewerPluginAccess?: ViewerPluginAccess;\n readonly viewerPlugins: readonly ViewerPluginPolicy[];\n readonly online: boolean;\n readonly access?: ShareAccess;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PackageResult',
|
||||
@@ -9152,6 +9162,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ShareCapabilityOffers',
|
||||
declaration: 'export interface ShareCapabilityOffers {\n readonly capabilities: readonly ShareCapabilityGroup[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'ShareSessions',
|
||||
declaration: 'export interface ShareSessions {\n readonly shareId: string;\n readonly sessionIds: readonly string[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'ShellExecRequest',
|
||||
declaration: 'export interface ShellExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n onExpiry?: ShellExpiryPolicy | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy?: SandboxExecutionPolicy | undefined;\n}',
|
||||
|
||||
@@ -371,8 +371,10 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
OfficeFileResult: 'office-kit.md',
|
||||
OfficeKitPriority: 'office-kit.md',
|
||||
OfficeKitGeneration: 'office-kit.md',
|
||||
AddShareSessionsRequest: 'liveshare.md',
|
||||
CreatedShare: 'liveshare.md',
|
||||
CreateShareRequest: 'liveshare.md',
|
||||
ShareSessions: 'liveshare.md',
|
||||
OwnerIdentityRequest: 'liveshare.md',
|
||||
OwnerIdentityStatus: 'liveshare.md',
|
||||
OwnerRelayState: 'liveshare.md',
|
||||
|
||||
@@ -71,7 +71,9 @@ export const EXISTING_PLUGIN_ENTRY_EXPORTS = {
|
||||
'checkDoneValue', 'detachResidual', 'encodeJsonPlain', 'hasNonLosslessNumber', 'hasUnsafeIntegerToken',
|
||||
'hostFrameParseCeiling', 'logTruncationMarker', 'readProcessStart', 'resolvePythonBin', 'validateChildFrame',
|
||||
],
|
||||
'@deepseek-ai/dsh-experimental-worktree': ['generatedWorktreeName'],
|
||||
'@deepseek-ai/dsh-experimental-worktree': [
|
||||
'CREATED_RECORD', 'createdWorktreeSchema', 'generatedWorktreeName', 'worktreeProjection',
|
||||
],
|
||||
'@deepseek-ai/dsh-file-reference-local': [
|
||||
'DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES', 'DEFAULT_FILE_SEARCH_MAX_ENTRIES', 'DEFAULT_FILE_SEARCH_MAX_RESULTS',
|
||||
'FILE_REFERENCE_PROMPT', 'WorkspaceFileSearch', 'activeAtToken', 'formatFileMention',
|
||||
@@ -207,6 +209,7 @@ export const EXISTING_PLUGIN_ENTRY_EXPORTS = {
|
||||
],
|
||||
'@deepseek-ai/dsh-terminal': ['TerminalSessionId'],
|
||||
'@deepseek-ai/dsh-terminal-bash': ['BashTerminalBackend', 'PWSH_PROMPT_SETUP'],
|
||||
'@deepseek-ai/dsh-terminal-panel': ['GUIDANCE'],
|
||||
'@deepseek-ai/dsh-tool-call-timeout-policy': ['TOOL_TIMEOUT'],
|
||||
'@deepseek-ai/dsh-tool-executor-native': ['NativeToolExecutor'],
|
||||
'@deepseek-ai/dsh-tool-executor-ptc': [
|
||||
|
||||
Reference in new issue
Block a user