fix(cua): preserve object schema fields when extending macOS configuration

This commit is contained in:
creatixchu
2026-10-29 19:23:03 +08:00
parent 84b26858d3
commit c9d2c5f908
4 changed files with 77 additions and 7 deletions
+5 -6
View File
@@ -23,12 +23,11 @@ export interface Config extends CuaConfig {
sourceSigning: 'ad-hoc' | 'local-identity'
}
/** Validate shared operation bounds and macOS source signing before preparing the helper. */
export const Config = Schema.intersect([
CuaConfig,
Schema.object({
sourceSigning: Schema.union([Schema.const('ad-hoc'), Schema.const('local-identity')]).default('ad-hoc'),
}),
]) satisfies Schema<Record<string, unknown>, Config>
export const Config = Schema.object({
// Schemastery erases object field types on dict; the shared Config declares their output types.
...CuaConfig.dict as { [Key in keyof CuaConfig]: Schema<unknown, CuaConfig[Key]> },
sourceSigning: Schema.union([Schema.const('ad-hoc'), Schema.const('local-identity')]).default('ad-hoc'),
}) satisfies Schema<Record<string, unknown>, Config>
const description = 'Inspect macOS permissions, select an application and window, read AX state, capture screenshots or dispatch input. Use appHandle from getApp; verify actions by reading fresh state.'
@@ -17,6 +17,8 @@ import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
import Hmr from '@deepseek-ai/dsh-hmr'
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
import ComputerUseRegistry from '@deepseek-ai/dsh-computer-use'
import { CuaConfig } from '@deepseek-ai/dsh-cua'
import { createConfigProjector } from '@deepseek-ai/dsh-app-boot/src/config-schema/projector.ts'
import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
import FileSystem from '@deepseek-ai/dsh-fs-local'
import UserQuestions from '@deepseek-ai/dsh-user-questions'
@@ -72,6 +74,20 @@ it('defaults source builds to ad-hoc signatures and accepts only the two signing
expect(() => NativeProvider.Config({ sourceSigning: 'developer-id' } as never)).toThrow()
})
it('projects shared controls and macOS signing without adding signing to the shared schema', async () => {
const project = await createConfigProjector()
const projected = project(NativeProvider.Config, 'cua-macos')
expect(projected.schema).toMatchObject({ anyOf: [
{ properties: { sourceSigning: expect.any(Object), agentPreset: expect.any(Object), helperPath: expect.any(Object) } },
expect.any(Object),
] })
expect(CuaConfig({})).not.toHaveProperty('sourceSigning')
expect(CuaConfig.dict).not.toHaveProperty('sourceSigning')
expect(NativeProvider.Config({ sourceSigning: 'local-identity', timeoutMs: 1234 })).toMatchObject({
agentPreset: 'ptc', sourceSigning: 'local-identity', timeoutMs: 1234,
})
})
it('defaults the explicit click hold limit to five seconds and accepts a configured zero or larger limit', () => {
expect(NativeProvider.Config({}).maxClickHoldMs).toBe(5000)
for (const maxClickHoldMs of [0, 7000]) expect(NativeProvider.Config({ maxClickHoldMs }).maxClickHoldMs).toBe(maxClickHoldMs)
+39
View File
@@ -86,6 +86,45 @@ export function apply(ctx: unknown, config: Config): void {}
.toEqual(new Set(['mode', 'headless', 'endpoint']))
})
it.each([false, true])('checks fields spread from an imported object schema (undeclared field: %s)', (invalid) => {
const { root, write } = fixture(`
import Schema from '@deepseek-ai/schemastery'
export interface SharedOptions { limit: number }
export const Shared = Schema.object({ limit: Schema.number(), ${invalid ? 'hidden: Schema.string(),' : ''} })
`)
write('packages/test/provider/src/index.ts', `
import Schema from '@deepseek-ai/schemastery'
import { Shared as SharedConfig, type SharedOptions } from '@test/runtime/config'
export interface Config extends SharedOptions { sourceSigning: string }
export const Config = Schema.object({ ...SharedConfig.dict, sourceSigning: Schema.string() })
export function apply(ctx: unknown, config: Config): void {}
`)
if (invalid) expect(() => collectConfigCatalog(root)).toThrow("schema validates key 'hidden'")
else expect(new Set(collectConfigCatalog(root).find(entry => entry.pkg === '@test/provider')?.schemaKeys))
.toEqual(new Set(['limit', 'sourceSigning']))
})
it.each(['SharedConfig.dict', 'unknownFields'])('rejects a spread without an object schema: %s', (spread) => {
const { root, write } = fixture()
write('packages/test/provider/src/index.ts', `
import Schema from '@deepseek-ai/schemastery'
import { Shared as SharedConfig, type BrowserConfig } from '@test/runtime/config'
export type Config = BrowserConfig
export const Config = Schema.object({ ...${spread} })
export function apply(ctx: unknown, config: Config): void {}
`)
expect(() => collectConfigCatalog(root)).toThrow('object schema')
})
it('rejects a recursive object-schema dict spread', () => {
const { root } = fixture(`
import Schema from '@deepseek-ai/schemastery'
export interface BrowserConfig { mode: string }
export const Shared = Schema.object({ ...Shared.dict, mode: Schema.string() })
`)
expect(() => collectConfigCatalog(root)).toThrow('cyclic schema alias')
})
it('rejects a schema field absent from the shared config type', () => {
const { root } = fixture(sharedSchema.replace('headless: Schema.boolean()', 'headless: Schema.boolean(), hidden: Schema.string()'))
expect(() => collectConfigCatalog(root)).toThrow("schema validates key 'hidden' but config type 'Config' declares no such member")
+17 -1
View File
@@ -504,7 +504,7 @@ function unwrapExpr(expr: ts.Expression): ts.Expression {
* packages whose schemas an intersect composes. A key path is the top-level
* key or a nested path through object/array compositions (`agents[].id`).
* Handles object/union calls, chained refinements, named const schema imports
* through public workspace source paths, and `z.intersect([X.Config, …])`; errors on
* through public workspace source paths, object-schema dict spreads, and `z.intersect([X.Config, …])`; errors on
* anything else, so a schema the walk cannot see fails the gate instead of
* silently thinning it. Nested values that are neither `object` nor `array`
* compositions (primitives, unions, dynamic-key dicts) contribute no paths.
@@ -570,6 +570,22 @@ function walkSchemaExpr(
const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
keys.push(key)
if (ts.isPropertyAssignment(prop)) collectValuePaths(prop.initializer, key)
} else if (ts.isSpreadAssignment(prop)) {
const spread = unwrapExpr(prop.expression)
if (!ts.isPropertyAccessExpression(spread) || spread.name.text !== 'dict' || !ts.isIdentifier(spread.expression)) {
violations.push(`${where}: schema spread '${prop.getText(ctx.sf)}' must name a const object schema's dict.`)
continue
}
try {
const target = schemaAlias(world, ctx, spread.expression.text)
const object = unwrapExpr(target.expr)
if (!ts.isCallExpression(object) || !ts.isPropertyAccessExpression(object.expression) || object.expression.name.text !== 'object') {
throw new Error(`schema spread '${spread.getText(ctx.sf)}' must refer directly to an object schema`)
}
visit(spread.expression)
} catch (error) {
violations.push(`${where}: ${error instanceof Error ? error.message : String(error)}.`)
}
} else {
violations.push(`${where}: schema object property '${prop.getText(ctx.sf)}' is not a plain key.`)
}