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

View File

@@ -136,6 +136,23 @@ export class FakeApiClient implements IApiClient {
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
}
readonly credentials: IApiClient['credentials'] = {
describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))),
set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))),
unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))),
}
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -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()
})
})