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:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user