fix(proxy): one decision on what a device spec names, for every surface
One decision on what a device spec names
Problem
Two parts of the code decided independently whether a request named a device spec, and they disagreed:
| Code | Old rule | osVersion: "" |
|---|---|---|
CLI (readLeaseDeviceSpec) | blank strings and physical: false don't count | dropped → open proceeds |
Daemon gate (namedLeaseDeviceSpecKeys) | anything not undefined counts | refused |
The CLI cleaned the values before sending, so it never hit the difference. The SDK and MCP send values unchanged, so the daemon refused "" and physical: false with device-spec-unsupported.
The rule
readLeaseDeviceSpec in lease-scope.ts is now the only place that decides. Each key gets exactly one of three outcomes:
| Value | Outcome |
|---|---|
"18", " >=26 " (sent as given), physical: true | named |
missing, "", " ", physical: false | not named |
osVersion: 26, physical: "yes", imageTag: null | invalid (wrong type) |
Changes
1. readLeaseDeviceSpec (lease-scope.ts)
for (const key of LEASE_DEVICE_SPEC_KEYS) {
const value = source?.[key];
if (value === undefined) continue; // missing → not named
if (typeof value !== (key === 'physical' ? 'boolean' : 'string'))
invalidKeys.push(key); // wrong type → invalid
else if (value === true) spec[key] = true; // physical: true → named
else if (typeof value === 'string' && value.trim())
spec[key] = value; // non-blank string → named, as given
} // anything left ("" or false) → not named
It returns { spec, keys, invalidKeys }. The old version returned only spec and silently ignored wrong types. namedLeaseDeviceSpecKeys is removed.
2. deviceSpecInvalidError (lease-scope.ts)
A wrong type is a caller mistake, not a missing feature, so it is refused with INVALID_ARGS and the typed reason device-spec-invalid, with details.keys, instead of device-spec-unsupported.
3. requireLeaseDeviceSpec (lease-scope.ts)
The same decision for client-side sources: it throws device-spec-invalid on an invalid key, so the CLI fails before any request is sent. The CLI callers use it:
proxy-lease-device.ts: deciding whether to allocate by spec, and the spec check on a live lease.connection-runtime.ts: the spec sent onlease.allocate.lease-device-spec.ts: the command readers foropen,install,reinstallanduninstall.
4. Daemon gate (request-router.ts)
const { keys, invalidKeys } = readLeaseDeviceSpec(req.flags);
if (invalidKeys.length > 0) return invalid(invalidKeys); // wrong type → INVALID_ARGS
if (keys.length === 0) return undefined; // "" / false → allowed through
return unsupported(keys); // real spec → refused as before
This fixes the SDK and MCP: the daemon now ignores "" and false the same way the CLI does.
5. Lease request params (readLeaseAllocateProviderFlags)
| Before | After | |
|---|---|---|
| Named key | sent as given | sent as given |
"" or false | sent | dropped |
| Wrong type | sent | sent unchanged, so the daemon still refuses it |
An invalid value is sent unchanged on purpose: dropping it here would let a bad request through without an error.
6. Tests and docs
lease-scope.test.ts: covers all three outcomes. The old assertion thatphysical: 'yes'names nothing is replaced, because that behaviour changed.agent-device-client-lease-device-spec.test.ts: the SDK against a real daemon. Blank values andphysical: falseallocate a lease;physical: 'yes'is refused withdevice-spec-invalidand leaves no lease.help remote,remote-proxy.mdand ADR 0007 each state the rule in one sentence.
Update: CI and module loading
- Trimming was removed in 1afb732: a named
osVersionorimageTagis sent exactly as given. Step 4 of the validation below ran before that change. feat/proxy-device-spec(#3865) was behind main, sodaemon-wire-compatcompared this branch against v0.21.33 without #3958. Main is merged into #3865, and #3865 is merged into this branch.- Coverage failed on
eager-closure-budgets(inherited from #3865):flag-groups.tsimportedlease-scope.tsat runtime, so it loaded 5 modules instead of 1. It now declaresLEASE_DEVICE_SPEC_FLAGSitself, pinned toLEASE_DEVICE_SPEC_KEYSwith a type-onlysatisfies.lease-device-spec.tswas a new eager module in the CLI closure. Its field set moved tocommand-input.tsasleaseDeviceSpecFields(), next torepeatedFields(). Its reader moved tocli-grammar/common.ts, next to the other flag readers.
help remoteandremote-proxy.mdnow state each device-spec rule once.
Validation
Steps 2 and 2c of the manual test, re-run with the same inputs on the iPhone 17 Pro simulator against a local daemon:
| Input | CLI | SDK before | SDK after |
|---|---|---|---|
osVersion: "18" | device-spec-unsupported [osVersion] | same | same ✅ |
physical: true | device-spec-unsupported [physical] | same | same ✅ |
osVersion: "" | opened | ❌ refused | opened ✅ |
osVersion: " " | opened | ❌ refused | opened ✅ |
physical: false | not expressible¹ | ❌ refused | opened ✅ |
physical: "yes" | not expressible¹ | device-spec-unsupported | INVALID_ARGS device-spec-invalid ✅ |
osVersion: 26 | not expressible¹ | device-spec-unsupported | INVALID_ARGS device-spec-invalid ✅ |
¹ The CLI parser types the flags, so it can't produce these values. --physical=false fails earlier with Flag --physical does not take a value, and leaving out --physical is how the CLI says false.
| Step | What was checked | Result |
|---|---|---|
| 0 | pnpm install --frozen-lockfile && pnpm build on pr-3865 plus the change | ✅ |
| 1 | --os, --image-tag and --physical appear in open --help; help remote includes the new device-spec-invalid sentence | ✅ |
| 2 (CLI) | --os 18 and --physical → device-spec-unsupported; --os "", --os " " and --image-tag "" → open proceeds | ✅ |
| 2 (SDK) | osVersion: "18" and physical: true → device-spec-unsupported; "", " " and physical: false → open proceeds; physical: "yes" and osVersion: 26 → INVALID_ARGS, device-spec-invalid | ✅ (failed before the change) |
| 3 | A plain agent-device proxy refuses --os 18; a plain open straight afterwards succeeds, so no lease was left behind | ✅ |
| 4 | Through the stub, the output shows Leased device: Stub Device, 18.6, virtual; --os ' 18.6 ' reaches the backend trimmed to "18.6"; snapshot works and doesn't carry the spec | ✅ |
| 5a | open with the same --os on a live lease is served | ✅ |
| 5b | open with a different --os → CONNECTION_DEVICE_CONFLICT, keys: ["osVersion"] | ✅ |
| 5c | open with a different --udid and no spec | ⚠️ LEASE_SCOPE_MISMATCH (unchanged - unrelated to this change) |
| 5c′ | open with a different --udid plus --os → CONNECTION_DEVICE_CONFLICT | ✅ |
| 5d | open naming no spec is served on the existing lease | ✅ |
| 5+ | open with --os "" on a live spec lease is served, because a blank value names nothing | ✅ (new case) |
| 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 (released: true) and the command fails with device-spec-unsupported; a plain open afterwards succeeds | ✅ |