Skip to content

Commit a822325

Browse files
thymikeeclaude
andauthored
feat: add integrated device leasing (#890)
* feat: add integrated device leasing * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPa5Z9GBkeqoxVctC85N7e * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPa5Z9GBkeqoxVctC85N7e * fix: reconcile integrated device leasing * docs: simplify remote lease guidance * refactor: satisfy leasing fallow checks * fix: harden integrated device leasing * refactor: deepen device lease lifecycle * refactor: centralize lease scope projection * fix: harden proxy lease e2e flow * fix: address lease review feedback * refactor: tighten lease release cleanup * fix: simplify proxy startup output * fix: harden cloud lease identity * fix: color proxy startup output * fix: simplify proxy tunnel placeholder --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f92572 commit a822325

65 files changed

Lines changed: 4576 additions & 761 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTEXT.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@
1515
- Modality: broad supported device family, such as mobile, tv, or desktop.
1616
- Session: daemon-owned state for a selected target and opened app or surface.
1717
- 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.
18+
- Device lease: logical remote ownership of one selected device for a
19+
tenant/run/client and lease provider, separate from platform helper process
20+
locking.
21+
- Device key: stable provider-scoped device identity used for lease contention,
22+
such as a simulator UDID, physical device id, or provider inventory id.
23+
- Lease provider: remote connection source that routes and owns a device lease,
24+
such as `proxy`, cloud bridge, or `limrun`.
25+
- Runner/process lease: backend helper mutual-exclusion guard for platform
26+
runners or tools; it is not the remote client ownership boundary.
1827
- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints.
1928
- 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.
2029
- 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.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# ADR 0007: Remote Device Leases
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Context
8+
9+
Remote daemon users need a clear ownership boundary before commands reach a
10+
platform runner or helper. Shared proxy and hosted providers need ownership to
11+
include the selected device and connection provider, not only tenant/run.
12+
13+
Runner and helper processes already have backend-specific mutual exclusion. That
14+
guard protects platform tooling, not remote client ownership, so surfacing those
15+
errors directly makes device contention harder to recover from.
16+
17+
## Decision
18+
19+
A remote device lease is logical ownership of one selected device by one
20+
remote client for a connection provider such as `proxy`, cloud, or `limrun`.
21+
22+
`connect` establishes connection profile and client identity. Lease allocation
23+
is lazy and happens when a device, backend, and provider are known.
24+
25+
A runner/process lease is a backend helper guard and is not a user/client
26+
ownership boundary. It stays below daemon device leases and should not be
27+
weakened or replaced by them.
28+
29+
`open` is the natural point to acquire a device lease because target resolution
30+
and session creation meet there. Commands after `open` must refresh the lease;
31+
no activity for five minutes should make the device available again.
32+
33+
Lease admission, heartbeat, stored session lease refresh, and request execution
34+
must run under the same daemon request lock. Scope resolution may happen before
35+
the lock, but lease ownership mutation must not.
36+
37+
Generated connection profiles are non-secret. They may persist routing and
38+
lease metadata, but must strip daemon and Metro bearer tokens. Tokens are
39+
supplied in-memory for the current command or through environment/CLI token
40+
paths.
41+
42+
The proxy process is expected to be long-lived and self-serve. Recovery from a
43+
stale or expired device lease should not require restarting the proxy.
44+
45+
## Consequences
46+
47+
Device contention can fail before platform execution with an explicit
48+
device-lease error that includes the backend, provider, selected device key, and
49+
owning lease expiry.
50+
51+
Backend-only leases remain valid for older remote clients, while provider-aware
52+
clients get device-level contention and clearer recovery.

src/__tests__/cloud-connect-profile.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,20 +152,27 @@ function mockCloudConnectionProfile(connection: Record<string, unknown>): Return
152152
function assertGeneratedProfileState(state: RemoteConnectionState): void {
153153
assert.equal(state.tenant, 'acme');
154154
assert.equal(state.runId, 'demo-run-001');
155+
assert.equal(state.leaseProvider, 'cloud');
156+
assert.match(state.clientId ?? '', /^[a-f0-9]{16}$/);
155157
assert.equal(state.daemon?.baseUrl, 'https://bridge.example.com/agent-device');
156158
assert.match(state.remoteConfigPath, /remote-connections\/generated\/cloud-[a-f0-9]{16}\.json$/);
157159
assert.equal(state.remoteConfigHash, hashRemoteConfigFile(state.remoteConfigPath));
158160
assert.deepEqual(readGeneratedConfigKeys(state.remoteConfigPath), [
161+
'clientId',
159162
'daemonBaseUrl',
160163
'daemonTransport',
164+
'leaseProvider',
161165
'metroKind',
162166
'metroProxyBaseUrl',
163167
'metroPublicBaseUrl',
164168
'runId',
165169
'sessionIsolation',
166170
'tenant',
167171
]);
168-
assert.equal(readGeneratedConfig(state.remoteConfigPath).tenant, 'acme');
172+
const generated = readGeneratedConfig(state.remoteConfigPath);
173+
assert.equal(generated.tenant, 'acme');
174+
assert.equal(generated.leaseProvider, 'cloud');
175+
assert.equal(generated.clientId, state.clientId);
169176
}
170177

171178
function fetchProfileUrl(fetchMock: ReturnType<typeof vi.fn>): string | undefined {
@@ -190,8 +197,16 @@ async function connectWithGeneratedCloudProfile(stateDir: string): Promise<void>
190197
}
191198
}
192199

193-
function readGeneratedConfig(configPath: string): { tenant?: string } {
194-
return JSON.parse(fs.readFileSync(configPath, 'utf8')) as { tenant?: string };
200+
function readGeneratedConfig(configPath: string): {
201+
tenant?: string;
202+
leaseProvider?: string;
203+
clientId?: string;
204+
} {
205+
return JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
206+
tenant?: string;
207+
leaseProvider?: string;
208+
clientId?: string;
209+
};
195210
}
196211

197212
function readGeneratedConfigKeys(configPath: string): string[] {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { renderProxyStartup } from '../cli/commands/proxy.ts';
4+
import { colorize } from '../utils/output.ts';
5+
6+
const STARTUP = {
7+
proxyBaseUrl: 'http://127.0.0.1:4310',
8+
agentDeviceBaseUrl: 'http://127.0.0.1:4310/agent-device',
9+
token: 'proxy-secret',
10+
upstreamBaseUrl: 'http://127.0.0.1:60149',
11+
stateDir: '/private/tmp/agent-device-proxy',
12+
};
13+
14+
test('renderProxyStartup keeps human output concise without color', () => {
15+
const output = renderProxyStartup(STARTUP, { useColor: false });
16+
17+
assert.equal(
18+
output,
19+
[
20+
'✓ Proxy listening at http://127.0.0.1:4310',
21+
'',
22+
'Provide this to the agent-device instance connecting:',
23+
'',
24+
'Daemon base URL: <tunnel URL>',
25+
'Daemon auth token: proxy-secret',
26+
].join('\n'),
27+
);
28+
assert.doesNotMatch(output, /upstream local daemon/);
29+
assert.doesNotMatch(output, /state dir/);
30+
assert.doesNotMatch(output, /Remote client example/);
31+
assert.doesNotMatch(output, /agent-device devices --daemon-base-url/);
32+
});
33+
34+
test('renderProxyStartup colors status, urls, and token', () => {
35+
const output = renderProxyStartup(STARTUP, { useColor: true });
36+
37+
assert.equal(
38+
output,
39+
[
40+
`${colored('✓', 'green')} Proxy listening at ${colored('http://127.0.0.1:4310', 'cyan')}`,
41+
'',
42+
'Provide this to the agent-device instance connecting:',
43+
'',
44+
`Daemon base URL: ${colored('<tunnel URL>', 'cyan')}`,
45+
`Daemon auth token: ${colored('proxy-secret', 'yellow')}`,
46+
].join('\n'),
47+
);
48+
});
49+
50+
function colored(text: string, format: Parameters<typeof colorize>[1]): string {
51+
return colorize(text, format, { validateStream: false });
52+
}

0 commit comments

Comments
 (0)