Skip to content

fix: audit follow-ups across Android input, remote auth, iOS corroboration, and CLI help - #1639

Closed
thymikee wants to merge 5 commits into
mainfrom
improve/audit-2026-08-06
Closed

fix: audit follow-ups across Android input, remote auth, iOS corroboration, and CLI help#1639
thymikee wants to merge 5 commits into
mainfrom
improve/audit-2026-08-06

Conversation

@thymikee

@thymikee thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

Five independent defect fixes found by a read-only audit of the codebase at 46abd0f48. Each is one commit, so this is trivially splittable if you'd rather land them separately — see "Scope" below.

1. Android free-text arguments are now shell-quoted (fix(android))

adb shell <argv> joins its arguments and the device's shell re-tokenizes the result, which is why app-lifecycle.ts already quotes deep-link URLs and launch arguments. The two paths carrying genuinely free-form text — type/fill (input text) and clipboard write (cmd clipboard set text) — did not. They now use the same shared shellQuoteIfNeeded helper, and app-lifecycle.ts's byte-identical local copy of it is retired.

The ASCII gate on the text path was never protection here: every shell metacharacter sits inside 0x200x7e and passes it.

User-visible side effect worth noting: multi-word clipboard write now arrives at the device as one argument instead of being re-tokenized into several.

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

2. Remote connections no longer persist the daemon bearer token (fix(remote)) — BREAKING

ADR 0007 states that generated connection profiles "must strip daemon and Metro bearer tokens". The Metro half was honored; the daemon half was not — connect wrote the token into the connection-state file and every later command read it back.

This changes behavior. After this, a token passed once to connect is not reused by later commands:

# before
agent-device connect proxy --daemon-base-url <url> --daemon-auth-token <token>
agent-device devices            # worked — token came back off disk

# after
export AGENT_DEVICE_DAEMON_AUTH_TOKEN=<token>
agent-device connect proxy --daemon-base-url <url>
agent-device devices            # resolves from env / config / flag

Every alternative path ADR 0007 names already existed (AGENT_DEVICE_DAEMON_AUTH_TOKEN, the daemonAuthToken config key, --daemon-auth-token), so this is conformance rather than new machinery. website/docs/docs/remote-proxy.md is updated to the env-var workflow.

3. iOS tap corroboration keeps its baseline's presentation (fix(ios))

matchingCaptureFlags returned undefined whenever the request carried no flags, discarding the baseline's depth/scope/raw. The probe was then captured at default presentation, presentationKey couldn't match a non-default baseline, and #1605's rescue silently declined to engage — leaving the caller with the possibly-false XCTEST_RECORDED_FAILURE it exists to eliminate. The asymmetry was visible in the same function: interactiveOnly was read off the presentation unconditionally while the other three fields were dropped.

Reachable via batch steps (cli/batch-steps.ts omits the flags key entirely when undefined) and the JSON-RPC boundary; CLI and Node-client callers always populate it.

4. A device claim retained by a failed close is now reported (fix(daemon))

When close can't confirm the device was released it deliberately keeps the advisory claim — correct — but then deletes the session record unconditionally and said nothing. The claim was left owned by a session name nothing else knew about, reclaimable only by reopening under that exact name or by daemon death. It now emits a device_claim_close_effects_unconfirmed warn diagnostic carrying the device key and session name, mirroring rollbackNewSessionClaim on the open path. Retention policy is unchanged.

5. Command aliases take the --help fast path (perf(cli))

bin.ts resolved aliases through a hand-written two-entry table while the real registry has five, so tap, launch, and relaunch fell through to a full CLI bootstrap just to print static help. It now delegates to normalizeCliCommandAlias.

--help before after
press (control) 49 ms 49 ms
tap 156 ms 49 ms
launch 157 ms 50 ms
relaunch 156 ms 53 ms

rotate deliberately still misses the fast path so its migration error keeps rendering.

Validation

Every regression test here was proven to fail without its fix — reverted, run, failure captured, restored. Two were instructive: the tap-corroboration test fails with expected false to be true, and the retained-claim test fails with ENOENT ... diagnostics.ndjson, because zero diagnostic events are emitted without the change.

pnpm check:affected --run is green on the assembled branch. Android quoting was additionally validated through pnpm test:integration:provider (42/42 files, 149/149 tests).

One test-harness change deserves attention: 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 — i.e. encoding the pre-fix behavior — 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.

Contention note: these ran on a machine reaching load average 70+, producing shifting timeout-only failures in unrelated files (doctor.test.ts, ios-lifecycle.test.ts, iOS install/exec suites). Each was confirmed to be a timeout rather than an assertion failure and to pass in isolation before being dismissed, per the documented contention-retry policy. Final gate runs were clean at load ~5.

Residual risk — no live device evidence

Per docs/agents/pull-requests.md, device-facing behavior isn't merge-ready on fixture-backed tests alone, and this PR has no live simulator/emulator run. Three changes touch device-facing paths:

  • Android text entry and clipboard write — needs a real emulator/device, ideally one without the test IME active so the adb shell fallback is actually exercised (open --test-ime off), typing text containing ;, `, $(), and a quote.
  • iOS tap corroboration — needs the penalty-boundary repro; the deterministic one is the Bluesky drawer-menu press under private-AX penalty.
  • Session close with a failed platform close — needs a device disconnected mid-close.

Treating this as residual risk rather than calling it verified. Happy to run any of these before merge.

Scope and follow-ups

20 files, +516/−38. Scope deliberately spans five unrelated areas because this is an audit batch, not one feature — one commit per fix, so splitting is a git cherry-pick away. Against the repo's own "keep changes to one command family" rule, that's a real tradeoff and reviewer's call.

… no request flags

matchingCaptureFlags dropped the baseline snapshot's scope/depth/raw whenever
the incoming request carried no flags, so the post-action corroboration
capture ran at the default presentation and could never match a non-default
baseline's presentationKey. The corroboration then silently declined to
engage, leaking the raw XCTEST_RECORDED_FAILURE it exists to eliminate.
When close cannot confirm the device was released, it deliberately keeps the
advisory claim (handing an unconfirmed device to the next session would be
worse) but deletes the session record on the next line regardless, leaving a
claim naming a session the daemon no longer tracks with no trace. Emit a warn
diagnostic naming the device key and session, mirroring the open path's
existing rollbackNewSessionClaim handling. Retention policy is unchanged.
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.
bin.ts's `--help` fast path resolved aliases through a hand-written
two-entry table that had drifted out of sync with the real
CLI_COMMAND_ALIASES registry (five entries). `tap`, `launch`, and
`relaunch` missed the table and silently fell through to a full
runCli() bootstrap just to print static help text (~150-165ms vs
~45-50ms for aliases already in the table).

Delegate to the shared normalizeCliCommandAlias registry instead of
the stale local table, so every alias the registry knows about gets
the fast path automatically.
ADR 0007 requires generated connection profiles to strip daemon and Metro
bearer tokens; only the Metro half was honored. `connect` was writing the
daemon bearer token into the 0600 connection-state file, and every later
command read it back out.

Stop writing `authToken` into `RemoteConnectionState['daemon']` and resolve
it at each reader from the existing flag -> environment
(AGENT_DEVICE_DAEMON_AUTH_TOKEN) -> remote-config-profile chain instead,
matching src/cli/auth-session.ts's precedence.

Behavior change: a user who ran `connect --daemon-auth-token <value>` and
relied on later commands picking the token back up from the state file will
now get an auth failure. They must export AGENT_DEVICE_DAEMON_AUTH_TOKEN,
set daemonAuthToken in their remote config, or pass --daemon-auth-token on
each command. website/docs/docs/remote-proxy.md is updated to show the
supported env-var workflow.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-06 13:52 UTC

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.98 MB 1.98 MB -800 B
JS gzip 634.7 kB 634.6 kB -98 B
npm tarball 768.3 kB 764.7 kB -3.6 kB
npm unpacked 2.70 MB 2.68 MB -15.7 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.4 ms 28.4 ms -1.0 ms
CLI --help 68.4 ms 67.7 ms -0.7 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js -1.0 kB -240 B
dist/src/cli.js -293 B -84 B
dist/src/screenshot-result.js -149 B -76 B
dist/src/session.js +93 B +22 B
dist/src/agent-device-client.js +30 B +10 B

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Reviewed exact head 54d57986. The individual Android quoting, iOS presentation preservation, close diagnostic, and alias-fast-path changes look directionally sound, but this batch is not ready:

  • P1 — connect --force can silently orphan the previous remote lease. cleanupForcedPreviousConnection passes the new connection's flags.daemonAuthToken into releasePreviousLease, while releaseRemoteConnectionLease combines that token with the previous connection's daemon URL and lease metadata. When replacing remote/profile A with differently authenticated B, cleanup sends B's token to A; releasePreviousLease then swallows the auth failure and the new connection succeeds, leaving A's lease/provider resources behind. This was previously avoided by the persisted old token. Resolve authentication from the previous profile/environment for the previous endpoint, or surface an actionable inability to release instead of silently using the new credential. Add a regression with old profile token A and new token B that proves A is used (plus the no-recoverable-old-token behavior).
  • Scope/readiness — split the audit batch. This PR combines five unrelated ownership areas, including a deliberately breaking remote-auth migration and three device-facing changes, despite the repository's one-command-family/module-group rule. The commits are already independent; splitting lets the security/lease change, Android shell behavior, iOS corroboration, close diagnostics, and CLI perf fix carry their own validation and rollback story. It also isolates the known overlap with fix(ios): pin tap-outcome corroboration probes to the baseline's backend #1634.
  • Required live evidence is still absent. Before the device-facing PRs are marked ready, provide exact-head emulator/device proof for the real Android adb shell fallback with shell metacharacters and clipboard spacing, the iOS corroboration path after reconciling fix(ios): pin tap-outcome corroboration probes to the baseline's backend #1634, and the retained-claim diagnostic/recovery shape for a failed close. Fixture-backed tests are useful but do not satisfy the repository's device-readiness rule.

Completed checks are green; iOS Smoke is still running. No ready-for-human label.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Thanks — the P1 is real, and I reproduced the mechanism independently before touching anything. Recording the exact shape since it is narrower than it first looks:

Of the two release call sites, only one is wrong. disconnect (connection.ts:289) releases the current connection, so the ambient flags.daemonAuthToken legitimately belongs there. cleanupForcedPreviousConnection (connection.ts:247) releases the previous connection — previous.leaseId, previous.daemon.baseUrl, previous.tenant all come off previous — while the token comes off the new connection's flags. releasePreviousLease's bare catch {} then hides the auth failure, so the reconnect reports success and A's lease is orphaned with nothing surfaced.

The root error is mine and worth naming: the token-strip commit swapped the token source at N call sites without asking which connection each site was releasing. The persisted token had been masking the distinction.

Fix in progress on fix/remote-connection-token-strip, to the rule you specified — resolve from the previous connection's own profile; fall back to the ambient token only when the previous endpoint equals the new one; otherwise refuse to send the new credential to the old endpoint and surface an actionable notice naming tenant/run/lease/base URL. releasePreviousLease stops swallowing failures. Re-persisting the token is explicitly off the table as a "fix". Regression tests are exactly the two you asked for — old-profile token A vs new token B proving A is used, plus the no-recoverable-old-token path — each proven to fail without the change.

Splitting the batch, as you asked. Five single-commit branches off 46abd0f48; this PR will be closed in favour of them:

Live evidence: agreed, and I am not going to argue the fixture tests cover it. The three device-facing PRs go up as drafts and stay drafts until exact-head evidence exists. Worth flagging one trap for the Android run: it has to execute with the test IME off, or the adb shell fallback being fixed never runs and a green result would prove nothing.

On the iOS corroboration one — it and #1634 fix different halves of the same fail-closed gate (hasMatchingPresentation rejects on backend and on presentation). I would rather land #1634 first and rebase mine onto it, so its live evidence is gathered against the reconciled path rather than a state that will not exist. Say the word if you would prefer the reverse order.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Superseded by the split, as requested. All three review points stand against this head and are being addressed on the split branches rather than here — which is why this head never moved.

  • P1 (forced-reconnect lease auth) — fixed on fix/remote-connection-token-strip (accf3383f), not yet pushed. Resolves the previous endpoint's credential from its own profile, falls back to the ambient token only when previous and next base URLs match, and otherwise refuses to send the new credential and surfaces an actionable notice instead of swallowing the failure. disconnect is untouched. Regression pin proven red: 'test-new-not-a-real-token' where 'test-old-not-a-real-token' was expected.
  • Splitperf/cli-alias-help-fast-path is up as perf(cli): route command aliases through the help fast path #1641 (now also carrying a rebase onto current main and an R12 structural guard for the test gap you raised there). The remaining three are rebasing onto current main before they go up as drafts; the iOS one reconciles onto merged fix(ios): pin tap-outcome corroboration probes to the baseline's backend #1634.
  • Live evidence — still absent, still blocking, not being argued around. The device-facing PRs go up as drafts and stay drafts until exact-head proof exists.

Closing in favour of the per-slice PRs so each carries its own validation and rollback story.

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