Skip to content

feat(proxy): lease by device spec — --os, --image-tag, --physical honoured or refused by the backend - #3865

Draft
V3RON wants to merge 10 commits into
mainfrom
feat/proxy-device-spec
Draft

V3RON wants to merge 10 commits into
mainfrom
feat/proxy-device-spec

Conversation

@V3RON

@V3RON V3RON commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

open, install, reinstall and uninstall through connect proxy can now lease by OS version, Android image tag, and physical or virtual class. A backend that cannot honour the spec refuses it rather than ignoring it. 38 files. Closes #3864.

Flag Key on agent_device.lease.allocate Echo on data.lease.device
--os <version|range> osVersion (opaque string) model, osVersion (exact)
--image-tag <tag> imageTag imageTag?
--physical physical: true physical (boolean, always present)

Rules:

  • The CLI sends the keys only on a by-spec allocate. devices.list is not called first.
  • With --physical, the backend leases one of its enrolled phones. Only physical: true names the key; physical: 'yes' or 1 names no spec.
  • A daemon refuses any request that names any of the three keys. This covers a local daemon and one behind agent-device proxy. The refusal is UNSUPPORTED_OPERATION with reason: 'device-spec-unsupported' and keys, and it happens before admission, so no lease is recorded.
  • If a lease comes back without lease.device, or with a non-boolean physical, the CLI releases it and fails with the same reason.
  • A live lease serves a command only when every spec key the command names, and any --udid/--serial, matches it. Otherwise the command fails with CONNECTION_DEVICE_CONFLICT.
  • connect proxy stores the keys as defaults. The keys are visible in MCP and the SDK. open --json prints lease.device; the human output shows physical|virtual.
  • Docs: remote-proxy.md, help remote, and an amendment to ADR 0007.
  • --physical is back, per the maintainer. It was dropped in 1270549 while no phone could reach Simlock's daemon. Simlock's phone path is now merged (Physical devices through agent-device callstackincubator/simlock#870): its front takes physical (boolean), strips it before forwarding, and echoes lease.device.physical.
agent-device open MyApp --platform android --os 34 --image-tag google_apis_playstore
agent-device open MyApp --platform ios --physical --os 18.6.2

Validation

At df790b73a, pnpm check:affected --run --head df790b73a ran with exactCommit=true (fail-open, full set): format, lint, typecheck, layering, di-seams, fallow, mcp-metadata, build, package, integration-node and macos-coverage passed. vitest-related had 4989 passed, 1 failed: daemon-client-ensure-drain.test.ts timed out (5 s). The run before, at the previous head, timed out in macos-recording.test.ts instead. Both files pass when run alone (7/7). Neither covers a changed file. CI owns device lanes and coverage.

Live: the --physical shape was proved end to end against Simlock on a Samsung SM-A165F and an iPhone 11 Pro Max (commit 06bb9d1, cherry-picked here).

Review: three independent Opus rounds on the --os/--image-tag version. All findings except the gaps below are fixed. The round-3 fixes and the --physical restore were not reviewed again.

Assumption: a spec key that a command leaves out does not constrain a live lease. This follows the existing --device rule.
Assumption: env vars cannot set the keys; only flags and connect proxy can.

Known gaps:

  • MCP and the SDK never allocate a proxy lease, so through them the keys are always refused.
  • connect forms other than proxy accept the flags but do not store them, as with --device.
  • If a backend returns the run's existing lease without an echo, the CLI releases that lease.
  • If a spec lease expires and a non-allocating command re-allocates it by device, the new lease has no spec. A later open --os then fails with CONNECTION_DEVICE_CONFLICT.

View guided diff Turn on auto-fix

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

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

Built to branch gh-pages at 2026-10-28 17:10 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.56 MB 5.57 MB +9.6 kB
Package (unpacked) 5.56 MB 5.57 MB +9.6 kB
Package (download) 1.69 MB 1.69 MB +2.8 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.4 ms 30.6 ms +2.3 ms
CLI --help 56.5 ms 60.1 ms +3.6 ms

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Explicit blank spec values can silently become unconstrained requests instead of being validated or refused.

2 open issues
What changed in this PR

Adds OS-version and Android-image-tag selection to proxy device leases, addressing #3864.

Changes:

  • Exposes device-spec options through CLI, SDK, MCP, and connection defaults.
  • Adds backend confirmation, unsupported-spec refusal, and live-lease conflict checks.
  • Reports granted device details and adds tests and documentation.
File Description
website/​docs/​docs/​remote-proxy.md Documents spec-based leasing.
src/​mcp/​__tests__/​command-tools-operator-inputs.test.ts Checks MCP spec fields.
src/​daemon-client/​daemon-client-rpc.test.ts Tests allocation wire fields.
src/​commands/​schema/​option-schema.ts Excludes ambient environment defaults.
src/​commands/​schema/​command-overrides.ts Allows spec flags on connect.
src/​commands/​schema/​cli-help.ts Explains proxy spec behavior.
src/​commands/​management/​lease-device-spec.ts Shares command spec fields.
src/​commands/​management/​install.ts Adds install-family spec inputs.
src/​commands/​management/​app.ts Adds open spec inputs.
src/​commands/​command-flags.ts Projects spec request flags.
src/​client/​lease-client.ts Reads granted device facts.
src/​cli/​connection/​proxy-profile.ts Stores proxy spec defaults.
src/​cli/​connection/​profile-fields.ts Groups hosted allocation fields.
src/​cli/​commands/​router.ts Accepts granted device metadata.
src/​cli/​commands/​router-types.ts Types granted device metadata.
src/​cli/​commands/​proxy-lease-device.ts Validates and binds spec leases.
src/​cli/​commands/​generic.ts Prints granted device facts.
src/​cli/​commands/​connection-runtime.ts Integrates spec allocation and reporting.
src/​cli/​commands/​__tests__/​proxy-lease-device-spec.test.ts Tests proxy spec lifecycle.
src/​cli/​commands/​__tests__/​generic.test.ts Tests granted-device output.
src/​cli.ts Carries lease facts to output.
src/​__tests__/​agent-device-client-leases.test.ts Tests SDK allocation fields.
src/​__tests__/​agent-device-client-lease-device-spec.test.ts Tests daemon and proxy refusals.
scripts/​integration-progress-model.ts Identifies spec-test ownership.
packages/​remote/​src/​remote-connection-state.ts Persists lease-bound spec metadata.
packages/​remote/​src/​remote-config-schema.ts Adds spec profile fields.
packages/​daemon/​src/​server/​request-router.ts Refuses specs before admission.
packages/​contracts/​src/​request-envelope.ts Adds request spec fields.
packages/​contracts/​src/​remote-config-fields.ts Types profile spec defaults.
packages/​contracts/​src/​lease-scope.ts Defines shared spec handling.
packages/​contracts/​src/​facades/​client.ts Exports spec and device types.
packages/​contracts/​src/​client-lease.ts Extends lease contracts.
packages/​contracts/​src/​client-app.ts Extends app-operation options.
packages/​contracts/​src/​cli-flags.ts Types CLI spec flags.
packages/​contracts/​src/​__tests__/​lease-scope.test.ts Tests spec and echo readers.
packages/​command-registry/​src/​flag-groups.ts Shares supported spec flags.
packages/​command-registry/​src/​flag-definitions-target.ts Defines public spec flags.
docs/​adr/​0007-remote-device-leases.md Records spec-leasing decisions.

Review effort: Balanced

Comment thread packages/contracts/src/lease-scope.ts Outdated
Comment on lines +326 to +328
return stripUndefined({
osVersion: nonEmptyString(source.osVersion),
imageTag: nonEmptyString(source.imageTag),
}
return deviceSpecUnsupportedError(
keys,
'The proxy backend leased a device without confirming the requested device spec, so the lease was released.',
@V3RON
V3RON marked this pull request as draft October 21, 2026 12:34
@thymikee

Copy link
Copy Markdown
Member

I reviewed 4f94d93. The design is sound, and one open Copilot thread still holds and needs a fix before this is ready: --os '' is dropped on the CLI but refused on MCP/SDK. readLeaseDeviceSpec drops empty values, while namedLeaseDeviceSpecKeys refuses them, so the two surfaces disagree. Could both surfaces handle an empty value the same way?

The new tests would fail on the old code, because the flags, keys and guard do not exist there. I did not rerun any tests. The honoured path, where a backend echoes lease.device, is proven only against a test-client fake, since simlock#814 is not available for a live allocate by spec.

Not blocking, and fine to take or leave: the release in src/cli/commands/proxy-lease-device.ts:138 uses .catch(() => undefined), so a failed release disappears, while releaseUnhonouredSpecLease puts released/releaseError in the conflict details. The spec key list has two names in packages/command-registry/src/flag-groups.ts:38, and readLeaseDeviceSpec, leaseDeviceSpecFields and the proxy profile code hand-write osVersion/imageTag instead of mapping over LEASE_DEVICE_SPEC_KEYS. The leaseDevice fact travels through the CLI router and then gets a special case for open twice in src/cli/commands/generic.ts:627, so open's own formatter could merge it once. The other Copilot thread also holds: the release message always says it was released, although details.released is right.

Smoke failed on open com.callstack.agentdevicelab --relaunch with android-app-close-timeout on a local daemon. That route uses no spec flag and no remote config, and the Android close and relaunch path does not overlap this diff, so it looks unrelated. Coverage also failed, but its log is cut off before any failing test, so I could not attribute it. Please rerun both, and if Coverage fails again, share the failing case. I know of no conflicts.

@V3RON V3RON changed the title feat(proxy): lease by device spec — --os, --image-tag honoured or refused by the backend feat(proxy): lease by device spec — --os, --image-tag, --physical honoured or refused by the backend Oct 22, 2026
@thymikee

Copy link
Copy Markdown
Member

The earlier review's main problem is not fixed at df790b7, and the new physical check adds a second value that splits the same way. The CLI reader still drops osVersion: '' and imageTag: ' ', and now also drops physical: false (lease-scope.ts#L326). The daemon router names any value that is not undefined (lease-scope.ts#L316, request-router.ts:575). So SDK/MCP open { physical: false } reaches the daemon as flags.physical=false and fails with UNSUPPORTED_OPERATION device-spec-unsupported, keys ['physical']. A connect proxy profile with physical: false, or CLI --os '', is dropped and the command runs with no spec. The same input gives different results on CLI, MCP and SDK, which breaks the consistency rule in #3864. An MCP agent that sends physical: false to mean a simulator is refused even on a local daemon. The rule to satisfy: one function in lease-scope.ts classifies each supplied spec value as named, not named or invalid, and every surface calls it. That covers leaseDeviceSpecInputFromFlags, hasLeaseDeviceSpec, the connection-runtime allocate and the daemon router's deviceSpecFlagsResponse. Either refuse blank strings and non-true physical everywhere (CLI INVALID_ARGS, daemon device-spec-unsupported), or treat them as not named everywhere. Typing LeaseDeviceSpec.physical and the option field as the literal true would make false impossible to send.

Not blocking, and you can take or leave these: the CLI checks only that lease.device exists, so a physical: true request with an echo of { physical: false } is accepted and bound (it could release through releaseUnhonouredSpecLease with keys ['physical'] when lease.device.physical !== true, see proxy-lease-device.ts#L131); once the rule above is decided, the physical row in remote-proxy.md should state it, and the release sentence there and in ADR 0007 should also cover a lease.device with no boolean physical; and one test that iterates LEASE_DEVICE_SPEC_KEYS and asserts getOptionSpec(key) has no env names would have caught the ENV_EXCLUDED_FLAG_KEYS miss that df790b7 fixed by hand (option-schema.ts#L39).

Could one per-key descriptor in lease-scope.ts, for example LEASE_DEVICE_SPEC_FIELDS = { osVersion: 'string', imageTag: 'string', physical: 'true' }, plus one classifier readLeaseDeviceSpec(source) -> { spec, invalidKeys } replace most of this? The CLI readers, the proxy allocate and the daemon router would all call it. The router would refuse the keys in spec and invalidKeys, and the CLI would raise INVALID_ARGS on invalidKeys. ENV_EXCLUDED_FLAG_KEYS, the remote-config env: false entries, leaseDeviceSpecFields, the proxy profile and the bindLeaseNamedDevice deletion map could then all map over the keys. The physical === true special case and the per-key deletes would go away, and this split could not recur for a fourth key. What would have to change first is the decision on whether a blank string or physical: false is invalid or not named, and then LeaseDeviceSpec.physical is typed to match.

The two open inline threads still stand: the blank osVersion/imageTag and physical mismatch (P2, same root as above) and the "lease was released" message (P3, unchanged and fine to leave). No open thread can be resolved yet.

The Coverage job failed in the Run coverage step, and its log ends without a failing test or a vitest summary. The previous head 4f94d93 failed the same way, and main is green at d217300. The PR touches paths that run exercises (the daemon request-router refusal, the daemon-client allocate flags and the CLI connection runtime), so I cannot rule out an overlap, but no failing case names this diff. Please rerun Coverage after the fix and share the failing case if it fails again.

I did not run any tests. The MCP/SDK route above comes from reading the code, not from an executed call. The honoured path is proven in the repo only against createTestClient fakes. The PR mentions a live Simlock run for --physical, but I could not see its output, and --os and --image-tag have no live run. I also do not know whether Simlock reads physical: false as virtual only or as no preference, and that decides which side of the rule is right.

Before merge, the CLI and the daemon must agree on which spec values name a key, which covers blank strings and now physical: false, and Coverage must pass or show a failing case.

@janjankowski-cs

Copy link
Copy Markdown

Tested at df790b73a against the iPhone 17 Pro simulator, through agent-device proxy and a stub backend that strips the spec keys and echoes a fake lease.device. The stub doesn't actually choose a device by spec.

Step What was checked Result
0 Worktree at df790b73a, pnpm install --frozen-lockfile && pnpm build ✅
1 --os, --image-tag and --physical appear in open --help and help remote ✅
2 A local daemon refuses --os and --physical with UNSUPPORTED_OPERATION, device-spec-unsupported, and the correct keys ✅
2c An empty --os "" is dropped and open proceeds normally ✅
3 A plain agent-device proxy refuses --os the same way ✅
3 check A plain open straight afterwards succeeds, so no lease was left behind ✅
4 Through the stub, the spec is sent on the allocate and stripped before the daemon. Output shows Leased device: Stub Device, 18.6, virtual; --json includes lease.device; snapshot works and doesn't carry the spec ✅
5a open again with the same --os is served ✅
5b open with a different --os fails with CONNECTION_DEVICE_CONFLICT, keys: ["osVersion"] ✅
5c open with a different --udid and no spec ⚠️ UNAUTHORIZED / LEASE_SCOPE_MISMATCH instead of CONNECTION_DEVICE_CONFLICT
5c′ open with a different --udid plus --os fails with CONNECTION_DEVICE_CONFLICT ✅
5d open naming no spec is served on the existing lease ✅
6 connect proxy --os 18.6 stores the spec, and a later plain open sends it ✅
7 A backend that doesn't echo lease.device: the lease is released and the command fails with device-spec-unsupported, released: true; a plain open afterwards succeeds ✅

About 5c: a --udid mismatch with no spec takes the old path where the CLI chooses the device itself, so the new identity check never runs. The request is still refused and can't land on the wrong device, but the error code doesn't match the PR description, which says any --udid/--serial mismatch gives CONNECTION_DEVICE_CONFLICT. The tests only cover --udid combined with a spec.

@thymikee

Copy link
Copy Markdown
Member

Thanks for the live run at df790b7, it is useful evidence for the proxy path. It does not change the open finding from the last review, because the code is the same: step 2c shows the CLI drops --os "", while SDK/MCP still send blank or physical: false values to the daemon, which refuses them. Please make one function in lease-scope.ts decide named, not named or invalid for every surface, then re-run steps 2 and 2c through the CLI and through MCP or the SDK with the same inputs. Step 5c (LEASE_SCOPE_MISMATCH instead of CONNECTION_DEVICE_CONFLICT for a different --udid with no spec) is worth a sentence in the PR body on whether that is the intended error.

@janjankowski-cs

Copy link
Copy Markdown

@thymikee #4009

* origin/main: (76 commits)
  fix(open): refuse --launch-console on a provider-owned device before the relaunch close (#3938)
  fix(test-app): keep the inert surface control above the settings fold (#3993)
  fix(fill): redact the read-back of a sensitive fill (#4014)
  fix(apple): read the runner cache toolchain key from Xcode's own files (#3995)
  docs: point stable test suites to e2e (#4010)
  docs(mutation): state the file-name rule that marks test support code (#4011)
  refactor(kernel): read the Action Button leaf from isHandheldAppleDevice (#3988)
  fix(ios): match alert labels through the OS prompt strings of the preferred languages (#4006)
  fix(mutation): measure each kernel against the tests that import it (#3994)
  fix(webdriver): avoid unused pre-tap focus geometry reads (#3997)
  refactor(apple): share coordinate drag execution (#4003)
  fix(apple): type a text-entry-trait element as a text input in the simulator AX tree (#4008)
  refactor(contracts): own the unsupported-interactor stub both platforms duplicated (#4002)
  refactor: reuse canonical device identity projections (#3996)
  refactor(apple): share simulator status-bar diagnostics (#3998)
  refactor(runtime): share host command runner construction (#3999)
  refactor(android): centralize session error encoding (#4000)
  chore: mark 0.21.33 as released
  chore(gates): detect new native duplication with jscpd (#3980)
  fix(android): publish the Compose role on its clickable parent instead of a fake child (#3983)
  ...
@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
agent-device-website Ignored Ignored Preview Oct 28, 2026 5:09pm UTC

Request Review

…ce (#4009)

* fix(proxy): one decision on what a device spec names, for every surface

The CLI dropped a blank --os or --image-tag and physical: false, while the
daemon gate refused any spec key that was not undefined, so the SDK and MCP
were refused for the same inputs the CLI accepted.

readLeaseDeviceSpec now decides each key is named, not named, or invalid,
and the daemon gate, the lease allocate params and the CLI all read it:
- a non-blank osVersion or imageTag and physical: true are named;
- an absent key, a blank string and physical: false name nothing;
- any other type is refused with INVALID_ARGS, reason device-spec-invalid.

namedLeaseDeviceSpecKeys is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(daemon): the device-spec gate lets a command naming no spec through

The SDK and MCP send command flags to the daemon as given, so a blank
osVersion or imageTag and physical: false reach the gate unchanged. The
lease-allocate test did not cover that path: readLeaseAllocateProviderFlags
drops those values on both sides before the gate runs.

Under the pre-fix gate rule (any key not undefined is named), both cases
fail: the not-named request is refused, and physical: false joins keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update docs/adr/0007-remote-device-leases.md

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update packages/contracts/src/lease-scope.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update website/docs/docs/remote-proxy.md

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* test(proxy): cover a wrong-typed spec key at the gate; align the ADR refusal rule; assert.throws cleanup;

* docs(adr): state each device-spec rule once in ADR 0007

* fix(proxy): use a named device-spec value as given, and store no connect proxy default for a blank one

readLeaseDeviceSpec no longer trims a named osVersion or imageTag. No producer pads these
values: the CLI parser, the remote config reader and the SDK/MCP input all pass strings as
given, and the other lease allocate flags are not trimmed either. A blank string still names
nothing.

connect proxy now builds its profile spec through requireLeaseDeviceSpec, so a blank --os or
--image-tag is not persisted. Before, the generated profile stored "" and failed its own
re-read with "Expected non-empty string".

The flag input descriptions, CLI help, ADR 0007 and the remote proxy docs state the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(daemon): name the device-spec gate test for request-router

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(proxy): state which device-spec values a daemon refuses, accepts, or rejects as invalid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(proxy): name the unhonoured-spec refusal explicitly instead of "the same reason"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update docs/adr/0007-remote-device-leases.md

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix(proxy): keep the device-spec flags and readers in modules the CLI already loads

flag-groups declared LEASE_DEVICE_SPEC_FLAGS by importing lease-scope, which
pulled lease-scope and three kernel modules into the flag-groups closure. The
list is now declared in flag-groups and pinned to LEASE_DEVICE_SPEC_KEYS by a
type-only satisfies.

lease-device-spec.ts was a new eager module in the CLI closure. Its field set
moves to command-input.ts beside repeatedFields(), and its reader moves to
cli-grammar/common.ts beside the other flag readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(proxy): state each device-spec rule once in help remote and remote-proxy.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): read structured device-spec input through the shared decision

The open, install, reinstall and uninstall input fields read osVersion and
imageTag as non-empty strings, so MCP refused a blank value with a generic
INVALID_ARGS before readLeaseDeviceSpec ran, and a wrong type got no
device-spec-invalid reason. The fields now read through requireLeaseDeviceSpec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>

This branch has not been deployed

No deployments
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.

Proxy lease by device spec: --os and --image-tag, honoured or refused by the backend

4 participants