mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
feat(android): privately compare focused native input values
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+73
@@ -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;
|
||||
}
|
||||
}
|
||||
+96
@@ -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; }
|
||||
}
|
||||
+13
@@ -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";
|
||||
}
|
||||
}
|
||||
+26
@@ -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.
|
||||
|
||||
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
+1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>)
|
||||
: {};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 () => {},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
Reference in New Issue
Block a user