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

Merged
JA
1h ago
+365-93

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:

CodeOld ruleosVersion: ""
CLI (readLeaseDeviceSpec)blank strings and physical: false don't countdropped → open proceeds
Daemon gate (namedLeaseDeviceSpecKeys)anything not undefined countsrefused

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:

ValueOutcome
"18", " >=26 " (sent as given), physical: truenamed
missing, "", " ", physical: falsenot named
osVersion: 26, physical: "yes", imageTag: nullinvalid (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 on lease.allocate.
  • lease-device-spec.ts: the command readers for open, install, reinstall and uninstall.

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)

BeforeAfter
Named keysent as givensent as given
"" or falsesentdropped
Wrong typesentsent 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 that physical: '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 and physical: false allocate a lease; physical: 'yes' is refused with device-spec-invalid and leaves no lease.
  • help remote, remote-proxy.md and ADR 0007 each state the rule in one sentence.

Update: CI and module loading

  • Trimming was removed in 1afb732: a named osVersion or imageTag is sent exactly as given. Step 4 of the validation below ran before that change.
  • feat/proxy-device-spec (#3865) was behind main, so daemon-wire-compat compared 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.ts imported lease-scope.ts at runtime, so it loaded 5 modules instead of 1. It now declares LEASE_DEVICE_SPEC_FLAGS itself, pinned to LEASE_DEVICE_SPEC_KEYS with a type-only satisfies.
    • lease-device-spec.ts was a new eager module in the CLI closure. Its field set moved to command-input.ts as leaseDeviceSpecFields(), next to repeatedFields(). Its reader moved to cli-grammar/common.ts, next to the other flag readers.
  • help remote and remote-proxy.md now 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:

InputCLISDK beforeSDK after
osVersion: "18"device-spec-unsupported [osVersion]samesame ✅
physical: truedevice-spec-unsupported [physical]samesame ✅
osVersion: ""opened❌ refusedopened ✅
osVersion: " "opened❌ refusedopened ✅
physical: falsenot expressible¹❌ refusedopened ✅
physical: "yes"not expressible¹device-spec-unsupportedINVALID_ARGS device-spec-invalid ✅
osVersion: 26not expressible¹device-spec-unsupportedINVALID_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.


StepWhat was checkedResult
0pnpm 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)
3A plain agent-device proxy refuses --os 18; a plain open straight afterwards succeeds, so no lease was left behind✅
4Through 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✅
5aopen with the same --os on a live lease is served✅
5bopen with a different --os → CONNECTION_DEVICE_CONFLICT, keys: ["osVersion"]✅
5copen 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✅
5dopen 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)
6connect proxy --os 18.6 stores the spec, and a later plain open sends it✅
7A 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✅