Skip to content

feat(host): request devices by shape - #3273

Draft
vkuprin wants to merge 9 commits into
callstack:mainfrom
vkuprin:feat/host-device-shape
Draft

vkuprin wants to merge 9 commits into
callstack:mainfrom
vkuprin:feat/host-device-shape

Conversation

@vkuprin

@vkuprin vkuprin commented Oct 7, 2026 •

Copy link
Copy Markdown

Stacked on #3272. Please review only the top three commits.

Summary

On Host, a worker requests a device by type (ADR 0021 §5):

agent-device open com.example.app --platform ios --device "iPhone 16"

If authenticated health reports device-shape, the client allocates by type instead of reading inventory, then addresses the leased device by UDID or serial. Anything else on a Host is refused, typed, before any lease request:

  • no --device, or a UDID or serial;
  • a missing token;
  • a Host without the feature;
  • another type on a session that holds one.

Plain proxy behaves as before.

The daemon validates a Host-principal lease_allocate, calls the new HostShapeAllocator seam, and publishes the lease bound to the returned device. It gives the allocation back if publishing fails or the requester left. /health advertises device-shape exactly when an allocator is set. No protocol bump.

The seam is proposed for #3269; nothing configures it yet, so tests use the scripted fake.

Closes #3267. 19 files, 924 gross lines vs #3272. Proxy lease device selection moves out of connection-runtime.ts (now under 1,000 lines).

Validation

Tested commit d3b9856c2:

  • pnpm check:affected --run: all pass except a flaky affected-selector test (ENOTEMPTY on temp cleanup; unchanged from main). The 17 checks after it pass when run separately.
  • pnpm check:daemon-wire-compat passed.
  • Tests: pre-lease refusals make no allocate call or inventory read; the leased UDID is pinned; the allocator gets the type and principal; cancellation and failed publication release the allocation.

@thymikee

thymikee commented Oct 7, 2026

Copy link
Copy Markdown
Member

Nothing in the code at 2a55127 blocks merge, but one design question below is open, and the lease lifecycle needs settling with #3269. At this head, CI is green (1 check, passing) and there are no conflicts. I did not run the tests or check:daemon-wire-compat, so the PR body's pass claims are unverified. I also reviewed only commits 6b542a9 and 2a55127, so the #3272 base is not re-reviewed. I did not check the eager-closure budget claim for the dynamic import at connection-runtime.ts:949.

Not blocking, and you can take or leave these. First, a Host lease can leak its device: allocateHostShapeLease calls HostShapeAllocator.release only when publishing to the registry fails. A normal lease_release, TTL expiry, or a requester that cancels mid-allocate never releases a device Simlock provisioned. The rule should be that every Host lease that reaches the registry releases its allocator device on every path that ends the lease. That belongs on the owning lifecycle, as LeaseLifecycleProvider does for allocate, heartbeat and release, not on one catch block. Second, two separate options decide two linked facts in http-server.ts: the device-shape feature and the allocator. A daemon can advertise device-shape with no allocator, so the client pre-check passes and the daemon then refuses with host-shape-allocation-unavailable. Deriving the feature from allocator presence at the one construction site would make that mismatch impossible. Third, the tests are loose: the plain-proxy test in host-device-shape-connection.test.ts uses a bare rejects.toThrow(), and the "given back" test in lease.test.ts:533 has no predicate. Both should pin the error code production raises. The Host front-end feature lift at host-front-end.ts:78-81 has no test.

Could the Host branch be simpler? It copies part of the allocate lifecycle (deadline, signal, release on failure) beside the LeaseLifecycleProvider path, and it drops that path's cancel and work-retention handling. Would one allocate path work, where the shape allocator resolves the deviceKey before leaseRegistry.allocateLease and the existing runDeviceMutation, cancel and release machinery plus a release hook own the rest?

Before merge, please settle the HostShapeAllocator contract with #3269 so it covers release on lease end, expiry and cancel, and so advertising follows allocator presence. Do this before any Simlock implementation lands on the seam. After that, the tests and check:daemon-wire-compat need a run on the final commit.

Add `agent-device host`, the Host front-end from ADR 0021 §3. It runs as
its own process, starts or reuses the local HTTP daemon, and serves it to
remote verification workers through the daemon proxy.

Workers authenticate with one persistent service credential. Host creates
it on first start at <state dir>/host/service-credential.json (directory
0700, file 0600) and reuses it after every restart. A malformed or
group/other-readable file stops startup with a typed reason, and so does a
TLS file Host cannot read. Both checks run before any daemon starts.
--tls-cert and --tls-key serve HTTPS. A wildcard bind advertises the
machine's hostname, since workers cannot dial 0.0.0.0.

The proxy command behaves as before. Its daemon startup and listen helpers
move into a module both commands use.

Closes callstack#3265
- Host checks that the TLS certificate and key load together, and that
  the key is mode 0600, before any daemon starts. A bind other than
  loopback without TLS is refused (host-tls-required), so the service
  token never travels in cleartext.
- A new credential reaches disk only once Host is serving, so a start
  that fails earlier never hides the token from the next one. A
  credential another start wrote first is refused (host-credential-raced).
- A credential that is a link or unreadable gets a typed reason, and
  platforms without POSIX ownership skip the mode check.
- The advertised URL keeps the host name the operator bound to, and the
  worker command in the startup output names the real URL and token.
- The proxy command keeps its original code. Host's daemon and listen
  helpers live in src/cli/host/local-daemon.ts.
- The host help topic moves into its own module.
Add `host` to the reviewed device-claim policy set, give the
--tls-cert/--tls-key flags their own Host bucket in the integration
progress model, list `hostCommand` with the dynamically loaded CLI handlers
in the fallow production exemptions, and waive the operator-facing `host`
help topic from the help benchmark.
Host now owns identity and the public route policy (ADR 0021 §6):

- The front-end drops every identity a client claims (tenant headers and
  body fields) and sends the credential's principal to the daemon as
  x-agent-device-principal on the daemon-token loopback request.
- The daemon reads that header only after the daemon token matched and
  treats it as an attested tenant, so sessions are isolated under the
  principal and req.internal.hostPrincipal carries it to admission. With an
  auth hook configured the header is refused with a typed reason.
- Administration routes, macos-app allocation, inputs naming a path on the
  Host machine and allowDownload are refused with 403 and a typed
  details.reason. The host-path inputs move out of the macos-app lease into
  one declaration both policies read.
- Anonymous /health shows only ok, service and rpcProtocolVersion.

The principal handoff is the contract proposed to the lease side on callstack#3264.

Closes callstack#3266
- The proxy gains an optional admitRpc hook that runs on an authorized
  /rpc request with its params already parsed. Host uses it instead of
  re-reading the body and repeating the token check, so oversized and
  unauthorized requests get the proxy's own answers. Plain proxy sets no
  hook and behaves as before.
- Path positionals come from each command's schema (`path`, `appOrPath`,
  `payloadOrJson`) instead of a hand-kept table, so trace, push and
  session save-script are covered. URLs no longer exempt a positional, an
  upload id exempts only install and reinstall, and only the exact temp
  locations the remote client writes are accepted.
- Batch steps are checked one by one, and replay and test are refused
  (host-script-refused) because Host cannot check their nested actions.
- launchConsole counts as a Host path, macos-app is matched the way the
  daemon normalizes it, and /admin/* is simply not served.
- The daemon reads the principal header only on a request that holds the
  daemon token, so an unauthenticated caller learns nothing about an
  auth hook.
- Host answers with an error response instead of rejecting.
… changes

DaemonHealthPayload widens `service` with 'agent-device-host', and the
auxiliary HTTP authorizer reads x-agent-device-principal after the daemon
token check. Both are additive under ADR 0006: released peers send and parse
the same bytes as before.
On Host, `--device "<type>"` names a device type Host allocates a fresh
device for (ADR 0021 §5), instead of a name resolved against inventory.

- The client asks the endpoint's health first, through the cache the RPC
  transport already fills. On `service: agent-device-host` it skips the
  inventory lookup and allocates with the type, platform and --os-version in
  the device-selection fields lease.allocate already carries.
- A Host that does not advertise the `device-shape` feature fails with
  host-shape-unsupported before any lease request (§8). Plain proxy is
  unchanged.
- The daemon builds a strict { platform, deviceType, osVersion? } shape for
  a lease_allocate that carries a Host principal, calls the new
  HostShapeAllocator seam, and only then publishes the Host lease bound to
  the returned device; an allocation it cannot publish is given back.
  A UDID or serial, or a daemon with no allocator, is refused with a typed
  reason.

The seam is the lease side's to implement (callstack#3269). Nothing configures it in
production yet; the tests drive it through the scripted allocator fake.

Closes callstack#3267
…ot allocate

- After a by-type allocation, the command addresses the device the lease
  bound (its UDID or serial) instead of a name another simulator may
  share.
- On a Host, every lease-allocating command is checked before any lease
  request. A missing --device, a UDID or serial, a missing token
  (host-unauthenticated) and a different type on a session that already
  holds one (host-shape-mismatch) are typed refusals. The platform comes
  from the connection when --platform is absent.
- The daemon validates the lease scope before it provisions anything,
  gives an allocation back when the requester has left, and keeps the
  original error if giving it back fails. --os-version must be a string.
- The daemon advertises device-shape exactly when it has an allocator,
  and the Host service name is one shared constant.
- The proxy lease device selection moves into its own module, loaded on
  demand, so connection-runtime.ts stays under 1,000 lines.
List DaemonHealthFeature, DAEMON_HOST_DEVICE_SHAPE_FEATURE and
DAEMON_HOST_SERVICE in the wire manifest, and acknowledge the additive
`features` field on the health payload, its builder and the client's health
parser under ADR 0006.
@vkuprin
vkuprin force-pushed the feat/host-device-shape branch from 5d30071 to d3b9856 Compare October 7, 2026 05:26
@thymikee

thymikee commented Oct 7, 2026

Copy link
Copy Markdown
Member

The PR is ready at d3b9856. The earlier gap is fixed: the requester-left path now cancels before the allocator is touched, and the new test covers it.

Not blocking: the requester-left test at https://github.com/callstack/agent-device/blob/d3b9856/src/daemon/handlers/__tests__/lease.test.ts#L707 uses assert.rejects with no predicate, so any throw passes, including one from the earlier host-shape-invalid or unavailable branches. You can assert error.code === 'COMMAND_FAILED' or the code that createRequestCanceledError produces, or leave it as is.

CI shows 1 check passing and none failing, and there are no conflicts. I did not run the tests or check:daemon-wire-compat, so the pass claims in the PR body for d3b9856 are unverified here. No HostShapeAllocator is configured in production, so the device path cannot be reached and no live run is possible or needed. I reviewed only this PR's delta over #3272, not the #3272 base.

One thing is unchanged from the earlier review and out of scope here. The end of a published Host lease never reaches allocator.release, whether it ends by lease_release, TTL expiry, or a response the client never received after the cancel check. Host heartbeats also do not renew the allocator TTL. The updated HostShapeAllocator.release doc limits release to unpublished allocations, so lease-end ownership rests with #3269.

Before a Simlock implementation lands on this seam, please settle with #3269 which side releases the allocator device and renews its TTL when a published Host lease is released, expires or is heartbeated. Please also re-run the tests and check:daemon-wire-compat on the final commit.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 7, 2026

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

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Host: request devices by shape (--device "iPhone 16" / "Pixel 7")

2 participants