feat(remote): deliver allowlisted Host events through ctx.remote.$on

api/remotes owns the allowlist and its type projection; type-meta owns the shape
predicate, the selection seat, and the internal remote/host-event carrier
signal; api/gateway's Client half turns that signal into $on callbacks through a
private dispatch. apiproxy forwards each allowlisted emission verbatim in one
host/remote-event frame, registered ahead of the derived invalidation frames so
frame order is unchanged, and drops the three per-event variants it replaces.
Owner packages move their Events declarations into client-safe ./types exports,
so a consumer's listener signature is the Host's own declaration.
This commit is contained in:
imccyu
2026-08-10 21:32:50 +08:00
parent b64da061a8
commit d88f771e19
59 changed files with 956 additions and 257 deletions

View File

@@ -6,7 +6,7 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { dirname } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import type { Context, Events } from '@deepseek-ai/cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
@@ -15,8 +15,8 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import { isAppendSurfaceEvent, isJsonValue, lastActivityTime } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
@@ -96,6 +96,7 @@ import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import {
ApiRemoteSessionNotFound as SessionNotFound,
ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership,
API_REMOTE_FORWARDED_EVENTS,
apiRemoteSubagentOwnershipError,
createApiRemoteAgentResolver,
hasApiRemoteSubagentOwner,
@@ -414,6 +415,27 @@ function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
}
/**
* Narrow one allowlisted host event's argument list to the JSON values the
* wrapper frame carries. A rejected argument is an allowlist mistake (the
* forwarded path applies no projection), not hostile input, so it fails loud
* here rather than degrading to a dropped or lossy frame. Exported for the
* test that owns this decision: every currently allowlisted event has a
* statically JSON-safe payload, so a type-legal `ctx.emit` cannot reach the
* rejection branch.
* @param event - forwarded host event name, named in the failure.
* @param args - the emitter's argument list.
* @returns the same arguments typed as JSON values.
*/
export function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] {
for (const [index, arg] of args.entries()) {
if (!isJsonValue(arg)) {
throw new Error(`forwarded host event "${event}" argument ${index} is not lossless JSON data`)
}
}
return args as JsonValue[]
}
/** Queue the subscription baseline frame. */
function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
@@ -3441,9 +3463,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
workspace: changedWorkspaceView(change.key, change.value),
}))
}),
ctx.on('commands/change', () => {
queue.push(frame({ type: 'host/commands-changed' }))
}),
// Allowlisted host events ride one verbatim wrapper frame each. The
// allowlist is api-remotes', and `ctx.remote.$on` is the consumer
// face; nothing here projects, redacts, or renames. Registered ahead
// of the derived frames below so a forwarded event still precedes the
// invalidation derived from it (`settings/document-updated` before
// its `host/models-changed`), which is the order a client sees.
...API_REMOTE_FORWARDED_EVENTS.map(name => ctx.on(
name,
// cordis keys `on` by literal event name, so subscribing from a
// runtime list erases the handler type once. The erasure is safe
// because the allowlist's shape assertion already proves each name
// is a real, non-scoped, void-returning event, and assertJsonArgs
// proves the payload is JSON-safe before it reaches the queue.
((...args: unknown[]) => {
queue.push(frame({
type: 'host/remote-event',
event: name,
args: assertJsonArgs(name, args),
}))
}) as Events[typeof name],
)),
// The recompose itself registers nothing (it re-parents the agent's
// scope onto a standing mount that may already exist), so the
// logged selection is the only commit point a client can follow.
@@ -3461,7 +3501,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// configuration client still has to re-read (its held revision is
// stale, and the field's meaning changed).
const name = String(ns)
queue.push(frame({ type: 'host/settings-changed', ns: name }))
// A provider's own settings carry its model catalog and endpoint,
// so a change there invalidates the model list even when the route
// set is untouched — `llm/adapters-updated` alone misses it. The
@@ -3473,9 +3512,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({ type: 'host/models-changed' }))
}
}),
ctx.on('credentials/updated', (ref) => {
queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))
}),
ctx.on('llm/adapters-updated', () => {
queue.push(frame({ type: 'host/models-changed' }))
}),

View File

@@ -83,10 +83,12 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
z.object({ type: z.literal('host/commands-changed') }),
// args stays wide, the same posture as session/projection's value: the frame
// arrives from JSON.parse, so every element is already a JSON value, and the
// structural contract belongs to the owner package's cordis `Events`
// declaration — the host validated JSON-safety before forwarding.
z.object({ type: z.literal('host/remote-event'), event: z.string().min(1), args: z.array(z.unknown()) }),
z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }),
z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
z.object({ type: z.literal('host/models-changed') }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<HostFrame>

View File

@@ -11,7 +11,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a
import type { Message } from '@deepseek-ai/dsh-llm/types'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { JsonValue, SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
import type { TaskView } from './tasks.ts'
@@ -140,36 +140,29 @@ export type HostFrame =
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
| { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] }
/**
* The command registry changed (`commands/change` passthrough). Pure
* invalidation signal, no payload: clients refetch `command.list` in the
* background rather than diffing.
* One allowlisted host cordis event forwarded verbatim. The allowlist is
* owned by `@deepseek-ai/dsh-api-remotes` (`API_REMOTE_FORWARDED_EVENTS`),
* which is also the only control point over what a consumer can receive.
* `event` is the host's own event name and `args` its argument list: this
* path applies no projection, no redaction, and no renaming, so the payload
* contract is the owner package's cordis `Events` declaration rather than
* anything stated here. Delivery lands on `ctx.remote.$on`, not on a
* per-event frame variant.
*/
| { type: 'host/commands-changed' }
| { type: 'host/remote-event'; event: string; args: JsonValue[] }
/**
* One blank session was recomposed onto another agent preset (the logged
* `agent-preset/selected` commit point, read off the session stream). The
* registry-wide `host/commands-changed` cannot stand in for it: recomposing
* re-parents that agent's scope without registering anything, so a
* preset already mounted for another session produces no registry change
* at all. Clients refetch the catalogs this session's composition decides
* (`command.list`, `skill.list`) for this sessionId alone, and fold the
* preset id into their session row — the RPC echo reaches only the client
* that issued the switch, so the row is where every other one learns it.
* registry-wide `commands/change` forwarded above cannot stand in for it:
* recomposing re-parents that agent's scope without registering anything,
* so a preset already mounted for another session produces no registry
* change at all. Clients refetch the catalogs this session's composition
* decides (`command.list`, `skill.list`) for this sessionId alone, and fold
* the preset id into their session row — the RPC echo reaches only the
* client that issued the switch, so the row is where every other one learns
* it.
*/
| { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string }
/**
* One settings namespace's resolved value changed (`settings/updated`
* passthrough) — an RPC write, an external `settings.yaml` edit, or a
* provider reload all converge here. Clients refetch `settings.describe`;
* values never ride the frame (they would need redaction and can go stale).
*/
| { type: 'host/settings-changed'; ns: string }
/**
* One credential reference's state changed (`credentials/updated`
* passthrough): a set/unset over this wire or an external `.env` edit.
* The ref is an environment-variable NAME — never a value.
*/
| { type: 'host/credentials-changed'; ref: string }
/**
* The provider topology changed (`llm/adapters-updated` passthrough):
* routes registered or dropped, or the configurable directory moved. Pure

View File

@@ -23,7 +23,7 @@ import SkillService from '@deepseek-ai/dsh-skill'
import type { HostFrame } from '../src/api/index.ts'
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
import { assertJsonArgs, createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
@@ -269,7 +269,7 @@ describe('skill.list', () => {
})
})
describe('host/commands-changed frame', () => {
describe('forwarded commands/change frame', () => {
it('broadcasts on registry change', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
@@ -277,7 +277,29 @@ describe('host/commands-changed frame', () => {
const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal)
const collected = collect<HostFrame>(stream, 1, abort)
ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) })
expect(await collected).toEqual([{ type: 'host/commands-changed' }])
// Verbatim forwarding: the wire name is the host's own event name and
// `args` is its argument list (empty for this pure invalidation).
expect(await collected).toEqual([{ type: 'host/remote-event', event: 'commands/change', args: [] }])
})
// The guard belongs to the forwarding boundary, so it is tested there rather
// than through a malformed `ctx.emit`: every currently allowlisted event has a
// statically JSON-safe payload, so no type-legal emit can reach the rejection
// branch. These cases stand in for a future allowlist entry whose payload the
// wire cannot carry — a composition mistake that must fail loud.
describe('assertJsonArgs', () => {
it('passes a JSON-safe argument list through unchanged', () => {
const args = ['llm-deepseek', 7, null, { nested: ['ok'] }]
expect(assertJsonArgs('settings/document-updated', args)).toEqual(args)
expect(assertJsonArgs('commands/change', [])).toEqual([])
})
it('names the offending event and argument position when a payload is not lossless JSON', () => {
expect(() => assertJsonArgs('credentials/updated', [1n]))
.toThrow('forwarded host event "credentials/updated" argument 0 is not lossless JSON data')
expect(() => assertJsonArgs('settings/document-updated', ['ns', () => {}]))
.toThrow('forwarded host event "settings/document-updated" argument 1 is not lossless JSON data')
})
})
})

View File

@@ -218,6 +218,21 @@ async function collectHost(
return frames
}
/**
* One forwarded `settings/document-updated` frame for `ns`. The revision rides
* the host's own argument list, so it is matched by shape rather than pinned to
* a per-test count.
* @param ns - the namespace whose stored section changed.
* @returns the expected wrapper frame.
*/
function forwardedSettings(ns: string): HostFrame {
return {
type: 'host/remote-event',
event: 'settings/document-updated',
args: [ns, expect.any(Number) as unknown as number],
}
}
describe('settings domain', () => {
it('reports an actionable error when no settings provider is mounted', async () => {
const ctx = await harness({ settings: false })
@@ -376,7 +391,7 @@ describe('settings domain', () => {
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.toEqual(['ui-onboarding', 'ui-theme'])
const frames = await collectHost(api, ['host/settings-changed'], 2, async () => {
const frames = await collectHost(api, ['host/remote-event'], 2, async () => {
expectOk(await api.settings.mutate(request({
ns: 'ui-onboarding',
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
@@ -386,10 +401,7 @@ describe('settings domain', () => {
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
})))
})
expect(frames).toEqual([
{ type: 'host/settings-changed', ns: 'ui-onboarding' },
{ type: 'host/settings-changed', ns: 'ui-theme' },
])
expect(frames).toEqual([forwardedSettings('ui-onboarding'), forwardedSettings('ui-theme')])
})
it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => {
@@ -425,11 +437,11 @@ describe('settings domain', () => {
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
const api = createApiProxy(ctx, DEFAULTS)
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 2, async () => {
await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } }))
})
expect(frames).toEqual([
{ type: 'host/settings-changed', ns: 'llm-deepseek' },
forwardedSettings('llm-deepseek'),
{ type: 'host/models-changed' },
])
// The resolved value never moved: base already said https://base.
@@ -445,10 +457,10 @@ describe('settings domain', () => {
base: { defaultPreset: 'read-only' },
})
const api = createApiProxy(ctx, DEFAULTS)
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 1, async () => {
const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 1, async () => {
await permission.update({ defaultPreset: 'workspace-write' })
})
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
expect(frames).toEqual([forwardedSettings('permission')])
})
it('invalidates the model catalog when the Agent default selection changes', async () => {
@@ -461,11 +473,11 @@ describe('settings domain', () => {
// The shared section names the selection every blank session resolves to,
// so an externally edited default — another tab, a
// hand-edited settings.yaml — has to reach an open selector as well.
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 2, async () => {
await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
})
expect(frames).toEqual([
{ type: 'host/settings-changed', ns: 'agent-default-model' },
forwardedSettings('agent-default-model'),
{ type: 'host/models-changed' },
])
})
@@ -488,14 +500,14 @@ describe('settings domain', () => {
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
const api = createApiProxy(ctx, DEFAULTS)
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
const frames = await collectHost(api, ['host/remote-event'], 1, async () => {
const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } })))
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' })
expect(view.user).toEqual({ baseURL: 'https://next' })
expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }])
expect(JSON.stringify(view)).not.toContain('sk-new')
})
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'llm-deepseek' }])
expect(frames).toEqual([forwardedSettings('llm-deepseek')])
})
it('replace resets the user layer wholesale', async () => {
@@ -560,7 +572,7 @@ describe('credentials domain', () => {
const api = createApiProxy(ctx, DEFAULTS)
const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } })
const frames = await collectHost(api, ['host/credentials-changed'], 2, async () => {
const frames = await collectHost(api, ['host/remote-event'], 2, async () => {
expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' })))
const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } })
@@ -568,8 +580,8 @@ describe('credentials domain', () => {
expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' })))
})
expect(frames).toEqual([
{ type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' },
{ type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' },
{ type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
{ type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
])
})

View File

@@ -505,7 +505,8 @@ describe('events frame schemas', () => {
createdAt: '0', updatedAt: '0',
} },
{ type: 'host/workspace-removed', workspaceId: 'w' },
{ type: 'host/commands-changed' },
{ type: 'host/remote-event', event: 'commands/change', args: [] },
{ type: 'host/remote-event', event: 'settings/document-updated', args: ['ns', 3] },
{ type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]