Skip to content

Commit b73d003

Browse files
committed
fix(android): classify the clipboard shell response, not its exit status
`clipboard read` answered `text: ""` and `clipboard write` answered "Clipboard updated" — both `success: true` — on a build where adb never touched the clipboard. Android 16 (API 36) answers every `adb shell cmd clipboard …` with the framework default `Binder.handleShellCommand`: `No shell command implementation.` on stderr and exit status 0. Both legs and the capability probe consulted the exit status first, so the silence became an empty clipboard and a false confirmation, and `capabilities` advertised `clipboard`. The response is now classified per stream into one typed verdict owned by `clipboard-shell-response.ts`. The service's own no-shell-command sentence is evidence at every exit status — a service that never ran the command wrote no payload, so a clean exit cannot discharge it — while stdout stays payload until the call has failed, and the generic missing-subcommand wording never overrides a clean exit because adb prints it about itself too. Both legs and the probe share the verdict, and a build with no clipboard shell surface refuses with `UNSUPPORTED_OPERATION` and a hint naming the device-side substitute. Closes #2674
1 parent 0cd679f commit b73d003

13 files changed

Lines changed: 364 additions & 55 deletions

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## Unreleased
44

5+
- Fixed (android): `clipboard read` and `clipboard write` stop reporting success on a build whose
6+
clipboard service has no shell command. Android 16 (API 36) answers every `adb shell cmd clipboard …`
7+
with the framework default `Binder.handleShellCommand` — `No shell command implementation.` on
8+
stderr and exit status 0 — and the exit status was consulted first, so a read reported `text: ""`,
9+
a write reported "Clipboard updated", and `capabilities` advertised `clipboard` on a clipboard no
10+
adb call ever touched. The response is now classified per stream into one typed verdict shared by
11+
both legs and the capability probe: the service's own sentence on `stderr` outranks a clean exit
12+
(a service that never ran wrote no payload to `stdout`), while `stdout` stays payload until the call
13+
has failed. Such a device now refuses with `UNSUPPORTED_OPERATION` and a hint naming the substitute,
14+
and the exported `readAndroidClipboardWithAdb` / `writeAndroidClipboardWithAdb` helpers reject
15+
instead of resolving an empty string or nothing at all (#2674).
516
- Fixed (limrun): `screenshot` on Limrun iOS direct sessions writes a PNG. Limrun serves its capture
617
as JPEG and the interactor wrote those bytes straight to the `.png` path, so every capture failed
718
downstream with "Screenshot file is not a valid PNG". The bytes are now sniffed and a JPEG is

‎packages/platform-android/src/__tests__/device-input-state.test.ts‎

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'vitest';
33
import type { AndroidAdbExecutor } from '../adb-executor.ts';
4-
import { AppError } from '@agent-device/kernel/errors';
4+
import { AppError, normalizeError } from '@agent-device/kernel/errors';
55
import { promises as fs } from 'node:fs';
66
import {
77
dismissAndroidKeyboard,
@@ -10,6 +10,7 @@ import {
1010
readAndroidClipboardWithAdb,
1111
writeAndroidClipboardWithAdb,
1212
} from '../device-input-state.ts';
13+
import { ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_REASON } from '../clipboard-shell-response.ts';
1314
import {
1415
flushDiagnosticsToSessionFile,
1516
withDiagnosticsScope,
@@ -236,16 +237,55 @@ test('writeAndroidClipboardWithAdb leaves safe text unquoted', async () => {
236237
assert.deepEqual(calls, [['shell', 'cmd', 'clipboard', 'set', 'text', 'android-otp']]);
237238
});
238239

239-
// A successful `clipboard get text` puts the clipboard's *contents* on stdout, so the missing-shell
240-
// prose is only ever evidence about a call that failed. Reading it on a clean exit turned a user who
241-
// had copied one of these phrases into a device that "does not support" its own working clipboard.
240+
// A read that ran puts the clipboard's *contents* on stdout, so prose there is evidence only about a
241+
// call that failed. Prose on stderr is about the call whatever the exit status says.
242242
test('readAndroidClipboardWithAdb returns contents that read like an adb refusal', async () => {
243243
for (const contents of ['Unknown command: clipboard', 'No shell command implementation.']) {
244244
const adb: AndroidAdbExecutor = async () => ({ stdout: contents, stderr: '', exitCode: 0 });
245245
assert.equal(await readAndroidClipboardWithAdb(adb), contents);
246246
}
247247
});
248248

249+
// Android 16 (API 36) answers every `cmd clipboard` call this way, and reading the clean exit as
250+
// execution turned it into `clipboard read` → `text: ""` and `clipboard write` → "Clipboard updated".
251+
test('readAndroidClipboardWithAdb refuses the exit-0 no-implementation answer instead of empty text', async () => {
252+
const adb: AndroidAdbExecutor = async () => ({
253+
stdout: '',
254+
stderr: 'No shell command implementation.',
255+
exitCode: 0,
256+
});
257+
258+
const error = await readAndroidClipboardWithAdb(adb).then(
259+
() => assert.fail('expected the clipboard read to reject'),
260+
(error: unknown) => error,
261+
);
262+
assert.ok(error instanceof AppError);
263+
assert.equal(error.code, 'UNSUPPORTED_OPERATION');
264+
assert.equal(
265+
(error.details as { reason?: unknown }).reason,
266+
ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_REASON,
267+
);
268+
});
269+
270+
test('writeAndroidClipboardWithAdb refuses the exit-0 no-implementation answer instead of claiming success', async () => {
271+
const adb: AndroidAdbExecutor = async () => ({
272+
stdout: '',
273+
stderr: 'No shell command implementation.',
274+
exitCode: 0,
275+
});
276+
277+
const error = await writeAndroidClipboardWithAdb(adb, 'android-otp').then(
278+
() => assert.fail('expected the clipboard write to reject'),
279+
(error: unknown) => error,
280+
);
281+
assert.ok(error instanceof AppError);
282+
assert.equal(error.code, 'UNSUPPORTED_OPERATION');
283+
assert.equal(
284+
(error.details as { reason?: unknown }).reason,
285+
ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_REASON,
286+
);
287+
});
288+
249289
test('readAndroidClipboardWithAdb still reports a genuine missing shell command', async () => {
250290
const adb: AndroidAdbExecutor = async () => ({
251291
stdout: '',
@@ -258,14 +298,26 @@ test('readAndroidClipboardWithAdb still reports a genuine missing shell command'
258298
});
259299
});
260300

301+
// A failed call that names no missing command stays the transport failure it is: the clipboard read
302+
// must not be relabelled, and the adb evidence has to survive for failure classification.
261303
test('readAndroidClipboardWithAdb reports a non-zero failure that names no missing command', async () => {
262304
const adb: AndroidAdbExecutor = async () => ({
263305
stdout: '',
264306
stderr: 'error: device offline',
265307
exitCode: 1,
266308
});
267309

268-
await assert.rejects(() => readAndroidClipboardWithAdb(adb));
310+
const error = await readAndroidClipboardWithAdb(adb).then(
311+
() => assert.fail('expected the clipboard read to reject'),
312+
(error: unknown) => error,
313+
);
314+
assert.ok(error instanceof AppError);
315+
assert.equal(error.code, 'COMMAND_FAILED');
316+
const details = error.details as Record<string, unknown>;
317+
assert.equal(details.exitCode, 1);
318+
assert.equal(details.stderr, 'error: device offline');
319+
assert.equal(details.processExitError, true);
320+
assert.equal(typeof normalizeError(error).hint, 'string');
269321
});
270322

271323
test('dismissAndroidKeyboard skips keyevent when keyboard is already hidden', async () => {

‎packages/platform-android/src/adb.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,31 @@ export async function runAndroidAdb(
1515
return await resolveAndroidAdbExecutor(device)(args, options);
1616
}
1717

18+
const ANDROID_NO_SHELL_COMMAND_SENTENCE = 'no shell command implementation';
19+
const ANDROID_UNKNOWN_SUBCOMMAND_PHRASE = 'unknown command';
20+
21+
/**
22+
* Whether one adb output stream carries the sentence a device prints when its service implements no
23+
* shell command at all: `Binder.handleShellCommand`'s framework default writes it to the **err**
24+
* descriptor and returns a **zero** status, so this sentence outranks the exit status — a clean exit
25+
* never discharges it. Ask it of a stream, never of a call: the `stdout` of a command with a payload
26+
* is that payload (`cmd clipboard get text` answers with arbitrary user text, which may quote this
27+
* sentence), so only `stderr` is prose about the call itself.
28+
*/
29+
export function reportsAndroidNoShellCommand(stream: string): boolean {
30+
return stream.toLowerCase().includes(ANDROID_NO_SHELL_COMMAND_SENTENCE);
31+
}
32+
1833
/**
19-
* Whether an adb `cmd <service> <command>` invocation was refused because this build ships no
20-
* shell implementation for it, 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-
* Only ever ask this about a call that *failed*. A command that succeeds prints its own payload
27-
* on stdout — `clipboard get text` returns arbitrary user text, which may quote these very
28-
* phrases — so callers must settle a zero exit before reaching for this.
34+
* Whether a call that already failed was refused for having no shell implementation rather than by
35+
* the failure itself. Both streams are consulted here because a call that never ran produced no
36+
* payload to confuse with the prose, and the generic missing-subcommand wording counts: adb prints
37+
* it too, so on its own it says nothing about the device's service and never overrides a clean exit.
2938
*/
3039
export function isAndroidShellCommandUnsupported(stdout: string, stderr: string): boolean {
3140
const haystack = `${stdout}\n${stderr}`.toLowerCase();
3241
return (
33-
haystack.includes('no shell command implementation') || haystack.includes('unknown command')
42+
haystack.includes(ANDROID_NO_SHELL_COMMAND_SENTENCE) ||
43+
haystack.includes(ANDROID_UNKNOWN_SUBCOMMAND_PHRASE)
3444
);
3545
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { normalizeError } from '@agent-device/kernel/errors';
4+
import {
5+
androidClipboardShellCommandUnavailableError,
6+
androidClipboardShellSupportForVerdict,
7+
ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_REASON,
8+
classifyAndroidClipboardShellResponse,
9+
} from './clipboard-shell-response.ts';
10+
11+
// Android 16 (API 36): `ClipboardService` implements no shell command, so the framework default
12+
// answers with its sentence on stderr and a clean exit. A classifier that settles the exit status
13+
// first reports a clipboard that no adb call ever touched.
14+
test('clipboard shell classification refuses the no-implementation answer despite a clean exit', () => {
15+
assert.equal(
16+
classifyAndroidClipboardShellResponse({
17+
stdout: '',
18+
stderr: 'No shell command implementation.',
19+
exitCode: 0,
20+
}),
21+
'no-shell-command',
22+
);
23+
});
24+
25+
test('clipboard shell classification reports a clipboard the service actually answered', () => {
26+
assert.equal(
27+
classifyAndroidClipboardShellResponse({
28+
stdout: 'clipboard text: otp-123456\n',
29+
stderr: '',
30+
exitCode: 0,
31+
}),
32+
'executed',
33+
);
34+
});
35+
36+
// A read that ran answers on stdout with arbitrary user text, so stdout is never evidence about the
37+
// call — whatever a clipboard happens to hold stays clipboard contents.
38+
test('clipboard shell classification keeps copied prose from looking like a refusal', () => {
39+
for (const contents of ['Unknown command: clipboard', 'No shell command implementation.']) {
40+
assert.equal(
41+
classifyAndroidClipboardShellResponse({ stdout: contents, stderr: '', exitCode: 0 }),
42+
'executed',
43+
contents,
44+
);
45+
}
46+
});
47+
48+
// Older adb merges the device's streams onto stdout, so prose there is evidence once the call itself
49+
// is known to have failed.
50+
test('clipboard shell classification reads a refusal off stdout once the call failed', () => {
51+
assert.equal(
52+
classifyAndroidClipboardShellResponse({
53+
stdout: 'No shell command implementation.',
54+
stderr: '',
55+
exitCode: 255,
56+
}),
57+
'no-shell-command',
58+
);
59+
});
60+
61+
// `unknown command` is adb's own wording as well as a service's (`adb: unknown command features`), so
62+
// it cannot say anything about this device's clipboard service on a call that succeeded. Only the
63+
// framework's no-shell-command sentence outranks a clean exit.
64+
test('clipboard shell classification refuses to read adb prose as a device verdict', () => {
65+
assert.equal(
66+
classifyAndroidClipboardShellResponse({
67+
stdout: 'clipboard text: otp-123456\n',
68+
stderr: 'adb: unknown command features',
69+
exitCode: 0,
70+
}),
71+
'executed',
72+
);
73+
});
74+
75+
// Streams separated, both talking: a service that printed its no-shell-command sentence never ran
76+
// the command, so whatever shares the call is not the payload it was asked for.
77+
test('clipboard shell classification believes the sentence over a stream it did not write', () => {
78+
assert.equal(
79+
classifyAndroidClipboardShellResponse({
80+
stdout: 'clipboard text: otp-123456\n',
81+
stderr: 'No shell command implementation.',
82+
exitCode: 0,
83+
}),
84+
'no-shell-command',
85+
);
86+
});
87+
88+
test('clipboard shell classification keeps a broken transport out of the missing-command verdict', () => {
89+
assert.equal(
90+
classifyAndroidClipboardShellResponse({
91+
stdout: '',
92+
stderr: 'error: device offline',
93+
exitCode: 1,
94+
}),
95+
'call-failed',
96+
);
97+
});
98+
99+
test('clipboard shell support verdicts carry the execution answer to admission', () => {
100+
assert.equal(androidClipboardShellSupportForVerdict('executed'), 'supported');
101+
assert.equal(androidClipboardShellSupportForVerdict('no-shell-command'), 'unsupported');
102+
assert.equal(androidClipboardShellSupportForVerdict('call-failed'), 'probe-failed');
103+
});
104+
105+
test('clipboard shell refusal is dispatchable and names the substitute', () => {
106+
const error = androidClipboardShellCommandUnavailableError('read');
107+
assert.equal(error.code, 'UNSUPPORTED_OPERATION');
108+
assert.equal(
109+
(error.details as { reason?: unknown }).reason,
110+
ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_REASON,
111+
);
112+
assert.match(String(normalizeError(error).hint), /pasting into a focused field/);
113+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support';
2+
import { AppError } from '@agent-device/kernel/errors';
3+
import type { AndroidAdbExecutorResult } from './adb-executor.ts';
4+
import { isAndroidShellCommandUnsupported, reportsAndroidNoShellCommand } from './adb.ts';
5+
6+
/** What the device answered an `adb shell cmd clipboard …` call. */
7+
export type AndroidClipboardShellVerdict = 'executed' | 'no-shell-command' | 'call-failed';
8+
9+
/** Which clipboard subcommand was called, shared by the refusal wording and the reason. */
10+
export type AndroidClipboardOperation = 'read' | 'write';
11+
12+
export const ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_REASON =
13+
'android_clipboard_shell_command_unavailable' as const;
14+
15+
/**
16+
* What to do instead, carried by both the fact that refuses admission and the error that refuses
17+
* execution: the substitute is a device-side check, never another adb attempt.
18+
*/
19+
export const ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_HINT =
20+
'This Android build ships no shell implementation for the clipboard service, so adb cannot read or write the clipboard on it. Verify a copy flow by pasting into a focused field and reading that field back.';
21+
22+
/**
23+
* Classifies one clipboard shell call from what the device said about it.
24+
*
25+
* A zero exit status does not settle whether the clipboard service ran the command: a service that
26+
* implements no shell command prints `No shell command implementation.` to its **err** descriptor
27+
* and returns 0 (`Binder.handleShellCommand`'s framework default, which is what Android's
28+
* `ClipboardService` falls back to). So that sentence is evidence at every exit status, and it wins
29+
* even against a non-empty stdout, because a service that never ran the command produced no payload
30+
* for stdout to hold.
31+
*
32+
* Nothing else about a clean exit is evidence of refusal. A read that ran answers on stdout with
33+
* arbitrary user text, which may quote any of these phrases, and the generic missing-subcommand
34+
* prose is adb's own wording too — it can describe the client, not this device — so both are read
35+
* only once the call has actually failed.
36+
*/
37+
export function classifyAndroidClipboardShellResponse(
38+
result: Pick<AndroidAdbExecutorResult, 'exitCode' | 'stdout' | 'stderr'>,
39+
): AndroidClipboardShellVerdict {
40+
if (reportsAndroidNoShellCommand(result.stderr)) return 'no-shell-command';
41+
if (result.exitCode !== 0) {
42+
return isAndroidShellCommandUnsupported(result.stdout, result.stderr)
43+
? 'no-shell-command'
44+
: 'call-failed';
45+
}
46+
return 'executed';
47+
}
48+
49+
/**
50+
* Translates the execution verdict into the admission verdict the clipboard facts are stated in.
51+
* A build that refused the probe's command will refuse the next one too, while a call that never
52+
* reached a build proves nothing about it.
53+
*/
54+
export function androidClipboardShellSupportForVerdict(
55+
verdict: AndroidClipboardShellVerdict,
56+
): AndroidClipboardShellSupport {
57+
if (verdict === 'executed') return 'supported';
58+
return verdict === 'no-shell-command' ? 'unsupported' : 'probe-failed';
59+
}
60+
61+
/**
62+
* The admission verdict for one probe call, for a caller that holds only what adb answered. The
63+
* verdict vocabulary stays here: a caller that composes it itself would have to know which
64+
* sentences outrank which streams, which is the part only this owner should assert.
65+
*/
66+
export function androidClipboardShellSupportForResult(
67+
result: Pick<AndroidAdbExecutorResult, 'exitCode' | 'stdout' | 'stderr'>,
68+
): AndroidClipboardShellSupport {
69+
return androidClipboardShellSupportForVerdict(classifyAndroidClipboardShellResponse(result));
70+
}
71+
72+
/**
73+
* The refusal for a build whose clipboard service exposes no shell command: not retryable, and not
74+
* a permission to ask for, so it carries the substitute hint rather than a retriable detail.
75+
*/
76+
export function androidClipboardShellCommandUnavailableError(
77+
operation: AndroidClipboardOperation,
78+
): AppError {
79+
return new AppError(
80+
'UNSUPPORTED_OPERATION',
81+
`Android shell clipboard ${operation} is not supported on this device.`,
82+
{
83+
reason: ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_REASON,
84+
hint: ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_HINT,
85+
},
86+
);
87+
}

0 commit comments

Comments
 (0)