Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@
- 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`.
- 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.
Expand Down
52 changes: 52 additions & 0 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ADR 0007: Remote Device Leases

## Status

Accepted

## Context

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
errors directly makes device contention harder to recover from.

## Decision

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

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

## 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, while provider-aware
clients get device-level contention and clearer recovery.
21 changes: 18 additions & 3 deletions src/__tests__/cloud-connect-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,20 +152,27 @@ function mockCloudConnectionProfile(connection: Record<string, unknown>): 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',
'runId',
'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<typeof vi.fn>): string | undefined {
Expand All @@ -190,8 +197,16 @@ async function connectWithGeneratedCloudProfile(stateDir: string): Promise<void>
}
}

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[] {
Expand Down
52 changes: 52 additions & 0 deletions src/__tests__/proxy-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { renderProxyStartup } from '../cli/commands/proxy.ts';
import { colorize } from '../utils/output.ts';

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',
'',
'Provide this to the agent-device instance connecting:',
'',
'Daemon base URL: <tunnel URL>',
'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/);
});

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('<tunnel URL>', 'cyan')}`,
`Daemon auth token: ${colored('proxy-secret', 'yellow')}`,
].join('\n'),
);
});

function colored(text: string, format: Parameters<typeof colorize>[1]): string {
return colorize(text, format, { validateStream: false });
}
Loading
Loading