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

@@ -26,6 +26,10 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -47,26 +51,35 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"dependencies": {
"@deepseek-ai/dsh-type-meta": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}

View File

@@ -6,6 +6,22 @@ import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
export type {} from '@deepseek-ai/dsh-goal/remote'
// The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
export type { ApiRemoteForwardedEvent } from '../types.ts'
// The owner packages' client-safe `./types` exports supply the `Events`
// signatures `$on` hands to a listener, so a consumer reads the very
// declaration the Host emits rather than a flattened restatement of it.
export type {} from '@deepseek-ai/dsh-commands/types'
export type {} from '@deepseek-ai/dsh-credentials/types'
export type {} from '@deepseek-ai/dsh-settings/types'
/**
* The Gateway Client face's own declaration merges, type-only: the internal
* `remote/host-event` delivery event a carrier owner emits and the Remote
* service subscribes to. Erased at emit, so this facade still carries no
* runtime edge to the Gateway implementation.
*/
export type {} from '@deepseek-ai/dsh-api-gateway/client'
declare module '@deepseek-ai/cordis' {
interface Context {

View File

@@ -1,5 +1,16 @@
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
// import type { TypeRTForwardableEvent } from '@deepseek-ai/dsh-type-meta'
// import { API_REMOTE_FORWARDED_EVENTS } from './types.ts'
// // The owner packages' client-safe `./types` exports carry the cordis `Events`
// // declarations for every allowlisted event. Pulling them into this face is what
// // makes the shape assertion below judge real signatures rather than an empty
// // event vocabulary.
// import type {} from '@deepseek-ai/dsh-commands/types'
// import type {} from '@deepseek-ai/dsh-credentials/types'
// import type {} from '@deepseek-ai/dsh-settings/types'
export {
ApiRemoteSessionNotFound,
ApiRemoteSubagentSessionOwnership,
@@ -13,6 +24,18 @@ export type {
ApiRemoteAgentResult,
ApiRemoteLookupError,
} from './agent-lookup.ts'
export { API_REMOTE_FORWARDED_EVENTS } from './types.ts'
export type { ApiRemoteForwardedEvent } from './types.ts'
// Shape gate over the allowlist, kept in the Host face because the Host's event
// vocabulary is the authoritative one. It pins three things at compile time:
// every entry NAMES a declared event (the predicate is keyed on `keyof
// Events`), no entry BINDS a Scope (a scoped event's `ThisParameterType` is not
// `unknown`, which is how "must not depend on AgentScope" is stated statically),
// and every entry is ONE-WAY (a waterfall or bail shape returns something other
// than void and is excluded). Widening the array to an event that fails any of
// these fails here, not on the wire.
// API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[]
/** Host plugin body; the selected contributions mount only in Client environments. */
export function apply(): void {}

View File

@@ -1,8 +1,8 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { API_REMOTE_FORWARDED_EVENTS } from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes'
@@ -11,8 +11,38 @@ export const name = 'api-remotes-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */
const install: InvariantInstaller = () => {}
/** The allowlist as a lookup over the live dispatch stream's plain event names. */
const FORWARDED_EVENTS: ReadonlySet<string> = new Set(API_REMOTE_FORWARDED_EVENTS)
/**
* Judge one observed dispatch of an allowlisted event against what verbatim
* forwarding can carry. The Host face's `TypeRTForwardableEvent` assertion
* judges each name's DECLARED signature; only the dispatch stream shows how a
* producer actually emitted it, and neither deviation below is visible to the
* compiler. A Scope carrier would be silently dropped on the way to a consumer
* because `ctx.remote.$on` has no scoped form, and a waterfall or bail dispatch
* expects a return value that a one-way carrier can never deliver back.
* @param mode - dispatch mode reported by the event bus.
* @param event - dispatched event name.
* @param carrier - the dispatch `this`; `null` when the event is unscoped.
* @param fail - reporter bound to this package.
*/
function validateDispatch(mode: string, event: string, carrier: unknown, fail: InvariantFailure): void {
if (!FORWARDED_EVENTS.has(event)) return
if (carrier !== null) {
fail(`forwarded host event "${event}" was dispatched with a Scope carrier, which consumers can never receive`)
}
if (mode !== 'emit') {
fail(`forwarded host event "${event}" was dispatched as "${mode}", but forwarding to consumers is one-way`)
}
}
/** Install the forwarded-event dispatch-shape check over the live event bus. */
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/dispatch', (mode, event, _args, thisArg) => {
validateDispatch(mode, event, thisArg, fail)
}, { global: true })
}
/**
* Register this package's invariant companion.
@@ -21,4 +51,3 @@ const install: InvariantInstaller = () => {}
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,29 @@
/**
* The one home of this application's forwarded-Host-event allowlist, listed in
* `tsconfig.host.json` AND `tsconfig.client.json` so the Host forwarding loop
* and the consumer `ctx.remote.$on` key face read the same declaration instead
* of two copies that could drift.
*
* @module @deepseek-ai/dsh-api-remotes/types
*/
/**
* Host events this application forwards to consumers verbatim: no projection,
* no redaction, no renaming. The wire name is the Host cordis event name and
* the payload is its argument list, so this array is simultaneously the whole
* control point over what a consumer can receive and the legal key set of
* `ctx.remote.$on`. Forwarding one more event is an entry here and nothing
* else.
*/
export const API_REMOTE_FORWARDED_EVENTS = [
'commands/change',
'credentials/updated',
'settings/document-updated',
] as const
/** Type projection of the allowlist; the consumer and the Host read this one. */
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { API_REMOTE_FORWARDED_EVENTS } from '@deepseek-ai/dsh-api-remotes'
import type { ApiRemoteForwardedEvent } from '@deepseek-ai/dsh-api-remotes'
import * as ApiRemotesInvariant from '@deepseek-ai/dsh-api-remotes/invariant'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(ApiRemotesInvariant)
return ctx
}
/**
* One legal emission per allowlisted event. `Events` types each emit by name,
* so the three arities (0, 2, 1) cannot share a single loop body; keying the
* table by {@link ApiRemoteForwardedEvent} makes the compiler reject it as soon
* as the allowlist grows, which keeps "every listed event is exercised" true
* without a single argument-list assertion.
*/
const legalEmission: Record<ApiRemoteForwardedEvent, (ctx: Context) => void> = {
'commands/change': ctx => { ctx.emit('commands/change') },
'credentials/updated': ctx => { ctx.emit('credentials/updated', credentialRef('DEMO_TOKEN')) },
'settings/document-updated': ctx => {
ctx.emit('settings/document-updated', settingsNamespace('demo'), 1)
},
}
describe('forwarded host event invariants', () => {
it('accepts an unscoped one-way dispatch of every allowlisted event', async () => {
const ctx = await setup()
for (const event of API_REMOTE_FORWARDED_EVENTS) {
expect(() => { legalEmission[event](ctx) }).not.toThrow()
}
})
it('ignores an owner package event the allowlist does not select', async () => {
const ctx = await setup()
// `settings/updated` is the resolved-value event, deliberately left out of
// the allowlist while its sibling `settings/document-updated` is in it, so
// this pins that the check discriminates by name rather than by owner.
expect(() => {
ctx.emit('settings/updated', settingsNamespace('demo'), { a: 1 }, { a: 2 }, 'update')
}).not.toThrow()
// The carrier that fails an allowlisted event must pass unremarked here.
expect(() => {
ctx.emit({}, 'settings/updated', settingsNamespace('demo'), { a: 1 }, { a: 2 }, 'update')
}).not.toThrow()
})
it('rejects an allowlisted event dispatched with a Scope carrier', async () => {
const ctx = await setup()
expect(() => { ctx.emit({}, 'commands/change') })
.toThrow(/"commands\/change" was dispatched with a Scope carrier/)
})
it('rejects an allowlisted event dispatched as anything but one-way', async () => {
const ctx = await setup()
expect(() => { ctx.bail('commands/change') })
.toThrow(/"commands\/change" was dispatched as "bail"/)
})
})

View File

@@ -6,15 +6,28 @@
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
"src/client/index.ts"
"src/client/index.ts",
"src/types.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../gateway"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../goal/goal"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../settings/settings"
},
{
"path": "../../typert/type-meta"
}

View File

@@ -8,7 +8,8 @@
"files": [
"src/agent-lookup.ts",
"src/index.ts",
"src/invariant.ts"
"src/invariant.ts",
"src/types.ts"
],
"references": [
{
@@ -20,9 +21,18 @@
{
"path": "../../core/session"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../session/session-persistence"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
},