Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions src/main/cli/providerModelAdminRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { randomUUID } from 'node:crypto'
import {
PublicModelConfigSchema,
PublicProviderSummarySchema,
modelsGetPublicConfigRoute,
modelsSetPublicConfigRoute,
Expand All @@ -15,7 +14,12 @@ import type { LLM_PROVIDER } from '@shared/types/provider'
import type { ProviderRuntime } from '@/provider'
import type { ProviderQueryScheduler } from '@/provider/providerService'
import type { ProviderSettingsPort } from '@/provider/settings'
import { createRouteMap, type DeepchatRouteMap, type RouteCaller } from '@/routes/routeRegistry'
import {
createRouteMap,
projectJsonRouteOutput,
type DeepchatRouteMap,
type RouteCaller
} from '@/routes/routeRegistry'
import { CliRequestError } from './errors'

type PublicProviderSettings = Pick<
Expand Down Expand Up @@ -257,10 +261,8 @@ export function createCliProviderModelAdminRoutes(
requireCliCaller(context.caller)
const input = modelsGetPublicConfigRoute.input.parse(rawInput)
requireModel(input.providerId, input.modelId)
return modelsGetPublicConfigRoute.output.parse({
config: PublicModelConfigSchema.parse(
dependencies.providerSettings.getModelConfig(input.modelId, input.providerId)
)
return projectJsonRouteOutput(modelsGetPublicConfigRoute.output, {
config: dependencies.providerSettings.getModelConfig(input.modelId, input.providerId)
})
}
],
Expand All @@ -277,10 +279,9 @@ export function createCliProviderModelAdminRoutes(
input.config
)
)
const config = PublicModelConfigSchema.parse(
dependencies.providerSettings.getModelConfig(input.modelId, input.providerId)
)
return modelsSetPublicConfigRoute.output.parse({ config })
return projectJsonRouteOutput(modelsSetPublicConfigRoute.output, {
config: dependencies.providerSettings.getModelConfig(input.modelId, input.providerId)
})
}
]
])
Expand Down
22 changes: 15 additions & 7 deletions src/main/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,17 +873,25 @@ export class CliServer {
rawOutput: unknown,
routeMethod: string
): JsonValue {
const parsedOutput = entry.contract.output.safeParse(rawOutput)
const parsedResult = parsedOutput.success
? JsonValueSchema.safeParse(parsedOutput.data)
: { success: false as const }
if (!parsedOutput.success || !parsedResult.success) {
this.log.error('[CLI] Route returned invalid output', { method: routeMethod })
const fail = (stage: 'route' | 'json', error: z.ZodError): never => {
this.log.error('[CLI] Route returned invalid output', {
method: routeMethod,
stage,
issueCount: error.issues.length,
issueCodes: Array.from(new Set(error.issues.slice(0, 16).map((issue) => issue.code)))
})
throw new CliRequestError('internal_error', 'Route returned an invalid result', {
httpStatus: 500
})
}
return parsedResult.data as JsonValue

const parsedOutput = entry.contract.output.safeParse(rawOutput)
if (!parsedOutput.success) return fail('route', parsedOutput.error)

const parsedResult = JsonValueSchema.safeParse(parsedOutput.data)
if (!parsedResult.success) return fail('json', parsedResult.error)

return parsedResult.data
}

private async dispatchStreamResponse(
Expand Down
3 changes: 2 additions & 1 deletion src/main/provider/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
} from '@shared/contracts/routes'
import {
createRouteMap,
projectJsonRouteOutput,
requireRendererCaller,
type DeepchatRouteMap
} from '@/routes/routeRegistry'
Expand Down Expand Up @@ -519,7 +520,7 @@ export function createProviderRoutes(deps: {
modelsListRuntimeRoute.name,
async (rawInput) => {
const input = modelsListRuntimeRoute.input.parse(rawInput)
return modelsListRuntimeRoute.output.parse({
return projectJsonRouteOutput(modelsListRuntimeRoute.output, {
models: await providerRuntime.getModelList(input.providerId)
})
}
Expand Down
14 changes: 14 additions & 0 deletions src/main/routes/routeRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DeepchatRouteName } from '@shared/contracts/routes'
import type { LocalControlScope } from '@shared/contracts/localControl'
import type { z } from 'zod'

export type RendererRouteCaller = Readonly<{
kind: 'renderer'
Expand Down Expand Up @@ -68,6 +69,19 @@ export type DeepchatRouteHandler = (rawInput: unknown, context: RouteContext) =>

export type DeepchatRouteMap = ReadonlyMap<DeepchatRouteName, DeepchatRouteHandler>

export function projectJsonRouteOutput<OutputSchema extends z.ZodType>(
outputSchema: OutputSchema,
rawOutput: unknown
): z.output<OutputSchema> {
// Project through the public contract before serialization can observe unknown/private fields.
const publicOutput = outputSchema.parse(rawOutput)
const serializedOutput = JSON.stringify(publicOutput)
if (serializedOutput === undefined) {
throw new TypeError('Route output cannot be represented as JSON')
}
return outputSchema.parse(JSON.parse(serializedOutput))
}

export function createRouteMap(
entries: ReadonlyArray<readonly [DeepchatRouteName, DeepchatRouteHandler]>
): DeepchatRouteMap {
Expand Down
56 changes: 51 additions & 5 deletions test/main/cli/providerModelAdminRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
providersTestPublicConnectionRoute,
providersUpdatePublicRoute
} from '@shared/contracts/routes'
import { JsonValueSchema } from '@shared/contracts/json'
import type { LLM_PROVIDER, ModelConfig } from '@shared/types/provider'
import { createCliProviderModelAdminRoutes } from '@/cli/providerModelAdminRoutes'
import type { CliRouteCaller, RouteContext } from '@/routes/routeRegistry'
Expand Down Expand Up @@ -255,7 +256,7 @@ describe('CLI provider administration routes', () => {
).toBe(true)
})

it('uses strict public model config input and strips main-owned identity fields', async () => {
it('returns sparse public model configs as redacted JSON values', async () => {
const provider: LLM_PROVIDER = {
id: 'provider-1',
name: 'Provider',
Expand Down Expand Up @@ -291,17 +292,62 @@ describe('CLI provider administration routes', () => {
}).success
).toBe(false)

harness.modelConfigs.set(`${provider.id}:model-1`, {
const sparseStoredConfig = {
...config,
temperature: undefined,
topP: undefined,
imageGeneration: {
size: undefined,
quality: 'high'
},
videoGeneration: {
seconds: undefined,
watermark: false,
inputReference: { data: 'reference-image', mimeType: undefined },
references: [
{
type: 'image',
url: 'https://example.com/reference.png',
data: undefined,
mimeType: undefined
}
]
},
tts: { voice: undefined, speed: 1 },
conversationId: 'private-session',
ownedBy: 'internal-owner'
} as ModelConfig)
ownedBy: 'internal-owner',
futureSecret: 'secret'
} as ModelConfig & { futureSecret: string }
const publicSparseConfig = {
...config,
imageGeneration: { quality: 'high' as const },
videoGeneration: {
watermark: false,
inputReference: { data: 'reference-image' },
references: [{ type: 'image' as const, url: 'https://example.com/reference.png' }]
},
tts: { speed: 1 }
}
harness.modelConfigs.set(`${provider.id}:model-1`, sparseStoredConfig)
const result = await harness.invoke(modelsGetPublicConfigRoute.name, {
providerId: provider.id,
modelId: 'model-1'
})
expect(result).toEqual({ config })
expect(result).toEqual({ config: publicSparseConfig })
expect(JsonValueSchema.safeParse(result).success).toBe(true)
expect(JSON.stringify(result)).not.toContain('private-session')
expect(JSON.stringify(result)).not.toContain('futureSecret')

harness.setModelConfig.mockImplementationOnce((modelId, providerId) => {
harness.modelConfigs.set(`${providerId}:${modelId}`, sparseStoredConfig)
})
const setResult = await harness.invoke(modelsSetPublicConfigRoute.name, {
providerId: provider.id,
modelId: 'model-1',
config
})
expect(setResult).toEqual({ config: publicSparseConfig })
expect(JsonValueSchema.safeParse(setResult).success).toBe(true)
})

it('normalizes mutation storage failures without exposing their details', async () => {
Expand Down
57 changes: 55 additions & 2 deletions test/main/cli/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { request as httpRequest } from 'node:http'
import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { z } from 'zod'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cliVersionRoute, type DeepchatRouteName } from '@shared/contracts/routes'
import { defineRouteContract } from '@shared/contracts/contract'
import type { JsonValue } from '@shared/contracts/json'
import {
LOCAL_CONTROL_PROTOCOL_VERSION,
Expand Down Expand Up @@ -277,6 +279,7 @@ async function createTestServer(
dispatch: ReturnType<typeof vi.fn>
dispatchUpload: ReturnType<typeof vi.fn>
authorize: ReturnType<typeof vi.fn>
log: Readonly<{ warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> }>
}> {
const userDataPath = await createTemporaryDirectory()
let server: CliServer
Expand All @@ -295,6 +298,7 @@ async function createTestServer(
)
const dispatchUpload = vi.fn(options.dispatchUpload ?? (async () => ({})))
const authorize = vi.fn(options.authorize ?? (async () => ({ release: () => undefined })))
const log = { warn: vi.fn(), error: vi.fn() }
server = new CliServer({
userDataPath,
appVersion: '1.2.3',
Expand Down Expand Up @@ -326,11 +330,11 @@ async function createTestServer(
dispatchUpload,
...(options.authorize ? { authorize } : {}),
surface: options.surface,
log: { warn: vi.fn(), error: vi.fn() }
log
})
servers.push(server)
const descriptor = await server.start()
return { userDataPath, server, descriptor, dispatch, dispatchUpload, authorize }
return { userDataPath, server, descriptor, dispatch, dispatchUpload, authorize, log }
}

afterEach(async () => {
Expand Down Expand Up @@ -506,6 +510,55 @@ describe('CLI local transport', () => {
})
})

it('rejects route output that passes its contract but is not a JSON value', async () => {
const sentinel = 'secret-route-key'
const contract = defineRouteContract({
name: 'test.optionalRecord',
input: z.object({}).default({}),
output: z.object({ values: z.record(z.string(), z.string().optional()) })
})
const output = { values: { [sentinel]: undefined } }
const surface = new Map<string, CliSurfaceEntry>([
[
contract.name,
{
contract,
effect: 'read',
callers: ['human'],
scopes: ['system:read'],
transport: 'rpc',
approval: 'never',
limits: { maxBodyBytes: 1024, timeoutMs: 5_000 }
}
]
])
expect(contract.output.safeParse(output).success).toBe(true)
const { descriptor, log } = await createTestServer({
surface,
dispatchOutput: () => output
})

const response = await rpcRequest(descriptor, {
method: contract.name,
params: {}
})

expect(response).toMatchObject({
status: 500,
body: { ok: false, error: { code: 'internal_error' } }
})
expect(log.error).toHaveBeenCalledWith(
'[CLI] Route returned invalid output',
expect.objectContaining({
method: contract.name,
stage: 'json',
issueCount: expect.any(Number),
issueCodes: expect.any(Array)
})
)
expect(JSON.stringify(log.error.mock.calls)).not.toContain(sentinel)
})

it('releases policy admission after a route contract failure', async () => {
const release = vi.fn()
const { descriptor, authorize } = await createTestServer({
Expand Down
Loading