Skip to content

fix(android): shell-quote free-text arguments reaching the device shell - #1645

Draft
thymikee wants to merge 1 commit into
mainfrom
fix/android-shell-arg-quoting
Draft

fix(android): shell-quote free-text arguments reaching the device shell#1645
thymikee wants to merge 1 commit into
mainfrom
fix/android-shell-arg-quoting

Conversation

@thymikee

@thymikee thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

adb shell <argv> joins its arguments and the device's shell re-tokenizes the result. app-lifecycle.ts already accounts for that and quotes deep-link URLs and launch arguments; the two paths carrying genuinely free-form text did not.

type/fill (input text) and clipboard write (cmd clipboard set text) now quote their text argument with the shared shellQuoteIfNeeded helper — the same one app-lifecycle.ts uses. Its byte-identical local copy, quoteAndroidShellArg, is retired in favour of the shared one.

The ASCII gate on the text path (assertAndroidShellTextSupported) is not a substitute: it only rejects characters outside 0x200x7e, and every shell-significant character sits inside that range.

User-visible behaviour change: multi-word clipboard write now reaches the device as a single argument instead of being re-tokenized into several.

agent-device clipboard write "android otp"
# before: device received `android` and `otp` as two arguments
# after:  device receives `android otp`

Validation

pnpm test:integration:provider green (42/42 files, 149/149 tests). Four new unit tests; the two that pin quoting were proven to fail without the change — reverted, run, 2 failed | 26 passed, restored:

× typeAndroid shell-quotes text containing shell metacharacters
× writeAndroidClipboardWithAdb shell-quotes text containing metacharacters
AssertionError: Expected 'text',"'otp; echo pwned'"  Received: 'text','otp; echo pwned'

One harness change deserves a look: provider-scenarios/android-world.ts simulated the device clipboard by exact-matching the joined argv, so quoting stopped the match firing and an unrelated assertion failed first. That harness was modelling the unquoted argv as correct — encoding the pre-fix behaviour — so it now matches the command prefix and shell-unquotes the value, mirroring what a real device shell does before cmd sees it. Matching the new quoted literal instead would have re-frozen the same mistake one step over.

Residual risk — draft until live evidence

No emulator run yet, so this stays a draft per the repo's device-readiness rule. The run must execute with the test IME off (open --test-ime not set, no provider-native injector), or the adb shell fallback being fixed never executes and a green result proves nothing.

Scope

7 files. Split out of #1639 per review; found by a read-only codebase audit. Local gates are currently unreliable on this machine due to a competing test run — pushed on GitHub CI's authority with maintainer agreement.

Text entry (input text) and clipboard write (cmd clipboard set text) now
quote their free-form text argument with the same shellQuoteIfNeeded
helper app-lifecycle.ts already uses for deep-link URLs and launch
arguments, and app-lifecycle.ts's local duplicate of that helper is
retired in favor of the shared one. Multi-word clipboard writes also now
arrive at the device as a single argument instead of being re-tokenized
into separate ones.

Updates the provider-scenario test harness's scripted clipboard-state
simulator to unwrap shell quoting the same way a device shell does, so
it keeps modelling what the device actually receives.
@thymikee
thymikee force-pushed the fix/android-shell-arg-quoting branch from 8d1440f to b6ab288 Compare August 6, 2026 14:09
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.99 MB 1.99 MB +113 B
JS gzip 635.8 kB 635.9 kB +80 B
npm tarball 769.2 kB 769.4 kB +125 B
npm unpacked 2.69 MB 2.69 MB +113 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.4 ms 26.5 ms -0.8 ms
CLI --help 65.0 ms 66.9 ms +1.9 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/screenshot-result.js -149 B -84 B
dist/src/internal/daemon.js +31 B +16 B
dist/src/agent-device-client.js +30 B +11 B
dist/src/cli.js +30 B +6 B
dist/src/context.js 0 B -2 B

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Reviewed exact head b6ab2880. No code finding: both production routes now apply the shared shell quoting after Android %s encoding, preserving one device-shell argument for text entry and clipboard writes. The regressions exercise the shipped helpers and would fail if either quote call were reverted; the provider scenario remains a useful daemon-route contract.

Not ready yet: required live Android evidence is absent. Rebuild the Android artifacts, run without the test IME/provider-native injector, and attach literal UI read-back for shell-metacharacter type input plus clipboard read-back for multi-word, metacharacter, and embedded-apostrophe values; include snapshot -i --json showing android-helper and matching helper version. Current completed checks are green, with required lanes still pending. No code blocker or conflict found.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Coverage is red here, and it is not attributable to this change. Evidence, since "unrelated flake" deserves proof rather than assertion:

Root cause: the test asserts absolute indices into a timing-dependent event array.

const firstStep = hookValue(hooks, 3, 'onTestStep');
expect(hookValue(hooks, 5, 'onTestStep')).toMatchObject({ attempt: 2,});

CI observed 6 hooks where 8 were expected (expected [ 'onSuiteStart', 'onTestStart', …(6) ] to deeply equal [ Array(8) ]), so index 3 held onTestResult rather than onTestStep and every later index shifted. The onTestStep hooks come from the per-attempt AsyncLocalStorage context across a retry, so whenever the suite's step/retry emission varies under load, the positional assertions break.

This is a latent defect on main. What this branch does is change which Android provider-scenario files run concurrently in the same shard, shifting the scheduling enough to expose it — deterministically for this file set, which is why a rerun reproduced it exactly.

Note it is outside CONTENTION_RETRY_FILES, and the job says so: No retry: a failure landed outside the enumerated retry list. Adding it there would be the wrong remedy anyway — that policy is timeouts-only, proven by the runner, and these are assertion failures. The fix belongs in the test: select hooks by name (or filter to onTestStep entries) instead of indexing into positions whose meaning depends on how many hooks happened to fire.

Not fixing it in this PR — that would bundle an unrelated main-branch test defect into an Android platform change, which is exactly the mixing the split was meant to undo. Happy to send it as its own PR.

This PR stays a draft regardless, pending live emulator evidence with the test IME off.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Live Android evidence — exact head b6ab28803

Emulator Pixel_7_CI (Android 16 / API 36), Settings → "Search Settings" field. Artifacts rebuilt first: pnpm build + pnpm build:android producing both helper APKs at 0.20.5.

One correction worth recording, because it nearly invalidated the run: --test-ime is default on for emulators per the CLI's own help, so merely omitting it is not enough — --no-test-ime had to be passed explicitly, or the adb shell fallback this PR fixes would never execute and a green result would prove nothing. Backend was then confirmed from diagnostics rather than from the flag, on all four type invocations:

{"phase":"android_text_injection","command":"type","data":{"action":"type","backend":"adb-shell","textLength":27}}

1. type with shell metacharacters — PASS

node bin/agent-device.mjs type 'echo;`id`;$(whoami)&ls test' --debug --state-dir … --json
→ {"success":true,"data":{"text":"echo;`id`;$(whoami)&ls test","message":"Typed 27 chars"}}

The exec_command trail shows the fix doing its job — chunks carrying metacharacters are single-quoted, the safe trailing chunk is left bare (and note %s space-encoding sits inside the quotes, i.e. encode-then-quote order held):

adb -s emulator-5554 shell input text 'echo;`id'
adb -s emulator-5554 shell input text '`;$(whoa'
adb -s emulator-5554 shell input text 'mi)&ls%st'
adb -s emulator-5554 shell input text est

Read-back, exact match:

node bin/agent-device.mjs get text @e82 --json   → "text": "echo;`id`;$(whoami)&ls test"

Independently corroborated through a raw adb shell uiautomator dump, bypassing agent-device's own read path entirely: text="echo;id;$(whoami)&amp;ls test" (the &amp; being correct XML escaping of a literal &).

Disclosed anomaly: the first attempt read back one extra ;, confirmed at raw-XML level so it was a real on-device discrepancy, not a parsing artifact. Investigated rather than discarded: the identical string and the identical chunk-boundary substring both round-tripped correctly on re-send, and the argv sent to adb was byte-identical across all attempts — so argument construction is deterministic. Attributed to a one-off emulator keystroke double-fire in input text, not to this change. Recorded here rather than omitted.

2. Clipboard round-trip — NOT CAPTURED, environment gap

clipboard write 'android otp code'
→ UNSUPPORTED_OPERATION: Android shell clipboard write is not supported on this device.

Root-caused independently of agent-device:

adb -s emulator-5554 shell cmd clipboard set text 'hello world'
→ No shell command implementation.   (exit 0)

The clipboard service exists (service list104 clipboard: [android.content.IClipboard]) but its shell-command handler is unimplemented on this build — cmd clipboard returns the same with no subcommand at all, and dumpsys clipboard is empty. Not a permissions issue (adb root refused on a production build; still fails). Reproduced identically on a second, differently-imaged emulator (Television_4K, Google TV, API 36), so it is not specific to the Play-certified image. Only android-36/android-37.0 are installed locally, both Play-certified/TV variants.

So the multi-word / metacharacter / embedded-apostrophe clipboard read-backs you asked for could not be produced on any locally available image. The quoting on that path is covered by the committed unit tests, but I am not offering those as a substitute for the live evidence you specified.

3. snapshot -i --json — PASS

"androidSnapshot": {
  "backend": "android-helper",
  "helperVersion": "0.20.5",
  "helperApiVersion": "2",
  "helperTransport": "persistent-session",
  "installReason": "current"
}

helperVersion matches the freshly built agent-device-android-snapshot-helper-0.20.5.apk exactly.

Cleanup

Session closed, daemon stopped gracefully, both emulators killed (adb devices empty afterwards). Nothing outstanding. Raw ndjson, uiautomator XML dumps and debug logs under /tmp/ad-evidence-1645/.


Staying a draft. Two of three requirements are met with live evidence; the clipboard round-trip is blocked by an image capability gap rather than by anything in this change. If you have a device or system image where cmd clipboard is implemented, I will run it there. Otherwise the call on whether the text-entry evidence plus unit coverage clears the bar for the clipboard path is yours, not mine.

Separately, Coverage remains red here for the unrelated session-test-reporter-values.test.ts index-assertion defect diagnosed above.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Evidence re-review at unchanged head b6ab2880: code remains clean and the new live proof clears the adb-shell text-entry side. Explicit --no-test-ime, diagnostics naming backend: adb-shell, encode-then-quote argv, exact agent-device read-back, independent raw uiautomator corroboration, matching android-helper metadata, and complete cleanup make that evidence production-shaped.

Readiness is still blocked by two concrete residuals:

  1. The clipboard production path still has no successful on-device round trip for multiword/metacharacter/apostrophe values. The API 36 images honestly demonstrate an environment capability gap (cmd clipboard has no shell implementation), not a code failure, but the requested path evidence remains absent. It needs a device/image implementing that shell handler, or an explicit maintainer decision to accept unit-only residual risk.
  2. Coverage is still confirmed red on the unrelated load-sensitive session-test-reporter-values.test.ts absolute-index assertions. That is not attributable to this Android quoting change, but the current head remains red until rerun green or the latent test defect is fixed separately.

No ready-for-human label yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant