Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/src/api-proxy.ts # packages/llm/llm-deepseek/tests/adapter.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/src/index.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/llm/llm/src/types.ts
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createServer, request as httpRequest } from 'node:http'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
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 type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
@@ -27,9 +29,9 @@ function fakeAttachments(): AttachmentStore {
|
||||
}
|
||||
|
||||
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
|
||||
function fakeRequest(headers: Record<string, string>): IncomingMessage {
|
||||
function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session.list`): IncomingMessage {
|
||||
const request = Readable.from([]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers })
|
||||
Object.assign(request, { url, method: 'GET', headers })
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -105,6 +107,31 @@ describe('connection node half', () => {
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
|
||||
// The privileged set: native dialogs plus the whole settings/credential
|
||||
// configuration plane, reads included. The same declared authority reaches
|
||||
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
|
||||
// passed), but each privileged method stays loopback-only and 403s.
|
||||
for (const method of [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.describe', 'settings.update', 'settings.replace',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`),
|
||||
denied.response,
|
||||
)
|
||||
expect(denied.state.status).toBe(403)
|
||||
expect(denied.state.body).toBe('forbidden')
|
||||
}
|
||||
const read = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: 'harness.example' }), read.response)
|
||||
expect(read.state.status).not.toBe(403)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('passes loopback and declared-authority requests through to the bridge', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
|
||||
// Loopback, no browser markers (curl shape): the fence passes; the carrier
|
||||
@@ -126,3 +153,69 @@ describe('connection node half', () => {
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half over a real HTTP server', () => {
|
||||
/** Serve the registered prefix route from a real server and return its port. */
|
||||
async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
const server = createServer((request, response) => {
|
||||
void routes[0]!.handler(request, response)
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as AddressInfo
|
||||
return {
|
||||
port: address.port,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined || error === null) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
|
||||
function call(port: number, method: string, host: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(
|
||||
{ host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
|
||||
(response) => {
|
||||
response.resume()
|
||||
response.on('end', () => { resolve(response.statusCode ?? 0) })
|
||||
},
|
||||
)
|
||||
request.on('error', reject)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
|
||||
// The fence's input is a real IncomingMessage parsed by Node from the
|
||||
// wire, not a hand-assembled object: the Host header a LAN browser sends
|
||||
// is exactly what decides loopback-only here, so the boundary is asserted
|
||||
// against the parse the server actually performs.
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
|
||||
const { port, close } = await serve(routes)
|
||||
try {
|
||||
// Reads are as privileged as writes: describe returns the exposed
|
||||
// configuration, and credentials.describe probes arbitrary env-var names.
|
||||
for (const method of [
|
||||
'settings.describe', 'settings.update', 'settings.replace',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
// The model catalog stays reachable for the same authority: a LAN
|
||||
// client's model picker needs it, and it carries no key or endpoint
|
||||
// state (404 is the empty proxy's carrier answer — the fence passed).
|
||||
for (const method of ['llm.providers', 'llm.models']) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
|
||||
}
|
||||
// Loopback reaches everything, configuration included.
|
||||
expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
|
||||
} finally {
|
||||
await close()
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user