mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
fix(cua): preserve object schema fields when extending macOS configuration
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user