feat(android): privately compare focused native input values

This commit is contained in:
AdzeB
2026-09-05 11:42:07 +01:00
parent a6fa69a8c0
commit f6c8a5cc7b
49 changed files with 1763 additions and 26 deletions
+5
View File
@@ -9,6 +9,11 @@
android:testOnly="true"
android:theme="@android:style/Theme.NoDisplay">
<provider
android:name=".PrivateInputRequestProvider"
android:authorities="com.callstack.agentdevice.imehelper.private"
android:permission="android.permission.WRITE_SECURE_SETTINGS"
android:exported="true" />
<service
android:name=".TestInputMethodService"
android:label="Agent Device Test IME"
+18
View File
@@ -71,6 +71,24 @@ field without an active input method.
tokenization; prefer the base64 variant for anything with spaces or non-ASCII).
- `ACTION_CLEAR_TEXT` -- select-all and commit an empty string.
Private field comparison uses `content write` with a permission-gated provider at
`content://com.callstack.agentdevice.imehelper.private/request/<random-id>`.
The JSON request travels over stdin, never command arguments or disk. The provider
accepts at most 64 KiB, closes an unfinished pipe after two seconds, and expires
unconsumed requests after five seconds. `ACTION_PRIVATE_INPUT` consumes the random
request ID once and returns only a comparison status and bounded provenance.
The `android-private-input-v1` protocol first acquires an input-connection token,
then compares against the same app and service-instance/input-generation token.
The original snapshot brackets its native capture with that token and retains
the token plus native window ID privately on its focused node. Comparison requires
the original token, a fresh complete capture of the same focused target and native
window, and another token fence after comparison. This private provenance is omitted
from JSON output and cannot survive a daemon restart. Null, partial, oversized,
fully or partially masked, timed-out, or changed-connection
readings return `unknown`. Neither dispatched text nor accessibility masks prove
equality. The IME's pure comparison tests run as part of its normal build.
An optional `--es protocol android-ime-helper-v1` extra is a defensive sanity check (not a
security boundary): if present and it doesn't match, the broadcast is dropped and logged.
@@ -0,0 +1,73 @@
package com.callstack.agentdevice.imehelper;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import java.util.UUID;
import org.json.JSONObject;
final class PrivateInputComparison {
private final String instance = UUID.randomUUID().toString();
private long generation = 0;
private final java.util.concurrent.atomic.AtomicBoolean extracting =
new java.util.concurrent.atomic.AtomicBoolean(false);
void invalidate() { generation++; }
JSONObject handle(JSONObject request, InputConnection connection, EditorInfo editor) {
try {
if (request == null) return unknown("request_unavailable");
if (!"android-private-input-v1".equals(request.optString("protocol")))
return unknown("protocol_mismatch");
if (connection == null || editor == null || editor.packageName == null)
return unknown("connection_unavailable");
if (!editor.packageName.equals(request.optString("appId")))
return unknown("app_changed");
String token = instance + ":" + generation;
if ("acquire".equals(request.optString("operation"))) {
return new JSONObject().put("status", "unknown").put("reason", "scope_acquired")
.put("connectionToken", token).put("appId", editor.packageName)
.put("fieldId", editor.fieldId);
}
if (!"compare".equals(request.optString("operation"))) return unknown("invalid_operation");
if (!token.equals(request.optString("connectionToken"))) return unknown("connection_changed");
Object expected = request.opt("expectedValue");
if (!(expected instanceof String) || ((String) expected).length() > 16_000)
return unknown("invalid_expected_value");
ExtractedTextRequest extraction = new ExtractedTextRequest();
extraction.hintMaxChars = 16_001;
extraction.hintMaxLines = 16_001;
ExtractedText actual = extract(connection, extraction);
if (actual == null || actual.text == null) return unknown("extraction_unavailable");
String comparison = PrivateInputValue.compare((String) expected, actual.text,
actual.startOffset, actual.partialStartOffset, actual.partialEndOffset);
if ("unknown".equals(comparison)) return unknown("incomplete_or_masked_extraction");
if (!token.equals(instance + ":" + generation)) return unknown("connection_changed");
return new JSONObject().put("status", comparison).put("connectionToken", token)
.put("appId", editor.packageName).put("fieldId", editor.fieldId)
.put("source", "android-ime-extracted-text");
} catch (Throwable ignored) { return unknown("comparison_unavailable"); }
}
private ExtractedText extract(InputConnection connection, ExtractedTextRequest request) {
if (!extracting.compareAndSet(false, true)) return null;
java.util.concurrent.FutureTask<ExtractedText> task = new java.util.concurrent.FutureTask<>(
() -> connection.getExtractedText(request, 0));
Thread worker = new Thread(() -> {
try { task.run(); }
finally { extracting.set(false); }
}, "private-input-extraction");
worker.setDaemon(true);
worker.start();
try { return task.get(1_500, java.util.concurrent.TimeUnit.MILLISECONDS); }
catch (Exception ignored) { task.cancel(true); return null; }
}
static JSONObject unknown(String reason) {
JSONObject result = new JSONObject();
try { result.put("status", "unknown").put("reason", reason); }
catch (Exception ignored) { }
return result;
}
}
@@ -0,0 +1,96 @@
package com.callstack.agentdevice.imehelper;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import org.json.JSONObject;
public final class PrivateInputRequestProvider extends ContentProvider {
private static final int MAX_BYTES = 65_536;
private static final Map<String, Request> requests = new HashMap<>();
private static final java.util.concurrent.Semaphore pending =
new java.util.concurrent.Semaphore(8);
private static final class Request {
final JSONObject value;
final long expires;
Request(JSONObject value) {
this.value = value;
this.expires = SystemClock.elapsedRealtime() + 5_000;
}
}
static synchronized JSONObject take(String id) {
long deadline = SystemClock.elapsedRealtime() + 300;
while (!requests.containsKey(id) && SystemClock.elapsedRealtime() < deadline) {
try { PrivateInputRequestProvider.class.wait(20); }
catch (InterruptedException ignored) { return null; }
}
Request request = requests.remove(id);
return request != null && request.expires >= SystemClock.elapsedRealtime()
? request.value : null;
}
private static synchronized void put(String id, JSONObject value) {
requests.entrySet().removeIf(entry -> entry.getValue().expires < SystemClock.elapsedRealtime());
if (requests.size() < 8) {
Request request = new Request(value);
requests.put(id, request);
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
synchronized (PrivateInputRequestProvider.class) {
if (requests.get(id) == request) requests.remove(id);
}
}, 5_000);
}
PrivateInputRequestProvider.class.notifyAll();
}
@Override public boolean onCreate() { return true; }
@Override public ParcelFileDescriptor openFile(Uri uri, String mode)
throws java.io.FileNotFoundException {
String id = uri.getLastPathSegment();
if (!"w".equals(mode) || id == null || !id.matches("[a-f0-9]{32}"))
throw new java.io.FileNotFoundException("Invalid private request");
if (!pending.tryAcquire()) throw new java.io.FileNotFoundException("Private request busy");
try {
ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
new Thread(() -> {
try (InputStream stream = new ParcelFileDescriptor.AutoCloseInputStream(pipe[0])) {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int count;
while ((count = stream.read(buffer)) != -1) {
if (bytes.size() + count > MAX_BYTES) return;
bytes.write(buffer, 0, count);
}
put(id, new JSONObject(new String(bytes.toByteArray(), StandardCharsets.UTF_8)));
} catch (Exception ignored) { }
finally { pending.release(); }
}, "private-input-request").start();
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
try { pipe[0].close(); } catch (Exception ignored) { }
}, 2_000);
return pipe[1];
} catch (Exception ignored) {
pending.release();
throw new java.io.FileNotFoundException("Private request unavailable");
}
}
@Override public String getType(Uri uri) { return "application/json"; }
@Override public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) { return null; }
@Override public Uri insert(Uri uri, ContentValues values) { return null; }
@Override public int delete(Uri uri, String selection, String[] selectionArgs) { return 0; }
@Override public int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs) { return 0; }
}
@@ -0,0 +1,13 @@
package com.callstack.agentdevice.imehelper;
final class PrivateInputValue {
static String compare(String expected, CharSequence observed,
int startOffset, int partialStartOffset, int partialEndOffset) {
if (observed == null) return "unknown";
if (startOffset != 0 || partialStartOffset != -1 || partialEndOffset != -1
|| observed.length() > 16_000 || expected.length() > 16_000) return "unknown";
if (observed.length() > 0 && observed.toString().matches("(?s).*[\\u00b7\\u2022\\u25cf\\u25cb\\u2217*].*"))
return "unknown";
return expected.contentEquals(observed) ? "match" : "mismatch";
}
}
@@ -39,6 +39,21 @@ public class TestInputMethodService extends InputMethodService {
private static final int MAX_TEXT_LENGTH = 32_000;
private BroadcastReceiver receiver;
private final PrivateInputComparison privateComparison = new PrivateInputComparison();
private static final String ACTION_PRIVATE_INPUT =
"com.callstack.agentdevice.imehelper.ACTION_PRIVATE_INPUT";
@Override
public void onStartInput(android.view.inputmethod.EditorInfo info, boolean restarting) {
privateComparison.invalidate();
super.onStartInput(info, restarting);
}
@Override
public void onFinishInput() {
privateComparison.invalidate();
super.onFinishInput();
}
@Override
public void onCreate() {
@@ -47,6 +62,16 @@ public class TestInputMethodService extends InputMethodService {
new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (ACTION_PRIVATE_INPUT.equals(intent.getAction())) {
try {
setResultData(privateComparison.handle(
PrivateInputRequestProvider.take(intent.getStringExtra("requestId")),
getCurrentInputConnection(), getCurrentInputEditorInfo()).toString());
} catch (Throwable ignored) {
setResultData(PrivateInputComparison.unknown("invalid_request").toString());
}
return;
}
try {
handleAction(intent);
} catch (Throwable error) {
@@ -59,6 +84,7 @@ public class TestInputMethodService extends InputMethodService {
filter.addAction(ACTION_INPUT_TEXT);
filter.addAction(ACTION_INPUT_TEXT_B64);
filter.addAction(ACTION_CLEAR_TEXT);
filter.addAction(ACTION_PRIVATE_INPUT);
// Register the receiver in the running IME process (so getCurrentInputConnection() is live)
// but require REQUIRED_SENDER_PERMISSION of every sender. On API 33+ the receiver must also be
// flagged exported to accept out-of-app broadcasts; the permission is the actual trust gate.
@@ -0,0 +1,26 @@
package com.callstack.agentdevice.imehelper;
public final class PrivateInputValueTest {
public static void main(String[] args) {
expect("match", "1947", "1947", 0, -1, -1);
expect("mismatch", "1947", "7491", 0, -1, -1);
expect("match", "", "", 0, -1, -1);
expect("match", "你好🧪", "你好🧪", 0, -1, -1);
expect("unknown", "1947", "••••", 0, -1, -1);
expect("unknown", "****", "****", 0, -1, -1);
expect("unknown", "1947", "•••7", 0, -1, -1);
expect("unknown", "1947", "···7", 0, -1, -1);
expect("unknown", "1947", "•·•7", 0, -1, -1);
expect("unknown", "1947", "1947", 1, -1, -1);
expect("unknown", "1947", "1947", 0, 0, 4);
expect("unknown", "1947", null, 0, -1, -1);
expect("unknown", "x".repeat(16_001), "x".repeat(16_001), 0, -1, -1);
System.out.println("PrivateInputValueTest: 13 passed");
}
private static void expect(String result, String expected, CharSequence observed,
int start, int partialStart, int partialEnd) {
if (!result.equals(PrivateInputValue.compare(expected, observed, start, partialStart, partialEnd)))
throw new AssertionError("Private comparison classification failed");
}
}
@@ -31,6 +31,7 @@ final class AccessibilityTreeXml {
node.getBoundsInScreen(bounds);
xml.append("<node");
appendAttribute(xml, "index", Integer.toString(nodeIndex));
appendAttribute(xml, "window-id", Integer.toString(node.getWindowId()));
if (windowMetadata != null) {
appendWindowMetadata(xml, windowMetadata);
}
+21
View File
@@ -0,0 +1,21 @@
# Private Android field comparison
`agent-device compare-field --private-stdin --json --platform android --session NAME -- @eN~sG`
accepts one stdin JSON object with exactly `protocol: "android-private-input-v1"`, a UUID
`requestId`, and string `expectedValue`. Supply this object through a private process pipe;
do not put the expected value in shell arguments. The envelope limit is 32 KiB and the
expected value limit is 16 KiB in UTF-8. Stdin must finish within ten seconds.
This operator entry maps to the existing `get attrs` session and ref ownership route.
It requires an explicit Android platform, named session, and versioned ref. It does not
expose expected input through the ordinary command schema or model-facing tools.
The expected value is attached only during final local socket serialization and removed
before daemon request handling, authentication, cloning, or diagnostics. Remote HTTP is
unsupported. There is no transport retry for a private request.
One-shot asynchronous request context holds the private input independently of caller IDs;
it is cleared on completion, exception, request cancellation, or after sixty seconds.
An expired or reused private context fails closed. Ordinary `get attrs` requests have no
private context. Native comparison requires the original versioned field identity and fresh
focus, application, window, and input-connection evidence. Results contain an equality
status and bounded provenance; neither observed nor expected text is returned.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-device",
"version": "0.20.11-dev",
"version": "0.20.11-a1",
"description": "Mobile app automation and verification for AI coding agents. CLI, MCP server, and typed Node.js API for iOS, Android, HarmonyOS, TV, web, macOS, and Linux.",
"mcpName": "io.github.callstack/agent-device",
"license": "MIT",
@@ -3,12 +3,29 @@ import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
bindElementTextRuntime,
attachPrivateFieldEvidence,
readPrivateFieldEvidence,
elementTextRead,
type ElementTextReadOutcome,
type ElementTextUnreadableReason,
} from './element-text-runtime.ts';
import type { Interactor } from './interactor-types.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot';
test('private scope survives ref attachment without serializing into public nodes', () => {
const node: RawSnapshotNode = { index: 1, editable: true };
const evidence = {
connectionToken: 'private-connection',
appId: 'example.app',
fieldId: 3,
windowId: 7,
};
attachPrivateFieldEvidence(node, evidence);
const published = attachRefs([{ ...node }])[0]!;
assert.deepEqual(readPrivateFieldEvidence(published), evidence);
assert.equal(JSON.stringify(published).includes('private-connection'), false);
});
/**
* The reasons this suite exercises. Kept local on purpose: exhaustiveness is enforced at the
+54 -3
View File
@@ -1,5 +1,5 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { Point } from '@agent-device/kernel/snapshot';
import type { Point, SnapshotNode, RawSnapshotNode } from '@agent-device/kernel/snapshot';
import type { Interactor, RunnerContext } from './interactor-types.ts';
import { invalidRuntimeContract } from './runtime-contract-error.ts';
import type { RuntimeOperationFact } from './platform-runtime.ts';
@@ -47,6 +47,7 @@ export function elementTextRead(text: string | undefined | null): ElementTextRea
}
export type ElementTextRuntimeOperations = Readonly<{
comparePrivateField(input: PrivateFieldComparisonInput): Promise<PrivateFieldComparisonResult>;
/**
* The live text an owner reads at a point, which can exceed the readable text carried by an
* already-captured snapshot node (an editable field whose value is longer than its label).
@@ -60,15 +61,64 @@ export type ElementTextRuntimeOperations = Readonly<{
}>;
export type ElementTextRuntimeOperationFacts = Readonly<{
comparePrivateField: RuntimeOperationFact;
readTextAtPoint: RuntimeOperationFact;
}>;
export function elementTextRuntimeOperationFacts(
input: ElementTextRuntimeOperationFacts,
input: Omit<ElementTextRuntimeOperationFacts, 'comparePrivateField'> &
Partial<Pick<ElementTextRuntimeOperationFacts, 'comparePrivateField'>>,
): ElementTextRuntimeOperationFacts {
return Object.freeze({ readTextAtPoint: input.readTextAtPoint });
return Object.freeze({
readTextAtPoint: input.readTextAtPoint,
comparePrivateField: input.comparePrivateField ?? {
available: false,
reason: 'owner-capability-missing',
hint: 'Private field comparison unavailable',
},
});
}
export type PrivateFieldComparisonInput = Readonly<{
target: SnapshotNode;
appId: string;
expectedValue: string;
}>;
const privateFieldEvidence = Symbol('privateFieldEvidence');
export type PrivateFieldEvidence = Readonly<{
connectionToken: string;
appId: string;
fieldId: number;
windowId: number;
}>;
export function attachPrivateFieldEvidence(
node: RawSnapshotNode,
evidence: PrivateFieldEvidence,
): void {
Object.defineProperty(node, privateFieldEvidence, {
value: Object.freeze(evidence),
enumerable: true,
});
}
export function readPrivateFieldEvidence(node: RawSnapshotNode): PrivateFieldEvidence | undefined {
return (node as RawSnapshotNode & { [privateFieldEvidence]?: PrivateFieldEvidence })[
privateFieldEvidence
];
}
export type PrivateFieldComparisonResult =
| Readonly<{ status: 'unknown'; reason: string }>
| Readonly<{
status: 'match' | 'mismatch';
source: 'android-ime-extracted-text';
connectionToken: string;
appId: string;
fieldId: number;
}>;
/** Resolves the selected owner's interactor, exactly as the snapshot runtime does. */
export type ElementTextInteractorResolver = (
device: DeviceInfo,
@@ -90,6 +140,7 @@ export function bindElementTextRuntime(
}>,
): ElementTextRuntimeOperations {
return Object.freeze({
comparePrivateField: async () => ({ status: 'unknown', reason: 'unsupported' }) as const,
readTextAtPoint: async (input: ReadTextAtPointInput) => {
const signal = params.signal;
signal.throwIfAborted();
@@ -374,11 +374,11 @@ const selectorCaptureWithoutActiveAppUse = defineUse({
/** `get` and read-only `find` may improve a captured result with a live element read. */
const selectorTextCaptureUse = defineUse({
required: ['captureSnapshot'],
preferred: ['readTextAtPoint'],
preferred: ['readTextAtPoint', 'comparePrivateField'],
});
const selectorTextCaptureWithoutActiveAppUse = defineUse({
required: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'],
preferred: ['readTextAtPoint'],
preferred: ['readTextAtPoint', 'comparePrivateField'],
});
/**
@@ -200,7 +200,10 @@ export function createUnavailablePlatformRuntimeFacts(
fillRef: touch,
tapElementSelector: touch,
}),
...elementTextRuntimeOperationFacts({ readTextAtPoint: elementText }),
...elementTextRuntimeOperationFacts({
readTextAtPoint: elementText,
comparePrivateField: elementText,
}),
...backRuntimeOperationFacts({ back }),
...homeRuntimeOperationFacts({ home }),
...orientationRuntimeOperationFacts({ orientation }),
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { test, vi } from 'vitest';
import { readPrivateFieldEvidence } from '@agent-device/contracts/element-text-runtime';
import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { finishPrivateFieldCapture } from './private-field-capture.ts';
import { parseUiHierarchyTree } from './ui-hierarchy.ts';
import type { AndroidAdbExecutor } from './adb-transport.ts';
const mocks = vi.hoisted(() => ({ acquire: vi.fn() }));
vi.mock('./private-input-comparison.ts', () => ({
acquireAndroidPrivateInputScope: mocks.acquire,
}));
const scope = { appId: 'example.app', fieldId: 3, connectionToken: 'original' };
const tree = parseUiHierarchyTree(
'<hierarchy><node window-index="0" window-type="1" window-active="true" window-focused="true"><node class="android.widget.EditText" package="example.app" window-id="7" bounds="[10,20][110,50]" editable="true" focused="true" /></node></hierarchy>',
);
const adb: AndroidAdbExecutor = async () => ({ stdout: '', stderr: '', exitCode: 0 });
test('only unchanged complete capture brackets retain private scope on issued refs', async () => {
for (const changed of [false, true]) {
const node: RawSnapshotNode = {
index: 1,
type: 'android.widget.EditText',
editable: true,
focused: true,
rect: { x: 10, y: 20, width: 100, height: 30 },
};
mocks.acquire.mockResolvedValue({
...scope,
connectionToken: changed ? 'replacement' : scope.connectionToken,
});
const signal = new AbortController().signal;
await finishPrivateFieldCapture({
adb,
scope,
signal,
nodes: [node],
tree,
metadata: { backend: 'android-helper', helperTruncated: false, rootPresent: true },
});
const issued = attachRefs([node])[0]!;
assert.equal(
readPrivateFieldEvidence(issued)?.connectionToken,
changed ? undefined : 'original',
);
assert.equal(JSON.stringify(issued).includes('original'), false);
const options = mocks.acquire.mock.calls.at(-1)![2];
assert.equal(options.timeoutMs, 3000);
assert.ok(options.signal instanceof AbortSignal);
}
});
test('partial capture cannot mint private scope evidence', async () => {
mocks.acquire.mockClear();
await finishPrivateFieldCapture({
adb,
scope,
nodes: [],
tree,
metadata: { backend: 'android-helper', helperTruncated: true, rootPresent: true },
});
assert.equal(mocks.acquire.mock.calls.length, 0);
});
@@ -0,0 +1,58 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { attachPrivateFieldEvidence } from '@agent-device/contracts/element-text-runtime';
import {
acquireAndroidPrivateInputScope,
type AndroidPrivateInputScope,
} from './private-input-comparison.ts';
import { isAndroidTestImeActive } from './ime-state.ts';
import { resolveFocusedPrivateTarget } from './private-field-target.ts';
import type { AndroidUiHierarchy } from './ui-hierarchy.ts';
import type { AndroidAdbExecutor } from './adb-transport.ts';
import type { AndroidSnapshotBackendMetadata } from './snapshot-types.ts';
export async function beginPrivateFieldCapture(
device: DeviceInfo,
adb: AndroidAdbExecutor,
appId?: string,
signal?: AbortSignal,
) {
return appId && isAndroidTestImeActive(device) ? await acquire(adb, appId, signal) : undefined;
}
export async function finishPrivateFieldCapture(input: {
adb: AndroidAdbExecutor;
scope?: AndroidPrivateInputScope;
signal?: AbortSignal;
nodes: RawSnapshotNode[];
tree: AndroidUiHierarchy;
metadata: AndroidSnapshotBackendMetadata;
truncated?: boolean;
}): Promise<void> {
const { scope } = input;
if (!scope || input.truncated || !isComplete(input.metadata)) return;
const after = await acquire(input.adb, scope.appId, input.signal);
if (after?.connectionToken !== scope.connectionToken || after.fieldId !== scope.fieldId) return;
for (const node of input.nodes) {
if (node.focused !== true || node.editable !== true) continue;
const focused = resolveFocusedPrivateTarget(input.tree, { target: node, appId: scope.appId });
if (focused?.windowId !== undefined)
attachPrivateFieldEvidence(node, { ...scope, windowId: focused.windowId });
}
}
function isComplete(metadata: AndroidSnapshotBackendMetadata): boolean {
return (
metadata.helperTruncated === false &&
metadata.rootPresent === true &&
!metadata.systemSurfaceOnly
);
}
async function acquire(adb: AndroidAdbExecutor, appId: string, signal?: AbortSignal) {
const deadline = AbortSignal.timeout(3_000);
return await acquireAndroidPrivateInputScope(adb, appId, {
timeoutMs: 3_000,
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
});
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { test, vi } from 'vitest';
import { attachPrivateFieldEvidence } from '@agent-device/contracts/element-text-runtime';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import { ANDROID_EMULATOR } from './runtime.fixtures.ts';
import { compareAndroidPrivateField } from './private-field-runtime.ts';
const mocks = vi.hoisted(() => ({ acquire: vi.fn(), compare: vi.fn(), capture: vi.fn() }));
vi.mock('./private-input-comparison.ts', () => ({
acquireAndroidPrivateInputScope: mocks.acquire,
compareAndroidPrivateInput: mocks.compare,
}));
vi.mock('./snapshot.ts', () => ({ captureAndroidCompleteUiHierarchy: mocks.capture }));
vi.mock('./adb-executor.ts', () => ({ resolveAndroidAdbExecutor: () => vi.fn() }));
test('requires proof from the original capture and rejects replaced input connections before recapture', async () => {
const target: SnapshotNode = { ref: 'e1', index: 1 };
const input = { target, appId: 'example.app', expectedValue: 'synthetic' };
const signal = new AbortController().signal;
assert.equal(
(await compareAndroidPrivateField(ANDROID_EMULATOR, input, signal)).status,
'unknown',
);
assert.equal(mocks.acquire.mock.calls.length, 0);
attachPrivateFieldEvidence(target, {
appId: input.appId,
fieldId: 4,
windowId: 7,
connectionToken: 'original',
});
mocks.acquire.mockResolvedValue({
appId: input.appId,
fieldId: 4,
connectionToken: 'replacement',
});
assert.deepEqual(await compareAndroidPrivateField(ANDROID_EMULATOR, input, signal), {
status: 'unknown',
reason: 'connection_changed',
});
assert.equal(mocks.capture.mock.calls.length, 0);
assert.equal(mocks.compare.mock.calls.length, 0);
});
@@ -0,0 +1,45 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import type {
PrivateFieldComparisonInput,
PrivateFieldComparisonResult,
} from '@agent-device/contracts/element-text-runtime';
import { readPrivateFieldEvidence } from '@agent-device/contracts/element-text-runtime';
import { resolveAndroidAdbExecutor } from './adb-executor.ts';
import { captureAndroidCompleteUiHierarchy } from './snapshot.ts';
import { resolveFocusedPrivateTarget } from './private-field-target.ts';
import {
acquireAndroidPrivateInputScope,
compareAndroidPrivateInput,
} from './private-input-comparison.ts';
export async function compareAndroidPrivateField(
device: DeviceInfo,
input: PrivateFieldComparisonInput,
signal: AbortSignal,
): Promise<PrivateFieldComparisonResult> {
try {
signal.throwIfAborted();
const original = readPrivateFieldEvidence(input.target);
if (!original || original.appId !== input.appId)
return { status: 'unknown', reason: 'original_scope_unavailable' };
const execute = resolveAndroidAdbExecutor(device);
const adb: typeof execute = (args, options) => execute(args, { ...options, signal });
const scope = await acquireAndroidPrivateInputScope(adb, input.appId);
if (
!scope ||
scope.connectionToken !== original.connectionToken ||
scope.fieldId !== original.fieldId
)
return { status: 'unknown', reason: 'connection_changed' };
const tree = await captureAndroidCompleteUiHierarchy(device, {
signal,
interactiveOnly: false,
});
if (!tree || !resolveFocusedPrivateTarget(tree, { ...input, windowId: original.windowId }))
return { status: 'unknown', reason: 'target_unconfirmed' };
signal.throwIfAborted();
return await compareAndroidPrivateInput(adb, scope, input.expectedValue);
} catch {
return { status: 'unknown', reason: 'comparison_unavailable' };
}
}
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { resolveFocusedPrivateTarget } from './private-field-target.ts';
import { parseUiHierarchyTree } from './ui-hierarchy.ts';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
const target: SnapshotNode = {
index: 2,
ref: 'e2',
type: 'android.widget.EditText',
rect: { x: 10, y: 20, width: 100, height: 30 },
editable: true,
focused: true,
};
const field =
'<node class="android.widget.EditText" package="example.app" window-id="7" bounds="[10,20][110,50]" editable="true" focused="true" />';
function hierarchy(
nodes = field,
window = 'window-active="true" window-focused="true" window-type="1"',
) {
return parseUiHierarchyTree(
`<hierarchy><node package="example.app" window-index="0" ${window}>${nodes}</node></hierarchy>`,
);
}
test('fresh focused unique native field binds without requiring static Android resource IDs', () => {
assert.ok(resolveFocusedPrivateTarget(hierarchy(), { target, appId: 'example.app' }));
});
test('rejects duplicate, other app, unfocused, wrong window, and missing window provenance', () => {
for (const tree of [
hierarchy(field + field),
hierarchy(field.replace('example.app', 'other.app')),
hierarchy(field.replace('focused="true"', 'focused="false"')),
hierarchy(field, ''),
hierarchy(field, 'window-active="true" window-focused="false" window-type="1"'),
]) {
assert.equal(resolveFocusedPrivateTarget(tree, { target, appId: 'example.app' }), undefined);
}
});
test('rejects stale target geometry and target that was not originally focused', () => {
assert.equal(
resolveFocusedPrivateTarget(hierarchy(), {
target: { ...target, focused: false },
appId: 'example.app',
}),
undefined,
);
assert.equal(
resolveFocusedPrivateTarget(hierarchy(), {
target: { ...target, rect: { ...target.rect!, x: 12 } },
appId: 'example.app',
}),
undefined,
);
assert.equal(
resolveFocusedPrivateTarget(hierarchy(), { target, appId: 'example.app', windowId: 8 }),
undefined,
);
});
@@ -0,0 +1,60 @@
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
import type { AndroidUiHierarchy } from './ui-hierarchy.ts';
export function resolveFocusedPrivateTarget(
tree: AndroidUiHierarchy,
input: { target: RawSnapshotNode; appId: string; windowId?: number },
): AndroidUiHierarchy | undefined {
const { target } = input;
const rect = target.rect;
if (!rect || target.editable !== true || target.focused !== true) return undefined;
const matches: AndroidUiHierarchy[] = [];
const focused: AndroidUiHierarchy[] = [];
function visit(node: AndroidUiHierarchy, window?: AndroidUiHierarchy) {
const owner = node.windowIndex !== undefined ? node : window;
if (node.focused === true && node.editable === true) focused.push(node);
if (
matchesIdentity(node, input) &&
matchesRect(node.rect, rect!) &&
isAvailableInput(node) &&
isFocusedApplicationWindow(owner)
)
matches.push(node);
for (const child of node.children) visit(child, owner);
}
visit(tree);
return matches.length === 1 && focused.length === 1 && matches[0] === focused[0]
? matches[0]
: undefined;
}
function matchesIdentity(
node: AndroidUiHierarchy,
input: { target: RawSnapshotNode; appId: string; windowId?: number },
): boolean {
return (
node.packageName === input.appId &&
node.type === input.target.type &&
(node.identifier ?? '') === (input.target.identifier ?? '') &&
node.windowId !== undefined &&
(input.windowId === undefined || input.windowId === node.windowId)
);
}
function matchesRect(actual: Rect | undefined, expected: Rect): boolean {
return (
actual !== undefined &&
actual.x === expected.x &&
actual.y === expected.y &&
actual.width === expected.width &&
actual.height === expected.height
);
}
function isAvailableInput(node: AndroidUiHierarchy): boolean {
return node.editable === true && node.enabled !== false && node.visibleToUser !== false;
}
function isFocusedApplicationWindow(window: AndroidUiHierarchy | undefined): boolean {
return window?.windowActive === true && window.windowFocused === true && window.windowType === 1;
}
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { AndroidAdbExecutor } from './adb-transport.ts';
import {
acquireAndroidPrivateInputScope,
compareAndroidPrivateInput,
} from './private-input-comparison.ts';
const bound = {
appId: 'example.app',
fieldId: 9,
connectionToken: '01234567-0123-0123-0123-012345678901:1',
};
function transport(status: 'match' | 'mismatch', changed = false) {
let payload: Record<string, unknown> = {};
const argsSeen: string[][] = [];
const adb: AndroidAdbExecutor = async (args, options) => {
argsSeen.push(args);
if (options?.stdin) {
payload = JSON.parse(String(options.stdin));
return { exitCode: 0, stdout: '', stderr: '' };
}
const result =
payload.operation === 'acquire'
? {
status: 'unknown',
reason: 'scope_acquired',
...bound,
connectionToken: changed
? bound.connectionToken.replace(':1', ':2')
: bound.connectionToken,
}
: { status, ...bound, source: 'android-ime-extracted-text' };
return {
exitCode: 0,
stdout: `Broadcast completed: result=0, data="${JSON.stringify(result)}"`,
stderr: '',
};
};
return { adb, argsSeen };
}
test('private comparison preserves match and same-length mismatch without expected data in argv', async () => {
for (const status of ['match', 'mismatch'] as const) {
const { adb, argsSeen } = transport(status);
assert.equal(
(await compareAndroidPrivateInput(adb, bound, 'synthetic-private-value')).status,
status,
);
assert.ok(!JSON.stringify(argsSeen).includes('synthetic-private-value'));
assert.ok(
argsSeen
.filter((args) => args.includes('broadcast'))
.every((args) => args.includes('--receiver-foreground')),
);
}
});
test('generation change after extraction rejects a matching result', async () => {
assert.deepEqual(await compareAndroidPrivateInput(transport('match', true).adb, bound, '1234'), {
status: 'unknown',
reason: 'connection_changed',
});
});
test('acquisition validates connection provenance', async () => {
assert.deepEqual(
await acquireAndroidPrivateInputScope(transport('match').adb, bound.appId),
bound,
);
assert.equal(
await acquireAndroidPrivateInputScope(transport('match').adb, 'other.app'),
undefined,
);
});
test('transport exceptions and oversized values cannot leak sensitive messages', async () => {
const adb: AndroidAdbExecutor = async () => {
throw new Error('synthetic-private-value');
};
const result = await compareAndroidPrivateInput(adb, bound, 'synthetic-private-value');
assert.deepEqual(result, { status: 'unknown', reason: 'comparison_unavailable' });
assert.equal(
(await compareAndroidPrivateInput(adb, bound, 'x'.repeat(16_001))).status,
'unknown',
);
});
@@ -0,0 +1,130 @@
import { randomBytes } from 'node:crypto';
import type { AndroidAdbExecutor } from './adb-transport.ts';
const PACKAGE = 'com.callstack.agentdevice.imehelper';
const PROTOCOL = 'android-private-input-v1';
const TIMEOUT_MS = 3_000;
export type AndroidPrivateInputScope = {
connectionToken: string;
appId: string;
fieldId: number;
};
export type AndroidPrivateInputResult =
| { status: 'unknown'; reason: string }
| ({
status: 'match' | 'mismatch';
source: 'android-ime-extracted-text';
} & AndroidPrivateInputScope);
export async function acquireAndroidPrivateInputScope(
adb: AndroidAdbExecutor,
appId: string,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<AndroidPrivateInputScope | undefined> {
const result = await request(adb, { protocol: PROTOCOL, operation: 'acquire', appId }, options);
return result.reason === 'scope_acquired' ? scope(result, appId) : undefined;
}
/** Caller must bind this scope to its fresh focused target before comparing. */
export async function compareAndroidPrivateInput(
adb: AndroidAdbExecutor,
boundScope: AndroidPrivateInputScope,
expectedValue: string,
): Promise<AndroidPrivateInputResult> {
if (expectedValue.length > 16_000) return unknown('invalid_expected_value');
const result = await request(adb, {
protocol: PROTOCOL,
operation: 'compare',
...boundScope,
expectedValue,
});
const observedScope = scope(result, boundScope.appId);
if (
(result.status !== 'match' && result.status !== 'mismatch') ||
!observedScope ||
observedScope.connectionToken !== boundScope.connectionToken ||
observedScope.fieldId !== boundScope.fieldId ||
result.source !== 'android-ime-extracted-text'
)
return unknown('comparison_unavailable');
const after = await acquireAndroidPrivateInputScope(adb, boundScope.appId);
if (
!after ||
after.connectionToken !== boundScope.connectionToken ||
after.fieldId !== boundScope.fieldId
)
return unknown('connection_changed');
return { status: result.status, source: 'android-ime-extracted-text', ...observedScope };
}
function unknown(reason: string): AndroidPrivateInputResult {
return { status: 'unknown', reason };
}
function scope(
value: Record<string, unknown>,
appId: string,
): AndroidPrivateInputScope | undefined {
if (
value.appId !== appId ||
typeof value.connectionToken !== 'string' ||
!/^[a-f0-9-]{36}:\d{1,16}$/.test(value.connectionToken) ||
typeof value.fieldId !== 'number' ||
!Number.isSafeInteger(value.fieldId)
)
return undefined;
return { appId, connectionToken: value.connectionToken, fieldId: value.fieldId };
}
async function request(
adb: AndroidAdbExecutor,
payload: Record<string, unknown>,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<Record<string, unknown>> {
try {
const stdin = JSON.stringify(payload);
if (Buffer.byteLength(stdin) > 65_536) return {};
const id = randomBytes(16).toString('hex');
const written = await adb(
['shell', 'content', 'write', '--uri', `content://${PACKAGE}.private/request/${id}`],
{
stdin,
timeoutMs: options.timeoutMs ?? TIMEOUT_MS,
signal: options.signal,
allowFailure: true,
},
);
if (written.exitCode !== 0) return {};
const response = await adb(
[
'shell',
'am',
'broadcast',
'--receiver-foreground',
'-p',
PACKAGE,
'-a',
`${PACKAGE}.ACTION_PRIVATE_INPUT`,
'--es',
'requestId',
id,
],
{ timeoutMs: options.timeoutMs ?? TIMEOUT_MS, signal: options.signal, allowFailure: true },
);
return response.exitCode === 0 ? parseResponse(response.stdout) : {};
} catch {
return {};
}
}
function parseResponse(stdout: string): Record<string, unknown> {
if (stdout.length > 4096) return {};
const match = /data="(\{[^\r\n]*\})"/.exec(stdout);
if (!match?.[1]) return {};
const parsed: unknown = JSON.parse(match[1]);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
}
+11
View File
@@ -339,6 +339,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
// uiautomator reads text at a point through the same adb path the snapshot uses, so the
// synthetic `simulator` row is the only Android kind without a live read.
...elementTextRuntimeOperationFacts({
comparePrivateField: device.kind === 'simulator' ? elementTextKindUnavailable : available,
readTextAtPoint: device.kind === 'simulator' ? elementTextKindUnavailable : available,
}),
...backRuntimeOperationFacts({ back: androidTouchFact(device) }),
@@ -523,5 +524,15 @@ function androidInteractionOperations(
facts: facts.operations,
pause: async (milliseconds) => await host.clock.sleep(milliseconds, request.scope.signal),
}),
...(facts.operations.comparePrivateField.available
? {
comparePrivateField: async (
input: import('@agent-device/contracts/element-text-runtime').PrivateFieldComparisonInput,
) => {
const { compareAndroidPrivateField } = await import('./private-field-runtime.ts');
return await compareAndroidPrivateField(request.device, input, request.scope.signal);
},
}
: {}),
};
}
+32
View File
@@ -98,11 +98,34 @@ export async function captureAndroidUiHierarchyXml(
return (await captureAndroidUiHierarchy(device, options, adb)).xml;
}
export async function captureAndroidCompleteUiHierarchy(
device: DeviceInfo,
options: AndroidSnapshotOptions = {},
): Promise<AndroidUiHierarchy | undefined> {
const adb = resolveAndroidAdbProvider(device, options.helperAdb).exec;
const capture = await captureAndroidUiHierarchy(device, options, adb);
if (
capture.metadata.helperTruncated !== false ||
capture.metadata.rootPresent !== true ||
capture.metadata.systemSurfaceOnly
)
return undefined;
return parseUiHierarchyTree(capture.xml);
}
export async function snapshotAndroid(
device: DeviceInfo,
options: AndroidSnapshotOptions = {},
): Promise<AndroidSnapshotCapture> {
const { beginPrivateFieldCapture, finishPrivateFieldCapture } =
await import('./private-field-capture.ts');
const adb = resolveAndroidAdbProvider(device, options.helperAdb).exec;
const privateScope = await beginPrivateFieldCapture(
device,
adb,
options.appBundleId,
options.signal,
);
const capture = await captureAndroidUiHierarchy(device, options, adb);
const xml = capture.xml;
const tree = parseUiHierarchyTree(xml);
@@ -135,6 +158,15 @@ export async function snapshotAndroid(
androidSnapshot,
quality: { state: 'healthy', backend: 'android-helper' } as const,
};
await finishPrivateFieldCapture({
adb,
scope: privateScope,
signal: options.signal,
nodes: result.nodes,
tree,
metadata: capture.metadata,
truncated,
});
return createAndroidSnapshotCapture(result, {
clickability: buildAndroidSnapshotClickabilityEvidence(built),
occlusionContext,
@@ -30,6 +30,7 @@ export type AndroidUiHierarchy = {
canScrollForward?: boolean;
canScrollBackward?: boolean;
windowIndex?: number;
windowId?: number;
windowType?: number;
windowLayer?: number;
windowActive?: boolean;
@@ -47,6 +47,7 @@ export type AndroidUiNodeMetadata = {
canScrollForward?: boolean;
canScrollBackward?: boolean;
windowIndex?: number;
windowId?: number;
windowType?: number;
windowLayer?: number;
windowActive?: boolean;
@@ -160,6 +161,7 @@ function readNodeAttributes(node: string): Omit<AndroidUiNodeMetadata, 'rect'> {
...optionalBoolAttr('canScrollForward', 'can-scroll-forward'),
...optionalBoolAttr('canScrollBackward', 'can-scroll-backward'),
...optionalNumberAttr('windowIndex', 'window-index'),
...optionalNumberAttr('windowId', 'window-id'),
...optionalNumberAttr('windowType', 'window-type'),
...optionalNumberAttr('windowLayer', 'window-layer'),
...optionalBoolAttr('windowActive', 'window-active'),
@@ -318,6 +320,7 @@ function normalizeAndroidUiHierarchyNode(
canScrollForward: attrs.canScrollForward,
canScrollBackward: attrs.canScrollBackward,
windowIndex: attrs.windowIndex,
windowId: attrs.windowId,
windowType: attrs.windowType,
windowLayer: attrs.windowLayer,
windowActive: attrs.windowActive,
+1 -1
View File
@@ -33,7 +33,7 @@ case "$HELPER" in
ime)
HELPER_DIR="$PROJECT_DIR/android/ime-helper"
PACKAGE_NAME="com.callstack.agentdevice.imehelper"
RUN_TEST_CLASS=""
RUN_TEST_CLASS="com.callstack.agentdevice.imehelper.PrivateInputValueTest"
RESOURCE_DIR="$PROJECT_DIR/android/ime-helper/res"
;;
*)
+2 -2
View File
@@ -7,12 +7,12 @@
"url": "https://github.com/callstack/agent-device",
"source": "github"
},
"version": "0.20.11-dev",
"version": "0.20.11-a1",
"packages": [
{
"registryType": "npm",
"identifier": "agent-device",
"version": "0.20.11-dev",
"version": "0.20.11-a1",
"transport": {
"type": "stdio"
},
+13
View File
@@ -104,6 +104,19 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([
]);
export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise<void> {
if (argv[0] === 'compare-field') {
try {
const { runPrivateFieldComparison } = await import('./cli/private-field-comparison.ts');
await runPrivateFieldComparison(argv, async (args) => await runCli(args, deps));
} catch {
await printJson({
success: false,
error: { code: 'INVALID_ARGS', message: 'Private field comparison failed' },
});
process.exitCode = 1;
}
return;
}
const requestId = createRequestId();
const version = readVersion();
const debugEnabled = isDebugRequested(argv);
+58
View File
@@ -0,0 +1,58 @@
import { Readable } from 'node:stream';
import { expect, it } from 'vitest';
import { privateComparisonArgs, runPrivateFieldComparison } from './private-field-comparison.ts';
import { serializePrivateSocketRequest } from '../daemon/private-field-comparison.ts';
const args = [
'compare-field',
'--private-stdin',
'--json',
'--platform',
'android',
'--session',
'qa',
'--',
'@e1~s2',
];
it('maps only explicit Android versioned-ref requests', () => {
expect(privateComparisonArgs(args)).toEqual([
'get',
'attrs',
'--json',
'--platform',
'android',
'--session',
'qa',
'--',
'@e1~s2',
]);
for (const argv of [
args.slice(0, -1),
[...args, 'unexpected'],
args.map((a) => (a === 'android' ? 'ios' : a)),
args.map((a) => (a === '@e1~s2' ? '@e1' : a)),
]) {
expect(() => privateComparisonArgs(argv)).toThrow();
}
});
it('keeps input out of translated argv and preserves Unicode', async () => {
const payload = {
protocol: 'android-private-input-v1',
requestId: '12345678-1234-1234-1234-123456789abc',
expectedValue: 'é🔒fixture',
};
const body = Buffer.from(JSON.stringify(payload));
await runPrivateFieldComparison(
args,
async (translated) => {
expect(JSON.stringify(translated)).not.toContain(payload.expectedValue);
expect(JSON.parse(serializePrivateSocketRequest({})).privateFieldComparison).toEqual(payload);
},
Readable.from([body.subarray(0, body.length - 8), body.subarray(body.length - 8)]),
);
});
it('never echoes malformed input in errors', async () => {
await expect(
runPrivateFieldComparison(args, async () => {}, Readable.from(['secret malformed input'])),
).rejects.toThrow('Invalid private comparison payload');
});
+89
View File
@@ -0,0 +1,89 @@
import type { Readable } from 'node:stream';
import { AppError } from '@agent-device/kernel/errors';
import {
parsePrivateFieldComparison,
PRIVATE_COMPARISON_MAX_BYTES,
withOutgoingPrivateFieldComparison,
} from '../daemon/private-field-comparison.ts';
export function privateComparisonArgs(argv: string[]): string[] {
const separator = argv.indexOf('--');
const flags = parseComparisonFlags(argv.slice(1, separator));
validateComparisonFlags(flags);
const session = requiredPattern(flags.get('--session'), /^[A-Za-z0-9_.-]{1,128}$/);
const ref = requiredPattern(argv[separator + 1], /^@e\d+~s\d+$/);
if (separator < 1 || separator + 2 !== argv.length) throw invalidArgs();
return ['get', 'attrs', '--json', '--platform', 'android', '--session', session, '--', ref];
}
function parseComparisonFlags(argv: string[]): Map<string, string | true> {
const flags = new Map<string, string | true>();
for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
if (!flag) throw invalidArgs();
if (flags.has(flag)) throw invalidArgs();
if (['--private-stdin', '--json'].includes(flag)) flags.set(flag, true);
else if (['--platform', '--session'].includes(flag)) {
flags.set(flag, requiredPattern(argv[++index], /^(?!--).+$/));
} else throw invalidArgs();
}
return flags;
}
function requiredPattern(value: unknown, pattern: RegExp): string {
if (typeof value !== 'string' || !pattern.test(value)) throw invalidArgs();
return value;
}
function validateComparisonFlags(flags: Map<string, string | true>): void {
if (
flags.size !== 4 ||
flags.get('--platform') !== 'android' ||
flags.get('--private-stdin') !== true ||
flags.get('--json') !== true
)
throw invalidArgs();
}
function invalidArgs(): AppError {
return new AppError(
'INVALID_ARGS',
'Usage: compare-field --private-stdin --json --platform android --session NAME -- @eN~sG',
);
}
export async function runPrivateFieldComparison(
argv: string[],
run: (args: string[]) => Promise<void>,
input: Readable = process.stdin,
): Promise<void> {
const args = privateComparisonArgs(argv);
let body = '';
let bytes = 0;
input.setEncoding('utf8');
const timer = setTimeout(
() => input.destroy(new AppError('INVALID_ARGS', 'Private comparison input timed out')),
10_000,
);
timer.unref();
try {
for await (const chunk of input) {
bytes += Buffer.byteLength(chunk);
if (bytes > PRIVATE_COMPARISON_MAX_BYTES)
throw new AppError('INVALID_ARGS', 'Private comparison input is too large');
body += chunk.toString();
}
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
throw new AppError('INVALID_ARGS', 'Invalid private comparison payload');
}
body = '';
const payload = parsePrivateFieldComparison(parsed);
await withOutgoingPrivateFieldComparison(payload, async () => await run(args));
} finally {
clearTimeout(timer);
body = '';
}
}
@@ -6,6 +6,7 @@ import {
providerRuntimeOwner,
} from '@agent-device/contracts/platform-runtime';
import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations';
import type { ElementTextRuntimeOperations } from '@agent-device/contracts/element-text-runtime';
import {
type CaptureSnapshotInput,
type SnapshotResult,
@@ -31,6 +32,7 @@ export function selectorCaptureFixture(
withoutActiveApp?: RuntimeOperationFact;
findText?: RuntimeOperationFact;
snapshot?: (input: CaptureSnapshotInput, index: number) => SnapshotResult;
comparePrivateField?: ElementTextRuntimeOperations['comparePrivateField'];
}> = {},
): Readonly<{
inspectFacts: InspectDeviceRuntimeFacts;
@@ -58,6 +60,7 @@ export function selectorCaptureFixture(
withoutActiveApp: params.withoutActiveApp ?? params.capture ?? available,
}),
...(params.findText ? { findText: params.findText } : {}),
...(params.comparePrivateField ? { comparePrivateField: available } : {}),
},
};
};
@@ -90,6 +93,9 @@ export function selectorCaptureFixture(
captureSnapshot,
captureSnapshotWithCustomActions: captureSnapshot,
captureSnapshotWithoutActiveApp: captureSnapshot,
...(params.comparePrivateField
? { comparePrivateField: params.comparePrivateField }
: {}),
},
[Symbol.asyncDispose]: async () => {},
},
+35 -2
View File
@@ -178,6 +178,32 @@ export async function sendRequest(
options: SendRequestOptions = {},
): Promise<DaemonResponse> {
const transport = chooseTransport(info, preference);
const { hasOutgoingPrivateFieldComparison } = await import('../private-field-comparison.ts');
if (hasOutgoingPrivateFieldComparison()) {
const { sanitizePrivateFieldResponse } = await import('../private-field-response.ts');
if (transport !== 'socket' || info.baseUrl) {
throw new AppError('INVALID_ARGS', 'Private comparison requires the local socket transport');
}
try {
const { requirePrivateFieldDaemonIdentity } =
await import('./private-field-daemon-identity.ts');
await requirePrivateFieldDaemonIdentity(info);
return sanitizePrivateFieldResponse(
await sendRequestWithTransport(info, req, statePaths, timeoutMs, transport, {
onProgress: () => {},
}),
);
} catch {
return {
ok: true,
data: {
protocol: 'android-private-input-v1',
status: 'unknown',
reason: 'private-comparison-transport-failed',
},
};
}
}
try {
return await sendRequestWithTransport(info, req, statePaths, timeoutMs, transport, options);
} catch (error) {
@@ -300,13 +326,20 @@ async function sendSocketRequest(
timeoutMs: number | undefined,
options: SendRequestOptions,
): Promise<DaemonResponse> {
const { serializePrivateSocketRequest } = await import('../private-field-comparison.ts');
const port = info.port;
if (!port) throw new AppError('COMMAND_FAILED', DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE);
return new Promise((resolve, reject) => {
let requestWritten = false;
const socket = net.createConnection({ host: '127.0.0.1', port }, () => {
requestWritten = true;
socket.write(`${JSON.stringify(req)}\n`);
try {
const wire = serializePrivateSocketRequest(req);
requestWritten = true;
socket.write(`${wire}\n`);
} catch {
socket.destroy();
reject(new AppError('INVALID_ARGS', 'Private comparison request expired'));
}
});
let settled = false;
const timeoutHandle =
@@ -0,0 +1,66 @@
import { expect, it, vi } from 'vitest';
import { requirePrivateFieldDaemonIdentity } from './private-field-daemon-identity.ts';
import { sendRequest } from './daemon-client-transport.ts';
import { withOutgoingPrivateFieldComparison } from '../private-field-comparison.ts';
import { resolveDaemonPaths } from '../config.ts';
import { resolveLocalDaemonCodeSignature } from './daemon-launch-spec.ts';
vi.mock('@agent-device/host-kit/version', () => ({ readVersion: () => '0.20.11-a1' }));
vi.mock('./daemon-launch-spec.ts', () => ({
resolveLocalDaemonCodeSignature: vi.fn(async () => 'graph:1:fixture'),
}));
const connection = vi.hoisted(() => vi.fn());
vi.mock('node:net', () => ({ default: { createConnection: connection } }));
const valid = {
version: '0.20.11-a1',
codeSignature: 'graph:1:fixture',
token: 'fixture',
pid: 1,
port: 1,
};
it('accepts matching current identity and rejects older or unverified daemon metadata', async () => {
await expect(requirePrivateFieldDaemonIdentity(valid)).resolves.toBeUndefined();
for (const patch of [
{ version: '0.20.10' },
{ version: undefined },
{ codeSignature: undefined },
{ codeSignature: 'unknown' },
{ codeSignature: 'graph:1:old' },
]) {
await expect(requirePrivateFieldDaemonIdentity({ ...valid, ...patch })).rejects.toThrow(
'verified current daemon',
);
}
vi.mocked(resolveLocalDaemonCodeSignature).mockResolvedValueOnce('unknown');
await expect(requirePrivateFieldDaemonIdentity(valid)).rejects.toThrow('verified current daemon');
});
it('does not connect or serialize private input when startup returns stale metadata', async () => {
connection.mockClear();
const payload = {
protocol: 'android-private-input-v1' as const,
requestId: '12345678-1234-1234-1234-123456789abc',
expectedValue: 'private regression fixture',
};
for (const patch of [
{ version: '0.20.10' },
{ codeSignature: undefined },
{ codeSignature: 'unknown' },
]) {
const result = await withOutgoingPrivateFieldComparison(
payload,
async () =>
await sendRequest(
{ ...valid, ...patch },
{ token: 'fixture', command: 'get', session: 'qa', positionals: ['attrs', '@e1~s2'] },
'socket',
resolveDaemonPaths('/tmp/private-field-identity-fixture'),
100,
),
);
expect(result).toMatchObject({ ok: true, data: { status: 'unknown' } });
expect(JSON.stringify(result)).not.toContain(payload.expectedValue);
}
expect(connection).not.toHaveBeenCalled();
});
@@ -0,0 +1,15 @@
import { AppError } from '@agent-device/kernel/errors';
import { readVersion } from '@agent-device/host-kit/version';
import type { DaemonInfo } from './daemon-client-metadata.ts';
import { resolveLocalDaemonCodeSignature } from './daemon-launch-spec.ts';
export async function requirePrivateFieldDaemonIdentity(info: DaemonInfo): Promise<void> {
if (info.version !== readVersion()) throw unverified();
if (!info.codeSignature || info.codeSignature === 'unknown') throw unverified();
const signature = await resolveLocalDaemonCodeSignature();
if (signature === 'unknown' || signature !== info.codeSignature) throw unverified();
}
function unverified(): AppError {
return new AppError('COMMAND_FAILED', 'Private comparison requires a verified current daemon');
}
@@ -378,6 +378,7 @@ function sourceRuntimeFacts(
}),
...scrollRuntimeOperationFacts({ scroll: unavailable }),
readTextAtPoint: unavailable,
comparePrivateField: unavailable,
back: unavailable,
home: unavailable,
setOrientation: unavailable,
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import {
consumePrivateFieldComparison,
hasOutgoingPrivateFieldComparison,
parsePrivateFieldComparison,
serializePrivateSocketRequest,
withOutgoingPrivateFieldComparison,
withPrivateFieldComparison,
} from './private-field-comparison.ts';
const payload = {
protocol: 'android-private-input-v1' as const,
requestId: '12345678-1234-1234-1234-123456789abc',
expectedValue: 'private-fixture-value',
};
describe('private comparison scope', () => {
it('adds private input only at final serialization and consumes it once', async () => {
const request = { command: 'get', positionals: ['attrs', '@e1~s2'] };
await withOutgoingPrivateFieldComparison(payload, async () => {
expect(JSON.stringify(request)).not.toContain(payload.expectedValue);
expect(JSON.parse(serializePrivateSocketRequest(request)).privateFieldComparison).toEqual(
payload,
);
expect(() => serializePrivateSocketRequest(request)).toThrow();
});
expect(hasOutgoingPrivateFieldComparison()).toBe(false);
});
it('isolates concurrent scopes even with identical caller IDs', async () => {
await Promise.all(
['one', 'two'].map(async (expectedValue) => {
await withPrivateFieldComparison({ ...payload, expectedValue }, async () => {
await Promise.resolve();
expect(consumePrivateFieldComparison()?.expectedValue).toBe(expectedValue);
expect(() => consumePrivateFieldComparison()).toThrow();
});
}),
);
expect(consumePrivateFieldComparison()).toBeUndefined();
});
it('cleans scope after exceptions and leaves ordinary requests unaffected', async () => {
await expect(
withPrivateFieldComparison(payload, async () => {
throw new Error('fixture');
}),
).rejects.toThrow('fixture');
await withPrivateFieldComparison(undefined, async () => {
expect(consumePrivateFieldComparison()).toBeUndefined();
});
});
it('rejects extra keys and oversized values without echoing input', () => {
for (const value of [
{ ...payload, extra: 'secret' },
{ ...payload, expectedValue: 'x'.repeat(16385) },
]) {
expect(() => parsePrivateFieldComparison(value)).toThrow(
'Invalid private comparison payload',
);
}
});
});
+92
View File
@@ -0,0 +1,92 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { AppError } from '@agent-device/kernel/errors';
export type PrivateFieldComparison = {
protocol: 'android-private-input-v1';
requestId: string;
expectedValue: string;
};
type PrivateScope = { payload?: PrivateFieldComparison };
const outgoing = new AsyncLocalStorage<PrivateScope>();
const incoming = new AsyncLocalStorage<PrivateScope>();
export const PRIVATE_COMPARISON_MAX_BYTES = 32_768;
export function parsePrivateFieldComparison(value: unknown): PrivateFieldComparison {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid();
const input = value as Record<string, unknown>;
if (
Object.keys(input).sort().join(',') !== 'expectedValue,protocol,requestId' ||
input.protocol !== 'android-private-input-v1' ||
typeof input.requestId !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input.requestId) ||
typeof input.expectedValue !== 'string' ||
Buffer.byteLength(input.expectedValue, 'utf8') > 16_384
)
throw invalid();
return input as PrivateFieldComparison;
}
function invalid(): AppError {
return new AppError('INVALID_ARGS', 'Invalid private comparison payload');
}
async function scoped<T>(
storage: AsyncLocalStorage<PrivateScope>,
payload: PrivateFieldComparison | undefined,
run: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const scope: PrivateScope = { payload };
const clear = () => {
scope.payload = undefined;
};
const timer = setTimeout(clear, 60_000);
timer.unref();
signal?.addEventListener('abort', clear, { once: true });
if (signal?.aborted) clear();
try {
return await storage.run(scope, run);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', clear);
clear();
}
}
export function withOutgoingPrivateFieldComparison<T>(
payload: PrivateFieldComparison,
run: () => Promise<T>,
): Promise<T> {
return scoped(outgoing, payload, run);
}
export function hasOutgoingPrivateFieldComparison(): boolean {
return outgoing.getStore() !== undefined;
}
export function serializePrivateSocketRequest(request: unknown): string {
const scope = outgoing.getStore();
const payload = scope?.payload;
if (scope && !payload) throw invalid();
if (scope) scope.payload = undefined;
return JSON.stringify(
payload ? { ...(request as object), privateFieldComparison: payload } : request,
);
}
export function withPrivateFieldComparison<T>(
payload: PrivateFieldComparison | undefined,
run: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
if (!payload) return run();
return scoped(incoming, payload, run, signal);
}
export function consumePrivateFieldComparison(): PrivateFieldComparison | undefined {
const scope = incoming.getStore();
const payload = scope?.payload;
if (scope && !payload) throw invalid();
if (scope) scope.payload = undefined;
return payload;
}
+35
View File
@@ -0,0 +1,35 @@
import { expect, it } from 'vitest';
import { sanitizePrivateFieldResponse } from './private-field-response.ts';
it('suppresses old daemon attrs and error details', () => {
for (const response of [
{ ok: true as const, data: { text: 'private observed fixture' } },
{ ok: false as const, error: { code: 'COMMAND_FAILED', message: 'private observed fixture' } },
]) {
const safe = sanitizePrivateFieldResponse(response);
expect(JSON.stringify(safe)).not.toContain('private observed fixture');
expect(safe).toMatchObject({ ok: true, data: { status: 'unknown' } });
}
});
it('whitelists bounded native provenance and removes additional fields', () => {
const response = sanitizePrivateFieldResponse({
ok: true,
data: {
protocol: 'android-private-input-v1',
status: 'match',
requestId: '12345678-1234-1234-1234-123456789abc',
sessionId: 'qa',
ref: '@e1~s2',
source: 'android-ime-extracted-text',
appId: 'org.fixture',
connectionToken: '12345678-1234-1234-1234-123456789abc:2',
fieldId: 1,
refsGeneration: 2,
text: 'private observed fixture',
expectedValue: 'private expected fixture',
},
});
expect(response).toMatchObject({ ok: true, data: { status: 'match' } });
expect(JSON.stringify(response)).not.toContain('private observed fixture');
expect(JSON.stringify(response)).not.toContain('private expected fixture');
});
+70
View File
@@ -0,0 +1,70 @@
import type { DaemonResponse } from './types.ts';
const protocol = 'android-private-input-v1';
export function sanitizePrivateFieldResponse(response: DaemonResponse): DaemonResponse {
const unavailable: DaemonResponse = {
ok: true,
data: { protocol, status: 'unknown', reason: 'private-comparison-response-invalid' },
};
if (!response.ok || !response.data || typeof response.data !== 'object') return unavailable;
const data = response.data as Record<string, unknown>;
if (data.protocol !== protocol || !['match', 'mismatch', 'unknown'].includes(String(data.status)))
return unavailable;
const result: Record<string, unknown> = { protocol, status: data.status };
if (!copyStringProvenance(data, result)) return unavailable;
if (!copyNumericProvenance(data, result)) return unavailable;
if (!hasRequiredProvenance(result)) return unavailable;
return { ok: true, data: result };
}
function copyStringProvenance(
data: Record<string, unknown>,
result: Record<string, unknown>,
): boolean {
const fields: Record<string, RegExp> = {
requestId: /^[0-9a-f-]{36}$/i,
sessionId: /^[A-Za-z0-9_.-]{1,128}$/,
ref: /^@e\d+~s\d+$/,
source: /^android-ime-extracted-text$/,
appId: /^[A-Za-z0-9_.]{1,255}$/,
connectionToken: /^[A-Za-z0-9_.:-]{1,128}$/,
reason: /^[a-z0-9_-]{1,128}$/,
};
for (const [key, pattern] of Object.entries(fields)) {
if (data[key] === undefined) continue;
if (typeof data[key] !== 'string' || !pattern.test(data[key])) return false;
result[key] = data[key];
}
return true;
}
function copyNumericProvenance(
data: Record<string, unknown>,
result: Record<string, unknown>,
): boolean {
for (const key of ['refsGeneration', 'fieldId']) {
if (data[key] === undefined) continue;
if (!Number.isSafeInteger(data[key])) return false;
result[key] = data[key];
}
return true;
}
function hasRequiredProvenance(result: Record<string, unknown>): boolean {
if (
result.status !== 'unknown' &&
[
'requestId',
'sessionId',
'ref',
'source',
'appId',
'connectionToken',
'refsGeneration',
'fieldId',
].some((key) => result[key] === undefined)
)
return false;
return true;
}
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import { makeAndroidSession } from '../__tests__/test-utils/session-factories.ts';
import { makeSessionStore } from '../__tests__/test-utils/store-factory.ts';
import { selectorCaptureFixture } from './__tests__/selector-capture-fixture.ts';
import { activateCompleteRefFrame } from './ref-frame.ts';
import { dispatchGetViaRuntime } from './selector-runtime.ts';
import { withPrivateFieldComparison } from './private-field-comparison.ts';
test('private get route resolves a real @ref generation through the owned frame', async () => {
for (const ref of ['@e1~s7', '@e1~s6']) {
const appId = 'example.app';
const tree: SnapshotState = {
backend: 'android',
producer: 'android-uiautomator',
createdAt: 1,
nodes: [{ index: 0, ref: 'e1', bundleId: appId, editable: true, focused: true }],
};
const session = makeAndroidSession('private-field-test', {
appBundleId: appId,
snapshot: tree,
snapshotGeneration: 7,
});
activateCompleteRefFrame(session);
const sessionStore = makeSessionStore();
sessionStore.set(session.name, session);
let compared = false;
const fixture = selectorCaptureFixture({
comparePrivateField: async (input) => {
compared = true;
assert.equal(input.target, tree.nodes[0]);
assert.equal(input.expectedValue, 'synthetic-private');
return {
status: 'match',
source: 'android-ime-extracted-text',
appId,
fieldId: 3,
connectionToken: '01234567-0123-0123-0123-012345678901:1',
};
},
});
const response = await withPrivateFieldComparison(
{
protocol: 'android-private-input-v1',
requestId: '01234567-0123-0123-0123-012345678901',
expectedValue: 'synthetic-private',
},
() =>
dispatchGetViaRuntime({
req: {
command: 'get',
positionals: ['attrs', ref],
token: 'test',
session: session.name,
},
sessionName: session.name,
sessionStore,
inspectFacts: fixture.inspectFacts,
bindDevice: fixture.bindDevice,
}),
);
assert.equal(response?.ok, true);
if (!response?.ok) throw new Error('Expected private result');
assert.equal(response.data?.status, ref === '@e1~s7' ? 'match' : 'unknown');
assert.equal(compared, ref === '@e1~s7');
assert.equal(JSON.stringify(response).includes('synthetic-private'), false);
}
});
+92
View File
@@ -0,0 +1,92 @@
import {
findNodeByRef,
normalizeRef,
type SnapshotState,
type SnapshotNode,
} from '@agent-device/kernel/snapshot';
import type { SelectorRuntimeParams } from './selector-runtime-backend.ts';
import type { DaemonResponse, SessionState } from './types.ts';
import { parseVersionedRefPositional } from './ref-positionals.ts';
import { readRefMutationFrame, readSessionRuntimeRevision } from './ref-frame.ts';
import { resolveBoundSelectorCapture } from './selector-capture-binding.ts';
export async function dispatchPrivateFieldComparison(
params: SelectorRuntimeParams,
request: { protocol: string; requestId: string; expectedValue: string },
): Promise<DaemonResponse> {
const originalRef = params.req.positionals?.[1] ?? '';
const session = params.sessionStore.get(params.sessionName);
const base = {
protocol: 'android-private-input-v1',
requestId: request.requestId,
sessionId: params.sessionName,
ref: originalRef,
source: 'android-ime-extracted-text',
};
const unknown = (reason: string): DaemonResponse => ({
ok: true,
data: { ...base, status: 'unknown', reason },
});
if (params.req.positionals?.[0] !== 'attrs' || !session || session.device.platform !== 'android')
return unknown('unsupported');
const owned = resolveOwnedTarget(session, originalRef);
if (!owned) return unknown('target_unconfirmed');
const { target, appId, generation } = owned;
const revision = readSessionRuntimeRevision(session);
try {
const bound = await resolveBoundSelectorCapture({
command: 'get',
device: session.device,
session,
inspectFacts: params.inspectFacts,
bindDevice: params.bindDevice,
});
if (!bound.ok || !bound.operations.comparePrivateField) return unknown('unsupported');
const result = await bound.operations.comparePrivateField({
target,
appId,
expectedValue: request.expectedValue,
});
if (
readSessionRuntimeRevision(session) !== revision ||
params.sessionStore.get(params.sessionName) !== session
)
return unknown('session_changed');
return { ok: true, data: { ...base, ...result, appId, refsGeneration: generation } };
} catch {
return unknown('comparison_unavailable');
}
}
function resolveOwnedTarget(session: SessionState, originalRef: string) {
const parsed = parseVersionedRefPositional(originalRef);
if (!parsed.ok || parsed.generation === undefined) return undefined;
const frame = readRefMutationFrame({
session,
ref: parsed.ref,
mintedGeneration: parsed.generation,
});
if (!frame.admission.admitted || frame.scope !== 'all') return undefined;
const tree = session.refFrameTree;
if (!isCompleteNativeFrame(tree)) return undefined;
const target = findNodeByRef(tree.nodes, normalizeRef(parsed.ref) ?? '');
const appId = session.appBundleId;
if (!appId || !isFocusedAppTarget(target, appId)) return undefined;
return { target, appId, generation: parsed.generation };
}
function isCompleteNativeFrame(tree: SnapshotState | undefined): tree is SnapshotState {
return (
!!tree &&
!tree.truncated &&
tree.backend === 'android' &&
tree.producer === 'android-uiautomator' &&
!tree.systemSurfaceOnly
);
}
function isFocusedAppTarget(target: SnapshotNode | null, appId: string): target is SnapshotNode {
return (
!!target && target.bundleId === appId && target.editable === true && target.focused === true
);
}
+2
View File
@@ -35,6 +35,7 @@ export type BoundSelectorFindSelector = FindSelectorRuntimeOperations['findSelec
export type BoundSelectorOperations = Readonly<{
capture: BoundSelectorCapture;
comparePrivateField?: ElementTextRuntimeOperations['comparePrivateField'];
readText?: BoundSelectorRead;
findText?: BoundSelectorFindText;
findSelector?: BoundSelectorFindSelector;
@@ -73,6 +74,7 @@ export async function resolveBoundSelectorCapture(
ok: true,
operations: {
capture: bound.capture,
...(bound.comparePrivateField ? { comparePrivateField: bound.comparePrivateField } : {}),
...(bound.readTextAtPoint ? { readText: bound.readTextAtPoint } : {}),
...(bound.findText ? { findText: bound.findText } : {}),
...(bound.findSelector ? { findSelector: bound.findSelector } : {}),
+13 -6
View File
@@ -17,6 +17,7 @@ export type BoundNativeTextRead = FindTextRuntimeOperations['findText'];
export type BoundNativeSelectorRead = FindSelectorRuntimeOperations['findSelector'];
type SelectorOperations = Readonly<{
comparePrivateField?: ElementTextRuntimeOperations['comparePrivateField'];
readTextAtPoint?: BoundElementRead;
findText?: BoundNativeTextRead;
findSelector?: BoundNativeSelectorRead;
@@ -25,19 +26,25 @@ type SelectorOperations = Readonly<{
/** Projects the one preferred operation admitted for `get` and read-only `find`. */
export function selectElementTextOperation(
runtime: Readonly<{
operations: Readonly<{ readTextAtPoint?: BoundElementRead }>;
operations: Readonly<{
readTextAtPoint?: BoundElementRead;
comparePrivateField?: ElementTextRuntimeOperations['comparePrivateField'];
}>;
}>,
): Pick<SelectorOperations, 'readTextAtPoint'> {
): Pick<SelectorOperations, 'readTextAtPoint' | 'comparePrivateField'> {
const { readTextAtPoint } = runtime.operations;
const selected = readTextAtPoint ? { operations: { readTextAtPoint } } : undefined;
return Object.freeze(
selected
return Object.freeze({
...(runtime.operations.comparePrivateField
? { comparePrivateField: runtime.operations.comparePrivateField }
: {}),
...(selected
? {
readTextAtPoint: async (input: ReadTextAtPointInput) =>
await selected.operations.readTextAtPoint(input),
}
: {},
);
: {}),
});
}
/** Projects only the fact-conditional observations admitted for `wait`. */
+6
View File
@@ -115,6 +115,12 @@ export async function dispatchGetViaRuntime(
): Promise<DaemonResponse | null> {
const { req } = params;
if (req.command !== 'get') return null;
const { consumePrivateFieldComparison } = await import('./private-field-comparison.ts');
const privateRequest = consumePrivateFieldComparison();
if (privateRequest) {
const { dispatchPrivateFieldComparison } = await import('./private-field-runtime.ts');
return await dispatchPrivateFieldComparison(params, privateRequest);
}
const format = checkGetFormat(req.positionals?.[0]);
if (!format.ok) return errorResponse(format.code, format.message);
const sub = format.format;
+42
View File
@@ -0,0 +1,42 @@
import net from 'node:net';
import { expect, it } from 'vitest';
import { createSocketServer, listenNetServer } from './transport.ts';
import { consumePrivateFieldComparison } from '../private-field-comparison.ts';
it('strips private wire input before invoking the daemon and scopes its consumption', async () => {
let ordinaryRequest = '';
const server = createSocketServer(async (req) => {
ordinaryRequest = JSON.stringify(req);
expect(consumePrivateFieldComparison()?.expectedValue).toBe('private fixture');
return { ok: true, data: { status: 'unknown' } };
});
const port = await listenNetServer(server as net.Server);
try {
await new Promise<void>((resolve, reject) => {
const socket = net.createConnection({ host: '127.0.0.1', port }, () => {
socket.write(
JSON.stringify({
command: 'get',
positionals: ['attrs', '@e1~s2'],
session: 'qa',
privateFieldComparison: {
protocol: 'android-private-input-v1',
requestId: '12345678-1234-1234-1234-123456789abc',
expectedValue: 'private fixture',
},
}) + '\n',
);
});
socket.on('data', () => {
socket.destroy();
resolve();
});
socket.on('error', reject);
});
expect(ordinaryRequest).not.toContain('private');
expect(consumePrivateFieldComparison()).toBeUndefined();
} finally {
server.destroyConnections?.();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
+40 -7
View File
@@ -1,5 +1,9 @@
import { AppError, normalizeError, createRequestCanceledError } from '@agent-device/kernel/errors';
import net from 'node:net';
import {
parsePrivateFieldComparison,
withPrivateFieldComparison,
} from '../private-field-comparison.ts';
import type { Server as HttpServer } from 'node:http';
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts';
import {
@@ -63,7 +67,7 @@ export function createSocketServer(handleRequest: DaemonInvokeFn): DaemonServer
let requestAbortRegistration: ReturnType<typeof registerRequestAbort>;
let streamProgress = false;
try {
const req = parseSocketDaemonRequest(line);
const { request: req, privatePayload } = parseSocketDaemonRequest(line);
streamProgress = shouldStreamRequestProgress(req);
requestIdForCleanup = resolveRequestTrackingId(req.meta?.requestId, 'socket');
req.meta = {
@@ -83,7 +87,12 @@ export function createSocketServer(handleRequest: DaemonInvokeFn): DaemonServer
}
}
: undefined,
async () => await handleRequest(req),
async () =>
await withPrivateFieldComparison(
privatePayload,
async () => await handleRequest(req),
requestAbortRegistration?.controller.signal,
),
);
} catch (error) {
response = { ok: false, error: normalizeError(error) };
@@ -113,17 +122,41 @@ export function createSocketServer(handleRequest: DaemonInvokeFn): DaemonServer
return server;
}
function parseSocketDaemonRequest(line: string): DaemonRequest {
const parsed = JSON.parse(line) as unknown;
function parseSocketDaemonRequest(line: string) {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
throw new AppError('INVALID_ARGS', 'Invalid socket request');
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return parsed as DaemonRequest;
return { request: parsed as DaemonRequest, privatePayload: undefined };
}
// `internal` carries daemon-issued capabilities and provenance. The socket is
// a public transport like HTTP, so a token-bearing client must not be able to
// forge those semantics even though the legacy socket request otherwise
// preserves its existing raw wire shape.
const { internal: _internal, ...request } = parsed as Record<string, unknown>;
return request as DaemonRequest;
const {
internal: _internal,
privateFieldComparison,
...request
} = parsed as Record<string, unknown>;
const privatePayload =
privateFieldComparison === undefined
? undefined
: parsePrivateFieldComparison(privateFieldComparison);
if (privatePayload) validatePrivateComparisonRoute(request);
return { request: request as DaemonRequest, privatePayload };
}
function validatePrivateComparisonRoute(request: Record<string, unknown>): void {
if (
request.command !== 'get' ||
!Array.isArray(request.positionals) ||
request.positionals[0] !== 'attrs'
) {
throw new AppError('INVALID_ARGS', 'Invalid private comparison route');
}
}
export function listenNetServer(server: net.Server): Promise<number> {
+3
View File
@@ -74,6 +74,7 @@ export type AdmittedSnapshotCapture =
* simply absent for them the member is additive and they are unchanged.
*/
readTextAtPoint?: BoundElementRead;
comparePrivateField?: import('@agent-device/contracts/element-text-runtime').ElementTextRuntimeOperations['comparePrivateField'];
/** A fact-conditional native text observation, present when the owner advertises it. */
findText?: BoundNativeTextRead;
/** A fact-conditional one-sided simple-selector observation. */
@@ -124,6 +125,7 @@ export async function admitAndBindSnapshotCapture(
ok: true,
capture: async (input: CaptureSnapshotInput) => await bound.captureSnapshot(input),
...(bound.readTextAtPoint ? { readTextAtPoint: bound.readTextAtPoint } : {}),
...(bound.comparePrivateField ? { comparePrivateField: bound.comparePrivateField } : {}),
...(bound.findText ? { findText: bound.findText } : {}),
...(bound.findSelector ? { findSelector: bound.findSelector } : {}),
...(bound.focusPoint ? { focusPoint: bound.focusPoint } : {}),
@@ -184,6 +186,7 @@ async function bindSnapshotCaptureRuntime(
Readonly<{
captureSnapshot(input: CaptureSnapshotInput): Promise<SnapshotResult>;
readTextAtPoint?: BoundElementRead;
comparePrivateField?: import('@agent-device/contracts/element-text-runtime').ElementTextRuntimeOperations['comparePrivateField'];
findText?: BoundNativeTextRead;
findSelector?: BoundNativeSelectorRead;
focusPoint?: (
@@ -66,7 +66,7 @@ test('Provider-backed integration daemon socket transport frames requests and no
},
});
assert.equal(responses[1]?.ok, false);
assert.equal(responses[1]?.error?.code, 'UNKNOWN');
assert.equal(responses[1]?.error?.code, 'INVALID_ARGS');
const clientClosed = new Promise<void>((resolve) => client.once('close', () => resolve()));
server.destroyConnections?.();