fix(native)!: preserve UI scalar facts and selection failures

BREAKING CHANGE: Accessibility node value can also be a JSON number or boolean. Strings and null retain their prior meaning.
This commit is contained in:
morluto committed 2026-10-24 04:25:13 +08:00
1 parent 9fb63ad066
commit cc90771af1
11 files changed
+330 -6

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
import Foundation
import CoreFoundation
struct AccessibilityValue {
let value: Any
let gap: String?
}
/** Preserve JSON scalars; report successful reads that JSON cannot represent faithfully. */
func captureAccessibilityValue(_ source: Any?) -> AccessibilityValue {
guard let source else { return AccessibilityValue(value: NSNull(), gap: nil) }
if let text = source as? String { return AccessibilityValue(value: text, gap: nil) }
if let number = source as? NSNumber {
if CFGetTypeID(number) == CFBooleanGetTypeID() {
return AccessibilityValue(value: number.boolValue, gap: nil)
}
let value = number.doubleValue
let encoding = String(cString: number.objCType)
let floating = encoding == "f" || encoding == "d"
if number is NSDecimalNumber || !value.isFinite || (value == 0 && value.sign == .minus) || (!floating && abs(value) > 9_007_199_254_740_991) {
return AccessibilityValue(value: NSNull(), gap: "AXValue number \(number.stringValue) cannot be represented faithfully as a JSON number")
}
return AccessibilityValue(value: number, gap: nil)
}
return AccessibilityValue(value: NSNull(), gap: "AXValue type \(type(of: source)) is not a supported JSON scalar")
}
+8 -3
View File
@@ -43,8 +43,11 @@ func windowBounds(_ element: AXUIElement) -> CGRect? {
return CGRect(origin: point, size: size)
}
func observe(_ request: Request) async throws -> [String: Any] {
guard let app = NSRunningApplication(processIdentifier: request.pid), let url = app.executableURL else {
try fail("target-mismatch", "Selected PID does not expose an application executable URL")
guard let app = NSRunningApplication(processIdentifier: request.pid) else {
try fail("pid-not-application", "LaunchServices does not expose an application for the selected PID; this does not establish that the process is absent")
}
guard let url = app.executableURL else {
try fail("application-executable-unavailable", "The selected application does not expose an executable URL; select a PID with an identifiable application executable")
}
guard let canonicalPointer = realpath(url.path, nil) else { try fail("target-mismatch", "Running executable path cannot be resolved") }
defer { free(canonicalPointer) }
@@ -117,7 +120,9 @@ func observe(_ request: Request) async throws -> [String: Any] {
let childCountResult = childCount(element)
var actions: CFArray?
AXUIElementCopyActionNames(element, &actions)
nodes.append(["path": path, "role": text(element, kAXRoleAttribute), "title": text(element, kAXTitleAttribute), "value": text(element, kAXValueAttribute), "actions": actions as? [String] ?? [], "children_count": childCountResult.value.map { $0 as Any } ?? NSNull()])
let observedValue = captureAccessibilityValue(attribute(element, kAXValueAttribute))
if let gap = observedValue.gap { gaps.append("\(gap) at path \(path)"); truncated = true }
nodes.append(["path": path, "role": text(element, kAXRoleAttribute), "title": text(element, kAXTitleAttribute), "value": observedValue.value, "actions": actions as? [String] ?? [], "children_count": childCountResult.value.map { $0 as Any } ?? NSNull()])
guard let totalChildren = childCountResult.value else {
truncated = true
gaps.append("AX child count unavailable at path \(path): AXError \(childCountResult.error ?? -1)")
+11
View File
@@ -732,6 +732,17 @@ capture or automatic permission prompts. Executable bytes and process launch
time guard against a different target or PID reuse. AX selection requires one
unique geometry match; ambiguity fails closed.
Accessibility node values preserve strings, finite numbers and booleans as their
original JSON scalar types. Values that JSON cannot represent faithfully,
including unsafe integers, negative zero and non-finite numbers, remain null
with a path-specific gap and incomplete capture coverage. Null alone does not
establish that an accessibility value was successfully read or absent.
An initial PID or window selection refusal is `invalid_request` on `pid` or
`window_id` and leaves provider availability intact. Identity or window loss
after the initial capture remains an observation failure with collected facts.
Permission and other helper failures retain their specific remediation text.
`capture_native_ui_scenario` takes AX child-index paths for press, increment/
decrement scrolling and text-value entry, or bounded waits. No global event
injection is used. Unsupported AX actions fail explicitly. The result preserves
+1
View File
@@ -52,6 +52,7 @@
"bridge/hopper_bridge.py",
"bridge/cutter/rea_bridge.py",
"bridge/native/NativeUIChildren.swift",
"bridge/native/NativeUIValues.swift",
"bridge/native/ReaNativeUI.swift",
"bridge/native/rea_lldb_tracer.py",
"bridge/process/ProcessRunTokenReader.swift",
+3
View File
@@ -28,6 +28,9 @@ try {
fileURLToPath(
new URL("../bridge/native/NativeUIChildren.swift", import.meta.url),
),
fileURLToPath(
new URL("../bridge/native/NativeUIValues.swift", import.meta.url),
),
fileURLToPath(
new URL(
"../tests/conformance/native/native-ui-children/main.swift",
+1 -1
View File
@@ -129,7 +129,7 @@ export const nativeUiSnapshotSchema = z.strictObject({
path: z.array(z.int().nonnegative()),
role: z.string().nullable(),
title: z.string().nullable(),
value: z.string().nullable(),
value: z.union([z.string(), z.number(), z.boolean()]).nullable(),
actions: z.array(z.string()),
children_count: z.int().nonnegative().nullable(),
}),
+3
View File
@@ -67,6 +67,9 @@ export const createNativeUiHelperRuntime = (
import.meta.url,
),
),
fileURLToPath(
new URL("../../bridge/native/NativeUIValues.swift", import.meta.url),
),
main,
"-o",
output,
+16 -1
View File
@@ -561,7 +561,22 @@ describe("native UI target and cancellation failures", () => {
}),
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.message).toContain(code);
if (!result.ok) {
const projected = projectAnalysisError(result.error);
expect(projected.message).toBe(
"Grant permission or select an unambiguous window",
);
if (code === "ambiguous-window")
expect(projected).toMatchObject({
code: "invalid_request",
details: {
issues: [
{ path: ["window_id"], message: expect.stringContaining(code) },
],
},
});
else expect(projected.details?.reason).toContain(code);
}
});
it("preserves the underlying helper failure reason in diagnostics", async () => {
const result = await observeNativeUi(target, "observe_native_ui", scope, {
+25 -1
View File
@@ -50,6 +50,13 @@ const responseSchema = z.discriminatedUnion("ok", [
message: z.string(),
}),
]);
const initialSelectionFields = new Map<string, "pid" | "window_id">([
["pid-not-application", "pid"],
["application-executable-unavailable", "pid"],
["target-mismatch", "pid"],
["window-mismatch", "window_id"],
["ambiguous-window", "window_id"],
]);
/** Narrow process seam preserves the native helper's OS and target failures. */
export type NativeUiHelper = (
parameters: Readonly<Record<string, unknown>>,
@@ -179,14 +186,31 @@ const observeWithHelper = async (
signal,
),
);
if (!response.ok)
if (!response.ok) {
const field = initialSelectionFields.get(response.code);
if (launchTime === undefined && field !== undefined)
return err(
new AnalysisInputError(
operation,
{ userMessage: response.message },
[
{
path: [field],
reason: "invalid_value",
message: `${response.code}: ${response.message}`,
},
],
),
);
return err(
new AnalysisCapabilityUnavailableError(
"native-macos",
operation,
`${response.code}: ${response.message}`,
{ userMessage: response.message },
),
);
}
if (
response.result.window.pid !== input.pid ||
response.result.window.window_id !== input.window_id ||
+221
View File
@@ -0,0 +1,221 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, onTestFinished, vi } from "vitest";
import { AnalysisCleanupLease } from "../../../src/artifacts/AnalysisCleanupLease.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import * as runtimes from "../../../src/native/NativeUiHelperRuntime.js";
import { NativeMacOSProvider } from "../../../src/native/NativeMacOSProvider.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { NativeFixtureRunner } from "../../fixtures/nativeCommands.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
const scope = {
pid: 123,
window_id: 456,
screenshot: false,
accessibility: true,
};
const connectNativeUi = async () => {
const directory = await createTestTempDirectory("rea-native-ui-facts-");
const path = join(directory, "Inert.macho");
const header = Buffer.alloc(32);
header.writeUInt32LE(0xfeedfacf, 0);
header.writeUInt32LE(0x0100000c, 4);
header.writeUInt32LE(2, 12);
await writeFile(path, header);
const replies: unknown[] = [];
const factory = vi
.spyOn(runtimes, "createNativeUiHelperRuntime")
.mockImplementation(() => {
const cleanupLease = new AnalysisCleanupLease(async () => {});
return {
signal: new AbortController().signal,
cleanupLease,
invoke: async () => {
if (replies.length === 0)
throw new Error("Missing fixture helper reply");
return replies.shift();
},
close: () => cleanupLease.release(),
resources: () => [],
};
});
const session = createTestBinarySession(
new NativeMacOSProvider({}, new NativeFixtureRunner(), "darwin"),
);
const server = createServer({ kind: "session", session });
const client = new Client({ name: "native-ui-facts", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
onTestFinished(async () => {
factory.mockRestore();
try {
await client.close();
} finally {
await server.close();
await session.close();
}
});
await server.connect(st);
await client.connect(ct);
expect(
(await client.callTool({ name: "open_binary", arguments: { path } }))
.isError,
).not.toBe(true);
const snapshot = (values: (string | number | boolean | null)[]) => ({
ok: true,
result: {
window: {
pid: 123,
window_id: 456,
executable: path,
launch_time: 1,
title: "Fixture",
},
nodes: values.map((value, index) => ({
path: [index],
role: "AXSlider",
title: null,
value,
actions: [],
children_count: 0,
})),
screenshot: null,
gaps: [],
truncated: false,
},
});
return { client, replies, snapshot };
};
it("preserves scalar accessibility values in passive and scenario Evidence", async () => {
const { client, replies, snapshot } = await connectNativeUi();
replies.push(snapshot([0.25, true, "selected", null]));
const passive = await client.callTool({
name: "observe_native_ui",
arguments: scope,
});
expect(passive.isError).not.toBe(true);
expect(
parseEvidence(passive.structuredContent).normalized_result,
).toMatchObject({
initial: {
nodes: [
{ value: 0.25 },
{ value: true },
{ value: "selected" },
{ value: null },
],
},
});
replies.push(snapshot([0.25, false]), snapshot([0.75, true]));
const scenario = await client.callTool({
name: "capture_native_ui_scenario",
arguments: { ...scope, steps: [{ kind: "wait", milliseconds: 0 }] },
});
expect(scenario.isError).not.toBe(true);
expect(
parseEvidence(scenario.structuredContent).normalized_result,
).toMatchObject({
initial: { nodes: [{ value: 0.25 }, { value: false }] },
steps: [
{
before: { nodes: [{ value: 0.25 }, { value: false }] },
after: { nodes: [{ value: 0.75 }, { value: true }] },
outcome: "completed",
},
],
});
});
it("keeps native UI availability after initial selection errors and preserves later failures", async () => {
const { client, replies, snapshot } = await connectNativeUi();
for (const [code, field] of [
["pid-not-application", "pid"],
["application-executable-unavailable", "pid"],
["target-mismatch", "pid"],
["window-mismatch", "window_id"],
] as const)
for (const tool of [
"observe_native_ui",
"capture_native_ui_scenario",
] as const) {
const message = `Fixture ${code} reason`;
replies.push({ ok: false, code, message });
const response = await client.callTool({
name: tool,
arguments: {
...scope,
...(tool === "capture_native_ui_scenario"
? { steps: [{ kind: "wait", milliseconds: 0 }] }
: {}),
},
});
expect(parseMcpToolError(response)).toMatchObject({
error: {
code: "invalid_request",
retryable: false,
message,
details: {
issues: [{ path: [field], message: `${code}: ${message}` }],
},
},
});
const inventory = toolContract("binary_session").outputSchema.parse(
(await client.callTool({ name: "binary_session", arguments: {} }))
.structuredContent,
);
expect(
expandToolAvailability(inventory.result.tool_availability).find(
(entry) => entry.name === tool,
),
).toMatchObject({ available: true });
}
replies.push(snapshot([0.25]));
expect(
(await client.callTool({ name: "observe_native_ui", arguments: scope }))
.isError,
).not.toBe(true);
replies.push(snapshot([0.25]), {
ok: false,
code: "window-mismatch",
message: "Selected window disappeared after initial capture",
});
const changed = await client.callTool({
name: "capture_native_ui_scenario",
arguments: { ...scope, steps: [{ kind: "wait", milliseconds: 0 }] },
});
expect(parseMcpToolError(changed)).toMatchObject({
error: {
code: "capability_unavailable",
message: "Selected window disappeared after initial capture",
details: {
partial_observation: {
initial: { nodes: [{ value: 0.25 }] },
steps: [{ outcome: "failed" }],
},
},
},
});
replies.push({
ok: false,
code: "accessibility-denied",
message: "Grant Accessibility to the REA host in System Settings",
});
const denied = await client.callTool({
name: "observe_native_ui",
arguments: scope,
});
expect(parseMcpToolError(denied)).toMatchObject({
error: {
code: "capability_unavailable",
message: "Grant Accessibility to the REA host in System Settings",
},
});
await client.ping();
});
@@ -1,5 +1,20 @@
import Foundation
let numericValue = captureAccessibilityValue(NSNumber(value: 0.25))
precondition((numericValue.value as? NSNumber)?.doubleValue == 0.25 && numericValue.gap == nil)
let booleanValue = captureAccessibilityValue(NSNumber(value: true))
precondition((booleanValue.value as? Bool) == true && booleanValue.gap == nil)
let stringValue = captureAccessibilityValue("selected")
precondition((stringValue.value as? String) == "selected" && stringValue.gap == nil)
let absentValue = captureAccessibilityValue(nil)
precondition(absentValue.value is NSNull && absentValue.gap == nil)
for unrepresentable in [NSNumber(value: Double.infinity), NSNumber(value: -0.0), NSNumber(value: UInt64.max), NSDecimalNumber(string: "0.1234567890123456789012345")] {
let result = captureAccessibilityValue(unrepresentable)
precondition(result.value is NSNull && result.gap != nil)
}
let compoundValue = captureAccessibilityValue(["nested": "value"])
precondition(compoundValue.value is NSNull && compoundValue.gap != nil)
let zero = captureChildCount(status: 0, success: 0, value: 0)
precondition(zero.value == 0, "A successful empty child list must remain a known zero")
precondition(zero.error == nil, "A successful child count must not report an error")