diff --git a/docs/design/ANTIGRAVITY_LOCAL_SCHEMA_REFS.md b/docs/design/ANTIGRAVITY_LOCAL_SCHEMA_REFS.md new file mode 100644 index 00000000..d3ae9cc5 --- /dev/null +++ b/docs/design/ANTIGRAVITY_LOCAL_SCHEMA_REFS.md @@ -0,0 +1,20 @@ +# Antigravity Local Schema References + +Status: validated +Created: 2026-09-08 +Verified: 2026-09-08 +Issue: [#465](https://github.com/openpi-dev/openpi/issues/465) + +The Antigravity boundary expands only same-document JSON Pointer references +before applying Cloud Code Assist's unsupported-keyword sanitizer. External, +unresolved, recursive, or over-limit references fail before a model request. +Expansion is bounded by depth, nodes, and serialized bytes. The original Pi +schema remains the authority for local validation; this only preserves its +meaning in the provider declaration. + +The ablation is explicit: removing expansion reproduces the original empty +`{}` property for a `$ref` result contract, while allowing references without +bounds could make provider preparation unbounded. Both the expansion and +limits are retained. + +Validation: `node --test --experimental-strip-types tests/extensions/ai-providers/antigravity.test.ts` (39/39) and `bun run check` passed. diff --git a/extensions/ai-providers/antigravity/google-conversion.ts b/extensions/ai-providers/antigravity/google-conversion.ts index 1c1165e3..89c093c9 100644 --- a/extensions/ai-providers/antigravity/google-conversion.ts +++ b/extensions/ai-providers/antigravity/google-conversion.ts @@ -34,6 +34,10 @@ const JSON_SCHEMA_META_DECLARATIONS = new Set([ "definitions", ]); +const MAX_SCHEMA_REF_DEPTH = 24; +const MAX_SCHEMA_REF_NODES = 10_000; +const MAX_SCHEMA_REF_BYTES = 256 * 1024; + interface GoogleFunctionCall { id?: string; name: string; @@ -394,6 +398,141 @@ export function convertMessages( return contents; } +function expandLocalSchemaRefs(schema: unknown): unknown { + if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { + return schema; + } + const root = schema as Record; + const hasRef = (value: unknown) => { + const pending = [value]; + const seen = new WeakSet(); + while (pending.length > 0) { + const entry = pending.pop(); + if (!entry || typeof entry !== "object" || seen.has(entry)) continue; + seen.add(entry); + if ( + Object.prototype.hasOwnProperty.call(entry, "$ref") && + typeof (entry as Record).$ref === "string" + ) { + return true; + } + pending.push(...Object.values(entry)); + } + return false; + }; + if (!hasRef(schema)) return schema; + + const pointer = (ref: string) => { + if (!ref.startsWith("#/") && ref !== "#") { + throw new Error(`Antigravity tool schema has unsupported external $ref: ${ref}`); + } + let value: unknown = root; + for (const segment of ref === "#" ? [] : ref.slice(2).split("/")) { + const key = segment.replaceAll("~1", "/").replaceAll("~0", "~"); + if ( + !value || + typeof value !== "object" || + !Object.prototype.hasOwnProperty.call(value, key) + ) { + throw new Error(`Antigravity tool schema has unresolved $ref: ${ref}`); + } + value = (value as Record)[key]; + } + return value; + }; + const assertDepth = (depth: number) => { + if (depth > MAX_SCHEMA_REF_DEPTH) { + throw new Error("Antigravity tool schema exceeds local $ref expansion limits"); + } + }; + const siblingsOf = (object: Record) => + Object.entries(object).filter(([key]) => key !== "$ref"); + + // Count the JSON that expansion will emit before cloning any referenced tree. + let bytes = 0; + const append = (text: string) => { + bytes += Buffer.byteLength(text); + if (bytes > MAX_SCHEMA_REF_BYTES) { + throw new Error("Antigravity tool schema exceeds local $ref expansion byte limit"); + } + }; + const measure = (value: unknown, depth: number, active: Set): void => { + assertDepth(depth); + if (value === null || typeof value !== "object") { + append(JSON.stringify(value) ?? "null"); + return; + } + if (Array.isArray(value)) { + append("["); + value.forEach((entry, index) => { + if (index > 0) append(","); + measure(entry, depth + 1, active); + }); + append("]"); + return; + } + const object = value as Record; + if (typeof object.$ref === "string") { + const ref = object.$ref; + if (active.has(ref)) throw new Error(`Antigravity tool schema has recursive $ref: ${ref}`); + active.add(ref); + const siblings = siblingsOf(object); + if (siblings.length === 0) measure(pointer(ref), depth + 1, active); + else { + append('{"allOf":['); + measure(pointer(ref), depth + 1, active); + append(",{"); + siblings.forEach(([key, entry], index) => { + if (index > 0) append(","); + append(`${JSON.stringify(key)}:`); + measure(entry, depth + 2, active); + }); + append("}]}"); + } + active.delete(ref); + return; + } + append("{"); + Object.entries(object).forEach(([key, entry], index) => { + if (index > 0) append(","); + append(`${JSON.stringify(key)}:`); + measure(entry, depth + 1, active); + }); + append("}"); + }; + measure(schema, 0, new Set()); + + let nodes = 0; + const active = new Set(); + const visit = (value: unknown, depth: number): unknown => { + assertDepth(depth); + if (++nodes > MAX_SCHEMA_REF_NODES) { + throw new Error("Antigravity tool schema exceeds local $ref expansion limits"); + } + if (Array.isArray(value)) return value.map((entry) => visit(entry, depth + 1)); + if (value === null || typeof value !== "object") return value; + const object = value as Record; + if (typeof object.$ref === "string") { + const ref = object.$ref; + if (active.has(ref)) throw new Error(`Antigravity tool schema has recursive $ref: ${ref}`); + active.add(ref); + try { + const target = visit(pointer(ref), depth + 1); + const siblings = Object.fromEntries( + siblingsOf(object).map(([key, entry]) => [key, visit(entry, depth + 2)]), + ); + return Object.keys(siblings).length === 0 ? target : { allOf: [target, siblings] }; + } finally { + active.delete(ref); + } + } + return Object.fromEntries( + Object.entries(object).map(([key, entry]) => [key, visit(entry, depth + 1)]), + ); + }; + return visit(schema, 0); +} + function sanitizeForOpenApi( schema: unknown, insidePropertiesMap = false, @@ -425,7 +564,11 @@ export function convertTools( name: tool.name, description: tool.description, ...(useParameters - ? { parameters: sanitizeForOpenApi(tool.parameters) } + ? { + parameters: sanitizeForOpenApi( + expandLocalSchemaRefs(tool.parameters), + ), + } : { parametersJsonSchema: tool.parameters }), })), }, diff --git a/tests/extensions/ai-providers/antigravity.test.ts b/tests/extensions/ai-providers/antigravity.test.ts index ea83c49c..b6a19910 100644 --- a/tests/extensions/ai-providers/antigravity.test.ts +++ b/tests/extensions/ai-providers/antigravity.test.ts @@ -21,6 +21,7 @@ import { import { fetchAntigravityModels } from "../../../extensions/ai-providers/antigravity/discovery.ts"; import { convertMessages, + convertTools, isThinkingPart, mapStopReasonString, retainThoughtSignature, @@ -442,6 +443,201 @@ test("sanitizeSchemaForCca preserves property names that match schema keywords", ); }); +test("convertTools expands bounded local refs before CCA sanitization", () => { + const declarations = convertTools( + [ + { + name: "structured_output", + description: "Return the result", + parameters: { + type: "object", + properties: { answer: { $ref: "#/$defs/Answer" } }, + required: ["answer"], + $defs: { + Answer: { + type: "object", + properties: { + verdict: { type: "string", enum: ["pass", "fail"] }, + }, + required: ["verdict"], + }, + }, + }, + } as never, + ], + true, + ); + assert.deepEqual(declarations?.[0]?.functionDeclarations[0]?.parameters, { + type: "object", + properties: { + answer: { + type: "object", + properties: { verdict: { type: "string", enum: ["pass", "fail"] } }, + required: ["verdict"], + }, + }, + required: ["answer"], + }); +}); + +test("convertTools counts each expanded ref subtree once", () => { + const fields = Object.fromEntries( + Array.from({ length: 60 }, (_, index) => [ + `field${index}`, + { type: "string" }, + ]), + ); + const declarations = convertTools( + [ + { + name: "structured_output", + description: "Return the result", + parameters: { + type: "object", + properties: { + first: { $ref: "#/$defs/Result" }, + second: { $ref: "#/$defs/Result" }, + }, + $defs: { + Result: { type: "object", properties: fields }, + }, + }, + } as never, + ], + true, + ); + const parameters = declarations?.[0]?.functionDeclarations[0]?.parameters as { + properties?: Record }>; + }; + assert.deepEqual(parameters.properties?.first, parameters.properties?.second); + assert.equal( + parameters.properties?.first?.properties?.field59 !== undefined, + true, + ); +}); + +test("convertTools bounds the serialized expanded schema size", () => { + assert.throws( + () => + convertTools( + [ + { + name: "oversized", + description: "oversized", + parameters: { + type: "object", + properties: { value: { $ref: "#/$defs/Value" } }, + $defs: { + Value: { type: "string", description: "x".repeat(256 * 1024) }, + }, + }, + } as never, + ], + true, + ), + /expansion byte limit/, + ); +}); + +test("convertTools preserves ref sibling conjunctions and boolean schemas", () => { + const declarations = convertTools( + [ + { + name: "conjunction", + description: "conjunction", + parameters: { + type: "object", + properties: { + value: { + $ref: "#/$defs/Value", + properties: { extra: { type: "string" } }, + }, + denied: { $ref: "#/$defs/Never" }, + }, + $defs: { + Value: { type: "object", properties: { required: { type: "string" } } }, + Never: false, + }, + }, + } as never, + ], + true, + ); + const parameters = declarations?.[0]?.functionDeclarations[0]?.parameters as { + properties?: Record; + }; + assert.deepEqual(parameters.properties?.value, { + allOf: [ + { type: "object", properties: { required: { type: "string" } } }, + { properties: { extra: { type: "string" } } }, + ], + }); + assert.equal(parameters.properties?.denied, false); +}); + +test("convertTools keeps ordinary no-ref schemas outside ref expansion limits", () => { + const properties = Object.fromEntries( + Array.from({ length: 600 }, (_, index) => [`field${index}`, { type: "string" }]), + ); + assert.doesNotThrow(() => + convertTools( + [{ name: "ordinary", description: "ordinary", parameters: { type: "object", properties } } as never], + true, + ), + ); +}); + +test("convertTools rejects inherited local ref targets", () => { + assert.throws( + () => + convertTools( + [ + { + name: "inherited", + description: "inherited", + parameters: { $defs: {}, $ref: "#/$defs/toString" }, + } as never, + ], + true, + ), + /unresolved \$ref/, + ); +}); + +test("convertTools rejects unsafe local refs before the request", () => { + assert.throws( + () => + convertTools( + [ + { + name: "bad", + description: "bad", + parameters: { $ref: "https://example.test/schema" }, + } as never, + ], + true, + ), + /unsupported external \$ref/, + ); + assert.throws( + () => + convertTools( + [ + { + name: "loop", + description: "loop", + parameters: { + $defs: { Node: { $ref: "#/$defs/Node" } }, + $ref: "#/$defs/Node", + }, + } as never, + ], + true, + ), + /recursive \$ref/, + ); +}); + test("buildRequestBody strips CCA-rejected keywords and spills constraints into description", () => { const contextWithTools = { ...SIMPLE_CONTEXT,