From c9d2c5f9082b0c20c1d7dc947bf680460bdd03cc Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 29 Oct 2026 19:23:03 +0800 Subject: [PATCH] fix(cua): preserve object schema fields when extending macOS configuration --- packages/experimental/cua-macos/src/index.ts | 11 +++--- .../tests/provider-lifecycle.spec.ts | 16 ++++++++ scripts/gen-config-catalog.spec.ts | 39 +++++++++++++++++++ scripts/gen-config-catalog.ts | 18 ++++++++- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/packages/experimental/cua-macos/src/index.ts b/packages/experimental/cua-macos/src/index.ts index 4722e5803c..841412eb3c 100644 --- a/packages/experimental/cua-macos/src/index.ts +++ b/packages/experimental/cua-macos/src/index.ts @@ -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, 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 }, + sourceSigning: Schema.union([Schema.const('ad-hoc'), Schema.const('local-identity')]).default('ad-hoc'), +}) satisfies Schema, 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.' diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index ac132d8bbb..b637ad2b10 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -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) diff --git a/scripts/gen-config-catalog.spec.ts b/scripts/gen-config-catalog.spec.ts index 4aed5f9782..f7f4ac2377 100644 --- a/scripts/gen-config-catalog.spec.ts +++ b/scripts/gen-config-catalog.spec.ts @@ -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") diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index 60a31c9fdb..610d3063a9 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -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.`) }