feat(apiproxy): settings/credentials/llm wire domains, frames, and write guard

Eight compiler-locked methods: settings.describe/update/replace serve
redacted layered namespace views (secrets structurally absent from every
layer, write-only in the update direction) and fold seam refusals into
settings-rejected; credentials.describe/set/unset expose value-free views
with credential-rejected on shadowed writes; llm.providers merges the
configurable directory with live routes and llm.models claims the
host-scoped catalog reservation through the buildModelCatalog extraction
session.models now shares. Three HostFrame invalidations bridge the seam
events (host/settings-changed, host/credentials-changed,
host/models-changed), and the connection route generalizes the native-
dialog check into a privileged-method set covering all four writes. The
fixture and both fake clients grow the same face.
This commit is contained in:
Yichen Jiang
2026-07-30 00:13:12 +08:00
parent a5c8136cb3
commit 191067559e
30 changed files with 1349 additions and 102 deletions
@@ -28,7 +28,13 @@ describe('connection node half', () => {
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
// The privileged set: native dialogs plus every settings/credential write.
// A non-loopback peer is denied even with same-origin headers.
for (const url of [
'/api/host.pickDirectory', '/api/host.openPath',
'/api/settings.update', '/api/settings.replace',
'/api/credentials.set', '/api/credentials.unset',
]) {
let status: number | undefined
let body: unknown
const deniedRequest = {
@@ -50,4 +56,49 @@ describe('connection node half', () => {
await fiber.dispose()
expect(routes).toHaveLength(0)
})
it('leaves reads and unprivileged methods to the bridge under the same untrusted peer', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
ctx.provide('httpServer', httpServer as HttpServerService)
// The bridge parses the request before the (empty) impl is consulted; a
// carrier-level 404/parse outcome proves the guard did not intercept.
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
let status: number | undefined
const request = {
url: '/api/settings.describe',
method: 'POST',
headers: {
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
},
socket: { remoteAddress: '192.168.1.8' },
// Minimal async-iterable face for the bridge's body assembly.
async *[Symbol.asyncIterator]() {
yield Buffer.from('not json')
},
} as unknown as IncomingMessage
const response = {
writeHead(value: number) { status = value; return this },
setHeader() { return this },
end() { return this },
write() { return true },
on() { return this },
} as unknown as ServerResponse
await routes[0]!.handler(request, response)
// 400 (body is not JSON) comes from the carrier, not the 403 guard: the
// read passed the privileged check and reached the fetch handler.
expect(status).toBe(400)
await fiber.dispose()
})
})