revert(liveshare-protocol): reset the wire protocol to version 1 and drop development history

The v2 upgrade guide and the fork-readonly normalization were development-period artifacts that never shipped, so the protocol returns to version 1: the guide triplet is deleted with its references, the withdrawn tier is no longer named anywhere (unknown tiers are rejected generically), and the README plus the version test state version 1. The file-upload capability and its additive field stay.
This commit is contained in:
mektpoy committed 2026-10-23 16:13:22 +08:00
1 parent 73df748c7f
commit 590826f66d
11 files changed
+24 -112

No files matched your search

@@ -1,16 +0,0 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/liveshare-wire-protocol-version-2/guide.md
/:
en: d6d74981a90a107b
zh: 2445aa783257140f
/move-the-liveshare-wire-protocol-to-version-2:
en: d263ba44f18b66b6
zh: dfbccc1489a3ab82
/move-the-liveshare-wire-protocol-to-version-2/change:
en: 217ab0901153ccf6
zh: 6adc0bc73e2e9a8f
/move-the-liveshare-wire-protocol-to-version-2/migration:
en: 6b35e5397da2e12d
zh: 2beb152b15493f48
@@ -1,20 +0,0 @@
---
kind: upgrade-guide
description: "The Liveshare wire protocol moves to version 2: the accessMode vocabulary is open|selected and the withdrawn approval value is refused, not reinterpreted."
---
# Move the Liveshare wire protocol to version 2
English | [中文](guide.zh.md)
## Change
`LIVESHARE_PROTOCOL_VERSION` is now `2`. Relay registration states that version and the Relay refuses a declared mismatch; there is no version translation. In the same release the access-mode vocabulary is `open | selected`: a registration still sending the retired `approval` name is refused as an invalid value (fail-closed) instead of being reinterpreted as `open` or `selected`. A share stored under the retired name keeps its ACL and is readable; the cross-site `share-access-state` frame reports it with the narrowed `selected` name and leaves readers the access they already hold.
The protocol version stays `2`: a new optional `allowsFileUpload` share/access-state field and the `file-upload` capability are additive, and a frame without the field reads as uploads off.
## Migration
1. Upgrade the owner Host, its gateway worker, and the Relay together. An owner worker or a Relay still declaring protocol version 1 is refused at registration with the declared mismatch (HTTP 426 for the owner workers, the Relay's registration refusal for shares), so upgrade every peer that registers or answers a share before serving viewers.
2. Replace `access_mode=approval` in stored deployment automation with `open` or `selected`. A registration still carrying `approval` is refused; inspect the Relay log for the refusal naming the invalid value.
3. Read one share's `share-access-state` frame after the upgrade: `accessMode` is `open` or `selected`, and a share created before the vocabulary change reports `selected` with its unchanged allowlist.
@@ -1,20 +0,0 @@
---
kind: upgrade-guide
description: "Liveshare 线协议升到版本 2:accessMode 词表为 open|selected,已退场的 approval 值被拒绝而不是被重新解释。"
---
# 把 Liveshare 线协议升到版本 2
[English](guide.md) | 中文
## 变更
`LIVESHARE_PROTOCOL_VERSION` 现在是 `2`。Relay 注册声明该版本,Relay 拒绝已声明的版本不匹配;本构建不提供逐版本翻译。同一发布里 access-mode 词表是 `open | selected`:仍发送已退场 `approval` 名称的注册会被当作非法值拒绝(fail-closed),而不会被重新解释为 `open` 或 `selected`。以已退场名称存储的分享保留其 ACL 与可读性;跨站 `share-access-state` 帧以收窄后的 `selected` 名称上报它,并保留读者已经持有的访问。
协议版本仍为 `2`:新增的可选 `allowsFileUpload` 分享/access-state 字段与 `file-upload` 能力是增量的,缺少该字段的帧读作上传关闭。
## 迁移
1. 同时升级 owner Host、它的 gateway worker 与 Relay。仍声明协议版本 1 的 owner worker 或 Relay 会在注册时被以声明的版本不匹配拒绝(owner worker 为 HTTP 426,分享为 Relay 的注册拒绝),因此在服务 viewer 之前先升级每个会注册或应答分享的 peer。
2. 把存储的部署自动化里的 `access_mode=approval` 替换为 `open` 或 `selected`。仍携带 `approval` 的注册会被拒绝;在 Relay 日志里查看点名该非法值的拒绝。
3. 升级后读取一个分享的 `share-access-state` 帧:`accessMode` 是 `open` 或 `selected`,词表变更前创建的分享上报 `selected`,其 allowlist 保持不变。
@@ -30,8 +30,8 @@
en: d80868b3f0050542
zh: 56bad4bab440b2bd
/deepseek-ai-dsh-experimental-liveshare-protocol/known-limitations-and-deferred-work:
en: ca0d1301ae58c8b8
zh: 46a25b25341e8eef
en: 9a1023be7ada2b5f
zh: 39845d3097636f94
/deepseek-ai-dsh-experimental-liveshare-protocol/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
@@ -75,7 +75,7 @@ Relay compatibility uses declared versions, capabilities, and explicit refusals,
<a id="known-limitations-and-deferred-work"></a>
- Gateway requests require the Relay's authenticated actor outside the opaque body; frames without that actor are refused.
- The Owner Host and worker ship together. The supervisor mints a branded UUID `registrationToken` per logical add, invalidates it on removal, and replays it only for the same retained registration after process restart. Lifecycle, requests, and cancellations echo that token with the share id; per-request `TunnelRequestToken` remains independent. IPC decoders refuse missing identities, and neither token enters Relay URLs or wire frames. Same-package IPC does not negotiate versions; Relay wire frames retain numeric ids and protocol version 2. Compatibility capabilities include `file-upload`: a relay registration that declares it may carry `allowsFileUpload` on the share support and access-state frames; every other peer keeps the stored value and a frame without the field reads as uploads off.
- The Owner Host and worker ship together. The supervisor mints a branded UUID `registrationToken` per logical add, invalidates it on removal, and replays it only for the same retained registration after process restart. Lifecycle, requests, and cancellations echo that token with the share id; per-request `TunnelRequestToken` remains independent. IPC decoders refuse missing identities, and neither token enters Relay URLs or wire frames. Same-package IPC does not negotiate versions; Relay wire frames retain numeric ids and protocol version 1. Compatibility capabilities include `file-upload`: a relay registration that declares it may carry `allowsFileUpload` on the share support and access-state frames; every other peer keeps the stored value and a frame without the field reads as uploads off.
- Owners declaring `share-access-state` receive committed, id-less ACL/mode/expiry snapshots with a durable monotonic version. The worker forwards snapshots only from its current socket; the Host clears authority on socket loss and refreshes the authenticated requests snapshot before external execution admission. An unavailable snapshot fails closed instead of borrowing persisted ACLs.
- **Historical release support is not verified** — `tests/frames/v1` records concrete frames and refusal obligations, but does not identify a published Owner release. The gateway declares `LIVESHARE_PROTOCOL_VERSION` and implemented capabilities; the Relay rejects a declared mismatch and sends optional lifecycle frames only to declared consumers. These checks preserve recorded fields and explicit refusals, not a per-release translation or an unbounded support window.
@@ -75,7 +75,7 @@ Relay 兼容性依靠声明版本、能力和明确拒绝,不提供逐发布
<a id="known-limitations-and-deferred-work"></a>
- 网关请求必须在不透明请求体之外携带 Relay 已认证主体;没有该主体的帧被拒绝。
- Owner Host 与 worker 同包发布。Supervisor 为每次逻辑添加生成品牌化 UUID `registrationToken`,移除时使其失效,进程重启只为仍保留的同一注册重播该身份。生命周期、请求及取消事件回传该 token 与分享 ID;逐请求 `TunnelRequestToken` 独立存在。IPC 编解码拒绝缺失身份,两种 token 都不进入 Relay URL 或线协议帧。同包 IPC 不协商版本;Relay 线协议帧保留数字 ID 与协议版本 2。兼容能力包含 `file-upload`:声明它的 Relay 注册可在分享支持与 access-state 帧上携带 `allowsFileUpload`;其它 peer 保留已存值,而缺少该字段的帧读作上传关闭。
- Owner Host 与 worker 同包发布。Supervisor 为每次逻辑添加生成品牌化 UUID `registrationToken`,移除时使其失效,进程重启只为仍保留的同一注册重播该身份。生命周期、请求及取消事件回传该 token 与分享 ID;逐请求 `TunnelRequestToken` 独立存在。IPC 编解码拒绝缺失身份,两种 token 都不进入 Relay URL 或线协议帧。同包 IPC 不协商版本;Relay 线协议帧保留数字 ID 与协议版本 1。兼容能力包含 `file-upload`:声明它的 Relay 注册可在分享支持与 access-state 帧上携带 `allowsFileUpload`;其它 peer 保留已存值,而缺少该字段的帧读作上传关闭。
- 声明 `share-access-state` 的 owner 接收已提交、无 id 的 ACL、模式与到期时间快照,快照携带持久单调版本。worker 只转发当前 socket 的快照;Host 在 socket 丢失后清空授权,并在外部执行准入前刷新已认证的请求快照。快照不可用时默认拒绝,不借用持久化 ACL。
- **历史发布版本支持尚未核实**——`tests/frames/v1` 记录具体帧和拒绝义务,但没有标识已发布的 Owner 版本。网关声明 `LIVESHARE_PROTOCOL_VERSION` 与已实现能力;Relay 拒绝已声明的版本不匹配,仅向已声明消费者发送可选生命周期帧。这些检查保留已录字段和明确拒绝,不提供逐发布版本翻译或无限支持窗口。
@@ -11,7 +11,7 @@ export type TunnelRequestToken = Branded<'TunnelRequestToken'>
export type TunnelRegistrationToken = Branded<'TunnelRegistrationToken'>
/** Relay wire version; Host/worker IPC ships together and is not negotiated by this number. */
export const LIVESHARE_PROTOCOL_VERSION = 2
export const LIVESHARE_PROTOCOL_VERSION = 1
/**
* Optional behaviors this build offers the Relay, beyond the frames themselves.
@@ -58,11 +58,9 @@ export interface TunnelShareSpec {
* Tier this share was created with.
*
* `view` reads the shared Session and drives its loop inside the granted workspace without writing it;
* `write` also modifies the files inside that workspace. `fork-readonly` is the withdrawn tier an older
* owner still names, and it reads back as `view` with that owner's switch deciding the forking half.
* Absent means the share predates tiers and reads as `view`.
* `write` also modifies the files inside that workspace. Absent reads as `view`.
*/
readonly sessionMode?: 'view' | 'fork-readonly' | 'write'
readonly sessionMode?: 'view' | 'write'
/**
* Whether an admitted viewer may attach uploaded files in this share's Session and its own forks.
*
@@ -190,15 +188,13 @@ export interface ShareAccessStateFrame {
/**
* Tier the share carries.
*
* `fork-readonly` is the withdrawn tier an older Relay or a stored row may still name; a reader of
* this frame treats it as `view`, because the forking half is {@link ShareAccessStateFrame.allowsFork}.
* The current tiers are `view` and `write`; any other value is rejected.
*/
readonly sessionMode: 'view' | 'fork-readonly' | 'write'
readonly sessionMode: 'view' | 'write'
/**
* Whether this share lets an admitted viewer fork it.
*
* Absent on a Relay older than the split between the tier and the switch, and then a `fork-readonly`
* tier stands for it: that is what the withdrawn name granted.
* Absent means no forking.
*/
readonly allowsFork?: boolean
/** Whether this share permits viewer file uploads; always present after parsing, absent means false. */
@@ -382,26 +378,9 @@ export function parseTunnelWorkerCommand(value: unknown): TunnelWorkerCommand |
return typeof value.shareId === 'string' && value.shareId !== ''
? { type: 'remove-share', shareId: value.shareId }
: undefined
case 'add-share': {
if (!isRegistrationToken(value.registrationToken) || !isTunnelShareSpec(value.share)) return undefined
const share = value.share
return {
type: 'add-share',
registrationToken: value.registrationToken,
share: {
...share,
...(share.sessionMode === undefined
? {}
: {
sessionMode: normalizeShareSessionMode(share.sessionMode),
// The withdrawn name granted forking; keep that half when the value named no switch.
...(share.sessionMode === 'fork-readonly' && share.allowsFork === undefined
? { allowsFork: true }
: {}),
}),
},
}
}
case 'add-share':
return isRegistrationToken(value.registrationToken) && isTunnelShareSpec(value.share)
? { type: 'add-share', registrationToken: value.registrationToken, share: value.share } : undefined
case 'share-rpc-response': {
if (!isTunnelRequestIdentity(value) || !Number.isSafeInteger(value.status)
|| Number(value.status) < 100 || Number(value.status) > 599) return undefined
@@ -467,15 +446,6 @@ export function parseTunnelWorkerEvent(value: unknown): TunnelWorkerEvent | unde
* @param value Candidate spec.
* @returns True when every required field is present with the declared type.
*/
/**
* Read the withdrawn `fork-readonly` tier as `view`; the forking half is `allowsFork`.
* @param mode - Tier a wire or IPC value names.
* @returns The current tier name.
*/
function normalizeShareSessionMode(mode: 'view' | 'fork-readonly' | 'write'): 'view' | 'write' {
return mode === 'fork-readonly' ? 'view' : mode
}
function isTunnelShareSpec(value: unknown): value is TunnelShareSpec {
if (!isRecord(value)) return false
const mode = value.sessionMode
@@ -491,7 +461,7 @@ function isTunnelShareSpec(value: unknown): value is TunnelShareSpec {
&& (value.dshVersion === undefined || typeof value.dshVersion === 'string')
&& (value.ownerInstanceId === undefined || typeof value.ownerInstanceId === 'string')
&& (value.allowsFork === undefined || typeof value.allowsFork === 'boolean')
&& (mode === undefined || mode === 'view' || mode === 'fork-readonly' || mode === 'write')
&& (mode === undefined || mode === 'view' || mode === 'write')
&& (value.allowsFileUpload === undefined || typeof value.allowsFileUpload === 'boolean')
&& (value.ownerCredential === undefined || typeof value.ownerCredential === 'string')
&& Array.isArray(value.allowedSubjectIds)
@@ -541,15 +511,14 @@ export function parseShareAccessState(value: unknown): ShareAccessStateFrame | u
|| !Number.isSafeInteger(value.version) || Number(value.version) < 0
|| (value.accessMode !== 'open' && value.accessMode !== 'selected')
|| !isSubjectIds(value.allowedSubjectIds)
|| (value.sessionMode !== 'view' && value.sessionMode !== 'fork-readonly' && value.sessionMode !== 'write')
|| (value.sessionMode !== 'view' && value.sessionMode !== 'write')
|| (value.allowsFork !== undefined && typeof value.allowsFork !== 'boolean')
|| (value.allowsFileUpload !== undefined && typeof value.allowsFileUpload !== 'boolean')
|| (value.expiresAt !== undefined && (typeof value.expiresAt !== 'string' || !Number.isFinite(Date.parse(value.expiresAt))))) {
return undefined
}
return { type: 'share-access-state', version: Number(value.version), accessMode: value.accessMode,
allowedSubjectIds: [...value.allowedSubjectIds], sessionMode: normalizeShareSessionMode(value.sessionMode),
...(value.sessionMode === 'fork-readonly' && value.allowsFork === undefined ? { allowsFork: true } : {}),
allowedSubjectIds: [...value.allowedSubjectIds], sessionMode: value.sessionMode,
allowsFileUpload: value.allowsFileUpload === true,
...(value.allowsFork === undefined ? {} : { allowsFork: value.allowsFork }),
...(value.expiresAt === undefined ? {} : { expiresAt: value.expiresAt }) }
@@ -7,7 +7,7 @@
"shareId": "share-three",
"sessionId": "session-three",
"sessionTitle": "Third",
"accessMode": "approval",
"accessMode": "future",
"relay": "wss://relay.example",
"gatewayToken": "token-three",
"ownerDisplayName": "Owner",
@@ -64,14 +64,13 @@ describe('Host/worker registration identity', () => {
})
})
describe('withdrawn tier at the IPC boundary', () => {
it('reads fork-readonly back as view and keeps the caller switch', () => {
describe('unknown tier at the IPC boundary', () => {
it('rejects a tier the current vocabulary does not name', () => {
const share = {
shareId: 'share', sessionId: 'session', sessionTitle: 'Source', accessMode: 'selected' as const,
relay: 'https://relay.test', gatewayToken: 'token', ownerDisplayName: 'Owner',
sessionMode: 'fork-readonly' as const, allowsFork: true, allowedSubjectIds: [],
sessionMode: 'future', allowsFork: true, allowedSubjectIds: [],
}
const parsed = parseTunnelWorkerCommand({ type: 'add-share', registrationToken: randomUUID(), share })
expect(parsed).toMatchObject({ share: { sessionMode: 'view', allowsFork: true } })
expect(parseTunnelWorkerCommand({ type: 'add-share', registrationToken: randomUUID(), share })).toBeUndefined()
})
})
@@ -178,7 +178,7 @@ describe('recorded frames', () => {
})
describe('wire protocol version', () => {
it('states protocol version 2 for the narrowed access-mode vocabulary', () => {
expect(LIVESHARE_PROTOCOL_VERSION).toBe(2)
it('states the initial wire protocol version', () => {
expect(LIVESHARE_PROTOCOL_VERSION).toBe(1)
})
})
@@ -15,7 +15,7 @@ describe('Relay authority snapshot frame', () => {
expect(LIVESHARE_CAPABILITIES).toContain('share-access-state')
})
it.each([
{ version: -1 }, { version: 0.5 }, { accessMode: 'approval' }, { sessionMode: 'future' },
{ version: -1 }, { version: 0.5 }, { accessMode: 'future' }, { sessionMode: 'future' },
{ allowedSubjectIds: [1] }, { allowedSubjectIds: [''] }, { expiresAt: 'not-a-time' },
])('refuses malformed authority fields %j', (fields) => {
expect(parseRelayFrame(JSON.stringify({ ...state, ...fields }))).toBeUndefined()