Skip to content

Commit b50a9c2

Browse files
committed
fix(android): never fabricate clipboard availability from a failed probe
Review blocker on #2021. The probe I added had a `catch { return true }`, then cached that result by device id for the runtime owner's lifetime. A transient adb offline or timeout therefore made `capabilities` advertise the clipboard on a build with no clipboard shell — recreating the exact lie the fix was for, and pinning it for the rest of the session. A test locked the behavior in. Support is now a typed verdict with three states, because "we could not ask" is not "it works": `supported | unsupported | probe-failed`. Only a definitive answer is cached; `probe-failed` refuses conservatively with a hint saying support could not be determined, and is deliberately not remembered, so the next inspection asks again. The same change repairs the ownership boundary. Turning raw adb stdout/stderr into a verdict is Android tool knowledge, so it belongs to the Android owner, not to shared vocabulary — `@agent-device/contracts/android-clipboard-support` now carries the typed union alone. The parser returns to `src/platforms/android/adb.ts` and runs in exactly one place, behind a new `AndroidToolHost.probeClipboardShellSupport` that hands owners the verdict. That also settles which Android home owns it: R13 lets only `src/platform-runtime.ts` import `@agent-device/platform-android`, so a parser shared between the package and the root leaf cannot live in the package either. Tests now cover the failure path the previous ones locked the wrong way: a failed probe refuses instead of admitting, its refusal says it could not determine support rather than claiming the build lacks it, and it is not cached — a second inspection re-probes and admits once the device answers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
1 parent eb76acc commit b50a9c2

7 files changed

Lines changed: 166 additions & 83 deletions

File tree

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
11
/**
2-
* Whether an adb `cmd clipboard` invocation was refused because this Android build ships no shell
3-
* implementation for the clipboard service, rather than because the call itself failed.
2+
* What an Android build's clipboard shell service answered when the owner asked.
43
*
5-
* Shared so admission and execution decide identically: `packages/platform-android` probes with it
6-
* when generating facts, and the root clipboard leaf re-checks with it as defense in depth. Two
7-
* copies of this predicate could drift into a device that `capabilities` advertises and execution
8-
* refuses, which is exactly the split ADR 0019 §2 forbids.
4+
* Three states, not two, because "we could not ask" is not "it works". `cmd clipboard` has no
5+
* shell implementation on every build, and admission has to distinguish a build that said so from
6+
* a probe that never got an answer — equating unknown with supported is how `capabilities` comes
7+
* to advertise a clipboard that execution then refuses.
98
*
10-
* It reads adb's own output because adb reports this condition in no other way: there is no exit
11-
* code or structured field that distinguishes "service has no shell command" from any other
12-
* non-zero result.
9+
* Contracts carry the typed verdict only. Turning raw adb output into it is Android tool
10+
* knowledge and stays with the Android owner (ADR 0019: platform output parsing belongs to the
11+
* owning family, never to shared vocabulary).
1312
*/
14-
export function isAndroidClipboardShellUnsupported(stdout: string, stderr: string): boolean {
15-
const haystack = `${stdout}\n${stderr}`.toLowerCase();
16-
return (
17-
haystack.includes('no shell command implementation') || haystack.includes('unknown command')
18-
);
19-
}
13+
export type AndroidClipboardShellSupport = 'supported' | 'unsupported' | 'probe-failed';

packages/contracts/src/platform-runtime-host.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DeviceInfo, Platform } from '@agent-device/kernel/device';
22
import type { JsonObject, JsonValue } from './json.ts';
3+
import type { AndroidClipboardShellSupport } from './android-clipboard-support.ts';
34

45
export type HostCommandRequest = Readonly<{
56
executable: string;
@@ -49,6 +50,22 @@ export type AppleToolHost = Readonly<{
4950

5051
/** Device-scoped Android transport selected by root composition; packages own all adb arguments. */
5152
export type AndroidToolHost = Readonly<{
53+
/**
54+
* Asks this device whether its clipboard service answers shell commands, as typed evidence.
55+
*
56+
* A host method rather than a `runAdb` call the caller classifies itself: normalizing adb's
57+
* output is Android tool knowledge, and it happens once here rather than in every owner that
58+
* needs the verdict.
59+
*
60+
* Optional, and its absence is not permission to assume support: a host that cannot probe
61+
* leaves the owner unable to establish the capability, so the clipboard is refused rather than
62+
* admitted. Only fabricated availability is forbidden — a refusal on incomplete information is
63+
* the safe answer.
64+
*/
65+
probeClipboardShellSupport?(
66+
device: DeviceInfo,
67+
signal?: AbortSignal,
68+
): Promise<AndroidClipboardShellSupport>;
5269
runAdb(
5370
device: DeviceInfo,
5471
args: readonly string[],

packages/platform-android/src/deployment/native.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ function hostFixture(options: { bundletool?: boolean; bundletoolJar?: string } =
2828
},
2929
androidDeployment: { bundletoolJar: options.bundletoolJar },
3030
androidTools: {
31+
probeClipboardShellSupport: async () => 'supported' as const,
3132
runAdb: async (_device, args, commandOptions, signal) =>
3233
await run({ executable: 'adb', args, ...commandOptions }, signal),
3334
installPackage: async (_device, packagePath, options, signal) =>

packages/platform-android/src/runtime.test.ts

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect, test, vi } from 'vitest';
2+
import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support';
23
import type {
34
DeviceBinding,
45
PlatformRuntimeHost,
@@ -32,6 +33,7 @@ test.each([
3233
stdout: 'mCurrentFocus=Window{1 u0 com.example.app/.MainActivity}',
3334
}));
3435
const host = {
36+
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
3537
commands: {
3638
which: async () => 'tool',
3739
run: async () => ({ stdout: '1', stderr: '', exitCode: 0 }),
@@ -156,6 +158,7 @@ test.each([
156158
test('rejects the non-discovered Android simulator cell for appstate', async () => {
157159
const runtimeDevice = { ...device, kind: 'simulator' as const };
158160
const host = {
161+
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
159162
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
160163
localInteractors: { resolve: async () => ({}) },
161164
appState: {
@@ -197,17 +200,13 @@ test('rejects the non-discovered Android simulator cell for appstate', async ()
197200
});
198201

199202
function androidNavigationHostFixture(
200-
runAdb?: (
201-
device: DeviceInfo,
202-
args: readonly string[],
203-
) => Promise<{
204-
stdout: string;
205-
stderr: string;
206-
exitCode: number;
207-
}>,
203+
probeClipboardShellSupport: () => Promise<AndroidClipboardShellSupport> = async () => 'supported',
208204
) {
209205
return {
210-
androidTools: { runAdb: runAdb ?? (async () => ({ stdout: '', stderr: '', exitCode: 0 })) },
206+
androidTools: {
207+
probeClipboardShellSupport,
208+
runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
209+
},
211210
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
212211
appInventory: {
213212
apple: { listApps: async () => [] },
@@ -417,6 +416,7 @@ test.each([
417416
'classifies the Android %s lifecycle denominator against the legacy dispatch cell',
418417
async (_name, runtimeDevice, legacy) => {
419418
const host = {
419+
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
420420
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
421421
appInventory: {
422422
apple: { listApps: async () => [] },
@@ -568,6 +568,7 @@ test('binds only the Android gesture tiers the target admitted', async () => {
568568

569569
function gestureHost(): PlatformRuntimeHost {
570570
return {
571+
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
571572
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
572573
appInventory: {
573574
apple: { listApps: async () => [] },
@@ -583,12 +584,9 @@ function gestureHost(): PlatformRuntimeHost {
583584
// clipboard halves on every real Android kind, `capabilities` advertised `clipboard`, and
584585
// `clipboard read` then failed with `UNSUPPORTED_OPERATION` from the leaf. Admission now probes
585586
// the same condition the leaf checks, so a build with no clipboard shell command refuses up front.
586-
test.each([
587-
['no shell command implementation', 'cmd: No shell command implementation.'],
588-
['unknown command', 'Unknown command: clipboard'],
589-
])('refuses both clipboard halves when adb reports %s', async (_name, stdout) => {
587+
test('refuses both clipboard halves when the build reports no clipboard shell', async () => {
590588
const binding = await createAndroidPlatformRuntime(
591-
androidNavigationHostFixture(async () => ({ stdout, stderr: '', exitCode: 0 })),
589+
androidNavigationHostFixture(async () => 'unsupported'),
592590
).bind({
593591
device: { ...device, id: 'android-no-clipboard-shell', kind: 'device' },
594592
intent: { kind: 'ordinary' },
@@ -611,29 +609,29 @@ test.each([
611609
expect(binding.facts.operations.appSwitcher).toEqual({ available: true });
612610
});
613611

614-
test('probes the clipboard shell once per device, not once per inspection', async () => {
615-
const runAdb = vi.fn(async (_device: DeviceInfo, _args: readonly string[]) => ({
616-
stdout: '',
617-
stderr: '',
618-
exitCode: 0,
619-
}));
620-
const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(runAdb));
621-
const target = { ...device, id: 'android-probe-cache', kind: 'device' as const };
612+
test.each([['supported'], ['unsupported']] as const)(
613+
'caches a definitive %s verdict instead of re-probing per inspection',
614+
async (verdict) => {
615+
const probe = vi.fn(async () => verdict);
616+
const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(probe));
617+
const target = { ...device, id: `android-probe-cache-${verdict}`, kind: 'device' as const };
622618

623-
await runtime.inspectFacts(target);
624-
await runtime.inspectFacts(target);
619+
await runtime.inspectFacts(target);
620+
await runtime.inspectFacts(target);
625621

626-
const clipboardProbes = runAdb.mock.calls.filter((call) => call[1].includes('clipboard'));
627-
expect(clipboardProbes).toHaveLength(1);
628-
});
622+
expect(probe).toHaveBeenCalledTimes(1);
623+
},
624+
);
629625

630-
test('an adb failure leaves the clipboard admitted rather than inventing an unsupported build', async () => {
626+
// The failure path is the one that recreates the defect if it guesses. A probe that never got an
627+
// answer must not report the clipboard available — execution would then refuse the very capability
628+
// `capabilities` advertised — and must not be remembered, or one transport blip decides the
629+
// question for the owner's whole life.
630+
test('a failed probe refuses rather than fabricating availability', async () => {
631631
const binding = await createAndroidPlatformRuntime(
632-
androidNavigationHostFixture(async () => {
633-
throw new Error('adb: device offline');
634-
}),
632+
androidNavigationHostFixture(async () => 'probe-failed'),
635633
).bind({
636-
device: { ...device, id: 'android-adb-offline', kind: 'device' },
634+
device: { ...device, id: 'android-probe-failed', kind: 'device' },
637635
intent: { kind: 'ordinary' },
638636
scope: {
639637
signal: new AbortController().signal,
@@ -642,8 +640,42 @@ test('an adb failure leaves the clipboard admitted rather than inventing an unsu
642640
},
643641
});
644642

645-
// The probe is definitive in one direction only: adb naming the condition means unsupported, a
646-
// probe that cannot run means unknown. Reporting unknown as unsupported would hide a working
647-
// clipboard behind a transport hiccup.
648-
expect(binding.facts.operations.readClipboard).toEqual({ available: true });
643+
for (const key of ['readClipboard', 'writeClipboard'] as const) {
644+
expect(binding.facts.operations[key]).toMatchObject({ available: false });
645+
expect(binding.operations[key]).toBeUndefined();
646+
}
647+
// The refusal says it could not determine support, not that the build lacks it.
648+
const fact = binding.facts.operations.readClipboard;
649+
expect(fact.available === false && String(fact.hint)).toMatch(/could not determine/i);
650+
});
651+
652+
test('a failed probe is not cached, so the next inspection asks again', async () => {
653+
const probe = vi
654+
.fn<() => Promise<AndroidClipboardShellSupport>>()
655+
.mockResolvedValueOnce('probe-failed')
656+
.mockResolvedValue('supported');
657+
const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(probe));
658+
const target = { ...device, id: 'android-probe-retry', kind: 'device' as const };
659+
660+
const first = await runtime.inspectFacts(target);
661+
const second = await runtime.inspectFacts(target);
662+
663+
expect(first.operations.readClipboard.available).toBe(false);
664+
expect(second.operations.readClipboard.available).toBe(true);
665+
expect(probe).toHaveBeenCalledTimes(2);
666+
});
667+
668+
test('a host with no clipboard probe refuses rather than assuming support', async () => {
669+
const host = androidNavigationHostFixture();
670+
const withoutProbe = { ...host, androidTools: {} } as unknown as PlatformRuntimeHost;
671+
672+
const facts = await createAndroidPlatformRuntime(withoutProbe).inspectFacts({
673+
...device,
674+
id: 'android-no-probe',
675+
kind: 'device',
676+
});
677+
678+
// Absence of a probe is absence of evidence, not evidence of support.
679+
expect(facts.operations.readClipboard.available).toBe(false);
680+
expect(facts.operations.writeClipboard.available).toBe(false);
649681
});

packages/platform-android/src/runtime.ts

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { bindAndroidScreenRecordingRuntime } from './recording/runtime.ts';
5050
import { ensureAndroidReady } from './readiness/runtime.ts';
5151
import { readAndroidAppState } from './app-state.ts';
5252
import { bindAndroidApplicationLifecycle } from './lifecycle.ts';
53-
import { isAndroidClipboardShellUnsupported } from '@agent-device/contracts/android-clipboard-support';
53+
import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support';
5454
import {
5555
androidAppDeploymentFacts,
5656
createAndroidAppDeploymentOperations,
@@ -210,6 +210,33 @@ const clipboardShellUnavailable = Object.freeze({
210210
hint: 'This Android build ships no shell implementation for the clipboard service, so adb cannot read or write the clipboard on it.',
211211
} as const);
212212

213+
/**
214+
* The probe could not reach the device, so this owner does not know whether the build supports a
215+
* shell clipboard. Refusing is the conservative answer and the only honest one: reporting
216+
* available would hand the caller a capability execution may immediately reject, which is the
217+
* exact failure fact-based admission exists to prevent. Deliberately not cached — the next
218+
* inspection asks again.
219+
*/
220+
const clipboardShellUnknown = Object.freeze({
221+
available: false,
222+
reason: 'owner-capability-missing',
223+
hint: 'Could not determine whether this Android build supports a shell clipboard: the adb probe did not complete. Retry once the device is reachable.',
224+
} as const);
225+
226+
/**
227+
* `probe-failed` covers both ways this owner can end up without an answer: the probe ran and could
228+
* not reach the device, or the host exposes no probe at all. Neither is evidence of support, and
229+
* both refuse rather than guess.
230+
*/
231+
async function probeClipboardShellSupport(
232+
host: PlatformRuntimeHost,
233+
device: DeviceInfo,
234+
): Promise<AndroidClipboardShellSupport> {
235+
const probe = host.androidTools?.probeClipboardShellSupport;
236+
if (!probe) return 'probe-failed';
237+
return await probe.call(host.androidTools, device);
238+
}
239+
213240
const tvRemoteUnavailable = Object.freeze({
214241
available: false,
215242
reason: 'unsupported-device-kind',
@@ -235,15 +262,17 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
235262
* Cached per device for this owner's lifetime: a build's shell command set cannot change while
236263
* the device is up, and admission would otherwise pay an adb round trip per request.
237264
*/
238-
const clipboardShell = new Map<string, Promise<boolean>>();
265+
const clipboardShell = new Map<string, AndroidClipboardShellSupport>();
239266
const clipboardFact = async (device: DeviceInfo): Promise<RuntimeOperationFact> => {
240267
if (device.kind === 'simulator') return clipboardShellUnavailable;
241-
let probe = clipboardShell.get(device.id);
242-
if (!probe) {
243-
probe = probeAndroidClipboardShell(host, device);
244-
clipboardShell.set(device.id, probe);
245-
}
246-
return (await probe) ? available : clipboardShellUnavailable;
268+
const support =
269+
clipboardShell.get(device.id) ?? (await probeClipboardShellSupport(host, device));
270+
// Only a definitive answer is worth keeping: a build's shell command set cannot change while
271+
// the device is up, but a failed probe says nothing about the build and must not become a
272+
// verdict this owner repeats for the rest of its life.
273+
if (support !== 'probe-failed') clipboardShell.set(device.id, support);
274+
if (support === 'supported') return available;
275+
return support === 'unsupported' ? clipboardShellUnavailable : clipboardShellUnknown;
247276
};
248277
const inspectFacts = async (device: Parameters<typeof appLogs.inspectFacts>[0]) => {
249278
const logs = await appLogs.inspectFacts(device);
@@ -461,26 +490,3 @@ function androidInteractionOperations(
461490
}),
462491
};
463492
}
464-
465-
/**
466-
* Asks the device, once, whether its clipboard service answers shell commands at all.
467-
*
468-
* Definitive only in one direction. adb naming the condition means unsupported; a probe that
469-
* cannot run (offline transport, adb failure) says nothing about shell support, so it reports
470-
* supported and leaves the real transport error to the operation that actually runs.
471-
*/
472-
async function probeAndroidClipboardShell(
473-
host: PlatformRuntimeHost,
474-
device: DeviceInfo,
475-
): Promise<boolean> {
476-
try {
477-
const result = await host.androidTools.runAdb(
478-
device,
479-
['shell', 'cmd', 'clipboard', 'get', 'text'],
480-
{ allowFailure: true },
481-
);
482-
return !isAndroidClipboardShellUnsupported(result.stdout, result.stderr);
483-
} catch {
484-
return true;
485-
}
486-
}

src/platform-runtime-android-tool-host.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,26 @@ import type { AndroidToolHost } from '@agent-device/contracts/platform';
33
/** Provider-aware Android transport. Command semantics and arguments stay package-owned. */
44
export function createAndroidToolHost(): AndroidToolHost {
55
return Object.freeze({
6+
/**
7+
* Definitive in both directions where adb answers, and honest when it does not: a transport
8+
* failure reports `probe-failed` rather than a guess, so admission can refuse instead of
9+
* fabricating availability the operation would then reject.
10+
*/
11+
probeClipboardShellSupport: async (device, signal) => {
12+
try {
13+
const { runAndroidAdb, isClipboardShellUnsupported } =
14+
await import('./platforms/android/adb.ts');
15+
const result = await runAndroidAdb(device, ['shell', 'cmd', 'clipboard', 'get', 'text'], {
16+
allowFailure: true,
17+
signal,
18+
});
19+
return isClipboardShellUnsupported(result.stdout, result.stderr)
20+
? 'unsupported'
21+
: 'supported';
22+
} catch {
23+
return 'probe-failed';
24+
}
25+
},
626
runAdb: async (device, args, options, signal) => {
727
const { runAndroidAdb } = await import('./platforms/android/adb.ts');
828
const result = await runAndroidAdb(device, [...args], {

src/platforms/android/adb.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,17 @@ export async function runAndroidAdb(
1515
return await resolveAndroidAdbExecutor(device)(args, options);
1616
}
1717

18-
export { isAndroidClipboardShellUnsupported as isClipboardShellUnsupported } from '@agent-device/contracts/android-clipboard-support';
18+
/**
19+
* Whether an adb `cmd clipboard` invocation was refused because this build ships no shell
20+
* implementation for the clipboard service, rather than because the call itself failed.
21+
*
22+
* adb reports this condition in its output and nowhere else — no exit code or structured field
23+
* separates "service has no shell command" from any other non-zero result — so this is the one
24+
* place that reads that prose, and it hands every caller a typed answer instead.
25+
*/
26+
export function isClipboardShellUnsupported(stdout: string, stderr: string): boolean {
27+
const haystack = `${stdout}\n${stderr}`.toLowerCase();
28+
return (
29+
haystack.includes('no shell command implementation') || haystack.includes('unknown command')
30+
);
31+
}

0 commit comments

Comments
 (0)