Skip to content

refactor(android): separate adb transport addressing from the command payload - #2632

Open
thymikee wants to merge 4 commits into
mainfrom
refactor/android-typed-adb-addressing
Open

thymikee wants to merge 4 commits into
mainfrom
refactor/android-typed-adb-addressing

Conversation

@thymikee

@thymikee thymikee commented Sep 15, 2026

Copy link
Copy Markdown
Member

Summary

Separates ADB transport addressing from the device-command payload, the production path traced in #2617. An invocation carries { target: { selector, server, waitFor?, hostGlobals? }, command, rawArgv? }; flat argv comes from one serializer, and the argv grammar — option arity, the 20 wait-for[-TRANSPORT][-STATE] forms, managed-forbidden commands — is one table read by parser, policy and tests alike. command is appended and never re-parsed; a request nobody rewrote is emitted as the argv it arrived in.

Four rules are now explicit:

  • A private adb server travels in the addressing that owns it. One rule covers both channels, argv -P or serverPort option: name no port and the transport uses its own, name this one and it is accepted, name another and the call is refused with managed-device-transport-mismatch before dispatch. Main rewrote a conflicting -P onto the transport's port, which let a caller read a zero exit as an answer about the server it had named; this tightening is intentional. Ambient adb is untouched — with no private server named, argv runs as typed.
  • Under a private server, globals that transport cannot restate (-t, -H, -a, -d, a second -s, an unparseable wait-for-…) are refused, not answered with addressing dropped.
  • A forwarding provider receives the caller's argv minus the scope's own -s pair, so readiness tokens survive the hop. It is checked against the same server rule, because a provider would otherwise hand the caller's -P to an adb it does not address.
  • One typed refusal answers every mismatch: device, target, or server.

provider-limrun drops its structural type copy and its own argv projection, and hands an ADB failure the invocation it addressed. The root host lowers a typed request in one function, which the test host now shares instead of imitating.

Route table and cost: #2617 (comment). 27 files; gross churn exceeds the 1,000-line budget: one contract moves across platform, provider and root binding. Not closing #2617; the published-options change stays in #2640.

Validation

b246b9546c on origin/main 9173d3c797 (0.21.4), with #2599, #2604, #2629, #2636 and #2639 integrated. pnpm test:unit (1325 files / 10340 tests), provider-integration (66 files / 210 tests), typecheck, lint, layering (243 pass / 0 fail) and package gates pass locally; CI green at the previous head. check:affected's mutation-model ownership assertion fails identically at origin/main here.

Refusals are pinned at the process boundary, not just at the thrown error: the fake adb appends every call it actually serves to a log, and the refused requests appear nowhere in it. Deleting the server check from requireManagedAndroidAdbAddressing fails four tests across the provider branch, the device route, the reachability run, and the root host. The user-visible part of the tightening is in CHANGELOG.md, including that ambient adb is unchanged.

Live at head, with helper assets built for 0.21.4 (pnpm build:android; snapshot and IME APKs in android/*/dist) on emulator-5554: open returning interactive @refs, press @e18 --settle settling +18/−10, close. (Pixel 7 CI, SDK 36) through the built CLI: devices, open com.callstack.agentdevicelab --foreground returning an interactive snapshot with @refs, press @e18 --settle settling with a +18/−10 diff onto the Settings screen, snapshot -i, apps, keyboard status, screenshot (1080x2400), logs start/mark/stop (15 KB), close.

Live against a real second adb server on port 15037 (adb -P 15037 start-server, separate daemon state dir): createLocalAndroidAdbProvider(device, { serverPort: 15037 }) answered shell id (uid=2000(shell)…) and getprop ro.serialno (EMULATOR36X2X12X0); while a call was in flight the OS process table showed exactly adb -P 15037 -s emulator-5554 shell sleep 3, which is external proof of the lowered argv; a background logcat spawn through the same route returned 961 lines; exec(['-P','9999','shell','id']) threw managed-device-transport-mismatch and polling lsof -iTCP:9999 -sTCP:LISTEN for two seconds showed no server ever forked there, so nothing dispatched; the ambient host route kept working unchanged. Driving the whole CLI with adb's server on 15037 (ANDROID_ADB_SERVER_PORT=15037) also completed devices, open, press --settle, screenshot, logs start/stop, close.

Live in process, on the two shapes the review asked to see, against a real adb server on 15037 and the real emulator:

  • Private-port scope (withAndroidAdbProvider(provider, { serial, serverPort: 15037 }), the shape a lease installs): runAndroidHostAdb(['-s', serial, 'shell', 'id']) answered uid=2000(shell)…; with a call in flight the process table read adb -P 15037 -s emulator-5554 shell sleep 3; a server-level devices was answered by the lease transport; -P 9999 threw managed-device-transport-mismatch and added zero calls to the provider; a forced failure rendered adb -s emulator-5554 shell definitely-not-a-command exited with code 127.
  • Ambient provider scope (the shape a forwarding provider installs, which is Limrun's): the provider received [["shell","id"], ["wait-for-device","shell","getprop","ro.serialno"], ["shell","definitely-not-a-command"]] — the scope's -s pair removed, the readiness token preserved — while real adb answered underneath (uid=2000(shell), EMULATOR36X2X12X0), and the forced failure surfaced through the same addressed shape.

Worth naming while doing that run: under a port scope, runAndroidHostAdb is answered by the lease transport, so a forwarding provider installed alongside a private server receives nothing on that channel; it is reached through the executor-override channel instead, where the same server rule applies. Pre-existing routing, unchanged here, and no shipped combination uses both.

Blocked validation, with what each needs

  • Simlock-granted lease. No lease can be granted from the shipped CLI in 0.21.3: agent-device host does not exist in this build (the ADR 0021 front-end is unshipped), and connect --lease-id <id> --lease-backend android-instance needs a Host plus co-resident Simlock to mint one and hold an Android slot. Per review, the in-process path stands in, and it now runs both ways: the reachability test drives a real fake-binary adb through the lease port and asserts the refused request appears in no dispatch log, and the private-port scope above runs against a real adb server. What still stands unverified is the allocator's grant, renewal and release around that transport.
  • Limrun Android. connect limrun --platform android returns INVALID_ARGS: connect limrun requires LIMRUN_API_KEY. An API key (LIMRUN_API_KEY, optional LIMRUN_REGION) and an available Android instance are the only missing pieces. With them the lane is connect limrun --platform androidinstallopensnapshot -ipresslogs start/stopclosedisconnect, each under --debug to show the provider receiving argv without the scope's -s pair, plus one forced adb failure to show the addressed failure output refactor(android): separate adb transport addressing from the command payload #2632 adds. The forwarding shape itself is live-verified locally (above); what a key buys is the tunnel, the real instance, and cleanup.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-2632/

Built to branch gh-pages at 2026-09-15 21:07 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.58 MB 4.59 MB +4.4 kB
Package (unpacked) 4.58 MB 4.59 MB +4.4 kB
Package (download) 1.36 MB 1.36 MB +1.3 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.5 ms 26.6 ms -0.8 ms
CLI --help 78.9 ms 76.5 ms -2.4 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 1dc9dea. One contract for the invocation is a good direction, but a leased executor may now accept a caller's server port, provider-scoped calls may lose addressing, and the PR closes an investigation issue without its output.

On main, createSerialAdbExecutor and createSerialAdbSpawner replace options.serverPort with the lease port. On head the caller's options pass through (adb-provider-scope.ts#L62), and the root lowering gives them priority: serverPort ?? androidAdbOwnedServerPort(invocation) (platform-runtime-android-adb-host.ts#L144). Can a caller that passes serverPort in AndroidAdbExecutorOptions move a managed lease off its private adb server this way? Main closed this for the -P argv spelling. If so, only the owned port should reach lowering under a lease, either by stripping serverPort from options or by refusing a mismatch, with a test that passes serverPort: 9999 to a leased executor and spawner.

The provider serial branch now passes invocation.command (adb-provider-scope.ts#L222), the tail after all parsed addressing. On main, stripAdbSerialArgs removed only the matching -s pair, so adb -s S wait-for-device shell getprop reached the provider with its wait-for-device. Does head drop that argument, and globals such as -d or -e without a lease, so a probe could run before the device is ready? The provider should get the caller's argv minus the scope's own -s pair, or a refusal, and a scope test with a wait-for argument would cover it.

Managed argv is not byte-identical to main for a few edge forms: a repeated -s with the same serial is pushed into hostGlobals and refused (adb-transport.ts#L414), a second wait-for argument overwrites the first, and a non-integer -P is now refused. No internal production caller builds these forms, so either restore parity or state the change in the PR body.

limrunAdbArgv (provider-limrun/src/android.ts#L195) re-implements serializeAndroidAdbInvocation for failure text without rawArgv, hostGlobals or waitFor, the limrun target builders repeat androidAdbInvocation(androidAdbSerialTarget(serial), args), and app-log-reconnect.ts writes the same target literals a third time. That is a second serializer to keep in sync, which the PR set out to remove. Can the failure text come from the real serializer, and the target builders be shared? Smaller cleanups: the back-to-back JSDoc blocks at adb-transport.ts#L212, androidHostAdbInvocation as a pure alias of parseAndroidAdbArgv (L341), and const requested = installed; at adb-provider-scope.ts#L287.

#2617 asks for a design trace, a route table and a continue/stop recommendation before any migration, and says the issue should not close as an implementation. Can "Closes #2617" be removed, the route table and recommendation posted on the issue, and the body corrected where it claims a single serializer and no re-slicing?

Would a smaller change do: keep the three host methods and flat argv, and move the three hand-written grammars (findAdbSerialIndex, withServerPort, assertManagedAdbCommand) into one table and parse helper in adb-transport.ts? That removes the duplicate parsing without new invocation types, rawArgv, the options/target double channel for the server port, or a limrun dependency on platform-android. If typed addressing stays, should the server port travel only on the target, with AndroidAdbExecutorOptions.serverPort removed from the device routes first?

All checks pass at 1dc9dea and there are no conflicts. A live run is still needed on the routes this changes: a managed-lease Android session on a private adb server whose --debug output shows adb -P <lease> -s <serial> ..., and a limrun-provider session showing adb -s <tunnel-serial> for exec and disconnect on cleanup. The run in the PR body covers only the ambient local route. Next: settle the lease port question and the provider argv, then add those runs.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed in 25a8564cab (platform + root lowering), 9cbb59c3fd (provider), 8af64ef06e (comment move).

Lease port. Yes, a caller could move a leased route: options passed through and root lowering preferred them. serverPort is now destructured out of both device routes and reconciled, not forwarded — deviceServerPort refuses a construction or per-call port that differs from the lease with managed-device-transport-mismatch, and without a lease the built-with port wins, as on main. Root lowering takes the single channel through requireAndroidAdbServerPort, so an owned port can never be moved by an option; scoped routes carry the lease port in the target and stop setting options.serverPort, which is what makes the second channel unrepresentable rather than merely unused. Pinned by a test that passes serverPort: 9999 to a leased executor and spawner, and one that pins the no-lease precedence.

Provider argv. It did drop wait-for-device, and -d/-e with it. The serial branch now hands over androidAdbPayloadWithoutSerial(args, scope.serial) — the caller's argv with only this scope's -s pair removed, byte-order kept, no rebuild. Under a lease, globals the private transport cannot restate are refused; without one they travel to the provider, which is what its adb can honor. Scope tests cover the wait-for case, the no-lease -d case, and that a server-level command stays on the host route.

Grammar parity. Restored where adb itself is preserved, stated where it is not. A repeated -s naming the same serial is now dropped as redundant instead of refused. A second wait-for token keeps the first in the typed slot and the rest as host globals, so a managed route refuses rather than answering two waits with one. Main forwarded both verbatim on every route; head keeps that on the ambient route and refuses it on a managed one. A malformed -P stays an unowned global: the ambient route emits the caller's argv unchanged and adb decides, as on main; a managed route refuses it along with other unrestatable globals. The body states the rule change. Each form has a parse test.

Second serializer. limrunAdbArgv is gone. adbError now takes the AndroidAdbInvocation and the root adapter renders command with serializeAndroidAdbInvocation, asserted in src/sdk/limrun-runtime-dependencies.test.ts. Target builders are shared and used by app-log-reconnect.ts. I first put them in provider-limrun/src/adb-addressing.ts; that added one eagerly evaluated module to provider-limrun/index, which ADR-0019's no-growth rule refuses for an entry the merge base carries, so they live in android.ts, inside the closure both callers already sit in. androidHostAdbInvocation is deleted, const requested = installed went with scopedServerPort, and the transcription note is now the table's own doc block.

#2617. Closes removed, route table plus continue recommendation posted (#2617 (comment)), and the two overclaims corrected: the parse direction does re-slice once — that is the only direction that flattens — and the provider no longer keeps a projection of its own.

Would a smaller change do? I tried that half first. The table plus one parse helper removes the duplicate parsing but not the double channel: adbInvocation still stitches -s/-P into argv at six sites, the provider arm still has to strip what it just read to hand the payload over, and provider-limrun still needs a structural copy of a type it cannot import by name. The invocation is what lets the provider branch pass the caller's array through untouched. I did take your narrower answer on the port: serverPort no longer travels as a live option on device routes. Removing the field from published AndroidAdbExecutorOptions is a public break that wants its own decision — say the word and I will open it as a follow-up.

Live runs still owed. Both remain unavailable here, so no evidence is claimed for them: a managed lease needs a Simlock host and token (agent-device --debug on a leased Android device, expecting adb -P <lease> -s <serial> …), and a Limrun session needs LIMRUN_API_KEY (logs/exec plus cleanup, expecting adb -s <tunnel-serial> and disconnect). snapshot/press additionally need pnpm build:android-snapshot-helper and package:android-snapshot-helper:npm, whose assets this worktree lacks; the recorded live run stays the ambient local route.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 8af64ef, as a follow-up to the review at 1dc9dea. The server-mismatch rule now has more than one owner, and the managed-lease and Limrun routes still have no live run.

requireSameServer at adb-provider-scope.ts#L127 builds the same error as requireAndroidAdbServerPort at adb-transport.ts#L474: COMMAND_FAILED, the same message, and reason managed-device-transport-mismatch. transportMismatch() already covers the device and target variants. If one copy drifts, the device route and root lowering answer the same misuse differently. Can transportMismatch get a server variant, with one exported check such as requireSameAndroidAdbServer(owned, requested) used by both, and the local copy deleted?

Under a lease, doesn't withScopedTransportOptions (adb-provider-scope.ts#L304) drop a caller's serverPort: 9999 without an error? deviceServerPort refuses the same option on device routes, and a -P in argv is overwritten. The route never moves, but the same misuse gets a refusal, a silent drop, or a silent overwrite depending on the route. Can every lease site use the one check above? If host-transport calls should ignore the option, can the PR body say so, with a 9999 case for createScopedHostTransport?

Your answer on the smaller design holds: the table plus parser alone keeps six -s/-P stitch sites and the provider's type copy. The extra surface left is the second port channel. Would removing serverPort from AndroidAdbExecutorOptions make a mismatch unrepresentable and delete deviceServerPort, requireSameServer, withScopedTransportOptions and requireAndroidAdbServerPort? That is a public SDK change, so it needs a maintainer decision, and the follow-up you offered is a good place for it. The continue-or-stop decision on #2617 should also land before merge.

Only the local emulator route ran live, and this PR changes two more. A --debug run on a Simlock-leased Android device (open, snapshot or press) should show every adb call as adb -P <leasePort> -s <serial> ..., plus one exec with a mismatched serverPort that returns managed-device-transport-mismatch. A Limrun Android session should show adb -s <tunnel-serial> ... on exec, adb disconnect <tunnel-serial> on close, and a forced failure that prints the command from serializeAndroidAdbInvocation. If either run is blocked, can the PR body name the device needed and the remaining risk?

One iOS Smoke Tests job is still running; this diff does not touch the iOS route, so it looks unrelated. There are no conflicts. Next: one server-mismatch check, the two live runs, and the #2617 decision.

@thymikee

thymikee commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

Rebased on origin/main 2ad819ca06 and pushed as d3162a0ade (the previous history was tree-equivalent and had too many conflict hunks to replay, so it went in as one commit for one contract; backup/pre-rebase-8af64ef holds the old history).

Point by point:

1 — one server check. Agreed, and taken further than a call. transportMismatch now takes 'device' | 'target' | 'server' and reads one message table, and requireSameAndroidAdbServer(owned, requested) is the only place that compares ports: requireAndroidAdbServerPort, deviceServerPort, and provider scope all go through it. The local requireSameServer and its duplicate message are deleted.

2 — a lease dropping serverPort. Fixed at the owning type rather than at each call. Every lease site now answers through that one check: deviceServerPort refuses a mismatched construction port and a mismatched per-call port for the executor and spawner routes, and execOnScopedTransport — where a lease builds host options — refuses before either arm runs. The 9999 case is pinned for createScopedHostTransport as well, which was the arm that silently swallowed it.

3 — remove serverPort from AndroidAdbExecutorOptions. This is your call, not mine: it narrows a published input type on agent-device/android-adb and the semver answer decides whether the deletion is allowed. I filed it as #2640 with the shape split (host options keep a per-call port, device executors and the *WithAdb helpers stop declaring one) and the exact deletions it buys — deviceServerPort's three-way precedence, the option-channel removal in lowering, and the per-call callers from item 1. Say the word and I land it here instead.

While doing that round I cut more, since the contract allowed: detached is out of AndroidAdbExecutorOptions (it is a spawn detail; both the root host and the test host apply process.platform !== 'win32' where they fork), lowerAndroidAdbInvocation lives beside the grammar so root and the test host stop maintaining two lowerings, the executor and spawner share deviceAdbRouteRequest, and four internal helpers in adb-transport are no longer exported — their tests assert parse and managed behavior instead of internals. Full suite 1325 files / 10326 tests, provider-integration 210, layering 243/0, package gate and typecheck clean.

4 — live runs. Ambient route now has more evidence than a snapshot: on emulator-5554 (Pixel 7 CI, SDK 36) through the built CLI I ran devices, open com.callstack.agentdevicelab --foreground, apps, keyboard status, clipboard write/read, screenshot (1080x2400 PNG), logs start/mark/stop (15 KB), close. The two lanes you asked for are still owed and the body now names what each needs: a managed-lease run needs a Simlock host and token (this machine has neither), a Limrun session needs an API key, and snapshot -i/press additionally need package:android-snapshot-helper assets that are absent here — open reports the missing artifact and every other command succeeds without it. If you can point me at a Simlock host with an Android slot I will run the lease lane before merge.

5 — #2617 disposition. Understood as yours. The PR does not close it and says so; the route table and the cost claim are on the issue. If the answer is "stop here", the useful residue is the grammar table and the provider handoff, which are the parts a different design would still want.

6 — rebase. Done; #2599's process-group handling and #2636's chunking are intact (CHANGELOG.md, recording/chunks.ts and the overlap in adb-host-test-setup.ts are main's, not my earlier copies).

One unrelated observation from the live pass: cmd clipboard get text on this SDK 36 image prints No shell command implementation. and exits 0, so clipboard read returns success with empty text. Pre-existing, and isAndroidShellCommandUnsupported's comment says a zero exit is not evidence of failure — flagging it only because it made my own live check ambiguous, not as a request in this PR.

@thymikee
thymikee force-pushed the refactor/android-typed-adb-addressing branch from e49c2bd to d3162a0 Compare September 15, 2026 15:53
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at d3162a0, as a follow-up to the review at 8af64ef. The lease refusal through options.serverPort is now one check, and #2640 tracks removing serverPort. Two things are left.

Isn't the argv channel still different? Under a lease, a caller passing -P 9999 -s <serial> gets the port overwritten, not refused (adb-transport.ts#L527). The override at adb-provider-scope.ts#L259 and execOnScopedTransport at #L299 both reach it through applyManagedAndroidAdbServer. androidAdbOwnedServerPort ignores a port from argv, so requireSameAndroidAdbServer never sees it, and on the serial-override branch the argv with -P 9999 reaches scope.provider.exec for a non-local scope. This is not a regression, since main also overwrote -P, but the same misuse is refused or overwritten depending on the channel. Can applyManagedAndroidAdbServer, which every lease route passes through, run requireSameAndroidAdbServer on the argv port before it rewrites? A -P 9999 test under a 15037 lease that expects managed-device-transport-mismatch would pin it. If overwrite is intended, can the PR body and #2640 say so? It is not confirmed that any production lease call passes -P today.

On size: removing AndroidAdbExecutorOptions.serverPort would also delete deviceServerPort and the per-call checks. Keeping this PR as one contract move is fine if a maintainer decides the semver question on #2640 first.

Live evidence for the two routes is still missing. For a managed lease: a Simlock lease with a server port other than 5037 running open, snapshot and logs start/stop, with --debug output showing -P <leasePort> -s <leaseSerial> and ANDROID_ADB_SERVER_PORT=<leasePort>, and a call naming another serial or server failing with managed-device-transport-mismatch. For Limrun: a session running open, snapshot -i, press and logs, showing the provider gets argv without the scope's -s pair and an adb failure reports the addressed invocation. The emulator-5554 run covers neither route.

All 20 checks pass and there are no conflicts. Next: the two live runs, and a decision on argv -P under a lease.

@thymikee

Copy link
Copy Markdown
Member Author

e795aa32ca implements the port rule as specified. CI green, unit 1325 files / 10327 tests, provider-integration 210, layering 243/0, package gate sound.

-P under a private server. One rule, both channels, checked in requireManagedAndroidAdbAddressing(target, managedPort) before anything is dispatched: no port named → the transport's own; matching → accepted; different → managed-device-transport-mismatch. The provider-forwarding branch parses the argv and applies the same check, so a caller's -P no longer reaches a provider that cannot address it. Ambient is untouched: runAndroidHostAdb(parseAndroidAdbArgv(['-P','9999','devices'])) with no private server named still runs that argv, pinned in the root host test. Zero-executor-calls is pinned twice over — the provider-scope and device-route tests assert an untouched call recorder, and the root host and reachability fakes now append every call they actually serve to a log, with the assertion that no refused request appears in it.

One consequence worth your eye: the rule also covers a host call whose two channels disagree, so runAndroidHostAdb(parseAndroidAdbArgv(['-P','9999','devices']), { serverPort: 15_038 }) is now refused instead of quietly preferring the option. Same trap, same answer; pinned.

The tightening is documented in the code doc on requireManagedAndroidAdbAddressing and in the PR body. No versioned help or user doc described the old overwrite — serverPort reaches it only through the published SDK types, and #2640 owns that surface.

Live evidence gathered here. Built the missing helper assets (pnpm build:android; snapshot + IME APKs land in android/*/dist, both installed on the emulator) and ran the full lane on emulator-5554: open com.callstack.agentdevicelab --foreground returning interactive @refs, press @e18 --settle settling +18/−10 onto Settings, snapshot -i, screenshot, logs start/mark/stop, close.

Then against a real second adb server (adb -P 15037 start-server, separate daemon state dir), driving the exact route a lease installs:

  • createLocalAndroidAdbProvider(device, { serverPort: 15037 }) answered shell id (uid=2000(shell)…) and getprop ro.serialno (EMULATOR36X2X12X0);
  • with a call in flight, the process table read back adb -P 15037 -s emulator-5554 shell sleep 3 — external proof of the lowered argv, not a mock's word for it;
  • a background logcat spawn through the same route returned 961 lines;
  • exec(['-P','9999','shell','id']) threw the typed mismatch and two seconds of polling lsof -iTCP:9999 -sTCP:LISTEN showed no server ever forked there, so nothing dispatched;
  • the ambient host route kept answering unchanged;
  • and the whole CLI through a private server (ANDROID_ADB_SERVER_PORT=15037 + separate state dir) completed devices, open, press --settle, screenshot, logs start/stop, close.

Still blocked, stated as blocked rather than waived.

  • Simlock-granted lease: not runnable on this machine at all. agent-device host does not exist in 0.21.3 — the ADR 0021 front-end is unshipped — and connect --lease-id <id> --lease-backend android-instance requires a Host plus co-resident Simlock to mint the lease and hold an Android slot. The private-server mechanics a lease installs are now live-verified; the grant, renewal, release and serial-ownership around them are not. I need a Host+Simlock host with an Android slot, or you run this row.
  • Limrun Android: connect limrun --platform androidINVALID_ARGS: connect limrun requires LIMRUN_API_KEY. With a key (LIMRUN_API_KEY, optional LIMRUN_REGION) and an available Android instance the lane is connect limrun --platform androidinstallopensnapshot -ipresslogs start/stopclosedisconnect, checking tunnel addressing, cleanup disconnect, and the addressed failure output.

Keeping merge on those two rows as you set it. #2617's typed-addressing direction continues here, and #2640 stays out of this branch.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at e795aa3. The argv -P check now lives in requireManagedAndroidAdbAddressing, which every managed route already calls, so the fix is in the right place. The new test does not reach it, though.

The lease-override case at adb-provider-scope.test.ts#L181 passes '-P', '9_999'. Number('9_999') is NaN, so parseAndroidAdbArgv keeps the pair in hostGlobals, and the older hostGlobals check refuses the call before the new server check runs. If the new check were removed, this test would still pass. Can you use '9999', assert the server refusal message (for example /cannot select another server/), and add a '-P', '15037' case on the same branch that asserts what the provider receives?

Under a private adb server, a conflicting argv -P used to be rewritten to the managed port. Now it fails with managed-device-transport-mismatch. This reaches createLocalAndroidAdbProvider(device, { serverPort }), runAndroidHostAdb(invocation, { serverPort }) and the Limrun runtime dependency. Could CHANGELOG.md get an Unreleased entry for this that also says ambient adb is unchanged?

On design: is removing AndroidAdbExecutorOptions.serverPort (#2640) still the plan? It would delete deviceServerPort, requireAndroidAdbServerPort and the per-call requireSameAndroidAdbServer checks, and leave argv as the only addressing channel. That needs a decision on the semver question in #2640 first.

Live evidence is still missing for two routes. Limrun Android ships changed code in provider-limrun/src/android.ts and app-log-reconnect.ts, so it needs a run of connect limrun --platform android, open, snapshot -i, press, logs start/stop, close and disconnect, with --debug output that shows the provider gets argv without the scope's -s pair, plus one forced adb failure. The runs in the PR body do not reach a managed lease on a non-5037 port through withAndroidAdbProvider. If no lease can be granted from the shipped CLI in 0.21.3, the in-process reachability test can stand in for that route.

Smoke Tests is still running. This PR changes lowerAndroidAdbInvocation and adb-host.ts, so a failure there would not be clearly unrelated. There are no conflicts. Next: fix the test literal, add the changelog entry, and run the Limrun route.

@thymikee

Copy link
Copy Markdown
Member Author

0bf8b3e424 fixes the test, 1de97dd415 adds the changelog entry.

Test literal. '-P', '9_999' became '-P', '9999', the assertion is .rejects.toThrowError(/cannot select another server/), and the same branch now has the matching-port case: ['-P','15037','-s',DEVICE.id,'shell','ls'] under a 15037 lease reaches the provider as ['-P', '15037', 'shell', 'ls'] — argv minus the scope's -s pair, port left where the caller wrote it. I then checked the reachability claim directly: deleting the requireSameAndroidAdbServer call from requireManagedAndroidAdbAddressing now fails four tests — this provider branch, the device route, the reachability run, and the root host — instead of none.

CHANGELOG. Added under Unreleased as Changed (android): the refusal replaces the argv rewrite, names the three surfaces it reaches (createLocalAndroidAdbProvider(device, { serverPort }), runAndroidHostAdb(invocation, { serverPort }), the Limrun runtime dependency), and states what does not change — a request naming no server or the one its route holds, and ambient adb, whose argv still runs as typed -P and all.

#2640. Still the plan, and still gated on your semver call. Removing AndroidAdbExecutorOptions.serverPort deletes deviceServerPort and its three-way precedence, requireAndroidAdbServerPort, and the per-call requireSameAndroidAdbServer callers in execOnScopedTransport, leaving argv as the only addressing channel; the host port keeps a per-call port on its own options type. I am not folding it in here — say the word after the semver decision and it lands as its own change.

Live, on the two shapes you named. Ran them in process against a real adb server on 15037 and the real emulator:

  • Private-port scope (withAndroidAdbProvider(provider, { serial, serverPort: 15037 })): runAndroidHostAdb(['-s', serial, 'shell', 'id'])uid=2000(shell)…; process table during a call → adb -P 15037 -s emulator-5554 shell sleep 3; devices answered by the lease transport; -P 9999managed-device-transport-mismatch with zero calls added to the provider; forced failure → adb -s emulator-5554 shell definitely-not-a-command exited with code 127.
  • Ambient provider scope, which is Limrun's forwarding shape: the provider received [["shell","id"], ["wait-for-device","shell","getprop","ro.serialno"], ["shell","definitely-not-a-command"]]-s pair gone, readiness token preserved — with real adb answering underneath (uid=2000(shell), EMULATOR36X2X12X0), and the same forced failure addressed the same way.

One routing fact I ran into while building that, pre-existing and unchanged here: under a port scope runAndroidHostAdb is answered by the lease transport, so a forwarding provider installed with a private server gets nothing through that channel and is reached via the executor-override channel instead, where the same server rule applies. No shipped combination uses both; noting it because it decides which arm a future lease-plus-provider route would exercise.

Per your fallback, the in-process reachability test now stands in for the lease route on a non-5037 port: it drives a real adb binary through the lease port and asserts the refused request appears in no dispatch log. The allocator's own grant/renew/release around that transport remains unverified — no host command ships in 0.21.3, and connect --lease-backend android-instance needs a Host plus co-resident Simlock to mint a lease.

Limrun is unchanged and still owed: connect limrun --platform androidINVALID_ARGS: connect limrun requires LIMRUN_API_KEY. Key plus an available Android instance are the only missing pieces; the lane and the --debug check are written into the PR body so it is copy-pasteable for whoever has them.

Smoke Tests passed; CI was 0 non-green at e795aa3, and the two new commits are lint/format/typecheck/layering clean with unit 1325 files / 10327 tests.

… payload

One array carried two meanings: which device and which adb server a call was
for, and what to run on it. Five relays read and rewrote the first meaning out
of the second — findAdbSerialIndex, readAdbSerial, stripAdbSerialArgs,
withServerPort, assertManagedAdbCommand — and a provider handed that array had
to undo the stitching to recover the command its caller actually asked for.

An invocation now carries the two apart:
`{ target: { selector, server, waitFor?, hostGlobals? }, command, rawArgv? }`.
One table holds the argv grammar — global option arity, the 20
`wait-for[-TRANSPORT][-STATE]` forms, the server and transport commands a
device-scoped call must never reach — and one parse/serialize pair reads and
writes it. `command` is appended and never re-parsed; a request nobody rewrote
is emitted as the argv it arrived in.

Four rules stop being implied:
- a private adb server travels in the addressing that owns it, so a per-call
  `serverPort` cannot move a leased route onto another server;
- under a private server, globals that transport cannot restate are refused
  rather than answered with addressing quietly left behind;
- a forwarding provider receives the caller's argv with only the scope's own
  `-s` pair removed, so a readiness token survives the hop;
- one typed refusal answers every mismatch, over device, target, or server.

`provider-limrun` drops its structural copy of the invocation type and its own
argv projection, and hands an ADB failure the invocation it addressed. The root
host lowers a typed request in one function, which the test host now shares
instead of imitating.

Design trace and route table: #2617. Production: 12 files, +722/-265.
…sport

A `-P` naming another server used to be rewritten onto the port the transport holds, so a caller who
asked for 5037 could read a zero exit as an answer about 5037. One rule now covers every channel,
argv or option: name no port and the transport uses its own; name this one and it is accepted; name
another and the call is refused before dispatch.

The provider-forwarding branch checks the parsed argv as well, because a provider receives argv and
would hand the caller's `-P` to an adb it does not address. Ambient adb is untouched: with no
private server named, the caller's argv is what runs.

Each refusal is pinned at the process boundary — the fake adb records what it was asked, and the
refused requests appear nowhere in that log.
…branch

A `-P` of `9_999` parses to no port at all, so the pair stayed an unowned global and the older
host-global refusal answered the call before the server rule ever saw it: the assertion passed
without reaching the check it was written to pin. The port now parses, the assertion names the
server refusal, and a port naming the server this lease holds is pinned on the same branch as what
the provider is handed.

Removing the server check from `requireManagedAndroidAdbAddressing` now fails four tests across the
provider branch, the device route, the reachability run, and the root host.
…rite

Names the three surfaces it reaches, and what does not change: a request naming no server or the
server its route holds, and ambient adb.
@thymikee
thymikee force-pushed the refactor/android-typed-adb-addressing branch from 1de97dd to b246b95 Compare September 15, 2026 21:07
@thymikee

Copy link
Copy Markdown
Member Author

Rebased on origin/main 9173d3c797 (0.21.4) and force-pushed; head is b246b9546c.

The only conflict was CHANGELOG.md, where main's three iOS entries from #2639 and this PR's Android entry both want the top of Unreleased — both are kept. mechanics.ts auto-merged: #2604 removed two snapshot-helper exports and this branch trimmed unrelated ones, and neither side touched the other's line.

Integration was not just textual, so it was re-verified rather than trusted. #2604 moved device-ownership reads off adb onto the device shell, which adds adb shell pidof traffic through the same provider this branch re-typed; #2629 re-routed test scratch through mkdtempForTest and forbade node:os in tests, which the tests added here already satisfy. At head, after pnpm install --frozen-lockfile and pnpm build: unit 1325 files / 10340 tests, provider-integration 210, layering 243 pass / 0 fail, lint, typecheck, and the package gate ("the package npm would publish is sound") all clean.

Live re-run at head with helpers rebuilt for 0.21.4 (pnpm build:android): open com.callstack.agentdevicelab --foreground returning the interactive snapshot, press @e18 --settle settling +18/−10, close.

The two owed rows are unchanged by the rebase and still open as stated above: Limrun needs LIMRUN_API_KEY plus an Android instance, and a Simlock-granted lease needs a Host plus co-resident Simlock, with the in-process reachability path standing in per your note.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at b246b95, as a follow-up to the review at e795aa3. Both points are fixed. The lease-override test now passes '9999' and asserts cannot select another server, with a '15037' case that checks the argv the provider receives, and CHANGELOG has an Unreleased entry for the -P refusal that says ambient adb is unchanged. The rebase keeps both CHANGELOG sides, and the mechanics.ts auto-merge touches separate exports.

Removing serverPort from device-scoped executors now lives in #2640, because it is an SDK break that needs its own semver decision, so it does not block this PR.

CI is green, and there are no conflicts. The Limrun Android route is still not live-validated, and this PR changes provider-limrun/src/android.ts and app-log-reconnect.ts. It needs a connect limrun --platform android, open, snapshot -i, press, logs start/stop, close, disconnect run with --debug showing the provider argv without the scope's -s pair, plus one forced adb failure. The Simlock lease route stays covered by the in-process reachability test, as agreed. Next: that Limrun run, or a maintainer decision to accept this PR without it.

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