feat(connection): dispatch TypeRT remotes through shared API

This commit is contained in:
imccyu
2026-08-06 16:41:24 +08:00
parent 4eff751058
commit 36516e97b9
20 changed files with 439 additions and 182 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e
README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51
README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b
README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
## /api browser-trust fence

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
## /api 浏览器信任栅栏

View File

@@ -16,12 +16,13 @@
import type { IncomingHttpHeaders } from 'node:http'
import { isLoopbackHostname } from './loopback-hostname.ts'
/** The request facts the fence reads (structural subset of IncomingMessage). */
/** The request facts the fence reads from either HTTP representation. */
interface ApiTrustRequest {
headers: IncomingHttpHeaders
headers: IncomingHttpHeaders | Headers
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined {
if (headers instanceof Headers) return headers.get(name) ?? undefined
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
@@ -88,7 +89,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
/**
* Decide whether one /api request may reach the RPC bridge.
* @param request - node HTTP request facts (headers).
* @param request - Node HTTP or Fetch request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
*/

View File

@@ -5,7 +5,13 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
interface FetchHandler {
/** Transport-independent request handler consumed by the Host HTTP bridge. */
export interface FetchHandler {
/**
* Handle one standard Fetch request.
* @param request - request produced by the active transport bridge.
* @returns complete or streaming Fetch response.
*/
fetch(request: Request): Promise<Response>
}

View File

@@ -12,6 +12,7 @@ import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink
export type {
ConnectionRpcAuthority,
ConnectionRpcEndpointMatcher,
ConnectionRpcHandler,
ConnectionRpcHandlerOptions,
HostConnectionHandle,
@@ -24,7 +25,7 @@ export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
/** Stable Cordis plugin name. */
export const name = 'client-connection'
/** Services required before providing Connection; legacy `/api` attaches when apiProxy is present. */
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
export const inject = ['httpServer']
/** Plugin config: the deployment's non-loopback serving authorities. */
@@ -93,35 +94,44 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
new HostConnectionService(ctx, trustedHosts)
const connection = new HostConnectionService(ctx, trustedHosts)
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
async fetch(request) {
const pathname = new URL(request.url).pathname
const method = pathname.startsWith(`${API_PATH}/`)
? pathname.slice(API_PATH.length + 1)
: undefined
if (method !== undefined
&& PRIVILEGED_METHODS.has(method)
&& !isTrustedApiRequest(request, [])) {
return new Response('forbidden', { status: 403 })
}
if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
return new Response('upgrade required', {
status: 426,
headers: { connection: 'Upgrade', upgrade: 'websocket' },
})
}
const apiProxy = ctx.get('apiProxy')
if (apiProxy === undefined) return new Response('not found', { status: 404 })
return toFetchHandler(apiProxy).fetch(request)
},
})
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: async (req, res) => {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return
}
await bridge(req, res, fetchHandler)
},
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
ctx.inject(['apiProxy'], (apiCtx) => {
const apiHandler = toFetchHandler(apiCtx.apiProxy)
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: async (req, res) => {
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const method = pathname.startsWith(`${API_PATH}/`)
? pathname.slice(API_PATH.length + 1)
: undefined
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
? isTrustedApiRequest(req, [])
: isTrustedApiRequest(req, trustedHosts)
if (!allowed) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
res.end('upgrade required')
return
}
await bridge(req, res, apiHandler)
},
}
apiCtx.effect(() => apiCtx.httpServer.register(route), 'client-connection: /api route')
const registerDownlink = (
path: string,
handle: WebUpgradeRoute['handler'],

View File

@@ -11,9 +11,11 @@ import {
type RpcId as RpcIdType,
type ServerResponse as RpcServerResponse,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { bridge } from './http-bridge.ts'
import { bridge, type FetchHandler } from './http-bridge.ts'
import { isTrustedApiRequest } from './api-request-trust.ts'
import { API_PATH } from './api-path.ts'
import type {
ConnectionRpcEndpointMatcher,
ConnectionRpcHandler,
ConnectionRpcHandlerOptions,
HostConnectionHandle,
@@ -24,8 +26,23 @@ const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
interface ConnectionRpcInterceptor {
readonly matches: ConnectionRpcEndpointMatcher
readonly fetchHandler: FetchHandler
readonly options: ConnectionRpcHandlerOptions
}
declare module 'cordis' {
interface Context {
/** Host Connection transport and RPC registrations. */
connection: HostConnectionHandle
}
}
/** Host Connection service whose channel registrations belong to the caller fiber. */
export class HostConnectionService extends Service implements HostConnectionHandle {
private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
/**
* Provide the Host half over the active HTTP server.
* @param ctx - owning Connection plugin context.
@@ -40,6 +57,33 @@ export class HostConnectionService extends Service implements HostConnectionHand
const owner = this.ctx
return {
handle: (channel, handler, options) => this.register(owner, channel, handler, options),
intercept: (channel, matches, handler, options) =>
this.registerInterceptor(owner, channel, matches, handler, options),
}
}
/**
* Compose one shared-channel Fetch handler from its interceptor and fallback.
* @param channel - shared channel mounted by Connection.
* @param fallback - handler for endpoints not claimed by the interceptor.
* @returns Fetch handler that selects exactly one target for each request.
*/
createSharedFetchHandler(
channel: '/api',
fallback: FetchHandler,
): FetchHandler {
return {
fetch: (request) => {
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
const interceptor = this.interceptors.get(channel)
if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) {
return fallback.fetch(request)
}
if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) {
return Promise.resolve(new Response('forbidden', { status: 403 }))
}
return interceptor.fetchHandler.fetch(request)
},
}
}
@@ -69,12 +113,38 @@ export class HostConnectionService extends Service implements HostConnectionHand
`client-connection: ${channel} rpc channel`,
)
}
private registerInterceptor(
owner: Context,
channel: string,
matches: ConnectionRpcEndpointMatcher,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void> {
if (channel !== API_PATH) {
throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`)
}
const interceptor: ConnectionRpcInterceptor = {
matches,
fetchHandler: rpcFetchHandler(channel, handler),
options,
}
return owner.effect(() => {
if (this.interceptors.has(channel)) {
throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`)
}
this.interceptors.set(channel, interceptor)
return () => {
this.interceptors.delete(channel)
}
}, `client-connection: ${channel} rpc interceptor`)
}
}
function rpcFetchHandler(
channel: string,
handler: ConnectionRpcHandler,
): { fetch(request: Request): Promise<Response> } {
): FetchHandler {
return {
async fetch(request: Request): Promise<Response> {
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)

View File

@@ -18,11 +18,14 @@ export type ConnectionRpcHandler = (
signal: AbortSignal,
) => Promise<RpcResult<unknown>>
/** Synchronous ownership test for one endpoint on a shared RPC channel. */
export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
/** Host registry for logical RPC channels carried by the current transport. */
export interface HostConnectionRpc {
/**
* Register one absolute channel prefix and its trust policy.
* @param channel - absolute logical channel such as `/api2`.
* @param channel - absolute logical channel such as `/rpc`.
* @param handler - decoded endpoint handler returning the existing RPC result shape.
* @param options - channel trust policy.
* @returns asynchronous disposer removing the channel and its physical route.
@@ -32,6 +35,21 @@ export interface HostConnectionRpc {
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void>
/**
* Intercept owned endpoints on the shared `/api` channel before its fallback.
* @param channel - reserved shared channel; currently `/api`.
* @param matches - synchronous endpoint ownership test.
* @param handler - decoded endpoint handler returning the existing RPC result shape.
* @param options - trust policy for every endpoint claimed by this interceptor.
* @returns asynchronous disposer removing the interceptor.
*/
intercept(
channel: '/api',
matches: ConnectionRpcEndpointMatcher,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void>
}
/** Host `ctx.connection` shape consumed by transport-independent adapters. */
@@ -44,7 +62,7 @@ export interface HostConnectionHandle {
export interface ClientConnectionRpc {
/**
* Call one endpoint through an already registered logical channel.
* @param channel - absolute logical channel such as `/api2`.
* @param channel - absolute logical channel such as `/api`.
* @param endpoint - channel-relative endpoint such as `goals/create`.
* @param payload - channel-owned request payload.
* @param signal - optional caller cancellation.

View File

@@ -204,7 +204,7 @@ describe('connection client apply', () => {
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
})
it('carries generic RPC calls over the isolated channel with rpcId echo validation', async () => {
it('carries RPC calls over the shared API channel with rpcId echo validation', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
const handle = await mount()
const original = globalThis.fetch
@@ -221,13 +221,13 @@ describe('connection client apply', () => {
})
}
try {
await expect(handle.rpc.call('/api2', 'goals/create', { args: { agentId: 'agent-1' } }))
await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } }))
.resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
} finally {
globalThis.fetch = original
}
expect(seen).toHaveLength(1)
expect(seen[0]?.url).toBe('http://dsh.internal/api2/goals/create')
expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
expect(seen[0]?.body).toMatchObject({
type: 'client-request',
method: 'goals/create',
@@ -244,10 +244,10 @@ describe('connection client apply', () => {
const abort = new AbortController()
globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }))
try {
await expect(handle.rpc.call('/api2', 'goals/create', {}, abort.signal))
await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
.rejects.toThrow('HTTP 503')
expect(globalThis.fetch).toHaveBeenCalledWith(
new URL('https://harness.example/api2/goals/create'),
new URL('https://harness.example/api/goals/create'),
expect.objectContaining({ signal: abort.signal }),
)
@@ -257,9 +257,9 @@ describe('connection client apply', () => {
rpcId: 'different-rpc',
result: { ok: true, value: null },
}))
await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
const fetch = vi.mocked(globalThis.fetch)
expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api2/goals/create'))
expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
} finally {
globalThis.fetch = original
@@ -267,12 +267,12 @@ describe('connection client apply', () => {
for (const [channel, endpoint] of [
['api2', 'goals/create'],
['/api2/path', 'goals/create'],
['/api2', ''],
['/api2', '.'],
['/api2', '..'],
['/api2', 'goals//create'],
['/api2', 'goals/create?unsafe'],
['/api/path', 'goals/create'],
['/api', ''],
['/api', '.'],
['/api', '..'],
['/api', 'goals//create'],
['/api', 'goals/create?unsafe'],
] as const) {
await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target')
}
@@ -281,6 +281,6 @@ describe('connection client apply', () => {
it('keeps generic Remote calls unavailable in the client-only fixture', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/)
await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/)
})
})

View File

@@ -195,35 +195,36 @@ describe('connection node half', () => {
await dispose()
})
it('provides a disposable generic RPC channel without requiring apiProxy', async () => {
it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(routes).toHaveLength(0)
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
const connection = ctx.get('connection') as HostConnectionHandle
const calls: unknown[] = []
const remove = connection.rpc.handle('/api2', async (endpoint, payload) => {
const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
calls.push({ endpoint, payload })
return { ok: true, value: { accepted: true } }
}, { authority: 'trusted-host' })
const route = routes.find(candidate => candidate.path === '/api2')
const route = routes.find(candidate => candidate.path === '/rpc')
expect(route).toBeDefined()
const request: ClientRequest = {
type: 'client-request',
rpcId: RpcId('rpc-api2'),
rpcId: RpcId('rpc-dedicated'),
method: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}
const result = fakeResponse()
await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/api2/goals/create', request), result.response)
await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response)
expect(result.state.status).toBe(200)
expect(JSON.parse(String(result.state.body))).toEqual({
type: 'server-response',
rpcId: 'rpc-api2',
rpcId: 'rpc-dedicated',
result: { ok: true, value: { accepted: true } },
})
expect(calls).toEqual([{
@@ -231,11 +232,90 @@ describe('connection node half', () => {
payload: { args: { agentId: 'agent-1' } },
}])
expect(() => connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), {
expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), {
authority: 'trusted-host',
})).toThrow(/duplicate route/)
await remove()
expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
await fiber.dispose()
expect(routes).toHaveLength(0)
})
it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
await fiber.await()
const connection = ctx.get('connection') as HostConnectionHandle
const calls: unknown[] = []
const remove = connection.rpc.intercept(
'/api',
endpoint => endpoint === 'goals/create',
async (endpoint, payload) => {
calls.push({ endpoint, payload })
return { ok: true, value: { accepted: true } }
},
{ authority: 'trusted-host' },
)
expect(() => connection.rpc.intercept(
'/api',
() => true,
async () => ({ ok: true, value: null }),
{ authority: 'trusted-host' },
)).toThrow('already has an interceptor')
expect(() => connection.rpc.intercept(
'/rpc' as '/api',
() => true,
async () => ({ ok: true, value: null }),
{ authority: 'trusted-host' },
)).toThrow('invalid shared RPC channel')
const route = routes.find(candidate => candidate.path === API_PATH)!
const request: ClientRequest = {
type: 'client-request',
rpcId: RpcId('rpc-shared'),
method: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}
const claimed = fakeResponse()
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response)
expect(JSON.parse(String(claimed.state.body))).toEqual({
type: 'server-response',
rpcId: 'rpc-shared',
result: { ok: true, value: { accepted: true } },
})
expect(calls).toEqual([{
endpoint: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}])
const denied = fakeResponse()
await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response)
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
expect(calls).toHaveLength(1)
const unclaimed = fakeResponse()
await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response)
expect(unclaimed.state.status).toBe(404)
await remove()
const withdrawn = fakeResponse()
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response)
expect(withdrawn.state.status).toBe(404)
expect(calls).toHaveLength(1)
const removeLoopback = connection.rpc.intercept(
'/api',
endpoint => endpoint === 'goals/create',
async () => ({ ok: true, value: null }),
{ authority: 'loopback' },
)
const loopbackOnly = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response)
expect(loopbackOnly.state.status).toBe(403)
await removeLoopback()
await fiber.dispose()
})
@@ -246,20 +326,20 @@ describe('connection node half', () => {
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
await fiber.await()
const connection = ctx.get('connection') as HostConnectionHandle
const remove = connection.rpc.handle('/api2', async (endpoint) => {
const remove = connection.rpc.handle('/rpc', async (endpoint) => {
if (endpoint === 'fail') throw new Error('handler broke')
return { ok: true, value: null }
}, {
authority: 'trusted-host',
})
const route = routes[0]!
const route = routes.find(candidate => candidate.path === '/rpc')!
const denied = fakeResponse()
await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response)
await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
const methodMismatch = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', {
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {
type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
}), methodMismatch.response)
expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
@@ -268,12 +348,12 @@ describe('connection node half', () => {
})
for (const [request, status] of [
[fakeRequest({ host: 'harness.example' }, '/api2/goals/create'), 404],
[fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404],
[fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404],
[fakePost({ host: 'harness.example' }, '/api2/goals//create', {}), 404],
[fakeRawPost({ host: 'harness.example' }, '/api2/goals/create', '{}'), 415],
[fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/api2/goals/create', '{}'), 415],
[fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/api2/goals/create', '{'), 400],
[fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404],
[fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415],
[fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
[fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
] as const) {
const response = fakeResponse()
await route.handler(request, response.response)
@@ -286,7 +366,7 @@ describe('connection node half', () => {
[null, 'invalid-request'],
] as const) {
const response = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', body), response.response)
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response)
expect(JSON.parse(String(response.state.body))).toMatchObject({
rpcId,
result: { ok: false, error: { code: 'bad-request' } },
@@ -294,7 +374,7 @@ describe('connection node half', () => {
}
const failed = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/api2/fail', {
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', {
type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
}), failed.response)
expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })