Merge branch 'stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring
The composer's preset seat stays removed here — this layer moved it to the hero chip and the session-header action — so only the model seat takes the `modelSeatLocked` narrowing master introduced. Conflicts: apps/web/tests/snapshots/*/*.expected.md packages/client/connection/README.md packages/client/connection/README.zh.md packages/client/ui-conversation/src/client/skeleton/InputBar.tsx packages/host/apiproxy/src/fetch/client.ts
This commit is contained in:
@@ -7,8 +7,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
|
||||
|
||||
/** Structural httpServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
@@ -17,6 +18,9 @@ function fakeHttpServer(
|
||||
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
|
||||
throw new Error(`duplicate route ${route.path}`)
|
||||
}
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
@@ -36,15 +40,32 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session
|
||||
return request
|
||||
}
|
||||
|
||||
/** JSON POST carrying a complete client-request envelope. */
|
||||
function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Raw POST for malformed-body and media-type boundary cases. */
|
||||
function fakeRawPost(headers: Record<string, string>, url: string, body: string): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
|
||||
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
|
||||
const state: { status?: number; body?: unknown } = {}
|
||||
const chunks: Buffer[] = []
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(value: number) { state.status = value; return this },
|
||||
write() { return true },
|
||||
write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
|
||||
end(this: { writableEnded: boolean }, value?: unknown) {
|
||||
if (value !== undefined) state.body = value
|
||||
if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
|
||||
else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
|
||||
if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
|
||||
this.writableEnded = true
|
||||
return this
|
||||
},
|
||||
@@ -176,6 +197,211 @@ describe('connection node half', () => {
|
||||
expect(declared.state.status).toBe(404)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
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(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('/rpc', async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload })
|
||||
return { ok: true, value: { accepted: true } }
|
||||
}, { authority: 'trusted-host' })
|
||||
const route = routes.find(candidate => candidate.path === '/rpc')
|
||||
expect(route).toBeDefined()
|
||||
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
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' }, '/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-dedicated',
|
||||
result: { ok: true, value: { accepted: true } },
|
||||
})
|
||||
expect(calls).toEqual([{
|
||||
endpoint: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}])
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
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('/rpc', async (endpoint) => {
|
||||
if (endpoint === 'fail') throw new Error('handler broke')
|
||||
return { ok: true, value: null }
|
||||
}, {
|
||||
authority: 'trusted-host',
|
||||
})
|
||||
const route = routes.find(candidate => candidate.path === '/rpc')!
|
||||
|
||||
const denied = fakeResponse()
|
||||
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' }, '/rpc/goals/create', {
|
||||
type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
|
||||
}), methodMismatch.response)
|
||||
expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
|
||||
rpcId: 'rpc-bad',
|
||||
result: { ok: false, error: { code: 'bad-request' } },
|
||||
})
|
||||
|
||||
for (const [request, status] of [
|
||||
[fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404],
|
||||
[fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404],
|
||||
[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)
|
||||
expect(response.state.status).toBe(status)
|
||||
}
|
||||
|
||||
for (const [body, rpcId] of [
|
||||
[{ rpcId: 'retained-id' }, 'retained-id'],
|
||||
[{ rpcId: 42 }, 'invalid-request'],
|
||||
[null, 'invalid-request'],
|
||||
] as const) {
|
||||
const response = fakeResponse()
|
||||
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' } },
|
||||
})
|
||||
}
|
||||
|
||||
const failed = fakeResponse()
|
||||
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' })
|
||||
|
||||
expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})).toThrow('invalid or reserved RPC channel')
|
||||
expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})).toThrow('invalid or reserved RPC channel')
|
||||
|
||||
const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})
|
||||
const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')!
|
||||
const publicResponse = fakeResponse()
|
||||
await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', {
|
||||
type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {},
|
||||
}), publicResponse.response)
|
||||
expect(publicResponse.state.status).toBe(403)
|
||||
await removeLoopback()
|
||||
await remove()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half over a real HTTP server', () => {
|
||||
|
||||
Reference in New Issue
Block a user