Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

## 快速安装

Expand Down
26 changes: 13 additions & 13 deletions packages/browser/bridge-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
24 changes: 23 additions & 1 deletion packages/browser/bridge-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -142,7 +143,28 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
}
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(
Expand Down
68 changes: 57 additions & 11 deletions packages/browser/bridge-browser/src/remote-host-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,23 @@ export interface TypertGatewayLike {
readonly method: string
readonly args: Readonly<Record<string, unknown>>
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<unknown>
}

/** Structural subset of dsh 0.2.1's Connection Peer. */
export interface PeerLike {
readonly signal: AbortSignal
readonly principal: { readonly kind: string }
dispose?(): Promise<void> | void
}

/**
* Empty Client→Host uplink for in-process Host wireStream.open calls.
* dsh 0.2 requires the uplink slot; Gateway-owned endpoints ($events) discard
Expand All @@ -65,19 +79,32 @@ const EMPTY_WIRE_UPLINK: AsyncIterable<unknown> = {
* 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<AsyncIterable<unknown>> {
function openWireStream(
gateway: TypertGatewayLike,
endpoint: string,
payload: unknown,
signal: AbortSignal,
peer?: PeerLike,
): Promise<AsyncIterable<unknown>> {
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. */
export interface HostConnectionLike {
createSharedFetchHandler(channel: '/api'): {
fetch(request: Request): Promise<Response>
}
/**
* 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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Record<string, unknown>>
readonly signal?: AbortSignal
}): Promise<unknown> {
return this.gateway.invoke(this.peer === undefined ? request : { ...request, peer: this.peer })
}

async call(call: HostRpcCall): Promise<HostRpcResult> {
if (call.method === 'session.history') return this.sessionHistory(call)
if (call.method === 'session.models') return this.sessionModels(call)
Expand All @@ -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,
Expand All @@ -167,6 +207,7 @@ class RemoteHostApi implements BrowserHostApi {
this.extensionSessions,
this.noteHistoryCursor.bind(this),
signal,
this.peer,
)
const previous = this.activeEvents
this.activeEvents = generation
Expand Down Expand Up @@ -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: {
Expand All @@ -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) }
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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])
}
Expand Down Expand Up @@ -454,6 +497,7 @@ class EventGeneration {
},
},
signal,
this.peer,
)
const iterator = source[Symbol.asyncIterator]()
const first = await iterator.next()
Expand Down Expand Up @@ -549,7 +593,7 @@ class EventGeneration {

private async pumpRemoteEvents(): Promise<void> {
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) {
Expand Down Expand Up @@ -739,6 +783,7 @@ async function oneShotSessionSnapshot(
sessionId: string,
outerSignal: AbortSignal,
maxMessages?: number,
peer?: PeerLike,
): Promise<SessionSnapshot> {
const controller = new AbortController()
const signal = AbortSignal.any([outerSignal, controller.signal])
Expand All @@ -754,6 +799,7 @@ async function oneShotSessionSnapshot(
},
},
signal,
peer,
)
const iterator = source[Symbol.asyncIterator]()
try {
Expand Down
42 changes: 42 additions & 0 deletions packages/browser/bridge-browser/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
29 changes: 28 additions & 1 deletion packages/browser/bridge-browser/tests/remote-host-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -19,6 +20,7 @@ function harness(options: {
invoke?: TypertGatewayLike['invoke']
open?: TypertGatewayLike['wireStream']['open']
fetch?: (request: Request) => Promise<Response>
peer?: PeerLike
} = {}) {
const invoke = vi.fn(options.invoke ?? (async () => ({ accepted: true })))
const open = vi.fn(options.open ?? (async (_endpoint, _payload, _uplink, _peer, signal) => ({
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<unknown>, 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,
Expand Down