From 9a3474466046e9432b35eee029940316e4c834fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 12:59:45 +0200 Subject: [PATCH 01/16] feat: add integrated device leasing --- CONTEXT.md | 11 + docs/adr/0007-remote-device-leases.md | 47 +++ plans/001-device-aware-lease-contracts.md | 280 ++++++++++++++++ ...02-automatic-direct-proxy-lease-on-open.md | 308 ++++++++++++++++++ ...-daemon-session-lease-admission-cleanup.md | 269 +++++++++++++++ .../004-runner-diagnostics-and-proxy-docs.md | 225 +++++++++++++ plans/README.md | 85 +++++ src/__tests__/remote-connection.test.ts | 281 +++++++++++++++- src/cli.ts | 31 +- src/cli/cloud-connection-profile.ts | 72 +--- src/cli/commands/connection-runtime.ts | 155 ++++++++- src/cli/commands/connection.ts | 76 ++++- src/cli/generated-remote-config.ts | 74 +++++ src/cli/proxy-connection-profile.ts | 88 +++++ src/client-normalizers.ts | 35 +- src/client-types.ts | 21 ++ src/client.ts | 4 + src/contracts.ts | 56 ++++ src/core/dispatch-context.ts | 2 + src/core/dispatch.ts | 1 + src/core/interactor-types.ts | 3 + src/core/runner-lease-context.ts | 8 + src/daemon-client-rpc.ts | 4 + src/daemon-runtime.ts | 5 + src/daemon/__tests__/lease-context.test.ts | 113 +++++++ src/daemon/__tests__/lease-registry.test.ts | 145 +++++++++ .../__tests__/request-execution-scope.test.ts | 190 ++++++++++- .../__tests__/request-handler-catalog.test.ts | 62 ++++ .../__tests__/request-router-open.test.ts | 152 ++++++++- src/daemon/__tests__/session-store.test.ts | 26 ++ src/daemon/context.ts | 4 + src/daemon/daemon-command-registry.ts | 4 +- src/daemon/handlers/lease.ts | 11 + src/daemon/handlers/session-close.ts | 28 +- src/daemon/handlers/session-open.ts | 13 + src/daemon/handlers/session.ts | 4 + src/daemon/http-server.ts | 4 + src/daemon/lease-context.ts | 134 ++++++++ src/daemon/lease-registry.ts | 302 ++++++++++++++--- src/daemon/request-admission.ts | 86 ++++- src/daemon/request-execution-scope.ts | 68 +++- src/daemon/request-handler-chain.ts | 1 + src/daemon/types.ts | 12 + .../ios/__tests__/runner-session.test.ts | 106 ++++++ src/platforms/ios/interactions.ts | 1 + src/platforms/ios/runner-lease.ts | 35 +- src/platforms/ios/runner-provider.ts | 2 + src/platforms/ios/runner-session-types.ts | 2 + src/platforms/ios/runner-session.ts | 40 ++- src/remote-config-core.ts | 6 +- src/remote-config-schema.ts | 16 +- src/remote-connection-state.ts | 28 ++ src/utils/__tests__/args.test.ts | 54 +-- src/utils/cli-help.ts | 44 ++- website/docs/docs/remote-proxy.md | 37 +-- 55 files changed, 3633 insertions(+), 238 deletions(-) create mode 100644 docs/adr/0007-remote-device-leases.md create mode 100644 plans/001-device-aware-lease-contracts.md create mode 100644 plans/002-automatic-direct-proxy-lease-on-open.md create mode 100644 plans/003-daemon-session-lease-admission-cleanup.md create mode 100644 plans/004-runner-diagnostics-and-proxy-docs.md create mode 100644 plans/README.md create mode 100644 src/cli/generated-remote-config.ts create mode 100644 src/cli/proxy-connection-profile.ts create mode 100644 src/core/runner-lease-context.ts create mode 100644 src/daemon/__tests__/lease-context.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index cf19f19e4..2a5464dd6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,6 +15,17 @@ - Modality: broad supported device family, such as mobile, tv, or desktop. - Session: daemon-owned state for a selected target and opened app or surface. - Recording backend: daemon-internal module interface selected per recording target that owns platform recording validation, output path policy, start/stop execution, and record-only cleanup below the daemon recording lifecycle. +- Device lease: logical remote ownership of one selected device for a + tenant/run/client and lease provider, separate from platform helper process + locking. +- Device key: stable provider-scoped device identity used for lease contention, + such as a simulator UDID, physical device id, or provider inventory id. +- Lease provider: remote connection source that routes and owns a device lease, + such as `proxy`, cloud bridge, or `limrun`. +- Direct proxy client id: optional remote proxy client identity used to bind + lease activity to the agent that acquired the selected device. +- Runner/process lease: backend helper mutual-exclusion guard for platform + runners or tools; it is not the remote client ownership boundary. - Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints. - Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution. - Runner command traits: per-command-type classification for iOS/macOS runner lifecycle behavior, distinct from the public command surface and daemon command registry. The Swift runner traits classify interaction, read-only, and runner-lifecycle axes for XCTest execution; Swift resolves the alert command as read-only only for its `get` action. The TypeScript runner command traits classify daemon-side runner send/recovery policy such as read-only retry routing, readiness probes, and recent-healthy-mutation preflight skips; the TypeScript table is command-type keyed and currently classifies alert as read-only for daemon retry policy. Each side keeps one source of truth keyed by runner command type. diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md new file mode 100644 index 000000000..91c78c301 --- /dev/null +++ b/docs/adr/0007-remote-device-leases.md @@ -0,0 +1,47 @@ +# ADR 0007: Remote Device Leases + +## Status + +Accepted + +## Context + +Remote daemon users need a clear ownership boundary before a command reaches a +platform runner or helper. The existing lease model can bind a tenant/run to a +backend, but direct proxy and hosted providers also need to identify the selected +device and the connection provider that owns it. + +Runner and helper processes already have backend-specific mutual exclusion. That +guard protects platform tooling, not remote client ownership, so surfacing those +errors directly makes device contention harder to recover from. + +## Decision + +A remote device lease is logical ownership of one selected device by one remote +agent/client for a connection provider such as `proxy`, a cloud bridge, or +`limrun`. + +`connect` establishes a connection profile and client identity. Lease allocation +remains lazy and happens only when a device, backend, and provider are known. + +A runner/process lease is a backend helper guard and is not a user/client +ownership boundary. It stays below daemon device leases and should not be +weakened or replaced by them. + +`open` is the natural point to acquire a device lease because target resolution +and session creation meet there. Commands after `open` must refresh the lease; +no activity for five minutes should make the device available again. + +The proxy process is expected to be long-lived and self-serve. Recovery from a +stale or expired device lease should not require restarting the proxy. + +## Consequences + +Device contention can fail before platform execution with an explicit +device-lease error that includes the backend, provider, selected device key, and +owning lease expiry. + +Backend-only leases remain valid for older remote clients. Device and provider +fields are optional until provider-aware `open` acquisition and admission +refreshes are implemented. + diff --git a/plans/001-device-aware-lease-contracts.md b/plans/001-device-aware-lease-contracts.md new file mode 100644 index 000000000..81fef2a3d --- /dev/null +++ b/plans/001-device-aware-lease-contracts.md @@ -0,0 +1,280 @@ +# Plan 001: Add device-aware lease contracts + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report; do not improvise. When done, update the status row for this plan in +> `plans/README.md`. +> +> **Drift check (run first)**: +> `git diff --stat 04f53bbc0..HEAD -- CONTEXT.md docs/adr src/contracts.ts src/client-types.ts src/remote-config-schema.ts src/remote-connection-state.ts src/daemon/lease-context.ts src/daemon/lease-registry.ts src/daemon/handlers/lease.ts src/daemon/http-server.ts src/daemon/__tests__/lease-registry.test.ts src/daemon/__tests__/request-handler-catalog.test.ts` +> +> If any in-scope file changed since this plan was written, compare the +> "Current state" excerpts against the live code before proceeding; on a +> mismatch, treat it as a STOP condition. + +## Status + +- **Priority**: P1 +- **Effort**: M +- **Risk**: HIGH +- **Depends on**: none +- **Category**: correctness | architecture +- **Planned at**: commit `04f53bbc0`, 2026-06-26 + +## Why this matters + +The current lease model can say "tenant/run has an iOS simulator allocation", +but it cannot say "client A owns device C25D... through provider proxy for the +next five minutes". Direct proxy needs that contract, and the limrun worktree +already adds a `leaseProvider` dimension for provider-backed runtime routing. +Without provider-aware device leases, the daemon cannot reject a second agent +before it reaches the iOS runner, and the user sees a backend helper ownership +error instead of a clear device contention error. + +## Current state + +- `src/daemon/lease-registry.ts` owns lease state. Today `SimulatorLease` + contains `leaseId`, `tenantId`, `runId`, `backend`, `createdAt`, + `heartbeatAt`, and `expiresAt`. +- The limrun integration worktree at + `/Users/thymikee/.codex/worktrees/20c8/agent-device` extends the same lease + model with `provider`/`leaseProvider` and routes Limrun inventory by provider + plus lease id. Preserve that dimension if it has landed before this plan is + executed. +- `src/daemon/lease-registry.ts` currently binds leases by + `tenantId:runId:backend`, via `bindingKey(tenantId, runId, backend)`. +- `src/daemon/lease-registry.ts` currently enforces capacity with + `maxActiveSimulatorLeases` and only counts `backend === 'ios-simulator'`. +- `src/daemon/handlers/lease.ts` routes `lease_allocate`, `lease_heartbeat`, + and `lease_release` into `LeaseRegistry`. +- `src/contracts.ts` exposes lease payloads and schemas. Current metadata + includes `tenantId`, `runId`, `leaseId`, `leaseTtlMs`, `leaseBackend`, and + `sessionIsolation`, but not device or client identity. If the limrun worktree + has landed, it also includes `leaseProvider`; keep it optional and + provider-neutral. +- `src/remote-connection-state.ts` persists `tenant`, `runId`, `leaseId`, + `leaseBackend`, platform, target, and runtime hints for `connect`. If the + limrun worktree has landed, it also persists `leaseProvider`. +- `docs/adr/0002-persistent-platform-helper-sessions.md` says helper sessions + are daemon-owned, session-scoped resources. Preserve that boundary: this plan + adds a logical device lease above helper sessions; it does not make runners + public resources. + +## Commands you will need + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Format | `pnpm format` | exit 0 | +| Focused tests | `pnpm exec vitest run src/daemon/__tests__/lease-registry.test.ts src/daemon/__tests__/request-handler-catalog.test.ts` | all tests pass | +| Typecheck | `pnpm typecheck` | exit 0, no errors | + +## Scope + +**In scope**: +- `docs/adr/0007-remote-device-leases.md` (create) +- `CONTEXT.md` +- `src/contracts.ts` +- `src/client-types.ts` +- `src/remote-config-schema.ts` +- `src/remote-connection-state.ts` +- `src/daemon/lease-context.ts` +- `src/daemon/lease-registry.ts` +- `src/daemon/handlers/lease.ts` +- `src/daemon/http-server.ts` +- `src/daemon/__tests__/lease-registry.test.ts` +- `src/daemon/__tests__/request-handler-catalog.test.ts` + +**Out of scope**: +- Connection provider behavior. Plan 002 owns `connect proxy` and lazy proxy + lease acquisition. +- Session admission/cleanup behavior. Plan 003 owns enforcement after `open`. +- iOS runner lease files. Plan 004 owns diagnostics and runner alignment. +- Android or iOS platform command execution changes. + +## Git workflow + +- Branch: `advisor/001-device-aware-lease-contracts` +- Commit message: `feat: add device-aware lease contracts` +- Do not push or open a PR unless the operator instructed it. + +## Steps + +### Step 1: Record the architecture decision and vocabulary + +Create `docs/adr/0007-remote-device-leases.md`. + +The ADR must state: +- A remote device lease is logical ownership of one selected device by one + remote agent/client for a connection provider such as `proxy`, cloud bridge, + or `limrun`. +- `connect` establishes a connection profile and client identity; lease + allocation remains lazy and happens when a device/backend/provider is known. +- A runner/process lease is a backend helper guard and is not a user/client + ownership boundary. +- `open` is the natural point to acquire a device lease because that is where + target resolution and session creation meet. +- Commands after `open` must refresh the lease; no activity for five minutes + should make the device available again. +- The proxy process is expected to be long-lived and self-serve; recovery should + not require restarting the proxy. + +Update `CONTEXT.md` with concise definitions for: +- `Device lease` +- `Device key` +- `Lease provider` +- `Direct proxy client id` +- `Runner/process lease` + +Do not duplicate CLI manuals in `CONTEXT.md`. + +**Verify**: `rg -n "Device lease|Device key|Lease provider|Direct proxy client id|Runner/process lease" CONTEXT.md docs/adr/0007-remote-device-leases.md` -> all five terms are found. + +### Step 2: Extend public lease payloads + +In `src/contracts.ts`, add optional fields to daemon request metadata and lease +RPC payload schemas: + +- `deviceKey?: string` +- `clientId?: string` +- `leaseProvider?: string` if it is not already present from the limrun worktree. + +Use the existing schema helper style near `leaseAllocateSchema`, +`leaseHeartbeatSchema`, and `leaseReleaseSchema`. Add validation helpers that +accept only bounded, printable, agent-safe identifiers: + +- `deviceKey`: 1-256 chars, no whitespace-only value. +- `clientId`: 1-128 chars, letters, numbers, dot, underscore, hyphen. +- `leaseProvider`: 1-64 chars, letters, numbers, dot, underscore, hyphen. + +In `src/client-types.ts`, `src/remote-config-schema.ts`, and +`src/remote-connection-state.ts`, expose/persist the same optional fields where +lease state is represented. Do not require the fields yet; +backward-compatible remote runtime leases must still compile. + +**Verify**: `pnpm typecheck` -> exits 0. + +### Step 3: Make `LeaseRegistry` device-aware + +In `src/daemon/lease-registry.ts`: + +- Rename `SimulatorLease` to `DeviceLease` only if you keep a compatibility + alias: `export type SimulatorLease = DeviceLease;`. Prefer `DeviceLease` + internally. This alias is required because the limrun worktree imports + `SimulatorLease` from `src/daemon/lease-registry.ts`. +- Add optional `deviceKey?: string` and `clientId?: string` to the lease record. +- Preserve optional `provider?: string`/`leaseProvider?: string` if present, or + add one normalized provider field if it has not landed yet. +- Add `deviceKey?: string` and `clientId?: string` to allocate, heartbeat, + release, and admission request types. +- Add provider to allocate, heartbeat, release, and admission request types. +- Keep old backend-only/provider-less leases working when `deviceKey` and + `leaseProvider` are omitted. +- Change idempotent allocation binding from `tenantId:runId:backend` to include + provider and `deviceKey` when present. A suggested key shape is + `${tenantId}:${runId}:${backend}:${provider ?? 'default'}:${deviceKey ?? '*'}`. +- Add a separate `deviceBindings` map keyed by + `${backend}:${provider ?? 'default'}:${deviceKey}`. When a new active lease + asks for a device key already bound to a different lease for the same + backend/provider, throw: + - code: `COMMAND_FAILED` + - message: `Device is already leased` + - details reason: `DEVICE_LEASE_BUSY` + - include `deviceKey`, `backend`, `leaseProvider`, `leaseId`, `tenantId`, + `runId`, `expiresAt`, and a hint saying to retry after the lease expires or + close the owning session. +- On heartbeat/release/admission, if `deviceKey`, `clientId`, or + `leaseProvider` is supplied, it must match the active lease. +- `cleanupExpiredLeases()` must remove both run bindings and device bindings. + +Keep `DEFAULT_LEASE_TTL_MS` at 60 seconds for now. Plan 002 sets the direct +proxy TTL explicitly to five minutes. + +**Verify**: `pnpm exec vitest run src/daemon/__tests__/lease-registry.test.ts` -> existing tests pass before adding new ones. + +### Step 4: Cover device contention and matching + +Extend `src/daemon/__tests__/lease-registry.test.ts` with behavioral tests: + +- allocating the same `tenantId`/`runId`/`backend`/`deviceKey` returns the same + lease and refreshes expiry when provider also matches. +- allocating a different tenant/run for the same `backend` and `deviceKey` + and provider throws `DEVICE_LEASE_BUSY`. +- allocating the same `deviceKey` for different providers succeeds; provider + routing must remain isolated for limrun/cloud/proxy. +- allocating two different `deviceKey` values succeeds even when backend is the + same. +- heartbeat with the wrong `deviceKey` or provider throws + `LEASE_SCOPE_MISMATCH`. +- expired device leases are removed from `deviceBindings` and a new client can + allocate the device. +- old backend-only leases still pass the existing tests. + +**Verify**: `pnpm exec vitest run src/daemon/__tests__/lease-registry.test.ts` -> all tests pass and at least five new tests cover device-aware behavior. + +### Step 5: Thread fields through the lease RPC layer + +Update: + +- `src/daemon/lease-context.ts` to resolve `deviceKey` and `clientId` from + `req.meta` or `req.flags` where appropriate, and resolve `leaseProvider` + from the same source if present. +- `src/daemon/handlers/lease.ts` to pass the resolved fields to + `allocateLease`, `heartbeatLease`, and `releaseLease`. +- `src/daemon/http-server.ts` to parse `deviceKey`, `clientId`, and + `leaseProvider` from JSON-RPC params into daemon request metadata for lease + RPC methods. +- `src/daemon/__tests__/request-handler-catalog.test.ts` to assert lease + handler responses preserve `deviceKey`, `clientId`, and `leaseProvider` when + supplied. + +Do not add user-facing CLI flags in this step. Connection providers in Plan 002 +should set these fields internally through generated profiles and request +metadata. + +**Verify**: `pnpm exec vitest run src/daemon/__tests__/lease-registry.test.ts src/daemon/__tests__/request-handler-catalog.test.ts` -> all tests pass. + +## Test plan + +- Primary tests: `src/daemon/__tests__/lease-registry.test.ts`. +- Handler projection test: `src/daemon/__tests__/request-handler-catalog.test.ts`. +- Run `pnpm typecheck` to prove public contract fields compile across client + and daemon types. + +## Done criteria + +- [ ] `docs/adr/0007-remote-device-leases.md` exists and defines logical + provider-aware device leases versus runner/process leases. +- [ ] `CONTEXT.md` includes the five new vocabulary entries. +- [ ] Lease payload schemas accept optional `deviceKey`, `clientId`, and + `leaseProvider`. +- [ ] `LeaseRegistry` rejects active same-device conflicting leases with + `DEVICE_LEASE_BUSY` only within the same backend/provider. +- [ ] Backend-only lease tests still pass. +- [ ] `pnpm format`, focused tests, and `pnpm typecheck` exit 0. +- [ ] `plans/README.md` status row updated. + +## STOP conditions + +Stop and report if: + +- Existing lease RPC payloads are generated from a different contract source + than `src/contracts.ts`. +- Adding `deviceKey` requires changing platform dispatch or target resolution; + that belongs in Plan 002 or Plan 003. This plan only adds the contract and + registry semantics. +- `leaseProvider` has already landed under a different public field name. In + that case, preserve the landed field and update this plan's names before + coding. +- Any public result type currently named `SimulatorLease` is consumed outside + this package in a way that makes renaming a breaking change. Use the alias + instead. +- Focused lease tests fail twice after a reasonable fix attempt. + +## Maintenance notes + +- Reviewers should scrutinize compatibility: old tenant/run/backend leases must + keep working. +- The busy error is part of the user experience. Keep it short and actionable. +- Do not weaken the iOS runner file lease. This plan adds a higher-level + logical lease, not a replacement for process mutual exclusion. diff --git a/plans/002-automatic-direct-proxy-lease-on-open.md b/plans/002-automatic-direct-proxy-lease-on-open.md new file mode 100644 index 000000000..2ea771787 --- /dev/null +++ b/plans/002-automatic-direct-proxy-lease-on-open.md @@ -0,0 +1,308 @@ +# Plan 002: Add proxy as a connect provider + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report; do not improvise. When done, update the status row for this plan in +> `plans/README.md`. +> +> **Drift check (run first)**: +> `git diff --stat 04f53bbc0..HEAD -- src/cli src/client-normalizers.ts src/client-types.ts src/commands src/contracts.ts src/remote-config-schema.ts src/remote-connection-state.ts src/daemon/handlers/session-open.ts src/daemon/__tests__/request-router-open.test.ts test/integration/smoke-open-remote-config.test.ts` +> +> If any in-scope file changed since this plan was written, compare the +> "Current state" excerpts against the live code before proceeding; on a +> mismatch, treat it as a STOP condition. + +## Status + +- **Priority**: P1 +- **Effort**: L +- **Risk**: HIGH +- **Depends on**: `plans/001-device-aware-lease-contracts.md` +- **Category**: correctness | dx +- **Planned at**: commit `04f53bbc0`, 2026-06-26 + +## Why this matters + +Cloud and remote-config flows already use `connect` as the user-facing +connection lifecycle: authenticate or resolve a profile, persist +`RemoteConnectionState`, then let later commands allocate or refresh the lease. +The limrun worktree follows the same pattern with `connect limrun`. Direct +proxy should not grow a parallel state machine. It should become another +connection provider, so agents use one mental model across self-hosted proxy, +agent-device-cloud, and limrun. + +## Current state + +- `src/cli/commands/connection.ts` implements `connect`, `disconnect`, and + `connection status`. Without `--remote-config`, it resolves a cloud + connection profile. +- `src/cli/commands/connection-runtime.ts` materializes remote config state on + non-deferred commands, allocates or heartbeats a lease, updates persisted + state, and prepares Metro on `open`. +- `src/remote-connection-state.ts` persists connection state under + `remote-connections`, including remote config path/hash, sanitized daemon + state, `tenant`, `runId`, `leaseId`, `leaseBackend`, platform, target, and + runtime hints. +- `src/cli/cloud-connection-profile.ts` fetches a cloud connection profile and + writes a generated remote config. +- The limrun worktree at + `/Users/thymikee/.codex/worktrees/20c8/agent-device` adds + `connect limrun`, `src/cli/generated-remote-config.ts`, + `src/cli/limrun-connection-profile.ts`, `leaseProvider`, and provider-backed + cloud runtimes. Reuse that shape if it has landed before this plan executes. +- `src/cli/commands/proxy.ts` starts a long-lived proxy and prints a shared + proxy URL/token. It does not create a per-agent connection profile. +- `src/utils/cli-help.ts` currently says direct proxy users should not use + `connect`, tenant, run, or lease flags. This must be changed after behavior + exists. + +## Commands you will need + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Format | `pnpm format` | exit 0 | +| Focused CLI tests | `pnpm exec vitest run src/__tests__/remote-connection.test.ts src/__tests__/cloud-connect-profile.test.ts src/utils/__tests__/args.test.ts` | all tests pass | +| Remote smoke | `node --test test/integration/smoke-open-remote-config.test.ts` | all tests pass | +| Open route tests | `pnpm exec vitest run src/daemon/__tests__/request-router-open.test.ts` | all tests pass | +| Typecheck | `pnpm typecheck` | exit 0, no errors | + +## Scope + +**In scope**: +- `src/cli/generated-remote-config.ts` (create if not already present) +- `src/cli/proxy-connection-profile.ts` (create) +- `src/cli/cloud-connection-profile.ts` +- `src/cli/commands/connection.ts` +- `src/cli/commands/connection-runtime.ts` +- `src/remote-config-schema.ts` +- `src/remote-connection-state.ts` +- `src/client-normalizers.ts` +- `src/client-types.ts` +- `src/commands/command-projection.ts` +- `src/daemon/handlers/session-open.ts` +- `src/daemon/__tests__/request-router-open.test.ts` +- `src/__tests__/remote-connection.test.ts` +- `src/__tests__/cloud-connect-profile.test.ts` +- `src/utils/__tests__/args.test.ts` +- `test/integration/smoke-open-remote-config.test.ts` + +**Out of scope**: +- Changing the proxy server token model. +- Replacing cloud auth or the cloud connection profile endpoint. +- Reworking limrun runtime internals. +- Session cleanup on expiry. Plan 003 owns inactivity cleanup. +- User-facing help/docs updates beyond parse tests. Plan 004 owns final copy. + +## Git workflow + +- Branch: `advisor/002-connect-proxy-provider` +- Commit message: `feat: add proxy connect provider` +- Do not push or open a PR unless the operator instructed it. + +## Steps + +### Step 1: Extract generated remote config helpers + +If `src/cli/generated-remote-config.ts` does not already exist, extract the +generated-profile writer from `src/cli/cloud-connection-profile.ts`. + +The helper should provide: + +```ts +writeGeneratedRemoteConfig({ + stateDir, + provider, + profile, +}): string + +resolveGeneratedRemoteConfigProfile({ + configPath, + cwd, + env, + provider, +}): ResolvedRemoteConfigProfile +``` + +Requirements: + +- Write under `${stateDir}/remote-connections/generated`. +- Include the provider name and a profile hash in the file name. +- Write mode `0o600` and avoid storing secrets. +- Keep cloud behavior byte-for-byte equivalent except for the generated file + name prefix changing from hard-coded `cloud` to provider-driven `cloud`. + +**Verify**: `pnpm exec vitest run src/__tests__/cloud-connect-profile.test.ts` -> existing cloud generated-profile tests pass. + +### Step 2: Add a proxy connection profile resolver + +Create `src/cli/proxy-connection-profile.ts`. + +It should build a generated remote config for `connect proxy` from flags/env: + +- `daemonBaseUrl`: required from `--daemon-base-url`, + `AGENT_DEVICE_DAEMON_BASE_URL`, or a proxy-specific flag if one already + exists. +- `daemonAuthToken`: optional from existing daemon auth sources. +- `daemonTransport`: default `http` for explicit proxy URLs unless the user + overrides it. +- `tenant`: default `proxy`. +- `runId`: stable per local state dir and proxy base URL, such as + `proxy-${clientId}`. +- `clientId`: generated once per state dir and proxy base URL, non-secret, safe + identifier. Store it in the generated profile or connection state only if + Plan 001 added the field; otherwise store enough data to reproduce the same + run id. +- `sessionIsolation`: `tenant`. +- `leaseProvider`: `proxy` if Plan 001 added provider support. +- `leaseBackend`: infer from `--platform ios|android` when supplied, otherwise + leave pending so the first device command can resolve it. +- `leaseTtlMs`: 300,000 ms if Plan 001 exposed it through connection/profile + defaults; otherwise set it when allocating/heartbeating in Step 4. +- `platform`, `target`, `device`, `udid`, `serial`, `session`, and Metro fields + should pass through from flags like cloud/remote config does. + +Do not put the shared proxy bearer token in the generated remote config unless +the current remote config contract already stores daemon auth there. Prefer the +existing sanitized daemon state and environment auth behavior. + +**Verify**: add tests proving `connect proxy` with a daemon base URL writes a +generated profile with tenant/run/session isolation and no raw token value. + +### Step 3: Route `connect` through providers + +Update `src/cli/commands/connection.ts` so `connect` accepts at most one +provider positional: + +- `agent-device connect` keeps current cloud behavior. +- `agent-device connect --remote-config ./remote.json` keeps current explicit + remote-config behavior. +- `agent-device connect proxy --daemon-base-url http://host:port/agent-device` + uses `resolveProxyConnectProfile`. +- If the limrun worktree has landed, preserve `agent-device connect limrun`. + +Rules: + +- Provider positional and `--remote-config` are mutually exclusive. +- Unknown provider errors must list supported providers. At minimum: + `proxy`; include `limrun` if present. +- `connect proxy` requires a daemon base URL. +- `connect proxy` should write normal `RemoteConnectionState`; do not create + a separate direct-proxy state file. + +**Verify**: `pnpm exec vitest run src/utils/__tests__/args.test.ts src/__tests__/remote-connection.test.ts` -> tests cover provider positional parsing, unknown provider, and state persistence. + +### Step 4: Preserve provider/client/device fields in connection state + +Update `src/remote-config-schema.ts`, `src/remote-connection-state.ts`, +`src/client-types.ts`, `src/client-normalizers.ts`, and +`src/commands/command-projection.ts` as needed so optional fields from Plan 001 +flow through the same path as tenant/run/lease/backend: + +- `leaseProvider` +- `clientId` +- `deviceKey` +- `leaseTtlMs` when present + +For remote-config and cloud flows, all fields remain optional. For proxy, +`clientId` and `leaseProvider: proxy` should be persisted. + +**Verify**: `pnpm typecheck` -> exits 0. + +### Step 5: Make lease allocation device-aware without breaking remote materialization + +Current `connection-runtime.ts` allocates leases before most commands based on +backend/provider only. Keep that behavior for providers that do not require a +known physical/local device key, including current cloud/limrun flows. + +Add a shared device-aware path for providers that do require a selected device +key. For `connect proxy`, use this path: + +- On `open`, resolve the target platform/device before platform side effects. +- Derive a stable `deviceKey`, for example + `${platform}:${target}:${udid|serial|deviceId}`. +- Allocate or heartbeat the lease with `tenant`, `runId`, `leaseBackend`, + `leaseProvider: proxy`, `clientId`, `deviceKey`, and `ttlMs: 300_000`. +- Attach the resulting `leaseId`, `leaseProvider`, `clientId`, and `deviceKey` + to the request metadata before app open/session creation continues. +- Persist the returned lease fields in `RemoteConnectionState`. + +Do not allow `open` to mutate the target device before a conflicting proxy +device lease would be detected. If the existing open route cannot expose the +selected `deviceKey` early enough, stop and report; solve that in daemon open +or target-resolution orchestration, not with a post-open retry. + +For non-`open` commands: + +- If a proxy connection already has `leaseId` and `deviceKey`, heartbeat and + attach them. +- If a command needs a session but there is no active proxy lease, fail with: + "No active proxy device lease for this session; run open first." +- `devices` may run after `connect proxy` without a device lease so users can + inspect inventory. + +**Verify**: `pnpm exec vitest run src/daemon/__tests__/request-router-open.test.ts` -> add a test proving a busy proxy device lease rejects `open` before dispatch/open side effects. + +### Step 6: Release proxy leases through `disconnect` and `close` + +Use the existing `disconnect` flow: + +- best-effort close the active session; +- stop Metro/React DevTools cleanup; +- release the persisted lease with tenant/run/provider/client/device metadata; +- remove `RemoteConnectionState`. + +Also make `close` release the active proxy lease only for the scoped session +when Plan 003 session ownership metadata is available. Before Plan 003 lands, +`close` should at least close the daemon session and leave `disconnect` as the +full connection cleanup command. + +**Verify**: tests show `disconnect` releases with `leaseProvider: proxy` and +removes connection state. + +## Test plan + +- Extend `src/__tests__/remote-connection.test.ts` for `connect proxy` state, + provider compatibility, `--force`, and disconnect release. +- Extend `src/utils/__tests__/args.test.ts` for provider positional parsing and + help examples. +- Extend `src/daemon/__tests__/request-router-open.test.ts` for device-aware + busy rejection before open side effects. +- Extend `test/integration/smoke-open-remote-config.test.ts` or create a + focused proxy smoke fixture proving `connect proxy -> open -> snapshot -> + disconnect` sends tenant/run/lease/provider metadata. + +## Done criteria + +- [ ] `agent-device connect proxy --daemon-base-url ...` creates normal + `RemoteConnectionState`. +- [ ] No separate direct-proxy lease-state file exists. +- [ ] Proxy connection state includes a stable non-secret client identity. +- [ ] Proxy `open` acquires a five-minute device-aware lease before mutating + the target device/app. +- [ ] Commands after proxy `open` attach lease metadata automatically. +- [ ] `disconnect` releases the proxy lease and removes connection state. +- [ ] Cloud, explicit remote-config, and limrun connect flows still work. +- [ ] `pnpm format`, focused tests, and `pnpm typecheck` exit 0. +- [ ] `plans/README.md` status row updated. + +## STOP conditions + +Stop and report if: + +- The open path cannot know the selected `deviceKey` before side effects. +- `connect proxy` cannot be represented as a generated remote config without + persisting secrets. +- Provider positional parsing conflicts with existing `connect --remote-config` + or cloud implicit login semantics. +- Limrun provider fields have landed under names that conflict with + `leaseProvider`, `clientId`, or `deviceKey`. +- Focused tests fail twice after a reasonable fix attempt. + +## Maintenance notes + +- `connect` owns connection/profile identity. Lease allocation remains lazy. +- Provider-specific runtime allocation belongs behind daemon/bridge providers; + ordinary command users should not see limrun/proxy internals. +- Reviewers should focus on the first command after `connect proxy`: inventory + may be lease-free, but device mutation must not bypass lease acquisition. diff --git a/plans/003-daemon-session-lease-admission-cleanup.md b/plans/003-daemon-session-lease-admission-cleanup.md new file mode 100644 index 000000000..e40ec2e8c --- /dev/null +++ b/plans/003-daemon-session-lease-admission-cleanup.md @@ -0,0 +1,269 @@ +# Plan 003: Bind daemon sessions to active device leases + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report; do not improvise. When done, update the status row for this plan in +> `plans/README.md`. +> +> **Drift check (run first)**: +> `git diff --stat 04f53bbc0..HEAD -- src/daemon src/platforms/ios/runner-session.ts src/platforms/android src/contracts.ts src/client-types.ts` +> +> If any in-scope file changed since this plan was written, compare the +> "Current state" excerpts against the live code before proceeding; on a +> mismatch, treat it as a STOP condition. + +## Status + +- **Priority**: P1 +- **Effort**: L +- **Risk**: HIGH +- **Depends on**: `plans/001-device-aware-lease-contracts.md`, + `plans/002-automatic-direct-proxy-lease-on-open.md` +- **Category**: correctness +- **Planned at**: commit `04f53bbc0`, 2026-06-26 + +## Why this matters + +Automatic lease acquisition is only half the fix. The daemon must also bind +session state to the lease that created it, refresh that lease during activity, +reject commands from other clients, and reclaim idle sessions without a proxy +restart. This plan makes the daemon the enforcement point for long-lived proxy +sharing while keeping cloud and limrun provider leases on the same +tenant/run/lease/provider contract. + +## Current state + +- `src/daemon/request-execution-scope.ts` scopes the request session, resolves + an effective session, and then calls lease admission. +- `src/daemon/request-admission.ts` currently enforces lease admission only when + `sessionIsolation === 'tenant'`. +- `src/daemon/session-store.ts` owns persisted daemon sessions. +- `src/daemon/handlers/session-open.ts` creates or updates the session after + target/app resolution and platform open work. +- `src/daemon/handlers/session-close.ts` tears down resources and deletes the + session. +- The limrun worktree uses the same `LeaseRegistry`, lease handler, request + admission, provider release, and device inventory hooks. Treat cloud/limrun + as in-scope regression paths when provider fields are present. +- `docs/adr/0002-persistent-platform-helper-sessions.md` and + `docs/adr/0005-ios-runner-interaction-lifecycle.md` keep helper lifecycle in + the daemon. Do not make CLI clients directly own helpers. + +## Commands you will need + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Format | `pnpm format` | exit 0 | +| Focused daemon tests | `pnpm exec vitest run src/daemon/__tests__/request-execution-scope.test.ts src/daemon/__tests__/request-router-open.test.ts src/daemon/__tests__/request-router-lock-policy.test.ts src/daemon/__tests__/session-store.test.ts` | all tests pass | +| Typecheck | `pnpm typecheck` | exit 0, no errors | +| Unit bundle | `pnpm check:unit` | exits 0 in a device-capable environment | + +## Scope + +**In scope**: +- `src/daemon/types.ts` +- `src/daemon/session-store.ts` +- `src/daemon/request-admission.ts` +- `src/daemon/request-execution-scope.ts` +- `src/daemon/request-router.ts` +- `src/daemon/handlers/session-open.ts` +- `src/daemon/handlers/session-close.ts` +- `src/daemon/handlers/session.ts` only for orchestration around open/close +- `src/daemon/__tests__/request-execution-scope.test.ts` +- `src/daemon/__tests__/request-router-open.test.ts` +- `src/daemon/__tests__/request-router-lock-policy.test.ts` +- `src/daemon/__tests__/session-store.test.ts` +- Limrun/cloud-focused lease tests if `src/cloud/**` or provider lifecycle + hooks have landed + +**Out of scope**: +- Changing platform action implementations. +- Changing the global iOS runner file lease. +- Adding a public lease status command. +- Changing provider runtime allocation internals. Provider regression tests are + in scope only to prove admission/cleanup did not break them. + +## Git workflow + +- Branch: `advisor/003-daemon-session-lease-admission-cleanup` +- Commit message: `fix: enforce device leases for daemon sessions` +- Do not push or open a PR unless the operator instructed it. + +## Steps + +### Step 1: Persist lease ownership on sessions + +Extend the daemon session type in `src/daemon/types.ts` with an optional field: + +```ts +lease?: { + leaseId: string; + tenantId: string; + runId: string; + clientId?: string; + backend?: string; + leaseProvider?: string; + deviceKey?: string; + expiresAt?: number; +}; +``` + +Update `src/daemon/session-store.ts` read/write behavior only as needed. Keep +old sessions without `lease` valid. + +Add tests in `src/daemon/__tests__/session-store.test.ts`: + +- session lease metadata round-trips through store persistence. +- old session fixture/object without lease metadata still loads. + +**Verify**: `pnpm exec vitest run src/daemon/__tests__/session-store.test.ts` -> all tests pass. + +### Step 2: Bind `open` sessions to the admitted lease + +In `src/daemon/handlers/session-open.ts`, after the lease from Plan 002 has +been allocated/admitted and before persisting the session, write lease metadata +into the session. + +Rules: + +- If request metadata contains `leaseId`, `tenantId`, `runId`, `leaseProvider`, + `clientId`, and `deviceKey`, the persisted session must contain the same + values. +- If request metadata lacks lease fields, preserve existing local behavior. +- If `open` is running in `connect proxy`/tenant isolation and required proxy + lease fields are missing, throw an actionable `INVALID_ARGS` error before + platform side effects. +- Cloud/limrun opens may have `leaseProvider` without `deviceKey`; keep that + valid because those providers allocate runtime devices behind the lease. + +Add an open route test proving the stored session contains lease metadata. + +**Verify**: `pnpm exec vitest run src/daemon/__tests__/request-router-open.test.ts` -> all tests pass. + +### Step 3: Enforce lease admission against the session owner + +Update `src/daemon/request-admission.ts` and +`src/daemon/request-execution-scope.ts` so device lease admission works even +when later requests rely on the stored session for ownership context. + +Behavior: + +- If request metadata has a `leaseId`, use it for admission. +- Else, if the effective session has `session.lease`, use that session lease for + admission. +- If both exist, they must match on `leaseId`, `tenantId`, `runId`, + `leaseProvider`, `clientId`, and `deviceKey` when those fields are present. +- If a session has `lease`, commands that are not lease-admission-exempt must + not run without a matching active lease. +- Lease commands remain admission-exempt. +- Local non-proxy sessions without `session.lease` keep existing behavior. + +On successful admission, heartbeat the lease with the requested TTL or the +default from the active lease. Use the proxy TTL of 300,000 ms when the metadata +indicates `leaseProvider: proxy`. + +Add tests: + +- command with matching session lease succeeds and heartbeats. +- command with wrong `leaseId` is rejected before handler dispatch. +- command with wrong `leaseProvider`, `clientId`, or `deviceKey` is rejected + before handler dispatch when those fields are present. +- command with no metadata but leased session succeeds by using session lease. +- command for local unleased session still succeeds. +- limrun/cloud provider lease admission still succeeds without `deviceKey` when + the provider owns runtime allocation behind the lease. + +**Verify**: `pnpm exec vitest run src/daemon/__tests__/request-execution-scope.test.ts src/daemon/__tests__/request-router-lock-policy.test.ts` -> all tests pass. + +### Step 4: Release lease on close + +In `src/daemon/handlers/session-close.ts`, release the session's lease when +closing a leased session. + +Rules: + +- Release after platform/session cleanup has been attempted. +- Release is idempotent. +- If release says `{ released: false }`, close still succeeds. +- If a different client tries to close a leased session, admission must reject + before cleanup. + +Add tests: + +- close releases the session lease. +- wrong client cannot close another client's leased session. + +**Verify**: `pnpm exec vitest run src/daemon/__tests__/request-execution-scope.test.ts src/daemon/__tests__/request-router-open.test.ts` -> all tests pass. + +### Step 5: Reclaim inactive leased sessions + +Add a cleanup path that runs opportunistically before request admission and +lease allocation. Prefer extending `LeaseRegistry.cleanupExpiredLeases()` to +return expired leases, or add a method such as `consumeExpiredLeases()`. + +When a lease expires: + +- Find sessions with matching `session.lease.leaseId`. +- Delete those sessions from `SessionStore`. +- Tear down associated helper resources only through existing session cleanup + paths. Do not directly kill runner processes from the lease registry. +- Emit diagnostics with the lease id, session name, device key, and reason + `LEASE_EXPIRED`. + +This cleanup is the five-minute inactivity safety net. It must not require +restarting the proxy. + +Add tests: + +- advancing fake time past expiry removes the leased session before the next + command. +- after expiry, another client can open/acquire the same `deviceKey`. +- after expiry, provider release hooks still run for cloud/limrun leases if + provider lifecycle hooks are present. + +**Verify**: `pnpm exec vitest run src/daemon/__tests__/request-execution-scope.test.ts src/daemon/__tests__/session-store.test.ts` -> all tests pass. + +## Test plan + +- Session persistence coverage in `session-store.test.ts`. +- Admission and heartbeat coverage in `request-execution-scope.test.ts`. +- Open/close behavior coverage in `request-router-open.test.ts`. +- Lock policy regression in `request-router-lock-policy.test.ts`, because + proxy sessions must remain serialized by the effective scoped session. +- Provider regression coverage when `src/cloud/**` is present: limrun/cloud + leases still allocate, heartbeat, release, and clean up expired provider + sessions. + +## Done criteria + +- [ ] Leased sessions persist lease metadata. +- [ ] Commands against leased sessions are rejected unless the active lease + matches. +- [ ] Successful commands heartbeat the lease. +- [ ] `close` releases the lease. +- [ ] Expired leases clean up owned sessions without proxy restart. +- [ ] Cloud/limrun provider leases still work when provider hooks are present. +- [ ] Local unleased workflows still work. +- [ ] `pnpm format`, focused tests, and `pnpm typecheck` exit 0. +- [ ] `plans/README.md` status row updated. + +## STOP conditions + +Stop and report if: + +- Session cleanup requires platform-specific runner killing from + `LeaseRegistry`; that would violate the daemon/helper lifecycle boundary. +- Effective session scoping happens too late to reject wrong-client commands + before side effects. +- Cloud or limrun leases start failing because proxy-only assumptions leaked + into provider-backed paths. +- Focused tests fail twice after a reasonable fix attempt. + +## Maintenance notes + +- The daemon should be the source of truth for active session ownership after + `open`; CLI state is only a convenience and recovery cache. +- Reviewers should inspect every path that can dispatch platform work and + confirm leased sessions cannot bypass admission. +- Inactivity cleanup should emit diagnostics, not noisy stderr output. diff --git a/plans/004-runner-diagnostics-and-proxy-docs.md b/plans/004-runner-diagnostics-and-proxy-docs.md new file mode 100644 index 000000000..da4d58e01 --- /dev/null +++ b/plans/004-runner-diagnostics-and-proxy-docs.md @@ -0,0 +1,225 @@ +# Plan 004: Align runner diagnostics and proxy docs + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report; do not improvise. When done, update the status row for this plan in +> `plans/README.md`. +> +> **Drift check (run first)**: +> `git diff --stat 04f53bbc0..HEAD -- src/platforms/ios/runner-lease.ts src/platforms/ios/runner-session.ts src/platforms/ios/__tests__/runner-session.test.ts src/utils/cli-help.ts src/utils/__tests__/args.test.ts README.md website docs` +> +> If any in-scope file changed since this plan was written, compare the +> "Current state" excerpts against the live code before proceeding; on a +> mismatch, treat it as a STOP condition. + +## Status + +- **Priority**: P2 +- **Effort**: M +- **Risk**: MED +- **Depends on**: `plans/001-device-aware-lease-contracts.md`, + `plans/002-automatic-direct-proxy-lease-on-open.md`, + `plans/003-daemon-session-lease-admission-cleanup.md` +- **Category**: dx | docs +- **Planned at**: commit `04f53bbc0`, 2026-06-26 + +## Why this matters + +The iOS runner file lease should remain the low-level guard that prevents two +daemons from controlling one XCTest runner, but it should no longer be the +first user-visible ownership model for local/proxy iOS contention. After Plans +001 through 003, `connect proxy` users should see device lease errors before +runner ownership errors. Limrun/cloud iOS devices route through provider +interactors and are not expected to hit the local iOS runner file lease. + +## Current state + +- `src/platforms/ios/runner-lease.ts` stores global runner ownership under + `~/.agent-device/ios-runner/leases` and reports owner daemon details. +- `src/platforms/ios/runner-session.ts` starts/reuses long-lived XCTest runner + sessions per device. +- `src/utils/cli-help.ts` remote/direct proxy guidance currently tells users + direct proxy mode should not use connect, tenant, run, or lease flags. +- Cloud docs in `/Users/thymikee/Developer/agent-device-cloud/docs/connecting-agent-device.md` + already define the recommended flow as `connect` -> normal commands -> + `disconnect`. The limrun worktree adds `connect limrun` with generated remote + config and `leaseProvider: limrun`. +- `src/commands/management/prepare.ts` help already distinguishes runner + preparation from recovery for "runner already owned by another daemon". +- `docs/adr/0005-ios-runner-interaction-lifecycle.md` requires readiness probes + and stale runner invalidation. Do not remove that behavior. + +## Commands you will need + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Format | `pnpm format` | exit 0 | +| Runner tests | `pnpm exec vitest run src/platforms/ios/__tests__/runner-session.test.ts` | all tests pass | +| Help tests | `pnpm exec vitest run src/utils/__tests__/args.test.ts` | all tests pass | +| Typecheck | `pnpm typecheck` | exit 0, no errors | +| Swift runner build | `pnpm build:xcuitest` | exit 0 if Swift runner files changed | + +## Scope + +**In scope**: +- `src/platforms/ios/runner-lease.ts` +- `src/platforms/ios/runner-session.ts` +- `src/platforms/ios/__tests__/runner-session.test.ts` +- `src/utils/cli-help.ts` +- `src/utils/__tests__/args.test.ts` +- `README.md` +- `website/docs/**` only if direct proxy docs already exist there + +**Out of scope**: +- Replacing the iOS runner file lease. +- Changing XCUITest runner Swift code unless diagnostics require it. +- Adding a new public status command. If needed, create a separate plan. +- Reworking Android platform behavior. + +## Git workflow + +- Branch: `advisor/004-runner-diagnostics-and-proxy-docs` +- Commit message: `docs: clarify direct proxy device leases` +- Do not push or open a PR unless the operator instructed it. + +## Steps + +### Step 1: Attach logical lease context to runner diagnostics + +After Plan 003, runner startup/reuse should be reachable only after device +lease admission. Thread non-secret logical lease context from daemon session or +request metadata into iOS runner session startup diagnostics: + +```ts +{ + leaseId?: string; + clientId?: string; + tenantId?: string; + runId?: string; + leaseProvider?: string; + deviceKey?: string; +} +``` + +Use existing diagnostics helpers. Do not write this context into the global +runner lease file unless the file already has a versioned schema and safe +backward compatibility. If you do persist it, make every field optional and keep +old lease files readable. + +**Verify**: `pnpm exec vitest run src/platforms/ios/__tests__/runner-session.test.ts` -> all tests pass. + +### Step 2: Improve runner busy errors without weakening ownership checks + +In `src/platforms/ios/runner-lease.ts`, keep the existing stale-owner and +same-state reclaim behavior. Update user-facing busy errors so, when logical +lease context is available, the message/hint says: + +- The device is busy because another active device lease owns it, or +- The runner is owned by another daemon/process after lease admission, which is + now a backend inconsistency and should include state dir/PID diagnostics. + +Do not suggest restarting the long-lived proxy as the first recovery step. +Suggested hint: + +"Retry after the owning session closes or after the five-minute inactivity +lease expires. If this persists after expiry, inspect the runner owner details +and clean the stale daemon state on the machine with simulator access." + +Add tests covering: + +- logical lease context appears in diagnostics. +- old runner busy errors still include state-dir/PID details. +- stale same-state reclaim still works. + +**Verify**: `pnpm exec vitest run src/platforms/ios/__tests__/runner-session.test.ts` -> all tests pass. + +### Step 3: Update direct proxy help + +Update `src/utils/cli-help.ts` remote/direct proxy section: + +- Remove guidance that direct proxy users should not use `connect` or leases. +- Explain that agents should run `agent-device connect proxy --daemon-base-url + ` before using a shared proxy. +- Explain that `connect` establishes the connection profile and client identity; + the proxy device lease is acquired lazily on `open`. +- State the inactivity timeout: five minutes with no commands refreshes. +- State that `disconnect` releases the connection lease and local state. State + that `close` releases the session/device lease once Plan 003 behavior exists. +- State that multiple agents can share one proxy if they use normal + `connect proxy`/`open`/command/`disconnect` flow; the daemon isolates + sessions by client. +- State that a busy device error means another agent owns the device until it + closes or the lease expires. +- Present cloud, remote config, proxy, and limrun as connection providers under + the same lifecycle. Do not expose provider internals in normal command + examples. +- Keep the copy compact; this repo treats CLI help as the agent-facing source + of truth. + +Update `src/utils/__tests__/args.test.ts` with assertions for the important +copy, especially "connect proxy", "automatic on open", "five minutes", and +"disconnect releases". + +**Verify**: `pnpm exec vitest run src/utils/__tests__/args.test.ts` -> all tests pass. + +### Step 4: Update README and website docs only where direct proxy is documented + +Search: + +`rg -n "agent-device proxy|direct proxy|remote proxy|lease" README.md website docs` + +For each existing direct proxy section, align it with the CLI help: + +- one long-lived proxy process on the device host; +- agents run `connect proxy` to create a connection profile; +- agents acquire device leases automatically on `open`; +- leases refresh on activity and expire after five minutes; +- `disconnect` releases the connection lease and local state; +- do not restart the proxy to recover normal contention. + +Do not introduce a new long tutorial if no docs section exists; CLI help is the +canonical agent-facing source. + +**Verify**: `rg -n "Do not use connect|Do not use .*lease|restart.*proxy" src/utils/cli-help.ts README.md website docs` -> no stale direct-proxy guidance remains, except unrelated contexts that clearly do not refer to direct proxy recovery. + +## Test plan + +- `runner-session.test.ts` for runner diagnostic behavior. +- `args.test.ts` for CLI help copy. +- `pnpm typecheck` for threading logical lease context through typed runner + session code. +- `pnpm build:xcuitest` only if Swift runner files changed. + +## Done criteria + +- [ ] Runner diagnostics can include logical lease context without exposing + secrets. +- [ ] Runner busy errors still preserve state-dir/PID details. +- [ ] Direct proxy help says to use `connect proxy` and that leases are + automatic on `open`. +- [ ] Help/docs mention five-minute inactivity expiry and `disconnect` release. +- [ ] Stale guidance telling users not to use `connect` or leases with direct + proxy is gone. +- [ ] `pnpm format`, focused tests, and `pnpm typecheck` exit 0. +- [ ] `plans/README.md` status row updated. + +## STOP conditions + +Stop and report if: + +- Improving diagnostics requires changing Swift runner behavior. +- The global runner lease file cannot accept optional fields without breaking + existing installs. In that case, keep logical lease context diagnostics-only. +- Help updates reveal a missing public command that is necessary for recovery; + create a follow-up plan instead of adding the command here. +- Focused tests fail twice after a reasonable fix attempt. + +## Maintenance notes + +- Runner/process lease errors should become rare in direct proxy mode. If users + still see them during normal contention, admission is being bypassed. +- Limrun/cloud iOS sessions should not depend on local runner file-lease + diagnostics; keep those docs provider-neutral. +- Reviewer focus: make sure docs describe the implemented behavior, not the + desired future behavior. diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 000000000..b39e0325a --- /dev/null +++ b/plans/README.md @@ -0,0 +1,85 @@ +# Implementation Plans + +Generated by the improve skill on 2026-06-26. Execute in the order below unless +dependencies say otherwise. Each executor: read the plan fully before starting, +honor its STOP conditions, and update your row when done. + +## Execution order and status + +| Plan | Title | Priority | Effort | Depends on | Status | +|------|-------|----------|--------|------------|--------| +| 001 | Add device-aware lease contracts | P1 | M | - | DONE | +| 002 | Add proxy as a connect provider | P1 | L | 001 | DONE | +| 003 | Bind daemon sessions to active device leases | P1 | L | 001, 002 | DONE | +| 004 | Align runner diagnostics and proxy docs | P2 | M | 001, 002, 003 | DONE | + +Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | +REJECTED (with one-line rationale). + +## Dependency notes + +- 001 creates the shared lease vocabulary and contract shape. Do this before + any proxy or session behavior so callers and daemon checks agree on fields. +- 002 makes direct proxy another `connect` provider and teaches the shared + connection runtime to acquire device-aware leases lazily. It depends on 001 + because it must pass `leaseProvider`, `deviceKey`, and `clientId`. +- 003 makes the daemon enforce and refresh the device lease for all commands in + a session. It depends on 002 because sessions need the client-provided lease + metadata produced by `open`. +- 004 improves diagnostics and docs after the behavior is real; doing it first + would document a contract the code cannot yet honor. + +## Findings + +### [CORRECTNESS-01] Direct proxy clients bypass the remote connection model + +- **Evidence**: `src/daemon/lease-registry.ts` stores leases by `tenantId`, + `runId`, and `backend`, but not by selected device. Capacity only counts + active iOS simulator leases. +- **Evidence**: `src/utils/cli-help.ts` direct proxy guidance currently tells + users not to use connect, tenant, run, or lease flags for direct proxy mode, + while `agent-device-cloud/docs/connecting-agent-device.md` already presents + `connect` as the umbrella for cloud/self-hosted bridge sessions. +- **Evidence**: `src/daemon-proxy.ts` authenticates with one shared proxy token + and forwards requests, but does not create a stable client identity or lease. +- **Impact**: Four remote agents can connect to one long-lived proxy and issue + commands against the same device/session without a first-class ownership + boundary. The iOS runner lease then surfaces the failure late as "already + owned by another daemon", which is accurate at the backend layer but wrong as + user-facing multi-agent coordination. +- **Effort**: L. +- **Risk**: HIGH, because this changes the direct proxy command lifecycle and + request admission semantics. +- **Confidence**: HIGH. +- **Fix sketch**: Add provider-aware device lease contracts, make proxy a + `connect` provider that writes normal remote connection state, acquire the + selected device lease lazily on `open` or the first device command, bind + daemon sessions to that lease, refresh on activity, and release or expire + after inactivity. + +### [DX-01] Runner/process lease errors hide the logical proxy owner + +- **Evidence**: `src/platforms/ios/runner-lease.ts` protects one iOS runner per + device using files under `~/.agent-device/ios-runner/leases`, but it only + knows daemon/process state, not the agent/client lease that caused runner use. +- **Impact**: When contention happens, users see daemon state-dir/PID language + instead of "device is leased by client X until timestamp Y". Recovery requires + hidden knowledge and often suggests restarting/cleaning the wrong layer. +- **Effort**: M. +- **Risk**: MED, because diagnostics must not weaken runner mutual exclusion. +- **Confidence**: HIGH. +- **Fix sketch**: Keep the global runner lease as a backend process guard, but + attach logical lease context to runner startup/reuse diagnostics and only let + daemon code reach the runner after device-lease admission succeeds. + +## Findings considered and rejected + +- Replace the proxy with one daemon per agent: rejected because the product goal + is a single long-lived proxy process on the machine with the device attached. +- Solve this only by shortening the existing iOS runner lease TTL: rejected + because it addresses stale helper ownership, not active agents issuing + conflicting commands through the same proxy. +- Keep direct proxy as an implicit daemon-base-url mode with separate local + state: rejected because cloud, remote config, and the limrun worktree already + converge on `connect` plus `RemoteConnectionState` as the user-facing + lifecycle. diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index e0b5c4f19..ac06040d5 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -20,6 +20,7 @@ import { import { hasDeferredMetroConfig, materializeRemoteConnectionForCommand, + PROXY_REMOTE_LEASE_TTL_MS, } from '../cli/commands/connection-runtime.ts'; import { stopMetroCompanion } from '../client-metro-companion.ts'; import { AppError } from '../utils/errors.ts'; @@ -74,11 +75,27 @@ function createTestClient( release?: AgentDeviceClient['leases']['release']; prepare?: AgentDeviceClient['metro']['prepare']; closeSession?: AgentDeviceClient['sessions']['close']; + listDevices?: AgentDeviceClient['devices']['list']; } = {}, ): AgentDeviceClient { return { command: createThrowingMethodGroup(), - devices: createThrowingMethodGroup(), + devices: createThrowingMethodGroup({ + list: + options.listDevices ?? + (async () => [ + { + platform: 'android', + target: 'mobile', + kind: 'emulator', + id: 'emulator-5554', + name: 'Android Emulator', + booted: true, + identifiers: { serial: 'emulator-5554' }, + android: { serial: 'emulator-5554' }, + }, + ]), + }), sessions: createThrowingMethodGroup({ close: options.closeSession ?? @@ -182,6 +199,88 @@ test('connect auto-generates a local session and writes minimal remote state', a fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('connect proxy writes normal remote state with generated non-secret profile', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-')); + const stateDir = path.join(tempRoot, '.state'); + + await captureStdout(async () => { + await connectCommand({ + positionals: ['proxy'], + flags: { + json: true, + help: false, + version: false, + stateDir, + daemonBaseUrl: 'http://proxy.example.test/agent-device', + daemonAuthToken: 'proxy-secret', + platform: 'android', + }, + client: createTestClient(), + }); + }); + + const state = readActiveConnectionState({ stateDir }); + assert.ok(state); + assert.match(state.session, /^adc-[a-z0-9]+$/); + assert.equal(state.tenant, 'proxy'); + assert.match(state.runId, /^proxy-[a-f0-9]{16}$/); + assert.equal(state.leaseProvider, 'proxy'); + assert.match(state.clientId ?? '', /^[a-f0-9]{16}$/); + assert.equal(state.leaseBackend, 'android-instance'); + assert.equal(state.leaseId, undefined); + assert.equal(state.daemon?.baseUrl, 'http://proxy.example.test/agent-device'); + assert.match(state.remoteConfigPath, /remote-connections\/generated\/proxy-[a-f0-9]{16}\.json$/); + const generated = JSON.parse(fs.readFileSync(state.remoteConfigPath, 'utf8')) as Record< + string, + unknown + >; + assert.equal(generated.daemonBaseUrl, 'http://proxy.example.test/agent-device'); + assert.equal(generated.daemonAuthToken, undefined); + assert.equal(generated.leaseProvider, 'proxy'); + assert.equal(generated.leaseTtlMs, undefined); + assert.equal(JSON.stringify(generated).includes('proxy-secret'), false); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect proxy rejects remote-config and unknown provider combinations', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-errors-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, '{}'); + + await assert.rejects( + async () => + await connectCommand({ + positionals: ['proxy'], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + }, + client: createTestClient(), + }), + /mutually exclusive/, + ); + + await assert.rejects( + async () => + await connectCommand({ + positionals: ['wat'], + flags: { + json: true, + help: false, + version: false, + stateDir, + }, + client: createTestClient(), + }), + /Supported providers: proxy/, + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('connect reports deferred Metro runtime preparation when remote config has Metro settings', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-metro-notice-')); const stateDir = path.join(tempRoot, '.state'); @@ -399,6 +498,134 @@ test('deferred materialization allocates lease and prepares Metro for open', asy fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('proxy open resolves device key before allocating lease', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-open-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-proxy', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'proxy', + runId: 'proxy-client-1', + leaseProvider: 'proxy', + clientId: 'client-1', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + let allocateRequest: Parameters[0] | undefined; + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'open', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'proxy', + runId: 'proxy-client-1', + session: 'adc-proxy', + platform: 'ios', + }, + client: createTestClient({ + listDevices: async () => [ + { + platform: 'ios', + target: 'mobile', + kind: 'simulator', + id: 'SIM-001', + name: 'iPhone 16', + booted: true, + identifiers: { udid: 'SIM-001' }, + ios: { udid: 'SIM-001' }, + }, + ], + allocate: async (request) => { + allocateRequest = request; + return { + leaseId: 'abc123abc123abc1', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'ios-instance', + leaseProvider: request.leaseProvider, + provider: request.leaseProvider, + clientId: request.clientId, + deviceKey: request.deviceKey, + }; + }, + }), + }); + + assert.equal(allocateRequest?.leaseProvider, 'proxy'); + assert.equal(allocateRequest?.clientId, 'client-1'); + assert.equal(allocateRequest?.deviceKey, 'ios:mobile:SIM-001'); + assert.equal(allocateRequest?.ttlMs, PROXY_REMOTE_LEASE_TTL_MS); + assert.equal(allocateRequest?.leaseBackend, 'ios-instance'); + assert.equal(materialized.flags.leaseId, 'abc123abc123abc1'); + assert.equal(materialized.connection?.deviceKey, 'ios:mobile:SIM-001'); + const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); + assert.equal(state?.leaseId, 'abc123abc123abc1'); + assert.equal(state?.deviceKey, 'ios:mobile:SIM-001'); + assert.equal(state?.leaseProvider, 'proxy'); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('proxy commands without active device lease fail before allocation', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-closed-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-proxy', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + tenant: 'proxy', + runId: 'proxy-client-1', + leaseProvider: 'proxy', + clientId: 'client-1', + leaseBackend: 'ios-instance', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + await assert.rejects( + async () => + await materializeRemoteConnectionForCommand({ + command: 'snapshot', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + tenant: 'proxy', + runId: 'proxy-client-1', + session: 'adc-proxy', + platform: 'ios', + }, + client: createTestClient({ + allocate: async () => { + throw new Error('snapshot should not allocate without proxy device lease'); + }, + }), + }), + /No active proxy device lease for this session; run open first/, + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('direct remote-config materialization creates state and prepares Metro for open', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-direct-remote-open-')); const stateDir = path.join(tempRoot, '.state'); @@ -1486,6 +1713,58 @@ test('disconnect without a session uses active connection state', async () => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('disconnect releases proxy lease with provider client and device metadata', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-disconnect-proxy-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, '{}'); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-proxy', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + tenant: 'proxy', + runId: 'proxy-client-1', + leaseId: 'abc123abc123abc1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + clientId: 'client-1', + deviceKey: 'ios:mobile:SIM-001', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + let releaseRequest: Parameters[0] | undefined; + + await captureStdout(async () => { + await disconnectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + shutdown: true, + }, + client: createTestClient({ + release: async (request) => { + releaseRequest = request; + return { released: true }; + }, + }), + }); + }); + + assert.equal(releaseRequest?.leaseProvider, 'proxy'); + assert.equal(releaseRequest?.clientId, 'client-1'); + assert.equal(releaseRequest?.deviceKey, 'ios:mobile:SIM-001'); + assert.equal(releaseRequest?.leaseId, 'abc123abc123abc1'); + assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' }), null); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('connection status reports missing state without daemon calls', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connection-status-')); let handled = false; diff --git a/src/cli.ts b/src/cli.ts index ffe71a550..89505716a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,10 @@ import { resolveDaemonPaths } from './daemon/config.ts'; import { applyDefaultPlatformBinding, resolveBindingSettings } from './utils/session-binding.ts'; import { resolveCliOptions } from './utils/cli-options.ts'; import { maybeRunUpgradeNotifier } from './utils/update-check.ts'; -import { resolveRemoteConnectionDefaults } from './remote-connection-state.ts'; +import { + resolveRemoteConnectionDefaults, + type RemoteConnectionRequestMetadata, +} from './remote-connection-state.ts'; import { resolveRemoteAuthForCli } from './cli/auth-session.ts'; import type { CliFlags, FlagKey } from './utils/cli-flags.ts'; import type { SessionRuntimeHints } from './contracts.ts'; @@ -223,9 +226,11 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): flags: effectiveFlags, }); let resolvedRuntime = connectionDefaults?.runtime; + let connectionMetadata = connectionDefaults?.connection; const buildClientConfig = ( currentFlags: CliFlags, runtime: SessionRuntimeHints | undefined, + connection: RemoteConnectionRequestMetadata | undefined, ): AgentDeviceClientConfig => ({ session: currentFlags.session, requestId, @@ -239,6 +244,9 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): runId: currentFlags.runId, leaseId: currentFlags.leaseId, leaseBackend: currentFlags.leaseBackend, + leaseProvider: connection?.leaseProvider, + clientId: connection?.clientId, + deviceKey: connection?.deviceKey, runtime, lockPolicy: binding.lockPolicy, lockPlatform: binding.defaultPlatform, @@ -265,7 +273,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): if (effectiveFlags.remoteConfig && shouldMaterializeRemoteConnection(command)) { const materializationClient = createAgentDeviceClient( - buildClientConfig(effectiveFlags, resolvedRuntime), + buildClientConfig(effectiveFlags, resolvedRuntime, connectionMetadata), { transport: deps.sendToDaemon as AgentDeviceDaemonTransport, }, @@ -281,6 +289,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): }); effectiveFlags = materialized.flags; resolvedRuntime = materialized.runtime; + connectionMetadata = materialized.connection; } if ( shouldWarnOpenMayMissRemoteRuntime({ @@ -310,13 +319,16 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): debugOutputEnabled && !effectiveFlags.json && !remoteDaemonBaseUrl ? startDaemonLogTail(daemonPaths.logPath) : null; - const client = createAgentDeviceClient(buildClientConfig(effectiveFlags, resolvedRuntime), { - transport: createCliDaemonTransport({ - command, - flags: effectiveFlags, - transport: deps.sendToDaemon as AgentDeviceDaemonTransport, - }), - }); + const client = createAgentDeviceClient( + buildClientConfig(effectiveFlags, resolvedRuntime, connectionMetadata), + { + transport: createCliDaemonTransport({ + command, + flags: effectiveFlags, + transport: deps.sendToDaemon as AgentDeviceDaemonTransport, + }), + }, + ); if (command === 'batch') { if (!parsedBatchSteps) { throw new AppError('INVALID_ARGS', 'batch requires --steps or --steps-file.'); @@ -459,6 +471,7 @@ function resolveActiveConnectionDefaults(options: { }): { flags: Partial; runtime?: SessionRuntimeHints; + connection?: RemoteConnectionRequestMetadata; } | null { if ( options.command === 'connect' || diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts index 5914daa16..80fe9be2f 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -1,14 +1,14 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { resolveRemoteConfigProfile } from '../remote-config.ts'; -import type { RemoteConfigProfile, ResolvedRemoteConfigProfile } from '../remote-config-schema.ts'; +import type { RemoteConfigProfile } from '../remote-config-schema.ts'; import { profileToCliFlags } from '../utils/remote-config.ts'; -import { AppError, asAppError } from '../utils/errors.ts'; +import { AppError } from '../utils/errors.ts'; import type { CliFlags } from '../utils/cli-flags.ts'; import type { EnvMap } from '../utils/env-map.ts'; import { resolveCloudAccessForConnect } from './auth-session.ts'; import { readCloudJsonResponse } from './cloud-response.ts'; +import { + resolveGeneratedRemoteConfigProfile, + writeGeneratedRemoteConfig, +} from './generated-remote-config.ts'; const CONNECTION_PROFILE_PATH = '/api/control-plane/connection-profile'; const HTTP_TIMEOUT_MS = 15_000; @@ -42,12 +42,14 @@ export async function resolveCloudConnectProfile(options: { }); const remoteConfigPath = writeGeneratedRemoteConfig({ stateDir: options.stateDir, + provider: 'cloud', profile, }); const remoteConfig = resolveGeneratedRemoteConfigProfile({ configPath: remoteConfigPath, cwd: options.cwd, env: options.env, + provider: 'Cloud', }); return { flags: { @@ -107,61 +109,3 @@ function parseRemoteConfigProfile(value: unknown): RemoteConfigProfile { } return value as RemoteConfigProfile; } - -function resolveGeneratedRemoteConfigProfile(options: { - configPath: string; - cwd: string; - env?: EnvMap; -}): ResolvedRemoteConfigProfile { - try { - // Re-read the generated file to reuse the standard env merge, type coercion, and path resolution. - return resolveRemoteConfigProfile(options); - } catch (error) { - const appError = asAppError(error); - throw new AppError( - 'COMMAND_FAILED', - 'Cloud connection profile returned invalid remote config.', - { - generatedConfigPath: options.configPath, - cause: appError.message, - }, - appError, - ); - } -} - -function writeGeneratedRemoteConfig(options: { - stateDir: string; - profile: RemoteConfigProfile; -}): string { - const normalized = normalizeJson(options.profile); - const configDir = path.join(options.stateDir, 'remote-connections', 'generated'); - fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); - const configPath = path.join(configDir, `cloud-${profileHash(normalized)}.json`); - fs.writeFileSync(configPath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 }); - try { - fs.chmodSync(configPath, 0o600); - } catch { - // Best effort on filesystems that do not support POSIX mode bits. - } - return configPath; -} - -function profileHash(value: unknown): string { - return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16); -} - -function normalizeJson(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(normalizeJson); - } - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value as Record) - .filter(([, entryValue]) => entryValue !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entryValue]) => [key, normalizeJson(entryValue)]), - ); - } - return value; -} diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index cc52e429b..42f0a1a09 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -2,14 +2,17 @@ import { resolveDaemonPaths } from '../../daemon/config.ts'; import { stopReactDevtoolsCompanion } from '../../client-react-devtools-companion.ts'; import { stopMetroTunnel } from '../../metro.ts'; import { resolveRemoteConfigProfile } from '../../remote-config.ts'; +import { resolveDevice, type DeviceInfo } from '../../utils/device.ts'; import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; import type { MetroBridgeScope } from '../../client-companion-tunnel-contract.ts'; import { buildRemoteConnectionDaemonState, + buildRemoteConnectionRequestMetadata, hashRemoteConfigFile, readRemoteConnectionState, writeRemoteConnectionState, type RemoteConnectionState, + type RemoteConnectionRequestMetadata, } from '../../remote-connection-state.ts'; import { profileToCliFlags } from '../../utils/remote-config.ts'; import type { BatchStep } from '../../client-types.ts'; @@ -28,6 +31,7 @@ const leaseDeferredCommands = new Set([ 'session', ]); const runtimeDeferredCommands = new Set(['open']); +export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; export async function materializeRemoteConnectionForCommand(options: { command: string; @@ -37,7 +41,11 @@ export async function materializeRemoteConnectionForCommand(options: { positionals?: string[]; batchSteps?: BatchStep[]; forceRuntimePrepare?: boolean; -}): Promise<{ flags: CliFlags; runtime?: SessionRuntimeHints }> { +}): Promise<{ + flags: CliFlags; + runtime?: SessionRuntimeHints; + connection?: RemoteConnectionRequestMetadata; +}> { const { command, flags, client } = options; if (!flags.remoteConfig) { return { flags, runtime: options.runtime }; @@ -72,7 +80,12 @@ export async function materializeRemoteConnectionForCommand(options: { } const state = - existingState ?? createRemoteConnectionStateFromFlags(mergedFlags, remoteConfig.resolvedPath); + existingState ?? + createRemoteConnectionStateFromFlags( + mergedFlags, + remoteConfig.resolvedPath, + remoteConfig.profile, + ); const nextFlags = { ...mergedFlags, session: state.session }; let nextRuntime = selectCompatibleRuntime(state.runtime, nextFlags.platform) ?? options.runtime; let nextState = state; @@ -80,19 +93,41 @@ export async function materializeRemoteConnectionForCommand(options: { let metroCleanupToStop: RemoteConnectionState['metro'] | undefined; let preparedMetroCleanupOnFailure: RemoteConnectionState['metro'] | undefined; - if (shouldAllocateLeaseForCommand(command)) { - const leaseBackend = state.leaseBackend ?? requireRequestedLeaseBackend(flags, command); - assertRequestedConnectionScope(state, flags, leaseBackend); + if (shouldAllocateLeaseForCommand(command, nextState)) { + const preliminaryLeaseBackend = state.leaseBackend ?? resolveRequestedLeaseBackend(nextFlags); + if (nextState.leaseProvider === 'proxy') { + nextState = ( + await resolveProxyLeaseState({ + command, + client, + state: nextState, + flags: nextFlags, + leaseBackend: preliminaryLeaseBackend, + }) + ).state; + } + const leaseBackend = + nextState.leaseBackend ?? + preliminaryLeaseBackend ?? + requireRequestedLeaseBackend(nextFlags, command); + assertRequestedConnectionScope(state, nextFlags, leaseBackend); const lease = await allocateOrReuseLease(client, nextState, leaseBackend); nextFlags.leaseId = lease.leaseId; nextFlags.leaseBackend = leaseBackend; nextFlags.platform = nextState.platform ?? nextFlags.platform; nextFlags.target = nextState.target ?? nextFlags.target; - if (nextState.leaseId !== lease.leaseId || nextState.leaseBackend !== leaseBackend) { + if ( + nextState.leaseId !== lease.leaseId || + nextState.leaseBackend !== leaseBackend || + nextState.deviceKey !== (lease.deviceKey ?? nextState.deviceKey) + ) { nextState = { ...nextState, leaseId: lease.leaseId, leaseBackend, + leaseProvider: lease.leaseProvider ?? lease.provider ?? nextState.leaseProvider, + clientId: lease.clientId ?? nextState.clientId, + deviceKey: lease.deviceKey ?? nextState.deviceKey, platform: nextState.platform ?? flags.platform, target: nextState.target ?? flags.target, updatedAt: new Date().toISOString(), @@ -168,6 +203,7 @@ export async function materializeRemoteConnectionForCommand(options: { target: nextState.target ?? nextFlags.target, }, runtime: nextRuntime, + connection: buildRemoteConnectionRequestMetadata(nextState), }; } @@ -267,6 +303,9 @@ export async function releasePreviousLease( daemonBaseUrl: previous.daemon?.baseUrl, daemonTransport: previous.daemon?.transport, daemonServerMode: previous.daemon?.serverMode, + leaseProvider: previous.leaseProvider, + clientId: previous.clientId, + deviceKey: previous.deviceKey, }); } catch { // Reconnect must succeed even if the old lease was already released. @@ -289,7 +328,8 @@ function requireRequestedLeaseBackend(flags: CliFlags, command: string): LeaseBa ); } -function shouldAllocateLeaseForCommand(command: string): boolean { +function shouldAllocateLeaseForCommand(command: string, state: RemoteConnectionState): boolean { + if (state.leaseProvider === 'proxy' && command === 'devices') return false; return !leaseDeferredCommands.has(command); } @@ -359,6 +399,7 @@ function selectCompatibleRuntime( function createRemoteConnectionStateFromFlags( flags: CliFlags, remoteConfigPath: string, + profile: Pick = {}, ): RemoteConnectionState { if (!flags.tenant) { throw new AppError( @@ -389,6 +430,9 @@ function createRemoteConnectionStateFromFlags( runId: flags.runId, leaseId: flags.leaseId, leaseBackend: flags.leaseBackend ?? resolveRequestedLeaseBackend(flags), + leaseProvider: profile.leaseProvider, + clientId: profile.clientId, + deviceKey: profile.deviceKey, platform: flags.platform, target: flags.target, connectedAt: now, @@ -406,6 +450,10 @@ async function allocateOrReuseLease( tenant: state.tenant, runId: state.runId, leaseBackend, + leaseProvider: state.leaseProvider, + clientId: state.clientId, + deviceKey: state.deviceKey, + ttlMs: leaseTtlMsForConnection(state), }); if (existing) return existing; } @@ -413,9 +461,84 @@ async function allocateOrReuseLease( tenant: state.tenant, runId: state.runId, leaseBackend, + leaseProvider: state.leaseProvider, + clientId: state.clientId, + deviceKey: state.deviceKey, + ttlMs: leaseTtlMsForConnection(state), }); } +async function resolveProxyLeaseState(options: { + command: string; + client: AgentDeviceClient; + state: RemoteConnectionState; + flags: CliFlags; + leaseBackend?: LeaseBackend; +}): Promise<{ state: RemoteConnectionState }> { + if (options.command !== 'open') { + if (options.state.leaseId && options.state.deviceKey) return { state: options.state }; + throw new AppError( + 'INVALID_ARGS', + 'No active proxy device lease for this session; run open first.', + ); + } + const device = await resolveSelectedDevice(options.client, options.flags); + const deviceKey = buildProxyDeviceKey(device); + return { + state: { + ...options.state, + deviceKey, + leaseBackend: + options.state.leaseBackend ?? options.leaseBackend ?? leaseBackendForDevice(device), + platform: options.state.platform ?? device.platform, + target: options.state.target ?? device.target, + updatedAt: new Date().toISOString(), + }, + }; +} + +async function resolveSelectedDevice( + client: AgentDeviceClient, + flags: CliFlags, +): Promise { + const devices = await client.devices.list({ + platform: flags.platform, + target: flags.target, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + }); + return await resolveDevice( + devices.map((device) => ({ + platform: device.platform, + id: device.id, + name: device.name, + kind: device.kind, + target: device.target, + booted: device.booted, + })), + { + platform: flags.platform, + target: flags.target, + deviceName: flags.device, + udid: flags.udid, + serial: flags.serial, + }, + ); +} + +function buildProxyDeviceKey(device: DeviceInfo): string { + return `${device.platform}:${device.target ?? 'mobile'}:${device.id}`; +} + +function leaseBackendForDevice(device: DeviceInfo): LeaseBackend | undefined { + if (device.platform === 'ios') return 'ios-instance'; + if (device.platform === 'android') return 'android-instance'; + return undefined; +} + function assertRequestedConnectionScope( state: RemoteConnectionState, flags: CliFlags, @@ -447,7 +570,15 @@ function assertRequestedConnectionScope( async function heartbeatOrAllocateLease( client: AgentDeviceClient, leaseId: string, - scope: { tenant: string; runId: string; leaseBackend: LeaseBackend }, + scope: { + tenant: string; + runId: string; + leaseBackend: LeaseBackend; + leaseProvider?: RemoteConnectionState['leaseProvider']; + clientId?: string; + deviceKey?: string; + ttlMs?: number; + }, ): Promise { try { return await client.leases.heartbeat({ @@ -455,6 +586,10 @@ async function heartbeatOrAllocateLease( runId: scope.runId, leaseId, leaseBackend: scope.leaseBackend, + leaseProvider: scope.leaseProvider, + clientId: scope.clientId, + deviceKey: scope.deviceKey, + ttlMs: scope.ttlMs, }); } catch (error) { if (isInactiveLeaseError(error)) return undefined; @@ -462,6 +597,10 @@ async function heartbeatOrAllocateLease( } } +function leaseTtlMsForConnection(state: RemoteConnectionState): number | undefined { + return state.leaseProvider === 'proxy' ? PROXY_REMOTE_LEASE_TTL_MS : undefined; +} + function isInactiveLeaseError(error: unknown): boolean { if (!(error instanceof AppError) || error.code !== 'UNAUTHORIZED') return false; return ( diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index cae7a593d..09a094081 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -10,9 +10,11 @@ import { removeRemoteConnectionState, writeRemoteConnectionState, type RemoteConnectionState, + type RemoteConnectionRequestMetadata, } from '../../remote-connection-state.ts'; import { AppError } from '../../utils/errors.ts'; import { resolveCloudConnectProfile } from '../cloud-connection-profile.ts'; +import { resolveProxyConnectProfile } from '../proxy-connection-profile.ts'; import { hasDeferredMetroConfig, releasePreviousLease, @@ -25,17 +27,32 @@ import type { LeaseBackend } from '../../contracts.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import type { ClientCommandHandler } from './router-types.ts'; -export const connectCommand: ClientCommandHandler = async ({ flags, client }) => { +export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const provider = readConnectProvider(positionals); + if (provider && flags.remoteConfig) { + throw new AppError( + 'INVALID_ARGS', + 'connect provider positional and --remote-config are mutually exclusive.', + ); + } const resolved = flags.remoteConfig ? resolveRemoteConnectFlags(flags) - : await resolveCloudConnectProfile({ - flags, - stateDir, - cwd: process.cwd(), - env: process.env, - }); + : provider === 'proxy' + ? resolveProxyConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }) + : await resolveCloudConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); const connectFlags = resolved.flags; + const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath); const tenant = connectFlags.tenant; const runId = connectFlags.runId; if (!tenant) { @@ -73,6 +90,7 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => remoteConfigPath: resolved.remoteConfigPath, remoteConfigHash, desiredLeaseBackend: resolveRequestedLeaseBackend(connectFlags), + connection: connectionMetadata, daemon, }) ) { @@ -99,6 +117,13 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => previous && !connectFlags.force ? previous.leaseBackend : resolveRequestedLeaseBackend(connectFlags), + leaseProvider: + connectionMetadata?.leaseProvider ?? + (previous && !connectFlags.force ? previous.leaseProvider : undefined), + clientId: + connectionMetadata?.clientId ?? + (previous && !connectFlags.force ? previous.clientId : undefined), + deviceKey: previous && !connectFlags.force ? previous.deviceKey : connectionMetadata?.deviceKey, platform: connectFlags.platform ?? (previous && !connectFlags.force ? previous.platform : undefined), target: connectFlags.target ?? (previous && !connectFlags.force ? previous.target : undefined), @@ -148,6 +173,22 @@ function resolveRemoteConnectFlags(flags: CliFlags): { }; } +function readRemoteConfigConnectionMetadata( + remoteConfigPath: string, +): RemoteConnectionRequestMetadata | undefined { + const profile = resolveRemoteConfigProfile({ + configPath: remoteConfigPath, + cwd: process.cwd(), + env: process.env, + }).profile; + const metadata = { + leaseProvider: profile.leaseProvider, + clientId: profile.clientId, + deviceKey: profile.deviceKey, + }; + return Object.values(metadata).some((value) => value !== undefined) ? metadata : undefined; +} + export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { const { session, stateDir, state } = readRequestedConnectionState(flags); if (!state) { @@ -170,6 +211,9 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) tenant: state.tenant, runId: state.runId, leaseId: state.leaseId, + leaseProvider: state.leaseProvider, + clientId: state.clientId, + deviceKey: state.deviceKey, }); released = result.released; } catch { @@ -221,6 +265,19 @@ function createRemoteSessionName(stateDir: string): string { return `adc-${Date.now().toString(36)}-${crypto.randomBytes(2).toString('hex')}`; } +function readConnectProvider(positionals: string[]): 'proxy' | undefined { + const provider = positionals[0]; + if (provider === undefined) return undefined; + if (positionals.length > 1) { + throw new AppError('INVALID_ARGS', 'connect accepts at most one provider positional.'); + } + if (provider === 'proxy') return provider; + throw new AppError( + 'INVALID_ARGS', + `Unknown connect provider: ${provider}. Supported providers: proxy.`, + ); +} + function readRequestedConnectionState(flags: CliFlags): { session: string; stateDir: string; @@ -253,6 +310,7 @@ function isCompatibleConnection( remoteConfigPath: string; remoteConfigHash: string; desiredLeaseBackend?: LeaseBackend; + connection?: RemoteConnectionRequestMetadata; daemon: RemoteConnectionState['daemon']; }, ): boolean { @@ -266,6 +324,10 @@ function isCompatibleConnection( state.leaseBackend === options.desiredLeaseBackend) && (options.flags.platform === undefined || state.platform === options.flags.platform) && (options.flags.target === undefined || state.target === options.flags.target) && + (options.connection?.leaseProvider === undefined || + state.leaseProvider === options.connection.leaseProvider) && + (options.connection?.clientId === undefined || + state.clientId === options.connection.clientId) && isSameDaemonState(state.daemon, options.daemon) ); } diff --git a/src/cli/generated-remote-config.ts b/src/cli/generated-remote-config.ts new file mode 100644 index 000000000..4ca5f990a --- /dev/null +++ b/src/cli/generated-remote-config.ts @@ -0,0 +1,74 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveRemoteConfigProfile } from '../remote-config.ts'; +import type { RemoteConfigProfile, ResolvedRemoteConfigProfile } from '../remote-config-schema.ts'; +import { AppError, asAppError } from '../utils/errors.ts'; +import type { EnvMap } from '../utils/env-map.ts'; + +export function writeGeneratedRemoteConfig(options: { + stateDir: string; + provider: string; + profile: RemoteConfigProfile; +}): string { + const normalized = normalizeJson(options.profile); + const configDir = path.join(options.stateDir, 'remote-connections', 'generated'); + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const configPath = path.join( + configDir, + `${safeProviderName(options.provider)}-${profileHash(normalized)}.json`, + ); + fs.writeFileSync(configPath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 }); + try { + fs.chmodSync(configPath, 0o600); + } catch { + // Best effort on filesystems that do not support POSIX mode bits. + } + return configPath; +} + +export function resolveGeneratedRemoteConfigProfile(options: { + configPath: string; + cwd: string; + env?: EnvMap; + provider: string; +}): ResolvedRemoteConfigProfile { + try { + // Re-read the generated file to reuse the standard env merge, type coercion, and path resolution. + return resolveRemoteConfigProfile(options); + } catch (error) { + const appError = asAppError(error); + throw new AppError( + 'COMMAND_FAILED', + `${options.provider} connection profile returned invalid remote config.`, + { + generatedConfigPath: options.configPath, + cause: appError.message, + }, + appError, + ); + } +} + +function profileHash(value: unknown): string { + return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16); +} + +function normalizeJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeJson); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => [key, normalizeJson(entryValue)]), + ); + } + return value; +} + +function safeProviderName(value: string): string { + return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_') || 'generated'; +} diff --git a/src/cli/proxy-connection-profile.ts b/src/cli/proxy-connection-profile.ts new file mode 100644 index 000000000..2a74608bc --- /dev/null +++ b/src/cli/proxy-connection-profile.ts @@ -0,0 +1,88 @@ +import crypto from 'node:crypto'; +import type { RemoteConfigProfile } from '../remote-config-schema.ts'; +import { profileToCliFlags } from '../utils/remote-config.ts'; +import { AppError } from '../utils/errors.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; +import type { EnvMap } from '../utils/env-map.ts'; +import { + resolveGeneratedRemoteConfigProfile, + writeGeneratedRemoteConfig, +} from './generated-remote-config.ts'; +import { resolveRequestedLeaseBackend } from './commands/connection-runtime.ts'; + +export function resolveProxyConnectProfile(options: { + flags: CliFlags; + stateDir: string; + cwd: string; + env?: EnvMap; +}): { flags: CliFlags; remoteConfigPath: string } { + const daemonBaseUrl = options.flags.daemonBaseUrl ?? options.env?.AGENT_DEVICE_DAEMON_BASE_URL; + if (!daemonBaseUrl) { + throw new AppError( + 'INVALID_ARGS', + 'connect proxy requires --daemon-base-url or AGENT_DEVICE_DAEMON_BASE_URL.', + ); + } + const clientId = buildProxyClientId(options.stateDir, daemonBaseUrl); + const profile: RemoteConfigProfile = { + daemonBaseUrl, + daemonTransport: options.flags.daemonTransport ?? 'http', + daemonServerMode: options.flags.daemonServerMode, + tenant: options.flags.tenant ?? 'proxy', + sessionIsolation: options.flags.sessionIsolation ?? 'tenant', + runId: options.flags.runId ?? `proxy-${clientId}`, + leaseProvider: 'proxy', + clientId, + leaseBackend: options.flags.leaseBackend ?? resolveRequestedLeaseBackend(options.flags), + platform: options.flags.platform, + target: options.flags.target, + device: options.flags.device, + udid: options.flags.udid, + serial: options.flags.serial, + iosSimulatorDeviceSet: options.flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: options.flags.androidDeviceAllowlist, + session: options.flags.session, + metroProjectRoot: options.flags.metroProjectRoot, + metroKind: options.flags.metroKind, + metroPublicBaseUrl: options.flags.metroPublicBaseUrl, + metroProxyBaseUrl: options.flags.metroProxyBaseUrl, + metroBearerToken: options.flags.metroBearerToken, + metroPreparePort: options.flags.metroPreparePort, + metroListenHost: options.flags.metroListenHost, + metroStatusHost: options.flags.metroStatusHost, + metroStartupTimeoutMs: options.flags.metroStartupTimeoutMs, + metroProbeTimeoutMs: options.flags.metroProbeTimeoutMs, + metroRuntimeFile: options.flags.metroRuntimeFile, + metroNoReuseExisting: options.flags.metroNoReuseExisting, + metroNoInstallDeps: options.flags.metroNoInstallDeps, + }; + const remoteConfigPath = writeGeneratedRemoteConfig({ + stateDir: options.stateDir, + provider: 'proxy', + profile, + }); + const remoteConfig = resolveGeneratedRemoteConfigProfile({ + configPath: remoteConfigPath, + cwd: options.cwd, + env: options.env, + provider: 'Proxy', + }); + return { + flags: { + ...profileToCliFlags(remoteConfig.profile), + ...options.flags, + remoteConfig: remoteConfig.resolvedPath, + daemonBaseUrl, + daemonTransport: options.flags.daemonTransport ?? 'http', + }, + remoteConfigPath: remoteConfig.resolvedPath, + }; +} + +function buildProxyClientId(stateDir: string, daemonBaseUrl: string): string { + return crypto + .createHash('sha256') + .update(`${stateDir}\0${daemonBaseUrl}`) + .digest('hex') + .slice(0, 16); +} diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 213fa90ab..af477c1f0 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -157,12 +157,13 @@ function buildClientDevicePlatformFields( } export function normalizeRuntimeHints(value: unknown): SessionRuntimeHints | undefined { - if (!isRecord(value)) return undefined; - const platform = value.platform; - const metroHost = readOptionalString(value, 'metroHost'); - const metroPort = typeof value.metroPort === 'number' ? value.metroPort : undefined; - const bundleUrl = readOptionalString(value, 'bundleUrl'); - const launchUrl = readOptionalString(value, 'launchUrl'); + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const platform = record.platform; + const metroHost = readOptionalString(record, 'metroHost'); + const metroPort = typeof record.metroPort === 'number' ? record.metroPort : undefined; + const bundleUrl = readOptionalString(record, 'bundleUrl'); + const launchUrl = readOptionalString(record, 'launchUrl'); return { platform: platform === 'ios' || platform === 'android' ? platform : undefined, metroHost, @@ -210,20 +211,21 @@ export function normalizeOpenDevice( } export function normalizeStartupSample(value: unknown): StartupPerfSample | undefined { - if (!isRecord(value)) return undefined; + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; if ( - typeof value.durationMs !== 'number' || - typeof value.measuredAt !== 'string' || - typeof value.method !== 'string' + typeof record.durationMs !== 'number' || + typeof record.measuredAt !== 'string' || + typeof record.method !== 'string' ) { return undefined; } return { - durationMs: value.durationMs, - measuredAt: value.measuredAt, - method: value.method, - appTarget: readOptionalString(value, 'appTarget'), - appBundleId: readOptionalString(value, 'appBundleId'), + durationMs: record.durationMs, + measuredAt: record.measuredAt, + method: record.method, + appTarget: readOptionalString(record, 'appTarget'), + appBundleId: readOptionalString(record, 'appBundleId'), }; } @@ -362,6 +364,9 @@ export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta' leaseId: options.leaseId, leaseBackend: options.leaseBackend, leaseTtlMs: options.leaseTtlMs, + leaseProvider: options.leaseProvider, + clientId: options.clientId, + deviceKey: options.deviceKey, sessionIsolation: options.sessionIsolation, installSource: options.installSource, retainMaterializedPaths: options.retainMaterializedPaths, diff --git a/src/client-types.ts b/src/client-types.ts index f414478f4..a14cfca43 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -71,6 +71,10 @@ export type AgentDeviceClientConfig = { runId?: string; leaseId?: string; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + leaseTtlMs?: number; runtime?: SessionRuntimeHints; cwd?: string; debug?: boolean; @@ -94,6 +98,10 @@ export type AgentDeviceRequestOverrides = Pick< | 'runId' | 'leaseId' | 'leaseBackend' + | 'leaseProvider' + | 'deviceKey' + | 'clientId' + | 'leaseTtlMs' | 'cwd' | 'debug' | 'iosXctestrunFile' @@ -273,6 +281,10 @@ export type Lease = { tenantId: string; runId: string; backend: LeaseBackend; + leaseProvider?: string; + provider?: string; + deviceKey?: string; + clientId?: string; createdAt?: number; heartbeatAt?: number; expiresAt?: number; @@ -286,12 +298,21 @@ export type LeaseAllocateOptions = LeaseOptions & { tenant: string; runId: string; leaseBackend?: LeaseBackend; + leaseProvider?: string; + provider?: string; + deviceKey?: string; + clientId?: string; }; export type LeaseScopedOptions = LeaseOptions & { tenant?: string; runId?: string; leaseId: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + provider?: string; + deviceKey?: string; + clientId?: string; }; export type MetroPrepareOptions = { diff --git a/src/client.ts b/src/client.ts index 6b6b95826..d9b020208 100644 --- a/src/client.ts +++ b/src/client.ts @@ -388,6 +388,10 @@ function normalizeLease(data: Record): Lease { tenantId: readRequiredString(rawLease, 'tenantId'), runId: readRequiredString(rawLease, 'runId'), backend: readRequiredString(rawLease, 'backend') as Lease['backend'], + leaseProvider: readOptionalString(rawLease, 'leaseProvider'), + provider: readOptionalString(rawLease, 'provider') as Lease['provider'], + clientId: readOptionalString(rawLease, 'clientId'), + deviceKey: readOptionalString(rawLease, 'deviceKey'), createdAt: typeof rawLease.createdAt === 'number' ? rawLease.createdAt : undefined, heartbeatAt: typeof rawLease.heartbeatAt === 'number' ? rawLease.heartbeatAt : undefined, expiresAt: typeof rawLease.expiresAt === 'number' ? rawLease.expiresAt : undefined, diff --git a/src/contracts.ts b/src/contracts.ts index c2a3a514c..cef68f9a1 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -68,6 +68,9 @@ export type DaemonRequestMeta = { leaseId?: string; leaseTtlMs?: number; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; sessionIsolation?: SessionIsolationMode; uploadedArtifactId?: string; clientArtifactPaths?: Record; @@ -129,6 +132,9 @@ export type LeaseAllocatePayload = { runId?: string; ttlMs?: number; backend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; export type LeaseHeartbeatPayload = { @@ -139,6 +145,10 @@ export type LeaseHeartbeatPayload = { runId?: string; leaseId?: string; ttlMs?: number; + backend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; export type LeaseReleasePayload = { @@ -148,6 +158,10 @@ export type LeaseReleasePayload = { tenant?: string; runId?: string; leaseId?: string; + backend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; export type JsonRpcId = string | number | null; @@ -225,6 +239,37 @@ function optionalString( return value === undefined ? undefined : expectString(value, `${path}.${key}`); } +function optionalDeviceKey( + record: Record, + key: string, + path: string, +): string | undefined { + const value = optionalString(record, key, path); + if (value === undefined) return undefined; + const trimmed = value.trim(); + if (!trimmed || value.length > 256 || !/^[\x20-\x7E]+$/.test(value)) { + fail(`${path}.${key}`, 'Expected 1-256 printable characters'); + } + return value; +} + +function optionalIdentifier( + record: Record, + key: string, + path: string, + maxLength: number, +): string | undefined { + const value = optionalString(record, key, path); + if (value === undefined) return undefined; + if (value.length < 1 || value.length > maxLength || !/^[a-zA-Z0-9._-]+$/.test(value)) { + fail( + `${path}.${key}`, + `Expected 1-${String(maxLength)} chars: letters, numbers, dot, underscore, hyphen`, + ); + } + return value; +} + function optionalBoolean( record: Record, key: string, @@ -374,6 +419,9 @@ export const daemonCommandRequestSchema = schema((input, path) => leaseId: optionalString(meta, 'leaseId', `${path}.meta`), leaseTtlMs: optionalInteger(meta, 'leaseTtlMs', `${path}.meta`), leaseBackend: optionalEnum(meta, 'leaseBackend', LEASE_BACKENDS, `${path}.meta`), + leaseProvider: optionalIdentifier(meta, 'leaseProvider', `${path}.meta`, 64), + deviceKey: optionalDeviceKey(meta, 'deviceKey', `${path}.meta`), + clientId: optionalIdentifier(meta, 'clientId', `${path}.meta`, 128), sessionIsolation: optionalEnum( meta, 'sessionIsolation', @@ -431,6 +479,9 @@ function parseLeaseScope( tenantId?: string; tenant?: string; runId?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; } { return { token: optionalString(record, 'token', path), @@ -438,6 +489,9 @@ function parseLeaseScope( tenantId: optionalString(record, 'tenantId', path), tenant: optionalString(record, 'tenant', path), runId: optionalString(record, 'runId', path), + leaseProvider: optionalIdentifier(record, 'leaseProvider', path, 64), + deviceKey: optionalDeviceKey(record, 'deviceKey', path), + clientId: optionalIdentifier(record, 'clientId', path, 128), }; } @@ -456,6 +510,7 @@ export const leaseHeartbeatSchema = schema((input, path) ...parseLeaseScope(parsed.record, path), leaseId: parsed.leaseId, ttlMs: parsed.ttlMs, + backend: optionalEnum(parsed.record, 'backend', LEASE_BACKENDS, path), }; }); @@ -467,6 +522,7 @@ export const leaseReleaseSchema = schema((input, path) => { return { ...parseLeaseScope(record, path), leaseId: optionalString(record, 'leaseId', path), + backend: optionalEnum(record, 'backend', LEASE_BACKENDS, path), }; }); diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 1077543e9..7c829e28a 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -6,6 +6,7 @@ import type { ClickButton } from './click-button.ts'; import type { ElementSelectorKey } from './interactor-types.ts'; import type { SwipePattern } from './scroll-gesture.ts'; import type { SessionSurface } from './session-surface.ts'; +import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; export type MaestroRuntimeFlags = { allowNonHittableCoordinateFallback?: boolean; @@ -43,6 +44,7 @@ export type DispatchContext = ScreenshotDispatchFlags & { iosXctestrunFile?: string; iosXctestDerivedDataPath?: string; iosXctestEnvDir?: string; + runnerLeaseContext?: RunnerLogicalLeaseContext; snapshotInteractiveOnly?: boolean; snapshotDepth?: number; snapshotScope?: string; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 154b6ce4a..8072baa62 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -54,6 +54,7 @@ export async function dispatchCommand( iosXctestrunFile: context?.iosXctestrunFile, iosXctestDerivedDataPath: context?.iosXctestDerivedDataPath, iosXctestEnvDir: context?.iosXctestEnvDir, + runnerLeaseContext: context?.runnerLeaseContext, }; const interactor = await getInteractor(device, runnerCtx); emitDiagnostic({ diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index f357be18f..d26e0cdfa 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -4,6 +4,7 @@ import type { ScrollDirection, TransformGestureParams } from './scroll-gesture.t import type { SettingOptions } from '../platforms/permission-utils.ts'; import type { SessionSurface } from './session-surface.ts'; import type { BackendSnapshotResult } from '../backend.ts'; +import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; import type { RawSnapshotNode, SnapshotBackend, @@ -19,6 +20,7 @@ export type RunnerContext = { iosXctestrunFile?: string; iosXctestDerivedDataPath?: string; iosXctestEnvDir?: string; + runnerLeaseContext?: RunnerLogicalLeaseContext; }; /** Subset of {@link RunnerContext} forwarded to runner command invocations. */ @@ -31,6 +33,7 @@ export type RunnerCallOptions = Pick< | 'iosXctestrunFile' | 'iosXctestDerivedDataPath' | 'iosXctestEnvDir' + | 'runnerLeaseContext' >; export type { BackMode }; diff --git a/src/core/runner-lease-context.ts b/src/core/runner-lease-context.ts new file mode 100644 index 000000000..d6dd5c411 --- /dev/null +++ b/src/core/runner-lease-context.ts @@ -0,0 +1,8 @@ +export type RunnerLogicalLeaseContext = { + leaseId?: string; + clientId?: string; + tenantId?: string; + runId?: string; + leaseProvider?: string; + deviceKey?: string; +}; diff --git a/src/daemon-client-rpc.ts b/src/daemon-client-rpc.ts index a0b21a531..53f97ad90 100644 --- a/src/daemon-client-rpc.ts +++ b/src/daemon-client-rpc.ts @@ -143,6 +143,10 @@ function buildLeaseRpcParams( session: req.session, tenantId: req.meta?.tenantId, runId: req.meta?.runId, + leaseProvider: req.meta?.leaseProvider, + provider: req.meta?.leaseProvider, + clientId: req.meta?.clientId, + deviceKey: req.meta?.deviceKey, }; switch (command) { case 'lease_allocate': diff --git a/src/daemon-runtime.ts b/src/daemon-runtime.ts index 3c7431757..e1a8786b7 100644 --- a/src/daemon-runtime.ts +++ b/src/daemon-runtime.ts @@ -33,6 +33,7 @@ import { } from './daemon/transport.ts'; import { prewarmPngWorker, terminatePngWorker } from './utils/png-worker-client.ts'; import { sleep } from './utils/timeouts.ts'; +import { setRunnerLeaseOwnerStateDir } from './platforms/ios/runner-lease.ts'; const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000; const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000; @@ -66,6 +67,7 @@ export async function startDaemonRuntime( const daemonPaths = resolveDaemonPaths(env.AGENT_DEVICE_STATE_DIR); const { baseDir, infoPath, lockPath, logPath, sessionsDir } = daemonPaths; const daemonServerMode = resolveDaemonServerMode(env.AGENT_DEVICE_DAEMON_SERVER_MODE); + setRunnerLeaseOwnerStateDir(baseDir); cleanupStaleAppLogProcesses(sessionsDir); @@ -182,6 +184,7 @@ export async function startDaemonRuntime( }; if (!acquireDaemonLock(baseDir, lockPath, lockData)) { stderr.write('Daemon lock is held by another process; exiting.\n'); + setRunnerLeaseOwnerStateDir(undefined); exit(0); return null; } @@ -201,6 +204,7 @@ export async function startDaemonRuntime( closeServersBestEffort(servers); removeInfo(infoPath); releaseDaemonLock(lockPath); + setRunnerLeaseOwnerStateDir(undefined); exit(1); return null; } @@ -228,6 +232,7 @@ export async function startDaemonRuntime( ]); removeInfo(infoPath); releaseDaemonLock(lockPath); + setRunnerLeaseOwnerStateDir(undefined); exit(shutdownOptions.exitCode ?? 0); }; diff --git a/src/daemon/__tests__/lease-context.test.ts b/src/daemon/__tests__/lease-context.test.ts new file mode 100644 index 000000000..4f51b7b53 --- /dev/null +++ b/src/daemon/__tests__/lease-context.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + buildLeaseDiagnosticsContext, + buildSessionLeaseFromRequest, + resolveRequestOrSessionLeaseScope, + type SessionLease, +} from '../lease-context.ts'; +import type { DaemonRequest } from '../types.ts'; + +test('buildSessionLeaseFromRequest captures complete request lease scope', () => { + const lease = buildSessionLeaseFromRequest({ + meta: { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }, + }); + + assert.deepEqual(lease, { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }); +}); + +test('buildSessionLeaseFromRequest skips incomplete lease scope', () => { + assert.equal( + buildSessionLeaseFromRequest({ + meta: { + tenantId: 'tenant-a', + runId: 'run-1', + }, + }), + undefined, + ); +}); + +test('resolveRequestOrSessionLeaseScope lets explicit request fields override session lease', () => { + const sessionLease: SessionLease = { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-session', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }; + + const scope = resolveRequestOrSessionLeaseScope( + { + meta: { + leaseId: 'lease-request', + leaseProvider: 'limrun', + }, + }, + { lease: sessionLease }, + ); + + assert.deepEqual(scope, { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-request', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + deviceKey: 'device-1', + clientId: 'client-a', + }); +}); + +test('resolveRequestOrSessionLeaseScope accepts deviceLease as session compatibility input', () => { + const scope = resolveRequestOrSessionLeaseScope({} satisfies Partial, { + deviceLease: { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-session', + }, + }); + + assert.deepEqual(scope, { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-session', + }); +}); + +test('buildLeaseDiagnosticsContext strips ttl and empty fields', () => { + const context = buildLeaseDiagnosticsContext({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseTtlMs: 60_000, + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + + assert.deepEqual(context, { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + assert.equal(buildLeaseDiagnosticsContext({}), undefined); +}); diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index f260a3a0e..d995e05db 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -98,3 +98,148 @@ test('capacity limits reject additional simulator leases', () => { /No simulator lease capacity available/, ); }); + +test('device-aware allocation is idempotent per tenant/run/backend/provider/device', () => { + let now = 1_000; + const registry = new LeaseRegistry({ + now: () => now, + defaultLeaseTtlMs: 10_000, + }); + const first = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }); + + now = 3_000; + const second = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }); + + assert.equal(second.leaseId, first.leaseId); + assert.equal(second.leaseProvider, 'proxy'); + assert.equal(second.provider, 'proxy'); + assert.equal(second.deviceKey, 'device-1'); + assert.equal(second.clientId, 'client-a'); + assert.equal(second.heartbeatAt, 3_000); + assert.equal(second.expiresAt, 13_000); +}); + +test('same backend/provider/device rejects conflicting active lease', () => { + const registry = new LeaseRegistry(); + const first = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + + assert.throws( + () => + registry.allocateLease({ + tenantId: 'tenant-b', + runId: 'run-2', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }), + (error) => + error instanceof Error && + error.message === 'Device is already leased' && + (error as { details?: Record }).details?.reason === 'DEVICE_LEASE_BUSY' && + (error as { details?: Record }).details?.leaseId === first.leaseId, + ); +}); + +test('device leases are isolated by provider and device key', () => { + const registry = new LeaseRegistry(); + const proxy = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + const limrun = registry.allocateLease({ + tenantId: 'tenant-b', + runId: 'run-2', + backend: 'ios-instance', + leaseProvider: 'limrun', + deviceKey: 'device-1', + }); + const secondDevice = registry.allocateLease({ + tenantId: 'tenant-c', + runId: 'run-3', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-2', + }); + + assert.notEqual(limrun.leaseId, proxy.leaseId); + assert.notEqual(secondDevice.leaseId, proxy.leaseId); +}); + +test('heartbeat enforces device and provider scope when supplied', () => { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }); + + assert.throws( + () => registry.heartbeatLease({ leaseId: lease.leaseId, deviceKey: 'device-2' }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_MISMATCH', + ); + assert.throws( + () => registry.heartbeatLease({ leaseId: lease.leaseId, leaseProvider: 'limrun' }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_MISMATCH', + ); + assert.throws( + () => registry.heartbeatLease({ leaseId: lease.leaseId, clientId: 'client-b' }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_MISMATCH', + ); +}); + +test('expired device lease releases device binding for new clients', () => { + let now = 1_000; + const registry = new LeaseRegistry({ + now: () => now, + defaultLeaseTtlMs: 5_000, + }); + const first = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + + now = 7_000; + const second = registry.allocateLease({ + tenantId: 'tenant-b', + runId: 'run-2', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + + assert.notEqual(second.leaseId, first.leaseId); +}); diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index fa1749aaf..6156e0263 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -6,7 +6,9 @@ import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../utils import { makeAndroidSession, makeIosSession, + makeSession, } from '../../__tests__/test-utils/session-factories.ts'; +import { LINUX_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { clearRequestCanceled, markRequestCanceled } from '../request-cancel.ts'; @@ -27,7 +29,13 @@ afterAll(() => { test('createRequestExecutionScope applies tenant scoping and lease admission', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); - const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + }); const scope = await createRequestExecutionScope({ req: makeRequest({ @@ -114,6 +122,186 @@ test('createRequestExecutionScope rejects tenant requests without an active leas ).rejects.toThrow(/Lease is not active/); }); +test('leased session admission uses stored lease metadata and heartbeats', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ now: () => now }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + expiresAt: lease.expiresAt, + }, + }), + ); + now = 2_000; + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry, + }); + + expect(scope.sessionName).toBe('default'); + const activeLease = leaseRegistry.listActiveLeases()[0]; + expect(activeLease?.heartbeatAt).toBe(2_000); + expect(activeLease?.expiresAt).toBe(302_000); + expect(sessionStore.get('default')?.lease?.expiresAt).toBe(302_000); +}); + +test('leased session rejects mismatched lease id before dispatch', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + }, + }), + ); + + await expect( + createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) } }), + sessionStore, + leaseRegistry, + }), + ).rejects.toThrow(/Lease does not match session owner \(leaseId\)/); +}); + +test.each([ + ['leaseProvider', { leaseProvider: 'cloud' }], + ['clientId', { clientId: 'client-b' }], + ['deviceKey', { deviceKey: 'ios:SIM-002' }], +] as const)('leased session rejects mismatched %s before dispatch', async (_field, meta) => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-001', + }, + }), + ); + + await expect( + createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot', meta }), + sessionStore, + leaseRegistry, + }), + ).rejects.toThrow(/Lease does not match session owner/); +}); + +test('local unleased session admission still succeeds', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + sessionStore.set('default', makeIosSession('default')); + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry: new LeaseRegistry(), + }); + + expect(scope.sessionName).toBe('default'); +}); + +test('provider lease admission succeeds without a device key', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + backend: 'android-instance', + leaseProvider: 'limrun', + }); + sessionStore.set( + 'default', + makeAndroidSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'limrun', + }, + }), + ); + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry, + }); + + expect(scope.sessionName).toBe('default'); +}); + +test('expired leases remove owned sessions before the next command and free capacity', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ + maxActiveSimulatorLeases: 1, + defaultLeaseTtlMs: 10, + minLeaseTtlMs: 1, + now: () => now, + }); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeSession('default', { + device: LINUX_DEVICE, + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + expiresAt: lease.expiresAt, + }, + }), + ); + now = 1_011; + + await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry, + }); + + expect(sessionStore.get('default')).toBeUndefined(); + const nextLease = leaseRegistry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2' }); + expect(nextLease.tenantId).toBe('tenant-b'); +}); + test('tenant lease rejection flushes diagnostics into the effective session request log', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const requestId = 'tenant-lease-rejection'; diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index db53cafef..e987c7eb5 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -90,6 +90,58 @@ test('lease handler executes commands owned by the lease route', async () => { } }); +test('lease handler preserves device-aware lease fields', async () => { + const leaseRegistry = new LeaseRegistry(); + const allocateResponse = await handleLeaseCommands({ + req: { + command: INTERNAL_COMMANDS.leaseAllocate, + token: 'test-token', + session: 'catalog-test', + meta: { + tenantId: 'tenant-a', + runId: 'run-a', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }, + positionals: [], + }, + leaseRegistry, + }); + + assert.equal(allocateResponse?.ok, true); + const allocateLease = readLeaseResponse(allocateResponse); + assert.equal(allocateLease.deviceKey, 'device-1'); + assert.equal(allocateLease.clientId, 'client-a'); + assert.equal(allocateLease.leaseProvider, 'proxy'); + + const heartbeatResponse = await handleLeaseCommands({ + req: { + command: INTERNAL_COMMANDS.leaseHeartbeat, + token: 'test-token', + session: 'catalog-test', + meta: { + tenantId: 'tenant-a', + runId: 'run-a', + leaseId: allocateLease.leaseId, + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }, + positionals: [], + }, + leaseRegistry, + }); + + assert.equal(heartbeatResponse?.ok, true); + const heartbeatLease = readLeaseResponse(heartbeatResponse); + assert.equal(heartbeatLease.deviceKey, 'device-1'); + assert.equal(heartbeatLease.clientId, 'client-a'); + assert.equal(heartbeatLease.leaseProvider, 'proxy'); +}); + function catalogCommandsForRoute(route: Exclude): string[] { return [...Object.values(PUBLIC_COMMANDS), ...Object.values(INTERNAL_COMMANDS)].filter( (command) => getDaemonCommandRoute(command) === route, @@ -154,3 +206,13 @@ function assertNoRoutingMismatch(error: unknown, command: string): void { assert.ok(error instanceof Error, `${command} threw a non-error value`); assert.doesNotMatch(error.message, new RegExp(ROUTING_MISMATCH_MESSAGE), command); } + +function readLeaseResponse(response: DaemonResponse | null): Record & { + leaseId: string; +} { + assert.ok(response?.ok); + const lease = response.data?.lease; + assert.ok(lease && typeof lease === 'object' && !Array.isArray(lease)); + assert.equal(typeof (lease as Record).leaseId, 'string'); + return lease as Record & { leaseId: string }; +} diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 4d129dd8e..3e770307a 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -29,24 +29,32 @@ function makeIosDevice(id: string): DeviceInfo { }; } -function createOpenHandler(sessionStore: ReturnType) { +function createOpenHandler( + sessionStore: ReturnType, + leaseRegistry = new LeaseRegistry(), +) { return createRequestHandler({ logPath: path.join(os.tmpdir(), 'daemon.log'), token: 'test-token', sessionStore, - leaseRegistry: new LeaseRegistry(), + leaseRegistry, trackDownloadableArtifact: () => 'artifact-id', }); } -function openRequest(session: string, flags: Record, requestId: string) { +function openRequest( + session: string, + flags: Record, + requestId: string, + meta: Record = {}, +) { return { token: 'test-token', session, command: 'open', positionals: [], flags, - meta: { requestId }, + meta: { requestId, ...meta }, }; } @@ -82,6 +90,142 @@ test('open returns and creates the session state directory', async () => { } }); +test('open stores admitted lease metadata on the session', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const leaseRegistry = new LeaseRegistry({ now: () => 1_000 }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-LEASED', + }); + const device = makeIosDevice('SIM-LEASED'); + mockResolveTargetDevice.mockResolvedValue(device); + + const handler = createOpenHandler(sessionStore, leaseRegistry); + + const response = await handler( + openRequest('default', { platform: 'ios' }, 'req-open-lease', { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: lease.leaseId, + sessionIsolation: 'tenant', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-LEASED', + leaseBackend: 'ios-simulator', + }), + ); + + expect(response.ok).toBe(true); + expect(sessionStore.get('tenant-a:default')?.lease).toEqual({ + leaseId: lease.leaseId, + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-simulator', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-LEASED', + expiresAt: 301_000, + }); +}); + +test('proxy open without required lease metadata fails before device resolution', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const handler = createOpenHandler(sessionStore, new LeaseRegistry()); + + const response = await handler( + openRequest('default', { platform: 'ios' }, 'req-open-proxy-missing', { + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + sessionIsolation: 'tenant', + }), + ); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/Proxy open requires leaseId/); + } + expect(mockResolveTargetDevice).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); +}); + +test('close releases the session lease', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + clientId: 'client-a', + }); + sessionStore.set('default', { + name: 'default', + device: makeIosDevice('SIM-CLOSE'), + createdAt: Date.now(), + actions: [], + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + clientId: 'client-a', + }, + }); + const handler = createOpenHandler(sessionStore, leaseRegistry); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'close', + positionals: [], + meta: { requestId: 'req-close-lease' }, + }); + + expect(response.ok).toBe(true); + expect(sessionStore.get('default')).toBeUndefined(); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); + +test('close rejects a different client before cleanup', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + clientId: 'client-a', + }); + sessionStore.set('default', { + name: 'default', + device: makeIosDevice('SIM-CLOSE-CLIENT'), + createdAt: Date.now(), + actions: [], + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + clientId: 'client-a', + }, + }); + const handler = createOpenHandler(sessionStore, leaseRegistry); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'close', + positionals: [], + meta: { requestId: 'req-close-wrong-client', clientId: 'client-b' }, + }); + + expect(response.ok).toBe(false); + expect(sessionStore.get('default')).toBeDefined(); + expect(leaseRegistry.listActiveLeases()).toHaveLength(1); + expect(mockDispatch).not.toHaveBeenCalled(); +}); + test('router serializes same-device open requests before first session creation finishes', async () => { const sessionStore = makeSessionStore('agent-device-router-open-'); const sameDevice = makeIosDevice('SIM-001'); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index c7eacdfb1..3c3fe4abd 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -108,6 +108,32 @@ test('defaultTracePath sanitizes session name', () => { assert.match(tracePath, /\.trace\.log$/); }); +test('session lease metadata round-trips through the store', () => { + const { store, session } = makeFixture('agent-device-session-lease-'); + session.lease = { + leaseId: 'f'.repeat(32), + tenantId: 'tenant-a', + runId: 'run-1', + clientId: 'client-a', + leaseBackend: 'ios-simulator', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + expiresAt: 123_456, + }; + + store.set(session.name, session); + + assert.deepEqual(store.get(session.name)?.lease, session.lease); +}); + +test('sessions without lease metadata remain valid', () => { + const { store, session } = makeFixture('agent-device-session-unleased-'); + + store.set(session.name, session); + + assert.equal(store.get(session.name)?.lease, undefined); +}); + test('saveScript flag enables .ad session log writing', () => { const { root, store, session } = makeFixture('agent-device-session-log-enabled-'); recordOpen(store, session); diff --git a/src/daemon/context.ts b/src/daemon/context.ts index ffd99cfd3..e7c8869b0 100644 --- a/src/daemon/context.ts +++ b/src/daemon/context.ts @@ -5,6 +5,8 @@ import { type ScreenshotRuntimeFlags, } from '../contracts/screenshot.ts'; import { getDiagnosticsMeta } from '../utils/diagnostics.ts'; +import { resolveRunnerLogicalLeaseContext } from './lease-context.ts'; +import type { DaemonRequest } from './types.ts'; export type DaemonCommandContext = DispatchContext & ScreenshotRuntimeFlags; @@ -17,11 +19,13 @@ export function contextFromFlags( appBundleId?: string, traceLogPath?: string, requestId?: string, + meta?: DaemonRequest['meta'], ): DaemonCommandContext { const effectiveRequestId = requestId ?? getDiagnosticsMeta().requestId; return { requestId: effectiveRequestId, appBundleId, + runnerLeaseContext: resolveRunnerLogicalLeaseContext({ meta }), activity: flags?.activity, launchConsole: flags?.launchConsole, launchArgs: flags?.launchArgs, diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index e535f797a..c089b9f32 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -124,7 +124,7 @@ const DAEMON_COMMAND_DESCRIPTORS = [ descriptor(PUBLIC_COMMANDS.record, 'recordTrace', { replayScopedAction: true, allowInvalidRecording: true, - allowSessionlessDefaultDevice: isRecordStartRequest, + allowSessionlessDefaultDevice: isRecordingStartRequest, }), descriptor(PUBLIC_COMMANDS.trace, 'recordTrace'), descriptor(PUBLIC_COMMANDS.find, 'find', { replayScopedAction: true }), @@ -261,7 +261,7 @@ function buildDaemonCommandRegistry(descriptors: readonly DaemonCommandDescripto return { descriptorsByCommand }; } -function isRecordStartRequest(req: DaemonRequest): boolean { +function isRecordingStartRequest(req: DaemonRequest): boolean { return (req.positionals?.[0] ?? '').toLowerCase() === 'start'; } diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index ad36e01ac..186ff2a9c 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -16,6 +16,9 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { - const { req, sessionName, logPath, sessionStore } = params; + const { req, sessionName, logPath, sessionStore, leaseRegistry = new LeaseRegistry() } = params; const session = sessionStore.get(sessionName); if (!session) { return await closeWithoutSession(req, logPath); @@ -163,6 +165,7 @@ export async function handleCloseCommand(params: { } sessionStore.writeSessionLog(session); await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); + releaseSessionLease(session, leaseRegistry); sessionStore.delete(sessionName); const shutdownResult = await maybeShutdownSessionTarget({ device: session.device, @@ -180,6 +183,29 @@ export async function handleCloseCommand(params: { return { ok: true, data: { session: session.name, ...successText(`Closed: ${session.name}`) } }; } +function releaseSessionLease(session: SessionState, leaseRegistry: LeaseRegistry): void { + const lease = session.lease; + if (!lease) return; + const result = leaseRegistry.releaseLease({ + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.leaseBackend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }); + emitDiagnostic({ + level: 'info', + phase: 'session_lease_released', + data: { + session: session.name, + leaseId: lease.leaseId, + released: result.released, + }, + }); +} + function shouldDispatchPlatformClose(req: DaemonRequest, session: SessionState): boolean { return hasCloseTarget(req) || session.device.platform === 'web'; } diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 2fd6e29e0..664e7da2d 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -43,6 +43,7 @@ import { resolveImplicitSessionScope, resolvePublicSessionName, } from '../session-routing.ts'; +import { buildSessionLeaseFromRequest } from '../lease-context.ts'; const firstSessionOpenLocks = new Map>(); @@ -188,6 +189,14 @@ async function completeOpenCommand(params: { logPath, traceLogPath, requestId: req.meta?.requestId, + runnerLeaseContext: contextFromFlags( + logPath, + req.flags, + sessionAppBundleId, + traceLogPath, + req.meta?.requestId, + req.meta, + ).runnerLeaseContext, iosXctestrunFile: req.flags?.iosXctestrunFile, iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, iosXctestEnvDir: req.flags?.iosXctestEnvDir, @@ -279,6 +288,8 @@ async function completeOpenCommand(params: { appName, saveScript: Boolean(req.flags?.saveScript), }); + nextSession.lease = + buildSessionLeaseFromRequest(req, req.internal?.admittedLease) ?? existingSession?.lease; if (req.runtime !== undefined) { setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtime); } @@ -347,6 +358,8 @@ async function prepareOpenDispatchSession(params: { appName, saveScript: Boolean(req.flags?.saveScript), }); + provisionalSession.lease = + buildSessionLeaseFromRequest(req, req.internal?.admittedLease) ?? existingSession?.lease; sessionStore.set(sessionName, provisionalSession); const lifecycleResponse = await beforeDispatch(provisionalSession); if (lifecycleResponse && !lifecycleResponse.ok) { diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 9d30f903e..0dd9b174f 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -36,6 +36,7 @@ import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; import { getSessionCommandKind } from '../daemon-command-registry.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; const PREPARE_IOS_RUNNER_MIN_STARTUP_TIMEOUT_MS = 45_000; const PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS = 5 * 60_000; @@ -247,6 +248,7 @@ export async function handleSessionCommands(params: { sessionName: string; logPath: string; sessionStore: SessionStore; + leaseRegistry?: LeaseRegistry; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; androidAdbExecutor?: AndroidAdbExecutor; @@ -256,6 +258,7 @@ export async function handleSessionCommands(params: { sessionName, logPath, sessionStore, + leaseRegistry = new LeaseRegistry(), invoke, invokeReplayAction, androidAdbExecutor, @@ -435,6 +438,7 @@ export async function handleSessionCommands(params: { sessionName, logPath, sessionStore, + leaseRegistry, }); } diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index 6f4600a36..e10b5660d 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -282,6 +282,10 @@ function toLeaseDaemonRequest( leaseId: readStringParam(params, 'leaseId'), leaseTtlMs: readIntParam(params, 'ttlMs'), leaseBackend: readStringParam(params, 'backend') as LeaseBackend | undefined, + leaseProvider: + readStringParam(params, 'leaseProvider') ?? readStringParam(params, 'provider'), + deviceKey: readStringParam(params, 'deviceKey'), + clientId: readStringParam(params, 'clientId'), }, }; } diff --git a/src/daemon/lease-context.ts b/src/daemon/lease-context.ts index 763e309c4..65b80cfd3 100644 --- a/src/daemon/lease-context.ts +++ b/src/daemon/lease-context.ts @@ -1,5 +1,17 @@ import type { DaemonRequest } from './types.ts'; import type { LeaseBackend } from '../contracts.ts'; +import type { DeviceLease } from './lease-registry.ts'; +import type { RunnerLogicalLeaseContext } from '../core/runner-lease-context.ts'; + +export const PROXY_LEASE_PROVIDER = 'proxy'; +export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; +export const REQUIRED_PROXY_LEASE_FIELDS = [ + 'leaseId', + 'tenantId', + 'runId', + 'clientId', + 'deviceKey', +] as const satisfies readonly (keyof LeaseScope)[]; export type LeaseScope = { tenantId?: string; @@ -7,6 +19,27 @@ export type LeaseScope = { leaseId?: string; leaseTtlMs?: number; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +export type SessionLease = { + tenantId: string; + runId: string; + leaseId: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + expiresAt?: number; +}; + +export type LeaseDiagnosticsContext = Omit; + +type SessionLeaseSource = { + lease?: SessionLease | null; + deviceLease?: SessionLease | null; }; export function resolveLeaseScope(req: Pick): LeaseScope { @@ -16,5 +49,106 @@ export function resolveLeaseScope(req: Pick): L leaseId: req.meta?.leaseId ?? req.flags?.leaseId, leaseTtlMs: req.meta?.leaseTtlMs, leaseBackend: req.meta?.leaseBackend, + leaseProvider: + req.meta?.leaseProvider ?? + readFlagString(req.flags, 'leaseProvider') ?? + readFlagString(req.flags, 'provider'), + deviceKey: req.meta?.deviceKey ?? readFlagString(req.flags, 'deviceKey'), + clientId: req.meta?.clientId ?? readFlagString(req.flags, 'clientId'), }; } + +export function buildSessionLeaseFromRequest( + req: Pick, + activeLease?: DeviceLease, +): SessionLease | undefined { + const leaseScope = resolveLeaseScope(req); + const leaseId = leaseScope.leaseId ?? activeLease?.leaseId; + const tenantId = leaseScope.tenantId ?? activeLease?.tenantId; + const runId = leaseScope.runId ?? activeLease?.runId; + if (!tenantId || !runId || !leaseId) { + return undefined; + } + return stripUndefined({ + tenantId, + runId, + leaseId, + leaseBackend: leaseScope.leaseBackend ?? activeLease?.backend, + leaseProvider: leaseScope.leaseProvider ?? activeLease?.leaseProvider, + deviceKey: leaseScope.deviceKey ?? activeLease?.deviceKey, + clientId: leaseScope.clientId ?? activeLease?.clientId, + expiresAt: activeLease?.expiresAt, + }); +} + +export function resolveRequestOrSessionLeaseScope( + req: Pick, + session?: SessionLeaseSource | null, +): LeaseScope { + const requestScope = resolveLeaseScope(req); + const sessionLease = session?.lease ?? session?.deviceLease ?? undefined; + return stripUndefined({ + tenantId: requestScope.tenantId ?? sessionLease?.tenantId, + runId: requestScope.runId ?? sessionLease?.runId, + leaseId: requestScope.leaseId ?? sessionLease?.leaseId, + leaseTtlMs: requestScope.leaseTtlMs, + leaseBackend: requestScope.leaseBackend ?? sessionLease?.leaseBackend, + leaseProvider: requestScope.leaseProvider ?? sessionLease?.leaseProvider, + deviceKey: requestScope.deviceKey ?? sessionLease?.deviceKey, + clientId: requestScope.clientId ?? sessionLease?.clientId, + }); +} + +export function buildLeaseDiagnosticsContext( + leaseScope: LeaseScope | SessionLease | undefined, +): LeaseDiagnosticsContext | undefined { + if (!leaseScope) return undefined; + const context = stripUndefined({ + tenantId: leaseScope.tenantId, + runId: leaseScope.runId, + leaseId: leaseScope.leaseId, + leaseBackend: leaseScope.leaseBackend, + leaseProvider: leaseScope.leaseProvider, + deviceKey: leaseScope.deviceKey, + clientId: leaseScope.clientId, + }); + return Object.keys(context).length > 0 ? context : undefined; +} + +export function resolveRunnerLogicalLeaseContext( + req: Pick, +): RunnerLogicalLeaseContext | undefined { + const meta = req.meta as (DaemonRequest['meta'] & Record) | undefined; + const context = stripUndefined({ + leaseId: readNonEmptyString(meta?.leaseId), + clientId: readNonEmptyString(meta?.clientId), + tenantId: readNonEmptyString(meta?.tenantId), + runId: readNonEmptyString(meta?.runId), + leaseProvider: + readNonEmptyString(meta?.leaseProvider) ?? readNonEmptyString(meta?.leaseBackend), + deviceKey: readNonEmptyString(meta?.deviceKey), + }); + return Object.keys(context).length > 0 ? context : undefined; +} + +export function isProxyLeaseScope(scope: LeaseScope | SessionLease): boolean { + return scope.leaseProvider === PROXY_LEASE_PROVIDER; +} + +export function findMissingProxyLeaseFields(scope: LeaseScope): string[] { + if (!isProxyLeaseScope(scope)) return []; + return REQUIRED_PROXY_LEASE_FIELDS.filter((field) => !scope[field]); +} + +function readFlagString(flags: object | undefined, key: string): string | undefined { + const value = (flags as Record | undefined)?.[key]; + return typeof value === 'string' ? value : undefined; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; +} diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 3f5a4ba3f..141b82b49 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -1,18 +1,24 @@ import crypto from 'node:crypto'; +import type { LeaseBackend } from '../contracts.ts'; import { AppError } from '../utils/errors.ts'; import { normalizeTenantId } from './config.ts'; -import type { LeaseBackend } from '../contracts.ts'; -export type SimulatorLease = { +export type DeviceLease = { leaseId: string; tenantId: string; runId: string; backend: LeaseBackend; + leaseProvider?: string; + provider?: string; + deviceKey?: string; + clientId?: string; createdAt: number; heartbeatAt: number; expiresAt: number; }; +export type SimulatorLease = DeviceLease; + export type LeaseRegistryOptions = { maxActiveSimulatorLeases?: number; defaultLeaseTtlMs?: number; @@ -25,6 +31,10 @@ export type AllocateLeaseRequest = { tenantId: string; runId: string; backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; ttlMs?: number; }; @@ -32,6 +42,11 @@ export type HeartbeatLeaseRequest = { leaseId: string; tenantId?: string; runId?: string; + backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; ttlMs?: number; }; @@ -39,6 +54,11 @@ export type ReleaseLeaseRequest = { leaseId: string; tenantId?: string; runId?: string; + backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; export type AdmissionRequest = { @@ -46,11 +66,16 @@ export type AdmissionRequest = { runId: string | undefined; leaseId: string | undefined; backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; const DEFAULT_LEASE_TTL_MS = 60_000; const MIN_LEASE_TTL_MS = 5_000; const MAX_LEASE_TTL_MS = 10 * 60_000; +const DEFAULT_LEASE_PROVIDER = 'default'; function normalizeRunId(raw: string | undefined): string | undefined { if (!raw) return undefined; @@ -75,9 +100,51 @@ function normalizeLeaseBackend(raw: string | undefined): LeaseBackend { throw new AppError('INVALID_ARGS', `Unsupported lease backend: ${raw ?? ''}`); } +function normalizeDeviceKey(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined; + const value = raw.trim(); + if (!value || value.length > 256 || !/^[\x20-\x7E]+$/.test(value)) { + throw new AppError('INVALID_ARGS', 'Invalid device key. Use 1-256 printable characters.'); + } + return value; +} + +function normalizeClientId(raw: string | undefined): string | undefined { + return normalizeAgentIdentifier(raw, 'client id', 128); +} + +function normalizeLeaseProviderFields(request: { + provider?: string; + leaseProvider?: string; +}): string | undefined { + const provider = normalizeAgentIdentifier(request.provider, 'lease provider', 64); + const leaseProvider = normalizeAgentIdentifier(request.leaseProvider, 'lease provider', 64); + if (provider && leaseProvider && provider !== leaseProvider) { + throw new AppError('INVALID_ARGS', 'Conflicting lease provider values.'); + } + return leaseProvider ?? provider; +} + +function normalizeAgentIdentifier( + raw: string | undefined, + label: string, + maxLength: number, +): string | undefined { + if (raw === undefined) return undefined; + const value = raw.trim(); + if (!value || value.length > maxLength || !/^[a-zA-Z0-9._-]+$/.test(value)) { + throw new AppError( + 'INVALID_ARGS', + `Invalid ${label}. Use 1-${String(maxLength)} chars: letters, numbers, dot, underscore, hyphen.`, + ); + } + return value; +} + export class LeaseRegistry { - private readonly leases = new Map(); + private readonly leases = new Map(); private readonly runBindings = new Map(); + private readonly deviceBindings = new Map(); private readonly maxActiveSimulatorLeases: number; private readonly defaultLeaseTtlMs: number; private readonly minLeaseTtlMs: number; @@ -100,8 +167,11 @@ export class LeaseRegistry { this.now = options.now ?? (() => Date.now()); } - allocateLease(request: AllocateLeaseRequest): SimulatorLease { + allocateLease(request: AllocateLeaseRequest): DeviceLease { const backend = normalizeLeaseBackend(request.backend); + const provider = normalizeLeaseProviderFields(request); + const deviceKey = normalizeDeviceKey(request.deviceKey); + const clientId = normalizeClientId(request.clientId); const tenantId = normalizeTenantId(request.tenantId); if (!tenantId) { throw new AppError( @@ -118,32 +188,37 @@ export class LeaseRegistry { } this.cleanupExpiredLeases(); const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); - const bindingKey = this.bindingKey(tenantId, runId, backend); + const bindingKey = this.bindingKey({ tenantId, runId, backend, provider, deviceKey }); const existingId = this.runBindings.get(bindingKey); if (existingId) { const existingLease = this.leases.get(existingId); if (existingLease) { + this.assertOptionalLeaseIdentityMatch(existingLease, { clientId }); return this.refreshLease(existingLease, leaseTtlMs); } this.runBindings.delete(bindingKey); } + this.assertDeviceAvailable({ backend, provider, deviceKey }); this.enforceCapacity(backend); const now = this.now(); - const lease: SimulatorLease = { + const lease: DeviceLease = { leaseId: crypto.randomBytes(16).toString('hex'), tenantId, runId, backend, + ...(provider ? { leaseProvider: provider, provider } : {}), + ...(deviceKey ? { deviceKey } : {}), + ...(clientId ? { clientId } : {}), createdAt: now, heartbeatAt: now, expiresAt: now + leaseTtlMs, }; this.leases.set(lease.leaseId, lease); - this.runBindings.set(bindingKey, lease.leaseId); + this.bindLease(lease); return { ...lease }; } - heartbeatLease(request: HeartbeatLeaseRequest): SimulatorLease { + heartbeatLease(request: HeartbeatLeaseRequest): DeviceLease { const leaseId = normalizeLeaseId(request.leaseId); if (!leaseId) { throw new AppError('INVALID_ARGS', 'Invalid lease id.'); @@ -155,7 +230,15 @@ export class LeaseRegistry { reason: 'LEASE_NOT_FOUND', }); } - this.assertOptionalScopeMatch(lease, request.tenantId, request.runId); + this.assertOptionalScopeMatch(lease, { + tenantId: request.tenantId, + runId: request.runId, + backend: request.backend, + provider: request.provider, + leaseProvider: request.leaseProvider, + deviceKey: request.deviceKey, + clientId: request.clientId, + }); const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); return this.refreshLease(lease, leaseTtlMs); } @@ -170,9 +253,17 @@ export class LeaseRegistry { if (!lease) { return { released: false }; } - this.assertOptionalScopeMatch(lease, request.tenantId, request.runId); + this.assertOptionalScopeMatch(lease, { + tenantId: request.tenantId, + runId: request.runId, + backend: request.backend, + provider: request.provider, + leaseProvider: request.leaseProvider, + deviceKey: request.deviceKey, + clientId: request.clientId, + }); this.leases.delete(leaseId); - this.runBindings.delete(this.bindingKey(lease.tenantId, lease.runId, lease.backend)); + this.unbindLease(lease); return { released: true }; } @@ -197,25 +288,36 @@ export class LeaseRegistry { reason: 'LEASE_NOT_FOUND', }); } - if (lease.backend !== backend || lease.tenantId !== tenantId || lease.runId !== runId) { - throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { - reason: 'LEASE_SCOPE_MISMATCH', - }); - } + this.assertOptionalScopeMatch(lease, { + tenantId, + runId, + backend, + provider: request.provider, + leaseProvider: request.leaseProvider, + deviceKey: request.deviceKey, + clientId: request.clientId, + }); } - listActiveLeases(): SimulatorLease[] { + listActiveLeases(): DeviceLease[] { this.cleanupExpiredLeases(); return Array.from(this.leases.values()).map((entry) => ({ ...entry })); } - private cleanupExpiredLeases(): void { + consumeExpiredLeases(): DeviceLease[] { const now = this.now(); + const expired: DeviceLease[] = []; for (const lease of this.leases.values()) { if (lease.expiresAt > now) continue; this.leases.delete(lease.leaseId); - this.runBindings.delete(this.bindingKey(lease.tenantId, lease.runId, lease.backend)); + this.unbindLease(lease); + expired.push({ ...lease }); } + return expired; + } + + private cleanupExpiredLeases(): void { + this.consumeExpiredLeases(); } private enforceCapacity(backend: LeaseBackend): void { @@ -246,53 +348,171 @@ export class LeaseRegistry { return value; } - private refreshLease(lease: SimulatorLease, ttlMs: number): SimulatorLease { + private refreshLease(lease: DeviceLease, ttlMs: number): DeviceLease { const now = this.now(); - const updated: SimulatorLease = { + const updated: DeviceLease = { ...lease, heartbeatAt: now, expiresAt: now + ttlMs, }; this.leases.set(updated.leaseId, updated); + this.bindLease(updated); + return { ...updated }; + } + + private bindLease(lease: DeviceLease): void { this.runBindings.set( - this.bindingKey(updated.tenantId, updated.runId, updated.backend), - updated.leaseId, + this.bindingKey({ + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + provider: lease.leaseProvider, + deviceKey: lease.deviceKey, + }), + lease.leaseId, ); - return { ...updated }; + const deviceBindingKey = this.deviceBindingKey(lease); + if (deviceBindingKey) { + this.deviceBindings.set(deviceBindingKey, lease.leaseId); + } + } + + private unbindLease(lease: DeviceLease): void { + this.runBindings.delete( + this.bindingKey({ + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + provider: lease.leaseProvider, + deviceKey: lease.deviceKey, + }), + ); + const deviceBindingKey = this.deviceBindingKey(lease); + if (deviceBindingKey) { + this.deviceBindings.delete(deviceBindingKey); + } + } + + private bindingKey(params: { + tenantId: string; + runId: string; + backend: LeaseBackend; + provider?: string; + deviceKey?: string; + }): string { + return JSON.stringify([ + params.tenantId, + params.runId, + params.backend, + params.provider ?? DEFAULT_LEASE_PROVIDER, + params.deviceKey ?? '*', + ]); + } + + private deviceBindingKey( + lease: Pick, + ): string | undefined { + if (!lease.deviceKey) return undefined; + return JSON.stringify([ + lease.backend, + lease.leaseProvider ?? DEFAULT_LEASE_PROVIDER, + lease.deviceKey, + ]); } - private bindingKey(tenantId: string, runId: string, backend: LeaseBackend): string { - return `${tenantId}:${runId}:${backend}`; + private assertDeviceAvailable(params: { + backend: LeaseBackend; + provider?: string; + deviceKey?: string; + }): void { + const deviceBindingKey = this.deviceBindingKey({ + backend: params.backend, + leaseProvider: params.provider, + deviceKey: params.deviceKey, + }); + if (!deviceBindingKey) return; + const activeLeaseId = this.deviceBindings.get(deviceBindingKey); + if (!activeLeaseId) return; + const activeLease = this.leases.get(activeLeaseId); + if (!activeLease) { + this.deviceBindings.delete(deviceBindingKey); + return; + } + throw new AppError('COMMAND_FAILED', 'Device is already leased', { + reason: 'DEVICE_LEASE_BUSY', + deviceKey: activeLease.deviceKey, + backend: activeLease.backend, + leaseProvider: activeLease.leaseProvider, + leaseId: activeLease.leaseId, + tenantId: activeLease.tenantId, + runId: activeLease.runId, + expiresAt: activeLease.expiresAt, + hint: 'Retry after the lease expires or close the owning session.', + }); } private assertOptionalScopeMatch( - lease: SimulatorLease, - tenantRaw: string | undefined, - runRaw: string | undefined, + lease: DeviceLease, + request: { + tenantId?: string; + runId?: string; + backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + }, ): void { - const tenantId = normalizeTenantId(tenantRaw); - const runId = normalizeRunId(runRaw); - if (tenantRaw && !tenantId) { + const tenantId = normalizeTenantId(request.tenantId); + const runId = normalizeRunId(request.runId); + if (request.tenantId && !tenantId) { throw new AppError( 'INVALID_ARGS', 'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', ); } - if (runRaw && !runId) { + if (request.runId && !runId) { throw new AppError( 'INVALID_ARGS', 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', ); } - if (tenantId && lease.tenantId !== tenantId) { - throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { - reason: 'LEASE_SCOPE_MISMATCH', - }); + const backend = request.backend ? normalizeLeaseBackend(request.backend) : undefined; + const provider = normalizeLeaseProviderFields(request); + const deviceKey = normalizeDeviceKey(request.deviceKey); + const clientId = normalizeClientId(request.clientId); + if ( + (tenantId && lease.tenantId !== tenantId) || + (runId && lease.runId !== runId) || + (backend && lease.backend !== backend) + ) { + this.throwScopeMismatch(); } - if (runId && lease.runId !== runId) { - throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { - reason: 'LEASE_SCOPE_MISMATCH', - }); + this.assertOptionalLeaseIdentityMatch(lease, { provider, deviceKey, clientId }); + } + + private assertOptionalLeaseIdentityMatch( + lease: DeviceLease, + request: { + provider?: string; + deviceKey?: string; + clientId?: string; + }, + ): void { + if (request.provider && lease.leaseProvider !== request.provider) { + this.throwScopeMismatch(); + } + if (request.deviceKey && lease.deviceKey !== request.deviceKey) { + this.throwScopeMismatch(); + } + if (request.clientId && lease.clientId !== request.clientId) { + this.throwScopeMismatch(); } } + + private throwScopeMismatch(): never { + throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { + reason: 'LEASE_SCOPE_MISMATCH', + }); + } } diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 1930a0784..511a54986 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -1,9 +1,15 @@ import { AppError } from '../utils/errors.ts'; import { normalizeTenantId, resolveSessionIsolationMode } from './config.ts'; import { isLeaseAdmissionExempt } from './daemon-command-registry.ts'; -import { resolveLeaseScope } from './lease-context.ts'; -import type { LeaseRegistry } from './lease-registry.ts'; -import type { DaemonRequest } from './types.ts'; +import { + DEFAULT_PROXY_LEASE_TTL_MS, + findMissingProxyLeaseFields, + isProxyLeaseScope, + resolveLeaseScope, + resolveRequestOrSessionLeaseScope, +} from './lease-context.ts'; +import type { DeviceLease, LeaseRegistry } from './lease-registry.ts'; +import type { DaemonRequest, SessionState } from './types.ts'; export function scopeRequestSession(req: DaemonRequest): DaemonRequest { const isolation = resolveSessionIsolationMode( @@ -52,15 +58,81 @@ export function scopeRequestSession(req: DaemonRequest): DaemonRequest { export function assertRequestLeaseAdmission( req: DaemonRequest, leaseRegistry: LeaseRegistry, -): void { - if (isLeaseAdmissionExempt(req.command) || req.meta?.sessionIsolation !== 'tenant') { - return; + session?: SessionState, +): DeviceLease | undefined { + if (isLeaseAdmissionExempt(req.command)) { + return undefined; + } + const requestLeaseScope = resolveLeaseScope(req); + assertProxyOpenLeaseMetadata(req, requestLeaseScope); + const sessionLease = session?.lease; + if (!sessionLease && req.meta?.sessionIsolation !== 'tenant' && !requestLeaseScope.leaseId) { + return undefined; } - const leaseScope = resolveLeaseScope(req); + assertRequestSessionLeaseMatches(requestLeaseScope, sessionLease); + const leaseScope = resolveRequestOrSessionLeaseScope(req, session); leaseRegistry.assertLeaseAdmission({ tenantId: leaseScope.tenantId, runId: leaseScope.runId, leaseId: leaseScope.leaseId, backend: leaseScope.leaseBackend, + leaseProvider: leaseScope.leaseProvider, + deviceKey: leaseScope.deviceKey, + clientId: leaseScope.clientId, + }); + return leaseRegistry.heartbeatLease({ + leaseId: leaseScope.leaseId ?? '', + tenantId: leaseScope.tenantId, + runId: leaseScope.runId, + backend: leaseScope.leaseBackend, + leaseProvider: leaseScope.leaseProvider, + deviceKey: leaseScope.deviceKey, + clientId: leaseScope.clientId, + ttlMs: + leaseScope.leaseTtlMs ?? + (isProxyLeaseScope(leaseScope) ? DEFAULT_PROXY_LEASE_TTL_MS : undefined), + }); +} + +function assertProxyOpenLeaseMetadata( + req: DaemonRequest, + requestLeaseScope: ReturnType, +): void { + if (req.command !== 'open') return; + const missing = findMissingProxyLeaseFields(requestLeaseScope); + if (missing.length === 0) return; + throw new AppError( + 'INVALID_ARGS', + 'Proxy open requires leaseId, tenantId, runId, clientId, and deviceKey lease metadata.', + { missing }, + ); +} + +function assertRequestSessionLeaseMatches( + requestLeaseScope: ReturnType, + sessionLease: SessionState['lease'] | undefined, +): void { + if (!sessionLease) return; + assertMatchingLeaseField('leaseId', requestLeaseScope.leaseId, sessionLease.leaseId); + assertMatchingLeaseField('tenantId', requestLeaseScope.tenantId, sessionLease.tenantId); + assertMatchingLeaseField('runId', requestLeaseScope.runId, sessionLease.runId); + assertMatchingLeaseField( + 'leaseProvider', + requestLeaseScope.leaseProvider, + sessionLease.leaseProvider, + ); + assertMatchingLeaseField('clientId', requestLeaseScope.clientId, sessionLease.clientId); + assertMatchingLeaseField('deviceKey', requestLeaseScope.deviceKey, sessionLease.deviceKey); +} + +function assertMatchingLeaseField( + field: string, + requestValue?: string, + sessionValue?: string, +): void { + if (!requestValue || !sessionValue || requestValue === sessionValue) return; + throw new AppError('UNAUTHORIZED', `Lease does not match session owner (${field})`, { + reason: 'LEASE_SESSION_MISMATCH', + field, }); } diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 115edb418..96c0433f3 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -31,6 +31,7 @@ import { type SessionStore, } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; +import { teardownSessionResources } from './handlers/session-close.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. @@ -74,10 +75,12 @@ export async function createRequestExecutionScope(params: { leaseRegistry: LeaseRegistry; }): Promise { const { sessionStore, leaseRegistry } = params; - const scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); + await cleanupExpiredLeasedSessions({ sessionStore, leaseRegistry }); + let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); const command = scopedReq.command; const sessionName = resolveEffectiveSessionName(scopedReq, sessionStore); + const existingSession = sessionStore.get(sessionName); const diagnosticsMeta = getDiagnosticsMeta(); const sessionDir = sessionStore.resolveSessionDir(sessionName); const requestLogPath = resolveSessionRequestLogPath( @@ -102,7 +105,26 @@ export async function createRequestExecutionScope(params: { runnerLogPath, }, }); - assertRequestLeaseAdmission(scopedReq, leaseRegistry); + const activeLease = assertRequestLeaseAdmission(scopedReq, leaseRegistry, existingSession); + if (activeLease) { + scopedReq = { + ...scopedReq, + internal: { + ...scopedReq.internal, + admittedLease: activeLease, + }, + }; + } + if (activeLease && existingSession?.lease) { + sessionStore.set(sessionName, { + ...existingSession, + lease: { + ...existingSession.lease, + leaseBackend: activeLease.backend, + expiresAt: activeLease.expiresAt, + }, + }); + } const executionLockKeys = shouldLockSessionExecution(command) ? await resolveRequestExecutionLockKeys({ req: scopedReq, sessionName, sessionStore }) : []; @@ -127,6 +149,42 @@ export async function createRequestExecutionScope(params: { return scope; } +async function cleanupExpiredLeasedSessions(params: { + sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; +}): Promise { + const expiredLeases = params.leaseRegistry.consumeExpiredLeases(); + if (expiredLeases.length === 0) return; + const expiredLeaseIds = new Set(expiredLeases.map((lease) => lease.leaseId)); + for (const session of params.sessionStore.toArray()) { + const lease = session.lease; + if (!lease || !expiredLeaseIds.has(lease.leaseId)) continue; + emitDiagnostic({ + level: 'info', + phase: 'leased_session_expired', + data: { + reason: 'LEASE_EXPIRED', + leaseId: lease.leaseId, + session: session.name, + deviceKey: lease.deviceKey, + }, + }); + await teardownSessionResources(session, session.name).catch((error) => { + emitDiagnostic({ + level: 'debug', + phase: 'leased_session_expiry_cleanup_failed', + data: { + reason: 'LEASE_EXPIRED', + leaseId: lease.leaseId, + session: session.name, + error: error instanceof Error ? error.message : String(error), + }, + }); + }); + params.sessionStore.delete(session.name); + } +} + async function withRequestExecutionLocks( locks: Map>, keys: RequestExecutionLockKey[], @@ -207,7 +265,8 @@ export function prepareLockedRequestScope(params: { flags: CommandFlags | undefined, appBundleId?: string, traceLogPath?: string, - ): DaemonCommandContext => contextFromRequestFlags(logPath, flags, appBundleId, traceLogPath); + ): DaemonCommandContext => + contextFromRequestFlags(logPath, flags, appBundleId, traceLogPath, lockedReq.meta); return { type: 'scope', @@ -233,10 +292,11 @@ function contextFromRequestFlags( flags: CommandFlags | undefined, appBundleId?: string, traceLogPath?: string, + meta?: DaemonRequest['meta'], ): DaemonCommandContext { const requestId = getDiagnosticsMeta().requestId; return { - ...contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath, requestId), + ...contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath, requestId, meta), requestId, }; } diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index b7c863305..ebd25e2d9 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -73,6 +73,7 @@ async function runSessionHandler(params: RequestHandlerChainParams): Promise & { @@ -227,6 +229,16 @@ export type SessionState = { kind: 'cwd'; id: string; }; + lease?: { + leaseId: string; + tenantId: string; + runId: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + expiresAt?: number; + }; device: DeviceInfo; createdAt: number; surface?: SessionSurface; diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index 86524d179..8b44320a8 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -110,6 +110,7 @@ import { cleanupRunnerLeasesForOwner, RUNNER_OWNER_START_TIME, RUNNER_OWNER_TOKEN, + setRunnerLeaseOwnerStateDir, writeRunnerLease, type RunnerLease, } from '../runner-lease.ts'; @@ -117,6 +118,7 @@ import { beforeEach(async () => { await abortAllIosRunnerSessions(); vi.resetAllMocks(); + setRunnerLeaseOwnerStateDir(undefined); process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = fs.mkdtempSync( path.join(os.tmpdir(), 'agent-device-runner-lease-test-'), ); @@ -609,6 +611,29 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv }); }); +test('runner session startup diagnostics include logical lease context', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-lease-context-sim' }; + + const diagnostics = await captureDiagnostics(async () => { + await ensureRunnerSession(device, { + runnerLeaseContext: { + tenantId: 'tenant-123', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'ios-simulator', + }, + }); + }); + + assert.match(diagnostics, /ios_runner_session_startup/); + assert.match(diagnostics, /"logicalLeaseContext"/); + assert.match(diagnostics, /"tenantId":"tenant-123"/); + assert.match(diagnostics, /"runId":"run-456"/); + assert.match(diagnostics, /"leaseId":"lease-789"/); + assert.match(diagnostics, /"leaseProvider":"ios-simulator"/); + assert.match(diagnostics, /"deviceKey":"runner-session-lease-context-sim"/); +}); + test('runner session fails early for physical iOS devices when Apple developer mode is disabled', async () => { const device = { ...IOS_DEVICE, id: 'runner-session-devtools-disabled-device' }; mockDevToolsSecurityDisabled(); @@ -693,6 +718,10 @@ test('runner session startup rejects live foreign runner lease', async () => { String((thrown as { details?: Record }).details?.hint), /Do not run prepare ios-runner/, ); + assert.match( + String((thrown as { details?: Record }).details?.hint), + /PID \d+ with AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner/, + ); assert.equal(mockRunCmdBackground.mock.calls.length, 0); assert.equal( mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'), @@ -704,6 +733,51 @@ test('runner session startup rejects live foreign runner lease', async () => { } }); +test('runner session busy error includes logical lease context after admission', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-logical-busy-lease-sim' }; + writeRunnerLease( + makeRunnerLease({ + deviceId: device.id, + ownerToken: 'owner-foreign-logical-live', + ownerPid: process.pid, + ownerStartTime: RUNNER_OWNER_START_TIME, + ownerStateDir: '/tmp/agent-device-owner', + }), + ); + + let thrown: unknown; + await assert.rejects(async () => { + try { + await ensureRunnerSession(device, { + runnerLeaseContext: { + tenantId: 'tenant-123', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'ios-simulator', + }, + }); + } catch (error) { + thrown = error; + throw error; + } + }, /busy after device lease admission/); + + assert.ok(thrown instanceof AppError); + assert.deepEqual(thrown.details?.logicalLeaseContext, { + tenantId: 'tenant-123', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'ios-simulator', + deviceKey: device.id, + }); + assert.match(String(thrown.details?.hint), /five-minute inactivity lease expires/); + assert.match( + String(thrown.details?.hint), + /Runner owner: PID \d+ with AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner/, + ); + assert.equal(mockRunCmdBackground.mock.calls.length, 0); +}); + test('runner session startup reclaims live foreign runner lease from same state dir', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-same-state-lease-sim' }; const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR; @@ -738,6 +812,38 @@ test('runner session startup reclaims live foreign runner lease from same state } }); +test('runner session startup reclaims same-state live lease from daemon runtime owner state dir', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-runtime-state-lease-sim' }; + const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR; + const stateDir = '/tmp/agent-device-runtime-state'; + delete process.env.AGENT_DEVICE_STATE_DIR; + setRunnerLeaseOwnerStateDir(stateDir); + writeRunnerLease( + makeRunnerLease({ + deviceId: device.id, + ownerToken: 'owner-foreign-runtime-state', + ownerPid: process.pid, + ownerStartTime: RUNNER_OWNER_START_TIME, + ownerStateDir: stateDir, + runnerPid: 4_321, + }), + ); + + try { + const session = await ensureRunnerSession(device, {}); + + assert.equal(session.deviceId, device.id); + assert.equal(mockRunCmdBackground.mock.calls.length, 1); + const pkillCalls = mockRunAppleToolCommand.mock.calls.filter(isXcodebuildPkillCall); + assert.ok(pkillCalls.length >= 2); + assert.match(String(pkillCalls[0]?.[1]?.[2] ?? ''), /owner-foreign-runtime-state/); + } finally { + setRunnerLeaseOwnerStateDir(undefined); + if (previousStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR; + else process.env.AGENT_DEVICE_STATE_DIR = previousStateDir; + } +}); + test('runner session startup reclaims dead foreign runner lease before launching', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-dead-lease-sim' }; mockIsProcessAlive.mockImplementation((pid) => pid !== 999_999_999 && pid !== 999_999_998); diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index cf6045a2f..1cd707bea 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -72,6 +72,7 @@ export function iosRunnerOverrides( iosXctestrunFile: ctx.iosXctestrunFile, iosXctestDerivedDataPath: ctx.iosXctestDerivedDataPath, iosXctestEnvDir: ctx.iosXctestEnvDir, + runnerLeaseContext: ctx.runnerLeaseContext, }; return { runnerOpts, diff --git a/src/platforms/ios/runner-lease.ts b/src/platforms/ios/runner-lease.ts index 506fcaac8..9d37dbd60 100644 --- a/src/platforms/ios/runner-lease.ts +++ b/src/platforms/ios/runner-lease.ts @@ -6,6 +6,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { AppError } from '../../utils/errors.ts'; import { acquireProcessLock } from '../../utils/process-lock.ts'; import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.ts'; +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; const RUNNER_LEASE_SCHEMA_VERSION = 1; const RUNNER_LEASE_LOCK_TIMEOUT_MS = 30_000; @@ -16,6 +17,8 @@ const RUNNER_OWNER_PID = process.pid; export const RUNNER_OWNER_START_TIME = readProcessStartTime(process.pid); export const RUNNER_OWNER_TOKEN = buildRunnerOwnerToken(RUNNER_OWNER_PID, RUNNER_OWNER_START_TIME); +let runnerLeaseOwnerStateDir: string | undefined; + export type RunnerLease = { schemaVersion: 1; deviceId: string; @@ -72,6 +75,10 @@ export function buildRunnerLease(params: { }; } +export function setRunnerLeaseOwnerStateDir(stateDir: string | undefined): void { + runnerLeaseOwnerStateDir = stateDir?.trim() || undefined; +} + export async function withRunnerLeaseLock(deviceId: string, task: () => Promise): Promise { const release = await acquireProcessLock({ lockDirPath: `${resolveRunnerLeasePath(deviceId)}.lock`, @@ -110,6 +117,7 @@ function classifyRunnerLease(lease: RunnerLease | null): RunnerLeaseState { export async function prepareRunnerLeaseForStartup( deviceId: string, cleanup: RunnerLeaseCleanupAdapter, + logicalLeaseContext?: RunnerLogicalLeaseContext, ): Promise { const state = classifyRunnerLease(readRunnerLease(deviceId)); if (state.type === 'empty') { @@ -123,15 +131,18 @@ export async function prepareRunnerLeaseForStartup( } throw new AppError( 'COMMAND_FAILED', - `iOS runner for ${deviceId} is already owned by another agent-device daemon`, + logicalLeaseContext + ? `iOS runner for ${deviceId} is busy after device lease admission` + : `iOS runner for ${deviceId} is already owned by another agent-device daemon`, { deviceId, + logicalLeaseContext, ownerPid: state.lease.ownerPid, ownerStartTime: state.lease.ownerStartTime, ownerStateDir: state.lease.ownerStateDir, ownerToken: state.lease.ownerToken, sessionId: state.lease.sessionId, - hint: buildBusyRunnerLeaseHint(state.lease), + hint: buildBusyRunnerLeaseHint(state.lease, logicalLeaseContext), }, ); } @@ -146,14 +157,30 @@ function isSameStateDirRunnerLease(lease: RunnerLease): boolean { } function readCurrentStateDir(): string | undefined { + if (runnerLeaseOwnerStateDir) return runnerLeaseOwnerStateDir; return process.env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; } -function buildBusyRunnerLeaseHint(lease: RunnerLease): string { +function buildBusyRunnerLeaseHint( + lease: RunnerLease, + logicalLeaseContext?: RunnerLogicalLeaseContext, +): string { const owner = `PID ${lease.ownerPid}`; const stateDir = lease.ownerStateDir ? ` with AGENT_DEVICE_STATE_DIR=${lease.ownerStateDir}` : ''; + const currentStateDir = readCurrentStateDir(); + const current = + currentStateDir && currentStateDir !== lease.ownerStateDir + ? ` Current daemon state dir is ${currentStateDir}.` + : ''; + if (logicalLeaseContext) { + return [ + `The device is busy because another active device lease owns it, or the runner is owned by another daemon/process after lease admission. Runner owner: ${owner}${stateDir}.${current}`, + 'Retry after the owning session closes or after the five-minute inactivity lease expires.', + 'If this persists after expiry, inspect the runner owner details and clean the stale daemon state on the machine with simulator access.', + ].join(' '); + } return [ - `The Mac operator must stop the owning daemon (${owner}${stateDir}) or wait for that run to finish, then retry.`, + `Runner owner details: ${owner}${stateDir}.${current} Retry after the owning runner finishes.`, 'Do not run prepare ios-runner from another daemon/client to recover this; a live foreign runner lease cannot be released by the remote client.', ].join(' '); } diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index 277379a68..f14ff8c46 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { RunnerCommand } from './runner-contract.ts'; import type { @@ -14,6 +15,7 @@ export type AppleRunnerCommandOptions = ExternalXctestRunnerOptions & { cleanStaleBundles?: boolean; startupTimeoutMs?: number; requestId?: string; + runnerLeaseContext?: RunnerLogicalLeaseContext; }; export type AppleRunnerLifecycleOptions = AppleRunnerCommandOptions & { diff --git a/src/platforms/ios/runner-session-types.ts b/src/platforms/ios/runner-session-types.ts index 3a114b207..f0ceb870f 100644 --- a/src/platforms/ios/runner-session-types.ts +++ b/src/platforms/ios/runner-session-types.ts @@ -1,3 +1,4 @@ +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; import type { ExecResult, ExecBackgroundResult } from '../../utils/exec.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { RunnerXctestrunArtifact } from './runner-xctestrun.ts'; @@ -21,6 +22,7 @@ export type RunnerSession = { lastHealthyMutation?: { atMs: number; appBundleId?: string }; startupTimings?: Record; startupTimingsReported?: boolean; + logicalLeaseContext?: RunnerLogicalLeaseContext; simulatorSetRedirect?: { release: () => Promise }; lease?: RunnerLease; }; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 8b6e3da18..516af68d4 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -3,6 +3,7 @@ import { runCmdBackground, type ExecResult, type ExecBackgroundResult } from '.. import { withKeyedLock } from '../../utils/keyed-lock.ts'; import { Deadline } from '../../utils/retry.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; import type { AppleRunnerLifecycleOptions } from './runner-provider.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; @@ -108,8 +109,20 @@ async function startRunnerSessionWithLease( options: RunnerSessionOptions, ): Promise { const startupTimings: Record = {}; + const logicalLeaseContext = normalizeRunnerLogicalLeaseContext( + options.runnerLeaseContext, + device.id, + ); + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_session_startup', + data: { + deviceId: device.id, + logicalLeaseContext, + }, + }); await measureRunnerStartupStep(startupTimings, 'cleanup_stale_xcodebuild', async () => { - await prepareRunnerLeaseForStartup(device.id, runnerLeaseCleanupAdapter); + await prepareRunnerLeaseForStartup(device.id, runnerLeaseCleanupAdapter, logicalLeaseContext); }); await measureRunnerStartupStep(startupTimings, 'ensure_booted', async () => { await ensureBootedIfNeeded(device); @@ -222,6 +235,7 @@ async function startRunnerSessionWithLease( ready: false, startupTimeoutMs: normalizeRunnerStartupTimeoutMs(options.startupTimeoutMs), startupTimings, + logicalLeaseContext, simulatorSetRedirect: simulatorSetRedirect ?? undefined, lease, }; @@ -262,6 +276,7 @@ async function resolveReusableRunnerSession( sessionId: existing.sessionId, ready: existing.ready, cache: existingArtifact.cache, + logicalLeaseContext: existing.logicalLeaseContext, }, }); return existing; @@ -295,6 +310,7 @@ async function resolveReusableRunnerSession( deviceId: device.id, sessionId: existing.sessionId, ready: existing.ready, + logicalLeaseContext: existing.logicalLeaseContext, }, }); return existing; @@ -889,7 +905,29 @@ function emitRunnerStartupTimings(session: RunnerSession, command: string): void command, sessionId: session.sessionId, ready: session.ready, + logicalLeaseContext: session.logicalLeaseContext, timings: session.startupTimings, }, }); } + +function normalizeRunnerLogicalLeaseContext( + context: RunnerLogicalLeaseContext | undefined, + deviceKey: string, +): RunnerLogicalLeaseContext | undefined { + if (!context) return undefined; + const normalized = { + leaseId: readOptionalContextString(context.leaseId), + clientId: readOptionalContextString(context.clientId), + tenantId: readOptionalContextString(context.tenantId), + runId: readOptionalContextString(context.runId), + leaseProvider: readOptionalContextString(context.leaseProvider), + deviceKey: readOptionalContextString(context.deviceKey) ?? deviceKey, + }; + const entries = Object.entries(normalized).filter(([, value]) => value !== undefined); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function readOptionalContextString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} diff --git a/src/remote-config-core.ts b/src/remote-config-core.ts index 69c5c8e38..c5f8e8011 100644 --- a/src/remote-config-core.ts +++ b/src/remote-config-core.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { - REMOTE_CONFIG_FIELD_SPECS, + REMOTE_CONFIG_PROFILE_FIELD_SPECS, getRemoteConfigEnvNames, getRemoteConfigFieldSpec, type RemoteConfigProfile, @@ -74,7 +74,7 @@ function readRemoteConfigEnvDefaults( env: Record = process.env, ): RemoteConfigProfile { const profile: RemoteConfigProfile = {}; - for (const spec of REMOTE_CONFIG_FIELD_SPECS) { + for (const spec of REMOTE_CONFIG_PROFILE_FIELD_SPECS) { const envMatch = getRemoteConfigEnvNames(spec.key) .map((name) => ({ name, value: env[name] })) .find((entry) => typeof entry.value === 'string' && entry.value.trim().length > 0); @@ -95,7 +95,7 @@ function mergeRemoteConfigProfile( const merged: RemoteConfigProfile = {}; for (const profile of profiles) { if (!profile) continue; - for (const spec of REMOTE_CONFIG_FIELD_SPECS) { + for (const spec of REMOTE_CONFIG_PROFILE_FIELD_SPECS) { const value = profile[spec.key]; if (value !== undefined) { (merged as Record)[spec.key] = value; diff --git a/src/remote-config-schema.ts b/src/remote-config-schema.ts index 6ca159a2a..e86d975cb 100644 --- a/src/remote-config-schema.ts +++ b/src/remote-config-schema.ts @@ -35,6 +35,9 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & { runId?: string; leaseId?: string; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; platform?: PlatformSelector; target?: DeviceTarget; device?: string; @@ -109,8 +112,19 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ { key: 'metroNoInstallDeps', type: 'boolean' }, ] as const satisfies readonly RemoteConfigFieldSpec[]; +const REMOTE_CONFIG_LEASE_FIELD_SPECS = [ + { key: 'leaseProvider', type: 'string', env: false }, + { key: 'deviceKey', type: 'string', env: false }, + { key: 'clientId', type: 'string', env: false }, +] as const satisfies readonly RemoteConfigFieldSpec[]; + +export const REMOTE_CONFIG_PROFILE_FIELD_SPECS = [ + ...REMOTE_CONFIG_FIELD_SPECS, + ...REMOTE_CONFIG_LEASE_FIELD_SPECS, +] as const satisfies readonly RemoteConfigFieldSpec[]; + const remoteConfigFieldSpecByKey = new Map( - REMOTE_CONFIG_FIELD_SPECS.map((spec) => [spec.key, spec]), + REMOTE_CONFIG_PROFILE_FIELD_SPECS.map((spec) => [spec.key, spec]), ); export function getRemoteConfigFieldSpec( diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index e4354d152..f4a112e87 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -21,6 +21,9 @@ export type RemoteConnectionState = { runId: string; leaseId?: string; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; platform?: CliFlags['platform']; target?: CliFlags['target']; runtime?: SessionRuntimeHints; @@ -33,9 +36,15 @@ export type RemoteConnectionState = { updatedAt: string; }; +export type RemoteConnectionRequestMetadata = Pick< + RemoteConnectionState, + 'leaseProvider' | 'deviceKey' | 'clientId' +>; + type RemoteConnectionDefaults = { flags: Partial; runtime?: SessionRuntimeHints; + connection?: RemoteConnectionRequestMetadata; }; export function readRemoteConnectionState(options: { @@ -129,6 +138,7 @@ export function resolveRemoteConnectionDefaults(options: { const profile = resolveConnectionProfile(state, options); return { runtime: state.runtime, + connection: buildRemoteConnectionRequestMetadata(state), flags: { ...profile, remoteConfig: state.remoteConfigPath, @@ -147,6 +157,17 @@ export function resolveRemoteConnectionDefaults(options: { }; } +export function buildRemoteConnectionRequestMetadata( + state: RemoteConnectionState, +): RemoteConnectionRequestMetadata | undefined { + const connection = stripUndefined({ + leaseProvider: state.leaseProvider, + deviceKey: state.deviceKey, + clientId: state.clientId, + }); + return Object.keys(connection).length > 0 ? connection : undefined; +} + export function hashRemoteConfigFile(configPath: string): string { try { return crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); @@ -264,6 +285,10 @@ function safeStateName(value: string): string { return `${safe}-${suffix}`; } +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; +} + function isRemoteConnectionState(value: unknown): value is RemoteConnectionState { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const record = value as Record; @@ -280,6 +305,9 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState typeof record.runId === 'string' && (record.leaseId === undefined || typeof record.leaseId === 'string') && (record.leaseBackend === undefined || typeof record.leaseBackend === 'string') && + (record.leaseProvider === undefined || typeof record.leaseProvider === 'string') && + (record.deviceKey === undefined || typeof record.deviceKey === 'string') && + (record.clientId === undefined || typeof record.clientId === 'string') && typeof record.connectedAt === 'string' && typeof record.updatedAt === 'string' ); diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 95349d3ce..36a1b39bc 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -700,6 +700,16 @@ test('parseArgs recognizes connect lease backend force and no-login flags', () = assert.equal(parsed.flags.noLogin, true); }); +test('parseArgs preserves connect proxy provider positional', () => { + const parsed = parseArgs( + ['connect', 'proxy', '--daemon-base-url', 'http://host:4310/agent-device'], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'connect'); + assert.deepEqual(parsed.positionals, ['proxy']); + assert.equal(parsed.flags.daemonBaseUrl, 'http://host:4310/agent-device'); +}); + test('parseArgs accepts auth management subcommands', () => { const status = parseArgs(['auth', 'status'], { strictFlags: true }); assert.equal(status.command, 'auth'); @@ -1232,12 +1242,14 @@ test('usage includes agent workflows, config, environment, and examples footers' assert.match(usageText, /verify the action with diff snapshot -i or snapshot --diff/); assert.match(usageText, /Sparse or AX-unavailable snapshot/); assert.match(usageText, /macOS context menus use click --button secondary/); - assert.match(usageText, /Direct proxy: Cloud\/Linux clients can use iOS simulators/); - assert.match(usageText, /A proxy URL\/token means direct proxy mode/); - assert.match(usageText, /Direct proxy sessions: choose one explicit --session/); - assert.match(usageText, /do not use connect, --remote-config, tenant, run, or lease flags/); - assert.match(usageText, /Cloud\/remote-config profiles are separate from direct proxy/); - assert.match(usageText, /Do not substitute --config/); + assert.match( + usageText, + /Remote lifecycle: use connect, then open, commands, close, and disconnect/, + ); + assert.match(usageText, /connect proxy --daemon-base-url /); + assert.match(usageText, /proxy device lease is automatic on open/); + assert.match(usageText, /expires after five minutes of inactivity/); + assert.match(usageText, /disconnect releases local connection state/); assert.match(usageText, /app-owned back uses back/); assert.match(usageText, /Web browser sessions: read help web/); assert.match( @@ -1575,11 +1587,10 @@ test('usageForCommand resolves remote help topic', () => { const help = usageForCommand('remote'); if (help === null) throw new Error('Expected remote help text'); assert.match(help, /agent-device connect/); - assert.match(help, /There are two different remote modes/); - assert.match(help, /Direct proxy: agent-device proxy exposes a Mac you control/); - assert.match(help, /A cloud\/Linux client can use iOS simulators through that proxied Mac/); - assert.match(help, /Use one explicit --session across open, snapshot, interactions, and close/); - assert.match(help, /Do not use connect, --remote-config, tenant, run, or lease flags/); + assert.match(help, /Remote connection providers use the same lifecycle/); + assert.match(help, /connect -> open -> commands -> close -> disconnect/); + assert.match(help, /Direct proxy: agent-device connect proxy/); + assert.match(help, /stores the shared proxy profile and client identity/); assert.match(help, /agent-device open com\.example\.app --remote-config \.\/remote-config\.json/); assert.match(help, /disconnect --remote-config \.\/remote-config\.json/); assert.match(help, /Script flow, per-command config/); @@ -1587,17 +1598,18 @@ test('usageForCommand resolves remote help topic', () => { assert.match(help, /agent-device proxy --port 4310/); assert.match( help, - /--daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device --daemon-auth-token /, - ); - assert.match(help, /agent-device open Maps --session maps/); - assert.match(help, /agent-device snapshot -i --session maps/); - assert.match(help, /agent-device close --session maps/); - assert.match(help, /store daemonBaseUrl and daemonAuthToken in normal agent-device\.json/); - assert.match(help, /keep the same explicit --session until close/); - assert.match(help, /do not run prepare ios-runner from the remote client/); - assert.match(help, /same-proxy-state stale runner leases are reclaimed/); + /connect proxy --daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device --daemon-auth-token /, + ); + assert.match(help, /agent-device open Maps --platform ios/); + assert.match(help, /agent-device snapshot -i --platform ios/); + assert.match(help, /agent-device close/); + assert.match(help, /lease is acquired lazily on open/); + assert.match(help, /expires after five minutes without commands/); + assert.match(help, /Multiple agents can share one proxy/); + assert.match(help, /disconnect releases the connection lease and local state/); + assert.match(help, /A busy direct-proxy device error means another agent owns the device/); + assert.match(help, /local\/proxy iOS reports that the runner is already owned/); assert.match(help, /same --remote-config to every operational command/); - assert.match(help, /do not use agent-device auth, connect, disconnect, --remote-config/); assert.match(help, /Do not use --config as a remote profile flag/); assert.match(help, /install-from-source --github-actions-artifact org\/repo:artifact/); }); diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 6279d60f6..52619fe18 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -80,9 +80,9 @@ const AGENT_QUICKSTART_LINES = [ 'Raw coordinates are fallback-only: use snapshot -i --json rects when iOS refs no-op or child refs are missing, then verify the action with diff snapshot -i or snapshot --diff.', 'Sparse or AX-unavailable snapshot: use screenshot for visual truth, press the visible coordinate to leave the bad screen, then retry AX with snapshot -i.', 'macOS context menus use click --button secondary, then snapshot -i. Longpress is for mobile hold gestures, not macOS secondary-click menus.', - 'Direct proxy: Cloud/Linux clients can use iOS simulators through a Mac running agent-device proxy. A proxy URL/token means direct proxy mode: use --daemon-base-url plus --daemon-auth-token, or saved daemonBaseUrl/daemonAuthToken config.', - 'Direct proxy sessions: choose one explicit --session and reuse it for open/snapshot/interactions/close; do not use connect, --remote-config, tenant, run, or lease flags.', - 'Cloud/remote-config profiles are separate from direct proxy: use connect or --remote-config on operational commands. Do not substitute --config; --config only loads CLI defaults.', + 'Remote lifecycle: use connect, then open, commands, close, and disconnect. Cloud, remote-config, direct proxy, and limrun are connection providers under the same flow.', + 'Direct proxy: run agent-device connect proxy --daemon-base-url before using a shared Mac proxy. The proxy device lease is automatic on open, refreshes on commands, expires after five minutes of inactivity, and disconnect releases local connection state.', + 'Busy direct-proxy device: another agent owns the local/proxy iOS device until it closes or the five-minute inactivity lease expires.', 'Batch JSON steps use "command" and structured "input"; legacy "positionals"/"flags" steps still run in CLI but are deprecated until the next major version.', 'Navigation: app-owned back uses back; system back uses back --system.', 'Web browser sessions: read help web; first slice is web setup if needed -> web doctor -> open --platform web -> snapshot -i -> click/fill/get/is/find/wait/screenshot -> close.', @@ -268,8 +268,10 @@ Validation and evidence: Android animations: settings animations off/on, not animations disable/restore. Debug logs: logs clear --restart, logs mark, reproduce, then logs path; do not split clear/restart into separate stop/start commands. Network headers: network dump --include headers; do not write network log headers. - Direct proxy to a Mac you control: cloud/Linux clients can still use iOS simulators through the proxied Mac. Use the printed /agent-device daemon base URL and auth token, or store them as daemonBaseUrl and daemonAuthToken in agent-device.json. Use one explicit --session across open, snapshot, interactions, and close. Do not use connect, --remote-config, tenant, run, or lease flags for direct proxy simulators. - Cloud/remote-config profiles: use connect to discover a cloud profile, or connect --remote-config ./remote-config.json for a local profile; then open, snapshot, disconnect. + Remote lifecycle: cloud, remote-config, direct proxy, and limrun are connection providers under the same flow: connect, open, commands, close, disconnect. + Remote config profile: agent-device connect --remote-config ./remote-config.json; then run normal commands and disconnect. + Direct proxy to a Mac you control: cloud/Linux clients can use local/proxy iOS devices through the proxied Mac. Run agent-device connect proxy --daemon-base-url first; connect stores the profile and client identity. The proxy device lease is automatic on open, refreshes on commands, expires after five minutes of inactivity, and disconnect releases local connection state. close releases the session/device lease where supported. + Busy direct-proxy device: another agent owns the device until it closes or the five-minute inactivity lease expires. Use lease expiry or close for normal contention. Web: agent-device uses a managed, pinned agent-browser backend as an implementation detail. Use --platform web when a browser step belongs inside an agent-device session, replay, batch, MCP, or typed-client flow; use agent-browser directly for standalone web automation. Run agent-device web setup before first use, then agent-device web doctor for backend health checks. Web automation requires Node 24+. agent-device web setup agent-device web doctor @@ -647,19 +649,26 @@ Android physical-device prerequisites: summary: 'Direct proxy, cloud profiles, and remote config', body: `agent-device help remote -There are two different remote modes: - 1. Direct proxy: agent-device proxy exposes a Mac you control. A cloud/Linux client can use iOS simulators through that proxied Mac. Use --daemon-base-url plus --daemon-auth-token, or store daemonBaseUrl and daemonAuthToken in agent-device.json. Use one explicit --session across open, snapshot, interactions, and close so implicit cwd-scoped default sessions do not diverge. Do not use connect, --remote-config, tenant, run, or lease flags for this mode. - 2. Cloud/profile: the cloud connection profile or a local --remote-config owns daemon URL, auth, tenant, run, lease, device scope, and Metro hints. Do not restate those as individual flags unless overriding intentionally. +Remote connection providers use the same lifecycle: + connect -> open -> commands -> close -> disconnect + +Providers: + Cloud: agent-device connect discovers the cloud profile. + Remote config: agent-device connect --remote-config ./remote-config.json uses a local profile. + Direct proxy: agent-device connect proxy --daemon-base-url stores the shared proxy profile and client identity. + Limrun: agent-device connect limrun uses the generated limrun profile when available. Direct proxy flow for a remote Mac/simulator: On the Mac with simulator/device access: agent-device proxy --port 4310 cloudflared tunnel --url http://127.0.0.1:4310 On the remote client: - agent-device devices --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token - agent-device open Maps --session maps --platform ios --device "iPhone 17 Pro" --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token - agent-device snapshot -i --session maps --platform ios --device "iPhone 17 Pro" --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token - agent-device close --session maps --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token + agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token + agent-device devices --platform ios + agent-device open Maps --platform ios --device "iPhone 17 Pro" + agent-device snapshot -i --platform ios --device "iPhone 17 Pro" + agent-device close + agent-device disconnect Cloud profile flow: agent-device connect @@ -681,11 +690,14 @@ Script flow, per-command config: Rules: connect and disconnect are top-level commands. Do not write agent-device remote connect or agent-device remote disconnect. Use connect without --remote-config when the cloud control plane owns the connection profile. - Prefer --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. - Use agent-device proxy for direct tunnel access to a Mac you control. Copy the printed daemon base URL and daemon auth token; do not use agent-device auth, connect, disconnect, --remote-config, tenant, run, or lease flags for this direct proxy flow. - For repeated direct proxy commands, store daemonBaseUrl and daemonAuthToken in normal agent-device.json CLI config. Keep platform selection on each command or workflow, and keep the same explicit --session until close. + Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. + Use agent-device proxy for direct tunnel access to a Mac you control. Copy the printed daemon base URL and daemon auth token, then run agent-device connect proxy --daemon-base-url before normal commands. + connect proxy establishes the connection profile and client identity. The proxy device lease is acquired lazily on open, refreshes on command activity, and expires after five minutes without commands. + Multiple agents can share one proxy when each uses the normal connect proxy/open/command/disconnect flow; the daemon isolates sessions by client. + disconnect releases the connection lease and local state. close releases the session/device lease where supported. + A busy direct-proxy device error means another agent owns the device until it closes or the five-minute inactivity lease expires. Keep the proxy token secret. Anyone with the token can control the proxied daemon. - If iOS snapshot/interaction reports that the runner is already owned by another agent-device daemon, do not run prepare ios-runner from the remote client. Retry the original snapshot or interaction; same-proxy-state stale runner leases are reclaimed by the proxy daemon. If the conflict repeats, the Mac operator should close the owning session or clean the conflicting local daemon. + If local/proxy iOS reports that the runner is already owned by another agent-device daemon after lease admission, do not run prepare ios-runner from the remote client. Retry after the owning session closes or after the five-minute inactivity lease expires; if the conflict repeats after expiry, inspect the runner owner details and clean stale daemon state on the machine with simulator access. Do not use --config as a remote profile flag. --config loads CLI defaults; --remote-config selects remote daemon/profile settings. For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required. For remote artifact installs, use install-from-source or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index a2f7de99b..f4aa8e580 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -31,41 +31,22 @@ By default the proxy binds `127.0.0.1`. Use `--host 0.0.0.0` only when you inten ## Remote Client -On the machine running the agent, use the public tunnel origin with the `/agent-device` base path: +On the machine running the agent, connect to the public tunnel origin with the `/agent-device` base path: ```bash -export AGENT_DEVICE_DAEMON_BASE_URL="https://example.trycloudflare.com/agent-device" -export AGENT_DEVICE_DAEMON_AUTH_TOKEN="" - +agent-device connect proxy \ + --daemon-base-url https://example.trycloudflare.com/agent-device \ + --daemon-auth-token agent-device devices --platform ios agent-device open MyApp --platform ios agent-device snapshot --platform ios +agent-device close +agent-device disconnect ``` -You can also pass the values per command: - -```bash -agent-device devices \ - --daemon-base-url https://example.trycloudflare.com/agent-device \ - --daemon-auth-token -``` - -For repeated use, put the remote client settings in normal CLI config: - -```json -{ - "daemonBaseUrl": "https://example.trycloudflare.com/agent-device", - "daemonAuthToken": "" -} -``` +`connect proxy` stores the proxy profile and client identity. The proxy device lease is acquired automatically on `open`, refreshes on command activity, and expires after five minutes without commands. `disconnect` releases the connection lease and local state; `close` releases the session/device lease where supported. -With `agent-device.json` in the working directory, normal commands pick up those defaults: - -```bash -agent-device devices -agent-device open MyApp -agent-device snapshot -``` +Multiple agents can share one proxy when each uses the normal `connect proxy`, `open`, command, `close`, and `disconnect` flow. A busy device error means another agent owns the device until it closes or the five-minute inactivity lease expires. Do not commit a config file that contains a live `daemonAuthToken`. @@ -81,4 +62,4 @@ Remote clients read `/health` before issuing commands and compare the daemon RPC ## Cleanup -Stop the tunnel and the `agent-device proxy` process when the remote session is done. Restarting the proxy generates a fresh token unless you supplied `--daemon-auth-token` explicitly. +Run `agent-device disconnect` when the remote session is done. Stop the tunnel and the `agent-device proxy` process only when the host should stop accepting remote clients. Restarting the proxy generates a fresh token unless you supplied `--daemon-auth-token` explicitly; use lease expiry or `close` for normal device contention. From 6f03937713054da245db106761cb5cdcfc70d9ca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 11:28:33 +0000 Subject: [PATCH 02/16] fix: keep metro bearer token out of generated proxy profile The proxy connect profile is written to disk as a non-secret remote config, but it unconditionally copied `metroBearerToken` into that file, leaking the secret at rest. Mirror the cloud path, which keeps `daemonAuthToken` in-memory only: the token still flows through this connect via the returned flags, and later commands re-supply it via AGENT_DEVICE_METRO_BEARER_TOKEN. Extend the non-secret-profile test to assert the bearer token is absent from disk. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VPa5Z9GBkeqoxVctC85N7e --- src/__tests__/remote-connection.test.ts | 3 +++ src/cli/proxy-connection-profile.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index ac06040d5..3de8f1531 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -213,6 +213,7 @@ test('connect proxy writes normal remote state with generated non-secret profile stateDir, daemonBaseUrl: 'http://proxy.example.test/agent-device', daemonAuthToken: 'proxy-secret', + metroBearerToken: 'metro-bearer-secret', platform: 'android', }, client: createTestClient(), @@ -236,9 +237,11 @@ test('connect proxy writes normal remote state with generated non-secret profile >; assert.equal(generated.daemonBaseUrl, 'http://proxy.example.test/agent-device'); assert.equal(generated.daemonAuthToken, undefined); + assert.equal(generated.metroBearerToken, undefined); assert.equal(generated.leaseProvider, 'proxy'); assert.equal(generated.leaseTtlMs, undefined); assert.equal(JSON.stringify(generated).includes('proxy-secret'), false); + assert.equal(JSON.stringify(generated).includes('metro-bearer-secret'), false); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli/proxy-connection-profile.ts b/src/cli/proxy-connection-profile.ts index 2a74608bc..ba3645425 100644 --- a/src/cli/proxy-connection-profile.ts +++ b/src/cli/proxy-connection-profile.ts @@ -46,7 +46,10 @@ export function resolveProxyConnectProfile(options: { metroKind: options.flags.metroKind, metroPublicBaseUrl: options.flags.metroPublicBaseUrl, metroProxyBaseUrl: options.flags.metroProxyBaseUrl, - metroBearerToken: options.flags.metroBearerToken, + // Secrets must never be persisted in the generated (non-secret) profile. + // Mirror the cloud path, which keeps daemonAuthToken in-memory only: the + // bearer token survives this connect via the returned flags below, and + // later commands re-supply it through AGENT_DEVICE_METRO_BEARER_TOKEN. metroPreparePort: options.flags.metroPreparePort, metroListenHost: options.flags.metroListenHost, metroStatusHost: options.flags.metroStatusHost, From d5a6489fc03bac5553a2e89b467e11ffd322a499 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 11:28:33 +0000 Subject: [PATCH 03/16] fix: always release device lease on session close releaseSessionLease + sessionStore.delete ran only on the happy path, after several awaits (app-log/perf/snapshot teardown, platform close dispatch, runner stop) that can throw. A failed close therefore stranded the device lease until the inactivity expiry. Wrap teardown in try/finally so ownership is always freed; the original error still propagates after finally. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VPa5Z9GBkeqoxVctC85N7e --- src/daemon/handlers/session-close.ts | 102 ++++++++++++++------------- 1 file changed, 54 insertions(+), 48 deletions(-) diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 518b5b998..e234f8b37 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -115,58 +115,64 @@ export async function handleCloseCommand(params: { if (!session) { return await closeWithoutSession(req, logPath); } - if (session.appLog) { - await stopAppLog(session.appLog); - } - await stopSessionApplePerfCapture(session); - await stopSessionAndroidNativePerfCapture(session); - await stopSessionAndroidSnapshotHelper(session); - if (shouldDispatchPlatformClose(req, session)) { - if (shouldStopAppleRunnerBeforeTargetedClose(session)) { + try { + if (session.appLog) { + await stopAppLog(session.appLog); + } + await stopSessionApplePerfCapture(session); + await stopSessionAndroidNativePerfCapture(session); + await stopSessionAndroidSnapshotHelper(session); + if (shouldDispatchPlatformClose(req, session)) { + if (shouldStopAppleRunnerBeforeTargetedClose(session)) { + await stopAppleRunnerForClose(session); + } + await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, { + ...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath), + }); + await settleIosSimulator(session.device, IOS_SIMULATOR_POST_CLOSE_SETTLE_MS); + } + if ( + isApplePlatform(session.device.platform) && + !shouldRetainAppleRunnerAfterClose(req, session) + ) { + // The targeted close path stops before dispatch to avoid runner/app races. + // Stop again here for idempotent cleanup, and keep cleanup-sensitive closes explicit. await stopAppleRunnerForClose(session); + } else if (isApplePlatform(session.device.platform)) { + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_retained_after_close', + data: { + session: session.name, + deviceId: session.device.id, + }, + }); } - await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, { - ...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath), - }); - await settleIosSimulator(session.device, IOS_SIMULATOR_POST_CLOSE_SETTLE_MS); - } - if ( - isApplePlatform(session.device.platform) && - !shouldRetainAppleRunnerAfterClose(req, session) - ) { - // The targeted close path stops before dispatch to avoid runner/app races. - // Stop again here for idempotent cleanup, and keep cleanup-sensitive closes explicit. - await stopAppleRunnerForClose(session); - } else if (isApplePlatform(session.device.platform)) { - emitDiagnostic({ - level: 'debug', - phase: 'ios_runner_retained_after_close', - data: { - session: session.name, - deviceId: session.device.id, - }, + const runtime = sessionStore.getRuntimeHints(sessionName); + if (hasRuntimeTransportHints(runtime) && session.appBundleId) { + await clearRuntimeHintsFromApp({ + device: session.device, + appId: session.appBundleId, + }).catch(() => {}); + } + sessionStore.recordAction(session, { + command: 'close', + positionals: req.positionals ?? [], + flags: req.flags ?? {}, + result: { session: session.name, ...successText(`Closed: ${session.name}`) }, }); + if (req.flags?.saveScript) { + session.recordSession = true; + } + sessionStore.writeSessionLog(session); + await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); + } finally { + // Always release the device lease and drop the session, even if teardown + // above threw: a failed close must not strand device ownership until the + // inactivity expiry. The original error still propagates after finally. + releaseSessionLease(session, leaseRegistry); + sessionStore.delete(sessionName); } - const runtime = sessionStore.getRuntimeHints(sessionName); - if (hasRuntimeTransportHints(runtime) && session.appBundleId) { - await clearRuntimeHintsFromApp({ - device: session.device, - appId: session.appBundleId, - }).catch(() => {}); - } - sessionStore.recordAction(session, { - command: 'close', - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { session: session.name, ...successText(`Closed: ${session.name}`) }, - }); - if (req.flags?.saveScript) { - session.recordSession = true; - } - sessionStore.writeSessionLog(session); - await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); - releaseSessionLease(session, leaseRegistry); - sessionStore.delete(sessionName); const shutdownResult = await maybeShutdownSessionTarget({ device: session.device, shutdownRequested: req.flags?.shutdown, From 7f9a61a22d207580a86ec195493527376e0b27bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 13:57:12 +0200 Subject: [PATCH 04/16] fix: reconcile integrated device leasing --- docs/adr/0007-remote-device-leases.md | 10 +- plans/001-device-aware-lease-contracts.md | 280 ---------------- ...02-automatic-direct-proxy-lease-on-open.md | 308 ------------------ ...-daemon-session-lease-admission-cleanup.md | 269 --------------- .../004-runner-diagnostics-and-proxy-docs.md | 225 ------------- plans/README.md | 85 ----- src/__tests__/remote-connection.test.ts | 25 ++ src/cli/generated-remote-config.ts | 10 +- .../__tests__/request-execution-scope.test.ts | 168 +++++++--- src/daemon/request-admission.ts | 5 + src/daemon/request-execution-scope.ts | 73 +++-- 11 files changed, 217 insertions(+), 1241 deletions(-) delete mode 100644 plans/001-device-aware-lease-contracts.md delete mode 100644 plans/002-automatic-direct-proxy-lease-on-open.md delete mode 100644 plans/003-daemon-session-lease-admission-cleanup.md delete mode 100644 plans/004-runner-diagnostics-and-proxy-docs.md delete mode 100644 plans/README.md diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 91c78c301..4d4976b23 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -32,6 +32,15 @@ weakened or replaced by them. and session creation meet there. Commands after `open` must refresh the lease; no activity for five minutes should make the device available again. +Lease admission, heartbeat, stored session lease refresh, and request execution +must run under the same daemon request lock. Scope resolution may happen before +the lock, but lease ownership mutation must not. + +Generated connection profiles are non-secret. They may persist stable routing, +device, lease provider, device key, and client identity metadata, but must strip +daemon and Metro bearer tokens. Tokens are supplied in-memory for the current +command or through the existing environment/CLI token paths. + The proxy process is expected to be long-lived and self-serve. Recovery from a stale or expired device lease should not require restarting the proxy. @@ -44,4 +53,3 @@ owning lease expiry. Backend-only leases remain valid for older remote clients. Device and provider fields are optional until provider-aware `open` acquisition and admission refreshes are implemented. - diff --git a/plans/001-device-aware-lease-contracts.md b/plans/001-device-aware-lease-contracts.md deleted file mode 100644 index 81fef2a3d..000000000 --- a/plans/001-device-aware-lease-contracts.md +++ /dev/null @@ -1,280 +0,0 @@ -# Plan 001: Add device-aware lease contracts - -> **Executor instructions**: Follow this plan step by step. Run every -> verification command and confirm the expected result before moving to the -> next step. If anything in the "STOP conditions" section occurs, stop and -> report; do not improvise. When done, update the status row for this plan in -> `plans/README.md`. -> -> **Drift check (run first)**: -> `git diff --stat 04f53bbc0..HEAD -- CONTEXT.md docs/adr src/contracts.ts src/client-types.ts src/remote-config-schema.ts src/remote-connection-state.ts src/daemon/lease-context.ts src/daemon/lease-registry.ts src/daemon/handlers/lease.ts src/daemon/http-server.ts src/daemon/__tests__/lease-registry.test.ts src/daemon/__tests__/request-handler-catalog.test.ts` -> -> If any in-scope file changed since this plan was written, compare the -> "Current state" excerpts against the live code before proceeding; on a -> mismatch, treat it as a STOP condition. - -## Status - -- **Priority**: P1 -- **Effort**: M -- **Risk**: HIGH -- **Depends on**: none -- **Category**: correctness | architecture -- **Planned at**: commit `04f53bbc0`, 2026-06-26 - -## Why this matters - -The current lease model can say "tenant/run has an iOS simulator allocation", -but it cannot say "client A owns device C25D... through provider proxy for the -next five minutes". Direct proxy needs that contract, and the limrun worktree -already adds a `leaseProvider` dimension for provider-backed runtime routing. -Without provider-aware device leases, the daemon cannot reject a second agent -before it reaches the iOS runner, and the user sees a backend helper ownership -error instead of a clear device contention error. - -## Current state - -- `src/daemon/lease-registry.ts` owns lease state. Today `SimulatorLease` - contains `leaseId`, `tenantId`, `runId`, `backend`, `createdAt`, - `heartbeatAt`, and `expiresAt`. -- The limrun integration worktree at - `/Users/thymikee/.codex/worktrees/20c8/agent-device` extends the same lease - model with `provider`/`leaseProvider` and routes Limrun inventory by provider - plus lease id. Preserve that dimension if it has landed before this plan is - executed. -- `src/daemon/lease-registry.ts` currently binds leases by - `tenantId:runId:backend`, via `bindingKey(tenantId, runId, backend)`. -- `src/daemon/lease-registry.ts` currently enforces capacity with - `maxActiveSimulatorLeases` and only counts `backend === 'ios-simulator'`. -- `src/daemon/handlers/lease.ts` routes `lease_allocate`, `lease_heartbeat`, - and `lease_release` into `LeaseRegistry`. -- `src/contracts.ts` exposes lease payloads and schemas. Current metadata - includes `tenantId`, `runId`, `leaseId`, `leaseTtlMs`, `leaseBackend`, and - `sessionIsolation`, but not device or client identity. If the limrun worktree - has landed, it also includes `leaseProvider`; keep it optional and - provider-neutral. -- `src/remote-connection-state.ts` persists `tenant`, `runId`, `leaseId`, - `leaseBackend`, platform, target, and runtime hints for `connect`. If the - limrun worktree has landed, it also persists `leaseProvider`. -- `docs/adr/0002-persistent-platform-helper-sessions.md` says helper sessions - are daemon-owned, session-scoped resources. Preserve that boundary: this plan - adds a logical device lease above helper sessions; it does not make runners - public resources. - -## Commands you will need - -| Purpose | Command | Expected on success | -|---------|---------|---------------------| -| Format | `pnpm format` | exit 0 | -| Focused tests | `pnpm exec vitest run src/daemon/__tests__/lease-registry.test.ts src/daemon/__tests__/request-handler-catalog.test.ts` | all tests pass | -| Typecheck | `pnpm typecheck` | exit 0, no errors | - -## Scope - -**In scope**: -- `docs/adr/0007-remote-device-leases.md` (create) -- `CONTEXT.md` -- `src/contracts.ts` -- `src/client-types.ts` -- `src/remote-config-schema.ts` -- `src/remote-connection-state.ts` -- `src/daemon/lease-context.ts` -- `src/daemon/lease-registry.ts` -- `src/daemon/handlers/lease.ts` -- `src/daemon/http-server.ts` -- `src/daemon/__tests__/lease-registry.test.ts` -- `src/daemon/__tests__/request-handler-catalog.test.ts` - -**Out of scope**: -- Connection provider behavior. Plan 002 owns `connect proxy` and lazy proxy - lease acquisition. -- Session admission/cleanup behavior. Plan 003 owns enforcement after `open`. -- iOS runner lease files. Plan 004 owns diagnostics and runner alignment. -- Android or iOS platform command execution changes. - -## Git workflow - -- Branch: `advisor/001-device-aware-lease-contracts` -- Commit message: `feat: add device-aware lease contracts` -- Do not push or open a PR unless the operator instructed it. - -## Steps - -### Step 1: Record the architecture decision and vocabulary - -Create `docs/adr/0007-remote-device-leases.md`. - -The ADR must state: -- A remote device lease is logical ownership of one selected device by one - remote agent/client for a connection provider such as `proxy`, cloud bridge, - or `limrun`. -- `connect` establishes a connection profile and client identity; lease - allocation remains lazy and happens when a device/backend/provider is known. -- A runner/process lease is a backend helper guard and is not a user/client - ownership boundary. -- `open` is the natural point to acquire a device lease because that is where - target resolution and session creation meet. -- Commands after `open` must refresh the lease; no activity for five minutes - should make the device available again. -- The proxy process is expected to be long-lived and self-serve; recovery should - not require restarting the proxy. - -Update `CONTEXT.md` with concise definitions for: -- `Device lease` -- `Device key` -- `Lease provider` -- `Direct proxy client id` -- `Runner/process lease` - -Do not duplicate CLI manuals in `CONTEXT.md`. - -**Verify**: `rg -n "Device lease|Device key|Lease provider|Direct proxy client id|Runner/process lease" CONTEXT.md docs/adr/0007-remote-device-leases.md` -> all five terms are found. - -### Step 2: Extend public lease payloads - -In `src/contracts.ts`, add optional fields to daemon request metadata and lease -RPC payload schemas: - -- `deviceKey?: string` -- `clientId?: string` -- `leaseProvider?: string` if it is not already present from the limrun worktree. - -Use the existing schema helper style near `leaseAllocateSchema`, -`leaseHeartbeatSchema`, and `leaseReleaseSchema`. Add validation helpers that -accept only bounded, printable, agent-safe identifiers: - -- `deviceKey`: 1-256 chars, no whitespace-only value. -- `clientId`: 1-128 chars, letters, numbers, dot, underscore, hyphen. -- `leaseProvider`: 1-64 chars, letters, numbers, dot, underscore, hyphen. - -In `src/client-types.ts`, `src/remote-config-schema.ts`, and -`src/remote-connection-state.ts`, expose/persist the same optional fields where -lease state is represented. Do not require the fields yet; -backward-compatible remote runtime leases must still compile. - -**Verify**: `pnpm typecheck` -> exits 0. - -### Step 3: Make `LeaseRegistry` device-aware - -In `src/daemon/lease-registry.ts`: - -- Rename `SimulatorLease` to `DeviceLease` only if you keep a compatibility - alias: `export type SimulatorLease = DeviceLease;`. Prefer `DeviceLease` - internally. This alias is required because the limrun worktree imports - `SimulatorLease` from `src/daemon/lease-registry.ts`. -- Add optional `deviceKey?: string` and `clientId?: string` to the lease record. -- Preserve optional `provider?: string`/`leaseProvider?: string` if present, or - add one normalized provider field if it has not landed yet. -- Add `deviceKey?: string` and `clientId?: string` to allocate, heartbeat, - release, and admission request types. -- Add provider to allocate, heartbeat, release, and admission request types. -- Keep old backend-only/provider-less leases working when `deviceKey` and - `leaseProvider` are omitted. -- Change idempotent allocation binding from `tenantId:runId:backend` to include - provider and `deviceKey` when present. A suggested key shape is - `${tenantId}:${runId}:${backend}:${provider ?? 'default'}:${deviceKey ?? '*'}`. -- Add a separate `deviceBindings` map keyed by - `${backend}:${provider ?? 'default'}:${deviceKey}`. When a new active lease - asks for a device key already bound to a different lease for the same - backend/provider, throw: - - code: `COMMAND_FAILED` - - message: `Device is already leased` - - details reason: `DEVICE_LEASE_BUSY` - - include `deviceKey`, `backend`, `leaseProvider`, `leaseId`, `tenantId`, - `runId`, `expiresAt`, and a hint saying to retry after the lease expires or - close the owning session. -- On heartbeat/release/admission, if `deviceKey`, `clientId`, or - `leaseProvider` is supplied, it must match the active lease. -- `cleanupExpiredLeases()` must remove both run bindings and device bindings. - -Keep `DEFAULT_LEASE_TTL_MS` at 60 seconds for now. Plan 002 sets the direct -proxy TTL explicitly to five minutes. - -**Verify**: `pnpm exec vitest run src/daemon/__tests__/lease-registry.test.ts` -> existing tests pass before adding new ones. - -### Step 4: Cover device contention and matching - -Extend `src/daemon/__tests__/lease-registry.test.ts` with behavioral tests: - -- allocating the same `tenantId`/`runId`/`backend`/`deviceKey` returns the same - lease and refreshes expiry when provider also matches. -- allocating a different tenant/run for the same `backend` and `deviceKey` - and provider throws `DEVICE_LEASE_BUSY`. -- allocating the same `deviceKey` for different providers succeeds; provider - routing must remain isolated for limrun/cloud/proxy. -- allocating two different `deviceKey` values succeeds even when backend is the - same. -- heartbeat with the wrong `deviceKey` or provider throws - `LEASE_SCOPE_MISMATCH`. -- expired device leases are removed from `deviceBindings` and a new client can - allocate the device. -- old backend-only leases still pass the existing tests. - -**Verify**: `pnpm exec vitest run src/daemon/__tests__/lease-registry.test.ts` -> all tests pass and at least five new tests cover device-aware behavior. - -### Step 5: Thread fields through the lease RPC layer - -Update: - -- `src/daemon/lease-context.ts` to resolve `deviceKey` and `clientId` from - `req.meta` or `req.flags` where appropriate, and resolve `leaseProvider` - from the same source if present. -- `src/daemon/handlers/lease.ts` to pass the resolved fields to - `allocateLease`, `heartbeatLease`, and `releaseLease`. -- `src/daemon/http-server.ts` to parse `deviceKey`, `clientId`, and - `leaseProvider` from JSON-RPC params into daemon request metadata for lease - RPC methods. -- `src/daemon/__tests__/request-handler-catalog.test.ts` to assert lease - handler responses preserve `deviceKey`, `clientId`, and `leaseProvider` when - supplied. - -Do not add user-facing CLI flags in this step. Connection providers in Plan 002 -should set these fields internally through generated profiles and request -metadata. - -**Verify**: `pnpm exec vitest run src/daemon/__tests__/lease-registry.test.ts src/daemon/__tests__/request-handler-catalog.test.ts` -> all tests pass. - -## Test plan - -- Primary tests: `src/daemon/__tests__/lease-registry.test.ts`. -- Handler projection test: `src/daemon/__tests__/request-handler-catalog.test.ts`. -- Run `pnpm typecheck` to prove public contract fields compile across client - and daemon types. - -## Done criteria - -- [ ] `docs/adr/0007-remote-device-leases.md` exists and defines logical - provider-aware device leases versus runner/process leases. -- [ ] `CONTEXT.md` includes the five new vocabulary entries. -- [ ] Lease payload schemas accept optional `deviceKey`, `clientId`, and - `leaseProvider`. -- [ ] `LeaseRegistry` rejects active same-device conflicting leases with - `DEVICE_LEASE_BUSY` only within the same backend/provider. -- [ ] Backend-only lease tests still pass. -- [ ] `pnpm format`, focused tests, and `pnpm typecheck` exit 0. -- [ ] `plans/README.md` status row updated. - -## STOP conditions - -Stop and report if: - -- Existing lease RPC payloads are generated from a different contract source - than `src/contracts.ts`. -- Adding `deviceKey` requires changing platform dispatch or target resolution; - that belongs in Plan 002 or Plan 003. This plan only adds the contract and - registry semantics. -- `leaseProvider` has already landed under a different public field name. In - that case, preserve the landed field and update this plan's names before - coding. -- Any public result type currently named `SimulatorLease` is consumed outside - this package in a way that makes renaming a breaking change. Use the alias - instead. -- Focused lease tests fail twice after a reasonable fix attempt. - -## Maintenance notes - -- Reviewers should scrutinize compatibility: old tenant/run/backend leases must - keep working. -- The busy error is part of the user experience. Keep it short and actionable. -- Do not weaken the iOS runner file lease. This plan adds a higher-level - logical lease, not a replacement for process mutual exclusion. diff --git a/plans/002-automatic-direct-proxy-lease-on-open.md b/plans/002-automatic-direct-proxy-lease-on-open.md deleted file mode 100644 index 2ea771787..000000000 --- a/plans/002-automatic-direct-proxy-lease-on-open.md +++ /dev/null @@ -1,308 +0,0 @@ -# Plan 002: Add proxy as a connect provider - -> **Executor instructions**: Follow this plan step by step. Run every -> verification command and confirm the expected result before moving to the -> next step. If anything in the "STOP conditions" section occurs, stop and -> report; do not improvise. When done, update the status row for this plan in -> `plans/README.md`. -> -> **Drift check (run first)**: -> `git diff --stat 04f53bbc0..HEAD -- src/cli src/client-normalizers.ts src/client-types.ts src/commands src/contracts.ts src/remote-config-schema.ts src/remote-connection-state.ts src/daemon/handlers/session-open.ts src/daemon/__tests__/request-router-open.test.ts test/integration/smoke-open-remote-config.test.ts` -> -> If any in-scope file changed since this plan was written, compare the -> "Current state" excerpts against the live code before proceeding; on a -> mismatch, treat it as a STOP condition. - -## Status - -- **Priority**: P1 -- **Effort**: L -- **Risk**: HIGH -- **Depends on**: `plans/001-device-aware-lease-contracts.md` -- **Category**: correctness | dx -- **Planned at**: commit `04f53bbc0`, 2026-06-26 - -## Why this matters - -Cloud and remote-config flows already use `connect` as the user-facing -connection lifecycle: authenticate or resolve a profile, persist -`RemoteConnectionState`, then let later commands allocate or refresh the lease. -The limrun worktree follows the same pattern with `connect limrun`. Direct -proxy should not grow a parallel state machine. It should become another -connection provider, so agents use one mental model across self-hosted proxy, -agent-device-cloud, and limrun. - -## Current state - -- `src/cli/commands/connection.ts` implements `connect`, `disconnect`, and - `connection status`. Without `--remote-config`, it resolves a cloud - connection profile. -- `src/cli/commands/connection-runtime.ts` materializes remote config state on - non-deferred commands, allocates or heartbeats a lease, updates persisted - state, and prepares Metro on `open`. -- `src/remote-connection-state.ts` persists connection state under - `remote-connections`, including remote config path/hash, sanitized daemon - state, `tenant`, `runId`, `leaseId`, `leaseBackend`, platform, target, and - runtime hints. -- `src/cli/cloud-connection-profile.ts` fetches a cloud connection profile and - writes a generated remote config. -- The limrun worktree at - `/Users/thymikee/.codex/worktrees/20c8/agent-device` adds - `connect limrun`, `src/cli/generated-remote-config.ts`, - `src/cli/limrun-connection-profile.ts`, `leaseProvider`, and provider-backed - cloud runtimes. Reuse that shape if it has landed before this plan executes. -- `src/cli/commands/proxy.ts` starts a long-lived proxy and prints a shared - proxy URL/token. It does not create a per-agent connection profile. -- `src/utils/cli-help.ts` currently says direct proxy users should not use - `connect`, tenant, run, or lease flags. This must be changed after behavior - exists. - -## Commands you will need - -| Purpose | Command | Expected on success | -|---------|---------|---------------------| -| Format | `pnpm format` | exit 0 | -| Focused CLI tests | `pnpm exec vitest run src/__tests__/remote-connection.test.ts src/__tests__/cloud-connect-profile.test.ts src/utils/__tests__/args.test.ts` | all tests pass | -| Remote smoke | `node --test test/integration/smoke-open-remote-config.test.ts` | all tests pass | -| Open route tests | `pnpm exec vitest run src/daemon/__tests__/request-router-open.test.ts` | all tests pass | -| Typecheck | `pnpm typecheck` | exit 0, no errors | - -## Scope - -**In scope**: -- `src/cli/generated-remote-config.ts` (create if not already present) -- `src/cli/proxy-connection-profile.ts` (create) -- `src/cli/cloud-connection-profile.ts` -- `src/cli/commands/connection.ts` -- `src/cli/commands/connection-runtime.ts` -- `src/remote-config-schema.ts` -- `src/remote-connection-state.ts` -- `src/client-normalizers.ts` -- `src/client-types.ts` -- `src/commands/command-projection.ts` -- `src/daemon/handlers/session-open.ts` -- `src/daemon/__tests__/request-router-open.test.ts` -- `src/__tests__/remote-connection.test.ts` -- `src/__tests__/cloud-connect-profile.test.ts` -- `src/utils/__tests__/args.test.ts` -- `test/integration/smoke-open-remote-config.test.ts` - -**Out of scope**: -- Changing the proxy server token model. -- Replacing cloud auth or the cloud connection profile endpoint. -- Reworking limrun runtime internals. -- Session cleanup on expiry. Plan 003 owns inactivity cleanup. -- User-facing help/docs updates beyond parse tests. Plan 004 owns final copy. - -## Git workflow - -- Branch: `advisor/002-connect-proxy-provider` -- Commit message: `feat: add proxy connect provider` -- Do not push or open a PR unless the operator instructed it. - -## Steps - -### Step 1: Extract generated remote config helpers - -If `src/cli/generated-remote-config.ts` does not already exist, extract the -generated-profile writer from `src/cli/cloud-connection-profile.ts`. - -The helper should provide: - -```ts -writeGeneratedRemoteConfig({ - stateDir, - provider, - profile, -}): string - -resolveGeneratedRemoteConfigProfile({ - configPath, - cwd, - env, - provider, -}): ResolvedRemoteConfigProfile -``` - -Requirements: - -- Write under `${stateDir}/remote-connections/generated`. -- Include the provider name and a profile hash in the file name. -- Write mode `0o600` and avoid storing secrets. -- Keep cloud behavior byte-for-byte equivalent except for the generated file - name prefix changing from hard-coded `cloud` to provider-driven `cloud`. - -**Verify**: `pnpm exec vitest run src/__tests__/cloud-connect-profile.test.ts` -> existing cloud generated-profile tests pass. - -### Step 2: Add a proxy connection profile resolver - -Create `src/cli/proxy-connection-profile.ts`. - -It should build a generated remote config for `connect proxy` from flags/env: - -- `daemonBaseUrl`: required from `--daemon-base-url`, - `AGENT_DEVICE_DAEMON_BASE_URL`, or a proxy-specific flag if one already - exists. -- `daemonAuthToken`: optional from existing daemon auth sources. -- `daemonTransport`: default `http` for explicit proxy URLs unless the user - overrides it. -- `tenant`: default `proxy`. -- `runId`: stable per local state dir and proxy base URL, such as - `proxy-${clientId}`. -- `clientId`: generated once per state dir and proxy base URL, non-secret, safe - identifier. Store it in the generated profile or connection state only if - Plan 001 added the field; otherwise store enough data to reproduce the same - run id. -- `sessionIsolation`: `tenant`. -- `leaseProvider`: `proxy` if Plan 001 added provider support. -- `leaseBackend`: infer from `--platform ios|android` when supplied, otherwise - leave pending so the first device command can resolve it. -- `leaseTtlMs`: 300,000 ms if Plan 001 exposed it through connection/profile - defaults; otherwise set it when allocating/heartbeating in Step 4. -- `platform`, `target`, `device`, `udid`, `serial`, `session`, and Metro fields - should pass through from flags like cloud/remote config does. - -Do not put the shared proxy bearer token in the generated remote config unless -the current remote config contract already stores daemon auth there. Prefer the -existing sanitized daemon state and environment auth behavior. - -**Verify**: add tests proving `connect proxy` with a daemon base URL writes a -generated profile with tenant/run/session isolation and no raw token value. - -### Step 3: Route `connect` through providers - -Update `src/cli/commands/connection.ts` so `connect` accepts at most one -provider positional: - -- `agent-device connect` keeps current cloud behavior. -- `agent-device connect --remote-config ./remote.json` keeps current explicit - remote-config behavior. -- `agent-device connect proxy --daemon-base-url http://host:port/agent-device` - uses `resolveProxyConnectProfile`. -- If the limrun worktree has landed, preserve `agent-device connect limrun`. - -Rules: - -- Provider positional and `--remote-config` are mutually exclusive. -- Unknown provider errors must list supported providers. At minimum: - `proxy`; include `limrun` if present. -- `connect proxy` requires a daemon base URL. -- `connect proxy` should write normal `RemoteConnectionState`; do not create - a separate direct-proxy state file. - -**Verify**: `pnpm exec vitest run src/utils/__tests__/args.test.ts src/__tests__/remote-connection.test.ts` -> tests cover provider positional parsing, unknown provider, and state persistence. - -### Step 4: Preserve provider/client/device fields in connection state - -Update `src/remote-config-schema.ts`, `src/remote-connection-state.ts`, -`src/client-types.ts`, `src/client-normalizers.ts`, and -`src/commands/command-projection.ts` as needed so optional fields from Plan 001 -flow through the same path as tenant/run/lease/backend: - -- `leaseProvider` -- `clientId` -- `deviceKey` -- `leaseTtlMs` when present - -For remote-config and cloud flows, all fields remain optional. For proxy, -`clientId` and `leaseProvider: proxy` should be persisted. - -**Verify**: `pnpm typecheck` -> exits 0. - -### Step 5: Make lease allocation device-aware without breaking remote materialization - -Current `connection-runtime.ts` allocates leases before most commands based on -backend/provider only. Keep that behavior for providers that do not require a -known physical/local device key, including current cloud/limrun flows. - -Add a shared device-aware path for providers that do require a selected device -key. For `connect proxy`, use this path: - -- On `open`, resolve the target platform/device before platform side effects. -- Derive a stable `deviceKey`, for example - `${platform}:${target}:${udid|serial|deviceId}`. -- Allocate or heartbeat the lease with `tenant`, `runId`, `leaseBackend`, - `leaseProvider: proxy`, `clientId`, `deviceKey`, and `ttlMs: 300_000`. -- Attach the resulting `leaseId`, `leaseProvider`, `clientId`, and `deviceKey` - to the request metadata before app open/session creation continues. -- Persist the returned lease fields in `RemoteConnectionState`. - -Do not allow `open` to mutate the target device before a conflicting proxy -device lease would be detected. If the existing open route cannot expose the -selected `deviceKey` early enough, stop and report; solve that in daemon open -or target-resolution orchestration, not with a post-open retry. - -For non-`open` commands: - -- If a proxy connection already has `leaseId` and `deviceKey`, heartbeat and - attach them. -- If a command needs a session but there is no active proxy lease, fail with: - "No active proxy device lease for this session; run open first." -- `devices` may run after `connect proxy` without a device lease so users can - inspect inventory. - -**Verify**: `pnpm exec vitest run src/daemon/__tests__/request-router-open.test.ts` -> add a test proving a busy proxy device lease rejects `open` before dispatch/open side effects. - -### Step 6: Release proxy leases through `disconnect` and `close` - -Use the existing `disconnect` flow: - -- best-effort close the active session; -- stop Metro/React DevTools cleanup; -- release the persisted lease with tenant/run/provider/client/device metadata; -- remove `RemoteConnectionState`. - -Also make `close` release the active proxy lease only for the scoped session -when Plan 003 session ownership metadata is available. Before Plan 003 lands, -`close` should at least close the daemon session and leave `disconnect` as the -full connection cleanup command. - -**Verify**: tests show `disconnect` releases with `leaseProvider: proxy` and -removes connection state. - -## Test plan - -- Extend `src/__tests__/remote-connection.test.ts` for `connect proxy` state, - provider compatibility, `--force`, and disconnect release. -- Extend `src/utils/__tests__/args.test.ts` for provider positional parsing and - help examples. -- Extend `src/daemon/__tests__/request-router-open.test.ts` for device-aware - busy rejection before open side effects. -- Extend `test/integration/smoke-open-remote-config.test.ts` or create a - focused proxy smoke fixture proving `connect proxy -> open -> snapshot -> - disconnect` sends tenant/run/lease/provider metadata. - -## Done criteria - -- [ ] `agent-device connect proxy --daemon-base-url ...` creates normal - `RemoteConnectionState`. -- [ ] No separate direct-proxy lease-state file exists. -- [ ] Proxy connection state includes a stable non-secret client identity. -- [ ] Proxy `open` acquires a five-minute device-aware lease before mutating - the target device/app. -- [ ] Commands after proxy `open` attach lease metadata automatically. -- [ ] `disconnect` releases the proxy lease and removes connection state. -- [ ] Cloud, explicit remote-config, and limrun connect flows still work. -- [ ] `pnpm format`, focused tests, and `pnpm typecheck` exit 0. -- [ ] `plans/README.md` status row updated. - -## STOP conditions - -Stop and report if: - -- The open path cannot know the selected `deviceKey` before side effects. -- `connect proxy` cannot be represented as a generated remote config without - persisting secrets. -- Provider positional parsing conflicts with existing `connect --remote-config` - or cloud implicit login semantics. -- Limrun provider fields have landed under names that conflict with - `leaseProvider`, `clientId`, or `deviceKey`. -- Focused tests fail twice after a reasonable fix attempt. - -## Maintenance notes - -- `connect` owns connection/profile identity. Lease allocation remains lazy. -- Provider-specific runtime allocation belongs behind daemon/bridge providers; - ordinary command users should not see limrun/proxy internals. -- Reviewers should focus on the first command after `connect proxy`: inventory - may be lease-free, but device mutation must not bypass lease acquisition. diff --git a/plans/003-daemon-session-lease-admission-cleanup.md b/plans/003-daemon-session-lease-admission-cleanup.md deleted file mode 100644 index e40ec2e8c..000000000 --- a/plans/003-daemon-session-lease-admission-cleanup.md +++ /dev/null @@ -1,269 +0,0 @@ -# Plan 003: Bind daemon sessions to active device leases - -> **Executor instructions**: Follow this plan step by step. Run every -> verification command and confirm the expected result before moving to the -> next step. If anything in the "STOP conditions" section occurs, stop and -> report; do not improvise. When done, update the status row for this plan in -> `plans/README.md`. -> -> **Drift check (run first)**: -> `git diff --stat 04f53bbc0..HEAD -- src/daemon src/platforms/ios/runner-session.ts src/platforms/android src/contracts.ts src/client-types.ts` -> -> If any in-scope file changed since this plan was written, compare the -> "Current state" excerpts against the live code before proceeding; on a -> mismatch, treat it as a STOP condition. - -## Status - -- **Priority**: P1 -- **Effort**: L -- **Risk**: HIGH -- **Depends on**: `plans/001-device-aware-lease-contracts.md`, - `plans/002-automatic-direct-proxy-lease-on-open.md` -- **Category**: correctness -- **Planned at**: commit `04f53bbc0`, 2026-06-26 - -## Why this matters - -Automatic lease acquisition is only half the fix. The daemon must also bind -session state to the lease that created it, refresh that lease during activity, -reject commands from other clients, and reclaim idle sessions without a proxy -restart. This plan makes the daemon the enforcement point for long-lived proxy -sharing while keeping cloud and limrun provider leases on the same -tenant/run/lease/provider contract. - -## Current state - -- `src/daemon/request-execution-scope.ts` scopes the request session, resolves - an effective session, and then calls lease admission. -- `src/daemon/request-admission.ts` currently enforces lease admission only when - `sessionIsolation === 'tenant'`. -- `src/daemon/session-store.ts` owns persisted daemon sessions. -- `src/daemon/handlers/session-open.ts` creates or updates the session after - target/app resolution and platform open work. -- `src/daemon/handlers/session-close.ts` tears down resources and deletes the - session. -- The limrun worktree uses the same `LeaseRegistry`, lease handler, request - admission, provider release, and device inventory hooks. Treat cloud/limrun - as in-scope regression paths when provider fields are present. -- `docs/adr/0002-persistent-platform-helper-sessions.md` and - `docs/adr/0005-ios-runner-interaction-lifecycle.md` keep helper lifecycle in - the daemon. Do not make CLI clients directly own helpers. - -## Commands you will need - -| Purpose | Command | Expected on success | -|---------|---------|---------------------| -| Format | `pnpm format` | exit 0 | -| Focused daemon tests | `pnpm exec vitest run src/daemon/__tests__/request-execution-scope.test.ts src/daemon/__tests__/request-router-open.test.ts src/daemon/__tests__/request-router-lock-policy.test.ts src/daemon/__tests__/session-store.test.ts` | all tests pass | -| Typecheck | `pnpm typecheck` | exit 0, no errors | -| Unit bundle | `pnpm check:unit` | exits 0 in a device-capable environment | - -## Scope - -**In scope**: -- `src/daemon/types.ts` -- `src/daemon/session-store.ts` -- `src/daemon/request-admission.ts` -- `src/daemon/request-execution-scope.ts` -- `src/daemon/request-router.ts` -- `src/daemon/handlers/session-open.ts` -- `src/daemon/handlers/session-close.ts` -- `src/daemon/handlers/session.ts` only for orchestration around open/close -- `src/daemon/__tests__/request-execution-scope.test.ts` -- `src/daemon/__tests__/request-router-open.test.ts` -- `src/daemon/__tests__/request-router-lock-policy.test.ts` -- `src/daemon/__tests__/session-store.test.ts` -- Limrun/cloud-focused lease tests if `src/cloud/**` or provider lifecycle - hooks have landed - -**Out of scope**: -- Changing platform action implementations. -- Changing the global iOS runner file lease. -- Adding a public lease status command. -- Changing provider runtime allocation internals. Provider regression tests are - in scope only to prove admission/cleanup did not break them. - -## Git workflow - -- Branch: `advisor/003-daemon-session-lease-admission-cleanup` -- Commit message: `fix: enforce device leases for daemon sessions` -- Do not push or open a PR unless the operator instructed it. - -## Steps - -### Step 1: Persist lease ownership on sessions - -Extend the daemon session type in `src/daemon/types.ts` with an optional field: - -```ts -lease?: { - leaseId: string; - tenantId: string; - runId: string; - clientId?: string; - backend?: string; - leaseProvider?: string; - deviceKey?: string; - expiresAt?: number; -}; -``` - -Update `src/daemon/session-store.ts` read/write behavior only as needed. Keep -old sessions without `lease` valid. - -Add tests in `src/daemon/__tests__/session-store.test.ts`: - -- session lease metadata round-trips through store persistence. -- old session fixture/object without lease metadata still loads. - -**Verify**: `pnpm exec vitest run src/daemon/__tests__/session-store.test.ts` -> all tests pass. - -### Step 2: Bind `open` sessions to the admitted lease - -In `src/daemon/handlers/session-open.ts`, after the lease from Plan 002 has -been allocated/admitted and before persisting the session, write lease metadata -into the session. - -Rules: - -- If request metadata contains `leaseId`, `tenantId`, `runId`, `leaseProvider`, - `clientId`, and `deviceKey`, the persisted session must contain the same - values. -- If request metadata lacks lease fields, preserve existing local behavior. -- If `open` is running in `connect proxy`/tenant isolation and required proxy - lease fields are missing, throw an actionable `INVALID_ARGS` error before - platform side effects. -- Cloud/limrun opens may have `leaseProvider` without `deviceKey`; keep that - valid because those providers allocate runtime devices behind the lease. - -Add an open route test proving the stored session contains lease metadata. - -**Verify**: `pnpm exec vitest run src/daemon/__tests__/request-router-open.test.ts` -> all tests pass. - -### Step 3: Enforce lease admission against the session owner - -Update `src/daemon/request-admission.ts` and -`src/daemon/request-execution-scope.ts` so device lease admission works even -when later requests rely on the stored session for ownership context. - -Behavior: - -- If request metadata has a `leaseId`, use it for admission. -- Else, if the effective session has `session.lease`, use that session lease for - admission. -- If both exist, they must match on `leaseId`, `tenantId`, `runId`, - `leaseProvider`, `clientId`, and `deviceKey` when those fields are present. -- If a session has `lease`, commands that are not lease-admission-exempt must - not run without a matching active lease. -- Lease commands remain admission-exempt. -- Local non-proxy sessions without `session.lease` keep existing behavior. - -On successful admission, heartbeat the lease with the requested TTL or the -default from the active lease. Use the proxy TTL of 300,000 ms when the metadata -indicates `leaseProvider: proxy`. - -Add tests: - -- command with matching session lease succeeds and heartbeats. -- command with wrong `leaseId` is rejected before handler dispatch. -- command with wrong `leaseProvider`, `clientId`, or `deviceKey` is rejected - before handler dispatch when those fields are present. -- command with no metadata but leased session succeeds by using session lease. -- command for local unleased session still succeeds. -- limrun/cloud provider lease admission still succeeds without `deviceKey` when - the provider owns runtime allocation behind the lease. - -**Verify**: `pnpm exec vitest run src/daemon/__tests__/request-execution-scope.test.ts src/daemon/__tests__/request-router-lock-policy.test.ts` -> all tests pass. - -### Step 4: Release lease on close - -In `src/daemon/handlers/session-close.ts`, release the session's lease when -closing a leased session. - -Rules: - -- Release after platform/session cleanup has been attempted. -- Release is idempotent. -- If release says `{ released: false }`, close still succeeds. -- If a different client tries to close a leased session, admission must reject - before cleanup. - -Add tests: - -- close releases the session lease. -- wrong client cannot close another client's leased session. - -**Verify**: `pnpm exec vitest run src/daemon/__tests__/request-execution-scope.test.ts src/daemon/__tests__/request-router-open.test.ts` -> all tests pass. - -### Step 5: Reclaim inactive leased sessions - -Add a cleanup path that runs opportunistically before request admission and -lease allocation. Prefer extending `LeaseRegistry.cleanupExpiredLeases()` to -return expired leases, or add a method such as `consumeExpiredLeases()`. - -When a lease expires: - -- Find sessions with matching `session.lease.leaseId`. -- Delete those sessions from `SessionStore`. -- Tear down associated helper resources only through existing session cleanup - paths. Do not directly kill runner processes from the lease registry. -- Emit diagnostics with the lease id, session name, device key, and reason - `LEASE_EXPIRED`. - -This cleanup is the five-minute inactivity safety net. It must not require -restarting the proxy. - -Add tests: - -- advancing fake time past expiry removes the leased session before the next - command. -- after expiry, another client can open/acquire the same `deviceKey`. -- after expiry, provider release hooks still run for cloud/limrun leases if - provider lifecycle hooks are present. - -**Verify**: `pnpm exec vitest run src/daemon/__tests__/request-execution-scope.test.ts src/daemon/__tests__/session-store.test.ts` -> all tests pass. - -## Test plan - -- Session persistence coverage in `session-store.test.ts`. -- Admission and heartbeat coverage in `request-execution-scope.test.ts`. -- Open/close behavior coverage in `request-router-open.test.ts`. -- Lock policy regression in `request-router-lock-policy.test.ts`, because - proxy sessions must remain serialized by the effective scoped session. -- Provider regression coverage when `src/cloud/**` is present: limrun/cloud - leases still allocate, heartbeat, release, and clean up expired provider - sessions. - -## Done criteria - -- [ ] Leased sessions persist lease metadata. -- [ ] Commands against leased sessions are rejected unless the active lease - matches. -- [ ] Successful commands heartbeat the lease. -- [ ] `close` releases the lease. -- [ ] Expired leases clean up owned sessions without proxy restart. -- [ ] Cloud/limrun provider leases still work when provider hooks are present. -- [ ] Local unleased workflows still work. -- [ ] `pnpm format`, focused tests, and `pnpm typecheck` exit 0. -- [ ] `plans/README.md` status row updated. - -## STOP conditions - -Stop and report if: - -- Session cleanup requires platform-specific runner killing from - `LeaseRegistry`; that would violate the daemon/helper lifecycle boundary. -- Effective session scoping happens too late to reject wrong-client commands - before side effects. -- Cloud or limrun leases start failing because proxy-only assumptions leaked - into provider-backed paths. -- Focused tests fail twice after a reasonable fix attempt. - -## Maintenance notes - -- The daemon should be the source of truth for active session ownership after - `open`; CLI state is only a convenience and recovery cache. -- Reviewers should inspect every path that can dispatch platform work and - confirm leased sessions cannot bypass admission. -- Inactivity cleanup should emit diagnostics, not noisy stderr output. diff --git a/plans/004-runner-diagnostics-and-proxy-docs.md b/plans/004-runner-diagnostics-and-proxy-docs.md deleted file mode 100644 index da4d58e01..000000000 --- a/plans/004-runner-diagnostics-and-proxy-docs.md +++ /dev/null @@ -1,225 +0,0 @@ -# Plan 004: Align runner diagnostics and proxy docs - -> **Executor instructions**: Follow this plan step by step. Run every -> verification command and confirm the expected result before moving to the -> next step. If anything in the "STOP conditions" section occurs, stop and -> report; do not improvise. When done, update the status row for this plan in -> `plans/README.md`. -> -> **Drift check (run first)**: -> `git diff --stat 04f53bbc0..HEAD -- src/platforms/ios/runner-lease.ts src/platforms/ios/runner-session.ts src/platforms/ios/__tests__/runner-session.test.ts src/utils/cli-help.ts src/utils/__tests__/args.test.ts README.md website docs` -> -> If any in-scope file changed since this plan was written, compare the -> "Current state" excerpts against the live code before proceeding; on a -> mismatch, treat it as a STOP condition. - -## Status - -- **Priority**: P2 -- **Effort**: M -- **Risk**: MED -- **Depends on**: `plans/001-device-aware-lease-contracts.md`, - `plans/002-automatic-direct-proxy-lease-on-open.md`, - `plans/003-daemon-session-lease-admission-cleanup.md` -- **Category**: dx | docs -- **Planned at**: commit `04f53bbc0`, 2026-06-26 - -## Why this matters - -The iOS runner file lease should remain the low-level guard that prevents two -daemons from controlling one XCTest runner, but it should no longer be the -first user-visible ownership model for local/proxy iOS contention. After Plans -001 through 003, `connect proxy` users should see device lease errors before -runner ownership errors. Limrun/cloud iOS devices route through provider -interactors and are not expected to hit the local iOS runner file lease. - -## Current state - -- `src/platforms/ios/runner-lease.ts` stores global runner ownership under - `~/.agent-device/ios-runner/leases` and reports owner daemon details. -- `src/platforms/ios/runner-session.ts` starts/reuses long-lived XCTest runner - sessions per device. -- `src/utils/cli-help.ts` remote/direct proxy guidance currently tells users - direct proxy mode should not use connect, tenant, run, or lease flags. -- Cloud docs in `/Users/thymikee/Developer/agent-device-cloud/docs/connecting-agent-device.md` - already define the recommended flow as `connect` -> normal commands -> - `disconnect`. The limrun worktree adds `connect limrun` with generated remote - config and `leaseProvider: limrun`. -- `src/commands/management/prepare.ts` help already distinguishes runner - preparation from recovery for "runner already owned by another daemon". -- `docs/adr/0005-ios-runner-interaction-lifecycle.md` requires readiness probes - and stale runner invalidation. Do not remove that behavior. - -## Commands you will need - -| Purpose | Command | Expected on success | -|---------|---------|---------------------| -| Format | `pnpm format` | exit 0 | -| Runner tests | `pnpm exec vitest run src/platforms/ios/__tests__/runner-session.test.ts` | all tests pass | -| Help tests | `pnpm exec vitest run src/utils/__tests__/args.test.ts` | all tests pass | -| Typecheck | `pnpm typecheck` | exit 0, no errors | -| Swift runner build | `pnpm build:xcuitest` | exit 0 if Swift runner files changed | - -## Scope - -**In scope**: -- `src/platforms/ios/runner-lease.ts` -- `src/platforms/ios/runner-session.ts` -- `src/platforms/ios/__tests__/runner-session.test.ts` -- `src/utils/cli-help.ts` -- `src/utils/__tests__/args.test.ts` -- `README.md` -- `website/docs/**` only if direct proxy docs already exist there - -**Out of scope**: -- Replacing the iOS runner file lease. -- Changing XCUITest runner Swift code unless diagnostics require it. -- Adding a new public status command. If needed, create a separate plan. -- Reworking Android platform behavior. - -## Git workflow - -- Branch: `advisor/004-runner-diagnostics-and-proxy-docs` -- Commit message: `docs: clarify direct proxy device leases` -- Do not push or open a PR unless the operator instructed it. - -## Steps - -### Step 1: Attach logical lease context to runner diagnostics - -After Plan 003, runner startup/reuse should be reachable only after device -lease admission. Thread non-secret logical lease context from daemon session or -request metadata into iOS runner session startup diagnostics: - -```ts -{ - leaseId?: string; - clientId?: string; - tenantId?: string; - runId?: string; - leaseProvider?: string; - deviceKey?: string; -} -``` - -Use existing diagnostics helpers. Do not write this context into the global -runner lease file unless the file already has a versioned schema and safe -backward compatibility. If you do persist it, make every field optional and keep -old lease files readable. - -**Verify**: `pnpm exec vitest run src/platforms/ios/__tests__/runner-session.test.ts` -> all tests pass. - -### Step 2: Improve runner busy errors without weakening ownership checks - -In `src/platforms/ios/runner-lease.ts`, keep the existing stale-owner and -same-state reclaim behavior. Update user-facing busy errors so, when logical -lease context is available, the message/hint says: - -- The device is busy because another active device lease owns it, or -- The runner is owned by another daemon/process after lease admission, which is - now a backend inconsistency and should include state dir/PID diagnostics. - -Do not suggest restarting the long-lived proxy as the first recovery step. -Suggested hint: - -"Retry after the owning session closes or after the five-minute inactivity -lease expires. If this persists after expiry, inspect the runner owner details -and clean the stale daemon state on the machine with simulator access." - -Add tests covering: - -- logical lease context appears in diagnostics. -- old runner busy errors still include state-dir/PID details. -- stale same-state reclaim still works. - -**Verify**: `pnpm exec vitest run src/platforms/ios/__tests__/runner-session.test.ts` -> all tests pass. - -### Step 3: Update direct proxy help - -Update `src/utils/cli-help.ts` remote/direct proxy section: - -- Remove guidance that direct proxy users should not use `connect` or leases. -- Explain that agents should run `agent-device connect proxy --daemon-base-url - ` before using a shared proxy. -- Explain that `connect` establishes the connection profile and client identity; - the proxy device lease is acquired lazily on `open`. -- State the inactivity timeout: five minutes with no commands refreshes. -- State that `disconnect` releases the connection lease and local state. State - that `close` releases the session/device lease once Plan 003 behavior exists. -- State that multiple agents can share one proxy if they use normal - `connect proxy`/`open`/command/`disconnect` flow; the daemon isolates - sessions by client. -- State that a busy device error means another agent owns the device until it - closes or the lease expires. -- Present cloud, remote config, proxy, and limrun as connection providers under - the same lifecycle. Do not expose provider internals in normal command - examples. -- Keep the copy compact; this repo treats CLI help as the agent-facing source - of truth. - -Update `src/utils/__tests__/args.test.ts` with assertions for the important -copy, especially "connect proxy", "automatic on open", "five minutes", and -"disconnect releases". - -**Verify**: `pnpm exec vitest run src/utils/__tests__/args.test.ts` -> all tests pass. - -### Step 4: Update README and website docs only where direct proxy is documented - -Search: - -`rg -n "agent-device proxy|direct proxy|remote proxy|lease" README.md website docs` - -For each existing direct proxy section, align it with the CLI help: - -- one long-lived proxy process on the device host; -- agents run `connect proxy` to create a connection profile; -- agents acquire device leases automatically on `open`; -- leases refresh on activity and expire after five minutes; -- `disconnect` releases the connection lease and local state; -- do not restart the proxy to recover normal contention. - -Do not introduce a new long tutorial if no docs section exists; CLI help is the -canonical agent-facing source. - -**Verify**: `rg -n "Do not use connect|Do not use .*lease|restart.*proxy" src/utils/cli-help.ts README.md website docs` -> no stale direct-proxy guidance remains, except unrelated contexts that clearly do not refer to direct proxy recovery. - -## Test plan - -- `runner-session.test.ts` for runner diagnostic behavior. -- `args.test.ts` for CLI help copy. -- `pnpm typecheck` for threading logical lease context through typed runner - session code. -- `pnpm build:xcuitest` only if Swift runner files changed. - -## Done criteria - -- [ ] Runner diagnostics can include logical lease context without exposing - secrets. -- [ ] Runner busy errors still preserve state-dir/PID details. -- [ ] Direct proxy help says to use `connect proxy` and that leases are - automatic on `open`. -- [ ] Help/docs mention five-minute inactivity expiry and `disconnect` release. -- [ ] Stale guidance telling users not to use `connect` or leases with direct - proxy is gone. -- [ ] `pnpm format`, focused tests, and `pnpm typecheck` exit 0. -- [ ] `plans/README.md` status row updated. - -## STOP conditions - -Stop and report if: - -- Improving diagnostics requires changing Swift runner behavior. -- The global runner lease file cannot accept optional fields without breaking - existing installs. In that case, keep logical lease context diagnostics-only. -- Help updates reveal a missing public command that is necessary for recovery; - create a follow-up plan instead of adding the command here. -- Focused tests fail twice after a reasonable fix attempt. - -## Maintenance notes - -- Runner/process lease errors should become rare in direct proxy mode. If users - still see them during normal contention, admission is being bypassed. -- Limrun/cloud iOS sessions should not depend on local runner file-lease - diagnostics; keep those docs provider-neutral. -- Reviewer focus: make sure docs describe the implemented behavior, not the - desired future behavior. diff --git a/plans/README.md b/plans/README.md deleted file mode 100644 index b39e0325a..000000000 --- a/plans/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# Implementation Plans - -Generated by the improve skill on 2026-06-26. Execute in the order below unless -dependencies say otherwise. Each executor: read the plan fully before starting, -honor its STOP conditions, and update your row when done. - -## Execution order and status - -| Plan | Title | Priority | Effort | Depends on | Status | -|------|-------|----------|--------|------------|--------| -| 001 | Add device-aware lease contracts | P1 | M | - | DONE | -| 002 | Add proxy as a connect provider | P1 | L | 001 | DONE | -| 003 | Bind daemon sessions to active device leases | P1 | L | 001, 002 | DONE | -| 004 | Align runner diagnostics and proxy docs | P2 | M | 001, 002, 003 | DONE | - -Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | -REJECTED (with one-line rationale). - -## Dependency notes - -- 001 creates the shared lease vocabulary and contract shape. Do this before - any proxy or session behavior so callers and daemon checks agree on fields. -- 002 makes direct proxy another `connect` provider and teaches the shared - connection runtime to acquire device-aware leases lazily. It depends on 001 - because it must pass `leaseProvider`, `deviceKey`, and `clientId`. -- 003 makes the daemon enforce and refresh the device lease for all commands in - a session. It depends on 002 because sessions need the client-provided lease - metadata produced by `open`. -- 004 improves diagnostics and docs after the behavior is real; doing it first - would document a contract the code cannot yet honor. - -## Findings - -### [CORRECTNESS-01] Direct proxy clients bypass the remote connection model - -- **Evidence**: `src/daemon/lease-registry.ts` stores leases by `tenantId`, - `runId`, and `backend`, but not by selected device. Capacity only counts - active iOS simulator leases. -- **Evidence**: `src/utils/cli-help.ts` direct proxy guidance currently tells - users not to use connect, tenant, run, or lease flags for direct proxy mode, - while `agent-device-cloud/docs/connecting-agent-device.md` already presents - `connect` as the umbrella for cloud/self-hosted bridge sessions. -- **Evidence**: `src/daemon-proxy.ts` authenticates with one shared proxy token - and forwards requests, but does not create a stable client identity or lease. -- **Impact**: Four remote agents can connect to one long-lived proxy and issue - commands against the same device/session without a first-class ownership - boundary. The iOS runner lease then surfaces the failure late as "already - owned by another daemon", which is accurate at the backend layer but wrong as - user-facing multi-agent coordination. -- **Effort**: L. -- **Risk**: HIGH, because this changes the direct proxy command lifecycle and - request admission semantics. -- **Confidence**: HIGH. -- **Fix sketch**: Add provider-aware device lease contracts, make proxy a - `connect` provider that writes normal remote connection state, acquire the - selected device lease lazily on `open` or the first device command, bind - daemon sessions to that lease, refresh on activity, and release or expire - after inactivity. - -### [DX-01] Runner/process lease errors hide the logical proxy owner - -- **Evidence**: `src/platforms/ios/runner-lease.ts` protects one iOS runner per - device using files under `~/.agent-device/ios-runner/leases`, but it only - knows daemon/process state, not the agent/client lease that caused runner use. -- **Impact**: When contention happens, users see daemon state-dir/PID language - instead of "device is leased by client X until timestamp Y". Recovery requires - hidden knowledge and often suggests restarting/cleaning the wrong layer. -- **Effort**: M. -- **Risk**: MED, because diagnostics must not weaken runner mutual exclusion. -- **Confidence**: HIGH. -- **Fix sketch**: Keep the global runner lease as a backend process guard, but - attach logical lease context to runner startup/reuse diagnostics and only let - daemon code reach the runner after device-lease admission succeeds. - -## Findings considered and rejected - -- Replace the proxy with one daemon per agent: rejected because the product goal - is a single long-lived proxy process on the machine with the device attached. -- Solve this only by shortening the existing iOS runner lease TTL: rejected - because it addresses stale helper ownership, not active agents issuing - conflicting commands through the same proxy. -- Keep direct proxy as an implicit daemon-base-url mode with separate local - state: rejected because cloud, remote config, and the limrun worktree already - converge on `connect` plus `RemoteConnectionState` as the user-facing - lifecycle. diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 3de8f1531..e5f69bc1a 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -17,6 +17,7 @@ import { connectionCommand, disconnectCommand, } from '../cli/commands/connection.ts'; +import { writeGeneratedRemoteConfig } from '../cli/generated-remote-config.ts'; import { hasDeferredMetroConfig, materializeRemoteConnectionForCommand, @@ -245,6 +246,30 @@ test('connect proxy writes normal remote state with generated non-secret profile fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('generated remote config writer strips secret fields', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-generated-profile-')); + const configPath = writeGeneratedRemoteConfig({ + stateDir: path.join(tempRoot, '.state'), + provider: 'proxy', + profile: { + daemonBaseUrl: 'http://proxy.example.test/agent-device', + daemonAuthToken: 'proxy-secret', + metroBearerToken: 'metro-bearer-secret', + leaseProvider: 'proxy', + clientId: 'client-a', + }, + }); + + const generated = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record; + assert.equal(generated.daemonBaseUrl, 'http://proxy.example.test/agent-device'); + assert.equal(generated.daemonAuthToken, undefined); + assert.equal(generated.metroBearerToken, undefined); + assert.equal(generated.leaseProvider, 'proxy'); + assert.equal(JSON.stringify(generated).includes('proxy-secret'), false); + assert.equal(JSON.stringify(generated).includes('metro-bearer-secret'), false); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('connect proxy rejects remote-config and unknown provider combinations', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-errors-')); const stateDir = path.join(tempRoot, '.state'); diff --git a/src/cli/generated-remote-config.ts b/src/cli/generated-remote-config.ts index 4ca5f990a..b758645f7 100644 --- a/src/cli/generated-remote-config.ts +++ b/src/cli/generated-remote-config.ts @@ -6,12 +6,14 @@ import type { RemoteConfigProfile, ResolvedRemoteConfigProfile } from '../remote import { AppError, asAppError } from '../utils/errors.ts'; import type { EnvMap } from '../utils/env-map.ts'; +const GENERATED_REMOTE_CONFIG_SECRET_KEYS = new Set(['daemonAuthToken', 'metroBearerToken']); + export function writeGeneratedRemoteConfig(options: { stateDir: string; provider: string; profile: RemoteConfigProfile; }): string { - const normalized = normalizeJson(options.profile); + const normalized = normalizeJson(stripGeneratedProfileSecrets(options.profile)); const configDir = path.join(options.stateDir, 'remote-connections', 'generated'); fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); const configPath = path.join( @@ -50,6 +52,12 @@ export function resolveGeneratedRemoteConfigProfile(options: { } } +function stripGeneratedProfileSecrets(profile: RemoteConfigProfile): RemoteConfigProfile { + return Object.fromEntries( + Object.entries(profile).filter(([key]) => !GENERATED_REMOTE_CONFIG_SECRET_KEYS.has(key)), + ) as RemoteConfigProfile; +} + function profileHash(value: unknown): string { return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16); } diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index 6156e0263..da0ece1b8 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -26,7 +26,7 @@ afterAll(() => { fs.rmSync(TEST_ROOT, { recursive: true, force: true }); }); -test('createRequestExecutionScope applies tenant scoping and lease admission', async () => { +test('createRequestExecutionScope applies tenant scoping and locked lease admission', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ @@ -55,6 +55,10 @@ test('createRequestExecutionScope applies tenant scoping and lease admission', a expect(scope.req.session).toBe('tenant-a:default'); expect(scope.req.meta?.tenantId).toBe('tenant-a'); expect(scope.sessionName).toBe('tenant-a:default'); + const admittedLeaseId = await scope.runLocked( + async () => scope.req.internal?.admittedLease?.leaseId, + ); + expect(admittedLeaseId).toBe(lease.leaseId); }); test('createRequestExecutionScope resolves session-scoped request and runner log paths', async () => { @@ -103,23 +107,23 @@ test('request diagnostics flush into the effective session request log', async ( expect(fs.readFileSync(result.expectedPath, 'utf8')).toContain('"phase":"request_start"'); }); -test('createRequestExecutionScope rejects tenant requests without an active lease', async () => { - await expect( - createRequestExecutionScope({ - req: makeRequest({ - session: 'default', - command: 'snapshot', - meta: { - tenantId: 'tenant-a', - runId: 'run-1', - leaseId: '0'.repeat(32), - sessionIsolation: 'tenant', - }, - }), - sessionStore: makeSessionStore('agent-device-request-scope-'), - leaseRegistry: new LeaseRegistry(), +test('runLocked rejects tenant requests without an active lease', async () => { + const scope = await createRequestExecutionScope({ + req: makeRequest({ + session: 'default', + command: 'snapshot', + meta: { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: '0'.repeat(32), + sessionIsolation: 'tenant', + }, }), - ).rejects.toThrow(/Lease is not active/); + sessionStore: makeSessionStore('agent-device-request-scope-'), + leaseRegistry: new LeaseRegistry(), + }); + + await expect(scope.runLocked(async () => 'ran')).rejects.toThrow(/Lease is not active/); }); test('leased session admission uses stored lease metadata and heartbeats', async () => { @@ -156,6 +160,8 @@ test('leased session admission uses stored lease metadata and heartbeats', async leaseRegistry, }); + await scope.runLocked(async () => 'ran'); + expect(scope.sessionName).toBe('default'); const activeLease = leaseRegistry.listActiveLeases()[0]; expect(activeLease?.heartbeatAt).toBe(2_000); @@ -163,6 +169,71 @@ test('leased session admission uses stored lease metadata and heartbeats', async expect(sessionStore.get('default')?.lease?.expiresAt).toBe(302_000); }); +test('leased session heartbeat is serialized with the request execution lock', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ now: () => now }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + expiresAt: lease.expiresAt, + }, + }), + ); + + const first = await createRequestExecutionScope({ + req: makeRequest({ command: 'click' }), + sessionStore, + leaseRegistry, + }); + const second = await createRequestExecutionScope({ + req: makeRequest({ command: 'click' }), + sessionStore, + leaseRegistry, + }); + + let releaseFirst: () => void = () => {}; + let firstEntered: () => void = () => {}; + const firstEnteredPromise = new Promise((resolve) => { + firstEntered = resolve; + }); + now = 2_000; + const firstRun = first.runLocked( + async () => + await new Promise((release) => { + releaseFirst = release; + firstEntered(); + }), + ); + await firstEnteredPromise; + expect(leaseRegistry.listActiveLeases()[0]?.heartbeatAt).toBe(2_000); + + now = 3_000; + const secondRun = second.runLocked(async () => 'second'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(leaseRegistry.listActiveLeases()[0]?.heartbeatAt).toBe(2_000); + + releaseFirst(); + await firstRun; + await expect(secondRun).resolves.toBe('second'); + expect(leaseRegistry.listActiveLeases()[0]?.heartbeatAt).toBe(3_000); +}); + test('leased session rejects mismatched lease id before dispatch', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); @@ -179,13 +250,15 @@ test('leased session rejects mismatched lease id before dispatch', async () => { }), ); - await expect( - createRequestExecutionScope({ - req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) } }), - sessionStore, - leaseRegistry, - }), - ).rejects.toThrow(/Lease does not match session owner \(leaseId\)/); + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) } }), + sessionStore, + leaseRegistry, + }); + + await expect(scope.runLocked(async () => 'ran')).rejects.toThrow( + /Lease does not match session owner \(leaseId\)/, + ); }); test.each([ @@ -211,13 +284,15 @@ test.each([ }), ); - await expect( - createRequestExecutionScope({ - req: makeRequest({ command: 'snapshot', meta }), - sessionStore, - leaseRegistry, - }), - ).rejects.toThrow(/Lease does not match session owner/); + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot', meta }), + sessionStore, + leaseRegistry, + }); + + await expect(scope.runLocked(async () => 'ran')).rejects.toThrow( + /Lease does not match session owner/, + ); }); test('local unleased session admission still succeeds', async () => { @@ -308,23 +383,22 @@ test('tenant lease rejection flushes diagnostics into the effective session requ let flushedPath: string | null = null; await withDiagnosticsScope({ command: 'snapshot', requestId, logPath: LOG_PATH }, async () => { - await expect( - createRequestExecutionScope({ - req: makeRequest({ - session: 'default', - command: 'snapshot', - meta: { - tenantId: 'tenant-a', - runId: 'run-1', - leaseId: '0'.repeat(32), - sessionIsolation: 'tenant', - requestId, - }, - }), - sessionStore, - leaseRegistry: new LeaseRegistry(), + const scope = await createRequestExecutionScope({ + req: makeRequest({ + session: 'default', + command: 'snapshot', + meta: { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: '0'.repeat(32), + sessionIsolation: 'tenant', + requestId, + }, }), - ).rejects.toThrow(/Lease is not active/); + sessionStore, + leaseRegistry: new LeaseRegistry(), + }); + await expect(scope.runLocked(async () => 'ran')).rejects.toThrow(/Lease is not active/); flushedPath = flushDiagnosticsToSessionFile({ force: true }); }); diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 511a54986..7bf6e4f15 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -94,6 +94,11 @@ export function assertRequestLeaseAdmission( }); } +export function assertRequestLeaseAdmissionPreflight(req: DaemonRequest): void { + if (isLeaseAdmissionExempt(req.command)) return; + assertProxyOpenLeaseMetadata(req, resolveLeaseScope(req)); +} + function assertProxyOpenLeaseMetadata( req: DaemonRequest, requestLeaseScope: ReturnType, diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 96c0433f3..27c6c4fa0 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -10,7 +10,11 @@ import type { DaemonCommandContext } from './context.ts'; import { contextFromFlags as contextFromFlagsWithLog } from './context.ts'; import { assertSessionSelectorMatches } from './session-selector.ts'; import { resolveEffectiveSessionName } from './session-routing.ts'; -import { assertRequestLeaseAdmission, scopeRequestSession } from './request-admission.ts'; +import { + assertRequestLeaseAdmission, + assertRequestLeaseAdmissionPreflight, + scopeRequestSession, +} from './request-admission.ts'; import { prepareLockedRequestBinding, resolveRequestExecutionLockKeys, @@ -80,7 +84,6 @@ export async function createRequestExecutionScope(params: { const command = scopedReq.command; const sessionName = resolveEffectiveSessionName(scopedReq, sessionStore); - const existingSession = sessionStore.get(sessionName); const diagnosticsMeta = getDiagnosticsMeta(); const sessionDir = sessionStore.resolveSessionDir(sessionName); const requestLogPath = resolveSessionRequestLogPath( @@ -105,26 +108,7 @@ export async function createRequestExecutionScope(params: { runnerLogPath, }, }); - const activeLease = assertRequestLeaseAdmission(scopedReq, leaseRegistry, existingSession); - if (activeLease) { - scopedReq = { - ...scopedReq, - internal: { - ...scopedReq.internal, - admittedLease: activeLease, - }, - }; - } - if (activeLease && existingSession?.lease) { - sessionStore.set(sessionName, { - ...existingSession, - lease: { - ...existingSession.lease, - leaseBackend: activeLease.backend, - expiresAt: activeLease.expiresAt, - }, - }); - } + assertRequestLeaseAdmissionPreflight(scopedReq); const executionLockKeys = shouldLockSessionExecution(command) ? await resolveRequestExecutionLockKeys({ req: scopedReq, sessionName, sessionStore }) : []; @@ -139,16 +123,55 @@ export async function createRequestExecutionScope(params: { throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), runLocked: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); - if (executionLockKeys.length === 0) return await task(); - return await withRequestExecutionLocks(executionLocks, executionLockKeys, async () => { + const runAdmitted = async () => { throwIfRequestCanceled(scopedReq.meta?.requestId); + scopedReq = admitRequestLeaseForLockedScope({ + req: scopedReq, + sessionName, + sessionStore, + leaseRegistry, + }); + scope.req = scopedReq; return await task(); - }); + }; + if (executionLockKeys.length === 0) return await runAdmitted(); + return await withRequestExecutionLocks(executionLocks, executionLockKeys, runAdmitted); }, }; return scope; } +function admitRequestLeaseForLockedScope(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; +}): DaemonRequest { + const { sessionName, sessionStore, leaseRegistry } = params; + const existingSession = sessionStore.get(sessionName); + const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession); + if (!activeLease) return params.req; + + const nextReq = { + ...params.req, + internal: { + ...params.req.internal, + admittedLease: activeLease, + }, + }; + if (existingSession?.lease) { + sessionStore.set(sessionName, { + ...existingSession, + lease: { + ...existingSession.lease, + leaseBackend: activeLease.backend, + expiresAt: activeLease.expiresAt, + }, + }); + } + return nextReq; +} + async function cleanupExpiredLeasedSessions(params: { sessionStore: SessionStore; leaseRegistry: LeaseRegistry; From 745ad9d3d10e2ced8c9eb8f6ca27b1e1897899d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 14:08:00 +0200 Subject: [PATCH 05/16] docs: simplify remote lease guidance --- CONTEXT.md | 2 -- docs/adr/0007-remote-device-leases.md | 29 ++++++++++++--------------- src/utils/__tests__/args.test.ts | 11 +++++----- src/utils/cli-help.ts | 21 +++++++++---------- website/docs/docs/remote-proxy.md | 6 +++--- 5 files changed, 30 insertions(+), 39 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2a5464dd6..da6bf0bc6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -22,8 +22,6 @@ such as a simulator UDID, physical device id, or provider inventory id. - Lease provider: remote connection source that routes and owns a device lease, such as `proxy`, cloud bridge, or `limrun`. -- Direct proxy client id: optional remote proxy client identity used to bind - lease activity to the agent that acquired the selected device. - Runner/process lease: backend helper mutual-exclusion guard for platform runners or tools; it is not the remote client ownership boundary. - Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints. diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 4d4976b23..6864093c9 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -6,10 +6,9 @@ Accepted ## Context -Remote daemon users need a clear ownership boundary before a command reaches a -platform runner or helper. The existing lease model can bind a tenant/run to a -backend, but direct proxy and hosted providers also need to identify the selected -device and the connection provider that owns it. +Remote daemon users need a clear ownership boundary before commands reach a +platform runner or helper. Shared proxy and hosted providers need ownership to +include the selected device and connection provider, not only tenant/run. Runner and helper processes already have backend-specific mutual exclusion. That guard protects platform tooling, not remote client ownership, so surfacing those @@ -17,12 +16,11 @@ errors directly makes device contention harder to recover from. ## Decision -A remote device lease is logical ownership of one selected device by one remote -agent/client for a connection provider such as `proxy`, a cloud bridge, or -`limrun`. +A remote device lease is logical ownership of one selected device by one +remote client for a connection provider such as `proxy`, cloud, or `limrun`. -`connect` establishes a connection profile and client identity. Lease allocation -remains lazy and happens only when a device, backend, and provider are known. +`connect` establishes connection profile and client identity. Lease allocation +is lazy and happens when a device, backend, and provider are known. A runner/process lease is a backend helper guard and is not a user/client ownership boundary. It stays below daemon device leases and should not be @@ -36,10 +34,10 @@ Lease admission, heartbeat, stored session lease refresh, and request execution must run under the same daemon request lock. Scope resolution may happen before the lock, but lease ownership mutation must not. -Generated connection profiles are non-secret. They may persist stable routing, -device, lease provider, device key, and client identity metadata, but must strip -daemon and Metro bearer tokens. Tokens are supplied in-memory for the current -command or through the existing environment/CLI token paths. +Generated connection profiles are non-secret. They may persist routing and +lease metadata, but must strip daemon and Metro bearer tokens. Tokens are +supplied in-memory for the current command or through environment/CLI token +paths. The proxy process is expected to be long-lived and self-serve. Recovery from a stale or expired device lease should not require restarting the proxy. @@ -50,6 +48,5 @@ Device contention can fail before platform execution with an explicit device-lease error that includes the backend, provider, selected device key, and owning lease expiry. -Backend-only leases remain valid for older remote clients. Device and provider -fields are optional until provider-aware `open` acquisition and admission -refreshes are implemented. +Backend-only leases remain valid for older remote clients, while provider-aware +clients get device-level contention and clearer recovery. diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 36a1b39bc..b89d30b8e 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1247,9 +1247,8 @@ test('usage includes agent workflows, config, environment, and examples footers' /Remote lifecycle: use connect, then open, commands, close, and disconnect/, ); assert.match(usageText, /connect proxy --daemon-base-url /); - assert.match(usageText, /proxy device lease is automatic on open/); - assert.match(usageText, /expires after five minutes of inactivity/); - assert.match(usageText, /disconnect releases local connection state/); + assert.match(usageText, /Device leases are automatic on open/); + assert.match(usageText, /expire after five minutes of inactivity/); assert.match(usageText, /app-owned back uses back/); assert.match(usageText, /Web browser sessions: read help web/); assert.match( @@ -1603,10 +1602,10 @@ test('usageForCommand resolves remote help topic', () => { assert.match(help, /agent-device open Maps --platform ios/); assert.match(help, /agent-device snapshot -i --platform ios/); assert.match(help, /agent-device close/); - assert.match(help, /lease is acquired lazily on open/); - assert.match(help, /expires after five minutes without commands/); + assert.match(help, /Device leases are acquired on open/); + assert.match(help, /expire after five minutes without commands/); assert.match(help, /Multiple agents can share one proxy/); - assert.match(help, /disconnect releases the connection lease and local state/); + assert.match(help, /disconnect releases local connection state/); assert.match(help, /A busy direct-proxy device error means another agent owns the device/); assert.match(help, /local\/proxy iOS reports that the runner is already owned/); assert.match(help, /same --remote-config to every operational command/); diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 52619fe18..52d72fd2b 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -80,9 +80,8 @@ const AGENT_QUICKSTART_LINES = [ 'Raw coordinates are fallback-only: use snapshot -i --json rects when iOS refs no-op or child refs are missing, then verify the action with diff snapshot -i or snapshot --diff.', 'Sparse or AX-unavailable snapshot: use screenshot for visual truth, press the visible coordinate to leave the bad screen, then retry AX with snapshot -i.', 'macOS context menus use click --button secondary, then snapshot -i. Longpress is for mobile hold gestures, not macOS secondary-click menus.', - 'Remote lifecycle: use connect, then open, commands, close, and disconnect. Cloud, remote-config, direct proxy, and limrun are connection providers under the same flow.', - 'Direct proxy: run agent-device connect proxy --daemon-base-url before using a shared Mac proxy. The proxy device lease is automatic on open, refreshes on commands, expires after five minutes of inactivity, and disconnect releases local connection state.', - 'Busy direct-proxy device: another agent owns the local/proxy iOS device until it closes or the five-minute inactivity lease expires.', + 'Remote lifecycle: use connect, then open, commands, close, and disconnect. Cloud, remote-config, direct proxy, and limrun use the same flow.', + 'Direct proxy: run agent-device connect proxy --daemon-base-url before using a shared Mac proxy. Device leases are automatic on open and expire after five minutes of inactivity.', 'Batch JSON steps use "command" and structured "input"; legacy "positionals"/"flags" steps still run in CLI but are deprecated until the next major version.', 'Navigation: app-owned back uses back; system back uses back --system.', 'Web browser sessions: read help web; first slice is web setup if needed -> web doctor -> open --platform web -> snapshot -i -> click/fill/get/is/find/wait/screenshot -> close.', @@ -268,10 +267,9 @@ Validation and evidence: Android animations: settings animations off/on, not animations disable/restore. Debug logs: logs clear --restart, logs mark, reproduce, then logs path; do not split clear/restart into separate stop/start commands. Network headers: network dump --include headers; do not write network log headers. - Remote lifecycle: cloud, remote-config, direct proxy, and limrun are connection providers under the same flow: connect, open, commands, close, disconnect. + Remote lifecycle: cloud, remote-config, direct proxy, and limrun use the same flow: connect, open, commands, close, disconnect. Remote config profile: agent-device connect --remote-config ./remote-config.json; then run normal commands and disconnect. - Direct proxy to a Mac you control: cloud/Linux clients can use local/proxy iOS devices through the proxied Mac. Run agent-device connect proxy --daemon-base-url first; connect stores the profile and client identity. The proxy device lease is automatic on open, refreshes on commands, expires after five minutes of inactivity, and disconnect releases local connection state. close releases the session/device lease where supported. - Busy direct-proxy device: another agent owns the device until it closes or the five-minute inactivity lease expires. Use lease expiry or close for normal contention. + Direct proxy to a Mac you control: cloud/Linux clients can use local/proxy iOS devices through the proxied Mac. Run agent-device connect proxy --daemon-base-url first. Device leases are automatic on open and expire after five minutes of inactivity. Web: agent-device uses a managed, pinned agent-browser backend as an implementation detail. Use --platform web when a browser step belongs inside an agent-device session, replay, batch, MCP, or typed-client flow; use agent-browser directly for standalone web automation. Run agent-device web setup before first use, then agent-device web doctor for backend health checks. Web automation requires Node 24+. agent-device web setup agent-device web doctor @@ -656,7 +654,6 @@ Providers: Cloud: agent-device connect discovers the cloud profile. Remote config: agent-device connect --remote-config ./remote-config.json uses a local profile. Direct proxy: agent-device connect proxy --daemon-base-url stores the shared proxy profile and client identity. - Limrun: agent-device connect limrun uses the generated limrun profile when available. Direct proxy flow for a remote Mac/simulator: On the Mac with simulator/device access: @@ -692,12 +689,12 @@ Rules: Use connect without --remote-config when the cloud control plane owns the connection profile. Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. Use agent-device proxy for direct tunnel access to a Mac you control. Copy the printed daemon base URL and daemon auth token, then run agent-device connect proxy --daemon-base-url before normal commands. - connect proxy establishes the connection profile and client identity. The proxy device lease is acquired lazily on open, refreshes on command activity, and expires after five minutes without commands. - Multiple agents can share one proxy when each uses the normal connect proxy/open/command/disconnect flow; the daemon isolates sessions by client. - disconnect releases the connection lease and local state. close releases the session/device lease where supported. - A busy direct-proxy device error means another agent owns the device until it closes or the five-minute inactivity lease expires. + connect proxy stores the connection profile and client identity. Device leases are acquired on open and expire after five minutes without commands. + Multiple agents can share one proxy when each uses connect proxy, open, commands, close, and disconnect. + disconnect releases local connection state; close releases the active session and device lease. + A busy direct-proxy device error means another agent owns the device until it closes or its inactivity lease expires. Keep the proxy token secret. Anyone with the token can control the proxied daemon. - If local/proxy iOS reports that the runner is already owned by another agent-device daemon after lease admission, do not run prepare ios-runner from the remote client. Retry after the owning session closes or after the five-minute inactivity lease expires; if the conflict repeats after expiry, inspect the runner owner details and clean stale daemon state on the machine with simulator access. + If local/proxy iOS reports that the runner is already owned by another agent-device daemon after lease admission, retry after the owning session closes or after lease expiry. If the conflict repeats, clean stale daemon state on the machine with simulator access. Do not use --config as a remote profile flag. --config loads CLI defaults; --remote-config selects remote daemon/profile settings. For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required. For remote artifact installs, use install-from-source or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index f4aa8e580..6537c9d47 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -44,9 +44,9 @@ agent-device close agent-device disconnect ``` -`connect proxy` stores the proxy profile and client identity. The proxy device lease is acquired automatically on `open`, refreshes on command activity, and expires after five minutes without commands. `disconnect` releases the connection lease and local state; `close` releases the session/device lease where supported. +`connect proxy` stores the proxy profile and client identity. Device leases are automatic on `open` and expire after five minutes without commands. `close` releases the active session and device lease; `disconnect` clears local connection state. -Multiple agents can share one proxy when each uses the normal `connect proxy`, `open`, command, `close`, and `disconnect` flow. A busy device error means another agent owns the device until it closes or the five-minute inactivity lease expires. +Multiple agents can share one proxy when each uses the normal `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent owns the device until it closes or its inactivity lease expires. Do not commit a config file that contains a live `daemonAuthToken`. @@ -62,4 +62,4 @@ Remote clients read `/health` before issuing commands and compare the daemon RPC ## Cleanup -Run `agent-device disconnect` when the remote session is done. Stop the tunnel and the `agent-device proxy` process only when the host should stop accepting remote clients. Restarting the proxy generates a fresh token unless you supplied `--daemon-auth-token` explicitly; use lease expiry or `close` for normal device contention. +Run `agent-device disconnect` when the remote session is done. Stop the tunnel and the `agent-device proxy` process only when the host should stop accepting remote clients. Restarting the proxy generates a fresh token unless you supplied `--daemon-auth-token` explicitly. From 9686695dbd5e05bd93ccc979423b2e5158186ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 14:14:31 +0200 Subject: [PATCH 06/16] refactor: satisfy leasing fallow checks --- src/__tests__/remote-connection.test.ts | 1 + src/cli/commands/connection-runtime.ts | 1 + src/cli/commands/connection.ts | 1 + src/client-types.ts | 16 +--- src/commands/management/viewport.ts | 10 +-- src/core/dispatch.ts | 9 +- src/core/viewport-dimension.ts | 12 +++ src/daemon-client-rpc.ts | 1 + .../__tests__/request-router-open.test.ts | 1 + src/daemon/handlers/session-open.ts | 1 + src/daemon/lease-context.ts | 4 +- src/daemon/lease-registry.ts | 83 ++++++++++--------- src/remote-config-schema.ts | 22 +++-- src/remote-connection-state.ts | 1 + 14 files changed, 83 insertions(+), 80 deletions(-) create mode 100644 src/core/viewport-dimension.ts diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index e5f69bc1a..6661ca3a6 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -526,6 +526,7 @@ test('deferred materialization allocates lease and prepares Metro for open', asy fs.rmSync(tempRoot, { recursive: true, force: true }); }); +// fallow-ignore-next-line complexity test('proxy open resolves device key before allocating lease', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-open-')); const stateDir = path.join(tempRoot, '.state'); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 42f0a1a09..0fc1c1090 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -33,6 +33,7 @@ const leaseDeferredCommands = new Set([ const runtimeDeferredCommands = new Set(['open']); export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; +// fallow-ignore-next-line complexity export async function materializeRemoteConnectionForCommand(options: { command: string; flags: CliFlags; diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 09a094081..2808cd3c5 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -27,6 +27,7 @@ import type { LeaseBackend } from '../../contracts.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import type { ClientCommandHandler } from './router-types.ts'; +// fallow-ignore-next-line complexity export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; const provider = readConnectProvider(positionals); diff --git a/src/client-types.ts b/src/client-types.ts index a14cfca43..9904ea3b5 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -6,8 +6,6 @@ import type { DaemonLockPolicy, DaemonRequest, DaemonResponse, - DaemonServerMode, - DaemonTransportPreference, LeaseBackend, NetworkIncludeMode, SessionIsolationMode, @@ -45,6 +43,7 @@ export type { TargetShutdownResult } from './target-shutdown-contract.ts'; import type { PerfAction, PerfArea, PerfKind, PerfSubject } from './contracts/perf.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; import type { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/debug-symbols.ts'; +import type { RemoteConnectionProfileFields } from './remote-config-schema.ts'; export type { FindLocator } from './utils/finders.ts'; export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts'; @@ -56,24 +55,13 @@ export type AgentDeviceDaemonTransport = ( req: Omit, ) => Promise; -export type AgentDeviceClientConfig = { +export type AgentDeviceClientConfig = RemoteConnectionProfileFields & { session?: string; lockPolicy?: DaemonLockPolicy; lockPlatform?: PlatformSelector; requestId?: string; - stateDir?: string; - daemonBaseUrl?: string; - daemonAuthToken?: string; - daemonTransport?: DaemonTransportPreference; - daemonServerMode?: DaemonServerMode; - tenant?: string; sessionIsolation?: SessionIsolationMode; - runId?: string; - leaseId?: string; leaseBackend?: LeaseBackend; - leaseProvider?: string; - deviceKey?: string; - clientId?: string; leaseTtlMs?: number; runtime?: SessionRuntimeHints; cwd?: string; diff --git a/src/commands/management/viewport.ts b/src/commands/management/viewport.ts index b121ca035..71c0fa080 100644 --- a/src/commands/management/viewport.ts +++ b/src/commands/management/viewport.ts @@ -1,7 +1,7 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { ViewportCommandOptions } from '../../client-types.ts'; +import { readViewportDimension } from '../../core/viewport-dimension.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { AppError } from '../../utils/errors.ts'; import { integerField, requiredField } from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { commonInputFromFlags, direct } from '../cli-grammar/common.ts'; @@ -51,11 +51,3 @@ export const viewportCommandFacet = defineCommandFacet({ daemonWriter: viewportDaemonWriter, cliOutputFormatter: managementCliOutputFormatters.viewport, }); - -function readViewportDimension(value: string | undefined, label: 'width' | 'height'): number { - const parsed = value === undefined ? NaN : Number(value); - if (!Number.isInteger(parsed) || parsed < 1) { - throw new AppError('INVALID_ARGS', `viewport ${label} must be a positive integer`); - } - return parsed; -} diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 8072baa62..88b80c060 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -34,6 +34,7 @@ import { } from './dispatch-interactions.ts'; import { readNotificationPayload } from './dispatch-payload.ts'; import { parseDeviceRotation } from './device-rotation.ts'; +import { readViewportDimension } from './viewport-dimension.ts'; export { resolveTargetDevice } from './dispatch-resolve.ts'; export type { CommandFlags, DispatchContext } from './dispatch-context.ts'; @@ -328,14 +329,6 @@ async function handleClipboardCommand( }; } -function readViewportDimension(value: string | undefined, label: 'width' | 'height'): number { - const parsed = value === undefined ? NaN : Number(value); - if (!Number.isInteger(parsed) || parsed < 1) { - throw new AppError('INVALID_ARGS', `viewport ${label} must be a positive integer`); - } - return parsed; -} - async function handleKeyboardCommand( device: DeviceInfo, positionals: string[], diff --git a/src/core/viewport-dimension.ts b/src/core/viewport-dimension.ts new file mode 100644 index 000000000..46879f868 --- /dev/null +++ b/src/core/viewport-dimension.ts @@ -0,0 +1,12 @@ +import { AppError } from '../utils/errors.ts'; + +export function readViewportDimension( + value: string | undefined, + label: 'width' | 'height', +): number { + const parsed = value === undefined ? NaN : Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new AppError('INVALID_ARGS', `viewport ${label} must be a positive integer`); + } + return parsed; +} diff --git a/src/daemon-client-rpc.ts b/src/daemon-client-rpc.ts index 53f97ad90..7cbaf3919 100644 --- a/src/daemon-client-rpc.ts +++ b/src/daemon-client-rpc.ts @@ -133,6 +133,7 @@ function leaseRpcMethodForCommand(command: LeaseRpcCommand): string { } } +// fallow-ignore-next-line complexity function buildLeaseRpcParams( req: DaemonRequest, command: LeaseRpcCommand, diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 3e770307a..f34335a97 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -66,6 +66,7 @@ beforeEach(() => { mockEnsureDeviceReady.mockResolvedValue(undefined); }); +// fallow-ignore-next-line complexity test('open returns and creates the session state directory', async () => { const sessionStore = makeSessionStore('agent-device-router-open-'); const device = makeIosDevice('SIM-STATE'); diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 664e7da2d..f389af250 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -324,6 +324,7 @@ async function completeOpenCommand(params: { return { ok: true, data: openResult }; } +// fallow-ignore-next-line complexity async function prepareOpenDispatchSession(params: { req: DaemonRequest; sessionName: string; diff --git a/src/daemon/lease-context.ts b/src/daemon/lease-context.ts index 65b80cfd3..2be167588 100644 --- a/src/daemon/lease-context.ts +++ b/src/daemon/lease-context.ts @@ -3,9 +3,9 @@ import type { LeaseBackend } from '../contracts.ts'; import type { DeviceLease } from './lease-registry.ts'; import type { RunnerLogicalLeaseContext } from '../core/runner-lease-context.ts'; -export const PROXY_LEASE_PROVIDER = 'proxy'; +const PROXY_LEASE_PROVIDER = 'proxy'; export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; -export const REQUIRED_PROXY_LEASE_FIELDS = [ +const REQUIRED_PROXY_LEASE_FIELDS = [ 'leaseId', 'tenantId', 'runId', diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 141b82b49..88c24a8e3 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -72,6 +72,16 @@ export type AdmissionRequest = { clientId?: string; }; +type LeaseScopeMatchRequest = { + tenantId?: string; + runId?: string; + backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + const DEFAULT_LEASE_TTL_MS = 60_000; const MIN_LEASE_TTL_MS = 5_000; const MAX_LEASE_TTL_MS = 10 * 60_000; @@ -219,49 +229,22 @@ export class LeaseRegistry { } heartbeatLease(request: HeartbeatLeaseRequest): DeviceLease { - const leaseId = normalizeLeaseId(request.leaseId); - if (!leaseId) { - throw new AppError('INVALID_ARGS', 'Invalid lease id.'); - } + const leaseId = this.normalizeRequiredLeaseId(request.leaseId); this.cleanupExpiredLeases(); - const lease = this.leases.get(leaseId); - if (!lease) { - throw new AppError('UNAUTHORIZED', 'Lease is not active', { - reason: 'LEASE_NOT_FOUND', - }); - } - this.assertOptionalScopeMatch(lease, { - tenantId: request.tenantId, - runId: request.runId, - backend: request.backend, - provider: request.provider, - leaseProvider: request.leaseProvider, - deviceKey: request.deviceKey, - clientId: request.clientId, - }); + const lease = this.getActiveLease(leaseId); + this.assertOptionalScopeMatch(lease, this.scopeMatchRequest(request)); const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); return this.refreshLease(lease, leaseTtlMs); } releaseLease(request: ReleaseLeaseRequest): { released: boolean } { - const leaseId = normalizeLeaseId(request.leaseId); - if (!leaseId) { - throw new AppError('INVALID_ARGS', 'Invalid lease id.'); - } + const leaseId = this.normalizeRequiredLeaseId(request.leaseId); this.cleanupExpiredLeases(); const lease = this.leases.get(leaseId); if (!lease) { return { released: false }; } - this.assertOptionalScopeMatch(lease, { - tenantId: request.tenantId, - runId: request.runId, - backend: request.backend, - provider: request.provider, - leaseProvider: request.leaseProvider, - deviceKey: request.deviceKey, - clientId: request.clientId, - }); + this.assertOptionalScopeMatch(lease, this.scopeMatchRequest(request)); this.leases.delete(leaseId); this.unbindLease(lease); return { released: true }; @@ -282,12 +265,7 @@ export class LeaseRegistry { throw new AppError('INVALID_ARGS', 'tenant isolation requires lease id.'); } this.cleanupExpiredLeases(); - const lease = this.leases.get(leaseId); - if (!lease) { - throw new AppError('UNAUTHORIZED', 'Lease is not active', { - reason: 'LEASE_NOT_FOUND', - }); - } + const lease = this.getActiveLease(leaseId); this.assertOptionalScopeMatch(lease, { tenantId, runId, @@ -348,6 +326,34 @@ export class LeaseRegistry { return value; } + private normalizeRequiredLeaseId(raw: string | undefined): string { + const leaseId = normalizeLeaseId(raw); + if (!leaseId) { + throw new AppError('INVALID_ARGS', 'Invalid lease id.'); + } + return leaseId; + } + + private getActiveLease(leaseId: string): DeviceLease { + const lease = this.leases.get(leaseId); + if (lease) return lease; + throw new AppError('UNAUTHORIZED', 'Lease is not active', { + reason: 'LEASE_NOT_FOUND', + }); + } + + private scopeMatchRequest(request: LeaseScopeMatchRequest): LeaseScopeMatchRequest { + return { + tenantId: request.tenantId, + runId: request.runId, + backend: request.backend, + provider: request.provider, + leaseProvider: request.leaseProvider, + deviceKey: request.deviceKey, + clientId: request.clientId, + }; + } + private refreshLease(lease: DeviceLease, ttlMs: number): DeviceLease { const now = this.now(); const updated: DeviceLease = { @@ -451,6 +457,7 @@ export class LeaseRegistry { }); } + // fallow-ignore-next-line complexity private assertOptionalScopeMatch( lease: DeviceLease, request: { diff --git a/src/remote-config-schema.ts b/src/remote-config-schema.ts index e86d975cb..365b42f0b 100644 --- a/src/remote-config-schema.ts +++ b/src/remote-config-schema.ts @@ -24,7 +24,7 @@ export type RemoteConfigMetroOptions = { metroNoInstallDeps?: boolean; }; -export type RemoteConfigProfile = RemoteConfigMetroOptions & { +export type RemoteConnectionProfileFields = { stateDir?: string; daemonBaseUrl?: string; daemonAuthToken?: string; @@ -38,16 +38,20 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & { leaseProvider?: string; deviceKey?: string; clientId?: string; - platform?: PlatformSelector; - target?: DeviceTarget; - device?: string; - udid?: string; - serial?: string; - iosSimulatorDeviceSet?: string; - androidDeviceAllowlist?: string; - session?: string; }; +export type RemoteConfigProfile = RemoteConfigMetroOptions & + RemoteConnectionProfileFields & { + platform?: PlatformSelector; + target?: DeviceTarget; + device?: string; + udid?: string; + serial?: string; + iosSimulatorDeviceSet?: string; + androidDeviceAllowlist?: string; + session?: string; + }; + export type RemoteConfigProfileOptions = { configPath: string; cwd: string; diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index f4a112e87..347b359b8 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -289,6 +289,7 @@ function stripUndefined>(record: T): T { return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; } +// fallow-ignore-next-line complexity function isRemoteConnectionState(value: unknown): value is RemoteConnectionState { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const record = value as Record; From 122c15b66e8df6aea82fc37d97a84dc133a02203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:04:58 +0200 Subject: [PATCH 07/16] fix: harden integrated device leasing --- src/__tests__/remote-connection.test.ts | 58 +++++++++ src/cli/cloud-connection-profile.ts | 23 +--- src/cli/commands/connection-runtime.ts | 35 ++++-- src/cli/commands/connection.ts | 8 ++ src/cli/generated-remote-config.ts | 40 +++++- src/cli/proxy-connection-profile.ts | 33 ++--- src/daemon/__tests__/lease-registry.test.ts | 119 +++++++++++++++--- .../__tests__/request-execution-scope.test.ts | 78 +++++++++++- .../handlers/__tests__/session-replay.test.ts | 2 + src/daemon/handlers/session-close.ts | 6 +- src/daemon/handlers/session-replay.ts | 5 +- src/daemon/handlers/session.ts | 1 + src/daemon/lease-context.ts | 5 +- src/daemon/lease-registry.ts | 45 ++++++- src/daemon/request-admission.ts | 5 +- src/daemon/request-execution-scope.ts | 83 ++++++------ src/daemon/request-router.ts | 2 +- src/remote-connection-state.ts | 5 +- 18 files changed, 427 insertions(+), 126 deletions(-) diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 6661ca3a6..0fb4919e0 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -246,6 +246,63 @@ test('connect proxy writes normal remote state with generated non-secret profile fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('connect proxy scopes generated client identity by explicit session', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-sessions-')); + const stateDir = path.join(tempRoot, '.state'); + + for (const session of ['agent-a', 'agent-b']) { + await captureStdout(async () => { + await connectCommand({ + positionals: ['proxy'], + flags: { + json: true, + help: false, + version: false, + stateDir, + daemonBaseUrl: 'http://proxy.example.test/agent-device', + platform: 'android', + session, + }, + client: createTestClient(), + }); + }); + } + + const first = readRemoteConnectionState({ stateDir, session: 'agent-a' }); + const second = readRemoteConnectionState({ stateDir, session: 'agent-b' }); + assert.ok(first); + assert.ok(second); + assert.notEqual(first.clientId, second.clientId); + assert.notEqual(first.runId, second.runId); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect proxy notice only advertises open as the lease allocator', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-notice-')); + const stateDir = path.join(tempRoot, '.state'); + + const stdout = await captureStdout(async () => { + await connectCommand({ + positionals: ['proxy'], + flags: { + json: false, + help: false, + version: false, + stateDir, + daemonBaseUrl: 'http://proxy.example.test/agent-device', + platform: 'android', + }, + client: createTestClient(), + }); + }); + + assert.match(stdout, /Proxy lease allocation is pending/); + assert.match(stdout, /run open when ready/); + assert.doesNotMatch(stdout, /snapshot/); + assert.doesNotMatch(stdout, /install-from-source/); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('generated remote config writer strips secret fields', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-generated-profile-')); const configPath = writeGeneratedRemoteConfig({ @@ -599,6 +656,7 @@ test('proxy open resolves device key before allocating lease', async () => { assert.equal(allocateRequest?.ttlMs, PROXY_REMOTE_LEASE_TTL_MS); assert.equal(allocateRequest?.leaseBackend, 'ios-instance'); assert.equal(materialized.flags.leaseId, 'abc123abc123abc1'); + assert.equal(materialized.flags.udid, 'SIM-001'); assert.equal(materialized.connection?.deviceKey, 'ios:mobile:SIM-001'); const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); assert.equal(state?.leaseId, 'abc123abc123abc1'); diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts index 80fe9be2f..21060bdd3 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -1,14 +1,10 @@ import type { RemoteConfigProfile } from '../remote-config-schema.ts'; -import { profileToCliFlags } from '../utils/remote-config.ts'; import { AppError } from '../utils/errors.ts'; import type { CliFlags } from '../utils/cli-flags.ts'; import type { EnvMap } from '../utils/env-map.ts'; import { resolveCloudAccessForConnect } from './auth-session.ts'; import { readCloudJsonResponse } from './cloud-response.ts'; -import { - resolveGeneratedRemoteConfigProfile, - writeGeneratedRemoteConfig, -} from './generated-remote-config.ts'; +import { persistAndResolveGeneratedProfile } from './generated-remote-config.ts'; const CONNECTION_PROFILE_PATH = '/api/control-plane/connection-profile'; const HTTP_TIMEOUT_MS = 15_000; @@ -40,26 +36,17 @@ export async function resolveCloudConnectProfile(options: { accessToken: auth.accessToken, fetchImpl: options.fetchImpl, }); - const remoteConfigPath = writeGeneratedRemoteConfig({ + return persistAndResolveGeneratedProfile({ stateDir: options.stateDir, provider: 'cloud', profile, - }); - const remoteConfig = resolveGeneratedRemoteConfigProfile({ - configPath: remoteConfigPath, cwd: options.cwd, env: options.env, - provider: 'Cloud', - }); - return { - flags: { - ...profileToCliFlags(remoteConfig.profile), - ...options.flags, - remoteConfig: remoteConfig.resolvedPath, + flags: options.flags, + extraFlags: { daemonAuthToken: auth.accessToken, }, - remoteConfigPath: remoteConfig.resolvedPath, - }; + }); } async function fetchConnectionProfile(options: { diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 0fc1c1090..51d634fa8 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -97,15 +97,17 @@ export async function materializeRemoteConnectionForCommand(options: { if (shouldAllocateLeaseForCommand(command, nextState)) { const preliminaryLeaseBackend = state.leaseBackend ?? resolveRequestedLeaseBackend(nextFlags); if (nextState.leaseProvider === 'proxy') { - nextState = ( - await resolveProxyLeaseState({ - command, - client, - state: nextState, - flags: nextFlags, - leaseBackend: preliminaryLeaseBackend, - }) - ).state; + const resolvedProxyLease = await resolveProxyLeaseState({ + command, + client, + state: nextState, + flags: nextFlags, + leaseBackend: preliminaryLeaseBackend, + }); + nextState = resolvedProxyLease.state; + if (resolvedProxyLease.device) { + applyResolvedDeviceSelector(nextFlags, resolvedProxyLease.device); + } } const leaseBackend = nextState.leaseBackend ?? @@ -475,7 +477,7 @@ async function resolveProxyLeaseState(options: { state: RemoteConnectionState; flags: CliFlags; leaseBackend?: LeaseBackend; -}): Promise<{ state: RemoteConnectionState }> { +}): Promise<{ state: RemoteConnectionState; device?: DeviceInfo }> { if (options.command !== 'open') { if (options.state.leaseId && options.state.deviceKey) return { state: options.state }; throw new AppError( @@ -495,9 +497,22 @@ async function resolveProxyLeaseState(options: { target: options.state.target ?? device.target, updatedAt: new Date().toISOString(), }, + device, }; } +function applyResolvedDeviceSelector(flags: CliFlags, device: DeviceInfo): void { + flags.platform = device.platform; + flags.target = device.target ?? flags.target; + if (device.platform === 'ios') { + flags.udid = device.id; + return; + } + if (device.platform === 'android') { + flags.serial = device.id; + } +} + async function resolveSelectedDevice( client: AgentDeviceClient, flags: CliFlags, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 2808cd3c5..9e578cb86 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -382,6 +382,14 @@ function buildLeasePreparationNotice( state: RemoteConnectionState, ): LeasePreparationNotice | undefined { if (state.leaseId) return undefined; + if (state.leaseProvider === 'proxy') { + return { + status: 'deferred', + nextSteps: ['agent-device open --relaunch', 'agent-device devices'], + message: + 'Proxy lease allocation is pending; run open when ready to allocate or refresh the device lease. Devices can inspect inventory but do not allocate a proxy lease.', + }; + } const needsPlatform = state.platform === undefined && state.leaseBackend === undefined ? ' Add --platform ios|android if the profile does not set a platform.' diff --git a/src/cli/generated-remote-config.ts b/src/cli/generated-remote-config.ts index b758645f7..975289367 100644 --- a/src/cli/generated-remote-config.ts +++ b/src/cli/generated-remote-config.ts @@ -5,6 +5,8 @@ import { resolveRemoteConfigProfile } from '../remote-config.ts'; import type { RemoteConfigProfile, ResolvedRemoteConfigProfile } from '../remote-config-schema.ts'; import { AppError, asAppError } from '../utils/errors.ts'; import type { EnvMap } from '../utils/env-map.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; +import { profileToCliFlags } from '../utils/remote-config.ts'; const GENERATED_REMOTE_CONFIG_SECRET_KEYS = new Set(['daemonAuthToken', 'metroBearerToken']); @@ -29,7 +31,7 @@ export function writeGeneratedRemoteConfig(options: { return configPath; } -export function resolveGeneratedRemoteConfigProfile(options: { +function resolveGeneratedRemoteConfigProfile(options: { configPath: string; cwd: string; env?: EnvMap; @@ -52,6 +54,37 @@ export function resolveGeneratedRemoteConfigProfile(options: { } } +export function persistAndResolveGeneratedProfile(options: { + stateDir: string; + cwd: string; + env?: EnvMap; + provider: string; + profile: RemoteConfigProfile; + flags: CliFlags; + extraFlags?: Partial; +}): { flags: CliFlags; remoteConfigPath: string } { + const remoteConfigPath = writeGeneratedRemoteConfig({ + stateDir: options.stateDir, + provider: options.provider, + profile: options.profile, + }); + const remoteConfig = resolveGeneratedRemoteConfigProfile({ + configPath: remoteConfigPath, + cwd: options.cwd, + env: options.env, + provider: titleCaseProvider(options.provider), + }); + return { + flags: { + ...profileToCliFlags(remoteConfig.profile), + ...options.flags, + ...(options.extraFlags ?? {}), + remoteConfig: remoteConfig.resolvedPath, + }, + remoteConfigPath: remoteConfig.resolvedPath, + }; +} + function stripGeneratedProfileSecrets(profile: RemoteConfigProfile): RemoteConfigProfile { return Object.fromEntries( Object.entries(profile).filter(([key]) => !GENERATED_REMOTE_CONFIG_SECRET_KEYS.has(key)), @@ -80,3 +113,8 @@ function normalizeJson(value: unknown): unknown { function safeProviderName(value: string): string { return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_') || 'generated'; } + +function titleCaseProvider(value: string): string { + const [first = '', ...rest] = value; + return `${first.toUpperCase()}${rest.join('')}`; +} diff --git a/src/cli/proxy-connection-profile.ts b/src/cli/proxy-connection-profile.ts index ba3645425..fc6d70316 100644 --- a/src/cli/proxy-connection-profile.ts +++ b/src/cli/proxy-connection-profile.ts @@ -1,13 +1,9 @@ import crypto from 'node:crypto'; import type { RemoteConfigProfile } from '../remote-config-schema.ts'; -import { profileToCliFlags } from '../utils/remote-config.ts'; import { AppError } from '../utils/errors.ts'; import type { CliFlags } from '../utils/cli-flags.ts'; import type { EnvMap } from '../utils/env-map.ts'; -import { - resolveGeneratedRemoteConfigProfile, - writeGeneratedRemoteConfig, -} from './generated-remote-config.ts'; +import { persistAndResolveGeneratedProfile } from './generated-remote-config.ts'; import { resolveRequestedLeaseBackend } from './commands/connection-runtime.ts'; export function resolveProxyConnectProfile(options: { @@ -23,7 +19,7 @@ export function resolveProxyConnectProfile(options: { 'connect proxy requires --daemon-base-url or AGENT_DEVICE_DAEMON_BASE_URL.', ); } - const clientId = buildProxyClientId(options.stateDir, daemonBaseUrl); + const clientId = buildProxyClientId(options.stateDir, daemonBaseUrl, options.flags.session); const profile: RemoteConfigProfile = { daemonBaseUrl, daemonTransport: options.flags.daemonTransport ?? 'http', @@ -59,33 +55,28 @@ export function resolveProxyConnectProfile(options: { metroNoReuseExisting: options.flags.metroNoReuseExisting, metroNoInstallDeps: options.flags.metroNoInstallDeps, }; - const remoteConfigPath = writeGeneratedRemoteConfig({ + return persistAndResolveGeneratedProfile({ stateDir: options.stateDir, provider: 'proxy', profile, - }); - const remoteConfig = resolveGeneratedRemoteConfigProfile({ - configPath: remoteConfigPath, cwd: options.cwd, env: options.env, - provider: 'Proxy', - }); - return { - flags: { - ...profileToCliFlags(remoteConfig.profile), - ...options.flags, - remoteConfig: remoteConfig.resolvedPath, + flags: options.flags, + extraFlags: { daemonBaseUrl, daemonTransport: options.flags.daemonTransport ?? 'http', }, - remoteConfigPath: remoteConfig.resolvedPath, - }; + }); } -function buildProxyClientId(stateDir: string, daemonBaseUrl: string): string { +function buildProxyClientId( + stateDir: string, + daemonBaseUrl: string, + session: string | undefined, +): string { return crypto .createHash('sha256') - .update(`${stateDir}\0${daemonBaseUrl}`) + .update(`${stateDir}\0${daemonBaseUrl}\0${session ?? ''}`) .digest('hex') .slice(0, 16); } diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index d995e05db..a5804d80e 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -135,7 +135,7 @@ test('device-aware allocation is idempotent per tenant/run/backend/provider/devi test('same backend/provider/device rejects conflicting active lease', () => { const registry = new LeaseRegistry(); - const first = registry.allocateLease({ + registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', backend: 'ios-instance', @@ -143,21 +143,23 @@ test('same backend/provider/device rejects conflicting active lease', () => { deviceKey: 'device-1', }); - assert.throws( - () => - registry.allocateLease({ - tenantId: 'tenant-b', - runId: 'run-2', - backend: 'ios-instance', - leaseProvider: 'proxy', - deviceKey: 'device-1', - }), - (error) => - error instanceof Error && - error.message === 'Device is already leased' && - (error as { details?: Record }).details?.reason === 'DEVICE_LEASE_BUSY' && - (error as { details?: Record }).details?.leaseId === first.leaseId, + const error = captureThrown(() => + registry.allocateLease({ + tenantId: 'tenant-b', + runId: 'run-2', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }), ); + + assert.ok(error instanceof Error); + assert.equal(error.message, 'Device is already leased'); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'DEVICE_LEASE_BUSY'); + assert.equal(details?.leaseId, undefined); + assert.equal(details?.tenantId, undefined); + assert.equal(details?.runId, undefined); }); test('device leases are isolated by provider and device key', () => { @@ -199,25 +201,97 @@ test('heartbeat enforces device and provider scope when supplied', () => { }); assert.throws( - () => registry.heartbeatLease({ leaseId: lease.leaseId, deviceKey: 'device-2' }), + () => + registry.heartbeatLease({ + leaseId: lease.leaseId, + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + deviceKey: 'device-2', + clientId: 'client-a', + }), (error) => error instanceof Error && (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_MISMATCH', ); assert.throws( - () => registry.heartbeatLease({ leaseId: lease.leaseId, leaseProvider: 'limrun' }), + () => + registry.heartbeatLease({ + leaseId: lease.leaseId, + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'limrun', + deviceKey: 'device-1', + clientId: 'client-a', + }), (error) => error instanceof Error && (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_MISMATCH', ); assert.throws( - () => registry.heartbeatLease({ leaseId: lease.leaseId, clientId: 'client-b' }), + () => + registry.heartbeatLease({ + leaseId: lease.leaseId, + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-b', + }), (error) => error instanceof Error && (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_MISMATCH', ); }); +test('heartbeat/release require owner scope for device-aware leases', () => { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }); + + assert.throws( + () => registry.heartbeatLease({ leaseId: lease.leaseId }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_REQUIRED', + ); + assert.throws( + () => + registry.releaseLease({ + leaseId: lease.leaseId, + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_REQUIRED', + ); +}); + +test('consumeExpiredLease removes one expired lease without sweeping unrelated sessions', () => { + let now = 1_000; + const registry = new LeaseRegistry({ + now: () => now, + defaultLeaseTtlMs: 5_000, + }); + const first = registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + const second = registry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2' }); + + now = 7_000; + const expired = registry.consumeExpiredLease(first.leaseId); + + assert.equal(expired?.leaseId, first.leaseId); + assert.equal(registry.consumeExpiredLease(second.leaseId)?.leaseId, second.leaseId); + assert.deepEqual(registry.consumeExpiredLease(first.leaseId), undefined); +}); + test('expired device lease releases device binding for new clients', () => { let now = 1_000; const registry = new LeaseRegistry({ @@ -243,3 +317,12 @@ test('expired device lease releases device binding for new clients', () => { assert.notEqual(second.leaseId, first.leaseId); }); + +function captureThrown(task: () => unknown): unknown { + try { + task(); + return undefined; + } catch (error) { + return error; + } +} diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index da0ece1b8..3715dfa94 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -45,6 +45,9 @@ test('createRequestExecutionScope applies tenant scoping and locked lease admiss tenantId: 'tenant-a', runId: 'run-1', leaseId: lease.leaseId, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', sessionIsolation: 'tenant', }, }), @@ -308,6 +311,21 @@ test('local unleased session admission still succeeds', async () => { expect(scope.sessionName).toBe('default'); }); +test('local unleased session ignores stale lease id without tenant scope', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + sessionStore.set('default', makeIosSession('default')); + const scope = await createRequestExecutionScope({ + req: makeRequest({ + command: 'snapshot', + meta: { leaseId: '1'.repeat(32) }, + }), + sessionStore, + leaseRegistry: new LeaseRegistry(), + }); + + await expect(scope.runLocked(async () => 'ran')).resolves.toBe('ran'); +}); + test('provider lease admission succeeds without a device key', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); @@ -366,17 +384,75 @@ test('expired leases remove owned sessions before the next command and free capa ); now = 1_011; - await createRequestExecutionScope({ + const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, }); + await scope.runLocked(async () => 'ran'); expect(sessionStore.get('default')).toBeUndefined(); const nextLease = leaseRegistry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2' }); expect(nextLease.tenantId).toBe('tenant-b'); }); +test('expired leased session cleanup waits for the request execution lock', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ + defaultLeaseTtlMs: 10, + minLeaseTtlMs: 1, + now: () => now, + }); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + expiresAt: lease.expiresAt, + }, + }), + ); + const first = await createRequestExecutionScope({ + req: makeRequest({ command: 'click' }), + sessionStore, + leaseRegistry, + }); + const second = await createRequestExecutionScope({ + req: makeRequest({ command: 'click' }), + sessionStore, + leaseRegistry, + }); + + let releaseFirst: () => void = () => {}; + let firstEntered: () => void = () => {}; + const firstEnteredPromise = new Promise((resolve) => { + firstEntered = resolve; + }); + const firstRun = first.runLocked( + async () => + await new Promise((release) => { + releaseFirst = release; + firstEntered(); + }), + ); + await firstEnteredPromise; + + now = 1_011; + const secondRun = second.runLocked(async () => 'second'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(sessionStore.get('default')).toBeDefined(); + + releaseFirst(); + await firstRun; + await expect(secondRun).resolves.toBe('second'); + expect(sessionStore.get('default')).toBeUndefined(); +}); + test('tenant lease rejection flushes diagnostics into the effective session request log', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const requestId = 'tenant-lease-rejection'; diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts index 494ba942e..69d0047f9 100644 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-replay.test.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { beforeEach, test, vi } from 'vitest'; import { SessionStore } from '../../session-store.ts'; +import { LeaseRegistry } from '../../lease-registry.ts'; import type { DaemonRequest, DaemonResponse } from '../../types.ts'; import { makeIosSession } from '../../../__tests__/test-utils/index.ts'; import { buildNestedReplayFlags, handleSessionReplayCommands } from '../session-replay.ts'; @@ -244,6 +245,7 @@ test('test --record-video records each replay attempt on the generated test sess sessionName: 'default', logPath: path.join(root, 'daemon.log'), sessionStore, + leaseRegistry: new LeaseRegistry(), invoke: async (nestedReq) => { nestedRequests.push(nestedReq); if (nestedReq.command === 'open') { diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index e234f8b37..4ea34ce09 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -25,7 +25,7 @@ import { settleIosSimulator, } from './session-device-utils.ts'; import { errorResponse } from './response.ts'; -import { LeaseRegistry } from '../lease-registry.ts'; +import type { LeaseRegistry } from '../lease-registry.ts'; async function maybeShutdownSessionTarget(params: { device: DeviceInfo; @@ -108,9 +108,9 @@ export async function handleCloseCommand(params: { sessionName: string; logPath: string; sessionStore: SessionStore; - leaseRegistry?: LeaseRegistry; + leaseRegistry: LeaseRegistry; }): Promise { - const { req, sessionName, logPath, sessionStore, leaseRegistry = new LeaseRegistry() } = params; + const { req, sessionName, logPath, sessionStore, leaseRegistry } = params; const session = sessionStore.get(sessionName); if (!session) { return await closeWithoutSession(req, logPath); diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts index 8f72c439a..e3e9df950 100644 --- a/src/daemon/handlers/session-replay.ts +++ b/src/daemon/handlers/session-replay.ts @@ -6,6 +6,7 @@ import { handleCloseCommand } from './session-close.ts'; import { collectReplayActionArtifactPaths, runReplayScriptFile } from './session-replay-runtime.ts'; import type { ReplayScriptMetadata } from '../../replay/script.ts'; import { buildReplayTestShardFlags, type ReplayTestShardContext } from './session-test-sharding.ts'; +import type { LeaseRegistry } from '../lease-registry.ts'; import { buildReplayTestVideoOpenLifecycle, finalizeReplayTestVideoRecording, @@ -52,9 +53,10 @@ export async function handleSessionReplayCommands(params: { sessionName: string; logPath: string; sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; invoke: DaemonInvokeFn; }): Promise { - const { req, sessionName, logPath, sessionStore, invoke } = params; + const { req, sessionName, logPath, sessionStore, leaseRegistry, invoke } = params; if (req.command === 'replay') { return await runReplayScriptFile({ @@ -166,6 +168,7 @@ export async function handleSessionReplayCommands(params: { sessionName: testSessionName, logPath, sessionStore, + leaseRegistry, }); }, }); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 0dd9b174f..0492edcf4 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -424,6 +424,7 @@ export async function handleSessionCommands(params: { sessionName, logPath, sessionStore, + leaseRegistry, invoke: invokeReplayAction ?? invoke, }); } diff --git a/src/daemon/lease-context.ts b/src/daemon/lease-context.ts index 2be167588..45f55e43e 100644 --- a/src/daemon/lease-context.ts +++ b/src/daemon/lease-context.ts @@ -2,6 +2,7 @@ import type { DaemonRequest } from './types.ts'; import type { LeaseBackend } from '../contracts.ts'; import type { DeviceLease } from './lease-registry.ts'; import type { RunnerLogicalLeaseContext } from '../core/runner-lease-context.ts'; +import { stripUndefined } from '../utils/parsing.ts'; const PROXY_LEASE_PROVIDER = 'proxy'; export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; @@ -148,7 +149,3 @@ function readFlagString(flags: object | undefined, key: string): string | undefi function readNonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; } - -function stripUndefined>(record: T): T { - return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; -} diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 88c24a8e3..9f07141e4 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -151,6 +151,20 @@ function normalizeAgentIdentifier( return value; } +function leaseRequiresOwnerScope(lease: DeviceLease): boolean { + return Boolean(lease.leaseProvider ?? lease.deviceKey ?? lease.clientId); +} + +function hasRequiredOwnerScope(lease: DeviceLease, request: LeaseScopeMatchRequest): boolean { + if (!request.tenantId || !request.runId) return false; + const provider = request.leaseProvider ?? request.provider; + return [ + [lease.leaseProvider, provider], + [lease.deviceKey, request.deviceKey], + [lease.clientId, request.clientId], + ].every(([leaseValue, requestValue]) => !leaseValue || Boolean(requestValue)); +} + export class LeaseRegistry { private readonly leases = new Map(); private readonly runBindings = new Map(); @@ -232,6 +246,7 @@ export class LeaseRegistry { const leaseId = this.normalizeRequiredLeaseId(request.leaseId); this.cleanupExpiredLeases(); const lease = this.getActiveLease(leaseId); + this.assertRequiredScopeForDeviceAwareLease(lease, this.scopeMatchRequest(request)); this.assertOptionalScopeMatch(lease, this.scopeMatchRequest(request)); const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); return this.refreshLease(lease, leaseTtlMs); @@ -244,6 +259,7 @@ export class LeaseRegistry { if (!lease) { return { released: false }; } + this.assertRequiredScopeForDeviceAwareLease(lease, this.scopeMatchRequest(request)); this.assertOptionalScopeMatch(lease, this.scopeMatchRequest(request)); this.leases.delete(leaseId); this.unbindLease(lease); @@ -294,6 +310,16 @@ export class LeaseRegistry { return expired; } + consumeExpiredLease(leaseId: string): DeviceLease | undefined { + const normalizedLeaseId = normalizeLeaseId(leaseId); + if (!normalizedLeaseId) return undefined; + const lease = this.leases.get(normalizedLeaseId); + if (!lease || lease.expiresAt > this.now()) return undefined; + this.leases.delete(lease.leaseId); + this.unbindLease(lease); + return { ...lease }; + } + private cleanupExpiredLeases(): void { this.consumeExpiredLeases(); } @@ -449,14 +475,21 @@ export class LeaseRegistry { deviceKey: activeLease.deviceKey, backend: activeLease.backend, leaseProvider: activeLease.leaseProvider, - leaseId: activeLease.leaseId, - tenantId: activeLease.tenantId, - runId: activeLease.runId, expiresAt: activeLease.expiresAt, hint: 'Retry after the lease expires or close the owning session.', }); } + private assertRequiredScopeForDeviceAwareLease( + lease: DeviceLease, + request: LeaseScopeMatchRequest, + ): void { + if (!leaseRequiresOwnerScope(lease)) return; + if (!hasRequiredOwnerScope(lease, request)) { + this.throwScopeRequired(); + } + } + // fallow-ignore-next-line complexity private assertOptionalScopeMatch( lease: DeviceLease, @@ -522,4 +555,10 @@ export class LeaseRegistry { reason: 'LEASE_SCOPE_MISMATCH', }); } + + private throwScopeRequired(): never { + throw new AppError('UNAUTHORIZED', 'Lease owner scope is required', { + reason: 'LEASE_SCOPE_REQUIRED', + }); + } } diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 7bf6e4f15..e09be4716 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -66,8 +66,9 @@ export function assertRequestLeaseAdmission( const requestLeaseScope = resolveLeaseScope(req); assertProxyOpenLeaseMetadata(req, requestLeaseScope); const sessionLease = session?.lease; - if (!sessionLease && req.meta?.sessionIsolation !== 'tenant' && !requestLeaseScope.leaseId) { - return undefined; + if (!sessionLease && req.meta?.sessionIsolation !== 'tenant') { + if (!requestLeaseScope.leaseId) return undefined; + if (!requestLeaseScope.tenantId && !requestLeaseScope.runId) return undefined; } assertRequestSessionLeaseMatches(requestLeaseScope, sessionLease); const leaseScope = resolveRequestOrSessionLeaseScope(req, session); diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 27c6c4fa0..5d1ce2f86 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -47,6 +47,7 @@ export type RequestExecutionScope = { sessionName: string; requestLogPath: string; runnerLogPath: string; + runAdmitted(task: () => Promise): Promise; runLocked(task: () => Promise): Promise; throwIfCanceled(): void; }; @@ -79,7 +80,6 @@ export async function createRequestExecutionScope(params: { leaseRegistry: LeaseRegistry; }): Promise { const { sessionStore, leaseRegistry } = params; - await cleanupExpiredLeasedSessions({ sessionStore, leaseRegistry }); let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); const command = scopedReq.command; @@ -121,21 +121,26 @@ export async function createRequestExecutionScope(params: { requestLogPath, runnerLogPath, throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), + runAdmitted: async (task) => { + throwIfRequestCanceled(scopedReq.meta?.requestId); + await cleanupExpiredLeasedSession({ sessionName, sessionStore, leaseRegistry }); + scopedReq = admitRequestLeaseForLockedScope({ + req: scopedReq, + sessionName, + sessionStore, + leaseRegistry, + }); + scope.req = scopedReq; + return await task(); + }, runLocked: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); - const runAdmitted = async () => { - throwIfRequestCanceled(scopedReq.meta?.requestId); - scopedReq = admitRequestLeaseForLockedScope({ - req: scopedReq, - sessionName, - sessionStore, - leaseRegistry, - }); - scope.req = scopedReq; - return await task(); - }; - if (executionLockKeys.length === 0) return await runAdmitted(); - return await withRequestExecutionLocks(executionLocks, executionLockKeys, runAdmitted); + if (executionLockKeys.length === 0) return await scope.runAdmitted(task); + return await withRequestExecutionLocks( + executionLocks, + executionLockKeys, + async () => await scope.runAdmitted(task), + ); }, }; return scope; @@ -172,40 +177,40 @@ function admitRequestLeaseForLockedScope(params: { return nextReq; } -async function cleanupExpiredLeasedSessions(params: { +async function cleanupExpiredLeasedSession(params: { + sessionName: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; -}): Promise { - const expiredLeases = params.leaseRegistry.consumeExpiredLeases(); - if (expiredLeases.length === 0) return; - const expiredLeaseIds = new Set(expiredLeases.map((lease) => lease.leaseId)); - for (const session of params.sessionStore.toArray()) { - const lease = session.lease; - if (!lease || !expiredLeaseIds.has(lease.leaseId)) continue; +}): Promise { + const session = params.sessionStore.get(params.sessionName); + const lease = session?.lease; + if (!session || !lease) return false; + const expiredLease = params.leaseRegistry.consumeExpiredLease(lease.leaseId); + if (!expiredLease) return false; + emitDiagnostic({ + level: 'info', + phase: 'leased_session_expired', + data: { + reason: 'LEASE_EXPIRED', + leaseId: lease.leaseId, + session: session.name, + deviceKey: lease.deviceKey, + }, + }); + await teardownSessionResources(session, session.name).catch((error) => { emitDiagnostic({ - level: 'info', - phase: 'leased_session_expired', + level: 'debug', + phase: 'leased_session_expiry_cleanup_failed', data: { reason: 'LEASE_EXPIRED', leaseId: lease.leaseId, session: session.name, - deviceKey: lease.deviceKey, + error: error instanceof Error ? error.message : String(error), }, }); - await teardownSessionResources(session, session.name).catch((error) => { - emitDiagnostic({ - level: 'debug', - phase: 'leased_session_expiry_cleanup_failed', - data: { - reason: 'LEASE_EXPIRED', - leaseId: lease.leaseId, - session: session.name, - error: error instanceof Error ? error.message : String(error), - }, - }); - }); - params.sessionStore.delete(session.name); - } + }); + params.sessionStore.delete(session.name); + return true; } async function withRequestExecutionLocks( diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 70e6775a4..996e89a34 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -151,7 +151,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { ); }; - return inheritedProviderScope ? await run() : await scope.runLocked(run); + return inheritedProviderScope ? await scope.runAdmitted(run) : await scope.runLocked(run); } async function executeLockedRequest(params: { diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index 347b359b8..74c1dbbe3 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -6,6 +6,7 @@ import { AppError } from './utils/errors.ts'; import { emitDiagnostic } from './utils/diagnostics.ts'; import type { CliFlags } from './utils/cli-flags.ts'; import type { LeaseBackend, SessionRuntimeHints } from './contracts.ts'; +import { stripUndefined } from './utils/parsing.ts'; export type RemoteConnectionState = { version: 1; @@ -285,10 +286,6 @@ function safeStateName(value: string): string { return `${safe}-${suffix}`; } -function stripUndefined>(record: T): T { - return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; -} - // fallow-ignore-next-line complexity function isRemoteConnectionState(value: unknown): value is RemoteConnectionState { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; From abbc2e1633a6e9bf7e20fc396b4f9de8da6ce656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:28:27 +0200 Subject: [PATCH 08/16] refactor: deepen device lease lifecycle --- src/daemon-runtime.ts | 2 +- src/daemon/__tests__/lease-lifecycle.test.ts | 160 ++++++++++++++++++ .../__tests__/session-close-shutdown.test.ts | 2 +- src/daemon/handlers/session-close.ts | 101 ++--------- src/daemon/handlers/session-open.ts | 14 +- src/daemon/lease-lifecycle.ts | 119 +++++++++++++ src/daemon/request-execution-scope.ts | 87 ++-------- src/daemon/session-teardown.ts | 71 ++++++++ 8 files changed, 384 insertions(+), 172 deletions(-) create mode 100644 src/daemon/__tests__/lease-lifecycle.test.ts create mode 100644 src/daemon/lease-lifecycle.ts create mode 100644 src/daemon/session-teardown.ts diff --git a/src/daemon-runtime.ts b/src/daemon-runtime.ts index e1a8786b7..728d5bc00 100644 --- a/src/daemon-runtime.ts +++ b/src/daemon-runtime.ts @@ -7,7 +7,7 @@ import { createDaemonHttpServer } from './daemon/http-server.ts'; import { trackDownloadableArtifact } from './daemon/artifact-tracking.ts'; import { LeaseRegistry } from './daemon/lease-registry.ts'; import { createRequestHandler } from './daemon/request-router.ts'; -import { teardownSessionResources } from './daemon/handlers/session-close.ts'; +import { teardownSessionResources } from './daemon/session-teardown.ts'; import { closeDaemonServers } from './daemon/server-shutdown.ts'; import type { SessionState } from './daemon/types.ts'; import { diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts new file mode 100644 index 000000000..a5ca71ea0 --- /dev/null +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -0,0 +1,160 @@ +import { test, expect, vi } from 'vitest'; +import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { + admitRequestLeaseForLockedScope, + cleanupExpiredLeasedSession, + releaseSessionLease, + resolveSessionLeaseForRequest, +} from '../lease-lifecycle.ts'; +import type { DaemonRequest } from '../types.ts'; + +test('admitRequestLeaseForLockedScope heartbeats and stores admitted lease on the request', () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-lease-lifecycle-'); + const leaseRegistry = new LeaseRegistry({ now: () => now }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + expiresAt: lease.expiresAt, + }, + }), + ); + now = 2_000; + + const req = admitRequestLeaseForLockedScope({ + req: makeRequest({ command: 'snapshot' }), + sessionName: 'default', + sessionStore, + leaseRegistry, + }); + + expect(req.internal?.admittedLease?.leaseId).toBe(lease.leaseId); + expect(req.internal?.admittedLease?.heartbeatAt).toBe(2_000); + expect(sessionStore.get('default')?.lease?.expiresAt).toBe(302_000); +}); + +test('cleanupExpiredLeasedSession consumes expired lease and deletes the session after teardown', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-lease-lifecycle-'); + const leaseRegistry = new LeaseRegistry({ + defaultLeaseTtlMs: 10, + minLeaseTtlMs: 1, + now: () => now, + }); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + const session = makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + expiresAt: lease.expiresAt, + }, + }); + sessionStore.set('default', session); + now = 1_011; + const teardownSession = vi.fn(async () => {}); + + const cleaned = await cleanupExpiredLeasedSession({ + sessionName: 'default', + sessionStore, + leaseRegistry, + teardownSession, + }); + + expect(cleaned).toBe(true); + expect(teardownSession).toHaveBeenCalledWith(session, 'default'); + expect(sessionStore.get('default')).toBeUndefined(); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); + +test('releaseSessionLease releases with the stored session owner scope', () => { + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + const session = makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }, + }); + + releaseSessionLease({ session, leaseRegistry }); + + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); + +test('resolveSessionLeaseForRequest prefers admitted lease and falls back to existing lease', () => { + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + const req = makeRequest({ + meta: { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: lease.leaseId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }, + internal: { admittedLease: lease }, + }); + + const resolved = resolveSessionLeaseForRequest({ + req, + existingLease: { + leaseId: 'older', + tenantId: 'tenant-a', + runId: 'run-1', + }, + }); + + expect(resolved?.leaseId).toBe(lease.leaseId); + expect(resolved?.expiresAt).toBe(lease.expiresAt); +}); + +function makeRequest(overrides: Partial = {}): DaemonRequest { + return { + token: 'token', + session: 'default', + command: 'snapshot', + positionals: [], + flags: {}, + ...overrides, + }; +} diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index 9e1e23420..f3953f628 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -49,7 +49,7 @@ vi.mock('../session-device-utils.ts', async (importOriginal) => { }); import { handleSessionCommands } from '../session.ts'; -import { teardownSessionResources } from '../session-close.ts'; +import { teardownSessionResources } from '../../session-teardown.ts'; import { shutdownSimulator } from '../../../platforms/ios/simulator.ts'; import { runCmd } from '../../../utils/exec.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 4ea34ce09..ecd307000 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -1,15 +1,9 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { isApplePlatform, type DeviceInfo } from '../../utils/device.ts'; -import { runMacOsAlertAction } from '../../platforms/ios/macos-helper.ts'; import { dispatchCommand } from '../../core/dispatch.ts'; import { contextFromFlags } from '../context.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { stopAppLog } from '../app-log.ts'; -import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts'; -import { cleanupAppleXctracePerfCapture } from '../../platforms/ios/perf-xctrace.ts'; -import { cleanupAndroidNativePerfSession } from '../../platforms/android/perf.ts'; -import { stopAndroidSnapshotHelperSessionForDevice } from '../../platforms/android/snapshot-helper.ts'; import { clearRuntimeHintsFromApp, hasRuntimeTransportHints } from '../runtime-hints.ts'; import { cleanupRetainedMaterializedPathsForSession } from '../materialized-path-registry.ts'; import { @@ -26,6 +20,14 @@ import { } from './session-device-utils.ts'; import { errorResponse } from './response.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; +import { releaseSessionLease } from '../lease-lifecycle.ts'; +import { + stopAppleRunnerForClose, + stopSessionAndroidNativePerfCapture, + stopSessionAndroidSnapshotHelper, + stopSessionAppLog, + stopSessionApplePerfCapture, +} from '../session-teardown.ts'; async function maybeShutdownSessionTarget(params: { device: DeviceInfo; @@ -37,30 +39,6 @@ async function maybeShutdownSessionTarget(params: { return await shutdownDeviceTarget(device); } -async function stopAppleRunnerForClose(session: SessionState): Promise { - await stopIosRunnerSession(session.device.id); - if (session.device.platform !== 'macos') { - return; - } - - const dismissOptions = - session.surface === 'frontmost-app' - ? { surface: 'frontmost-app' as const } - : session.appBundleId - ? { bundleId: session.appBundleId } - : {}; - await runMacOsAlertAction('dismiss', dismissOptions).catch((error) => { - emitDiagnostic({ - level: 'debug', - phase: 'macos_close_alert_dismiss_failed', - data: { - session: session.name, - error: error instanceof Error ? error.message : String(error), - }, - }); - }); -} - function shouldRetainAppleRunnerAfterClose(req: DaemonRequest, session: SessionState): boolean { return isIosSimulator(session.device) && !req.flags?.shutdown && !session.recording; } @@ -69,40 +47,6 @@ function shouldStopAppleRunnerBeforeTargetedClose(session: SessionState): boolea return isApplePlatform(session.device.platform) && !isIosSimulator(session.device); } -async function stopSessionApplePerfCapture(session: SessionState): Promise { - if (!session.applePerf?.active) return; - await cleanupAppleXctracePerfCapture(session.applePerf.active); - session.applePerf = { ...(session.applePerf ?? {}), active: undefined }; -} - -async function stopSessionAndroidNativePerfCapture(session: SessionState): Promise { - const active = session.nativePerf?.android; - if (!active) return; - await cleanupAndroidNativePerfSession(session.device, active); - session.nativePerf = { ...(session.nativePerf ?? {}), android: undefined }; -} - -async function stopSessionAndroidSnapshotHelper(session: SessionState): Promise { - if (session.device.platform !== 'android') return; - await stopAndroidSnapshotHelperSessionForDevice(session.device); -} - -export async function teardownSessionResources( - session: SessionState, - sessionName: string, -): Promise { - if (session.appLog) { - await stopAppLog(session.appLog); - } - await stopSessionApplePerfCapture(session); - await stopSessionAndroidNativePerfCapture(session); - await stopSessionAndroidSnapshotHelper(session); - if (isApplePlatform(session.device.platform)) { - await stopAppleRunnerForClose(session); - } - await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); -} - export async function handleCloseCommand(params: { req: DaemonRequest; sessionName: string; @@ -116,9 +60,7 @@ export async function handleCloseCommand(params: { return await closeWithoutSession(req, logPath); } try { - if (session.appLog) { - await stopAppLog(session.appLog); - } + await stopSessionAppLog(session); await stopSessionApplePerfCapture(session); await stopSessionAndroidNativePerfCapture(session); await stopSessionAndroidSnapshotHelper(session); @@ -170,7 +112,7 @@ export async function handleCloseCommand(params: { // Always release the device lease and drop the session, even if teardown // above threw: a failed close must not strand device ownership until the // inactivity expiry. The original error still propagates after finally. - releaseSessionLease(session, leaseRegistry); + releaseSessionLease({ session, leaseRegistry }); sessionStore.delete(sessionName); } const shutdownResult = await maybeShutdownSessionTarget({ @@ -189,29 +131,6 @@ export async function handleCloseCommand(params: { return { ok: true, data: { session: session.name, ...successText(`Closed: ${session.name}`) } }; } -function releaseSessionLease(session: SessionState, leaseRegistry: LeaseRegistry): void { - const lease = session.lease; - if (!lease) return; - const result = leaseRegistry.releaseLease({ - leaseId: lease.leaseId, - tenantId: lease.tenantId, - runId: lease.runId, - backend: lease.leaseBackend, - leaseProvider: lease.leaseProvider, - deviceKey: lease.deviceKey, - clientId: lease.clientId, - }); - emitDiagnostic({ - level: 'info', - phase: 'session_lease_released', - data: { - session: session.name, - leaseId: lease.leaseId, - released: result.released, - }, - }); -} - function shouldDispatchPlatformClose(req: DaemonRequest, session: SessionState): boolean { return hasCloseTarget(req) || session.device.platform === 'web'; } diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index f389af250..0e33fa318 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -43,7 +43,7 @@ import { resolveImplicitSessionScope, resolvePublicSessionName, } from '../session-routing.ts'; -import { buildSessionLeaseFromRequest } from '../lease-context.ts'; +import { resolveSessionLeaseForRequest } from '../lease-lifecycle.ts'; const firstSessionOpenLocks = new Map>(); @@ -288,8 +288,10 @@ async function completeOpenCommand(params: { appName, saveScript: Boolean(req.flags?.saveScript), }); - nextSession.lease = - buildSessionLeaseFromRequest(req, req.internal?.admittedLease) ?? existingSession?.lease; + nextSession.lease = resolveSessionLeaseForRequest({ + req, + existingLease: existingSession?.lease, + }); if (req.runtime !== undefined) { setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtime); } @@ -359,8 +361,10 @@ async function prepareOpenDispatchSession(params: { appName, saveScript: Boolean(req.flags?.saveScript), }); - provisionalSession.lease = - buildSessionLeaseFromRequest(req, req.internal?.admittedLease) ?? existingSession?.lease; + provisionalSession.lease = resolveSessionLeaseForRequest({ + req, + existingLease: existingSession?.lease, + }); sessionStore.set(sessionName, provisionalSession); const lifecycleResponse = await beforeDispatch(provisionalSession); if (lifecycleResponse && !lifecycleResponse.ok) { diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts new file mode 100644 index 000000000..fdc5f039a --- /dev/null +++ b/src/daemon/lease-lifecycle.ts @@ -0,0 +1,119 @@ +import { emitDiagnostic } from '../utils/diagnostics.ts'; +import type { LeaseRegistry } from './lease-registry.ts'; +import { buildSessionLeaseFromRequest, type SessionLease } from './lease-context.ts'; +import { + assertRequestLeaseAdmission, + assertRequestLeaseAdmissionPreflight, +} from './request-admission.ts'; +import type { SessionStore } from './session-store.ts'; +import type { DaemonRequest, SessionState } from './types.ts'; + +export type SessionTeardown = (session: SessionState, sessionName: string) => Promise; + +export function assertLockedLeaseAdmissionPreflight(req: DaemonRequest): void { + assertRequestLeaseAdmissionPreflight(req); +} + +export async function cleanupExpiredLeasedSession(params: { + sessionName: string; + sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; + teardownSession: SessionTeardown; +}): Promise { + const session = params.sessionStore.get(params.sessionName); + const lease = session?.lease; + if (!session || !lease) return false; + const expiredLease = params.leaseRegistry.consumeExpiredLease(lease.leaseId); + if (!expiredLease) return false; + emitDiagnostic({ + level: 'info', + phase: 'leased_session_expired', + data: { + reason: 'LEASE_EXPIRED', + leaseId: lease.leaseId, + session: session.name, + deviceKey: lease.deviceKey, + }, + }); + await params.teardownSession(session, session.name).catch((error) => { + emitDiagnostic({ + level: 'debug', + phase: 'leased_session_expiry_cleanup_failed', + data: { + reason: 'LEASE_EXPIRED', + leaseId: lease.leaseId, + session: session.name, + error: error instanceof Error ? error.message : String(error), + }, + }); + }); + params.sessionStore.delete(session.name); + return true; +} + +export function admitRequestLeaseForLockedScope(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; +}): DaemonRequest { + const { sessionName, sessionStore, leaseRegistry } = params; + const existingSession = sessionStore.get(sessionName); + const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession); + if (!activeLease) return params.req; + + const nextReq = { + ...params.req, + internal: { + ...params.req.internal, + admittedLease: activeLease, + }, + }; + if (existingSession?.lease) { + sessionStore.set(sessionName, { + ...existingSession, + lease: { + ...existingSession.lease, + leaseBackend: activeLease.backend, + expiresAt: activeLease.expiresAt, + }, + }); + } + return nextReq; +} + +export function resolveSessionLeaseForRequest(params: { + req: Pick; + existingLease?: SessionLease; +}): SessionLease | undefined { + return ( + buildSessionLeaseFromRequest(params.req, params.req.internal?.admittedLease) ?? + params.existingLease + ); +} + +export function releaseSessionLease(params: { + session: SessionState; + leaseRegistry: LeaseRegistry; +}): void { + const lease = params.session.lease; + if (!lease) return; + const result = params.leaseRegistry.releaseLease({ + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.leaseBackend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }); + emitDiagnostic({ + level: 'info', + phase: 'session_lease_released', + data: { + session: params.session.name, + leaseId: lease.leaseId, + released: result.released, + }, + }); +} diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 5d1ce2f86..a9a4f53b0 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -10,11 +10,12 @@ import type { DaemonCommandContext } from './context.ts'; import { contextFromFlags as contextFromFlagsWithLog } from './context.ts'; import { assertSessionSelectorMatches } from './session-selector.ts'; import { resolveEffectiveSessionName } from './session-routing.ts'; +import { scopeRequestSession } from './request-admission.ts'; import { - assertRequestLeaseAdmission, - assertRequestLeaseAdmissionPreflight, - scopeRequestSession, -} from './request-admission.ts'; + admitRequestLeaseForLockedScope, + assertLockedLeaseAdmissionPreflight, + cleanupExpiredLeasedSession, +} from './lease-lifecycle.ts'; import { prepareLockedRequestBinding, resolveRequestExecutionLockKeys, @@ -35,7 +36,7 @@ import { type SessionStore, } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; -import { teardownSessionResources } from './handlers/session-close.ts'; +import { teardownSessionResources } from './session-teardown.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. @@ -108,7 +109,7 @@ export async function createRequestExecutionScope(params: { runnerLogPath, }, }); - assertRequestLeaseAdmissionPreflight(scopedReq); + assertLockedLeaseAdmissionPreflight(scopedReq); const executionLockKeys = shouldLockSessionExecution(command) ? await resolveRequestExecutionLockKeys({ req: scopedReq, sessionName, sessionStore }) : []; @@ -123,7 +124,12 @@ export async function createRequestExecutionScope(params: { throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), runAdmitted: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); - await cleanupExpiredLeasedSession({ sessionName, sessionStore, leaseRegistry }); + await cleanupExpiredLeasedSession({ + sessionName, + sessionStore, + leaseRegistry, + teardownSession: teardownSessionResources, + }); scopedReq = admitRequestLeaseForLockedScope({ req: scopedReq, sessionName, @@ -146,73 +152,6 @@ export async function createRequestExecutionScope(params: { return scope; } -function admitRequestLeaseForLockedScope(params: { - req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - leaseRegistry: LeaseRegistry; -}): DaemonRequest { - const { sessionName, sessionStore, leaseRegistry } = params; - const existingSession = sessionStore.get(sessionName); - const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession); - if (!activeLease) return params.req; - - const nextReq = { - ...params.req, - internal: { - ...params.req.internal, - admittedLease: activeLease, - }, - }; - if (existingSession?.lease) { - sessionStore.set(sessionName, { - ...existingSession, - lease: { - ...existingSession.lease, - leaseBackend: activeLease.backend, - expiresAt: activeLease.expiresAt, - }, - }); - } - return nextReq; -} - -async function cleanupExpiredLeasedSession(params: { - sessionName: string; - sessionStore: SessionStore; - leaseRegistry: LeaseRegistry; -}): Promise { - const session = params.sessionStore.get(params.sessionName); - const lease = session?.lease; - if (!session || !lease) return false; - const expiredLease = params.leaseRegistry.consumeExpiredLease(lease.leaseId); - if (!expiredLease) return false; - emitDiagnostic({ - level: 'info', - phase: 'leased_session_expired', - data: { - reason: 'LEASE_EXPIRED', - leaseId: lease.leaseId, - session: session.name, - deviceKey: lease.deviceKey, - }, - }); - await teardownSessionResources(session, session.name).catch((error) => { - emitDiagnostic({ - level: 'debug', - phase: 'leased_session_expiry_cleanup_failed', - data: { - reason: 'LEASE_EXPIRED', - leaseId: lease.leaseId, - session: session.name, - error: error instanceof Error ? error.message : String(error), - }, - }); - }); - params.sessionStore.delete(session.name); - return true; -} - async function withRequestExecutionLocks( locks: Map>, keys: RequestExecutionLockKey[], diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts new file mode 100644 index 000000000..25c35517f --- /dev/null +++ b/src/daemon/session-teardown.ts @@ -0,0 +1,71 @@ +import { emitDiagnostic } from '../utils/diagnostics.ts'; +import { isApplePlatform } from '../utils/device.ts'; +import { runMacOsAlertAction } from '../platforms/ios/macos-helper.ts'; +import { stopAppLog } from './app-log.ts'; +import { stopIosRunnerSession } from '../platforms/ios/runner-client.ts'; +import { cleanupAppleXctracePerfCapture } from '../platforms/ios/perf-xctrace.ts'; +import { cleanupAndroidNativePerfSession } from '../platforms/android/perf.ts'; +import { stopAndroidSnapshotHelperSessionForDevice } from '../platforms/android/snapshot-helper.ts'; +import { cleanupRetainedMaterializedPathsForSession } from './materialized-path-registry.ts'; +import type { SessionState } from './types.ts'; + +export async function stopAppleRunnerForClose(session: SessionState): Promise { + await stopIosRunnerSession(session.device.id); + if (session.device.platform !== 'macos') { + return; + } + + const dismissOptions = + session.surface === 'frontmost-app' + ? { surface: 'frontmost-app' as const } + : session.appBundleId + ? { bundleId: session.appBundleId } + : {}; + await runMacOsAlertAction('dismiss', dismissOptions).catch((error) => { + emitDiagnostic({ + level: 'debug', + phase: 'macos_close_alert_dismiss_failed', + data: { + session: session.name, + error: error instanceof Error ? error.message : String(error), + }, + }); + }); +} + +export async function stopSessionAppLog(session: SessionState): Promise { + if (!session.appLog) return; + await stopAppLog(session.appLog); +} + +export async function stopSessionApplePerfCapture(session: SessionState): Promise { + if (!session.applePerf?.active) return; + await cleanupAppleXctracePerfCapture(session.applePerf.active); + session.applePerf = { ...(session.applePerf ?? {}), active: undefined }; +} + +export async function stopSessionAndroidNativePerfCapture(session: SessionState): Promise { + const active = session.nativePerf?.android; + if (!active) return; + await cleanupAndroidNativePerfSession(session.device, active); + session.nativePerf = { ...(session.nativePerf ?? {}), android: undefined }; +} + +export async function stopSessionAndroidSnapshotHelper(session: SessionState): Promise { + if (session.device.platform !== 'android') return; + await stopAndroidSnapshotHelperSessionForDevice(session.device); +} + +export async function teardownSessionResources( + session: SessionState, + sessionName: string, +): Promise { + await stopSessionAppLog(session); + await stopSessionApplePerfCapture(session); + await stopSessionAndroidNativePerfCapture(session); + await stopSessionAndroidSnapshotHelper(session); + if (isApplePlatform(session.device.platform)) { + await stopAppleRunnerForClose(session); + } + await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); +} From 20b2d95acb7c28c288bf43dbf2caec84f7b34878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:56:21 +0200 Subject: [PATCH 09/16] refactor: centralize lease scope projection --- src/client-normalizers.ts | 53 +++---- src/client.ts | 8 +- src/core/__tests__/lease-scope.test.ts | 167 +++++++++++++++++++++ src/core/lease-scope.ts | 196 +++++++++++++++++++++++++ src/daemon-client-rpc.ts | 40 ++--- src/daemon/lease-context.ts | 80 ++-------- src/remote-connection-state.ts | 21 ++- 7 files changed, 422 insertions(+), 143 deletions(-) create mode 100644 src/core/__tests__/lease-scope.test.ts create mode 100644 src/core/lease-scope.ts diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index af477c1f0..e98f1361d 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -4,6 +4,11 @@ import type { DaemonRequest, SessionRuntimeHints } from './daemon/types.ts'; import { AppError, type NormalizedError } from './utils/errors.ts'; import type { SnapshotNode } from './utils/snapshot.ts'; import { buildAppIdentifiers, buildDeviceIdentifiers } from './client-shared.ts'; +import { + leaseScopeFromOptions, + leaseScopeToCommandFlags, + leaseScopeToRequestMeta, +} from './core/lease-scope.ts'; import type { AgentDeviceDevice, AgentDeviceSession, @@ -157,13 +162,12 @@ function buildClientDevicePlatformFields( } export function normalizeRuntimeHints(value: unknown): SessionRuntimeHints | undefined { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; - const platform = record.platform; - const metroHost = readOptionalString(record, 'metroHost'); - const metroPort = typeof record.metroPort === 'number' ? record.metroPort : undefined; - const bundleUrl = readOptionalString(record, 'bundleUrl'); - const launchUrl = readOptionalString(record, 'launchUrl'); + if (!isRecord(value)) return undefined; + const platform = value.platform; + const metroHost = readOptionalString(value, 'metroHost'); + const metroPort = typeof value.metroPort === 'number' ? value.metroPort : undefined; + const bundleUrl = readOptionalString(value, 'bundleUrl'); + const launchUrl = readOptionalString(value, 'launchUrl'); return { platform: platform === 'ios' || platform === 'android' ? platform : undefined, metroHost, @@ -211,21 +215,20 @@ export function normalizeOpenDevice( } export function normalizeStartupSample(value: unknown): StartupPerfSample | undefined { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; + if (!isRecord(value)) return undefined; if ( - typeof record.durationMs !== 'number' || - typeof record.measuredAt !== 'string' || - typeof record.method !== 'string' + typeof value.durationMs !== 'number' || + typeof value.measuredAt !== 'string' || + typeof value.method !== 'string' ) { return undefined; } return { - durationMs: record.durationMs, - measuredAt: record.measuredAt, - method: record.method, - appTarget: readOptionalString(record, 'appTarget'), - appBundleId: readOptionalString(record, 'appBundleId'), + durationMs: value.durationMs, + measuredAt: value.measuredAt, + method: value.method, + appTarget: readOptionalString(value, 'appTarget'), + appBundleId: readOptionalString(value, 'appBundleId'), }; } @@ -268,17 +271,15 @@ export function readSnapshotNodes(value: unknown): SnapshotNode[] { } export function buildFlags(options: InternalRequestOptions): CommandFlags { + const leaseScope = leaseScopeFromOptions(options); return stripUndefined({ stateDir: options.stateDir, daemonBaseUrl: options.daemonBaseUrl, daemonAuthToken: options.daemonAuthToken, daemonTransport: options.daemonTransport, daemonServerMode: options.daemonServerMode, - tenant: options.tenant, + ...leaseScopeToCommandFlags(leaseScope), sessionIsolation: options.sessionIsolation, - runId: options.runId, - leaseId: options.leaseId, - leaseBackend: options.leaseBackend, platform: options.platform, target: options.target, device: options.device, @@ -352,6 +353,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { } export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta'] { + const leaseScope = leaseScopeFromOptions(options); return stripUndefined({ requestId: options.requestId, cwd: options.cwd, @@ -359,14 +361,7 @@ export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta' debug: options.debug, lockPolicy: options.lockPolicy, lockPlatform: options.lockPlatform, - tenantId: options.tenant, - runId: options.runId, - leaseId: options.leaseId, - leaseBackend: options.leaseBackend, - leaseTtlMs: options.leaseTtlMs, - leaseProvider: options.leaseProvider, - clientId: options.clientId, - deviceKey: options.deviceKey, + ...leaseScopeToRequestMeta(leaseScope), sessionIsolation: options.sessionIsolation, installSource: options.installSource, retainMaterializedPaths: options.retainMaterializedPaths, diff --git a/src/client.ts b/src/client.ts index d9b020208..c333c1ea3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -215,16 +215,10 @@ export function createAgentDeviceClient( await execute(INTERNAL_COMMANDS.leaseAllocate, [], { ...options, leaseId: undefined, - leaseTtlMs: options.ttlMs, }), ), heartbeat: async (options) => - normalizeLease( - await execute(INTERNAL_COMMANDS.leaseHeartbeat, [], { - ...options, - leaseTtlMs: options.ttlMs, - }), - ), + normalizeLease(await execute(INTERNAL_COMMANDS.leaseHeartbeat, [], options)), release: async (options) => { const data = await execute(INTERNAL_COMMANDS.leaseRelease, [], options); return { released: data.released === true }; diff --git a/src/core/__tests__/lease-scope.test.ts b/src/core/__tests__/lease-scope.test.ts new file mode 100644 index 000000000..445134e45 --- /dev/null +++ b/src/core/__tests__/lease-scope.test.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + findMissingProxyLeaseFields, + leaseScopeFromOptions, + leaseScopeFromRequest, + leaseScopeToCommandFlags, + leaseScopeToConnectionMetadata, + leaseScopeToLeaseRpcParams, + leaseScopeToRequestMeta, +} from '../lease-scope.ts'; + +test('leaseScopeFromOptions normalizes public aliases and projects request meta', () => { + const scope = leaseScopeFromOptions({ + tenant: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + ttlMs: 120_000, + leaseBackend: 'ios-instance', + provider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + + assert.deepEqual(scope, { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseTtlMs: 120_000, + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + assert.deepEqual(leaseScopeToRequestMeta(scope), { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseTtlMs: 120_000, + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + assert.deepEqual(leaseScopeToCommandFlags(scope), { + tenant: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseBackend: 'ios-instance', + }); +}); + +test('leaseScopeFromRequest prefers metadata and falls back to legacy flags', () => { + assert.deepEqual( + leaseScopeFromRequest({ + meta: { + tenantId: 'tenant-meta', + leaseProvider: 'limrun', + }, + flags: { + tenant: 'tenant-flag', + runId: 'run-flag', + leaseId: 'lease-flag', + provider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }, + }), + { + tenantId: 'tenant-meta', + runId: 'run-flag', + leaseId: 'lease-flag', + leaseProvider: 'limrun', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }, + ); +}); + +test('leaseScopeToLeaseRpcParams preserves provider alias and command-specific fields', () => { + const scope = leaseScopeFromOptions({ + tenant: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseTtlMs: 60_000, + leaseBackend: 'android-instance', + leaseProvider: 'proxy', + deviceKey: 'android:emulator-5554', + clientId: 'client-a', + }); + + assert.deepEqual( + leaseScopeToLeaseRpcParams(scope, 'lease_allocate', { + includeTokenParam: true, + token: 'token', + session: 'default', + }), + { + token: 'token', + session: 'default', + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + provider: 'proxy', + clientId: 'client-a', + deviceKey: 'android:emulator-5554', + ttlMs: 60_000, + backend: 'android-instance', + }, + ); + assert.deepEqual( + leaseScopeToLeaseRpcParams(scope, 'lease_release', { + includeTokenParam: false, + token: 'token', + session: 'default', + }), + { + session: 'default', + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + provider: 'proxy', + clientId: 'client-a', + deviceKey: 'android:emulator-5554', + leaseId: 'lease-1', + }, + ); +}); + +test('leaseScopeToConnectionMetadata returns only connection lease fields', () => { + assert.deepEqual( + leaseScopeToConnectionMetadata( + leaseScopeFromOptions({ + tenant: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }), + ), + { + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }, + ); + assert.equal(leaseScopeToConnectionMetadata({}), undefined); +}); + +test('findMissingProxyLeaseFields enforces complete proxy ownership scope', () => { + assert.deepEqual( + findMissingProxyLeaseFields({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + }), + ['leaseId', 'deviceKey'], + ); + assert.deepEqual( + findMissingProxyLeaseFields({ + leaseProvider: 'limrun', + }), + [], + ); +}); diff --git a/src/core/lease-scope.ts b/src/core/lease-scope.ts new file mode 100644 index 000000000..cbc0a3240 --- /dev/null +++ b/src/core/lease-scope.ts @@ -0,0 +1,196 @@ +import type { LeaseBackend } from '../contracts.ts'; +import { stripUndefined } from '../utils/parsing.ts'; + +const PROXY_LEASE_PROVIDER = 'proxy'; +export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; + +const REQUIRED_PROXY_LEASE_FIELDS = [ + 'leaseId', + 'tenantId', + 'runId', + 'clientId', + 'deviceKey', +] as const satisfies readonly (keyof LeaseScope)[]; + +export type LeaseScope = { + tenantId?: string; + runId?: string; + leaseId?: string; + leaseTtlMs?: number; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +export type LeaseDiagnosticsContext = Omit; + +export type LeaseRpcCommand = 'lease_allocate' | 'lease_heartbeat' | 'lease_release'; + +type LeaseRequestLike = { + flags?: Record; + meta?: { + tenantId?: string; + runId?: string; + leaseId?: string; + leaseTtlMs?: number; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + }; +}; + +type LeaseOptionsLike = { + tenant?: string; + runId?: string; + leaseId?: string; + leaseTtlMs?: number; + ttlMs?: number; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + provider?: string; + deviceKey?: string; + clientId?: string; +}; + +export function leaseScopeFromRequest(req: LeaseRequestLike): LeaseScope { + return stripUndefined({ + tenantId: req.meta?.tenantId ?? readFlagString(req.flags, 'tenant'), + runId: req.meta?.runId ?? readFlagString(req.flags, 'runId'), + leaseId: req.meta?.leaseId ?? readFlagString(req.flags, 'leaseId'), + leaseTtlMs: req.meta?.leaseTtlMs, + leaseBackend: req.meta?.leaseBackend, + leaseProvider: + req.meta?.leaseProvider ?? + readFlagString(req.flags, 'leaseProvider') ?? + readFlagString(req.flags, 'provider'), + deviceKey: req.meta?.deviceKey ?? readFlagString(req.flags, 'deviceKey'), + clientId: req.meta?.clientId ?? readFlagString(req.flags, 'clientId'), + }); +} + +export function leaseScopeFromOptions(options: LeaseOptionsLike): LeaseScope { + return stripUndefined({ + tenantId: options.tenant, + runId: options.runId, + leaseId: options.leaseId, + leaseTtlMs: options.leaseTtlMs ?? options.ttlMs, + leaseBackend: options.leaseBackend, + leaseProvider: options.leaseProvider ?? options.provider, + deviceKey: options.deviceKey, + clientId: options.clientId, + }); +} + +export function leaseScopeToRequestMeta(scope: LeaseScope): LeaseRequestLike['meta'] { + return stripUndefined({ + tenantId: scope.tenantId, + runId: scope.runId, + leaseId: scope.leaseId, + leaseTtlMs: scope.leaseTtlMs, + leaseBackend: scope.leaseBackend, + leaseProvider: scope.leaseProvider, + deviceKey: scope.deviceKey, + clientId: scope.clientId, + }); +} + +export function leaseScopeToCommandFlags(scope: LeaseScope): Record { + return stripUndefined({ + tenant: scope.tenantId, + runId: scope.runId, + leaseId: scope.leaseId, + leaseBackend: scope.leaseBackend, + }); +} + +export function leaseScopeToLeaseRpcParams( + scope: LeaseScope, + command: LeaseRpcCommand, + options: { + includeTokenParam: boolean; + token?: string; + session?: string; + }, +): Record { + const common = stripUndefined({ + ...(options.includeTokenParam ? { token: options.token } : {}), + session: options.session, + tenantId: scope.tenantId, + runId: scope.runId, + leaseProvider: scope.leaseProvider, + provider: scope.leaseProvider, + clientId: scope.clientId, + deviceKey: scope.deviceKey, + }); + switch (command) { + case 'lease_allocate': + return { + ...common, + ...stripUndefined({ + ttlMs: scope.leaseTtlMs, + backend: scope.leaseBackend, + }), + }; + case 'lease_heartbeat': + return { + ...common, + ...stripUndefined({ + leaseId: scope.leaseId, + ttlMs: scope.leaseTtlMs, + }), + }; + case 'lease_release': + return { + ...common, + ...stripUndefined({ + leaseId: scope.leaseId, + }), + }; + } +} + +export function leaseScopeToConnectionMetadata( + scope: LeaseScope, +): Pick | undefined { + const connection = stripUndefined({ + leaseProvider: scope.leaseProvider, + deviceKey: scope.deviceKey, + clientId: scope.clientId, + }); + return Object.keys(connection).length > 0 ? connection : undefined; +} + +export function buildLeaseDiagnosticsContext( + leaseScope: LeaseScope | undefined, +): LeaseDiagnosticsContext | undefined { + if (!leaseScope) return undefined; + const context = stripUndefined({ + tenantId: leaseScope.tenantId, + runId: leaseScope.runId, + leaseId: leaseScope.leaseId, + leaseBackend: leaseScope.leaseBackend, + leaseProvider: leaseScope.leaseProvider, + deviceKey: leaseScope.deviceKey, + clientId: leaseScope.clientId, + }); + return Object.keys(context).length > 0 ? context : undefined; +} + +export function isProxyLeaseScope(scope: LeaseScope): boolean { + return scope.leaseProvider === PROXY_LEASE_PROVIDER; +} + +export function findMissingProxyLeaseFields(scope: LeaseScope): string[] { + if (!isProxyLeaseScope(scope)) return []; + return REQUIRED_PROXY_LEASE_FIELDS.filter((field) => !scope[field]); +} + +function readFlagString( + flags: Record | undefined, + key: string, +): string | undefined { + const value = flags?.[key]; + return typeof value === 'string' ? value : undefined; +} diff --git a/src/daemon-client-rpc.ts b/src/daemon-client-rpc.ts index 7cbaf3919..74bc4b435 100644 --- a/src/daemon-client-rpc.ts +++ b/src/daemon-client-rpc.ts @@ -3,6 +3,11 @@ import { createRequestId } from './utils/diagnostics.ts'; import type { DaemonRequest, DaemonResponse } from './daemon/types.ts'; import { materializeRemoteArtifacts } from './daemon-artifacts.ts'; import type { DaemonInfo } from './daemon-client-metadata.ts'; +import { + leaseScopeFromRequest, + leaseScopeToLeaseRpcParams, + type LeaseRpcCommand, +} from './core/lease-scope.ts'; export function handleDaemonHttpResponseBody( body: string, @@ -114,8 +119,6 @@ export function buildHttpRpcPayload( }; } -type LeaseRpcCommand = 'lease_allocate' | 'lease_heartbeat' | 'lease_release'; - function isLeaseRpcCommand(command: string): command is LeaseRpcCommand { return ( command === 'lease_allocate' || command === 'lease_heartbeat' || command === 'lease_release' @@ -133,39 +136,14 @@ function leaseRpcMethodForCommand(command: LeaseRpcCommand): string { } } -// fallow-ignore-next-line complexity function buildLeaseRpcParams( req: DaemonRequest, command: LeaseRpcCommand, options: { includeTokenParam: boolean }, ): Record { - const common = { - ...(options.includeTokenParam ? { token: req.token } : {}), + return leaseScopeToLeaseRpcParams(leaseScopeFromRequest(req), command, { + includeTokenParam: options.includeTokenParam, + token: req.token, session: req.session, - tenantId: req.meta?.tenantId, - runId: req.meta?.runId, - leaseProvider: req.meta?.leaseProvider, - provider: req.meta?.leaseProvider, - clientId: req.meta?.clientId, - deviceKey: req.meta?.deviceKey, - }; - switch (command) { - case 'lease_allocate': - return { - ...common, - ttlMs: req.meta?.leaseTtlMs, - backend: req.meta?.leaseBackend, - }; - case 'lease_heartbeat': - return { - ...common, - leaseId: req.meta?.leaseId, - ttlMs: req.meta?.leaseTtlMs, - }; - case 'lease_release': - return { - ...common, - leaseId: req.meta?.leaseId, - }; - } + }); } diff --git a/src/daemon/lease-context.ts b/src/daemon/lease-context.ts index 45f55e43e..52cadd8ae 100644 --- a/src/daemon/lease-context.ts +++ b/src/daemon/lease-context.ts @@ -3,27 +3,23 @@ import type { LeaseBackend } from '../contracts.ts'; import type { DeviceLease } from './lease-registry.ts'; import type { RunnerLogicalLeaseContext } from '../core/runner-lease-context.ts'; import { stripUndefined } from '../utils/parsing.ts'; +import { + DEFAULT_PROXY_LEASE_TTL_MS, + buildLeaseDiagnosticsContext, + findMissingProxyLeaseFields, + isProxyLeaseScope, + leaseScopeFromRequest, + type LeaseDiagnosticsContext, + type LeaseScope, +} from '../core/lease-scope.ts'; -const PROXY_LEASE_PROVIDER = 'proxy'; -export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; -const REQUIRED_PROXY_LEASE_FIELDS = [ - 'leaseId', - 'tenantId', - 'runId', - 'clientId', - 'deviceKey', -] as const satisfies readonly (keyof LeaseScope)[]; - -export type LeaseScope = { - tenantId?: string; - runId?: string; - leaseId?: string; - leaseTtlMs?: number; - leaseBackend?: LeaseBackend; - leaseProvider?: string; - deviceKey?: string; - clientId?: string; +export { + DEFAULT_PROXY_LEASE_TTL_MS, + buildLeaseDiagnosticsContext, + findMissingProxyLeaseFields, + isProxyLeaseScope, }; +export type { LeaseDiagnosticsContext, LeaseScope }; export type SessionLease = { tenantId: string; @@ -36,27 +32,13 @@ export type SessionLease = { expiresAt?: number; }; -export type LeaseDiagnosticsContext = Omit; - type SessionLeaseSource = { lease?: SessionLease | null; deviceLease?: SessionLease | null; }; export function resolveLeaseScope(req: Pick): LeaseScope { - return { - tenantId: req.meta?.tenantId ?? req.flags?.tenant, - runId: req.meta?.runId ?? req.flags?.runId, - leaseId: req.meta?.leaseId ?? req.flags?.leaseId, - leaseTtlMs: req.meta?.leaseTtlMs, - leaseBackend: req.meta?.leaseBackend, - leaseProvider: - req.meta?.leaseProvider ?? - readFlagString(req.flags, 'leaseProvider') ?? - readFlagString(req.flags, 'provider'), - deviceKey: req.meta?.deviceKey ?? readFlagString(req.flags, 'deviceKey'), - clientId: req.meta?.clientId ?? readFlagString(req.flags, 'clientId'), - }; + return leaseScopeFromRequest(req); } export function buildSessionLeaseFromRequest( @@ -100,22 +82,6 @@ export function resolveRequestOrSessionLeaseScope( }); } -export function buildLeaseDiagnosticsContext( - leaseScope: LeaseScope | SessionLease | undefined, -): LeaseDiagnosticsContext | undefined { - if (!leaseScope) return undefined; - const context = stripUndefined({ - tenantId: leaseScope.tenantId, - runId: leaseScope.runId, - leaseId: leaseScope.leaseId, - leaseBackend: leaseScope.leaseBackend, - leaseProvider: leaseScope.leaseProvider, - deviceKey: leaseScope.deviceKey, - clientId: leaseScope.clientId, - }); - return Object.keys(context).length > 0 ? context : undefined; -} - export function resolveRunnerLogicalLeaseContext( req: Pick, ): RunnerLogicalLeaseContext | undefined { @@ -132,20 +98,6 @@ export function resolveRunnerLogicalLeaseContext( return Object.keys(context).length > 0 ? context : undefined; } -export function isProxyLeaseScope(scope: LeaseScope | SessionLease): boolean { - return scope.leaseProvider === PROXY_LEASE_PROVIDER; -} - -export function findMissingProxyLeaseFields(scope: LeaseScope): string[] { - if (!isProxyLeaseScope(scope)) return []; - return REQUIRED_PROXY_LEASE_FIELDS.filter((field) => !scope[field]); -} - -function readFlagString(flags: object | undefined, key: string): string | undefined { - const value = (flags as Record | undefined)?.[key]; - return typeof value === 'string' ? value : undefined; -} - function readNonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; } diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index 74c1dbbe3..4d92db716 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -6,7 +6,11 @@ import { AppError } from './utils/errors.ts'; import { emitDiagnostic } from './utils/diagnostics.ts'; import type { CliFlags } from './utils/cli-flags.ts'; import type { LeaseBackend, SessionRuntimeHints } from './contracts.ts'; -import { stripUndefined } from './utils/parsing.ts'; +import { + leaseScopeFromOptions, + leaseScopeToCommandFlags, + leaseScopeToConnectionMetadata, +} from './core/lease-scope.ts'; export type RemoteConnectionState = { version: 1; @@ -137,20 +141,18 @@ export function resolveRemoteConnectionDefaults(options: { ); } const profile = resolveConnectionProfile(state, options); + const leaseScope = leaseScopeFromOptions(state); return { runtime: state.runtime, - connection: buildRemoteConnectionRequestMetadata(state), + connection: leaseScopeToConnectionMetadata(leaseScope), flags: { ...profile, remoteConfig: state.remoteConfigPath, daemonBaseUrl: state.daemon?.baseUrl ?? profile.daemonBaseUrl, daemonTransport: state.daemon?.transport ?? profile.daemonTransport, daemonServerMode: state.daemon?.serverMode ?? profile.daemonServerMode, - tenant: state.tenant, + ...leaseScopeToCommandFlags(leaseScope), sessionIsolation: 'tenant', - runId: state.runId, - leaseId: state.leaseId, - leaseBackend: state.leaseBackend, session: state.session, platform: state.platform ?? profile.platform, target: state.target ?? profile.target, @@ -161,12 +163,7 @@ export function resolveRemoteConnectionDefaults(options: { export function buildRemoteConnectionRequestMetadata( state: RemoteConnectionState, ): RemoteConnectionRequestMetadata | undefined { - const connection = stripUndefined({ - leaseProvider: state.leaseProvider, - deviceKey: state.deviceKey, - clientId: state.clientId, - }); - return Object.keys(connection).length > 0 ? connection : undefined; + return leaseScopeToConnectionMetadata(leaseScopeFromOptions(state)); } export function hashRemoteConfigFile(configPath: string): string { From 88f079d566b1a3c17c9a73357f4dacc05d989996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 17:11:08 +0200 Subject: [PATCH 10/16] fix: harden proxy lease e2e flow --- src/__tests__/remote-connection.test.ts | 45 ++++++++++++++++++- src/cli/commands/connection-runtime.ts | 1 + src/cli/commands/connection.ts | 22 ++++++++- .../ios/__tests__/runner-session.test.ts | 31 +++++++++++++ src/platforms/ios/runner-lease.ts | 30 ++++++++++++- src/remote-connection-state.ts | 21 ++++++++- 6 files changed, 144 insertions(+), 6 deletions(-) diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 0fb4919e0..09b3ed081 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -230,7 +230,11 @@ test('connect proxy writes normal remote state with generated non-secret profile assert.match(state.clientId ?? '', /^[a-f0-9]{16}$/); assert.equal(state.leaseBackend, 'android-instance'); assert.equal(state.leaseId, undefined); - assert.equal(state.daemon?.baseUrl, 'http://proxy.example.test/agent-device'); + assert.deepEqual(state.daemon, { + baseUrl: 'http://proxy.example.test/agent-device', + authToken: 'proxy-secret', + transport: 'http', + }); assert.match(state.remoteConfigPath, /remote-connections\/generated\/proxy-[a-f0-9]{16}\.json$/); const generated = JSON.parse(fs.readFileSync(state.remoteConfigPath, 'utf8')) as Record< string, @@ -246,6 +250,39 @@ test('connect proxy writes normal remote state with generated non-secret profile fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('connect daemon-base-url shortcut uses proxy profile for direct proxy URLs', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-shortcut-')); + const stateDir = path.join(tempRoot, '.state'); + + await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + daemonBaseUrl: 'http://127.0.0.1:4310/agent-device', + daemonAuthToken: 'proxy-secret', + }, + client: createTestClient(), + }); + }); + + const state = readActiveConnectionState({ stateDir }); + assert.ok(state); + assert.equal(state.tenant, 'proxy'); + assert.equal(state.leaseProvider, 'proxy'); + assert.match(state.clientId ?? '', /^[a-f0-9]{16}$/); + assert.deepEqual(state.daemon, { + baseUrl: 'http://127.0.0.1:4310/agent-device', + authToken: 'proxy-secret', + transport: 'http', + }); + assert.equal(state.leaseId, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('connect proxy scopes generated client identity by explicit session', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-sessions-')); const stateDir = path.join(tempRoot, '.state'); @@ -1815,6 +1852,10 @@ test('disconnect releases proxy lease with provider client and device metadata', tenant: 'proxy', runId: 'proxy-client-1', leaseId: 'abc123abc123abc1', + daemon: { + baseUrl: 'http://proxy.example.test/agent-device', + authToken: 'proxy-secret', + }, leaseBackend: 'ios-instance', leaseProvider: 'proxy', clientId: 'client-1', @@ -1848,6 +1889,8 @@ test('disconnect releases proxy lease with provider client and device metadata', assert.equal(releaseRequest?.clientId, 'client-1'); assert.equal(releaseRequest?.deviceKey, 'ios:mobile:SIM-001'); assert.equal(releaseRequest?.leaseId, 'abc123abc123abc1'); + assert.equal(releaseRequest?.daemonBaseUrl, 'http://proxy.example.test/agent-device'); + assert.equal(releaseRequest?.daemonAuthToken, 'proxy-secret'); assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' }), null); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 51d634fa8..4e413f0b7 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -304,6 +304,7 @@ export async function releasePreviousLease( runId: previous.runId, leaseId: previous.leaseId, daemonBaseUrl: previous.daemon?.baseUrl, + daemonAuthToken: previous.daemon?.authToken, daemonTransport: previous.daemon?.transport, daemonServerMode: previous.daemon?.serverMode, leaseProvider: previous.leaseProvider, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 9e578cb86..65adee8da 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -39,7 +39,7 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, } const resolved = flags.remoteConfig ? resolveRemoteConnectFlags(flags) - : provider === 'proxy' + : provider === 'proxy' || shouldUseProxyConnectShortcut(flags) ? resolveProxyConnectProfile({ flags, stateDir, @@ -212,6 +212,10 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) tenant: state.tenant, runId: state.runId, leaseId: state.leaseId, + daemonBaseUrl: state.daemon?.baseUrl, + daemonAuthToken: state.daemon?.authToken, + daemonTransport: state.daemon?.transport, + daemonServerMode: state.daemon?.serverMode, leaseProvider: state.leaseProvider, clientId: state.clientId, deviceKey: state.deviceKey, @@ -279,6 +283,22 @@ function readConnectProvider(positionals: string[]): 'proxy' | undefined { ); } +function shouldUseProxyConnectShortcut(flags: CliFlags): boolean { + if (!flags.daemonBaseUrl || flags.tenant || flags.runId || flags.leaseId || flags.leaseBackend) { + return false; + } + return isAgentDeviceProxyBaseUrl(flags.daemonBaseUrl); +} + +function isAgentDeviceProxyBaseUrl(value: string): boolean { + try { + const url = new URL(value); + return url.pathname.replace(/\/+$/, '').endsWith('/agent-device'); + } catch { + return false; + } +} + function readRequestedConnectionState(flags: CliFlags): { session: string; stateDir: string; diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index 8b44320a8..14e6ff3eb 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -778,6 +778,37 @@ test('runner session busy error includes logical lease context after admission', assert.equal(mockRunCmdBackground.mock.calls.length, 0); }); +test('runner session startup reclaims live foreign runner lease after proxy lease admission', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-proxy-takeover-sim' }; + writeRunnerLease( + makeRunnerLease({ + deviceId: device.id, + ownerToken: 'owner-foreign-proxy-live', + ownerPid: process.pid, + ownerStartTime: RUNNER_OWNER_START_TIME, + ownerStateDir: '/tmp/agent-device-owner', + runnerPid: 4_321, + }), + ); + + const session = await ensureRunnerSession(device, { + runnerLeaseContext: { + tenantId: 'proxy', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: `ios:mobile:${device.id}`, + }, + }); + + assert.equal(session.deviceId, device.id); + assert.equal(mockRunCmdBackground.mock.calls.length, 1); + const pkillCalls = mockRunAppleToolCommand.mock.calls.filter(isXcodebuildPkillCall); + assert.ok(pkillCalls.length >= 2); + assert.match(String(pkillCalls[0]?.[1]?.[2] ?? ''), /owner-foreign-proxy-live/); +}); + test('runner session startup reclaims live foreign runner lease from same state dir', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-same-state-lease-sim' }; const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR; diff --git a/src/platforms/ios/runner-lease.ts b/src/platforms/ios/runner-lease.ts index 9d37dbd60..86e052eb8 100644 --- a/src/platforms/ios/runner-lease.ts +++ b/src/platforms/ios/runner-lease.ts @@ -129,6 +129,10 @@ export async function prepareRunnerLeaseForStartup( await cleanupLeasedRunnerProcesses(state.lease, 'same-state-dir', cleanup); return; } + if (canLogicalLeaseReclaimRunner(state.lease, logicalLeaseContext)) { + await cleanupLeasedRunnerProcesses(state.lease, 'logical-lease-takeover', cleanup); + return; + } throw new AppError( 'COMMAND_FAILED', logicalLeaseContext @@ -156,6 +160,25 @@ function isSameStateDirRunnerLease(lease: RunnerLease): boolean { return path.resolve(currentStateDir) === path.resolve(lease.ownerStateDir); } +function canLogicalLeaseReclaimRunner( + lease: RunnerLease, + logicalLeaseContext: RunnerLogicalLeaseContext | undefined, +): boolean { + if (!logicalLeaseContext || logicalLeaseContext.leaseProvider !== 'proxy') return false; + if (!logicalLeaseContext.leaseId || !logicalLeaseContext.clientId) return false; + return logicalLeaseContextMatchesDevice(logicalLeaseContext.deviceKey, lease.deviceId); +} + +function logicalLeaseContextMatchesDevice( + logicalDeviceKey: string | undefined, + runnerDeviceId: string, +): boolean { + if (!logicalDeviceKey) return false; + if (logicalDeviceKey === runnerDeviceId) return true; + const [, , canonicalDeviceId] = logicalDeviceKey.split(':', 3); + return canonicalDeviceId === runnerDeviceId; +} + function readCurrentStateDir(): string | undefined { if (runnerLeaseOwnerStateDir) return runnerLeaseOwnerStateDir; return process.env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; @@ -341,11 +364,14 @@ function isRunnerLeaseOwnerAlive(lease: RunnerLease): boolean { async function cleanupLeasedRunnerProcesses( lease: RunnerLease, - reason: 'owned' | 'stale' | 'same-state-dir', + reason: 'owned' | 'stale' | 'same-state-dir' | 'logical-lease-takeover', cleanup: RunnerLeaseCleanupAdapter, ): Promise { emitDiagnostic({ - level: reason === 'stale' || reason === 'same-state-dir' ? 'warn' : 'debug', + level: + reason === 'stale' || reason === 'same-state-dir' || reason === 'logical-lease-takeover' + ? 'warn' + : 'debug', phase: 'ios_runner_lease_cleanup', data: { deviceId: lease.deviceId, diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index 4d92db716..d348b9640 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -19,6 +19,7 @@ export type RemoteConnectionState = { remoteConfigHash: string; daemon?: { baseUrl?: string; + authToken?: string; transport?: CliFlags['daemonTransport']; serverMode?: CliFlags['daemonServerMode']; }; @@ -86,10 +87,14 @@ export function writeRemoteConnectionState(options: { } export function buildRemoteConnectionDaemonState( - flags: Pick, + flags: Pick< + CliFlags, + 'daemonBaseUrl' | 'daemonAuthToken' | 'daemonTransport' | 'daemonServerMode' + >, ): RemoteConnectionState['daemon'] { return { baseUrl: sanitizeDaemonBaseUrl(flags.daemonBaseUrl), + authToken: flags.daemonAuthToken, transport: flags.daemonTransport, serverMode: flags.daemonServerMode, }; @@ -149,6 +154,7 @@ export function resolveRemoteConnectionDefaults(options: { ...profile, remoteConfig: state.remoteConfigPath, daemonBaseUrl: state.daemon?.baseUrl ?? profile.daemonBaseUrl, + daemonAuthToken: state.daemon?.authToken ?? profile.daemonAuthToken, daemonTransport: state.daemon?.transport ?? profile.daemonTransport, daemonServerMode: state.daemon?.serverMode ?? profile.daemonServerMode, ...leaseScopeToCommandFlags(leaseScope), @@ -295,7 +301,8 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState (record.daemon === undefined || (typeof record.daemon === 'object' && record.daemon !== null && - !Array.isArray(record.daemon))) && + !Array.isArray(record.daemon) && + isRemoteConnectionDaemonState(record.daemon))) && typeof record.tenant === 'string' && typeof record.runId === 'string' && (record.leaseId === undefined || typeof record.leaseId === 'string') && @@ -307,3 +314,13 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState typeof record.updatedAt === 'string' ); } + +function isRemoteConnectionDaemonState(value: object): boolean { + const record = value as Record; + return ( + (record.baseUrl === undefined || typeof record.baseUrl === 'string') && + (record.authToken === undefined || typeof record.authToken === 'string') && + (record.transport === undefined || typeof record.transport === 'string') && + (record.serverMode === undefined || typeof record.serverMode === 'string') + ); +} From ced48a7027fce8aef6ca870631bbcf683c565232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 17:40:08 +0200 Subject: [PATCH 11/16] fix: address lease review feedback --- src/__tests__/remote-connection.test.ts | 13 +- src/cli/commands/connection-runtime.ts | 402 +++++++++++++----- src/cli/commands/connection.ts | 331 +++++++++----- src/client-types.ts | 1 - src/client.ts | 1 - src/core/__tests__/lease-scope.test.ts | 4 +- src/core/lease-scope.ts | 58 ++- src/daemon/__tests__/lease-context.test.ts | 18 + src/daemon/__tests__/lease-lifecycle.test.ts | 4 +- src/daemon/__tests__/lease-registry.test.ts | 19 +- .../__tests__/request-execution-scope.test.ts | 2 +- src/daemon/handlers/lease.ts | 36 +- src/daemon/handlers/session-open.ts | 1 - src/daemon/lease-context.ts | 3 +- src/daemon/lease-lifecycle.ts | 21 +- src/daemon/lease-registry.ts | 128 +++--- src/daemon/request-admission.ts | 26 +- src/remote-connection-state.ts | 49 ++- 18 files changed, 719 insertions(+), 398 deletions(-) diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 09b3ed081..efae4d563 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -679,7 +679,6 @@ test('proxy open resolves device key before allocating lease', async () => { runId: request.runId, backend: request.leaseBackend ?? 'ios-instance', leaseProvider: request.leaseProvider, - provider: request.leaseProvider, clientId: request.clientId, deviceKey: request.deviceKey, }; @@ -1435,6 +1434,7 @@ test('deferred materialization stops the new Metro companion if state persistenc const stateDir = path.join(tempRoot, '.state'); const remoteConfigPath = path.join(tempRoot, 'remote.json'); fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + let releaseRequest: Parameters[0] | undefined; writeRemoteConnectionState({ stateDir, state: { @@ -1487,7 +1487,12 @@ test('deferred materialization stops the new Metro companion if state persistenc metroPublicBaseUrl: 'https://sandbox.example.test', metroProxyBaseUrl: 'https://proxy.example.test', }, - client: createTestClient(), + client: createTestClient({ + release: async (request) => { + releaseRequest = request; + return { released: true }; + }, + }), }), writeFailure, ); @@ -1498,6 +1503,10 @@ test('deferred materialization stops the new Metro companion if state persistenc profileKey: remoteConfigPath, consumerKey: 'adc-android', }); + assert.equal(releaseRequest?.leaseId, 'lease-1'); + assert.equal(releaseRequest?.tenant, 'acme'); + assert.equal(releaseRequest?.runId, 'run-123'); + assert.equal(releaseRequest?.leaseBackend, 'android-instance'); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 4e413f0b7..85a10f36c 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -33,7 +33,6 @@ const leaseDeferredCommands = new Set([ const runtimeDeferredCommands = new Set(['open']); export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; -// fallow-ignore-next-line complexity export async function materializeRemoteConnectionForCommand(options: { command: string; flags: CliFlags; @@ -91,110 +90,46 @@ export async function materializeRemoteConnectionForCommand(options: { let nextRuntime = selectCompatibleRuntime(state.runtime, nextFlags.platform) ?? options.runtime; let nextState = state; let changed = !existingState; - let metroCleanupToStop: RemoteConnectionState['metro'] | undefined; - let preparedMetroCleanupOnFailure: RemoteConnectionState['metro'] | undefined; - - if (shouldAllocateLeaseForCommand(command, nextState)) { - const preliminaryLeaseBackend = state.leaseBackend ?? resolveRequestedLeaseBackend(nextFlags); - if (nextState.leaseProvider === 'proxy') { - const resolvedProxyLease = await resolveProxyLeaseState({ - command, - client, - state: nextState, - flags: nextFlags, - leaseBackend: preliminaryLeaseBackend, - }); - nextState = resolvedProxyLease.state; - if (resolvedProxyLease.device) { - applyResolvedDeviceSelector(nextFlags, resolvedProxyLease.device); - } - } - const leaseBackend = - nextState.leaseBackend ?? - preliminaryLeaseBackend ?? - requireRequestedLeaseBackend(nextFlags, command); - assertRequestedConnectionScope(state, nextFlags, leaseBackend); - const lease = await allocateOrReuseLease(client, nextState, leaseBackend); - nextFlags.leaseId = lease.leaseId; - nextFlags.leaseBackend = leaseBackend; - nextFlags.platform = nextState.platform ?? nextFlags.platform; - nextFlags.target = nextState.target ?? nextFlags.target; - if ( - nextState.leaseId !== lease.leaseId || - nextState.leaseBackend !== leaseBackend || - nextState.deviceKey !== (lease.deviceKey ?? nextState.deviceKey) - ) { - nextState = { - ...nextState, - leaseId: lease.leaseId, - leaseBackend, - leaseProvider: lease.leaseProvider ?? lease.provider ?? nextState.leaseProvider, - clientId: lease.clientId ?? nextState.clientId, - deviceKey: lease.deviceKey ?? nextState.deviceKey, - platform: nextState.platform ?? flags.platform, - target: nextState.target ?? flags.target, - updatedAt: new Date().toISOString(), - }; - changed = true; - } - } - - if ( - shouldPrepareRuntimeForCommand(command, nextFlags, options.batchSteps, options.positionals) && - hasDeferredMetroConfig(nextFlags) - ) { - if (!nextState.leaseId && nextFlags.leaseId) { - nextState = { - ...nextState, - leaseId: nextFlags.leaseId, - leaseBackend: nextFlags.leaseBackend, - }; - } - const requiresPreparedRuntime = - options.forceRuntimePrepare || - !nextRuntime || - !isRuntimeCompatibleWithPlatform(nextRuntime, nextFlags.platform); - if (requiresPreparedRuntime) { - if (!nextState.leaseId) { - throw new AppError( - 'INVALID_ARGS', - `${command} requires a resolved remote lease before Metro runtime can be prepared.`, - ); - } - const prepared = await prepareConnectedMetro( - nextFlags, - client, - state.remoteConfigPath, - state.session, - { - tenantId: state.tenant, - runId: state.runId, - leaseId: nextState.leaseId, - }, - ); - nextRuntime = prepared.runtime; - const replacesExistingMetroCleanup = !isSameMetroCleanup(nextState.metro, prepared.cleanup); - metroCleanupToStop = replacesExistingMetroCleanup ? nextState.metro : undefined; - preparedMetroCleanupOnFailure = replacesExistingMetroCleanup ? prepared.cleanup : undefined; - nextState = { - ...nextState, - runtime: prepared.runtime, - metro: prepared.cleanup, - updatedAt: new Date().toISOString(), - }; - changed = true; - } + let acquiredLeaseForCleanup: Lease | undefined; + + const leasePolicy = connectionLeasePolicyForState(nextState); + if (leasePolicy.shouldAllocate(command)) { + const materializedLease = await materializeLeaseForCommand({ + command, + client, + state, + nextState, + nextFlags, + policy: leasePolicy, + }); + nextState = materializedLease.state; + changed = changed || materializedLease.changed; + acquiredLeaseForCleanup = materializedLease.acquiredLeaseForCleanup; } - if (changed) { - try { - writeRemoteConnectionState({ stateDir, state: nextState }); - } catch (error) { - await stopMetroCleanup(preparedMetroCleanupOnFailure); - throw error; - } - } - await stopMetroCleanup(metroCleanupToStop); + const runtimePreparation = await prepareRuntimeForCommand({ + command, + flags: nextFlags, + client, + state, + nextState, + runtime: nextRuntime, + positionals: options.positionals, + batchSteps: options.batchSteps, + forceRuntimePrepare: options.forceRuntimePrepare, + }); + nextState = runtimePreparation.state; + nextRuntime = runtimePreparation.runtime; + changed = changed || runtimePreparation.changed; + await persistMaterializedConnection({ + changed, + stateDir, + state: nextState, + client, + acquiredLeaseForCleanup, + preparedMetroCleanupOnFailure: runtimePreparation.preparedMetroCleanupOnFailure, + metroCleanupToStop: runtimePreparation.metroCleanupToStop, + }); return { flags: { @@ -210,6 +145,225 @@ export async function materializeRemoteConnectionForCommand(options: { }; } +async function prepareRuntimeForCommand(options: { + command: string; + flags: CliFlags; + client: AgentDeviceClient; + state: RemoteConnectionState; + nextState: RemoteConnectionState; + runtime?: SessionRuntimeHints; + positionals?: string[]; + batchSteps?: BatchStep[]; + forceRuntimePrepare?: boolean; +}): Promise<{ + state: RemoteConnectionState; + runtime?: SessionRuntimeHints; + changed: boolean; + metroCleanupToStop?: RemoteConnectionState['metro']; + preparedMetroCleanupOnFailure?: RemoteConnectionState['metro']; +}> { + const { command, flags, state, client } = options; + let nextState = ensureRuntimeLeaseState(options.nextState, flags); + const nextRuntime = options.runtime; + if ( + !shouldPrepareRuntimeForCommand(command, flags, options.batchSteps, options.positionals) || + !hasDeferredMetroConfig(flags) || + !shouldPrepareRuntime(options.forceRuntimePrepare, nextRuntime, flags.platform) + ) { + return { state: nextState, runtime: nextRuntime, changed: false }; + } + if (!nextState.leaseId) { + throw new AppError( + 'INVALID_ARGS', + `${command} requires a resolved remote lease before Metro runtime can be prepared.`, + ); + } + const prepared = await prepareConnectedMetro( + flags, + client, + state.remoteConfigPath, + state.session, + { + tenantId: state.tenant, + runId: state.runId, + leaseId: nextState.leaseId, + }, + ); + const replacesExistingMetroCleanup = !isSameMetroCleanup(nextState.metro, prepared.cleanup); + nextState = { + ...nextState, + runtime: prepared.runtime, + metro: prepared.cleanup, + updatedAt: new Date().toISOString(), + }; + return { + state: nextState, + runtime: prepared.runtime, + changed: true, + metroCleanupToStop: replacesExistingMetroCleanup ? options.nextState.metro : undefined, + preparedMetroCleanupOnFailure: replacesExistingMetroCleanup ? prepared.cleanup : undefined, + }; +} + +function ensureRuntimeLeaseState( + state: RemoteConnectionState, + flags: CliFlags, +): RemoteConnectionState { + if (state.leaseId || !flags.leaseId) return state; + return { + ...state, + leaseId: flags.leaseId, + leaseBackend: flags.leaseBackend, + }; +} + +function shouldPrepareRuntime( + forceRuntimePrepare: boolean | undefined, + runtime: SessionRuntimeHints | undefined, + platform: CliFlags['platform'], +): boolean { + return ( + forceRuntimePrepare === true || !runtime || !isRuntimeCompatibleWithPlatform(runtime, platform) + ); +} + +async function persistMaterializedConnection(options: { + changed: boolean; + stateDir: string; + state: RemoteConnectionState; + client: AgentDeviceClient; + acquiredLeaseForCleanup?: Lease; + preparedMetroCleanupOnFailure?: RemoteConnectionState['metro']; + metroCleanupToStop?: RemoteConnectionState['metro']; +}): Promise { + if (options.changed) { + try { + writeRemoteConnectionState({ stateDir: options.stateDir, state: options.state }); + } catch (error) { + await stopMetroCleanup(options.preparedMetroCleanupOnFailure); + await releaseAcquiredLeaseOnWriteFailure( + options.client, + options.state, + options.acquiredLeaseForCleanup, + ); + throw error; + } + } + await stopMetroCleanup(options.metroCleanupToStop); +} + +async function materializeLeaseForCommand(options: { + command: string; + client: AgentDeviceClient; + state: RemoteConnectionState; + nextState: RemoteConnectionState; + nextFlags: CliFlags; + policy: ConnectionLeasePolicy; +}): Promise<{ + state: RemoteConnectionState; + changed: boolean; + acquiredLeaseForCleanup?: Lease; +}> { + const { command, client, state, nextFlags, policy } = options; + const preliminaryLeaseBackend = state.leaseBackend ?? resolveRequestedLeaseBackend(nextFlags); + let nextState = options.nextState; + const resolvedLeaseState = await policy.resolveLeaseState({ + command, + client, + state: nextState, + flags: nextFlags, + leaseBackend: preliminaryLeaseBackend, + }); + nextState = resolvedLeaseState.state; + if (resolvedLeaseState.device) { + applyResolvedDeviceSelector(nextFlags, resolvedLeaseState.device); + } + const leaseBackend = + nextState.leaseBackend ?? + preliminaryLeaseBackend ?? + requireRequestedLeaseBackend(nextFlags, command); + assertRequestedConnectionScope(state, nextFlags, leaseBackend); + const materializedLease = await allocateOrReuseLease(client, nextState, leaseBackend, policy); + const lease = materializedLease.lease; + nextFlags.leaseId = lease.leaseId; + nextFlags.leaseBackend = leaseBackend; + nextFlags.platform = nextState.platform ?? nextFlags.platform; + nextFlags.target = nextState.target ?? nextFlags.target; + if (leaseStateMatches(nextState, lease, leaseBackend)) { + return { + state: nextState, + changed: false, + acquiredLeaseForCleanup: materializedLease.acquired ? lease : undefined, + }; + } + return { + state: buildMaterializedLeaseState(nextState, lease, leaseBackend, nextFlags), + changed: true, + acquiredLeaseForCleanup: materializedLease.acquired ? lease : undefined, + }; +} + +function leaseStateMatches( + state: RemoteConnectionState, + lease: Lease, + leaseBackend: LeaseBackend, +): boolean { + return ( + state.leaseId === lease.leaseId && + state.leaseBackend === leaseBackend && + state.deviceKey === (lease.deviceKey ?? state.deviceKey) + ); +} + +function buildMaterializedLeaseState( + state: RemoteConnectionState, + lease: Lease, + leaseBackend: LeaseBackend, + flags: CliFlags, +): RemoteConnectionState { + return { + ...state, + leaseId: lease.leaseId, + leaseBackend, + leaseProvider: lease.leaseProvider ?? state.leaseProvider, + clientId: lease.clientId ?? state.clientId, + deviceKey: lease.deviceKey ?? state.deviceKey, + platform: state.platform ?? flags.platform, + target: state.target ?? flags.target, + updatedAt: new Date().toISOString(), + }; +} + +type ConnectionLeasePolicy = { + shouldAllocate(command: string): boolean; + ttlMs(state: RemoteConnectionState): number | undefined; + resolveLeaseState(options: { + command: string; + client: AgentDeviceClient; + state: RemoteConnectionState; + flags: CliFlags; + leaseBackend?: LeaseBackend; + }): Promise<{ state: RemoteConnectionState; device?: DeviceInfo }>; +}; + +function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { + return state.leaseProvider === 'proxy' + ? PROXY_CONNECTION_LEASE_POLICY + : DEFAULT_CONNECTION_LEASE_POLICY; +} + +const DEFAULT_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { + shouldAllocate: (command) => !leaseDeferredCommands.has(command), + ttlMs: () => undefined, + resolveLeaseState: async (options) => ({ state: options.state }), +}; + +const PROXY_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { + shouldAllocate: (command) => command !== 'devices' && !leaseDeferredCommands.has(command), + ttlMs: () => PROXY_REMOTE_LEASE_TTL_MS, + resolveLeaseState: resolveProxyLeaseState, +}; + async function prepareConnectedMetro( flags: CliFlags, client: AgentDeviceClient, @@ -316,6 +470,27 @@ export async function releasePreviousLease( } } +async function releaseAcquiredLeaseOnWriteFailure( + client: AgentDeviceClient, + state: RemoteConnectionState, + lease: Lease | undefined, +): Promise { + if (!lease) return; + try { + await client.leases.release({ + tenant: state.tenant, + runId: state.runId, + leaseId: lease.leaseId, + leaseBackend: state.leaseBackend ?? lease.backend, + leaseProvider: state.leaseProvider ?? lease.leaseProvider, + clientId: state.clientId ?? lease.clientId, + deviceKey: state.deviceKey ?? lease.deviceKey, + }); + } catch { + // Preserve the state-write failure; cleanup is best-effort. + } +} + export function resolveRequestedLeaseBackend(flags: CliFlags): LeaseBackend | undefined { if (flags.leaseBackend) return flags.leaseBackend; if (flags.platform === 'android') return 'android-instance'; @@ -332,11 +507,6 @@ function requireRequestedLeaseBackend(flags: CliFlags, command: string): LeaseBa ); } -function shouldAllocateLeaseForCommand(command: string, state: RemoteConnectionState): boolean { - if (state.leaseProvider === 'proxy' && command === 'devices') return false; - return !leaseDeferredCommands.has(command); -} - function shouldPrepareRuntimeForCommand( command: string, flags: CliFlags, @@ -448,7 +618,8 @@ async function allocateOrReuseLease( client: AgentDeviceClient, state: RemoteConnectionState, leaseBackend: LeaseBackend, -): Promise { + policy: ConnectionLeasePolicy, +): Promise<{ lease: Lease; acquired: boolean }> { if (state.leaseId && state.leaseBackend === leaseBackend) { const existing = await heartbeatOrAllocateLease(client, state.leaseId, { tenant: state.tenant, @@ -457,19 +628,20 @@ async function allocateOrReuseLease( leaseProvider: state.leaseProvider, clientId: state.clientId, deviceKey: state.deviceKey, - ttlMs: leaseTtlMsForConnection(state), + ttlMs: policy.ttlMs(state), }); - if (existing) return existing; + if (existing) return { lease: existing, acquired: false }; } - return await client.leases.allocate({ + const lease = await client.leases.allocate({ tenant: state.tenant, runId: state.runId, leaseBackend, leaseProvider: state.leaseProvider, clientId: state.clientId, deviceKey: state.deviceKey, - ttlMs: leaseTtlMsForConnection(state), + ttlMs: policy.ttlMs(state), }); + return { lease, acquired: true }; } async function resolveProxyLeaseState(options: { @@ -614,10 +786,6 @@ async function heartbeatOrAllocateLease( } } -function leaseTtlMsForConnection(state: RemoteConnectionState): number | undefined { - return state.leaseProvider === 'proxy' ? PROXY_REMOTE_LEASE_TTL_MS : undefined; -} - function isInactiveLeaseError(error: unknown): boolean { if (!(error instanceof AppError) || error.code !== 'UNAUTHORIZED') return false; return ( diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 65adee8da..8cb7466a5 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -27,134 +27,224 @@ import type { LeaseBackend } from '../../contracts.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import type { ClientCommandHandler } from './router-types.ts'; -// fallow-ignore-next-line complexity export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; const provider = readConnectProvider(positionals); - if (provider && flags.remoteConfig) { - throw new AppError( - 'INVALID_ARGS', - 'connect provider positional and --remote-config are mutually exclusive.', - ); - } - const resolved = flags.remoteConfig - ? resolveRemoteConnectFlags(flags) - : provider === 'proxy' || shouldUseProxyConnectShortcut(flags) - ? resolveProxyConnectProfile({ - flags, - stateDir, - cwd: process.cwd(), - env: process.env, - }) - : await resolveCloudConnectProfile({ - flags, - stateDir, - cwd: process.cwd(), - env: process.env, - }); + assertConnectProviderUsage(provider, flags); + const resolved = await resolveConnectProfile({ provider, flags, stateDir }); const connectFlags = resolved.flags; const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath); - const tenant = connectFlags.tenant; - const runId = connectFlags.runId; - if (!tenant) { + const scope = readRequiredConnectScope(connectFlags); + const context = resolveConnectContext({ + stateDir, + flags: connectFlags, + remoteConfigPath: resolved.remoteConfigPath, + }); + assertCompatibleConnectionOrForce(context.previous, { + flags: connectFlags, + session: context.session, + remoteConfigPath: resolved.remoteConfigPath, + remoteConfigHash: context.remoteConfigHash, + desiredLeaseBackend: resolveRequestedLeaseBackend(connectFlags), + connection: connectionMetadata, + daemon: context.daemon, + }); + const state = buildConnectedState({ + flags: connectFlags, + scope, + connectionMetadata, + context, + remoteConfigPath: resolved.remoteConfigPath, + }); + writeRemoteConnectionState({ stateDir, state }); + await cleanupForcedPreviousConnection(client, stateDir, connectFlags, context.previous); + const leasePreparation = buildLeasePreparationNotice(state); + const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state); + + writeCommandOutput(connectFlags, serializeConnectionState(state, runtimePreparation), () => + [ + `Connected remote session "${context.session}" tenant "${scope.tenant}" run "${scope.runId}" ${ + state.leaseId ? `lease ${state.leaseId}` : 'lease pending' + }`, + leasePreparation?.message, + runtimePreparation?.message, + ] + .filter((line): line is string => Boolean(line)) + .join('\n'), + ); + return true; +}; + +async function resolveConnectProfile(options: { + provider?: 'proxy'; + flags: CliFlags; + stateDir: string; +}): Promise<{ flags: CliFlags; remoteConfigPath: string }> { + const { provider, flags, stateDir } = options; + if (flags.remoteConfig) return resolveRemoteConnectFlags(flags); + if (provider === 'proxy' || shouldUseProxyConnectShortcut(flags)) { + return resolveProxyConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); + } + return await resolveCloudConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); +} + +function assertConnectProviderUsage(provider: 'proxy' | undefined, flags: CliFlags): void { + if (!provider || !flags.remoteConfig) return; + throw new AppError( + 'INVALID_ARGS', + 'connect provider positional and --remote-config are mutually exclusive.', + ); +} + +function readRequiredConnectScope(flags: CliFlags): { tenant: string; runId: string } { + if (!flags.tenant) { throw new AppError( 'INVALID_ARGS', 'connect requires tenant in remote config or via --tenant .', ); } - if (!runId) { + if (!flags.runId) { throw new AppError( 'INVALID_ARGS', 'connect requires runId in remote config or via --run-id .', ); } - if (!connectFlags.daemonBaseUrl) { + if (!flags.daemonBaseUrl) { throw new AppError( 'INVALID_ARGS', 'connect requires daemonBaseUrl in remote config, config, env, or --daemon-base-url.', ); } + return { tenant: flags.tenant, runId: flags.runId }; +} - const activeState = connectFlags.session ? null : readActiveConnectionState({ stateDir }); - const session = connectFlags.session ?? activeState?.session ?? createRemoteSessionName(stateDir); - const remoteConfigHash = hashRemoteConfigFile(resolved.remoteConfigPath); - const daemon = buildDaemonState(connectFlags); +type ConnectContext = { + session: string; + remoteConfigHash: string; + daemon: RemoteConnectionState['daemon']; + previous: RemoteConnectionState | null; +}; + +function resolveConnectContext(options: { + stateDir: string; + flags: CliFlags; + remoteConfigPath: string; +}): ConnectContext { + const { stateDir, flags, remoteConfigPath } = options; + const activeState = flags.session ? null : readActiveConnectionState({ stateDir }); + const session = flags.session ?? activeState?.session ?? createRemoteSessionName(stateDir); const previous = activeState?.session === session ? activeState : readRemoteConnectionState({ stateDir, session }); - if ( - previous && - !isCompatibleConnection(previous, { - flags: connectFlags, - session, - remoteConfigPath: resolved.remoteConfigPath, - remoteConfigHash, - desiredLeaseBackend: resolveRequestedLeaseBackend(connectFlags), - connection: connectionMetadata, - daemon, - }) - ) { - if (!connectFlags.force) { - throw new AppError( - 'INVALID_ARGS', - 'A different remote connection is already active for this session. Re-run connect with --force to replace it.', - { session, remoteConfig: previous.remoteConfigPath }, - ); - } - } + return { + session, + previous, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: buildDaemonState(flags), + }; +} + +function assertCompatibleConnectionOrForce( + previous: RemoteConnectionState | null, + options: Parameters[1], +): void { + if (!previous || isCompatibleConnection(previous, options)) return; + if (options.flags.force) return; + throw new AppError( + 'INVALID_ARGS', + 'A different remote connection is already active for this session. Re-run connect with --force to replace it.', + { session: options.session, remoteConfig: previous.remoteConfigPath }, + ); +} +function buildConnectedState(options: { + flags: CliFlags; + scope: { tenant: string; runId: string }; + connectionMetadata?: RemoteConnectionRequestMetadata; + context: ConnectContext; + remoteConfigPath: string; +}): RemoteConnectionState { + const { flags, scope, connectionMetadata, context, remoteConfigPath } = options; + const previous = shouldReusePreviousConnectionState(flags, context.previous) + ? context.previous + : null; const now = new Date().toISOString(); - const state: RemoteConnectionState = { + const leaseBinding = buildConnectionLeaseBinding(flags, previous, connectionMetadata); + const runtimeBinding = buildConnectionRuntimeBinding(flags, previous, now); + return { version: 1, - session, - remoteConfigPath: resolved.remoteConfigPath, - remoteConfigHash, - daemon, - tenant, - runId, - leaseId: previous && !connectFlags.force ? previous.leaseId : undefined, - leaseBackend: - previous && !connectFlags.force - ? previous.leaseBackend - : resolveRequestedLeaseBackend(connectFlags), - leaseProvider: - connectionMetadata?.leaseProvider ?? - (previous && !connectFlags.force ? previous.leaseProvider : undefined), - clientId: - connectionMetadata?.clientId ?? - (previous && !connectFlags.force ? previous.clientId : undefined), - deviceKey: previous && !connectFlags.force ? previous.deviceKey : connectionMetadata?.deviceKey, - platform: - connectFlags.platform ?? (previous && !connectFlags.force ? previous.platform : undefined), - target: connectFlags.target ?? (previous && !connectFlags.force ? previous.target : undefined), - runtime: previous && !connectFlags.force ? previous.runtime : undefined, - metro: previous && !connectFlags.force ? previous.metro : undefined, - connectedAt: previous && !connectFlags.force ? previous.connectedAt : now, + session: context.session, + remoteConfigPath, + remoteConfigHash: context.remoteConfigHash, + daemon: context.daemon, + tenant: scope.tenant, + runId: scope.runId, + ...leaseBinding, + ...runtimeBinding, updatedAt: now, }; - writeRemoteConnectionState({ stateDir, state }); - if (previous && connectFlags.force) { - await stopMetroCleanup(previous.metro); - await stopReactDevtoolsCleanup({ stateDir, state: previous }); - await releasePreviousLease(client, previous); - } - const leasePreparation = buildLeasePreparationNotice(state); - const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state); +} - writeCommandOutput(connectFlags, serializeConnectionState(state, runtimePreparation), () => - [ - `Connected remote session "${session}" tenant "${tenant}" run "${runId}" ${ - state.leaseId ? `lease ${state.leaseId}` : 'lease pending' - }`, - leasePreparation?.message, - runtimePreparation?.message, - ] - .filter((line): line is string => Boolean(line)) - .join('\n'), - ); - return true; -}; +function buildConnectionLeaseBinding( + flags: CliFlags, + previous: RemoteConnectionState | null, + connectionMetadata: RemoteConnectionRequestMetadata | undefined, +): Pick< + RemoteConnectionState, + 'clientId' | 'deviceKey' | 'leaseBackend' | 'leaseId' | 'leaseProvider' +> { + return { + leaseId: previous?.leaseId, + leaseBackend: previous?.leaseBackend ?? resolveRequestedLeaseBackend(flags), + leaseProvider: connectionMetadata?.leaseProvider ?? previous?.leaseProvider, + clientId: connectionMetadata?.clientId ?? previous?.clientId, + deviceKey: previous?.deviceKey ?? connectionMetadata?.deviceKey, + }; +} + +function buildConnectionRuntimeBinding( + flags: CliFlags, + previous: RemoteConnectionState | null, + now: string, +): Pick { + return { + platform: flags.platform ?? previous?.platform, + target: flags.target ?? previous?.target, + runtime: previous?.runtime, + metro: previous?.metro, + connectedAt: previous?.connectedAt ?? now, + }; +} + +function shouldReusePreviousConnectionState( + flags: CliFlags, + previous: RemoteConnectionState | null, +): previous is RemoteConnectionState { + return Boolean(previous && !flags.force); +} + +async function cleanupForcedPreviousConnection( + client: Parameters[0]['client'], + stateDir: string, + flags: CliFlags, + previous: RemoteConnectionState | null, +): Promise { + if (!previous || !flags.force) return; + await stopMetroCleanup(previous.metro); + await stopReactDevtoolsCleanup({ stateDir, state: previous }); + await releasePreviousLease(client, previous); +} function resolveRemoteConnectFlags(flags: CliFlags): { flags: CliFlags; @@ -336,31 +426,44 @@ function isCompatibleConnection( }, ): boolean { return ( - state.remoteConfigPath === options.remoteConfigPath && - state.remoteConfigHash === options.remoteConfigHash && - state.session === options.session && - state.tenant === options.flags.tenant && - state.runId === options.flags.runId && - (options.desiredLeaseBackend === undefined || - state.leaseBackend === options.desiredLeaseBackend) && - (options.flags.platform === undefined || state.platform === options.flags.platform) && - (options.flags.target === undefined || state.target === options.flags.target) && - (options.connection?.leaseProvider === undefined || - state.leaseProvider === options.connection.leaseProvider) && - (options.connection?.clientId === undefined || - state.clientId === options.connection.clientId) && + requiredConnectionFieldsMatch(state, options) && + optionalConnectionFieldsMatch(state, options) && isSameDaemonState(state.daemon, options.daemon) ); } +function requiredConnectionFieldsMatch( + state: RemoteConnectionState, + options: Parameters[1], +): boolean { + return [ + [state.remoteConfigPath, options.remoteConfigPath], + [state.remoteConfigHash, options.remoteConfigHash], + [state.session, options.session], + [state.tenant, options.flags.tenant], + [state.runId, options.flags.runId], + ].every(([left, right]) => left === right); +} + +function optionalConnectionFieldsMatch( + state: RemoteConnectionState, + options: Parameters[1], +): boolean { + return [ + [state.leaseBackend, options.desiredLeaseBackend], + [state.platform, options.flags.platform], + [state.target, options.flags.target], + [state.leaseProvider, options.connection?.leaseProvider], + [state.clientId, options.connection?.clientId], + ].every(([left, right]) => right === undefined || left === right); +} + function isSameDaemonState( a: RemoteConnectionState['daemon'], b: RemoteConnectionState['daemon'], ): boolean { - return ( - (a?.baseUrl ?? undefined) === (b?.baseUrl ?? undefined) && - (a?.transport ?? undefined) === (b?.transport ?? undefined) && - (a?.serverMode ?? undefined) === (b?.serverMode ?? undefined) + return (['baseUrl', 'transport', 'serverMode'] as const).every( + (key) => (a?.[key] ?? undefined) === (b?.[key] ?? undefined), ); } diff --git a/src/client-types.ts b/src/client-types.ts index 9904ea3b5..ef62703eb 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -270,7 +270,6 @@ export type Lease = { runId: string; backend: LeaseBackend; leaseProvider?: string; - provider?: string; deviceKey?: string; clientId?: string; createdAt?: number; diff --git a/src/client.ts b/src/client.ts index c333c1ea3..9f5d6d6c3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -383,7 +383,6 @@ function normalizeLease(data: Record): Lease { runId: readRequiredString(rawLease, 'runId'), backend: readRequiredString(rawLease, 'backend') as Lease['backend'], leaseProvider: readOptionalString(rawLease, 'leaseProvider'), - provider: readOptionalString(rawLease, 'provider') as Lease['provider'], clientId: readOptionalString(rawLease, 'clientId'), deviceKey: readOptionalString(rawLease, 'deviceKey'), createdAt: typeof rawLease.createdAt === 'number' ? rawLease.createdAt : undefined, diff --git a/src/core/__tests__/lease-scope.test.ts b/src/core/__tests__/lease-scope.test.ts index 445134e45..3bdff94cd 100644 --- a/src/core/__tests__/lease-scope.test.ts +++ b/src/core/__tests__/lease-scope.test.ts @@ -77,7 +77,7 @@ test('leaseScopeFromRequest prefers metadata and falls back to legacy flags', () ); }); -test('leaseScopeToLeaseRpcParams preserves provider alias and command-specific fields', () => { +test('leaseScopeToLeaseRpcParams projects canonical provider and command-specific fields', () => { const scope = leaseScopeFromOptions({ tenant: 'tenant-a', runId: 'run-1', @@ -101,7 +101,6 @@ test('leaseScopeToLeaseRpcParams preserves provider alias and command-specific f tenantId: 'tenant-a', runId: 'run-1', leaseProvider: 'proxy', - provider: 'proxy', clientId: 'client-a', deviceKey: 'android:emulator-5554', ttlMs: 60_000, @@ -119,7 +118,6 @@ test('leaseScopeToLeaseRpcParams preserves provider alias and command-specific f tenantId: 'tenant-a', runId: 'run-1', leaseProvider: 'proxy', - provider: 'proxy', clientId: 'client-a', deviceKey: 'android:emulator-5554', leaseId: 'lease-1', diff --git a/src/core/lease-scope.ts b/src/core/lease-scope.ts index cbc0a3240..690623b4a 100644 --- a/src/core/lease-scope.ts +++ b/src/core/lease-scope.ts @@ -27,6 +27,27 @@ export type LeaseDiagnosticsContext = Omit; export type LeaseRpcCommand = 'lease_allocate' | 'lease_heartbeat' | 'lease_release'; +export type LeaseAllocateRequestScope = { + tenantId: string; + runId: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + ttlMs?: number; +}; + +export type LeaseScopedRequestScope = { + leaseId: string; + tenantId?: string; + runId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + ttlMs?: number; +}; + type LeaseRequestLike = { flags?: Record; meta?: { @@ -105,6 +126,29 @@ export function leaseScopeToCommandFlags(scope: LeaseScope): Record { + const { ttlMs: _ttlMs, ...request } = leaseScopeToScopedRequest(scope); + return request; +} + export function leaseScopeToLeaseRpcParams( scope: LeaseScope, command: LeaseRpcCommand, @@ -120,7 +164,6 @@ export function leaseScopeToLeaseRpcParams( tenantId: scope.tenantId, runId: scope.runId, leaseProvider: scope.leaseProvider, - provider: scope.leaseProvider, clientId: scope.clientId, deviceKey: scope.deviceKey, }); @@ -187,6 +230,19 @@ export function findMissingProxyLeaseFields(scope: LeaseScope): string[] { return REQUIRED_PROXY_LEASE_FIELDS.filter((field) => !scope[field]); } +function leaseScopeToScopedRequest(scope: LeaseScope): LeaseScopedRequestScope { + return stripUndefined({ + leaseId: scope.leaseId ?? '', + tenantId: scope.tenantId, + runId: scope.runId, + leaseBackend: scope.leaseBackend, + leaseProvider: scope.leaseProvider, + deviceKey: scope.deviceKey, + clientId: scope.clientId, + ttlMs: scope.leaseTtlMs, + }) as LeaseScopedRequestScope; +} + function readFlagString( flags: Record | undefined, key: string, diff --git a/src/daemon/__tests__/lease-context.test.ts b/src/daemon/__tests__/lease-context.test.ts index 4f51b7b53..c90a91793 100644 --- a/src/daemon/__tests__/lease-context.test.ts +++ b/src/daemon/__tests__/lease-context.test.ts @@ -3,6 +3,7 @@ import { test } from 'vitest'; import { buildLeaseDiagnosticsContext, buildSessionLeaseFromRequest, + resolveRunnerLogicalLeaseContext, resolveRequestOrSessionLeaseScope, type SessionLease, } from '../lease-context.ts'; @@ -111,3 +112,20 @@ test('buildLeaseDiagnosticsContext strips ttl and empty fields', () => { }); assert.equal(buildLeaseDiagnosticsContext({}), undefined); }); + +test('resolveRunnerLogicalLeaseContext keeps lease backend separate from provider', () => { + const context = resolveRunnerLogicalLeaseContext({ + meta: { + leaseId: 'lease-1', + leaseBackend: 'ios-instance', + tenantId: 'tenant-a', + runId: 'run-1', + }, + }); + + assert.deepEqual(context, { + leaseId: 'lease-1', + tenantId: 'tenant-a', + runId: 'run-1', + }); +}); diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index a5ca71ea0..0eea3d4af 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -90,7 +90,7 @@ test('releaseSessionLease releases with the stored session owner scope', () => { const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'proxy', deviceKey: 'ios:SIM-001', clientId: 'client-a', @@ -117,7 +117,7 @@ test('resolveSessionLeaseForRequest prefers admitted lease and falls back to exi const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'proxy', deviceKey: 'ios:SIM-001', clientId: 'client-a', diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index a5804d80e..85d11c100 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -108,7 +108,7 @@ test('device-aware allocation is idempotent per tenant/run/backend/provider/devi const first = registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'proxy', deviceKey: 'device-1', clientId: 'client-a', @@ -118,7 +118,7 @@ test('device-aware allocation is idempotent per tenant/run/backend/provider/devi const second = registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'proxy', deviceKey: 'device-1', clientId: 'client-a', @@ -126,7 +126,6 @@ test('device-aware allocation is idempotent per tenant/run/backend/provider/devi assert.equal(second.leaseId, first.leaseId); assert.equal(second.leaseProvider, 'proxy'); - assert.equal(second.provider, 'proxy'); assert.equal(second.deviceKey, 'device-1'); assert.equal(second.clientId, 'client-a'); assert.equal(second.heartbeatAt, 3_000); @@ -138,7 +137,7 @@ test('same backend/provider/device rejects conflicting active lease', () => { registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'proxy', deviceKey: 'device-1', }); @@ -147,7 +146,7 @@ test('same backend/provider/device rejects conflicting active lease', () => { registry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'proxy', deviceKey: 'device-1', }), @@ -167,21 +166,21 @@ test('device leases are isolated by provider and device key', () => { const proxy = registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'proxy', deviceKey: 'device-1', }); const limrun = registry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'limrun', deviceKey: 'device-1', }); const secondDevice = registry.allocateLease({ tenantId: 'tenant-c', runId: 'run-3', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'proxy', deviceKey: 'device-2', }); @@ -301,7 +300,7 @@ test('expired device lease releases device binding for new clients', () => { const first = registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'proxy', deviceKey: 'device-1', }); @@ -310,7 +309,7 @@ test('expired device lease releases device binding for new clients', () => { const second = registry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2', - backend: 'ios-instance', + leaseBackend: 'ios-instance', leaseProvider: 'proxy', deviceKey: 'device-1', }); diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index 3715dfa94..424af3094 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -332,7 +332,7 @@ test('provider lease admission succeeds without a device key', async () => { const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', - backend: 'android-instance', + leaseBackend: 'android-instance', leaseProvider: 'limrun', }); sessionStore.set( diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 186ff2a9c..5e677643f 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -1,6 +1,11 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { resolveLeaseScope } from '../lease-context.ts'; +import { + leaseScopeToAllocateRequest, + leaseScopeToHeartbeatRequest, + leaseScopeToReleaseRequest, +} from '../../core/lease-scope.ts'; type LeaseHandlerArgs = { req: DaemonRequest; @@ -12,46 +17,21 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise 0 ? context : undefined; diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index fdc5f039a..d7ce7f0ff 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -1,4 +1,5 @@ import { emitDiagnostic } from '../utils/diagnostics.ts'; +import { leaseScopeToReleaseRequest } from '../core/lease-scope.ts'; import type { LeaseRegistry } from './lease-registry.ts'; import { buildSessionLeaseFromRequest, type SessionLease } from './lease-context.ts'; import { @@ -98,15 +99,17 @@ export function releaseSessionLease(params: { }): void { const lease = params.session.lease; if (!lease) return; - const result = params.leaseRegistry.releaseLease({ - leaseId: lease.leaseId, - tenantId: lease.tenantId, - runId: lease.runId, - backend: lease.leaseBackend, - leaseProvider: lease.leaseProvider, - deviceKey: lease.deviceKey, - clientId: lease.clientId, - }); + const result = params.leaseRegistry.releaseLease( + leaseScopeToReleaseRequest({ + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.leaseBackend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }), + ); emitDiagnostic({ level: 'info', phase: 'session_lease_released', diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 9f07141e4..b57e4ebdd 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -9,7 +9,6 @@ export type DeviceLease = { runId: string; backend: LeaseBackend; leaseProvider?: string; - provider?: string; deviceKey?: string; clientId?: string; createdAt: number; @@ -30,8 +29,7 @@ export type LeaseRegistryOptions = { export type AllocateLeaseRequest = { tenantId: string; runId: string; - backend?: LeaseBackend; - provider?: string; + leaseBackend?: LeaseBackend; leaseProvider?: string; deviceKey?: string; clientId?: string; @@ -42,8 +40,7 @@ export type HeartbeatLeaseRequest = { leaseId: string; tenantId?: string; runId?: string; - backend?: LeaseBackend; - provider?: string; + leaseBackend?: LeaseBackend; leaseProvider?: string; deviceKey?: string; clientId?: string; @@ -54,19 +51,17 @@ export type ReleaseLeaseRequest = { leaseId: string; tenantId?: string; runId?: string; - backend?: LeaseBackend; - provider?: string; + leaseBackend?: LeaseBackend; leaseProvider?: string; deviceKey?: string; clientId?: string; }; export type AdmissionRequest = { - tenantId: string | undefined; - runId: string | undefined; - leaseId: string | undefined; - backend?: LeaseBackend; - provider?: string; + tenantId?: string; + runId?: string; + leaseId?: string; + leaseBackend?: LeaseBackend; leaseProvider?: string; deviceKey?: string; clientId?: string; @@ -75,8 +70,16 @@ export type AdmissionRequest = { type LeaseScopeMatchRequest = { tenantId?: string; runId?: string; - backend?: LeaseBackend; - provider?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +type NormalizedLeaseScopeMatchRequest = { + tenantId?: string; + runId?: string; + leaseBackend?: LeaseBackend; leaseProvider?: string; deviceKey?: string; clientId?: string; @@ -123,16 +126,8 @@ function normalizeClientId(raw: string | undefined): string | undefined { return normalizeAgentIdentifier(raw, 'client id', 128); } -function normalizeLeaseProviderFields(request: { - provider?: string; - leaseProvider?: string; -}): string | undefined { - const provider = normalizeAgentIdentifier(request.provider, 'lease provider', 64); - const leaseProvider = normalizeAgentIdentifier(request.leaseProvider, 'lease provider', 64); - if (provider && leaseProvider && provider !== leaseProvider) { - throw new AppError('INVALID_ARGS', 'Conflicting lease provider values.'); - } - return leaseProvider ?? provider; +function normalizeLeaseProvider(raw: string | undefined): string | undefined { + return normalizeAgentIdentifier(raw, 'lease provider', 64); } function normalizeAgentIdentifier( @@ -157,9 +152,8 @@ function leaseRequiresOwnerScope(lease: DeviceLease): boolean { function hasRequiredOwnerScope(lease: DeviceLease, request: LeaseScopeMatchRequest): boolean { if (!request.tenantId || !request.runId) return false; - const provider = request.leaseProvider ?? request.provider; return [ - [lease.leaseProvider, provider], + [lease.leaseProvider, request.leaseProvider], [lease.deviceKey, request.deviceKey], [lease.clientId, request.clientId], ].every(([leaseValue, requestValue]) => !leaseValue || Boolean(requestValue)); @@ -192,8 +186,8 @@ export class LeaseRegistry { } allocateLease(request: AllocateLeaseRequest): DeviceLease { - const backend = normalizeLeaseBackend(request.backend); - const provider = normalizeLeaseProviderFields(request); + const backend = normalizeLeaseBackend(request.leaseBackend); + const leaseProvider = normalizeLeaseProvider(request.leaseProvider); const deviceKey = normalizeDeviceKey(request.deviceKey); const clientId = normalizeClientId(request.clientId); const tenantId = normalizeTenantId(request.tenantId); @@ -212,7 +206,7 @@ export class LeaseRegistry { } this.cleanupExpiredLeases(); const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); - const bindingKey = this.bindingKey({ tenantId, runId, backend, provider, deviceKey }); + const bindingKey = this.bindingKey({ tenantId, runId, backend, leaseProvider, deviceKey }); const existingId = this.runBindings.get(bindingKey); if (existingId) { const existingLease = this.leases.get(existingId); @@ -222,7 +216,7 @@ export class LeaseRegistry { } this.runBindings.delete(bindingKey); } - this.assertDeviceAvailable({ backend, provider, deviceKey }); + this.assertDeviceAvailable({ backend, leaseProvider, deviceKey }); this.enforceCapacity(backend); const now = this.now(); const lease: DeviceLease = { @@ -230,7 +224,7 @@ export class LeaseRegistry { tenantId, runId, backend, - ...(provider ? { leaseProvider: provider, provider } : {}), + ...(leaseProvider ? { leaseProvider } : {}), ...(deviceKey ? { deviceKey } : {}), ...(clientId ? { clientId } : {}), createdAt: now, @@ -267,7 +261,7 @@ export class LeaseRegistry { } assertLeaseAdmission(request: AdmissionRequest): void { - const backend = normalizeLeaseBackend(request.backend); + const backend = normalizeLeaseBackend(request.leaseBackend); const tenantId = normalizeTenantId(request.tenantId); if (!tenantId) { throw new AppError('INVALID_ARGS', 'tenant isolation requires tenant id.'); @@ -285,8 +279,7 @@ export class LeaseRegistry { this.assertOptionalScopeMatch(lease, { tenantId, runId, - backend, - provider: request.provider, + leaseBackend: backend, leaseProvider: request.leaseProvider, deviceKey: request.deviceKey, clientId: request.clientId, @@ -372,8 +365,7 @@ export class LeaseRegistry { return { tenantId: request.tenantId, runId: request.runId, - backend: request.backend, - provider: request.provider, + leaseBackend: request.leaseBackend, leaseProvider: request.leaseProvider, deviceKey: request.deviceKey, clientId: request.clientId, @@ -398,7 +390,7 @@ export class LeaseRegistry { tenantId: lease.tenantId, runId: lease.runId, backend: lease.backend, - provider: lease.leaseProvider, + leaseProvider: lease.leaseProvider, deviceKey: lease.deviceKey, }), lease.leaseId, @@ -415,7 +407,7 @@ export class LeaseRegistry { tenantId: lease.tenantId, runId: lease.runId, backend: lease.backend, - provider: lease.leaseProvider, + leaseProvider: lease.leaseProvider, deviceKey: lease.deviceKey, }), ); @@ -429,14 +421,14 @@ export class LeaseRegistry { tenantId: string; runId: string; backend: LeaseBackend; - provider?: string; + leaseProvider?: string; deviceKey?: string; }): string { return JSON.stringify([ params.tenantId, params.runId, params.backend, - params.provider ?? DEFAULT_LEASE_PROVIDER, + params.leaseProvider ?? DEFAULT_LEASE_PROVIDER, params.deviceKey ?? '*', ]); } @@ -454,12 +446,12 @@ export class LeaseRegistry { private assertDeviceAvailable(params: { backend: LeaseBackend; - provider?: string; + leaseProvider?: string; deviceKey?: string; }): void { const deviceBindingKey = this.deviceBindingKey({ backend: params.backend, - leaseProvider: params.provider, + leaseProvider: params.leaseProvider, deviceKey: params.deviceKey, }); if (!deviceBindingKey) return; @@ -490,19 +482,21 @@ export class LeaseRegistry { } } - // fallow-ignore-next-line complexity - private assertOptionalScopeMatch( - lease: DeviceLease, - request: { - tenantId?: string; - runId?: string; - backend?: LeaseBackend; - provider?: string; - leaseProvider?: string; - deviceKey?: string; - clientId?: string; - }, - ): void { + private assertOptionalScopeMatch(lease: DeviceLease, request: LeaseScopeMatchRequest): void { + const normalized = this.normalizeOptionalScopeMatchRequest(request); + if ( + (normalized.tenantId && lease.tenantId !== normalized.tenantId) || + (normalized.runId && lease.runId !== normalized.runId) || + (normalized.leaseBackend && lease.backend !== normalized.leaseBackend) + ) { + this.throwScopeMismatch(); + } + this.assertOptionalLeaseIdentityMatch(lease, normalized); + } + + private normalizeOptionalScopeMatchRequest( + request: LeaseScopeMatchRequest, + ): NormalizedLeaseScopeMatchRequest { const tenantId = normalizeTenantId(request.tenantId); const runId = normalizeRunId(request.runId); if (request.tenantId && !tenantId) { @@ -517,29 +511,25 @@ export class LeaseRegistry { 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', ); } - const backend = request.backend ? normalizeLeaseBackend(request.backend) : undefined; - const provider = normalizeLeaseProviderFields(request); - const deviceKey = normalizeDeviceKey(request.deviceKey); - const clientId = normalizeClientId(request.clientId); - if ( - (tenantId && lease.tenantId !== tenantId) || - (runId && lease.runId !== runId) || - (backend && lease.backend !== backend) - ) { - this.throwScopeMismatch(); - } - this.assertOptionalLeaseIdentityMatch(lease, { provider, deviceKey, clientId }); + return { + tenantId, + runId, + leaseBackend: request.leaseBackend ? normalizeLeaseBackend(request.leaseBackend) : undefined, + leaseProvider: normalizeLeaseProvider(request.leaseProvider), + deviceKey: normalizeDeviceKey(request.deviceKey), + clientId: normalizeClientId(request.clientId), + }; } private assertOptionalLeaseIdentityMatch( lease: DeviceLease, request: { - provider?: string; + leaseProvider?: string; deviceKey?: string; clientId?: string; }, ): void { - if (request.provider && lease.leaseProvider !== request.provider) { + if (request.leaseProvider && lease.leaseProvider !== request.leaseProvider) { this.throwScopeMismatch(); } if (request.deviceKey && lease.deviceKey !== request.deviceKey) { diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index e09be4716..b727dafde 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -8,6 +8,7 @@ import { resolveLeaseScope, resolveRequestOrSessionLeaseScope, } from './lease-context.ts'; +import { leaseScopeToHeartbeatRequest } from '../core/lease-scope.ts'; import type { DeviceLease, LeaseRegistry } from './lease-registry.ts'; import type { DaemonRequest, SessionState } from './types.ts'; @@ -72,27 +73,14 @@ export function assertRequestLeaseAdmission( } assertRequestSessionLeaseMatches(requestLeaseScope, sessionLease); const leaseScope = resolveRequestOrSessionLeaseScope(req, session); - leaseRegistry.assertLeaseAdmission({ - tenantId: leaseScope.tenantId, - runId: leaseScope.runId, - leaseId: leaseScope.leaseId, - backend: leaseScope.leaseBackend, - leaseProvider: leaseScope.leaseProvider, - deviceKey: leaseScope.deviceKey, - clientId: leaseScope.clientId, - }); - return leaseRegistry.heartbeatLease({ - leaseId: leaseScope.leaseId ?? '', - tenantId: leaseScope.tenantId, - runId: leaseScope.runId, - backend: leaseScope.leaseBackend, - leaseProvider: leaseScope.leaseProvider, - deviceKey: leaseScope.deviceKey, - clientId: leaseScope.clientId, - ttlMs: + const heartbeatLeaseScope = { + ...leaseScope, + leaseTtlMs: leaseScope.leaseTtlMs ?? (isProxyLeaseScope(leaseScope) ? DEFAULT_PROXY_LEASE_TTL_MS : undefined), - }); + }; + leaseRegistry.assertLeaseAdmission(leaseScopeToHeartbeatRequest(leaseScope)); + return leaseRegistry.heartbeatLease(leaseScopeToHeartbeatRequest(heartbeatLeaseScope)); } export function assertRequestLeaseAdmissionPreflight(req: DaemonRequest): void { diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index d348b9640..52c0761d5 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -289,32 +289,45 @@ function safeStateName(value: string): string { return `${safe}-${suffix}`; } -// fallow-ignore-next-line complexity function isRemoteConnectionState(value: unknown): value is RemoteConnectionState { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const record = value as Record; return ( record.version === 1 && - typeof record.session === 'string' && - typeof record.remoteConfigPath === 'string' && - typeof record.remoteConfigHash === 'string' && - (record.daemon === undefined || - (typeof record.daemon === 'object' && - record.daemon !== null && - !Array.isArray(record.daemon) && - isRemoteConnectionDaemonState(record.daemon))) && - typeof record.tenant === 'string' && - typeof record.runId === 'string' && - (record.leaseId === undefined || typeof record.leaseId === 'string') && - (record.leaseBackend === undefined || typeof record.leaseBackend === 'string') && - (record.leaseProvider === undefined || typeof record.leaseProvider === 'string') && - (record.deviceKey === undefined || typeof record.deviceKey === 'string') && - (record.clientId === undefined || typeof record.clientId === 'string') && - typeof record.connectedAt === 'string' && - typeof record.updatedAt === 'string' + hasStringFields(record, [ + 'session', + 'remoteConfigPath', + 'remoteConfigHash', + 'tenant', + 'runId', + 'connectedAt', + 'updatedAt', + ]) && + hasOptionalStringFields(record, [ + 'leaseId', + 'leaseBackend', + 'leaseProvider', + 'deviceKey', + 'clientId', + ]) && + isOptionalRemoteConnectionDaemonState(record.daemon) ); } +function hasStringFields(record: Record, fields: string[]): boolean { + return fields.every((field) => typeof record[field] === 'string'); +} + +function hasOptionalStringFields(record: Record, fields: string[]): boolean { + return fields.every((field) => record[field] === undefined || typeof record[field] === 'string'); +} + +function isOptionalRemoteConnectionDaemonState(value: unknown): boolean { + if (value === undefined) return true; + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return isRemoteConnectionDaemonState(value); +} + function isRemoteConnectionDaemonState(value: object): boolean { const record = value as Record; return ( From 27386149d06e80c332d49206f704e443f2e8d9ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 17:46:54 +0200 Subject: [PATCH 12/16] refactor: tighten lease release cleanup --- src/__tests__/remote-connection.test.ts | 1 + src/cli/commands/connection-runtime.ts | 34 ++++++++++++++++--------- src/cli/commands/connection.ts | 15 ++--------- src/daemon/lease-registry.ts | 19 +++----------- 4 files changed, 29 insertions(+), 40 deletions(-) diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index efae4d563..e6d2233e8 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -1898,6 +1898,7 @@ test('disconnect releases proxy lease with provider client and device metadata', assert.equal(releaseRequest?.clientId, 'client-1'); assert.equal(releaseRequest?.deviceKey, 'ios:mobile:SIM-001'); assert.equal(releaseRequest?.leaseId, 'abc123abc123abc1'); + assert.equal(releaseRequest?.leaseBackend, 'ios-instance'); assert.equal(releaseRequest?.daemonBaseUrl, 'http://proxy.example.test/agent-device'); assert.equal(releaseRequest?.daemonAuthToken, 'proxy-secret'); assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' }), null); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 85a10f36c..31c5092dd 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -447,24 +447,34 @@ export async function stopReactDevtoolsCleanup(options: { } } +export async function releaseRemoteConnectionLease( + client: AgentDeviceClient, + state: RemoteConnectionState, +): Promise { + if (!state.leaseId) return false; + const result = await client.leases.release({ + tenant: state.tenant, + runId: state.runId, + leaseId: state.leaseId, + leaseBackend: state.leaseBackend, + daemonBaseUrl: state.daemon?.baseUrl, + daemonAuthToken: state.daemon?.authToken, + daemonTransport: state.daemon?.transport, + daemonServerMode: state.daemon?.serverMode, + leaseProvider: state.leaseProvider, + clientId: state.clientId, + deviceKey: state.deviceKey, + }); + return result.released; +} + export async function releasePreviousLease( client: AgentDeviceClient, previous: RemoteConnectionState, ): Promise { if (!previous.leaseId) return; try { - await client.leases.release({ - tenant: previous.tenant, - runId: previous.runId, - leaseId: previous.leaseId, - daemonBaseUrl: previous.daemon?.baseUrl, - daemonAuthToken: previous.daemon?.authToken, - daemonTransport: previous.daemon?.transport, - daemonServerMode: previous.daemon?.serverMode, - leaseProvider: previous.leaseProvider, - clientId: previous.clientId, - deviceKey: previous.deviceKey, - }); + await releaseRemoteConnectionLease(client, previous); } catch { // Reconnect must succeed even if the old lease was already released. } diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 8cb7466a5..42d012251 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -17,6 +17,7 @@ import { resolveCloudConnectProfile } from '../cloud-connection-profile.ts'; import { resolveProxyConnectProfile } from '../proxy-connection-profile.ts'; import { hasDeferredMetroConfig, + releaseRemoteConnectionLease, releasePreviousLease, resolveRequestedLeaseBackend, stopMetroCleanup, @@ -298,19 +299,7 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) let released = false; if (state.leaseId) { try { - const result = await client.leases.release({ - tenant: state.tenant, - runId: state.runId, - leaseId: state.leaseId, - daemonBaseUrl: state.daemon?.baseUrl, - daemonAuthToken: state.daemon?.authToken, - daemonTransport: state.daemon?.transport, - daemonServerMode: state.daemon?.serverMode, - leaseProvider: state.leaseProvider, - clientId: state.clientId, - deviceKey: state.deviceKey, - }); - released = result.released; + released = await releaseRemoteConnectionLease(client, state); } catch { // Bridges may release on close or be unreachable; local state still needs cleanup. } diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index b57e4ebdd..3b52fcb19 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -240,8 +240,8 @@ export class LeaseRegistry { const leaseId = this.normalizeRequiredLeaseId(request.leaseId); this.cleanupExpiredLeases(); const lease = this.getActiveLease(leaseId); - this.assertRequiredScopeForDeviceAwareLease(lease, this.scopeMatchRequest(request)); - this.assertOptionalScopeMatch(lease, this.scopeMatchRequest(request)); + this.assertRequiredScopeForDeviceAwareLease(lease, request); + this.assertOptionalScopeMatch(lease, request); const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); return this.refreshLease(lease, leaseTtlMs); } @@ -253,8 +253,8 @@ export class LeaseRegistry { if (!lease) { return { released: false }; } - this.assertRequiredScopeForDeviceAwareLease(lease, this.scopeMatchRequest(request)); - this.assertOptionalScopeMatch(lease, this.scopeMatchRequest(request)); + this.assertRequiredScopeForDeviceAwareLease(lease, request); + this.assertOptionalScopeMatch(lease, request); this.leases.delete(leaseId); this.unbindLease(lease); return { released: true }; @@ -361,17 +361,6 @@ export class LeaseRegistry { }); } - private scopeMatchRequest(request: LeaseScopeMatchRequest): LeaseScopeMatchRequest { - return { - tenantId: request.tenantId, - runId: request.runId, - leaseBackend: request.leaseBackend, - leaseProvider: request.leaseProvider, - deviceKey: request.deviceKey, - clientId: request.clientId, - }; - } - private refreshLease(lease: DeviceLease, ttlMs: number): DeviceLease { const now = this.now(); const updated: DeviceLease = { From 51725c46ab4da5915d0fc5197ca4e205cccce2c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 18:15:38 +0200 Subject: [PATCH 13/16] fix: simplify proxy startup output --- src/__tests__/proxy-command.test.ts | 29 +++++++++++++++++++++++++++++ src/cli/commands/proxy.ts | 15 ++++++--------- src/utils/cli-command-overrides.ts | 4 ++-- src/utils/cli-help.ts | 2 +- website/docs/docs/remote-proxy.md | 4 ++-- 5 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/proxy-command.test.ts diff --git a/src/__tests__/proxy-command.test.ts b/src/__tests__/proxy-command.test.ts new file mode 100644 index 000000000..184632afe --- /dev/null +++ b/src/__tests__/proxy-command.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { renderProxyStartup } from '../cli/commands/proxy.ts'; + +test('renderProxyStartup keeps human output concise', () => { + const output = renderProxyStartup({ + proxyBaseUrl: 'http://127.0.0.1:4310', + agentDeviceBaseUrl: 'http://127.0.0.1:4310/agent-device', + token: 'proxy-secret', + upstreamBaseUrl: 'http://127.0.0.1:60149', + stateDir: '/private/tmp/agent-device-proxy', + }); + + assert.equal( + output, + [ + '✔️ Proxy listening at http://127.0.0.1:4310', + '', + 'Provide this to the agent-device instance connecting:', + '', + 'Daemon base URL: /agent-device', + 'Daemon auth token: proxy-secret', + ].join('\n'), + ); + assert.doesNotMatch(output, /upstream local daemon/); + assert.doesNotMatch(output, /state dir/); + assert.doesNotMatch(output, /Remote client example/); + assert.doesNotMatch(output, /agent-device devices --daemon-base-url/); +}); diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index f6baf5bf8..fe5de830e 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -89,17 +89,14 @@ function formatHostForUrl(host: string): string { return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; } -function renderProxyStartup(startup: ProxyStartup): string { +export function renderProxyStartup(startup: ProxyStartup): string { return [ - `agent-device proxy listening on ${startup.proxyBaseUrl}`, - `daemon base URL: ${startup.agentDeviceBaseUrl}`, - `daemon auth token: ${startup.token}`, - 'treat the daemon auth token as a secret; anyone with it can control the proxied daemon', - `upstream local daemon: ${startup.upstreamBaseUrl}`, - `state dir: ${startup.stateDir}`, + `✔️ Proxy listening at ${startup.proxyBaseUrl}`, '', - 'Remote client example:', - `agent-device devices --daemon-base-url ${startup.agentDeviceBaseUrl} --daemon-auth-token ${startup.token}`, + 'Provide this to the agent-device instance connecting:', + '', + 'Daemon base URL: /agent-device', + `Daemon auth token: ${startup.token}`, ].join('\n'); } diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 87d327b7f..4db492e5c 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -81,7 +81,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { listUsageOverride: 'proxy', helpDescription: `Expose the local daemon HTTP contract through a tunnel-friendly reverse proxy. -Run this on the host that has access to simulators/devices, then point another machine at the printed daemon base URL with --daemon-base-url or AGENT_DEVICE_DAEMON_BASE_URL. +Run this on the host that has access to simulators/devices, expose the printed local proxy URL through a tunnel, then point another machine at the tunnel URL with connect proxy. The proxy starts or reuses a local HTTP daemon, accepts /health, /rpc, /upload, and /artifacts/*, and also accepts the same routes under /agent-device/*. Health is unauthenticated for reachability probes. Other routes require the generated bearer token printed at startup, or the explicit --daemon-auth-token value when provided. The proxy rewrites authorized client requests to the upstream daemon token instead of exposing the local daemon token. @@ -90,7 +90,7 @@ Use the /agent-device base path when connecting through cloudflared, ngrok, or a Examples: agent-device proxy --port 4310 cloudflared tunnel --url http://127.0.0.1:4310 - agent-device devices --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token `, + agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token `, summary: 'Expose a local daemon through cloudflared, ngrok, or another HTTP tunnel', allowedFlags: ['proxyHost', 'proxyPort', 'daemonAuthToken', 'stateDir'], }, diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 52d72fd2b..b9aac2ed0 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -688,7 +688,7 @@ Rules: connect and disconnect are top-level commands. Do not write agent-device remote connect or agent-device remote disconnect. Use connect without --remote-config when the cloud control plane owns the connection profile. Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. - Use agent-device proxy for direct tunnel access to a Mac you control. Copy the printed daemon base URL and daemon auth token, then run agent-device connect proxy --daemon-base-url before normal commands. + Use agent-device proxy for direct tunnel access to a Mac you control. Expose the printed proxy URL through cloudflared/ngrok, then run agent-device connect proxy with the tunnel URL and printed token before normal commands. connect proxy stores the connection profile and client identity. Device leases are acquired on open and expire after five minutes without commands. Multiple agents can share one proxy when each uses connect proxy, open, commands, close, and disconnect. disconnect releases local connection state; close releases the active session and device lease. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 6537c9d47..11dfa4aa4 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -17,7 +17,7 @@ On the Mac with simulator or device access: agent-device proxy --port 4310 ``` -The command prints a `daemon base URL` and `daemon auth token`. Keep the token secret; anyone with it can control the proxied daemon. +The command prints the local proxy URL and a `daemon auth token`. Keep the token secret; anyone with it can control the proxied daemon. Expose the proxy with your tunnel: @@ -31,7 +31,7 @@ By default the proxy binds `127.0.0.1`. Use `--host 0.0.0.0` only when you inten ## Remote Client -On the machine running the agent, connect to the public tunnel origin with the `/agent-device` base path: +On the machine running the agent, connect to the public tunnel origin with the `/agent-device` base path and the printed token: ```bash agent-device connect proxy \ From 34a3aab5c9c16ba8cb15323ce5c21132c35db2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 18:21:20 +0200 Subject: [PATCH 14/16] fix: harden cloud lease identity --- src/__tests__/cloud-connect-profile.test.ts | 21 ++- src/cli/cloud-connection-profile.ts | 29 ++++- src/daemon/__tests__/lease-registry.test.ts | 30 +++++ src/daemon/lease-registry.ts | 136 ++++++++++++++------ 4 files changed, 174 insertions(+), 42 deletions(-) diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index fc4662452..92bb8fbbc 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -152,12 +152,16 @@ function mockCloudConnectionProfile(connection: Record): Return function assertGeneratedProfileState(state: RemoteConnectionState): void { assert.equal(state.tenant, 'acme'); assert.equal(state.runId, 'demo-run-001'); + assert.equal(state.leaseProvider, 'cloud'); + assert.match(state.clientId ?? '', /^[a-f0-9]{16}$/); assert.equal(state.daemon?.baseUrl, 'https://bridge.example.com/agent-device'); assert.match(state.remoteConfigPath, /remote-connections\/generated\/cloud-[a-f0-9]{16}\.json$/); assert.equal(state.remoteConfigHash, hashRemoteConfigFile(state.remoteConfigPath)); assert.deepEqual(readGeneratedConfigKeys(state.remoteConfigPath), [ + 'clientId', 'daemonBaseUrl', 'daemonTransport', + 'leaseProvider', 'metroKind', 'metroProxyBaseUrl', 'metroPublicBaseUrl', @@ -165,7 +169,10 @@ function assertGeneratedProfileState(state: RemoteConnectionState): void { 'sessionIsolation', 'tenant', ]); - assert.equal(readGeneratedConfig(state.remoteConfigPath).tenant, 'acme'); + const generated = readGeneratedConfig(state.remoteConfigPath); + assert.equal(generated.tenant, 'acme'); + assert.equal(generated.leaseProvider, 'cloud'); + assert.equal(generated.clientId, state.clientId); } function fetchProfileUrl(fetchMock: ReturnType): string | undefined { @@ -190,8 +197,16 @@ async function connectWithGeneratedCloudProfile(stateDir: string): Promise } } -function readGeneratedConfig(configPath: string): { tenant?: string } { - return JSON.parse(fs.readFileSync(configPath, 'utf8')) as { tenant?: string }; +function readGeneratedConfig(configPath: string): { + tenant?: string; + leaseProvider?: string; + clientId?: string; +} { + return JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + tenant?: string; + leaseProvider?: string; + clientId?: string; + }; } function readGeneratedConfigKeys(configPath: string): string[] { diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts index 21060bdd3..1e183674e 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import type { RemoteConfigProfile } from '../remote-config-schema.ts'; import { AppError } from '../utils/errors.ts'; import type { CliFlags } from '../utils/cli-flags.ts'; @@ -36,10 +37,21 @@ export async function resolveCloudConnectProfile(options: { accessToken: auth.accessToken, fetchImpl: options.fetchImpl, }); + const clientId = buildCloudClientId({ + stateDir: options.stateDir, + cloudBaseUrl: auth.cloudBaseUrl, + daemonBaseUrl: typeof profile.daemonBaseUrl === 'string' ? profile.daemonBaseUrl : '', + session: options.flags.session, + }); return persistAndResolveGeneratedProfile({ stateDir: options.stateDir, provider: 'cloud', - profile, + profile: { + ...profile, + leaseProvider: profile.leaseProvider ?? 'cloud', + clientId: profile.clientId ?? clientId, + runId: profile.runId ?? `cloud-${clientId}`, + }, cwd: options.cwd, env: options.env, flags: options.flags, @@ -96,3 +108,18 @@ function parseRemoteConfigProfile(value: unknown): RemoteConfigProfile { } return value as RemoteConfigProfile; } + +function buildCloudClientId(options: { + stateDir: string; + cloudBaseUrl: string; + daemonBaseUrl: string; + session: string | undefined; +}): string { + return crypto + .createHash('sha256') + .update( + `${options.stateDir}\0${options.cloudBaseUrl}\0${options.daemonBaseUrl}\0${options.session ?? ''}`, + ) + .digest('hex') + .slice(0, 16); +} diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index 85d11c100..0fef548ff 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -161,6 +161,36 @@ test('same backend/provider/device rejects conflicting active lease', () => { assert.equal(details?.runId, undefined); }); +test('same run/provider/device with different client reports device busy', () => { + const registry = new LeaseRegistry(); + registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'shared-run', + leaseBackend: 'ios-instance', + leaseProvider: 'cloud', + deviceKey: 'device-1', + clientId: 'client-a', + }); + + const error = captureThrown(() => + registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'shared-run', + leaseBackend: 'ios-instance', + leaseProvider: 'cloud', + deviceKey: 'device-1', + clientId: 'client-b', + }), + ); + + assert.ok(error instanceof Error); + assert.equal(error.message, 'Device is already leased'); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'DEVICE_LEASE_BUSY'); + assert.equal(details?.deviceKey, 'device-1'); + assert.equal(details?.leaseProvider, 'cloud'); +}); + test('device leases are isolated by provider and device key', () => { const registry = new LeaseRegistry(); const proxy = registry.allocateLease({ diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 3b52fcb19..aa58cec49 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -85,6 +85,16 @@ type NormalizedLeaseScopeMatchRequest = { clientId?: string; }; +type NormalizedAllocateLeaseRequest = { + tenantId: string; + runId: string; + backend: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + ttlMs?: number; +}; + const DEFAULT_LEASE_TTL_MS = 60_000; const MIN_LEASE_TTL_MS = 5_000; const MAX_LEASE_TTL_MS = 10 * 60_000; @@ -146,6 +156,42 @@ function normalizeAgentIdentifier( return value; } +function normalizeRequiredTenantId(raw: string): string { + const tenantId = normalizeTenantId(raw); + if (!tenantId) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + ); + } + return tenantId; +} + +function normalizeRequiredRunId(raw: string): string { + const runId = normalizeRunId(raw); + if (!runId) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + ); + } + return runId; +} + +function normalizeAllocateLeaseRequest( + request: AllocateLeaseRequest, +): NormalizedAllocateLeaseRequest { + return { + backend: normalizeLeaseBackend(request.leaseBackend), + leaseProvider: normalizeLeaseProvider(request.leaseProvider), + deviceKey: normalizeDeviceKey(request.deviceKey), + clientId: normalizeClientId(request.clientId), + tenantId: normalizeRequiredTenantId(request.tenantId), + runId: normalizeRequiredRunId(request.runId), + ttlMs: request.ttlMs, + }; +} + function leaseRequiresOwnerScope(lease: DeviceLease): boolean { return Boolean(lease.leaseProvider ?? lease.deviceKey ?? lease.clientId); } @@ -186,54 +232,55 @@ export class LeaseRegistry { } allocateLease(request: AllocateLeaseRequest): DeviceLease { - const backend = normalizeLeaseBackend(request.leaseBackend); - const leaseProvider = normalizeLeaseProvider(request.leaseProvider); - const deviceKey = normalizeDeviceKey(request.deviceKey); - const clientId = normalizeClientId(request.clientId); - const tenantId = normalizeTenantId(request.tenantId); - if (!tenantId) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', - ); - } - const runId = normalizeRunId(request.runId); - if (!runId) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', - ); - } + const normalized = normalizeAllocateLeaseRequest(request); this.cleanupExpiredLeases(); - const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); - const bindingKey = this.bindingKey({ tenantId, runId, backend, leaseProvider, deviceKey }); + const leaseTtlMs = this.resolveLeaseTtlMs(normalized.ttlMs); + const existingLease = this.refreshExistingRunBinding(normalized, leaseTtlMs); + if (existingLease) return existingLease; + this.assertDeviceAvailable(normalized); + this.enforceCapacity(normalized.backend); + const lease = this.createLease(normalized, leaseTtlMs); + this.leases.set(lease.leaseId, lease); + this.bindLease(lease); + return { ...lease }; + } + + private refreshExistingRunBinding( + request: NormalizedAllocateLeaseRequest, + leaseTtlMs: number, + ): DeviceLease | undefined { + const bindingKey = this.bindingKey(request); const existingId = this.runBindings.get(bindingKey); - if (existingId) { - const existingLease = this.leases.get(existingId); - if (existingLease) { - this.assertOptionalLeaseIdentityMatch(existingLease, { clientId }); - return this.refreshLease(existingLease, leaseTtlMs); - } + if (!existingId) return undefined; + const existingLease = this.leases.get(existingId); + if (!existingLease) { this.runBindings.delete(bindingKey); + return undefined; } - this.assertDeviceAvailable({ backend, leaseProvider, deviceKey }); - this.enforceCapacity(backend); + if (this.canReuseRunBinding(existingLease, request)) { + return this.refreshLease(existingLease, leaseTtlMs); + } + if (existingLease.deviceKey) { + this.throwDeviceBusy(existingLease); + } + this.assertOptionalLeaseIdentityMatch(existingLease, request); + return this.refreshLease(existingLease, leaseTtlMs); + } + + private createLease(request: NormalizedAllocateLeaseRequest, leaseTtlMs: number): DeviceLease { const now = this.now(); - const lease: DeviceLease = { + return { leaseId: crypto.randomBytes(16).toString('hex'), - tenantId, - runId, - backend, - ...(leaseProvider ? { leaseProvider } : {}), - ...(deviceKey ? { deviceKey } : {}), - ...(clientId ? { clientId } : {}), + tenantId: request.tenantId, + runId: request.runId, + backend: request.backend, + ...(request.leaseProvider ? { leaseProvider: request.leaseProvider } : {}), + ...(request.deviceKey ? { deviceKey: request.deviceKey } : {}), + ...(request.clientId ? { clientId: request.clientId } : {}), createdAt: now, heartbeatAt: now, expiresAt: now + leaseTtlMs, }; - this.leases.set(lease.leaseId, lease); - this.bindLease(lease); - return { ...lease }; } heartbeatLease(request: HeartbeatLeaseRequest): DeviceLease { @@ -451,6 +498,19 @@ export class LeaseRegistry { this.deviceBindings.delete(deviceBindingKey); return; } + this.throwDeviceBusy(activeLease); + } + + private canReuseRunBinding( + lease: DeviceLease, + request: { + clientId?: string; + }, + ): boolean { + return lease.clientId === request.clientId; + } + + private throwDeviceBusy(activeLease: DeviceLease): never { throw new AppError('COMMAND_FAILED', 'Device is already leased', { reason: 'DEVICE_LEASE_BUSY', deviceKey: activeLease.deviceKey, From 12a7f0e296e4391a6e71e9b0ac1800275b01e063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 18:24:35 +0200 Subject: [PATCH 15/16] fix: color proxy startup output --- src/__tests__/proxy-command.test.ts | 41 ++++++++++++++++++++++------- src/cli/commands/proxy.ts | 25 +++++++++++++++--- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/__tests__/proxy-command.test.ts b/src/__tests__/proxy-command.test.ts index 184632afe..971359b2c 100644 --- a/src/__tests__/proxy-command.test.ts +++ b/src/__tests__/proxy-command.test.ts @@ -1,20 +1,23 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { renderProxyStartup } from '../cli/commands/proxy.ts'; +import { colorize } from '../utils/output.ts'; -test('renderProxyStartup keeps human output concise', () => { - const output = renderProxyStartup({ - proxyBaseUrl: 'http://127.0.0.1:4310', - agentDeviceBaseUrl: 'http://127.0.0.1:4310/agent-device', - token: 'proxy-secret', - upstreamBaseUrl: 'http://127.0.0.1:60149', - stateDir: '/private/tmp/agent-device-proxy', - }); +const STARTUP = { + proxyBaseUrl: 'http://127.0.0.1:4310', + agentDeviceBaseUrl: 'http://127.0.0.1:4310/agent-device', + token: 'proxy-secret', + upstreamBaseUrl: 'http://127.0.0.1:60149', + stateDir: '/private/tmp/agent-device-proxy', +}; + +test('renderProxyStartup keeps human output concise without color', () => { + const output = renderProxyStartup(STARTUP, { useColor: false }); assert.equal( output, [ - '✔️ Proxy listening at http://127.0.0.1:4310', + '✓ Proxy listening at http://127.0.0.1:4310', '', 'Provide this to the agent-device instance connecting:', '', @@ -27,3 +30,23 @@ test('renderProxyStartup keeps human output concise', () => { assert.doesNotMatch(output, /Remote client example/); assert.doesNotMatch(output, /agent-device devices --daemon-base-url/); }); + +test('renderProxyStartup colors status, urls, and token', () => { + const output = renderProxyStartup(STARTUP, { useColor: true }); + + assert.equal( + output, + [ + `${colored('✓', 'green')} Proxy listening at ${colored('http://127.0.0.1:4310', 'cyan')}`, + '', + 'Provide this to the agent-device instance connecting:', + '', + `Daemon base URL: ${colored('/agent-device', 'cyan')}`, + `Daemon auth token: ${colored('proxy-secret', 'yellow')}`, + ].join('\n'), + ); +}); + +function colored(text: string, format: Parameters[1]): string { + return colorize(text, format, { validateStream: false }); +} diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index fe5de830e..240175a47 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -3,6 +3,7 @@ import { createDaemonProxyServer } from '../../daemon-proxy.ts'; import { buildDaemonHttpBaseUrl } from '../../daemon/http-contract.ts'; import { ensureDaemon, resolveClientSettings } from '../../daemon-client-lifecycle.ts'; import { AppError } from '../../utils/errors.ts'; +import { colorize, supportsColor } from '../../utils/output.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; @@ -89,17 +90,33 @@ function formatHostForUrl(host: string): string { return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; } -export function renderProxyStartup(startup: ProxyStartup): string { +export function renderProxyStartup( + startup: ProxyStartup, + options: { useColor?: boolean } = {}, +): string { + const useColor = options.useColor ?? supportsColor(); + const checkmark = formatProxyOutputValue('✓', 'green', useColor); + const proxyBaseUrl = formatProxyOutputValue(startup.proxyBaseUrl, 'cyan', useColor); + const daemonBaseUrl = formatProxyOutputValue('/agent-device', 'cyan', useColor); + const token = formatProxyOutputValue(startup.token, 'yellow', useColor); return [ - `✔️ Proxy listening at ${startup.proxyBaseUrl}`, + `${checkmark} Proxy listening at ${proxyBaseUrl}`, '', 'Provide this to the agent-device instance connecting:', '', - 'Daemon base URL: /agent-device', - `Daemon auth token: ${startup.token}`, + `Daemon base URL: ${daemonBaseUrl}`, + `Daemon auth token: ${token}`, ].join('\n'); } +function formatProxyOutputValue( + value: string, + format: Parameters[1], + useColor: boolean, +): string { + return useColor ? colorize(value, format, { validateStream: false }) : value; +} + function waitForever(): Promise { return new Promise(() => {}); } From 386abe7071305d81b49403b14fc55cc2b6801222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 18:38:22 +0200 Subject: [PATCH 16/16] fix: simplify proxy tunnel placeholder --- src/__tests__/proxy-command.test.ts | 4 ++-- src/cli/commands/proxy.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/proxy-command.test.ts b/src/__tests__/proxy-command.test.ts index 971359b2c..ca9eca921 100644 --- a/src/__tests__/proxy-command.test.ts +++ b/src/__tests__/proxy-command.test.ts @@ -21,7 +21,7 @@ test('renderProxyStartup keeps human output concise without color', () => { '', 'Provide this to the agent-device instance connecting:', '', - 'Daemon base URL: /agent-device', + 'Daemon base URL: ', 'Daemon auth token: proxy-secret', ].join('\n'), ); @@ -41,7 +41,7 @@ test('renderProxyStartup colors status, urls, and token', () => { '', 'Provide this to the agent-device instance connecting:', '', - `Daemon base URL: ${colored('/agent-device', 'cyan')}`, + `Daemon base URL: ${colored('', 'cyan')}`, `Daemon auth token: ${colored('proxy-secret', 'yellow')}`, ].join('\n'), ); diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 240175a47..2188fd711 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -97,7 +97,7 @@ export function renderProxyStartup( const useColor = options.useColor ?? supportsColor(); const checkmark = formatProxyOutputValue('✓', 'green', useColor); const proxyBaseUrl = formatProxyOutputValue(startup.proxyBaseUrl, 'cyan', useColor); - const daemonBaseUrl = formatProxyOutputValue('/agent-device', 'cyan', useColor); + const daemonBaseUrl = formatProxyOutputValue('', 'cyan', useColor); const token = formatProxyOutputValue(startup.token, 'yellow', useColor); return [ `${checkmark} Proxy listening at ${proxyBaseUrl}`,