diff --git a/README.md b/README.md index 3f743c5b..10004a52 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Connect [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) to t Browser operation remains text-only: pages become structured text with a numbered inventory of interactive elements, and the model addresses those elements by number. dsh 0.2.0 multimodal chat is separate from that page channel—the side panel accepts PNG, JPEG, WebP, and GIF attachments when the host advertises image support, while browser tools still never capture screenshots. > [!IMPORTANT] -> The workspace pins dsh 0.2.0-rc.2, the minimum supported runtime. Older DSH releases are not supported. +> The workspace pins dsh 0.2.0-rc.2, the minimum supported runtime, and the bridge also runs on dsh 0.2.1 (verified on 0.2.1-rc.5). Older DSH releases are not supported. ## Quick install diff --git a/README.zh.md b/README.zh.md index fc8fe4d4..c9e6acec 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,7 +11,7 @@ 浏览器操作仍采用纯文本设计:页面会转换为结构化文本和带编号的交互元素清单,模型通过编号定位元素。dsh 0.2.0 的多模态对话走独立通道——宿主声明图片能力时,侧栏可发送 PNG、JPEG、WebP 和 GIF;浏览器工具本身仍不会截取页面截图。 > [!IMPORTANT] -> 当前工作区固定使用 dsh 0.2.0-rc.2,也是最低支持版本;不再支持旧版 DSH。 +> 当前工作区固定使用 dsh 0.2.0-rc.2,也是最低支持版本;桥插件同时支持 dsh 0.2.1(已在 0.2.1-rc.5 实测)。不再支持旧版 DSH。 ## 快速安装 diff --git a/packages/browser/bridge-browser/package.json b/packages/browser/bridge-browser/package.json index 8be073c4..7de8cc16 100644 --- a/packages/browser/bridge-browser/package.json +++ b/packages/browser/bridge-browser/package.json @@ -50,19 +50,19 @@ ], "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4", - "@deepseek-ai/dsh-agent": "0.2.0-rc.2", - "@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2", - "@deepseek-ai/dsh-attachment": "0.2.0-rc.2", - "@deepseek-ai/dsh-client-connection": "0.2.0-rc.2", - "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2", - "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2", - "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2", - "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2", - "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2", - "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", - "@deepseek-ai/dsh-llm": "0.2.0-rc.2", - "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.2", - "@deepseek-ai/dsh-tools": "0.2.0-rc.2", + "@deepseek-ai/dsh-agent": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-attachment": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-client-connection": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", + "@deepseek-ai/dsh-tools": "0.2.0-rc.2 || >=0.2.1-alpha.0 <0.3.0-0", "react": ">=18.2.0 <19" }, "peerDependenciesMeta": { diff --git a/packages/browser/bridge-browser/src/index.ts b/packages/browser/bridge-browser/src/index.ts index 1d77aa76..dabe27ee 100644 --- a/packages/browser/bridge-browser/src/index.ts +++ b/packages/browser/bridge-browser/src/index.ts @@ -42,6 +42,7 @@ import { resolveToken } from './token.ts' import { createRemoteHostApi, type HostConnectionLike, + type PeerLike, type TypertGatewayLike, } from './remote-host-api.ts' import { isRecord, type BrowserHostApi } from './host-api.ts' @@ -142,7 +143,28 @@ export async function apply(ctx: Context, config: Config): Promise { } if (connection === undefined) throw new Error('bridge-browser: dsh connection service is required') const tokenRes = await resolveToken(resolved.token) - mountBridge(ctx, resolved, tokenRes, createRemoteHostApi(gateway, connection)) + mountBridge(ctx, resolved, tokenRes, createRemoteHostApi(gateway, connection, createHostPeer(ctx, connection))) +} + +/** + * Mint the in-process Peer this Host speaks for. + * + * dsh 0.2.1 validates every Gateway call's Peer (`assertPeerLive`), so forwarding + * `undefined` fails with `Cannot read properties of undefined (reading + * 'signal')`. 0.2.0 has no `createPeer` and needs none: the Host then keeps the + * pre-0.2.1 behaviour and the Peer slot stays empty. + * + * @param ctx - Cordis context owning the Peer scope. + * @param connection - Host Connection service. + * @returns the Peer, or undefined on a runtime that does not mint them. + */ +function createHostPeer(ctx: Context, connection: HostConnectionLike): PeerLike | undefined { + if (typeof connection.createPeer !== 'function') return undefined + const peer = connection.createPeer({ principal: { kind: 'owner' } }) + // The Peer lives exactly as long as this plugin instance; a disposed Peer + // would make every later call fail with `gateway/peer-disposed`. + ctx.effect(() => () => peer.dispose?.(), 'bridge-browser: Host Peer scope') + return peer } function mountBridge( diff --git a/packages/browser/bridge-browser/src/remote-host-api.ts b/packages/browser/bridge-browser/src/remote-host-api.ts index e620d517..5f35c2cf 100644 --- a/packages/browser/bridge-browser/src/remote-host-api.ts +++ b/packages/browser/bridge-browser/src/remote-host-api.ts @@ -44,9 +44,23 @@ export interface TypertGatewayLike { readonly method: string readonly args: Readonly> readonly signal?: AbortSignal + /** + * The Peer the call speaks for. dsh 0.2.0 ignored this slot; from 0.2.1 the + * Host asserts the Peer is live before dispatch, so an in-process caller + * that omits it fails with `Cannot read properties of undefined + * (reading 'signal')`. + */ + readonly peer?: unknown }): Promise } +/** Structural subset of dsh 0.2.1's Connection Peer. */ +export interface PeerLike { + readonly signal: AbortSignal + readonly principal: { readonly kind: string } + dispose?(): Promise | void +} + /** * Empty Client→Host uplink for in-process Host wireStream.open calls. * dsh 0.2 requires the uplink slot; Gateway-owned endpoints ($events) discard @@ -65,12 +79,20 @@ const EMPTY_WIRE_UPLINK: AsyncIterable = { * these as three-arg maps AbortSignal onto uplink and leaves signal * undefined (hello.ok → stream-failed → WS 1011). */ -function openWireStream(gateway: TypertGatewayLike, endpoint: string, payload: unknown, signal: AbortSignal): Promise> { +function openWireStream( + gateway: TypertGatewayLike, + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer?: PeerLike, +): Promise> { const open = gateway.wireStream.open if (open.length === 3) { return open(endpoint, payload, signal) } - return open(endpoint, payload, EMPTY_WIRE_UPLINK, undefined, signal) + // dsh 0.2.1 validates this slot (`assertPeerLive`), so an in-process Host must + // hand over the Connection Peer it speaks for; 0.2.0 tolerates `undefined`. + return open(endpoint, payload, EMPTY_WIRE_UPLINK, peer, signal) } /** Structural subset of dsh 0.2's Host Connection service. */ @@ -78,6 +100,11 @@ export interface HostConnectionLike { createSharedFetchHandler(channel: '/api'): { fetch(request: Request): Promise } + /** + * Mint one in-process Peer owned by the reading Context. + * Absent on dsh 0.2.0, where Host calls did not need a Peer. + */ + createPeer?(options: { readonly principal: { readonly kind: 'owner' } }): PeerLike } interface InvokeTarget { @@ -106,8 +133,9 @@ interface PendingQuestion { export function createRemoteHostApi( gateway: TypertGatewayLike, connection: HostConnectionLike, + peer?: PeerLike, ): BrowserHostApi { - return new RemoteHostApi(gateway, connection) + return new RemoteHostApi(gateway, connection, peer) } class RemoteHostApi implements BrowserHostApi { @@ -120,10 +148,22 @@ class RemoteHostApi implements BrowserHostApi { constructor( private readonly gateway: TypertGatewayLike, connection: HostConnectionLike, + /** Peer minted by the Connection service on dsh 0.2.1+; absent on 0.2.0. */ + private readonly peer?: PeerLike, ) { this.fetchHandler = connection.createSharedFetchHandler('/api') } + /** Call the Gateway as this Host's Peer; 0.2.0 hosts accept the request without one. */ + private invokeRemote(request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + readonly signal?: AbortSignal + }): Promise { + return this.gateway.invoke(this.peer === undefined ? request : { ...request, peer: this.peer }) + } + async call(call: HostRpcCall): Promise { if (call.method === 'session.history') return this.sessionHistory(call) if (call.method === 'session.models') return this.sessionModels(call) @@ -140,7 +180,7 @@ class RemoteHostApi implements BrowserHostApi { const sessionId = sessionIdOf(call.payload) if (sessionId !== undefined) await this.activeEvents?.ensureSessionFollow(sessionId, call.signal) } - const value = await this.gateway.invoke({ + const value = await this.invokeRemote({ namespace: target.namespace, method: target.method, args: target.args, @@ -167,6 +207,7 @@ class RemoteHostApi implements BrowserHostApi { this.extensionSessions, this.noteHistoryCursor.bind(this), signal, + this.peer, ) const previous = this.activeEvents this.activeEvents = generation @@ -200,7 +241,7 @@ class RemoteHostApi implements BrowserHostApi { try { if (beforeSeq !== undefined) { const throughSeq = await this.historyThroughSeq(sessionId, call.signal) - const page = await this.gateway.invoke({ + const page = await this.invokeRemote({ namespace: 'session', method: 'page', args: { @@ -217,7 +258,7 @@ class RemoteHostApi implements BrowserHostApi { } const snapshot = this.activeEvents === undefined - ? await oneShotSessionSnapshot(this.gateway, sessionId, call.signal, maxMessages) + ? await oneShotSessionSnapshot(this.gateway, sessionId, call.signal, maxMessages, this.peer) : await this.activeEvents.openSessionHistory(sessionId, call.signal, maxMessages) this.noteHistoryCursor(sessionId, snapshot.cursor) return { ok: true, value: historyValue(snapshot) } @@ -231,11 +272,11 @@ class RemoteHostApi implements BrowserHostApi { if (!isRecord(call.payload)) return badRequest('session.models payload must be an object') const sessionId = sessionIdOf(call.payload) try { - const catalog = await this.gateway.invoke({ + const catalog = await this.invokeRemote({ namespace: 'session', method: 'modelCatalog', args: {}, signal: call.signal, }) // Provisional Sessions request only the catalog and overlay their pending choice. - const projections = sessionId === undefined ? undefined : await this.gateway.invoke({ + const projections = sessionId === undefined ? undefined : await this.invokeRemote({ namespace: 'session', method: 'projections', args: { request: { sessionId } }, signal: call.signal, }) const values = isRecord(projections) ? projections.values : undefined @@ -255,7 +296,7 @@ class RemoteHostApi implements BrowserHostApi { const cached = this.historyCursors.get(sessionId) if (cached !== undefined) return cached const snapshot = this.activeEvents === undefined - ? await oneShotSessionSnapshot(this.gateway, sessionId, signal) + ? await oneShotSessionSnapshot(this.gateway, sessionId, signal, undefined, this.peer) : await this.activeEvents.openSessionHistory(sessionId, signal) this.noteHistoryCursor(sessionId, snapshot.cursor) const throughSeq = this.historyCursors.get(sessionId) @@ -277,7 +318,7 @@ class RemoteHostApi implements BrowserHostApi { try { const controller = new AbortController() const signal = AbortSignal.any([call.signal, controller.signal]) - const source = await openWireStream(this.gateway, 'workspace/follow', { args: {} }, signal) + const source = await openWireStream(this.gateway, 'workspace/follow', { args: {} }, signal, this.peer) const iterator = source[Symbol.asyncIterator]() try { const first = await iterator.next() @@ -380,6 +421,8 @@ class EventGeneration { private readonly extensionSessions: ExtensionSessionRegistry, private readonly onHistoryCursor: (sessionId: string, cursor: number) => void, outerSignal: AbortSignal, + /** Peer minted by the Connection service on dsh 0.2.1+; absent on 0.2.0. */ + private readonly peer?: PeerLike, ) { this.signal = AbortSignal.any([outerSignal, this.lifetime.signal]) } @@ -454,6 +497,7 @@ class EventGeneration { }, }, signal, + this.peer, ) const iterator = source[Symbol.asyncIterator]() const first = await iterator.next() @@ -549,7 +593,7 @@ class EventGeneration { private async pumpRemoteEvents(): Promise { try { - const source = await openWireStream(this.gateway, '$events', { args: {} }, this.signal) + const source = await openWireStream(this.gateway, '$events', { args: {} }, this.signal, this.peer) let ready = false for await (const value of source) { if (!ready) { @@ -739,6 +783,7 @@ async function oneShotSessionSnapshot( sessionId: string, outerSignal: AbortSignal, maxMessages?: number, + peer?: PeerLike, ): Promise { const controller = new AbortController() const signal = AbortSignal.any([outerSignal, controller.signal]) @@ -754,6 +799,7 @@ async function oneShotSessionSnapshot( }, }, signal, + peer, ) const iterator = source[Symbol.asyncIterator]() try { diff --git a/packages/browser/bridge-browser/tests/index.spec.ts b/packages/browser/bridge-browser/tests/index.spec.ts index 08d0ee65..bbe7340e 100644 --- a/packages/browser/bridge-browser/tests/index.spec.ts +++ b/packages/browser/bridge-browser/tests/index.spec.ts @@ -113,6 +113,48 @@ describe('apply', () => { await apply(stubContext(), VALID) }) + // dsh 0.2.1 requires a live Peer on every Gateway call; 0.2.0 has no + // `createPeer` and must keep working without one. + it('mints an owner Peer when the Connection offers one and disposes it with the plugin scope', async () => { + const ctx = stubContext() + const dispose = vi.fn() + const peer = { signal: new AbortController().signal, principal: { kind: 'owner' as const }, dispose } + const createPeer = vi.fn(() => peer) + const connection = { ...(ctx.get('connection') as object), createPeer } + const get = ctx.get.bind(ctx) + vi.spyOn(ctx, 'get').mockImplementation((key) => key === 'connection' ? connection : get(key)) + const scopes: Array<() => unknown> = [] + const effect = ctx.effect.bind(ctx) + vi.spyOn(ctx, 'effect').mockImplementation((fn, label) => { + if (label !== 'bridge-browser: Host Peer scope') return effect(fn, label) + // Cordis: the effect callback returns the disposer. + scopes.push((fn as () => unknown)() as () => unknown) + return () => {} + }) + + await apply(ctx, { token: 'fixed-token', ...VALID, sessionWorkspacePath: '' }) + + expect(createPeer).toHaveBeenCalledWith({ principal: { kind: 'owner' } }) + expect(scopes).toHaveLength(1) + expect(dispose).not.toHaveBeenCalled() + scopes[0]?.() + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('keeps the Peer slot empty on a runtime without createPeer (dsh 0.2.0)', async () => { + const ctx = stubContext() + const mint = vi.fn() + const get = ctx.get.bind(ctx) + vi.spyOn(ctx, 'get').mockImplementation((key) => key === 'connection' ? { createSharedFetchHandler: () => ({ fetch: async () => new Response('') }) } : get(key)) + vi.spyOn(ctx, 'effect').mockImplementation((fn, label) => { + if (label === 'bridge-browser: Host Peer scope') mint() + return (fn as () => unknown)() as () => void + }) + + await expect(apply(ctx, { token: 'fixed-token', ...VALID, sessionWorkspacePath: '' })).resolves.toBeUndefined() + expect(mint).not.toHaveBeenCalled() + }) + it('rejects invalid budgets loudly', async () => { await expect(apply(stubContext(), { ...VALID, toolTimeoutMs: 0 })).rejects.toThrow(/toolTimeoutMs/) await expect(apply(stubContext(), { ...VALID, snapshotMaxChars: -1 })).rejects.toThrow(/snapshotMaxChars/) diff --git a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts index 471e1c82..7d1f12f9 100644 --- a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts +++ b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { createRemoteHostApi, type HostConnectionLike, + type PeerLike, type TypertGatewayLike, } from '../src/remote-host-api.ts' import type { HostRpcCall } from '../src/host-api.ts' @@ -19,6 +20,7 @@ function harness(options: { invoke?: TypertGatewayLike['invoke'] open?: TypertGatewayLike['wireStream']['open'] fetch?: (request: Request) => Promise + peer?: PeerLike } = {}) { const invoke = vi.fn(options.invoke ?? (async () => ({ accepted: true }))) const open = vi.fn(options.open ?? (async (_endpoint, _payload, _uplink, _peer, signal) => ({ @@ -47,7 +49,7 @@ function harness(options: { const connection: HostConnectionLike = { createSharedFetchHandler: () => ({ fetch }), } - return { api: createRemoteHostApi(gateway, connection), invoke, open, fetch, requestBodies } + return { api: createRemoteHostApi(gateway, connection, options.peer), invoke, open, fetch, requestBodies } } describe('dsh 0.2 Remote Host adapter', () => { @@ -100,6 +102,31 @@ describe('dsh 0.2 Remote Host adapter', () => { await pending.catch(() => undefined) expect(open.mock.calls[0]?.[4]).toBeInstanceOf(AbortSignal) }) + + // dsh 0.2.1 asserts the Peer of every Gateway call and stream + // (`assertPeerLive`), so forwarding `undefined` throws + // `Cannot read properties of undefined (reading 'signal')`. + it('speaks for the minted Connection Peer on streams and unary calls', async () => { + const peer: PeerLike = { signal: new AbortController().signal, principal: { kind: 'owner' } } + const open = vi.fn(async (_endpoint: string, _payload: unknown, _uplink: AsyncIterable, admitted: unknown, signal: AbortSignal) => { + expect(admitted).toBe(peer) + return { async *[Symbol.asyncIterator]() { yield { type: 'ready', clientId: 'test', host: {} }; await abortWait(signal) } } + }) + const { api, invoke } = harness({ + peer, + open, + invoke: async () => ({ default: { provider: 'p', model: 'm' }, groups: [] }), + }) + const controller = new AbortController() + const iterator = api.events(controller.signal)[Symbol.asyncIterator]() + const pending = iterator.next() + await vi.waitFor(() => expect(open).toHaveBeenCalled()) + await expect(api.call(call('session.models', {}))).resolves.toMatchObject({ ok: true }) + expect(open.mock.calls[0]?.[3]).toBe(peer) + expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ peer })) + controller.abort() + await pending.catch(() => undefined) + }) it('preserves V3 durable streams and orders reconnect baselines before transient frames without advancing the durable cursor', async () => { const baseline = { revision: 2,