mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
fix: clarify proxy runner ownership (#882)
* fix: clarify proxy runner ownership * fix: tighten proxy runner review follow-ups
This commit is contained in:
committed by
GitHub
parent
98c0b1d3bf
commit
8c49d54ff1
@@ -32,7 +32,7 @@ const prepareCliSchema = {
|
||||
usageOverride: 'prepare ios-runner --platform ios|macos [--timeout <ms>]',
|
||||
listUsageOverride: 'prepare',
|
||||
helpDescription:
|
||||
'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.',
|
||||
'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. It is not a recovery step for "runner already owned by another agent-device daemon"; stop or clean the owning daemon on the Mac with simulator access instead. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.',
|
||||
summary:
|
||||
'Pre-warm platform helpers, especially the iOS/macOS XCTest runner before Apple automation',
|
||||
positionalArgs: ['ios-runner'],
|
||||
|
||||
@@ -32,7 +32,12 @@ import {
|
||||
type DaemonInfo,
|
||||
type DaemonStartupCleanupResult,
|
||||
} from './daemon-client-metadata.ts';
|
||||
import { canConnect, readRemoteDaemonHealth } from './daemon-client-transport.ts';
|
||||
import {
|
||||
canConnect,
|
||||
DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE,
|
||||
DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE,
|
||||
readRemoteDaemonHealth,
|
||||
} from './daemon-client-transport.ts';
|
||||
|
||||
export type DaemonClientSettings = {
|
||||
paths: DaemonPaths;
|
||||
@@ -166,7 +171,7 @@ async function readReusableLocalDaemon(settings: DaemonClientSettings): Promise<
|
||||
const existing = readDaemonInfo(settings.paths.infoPath);
|
||||
if (!existing) return null;
|
||||
|
||||
const existingReachable = await canConnect(existing, settings.transportPreference);
|
||||
const existingReachable = await canConnectReusableDaemon(existing, settings.transportPreference);
|
||||
if (isReusableDaemonInfo(existing, existingReachable)) return existing;
|
||||
|
||||
emitDaemonTakeoverNotice(existing, existingReachable, settings.paths.baseDir);
|
||||
@@ -175,6 +180,27 @@ async function readReusableLocalDaemon(settings: DaemonClientSettings): Promise<
|
||||
return null;
|
||||
}
|
||||
|
||||
async function canConnectReusableDaemon(
|
||||
info: DaemonInfo,
|
||||
preference: DaemonTransportPreference,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await canConnect(info, preference);
|
||||
} catch (error) {
|
||||
if (isDaemonTransportUnavailableError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isDaemonTransportUnavailableError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof AppError &&
|
||||
error.code === 'COMMAND_FAILED' &&
|
||||
(error.message === DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE ||
|
||||
error.message === DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE)
|
||||
);
|
||||
}
|
||||
|
||||
function isReusableDaemonInfo(info: DaemonInfo, reachable: boolean): boolean {
|
||||
return (
|
||||
info.version === readVersion() &&
|
||||
|
||||
@@ -22,6 +22,8 @@ type ResolvedDaemonTransport = 'socket' | 'http';
|
||||
|
||||
const LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS = 500;
|
||||
const REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS = 3000;
|
||||
export const DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE = 'Daemon HTTP endpoint is unavailable';
|
||||
export const DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE = 'Daemon socket endpoint is unavailable';
|
||||
|
||||
export type RemoteDaemonHealth = {
|
||||
reachable: boolean;
|
||||
@@ -254,8 +256,8 @@ function requireDaemonTransport(
|
||||
throw new AppError(
|
||||
'COMMAND_FAILED',
|
||||
transport === 'http'
|
||||
? 'Daemon HTTP endpoint is unavailable'
|
||||
: 'Daemon socket endpoint is unavailable',
|
||||
? DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE
|
||||
: DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -294,7 +296,7 @@ async function sendSocketRequest(
|
||||
timeoutMs: number | undefined,
|
||||
): Promise<DaemonResponse> {
|
||||
const port = info.port;
|
||||
if (!port) throw new AppError('COMMAND_FAILED', 'Daemon socket endpoint is unavailable');
|
||||
if (!port) throw new AppError('COMMAND_FAILED', DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE);
|
||||
return new Promise((resolve, reject) => {
|
||||
let requestWritten = false;
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port }, () => {
|
||||
@@ -360,7 +362,7 @@ async function sendHttpRequest(
|
||||
: info.httpPort
|
||||
? new URL(`http://127.0.0.1:${info.httpPort}/rpc`)
|
||||
: null;
|
||||
if (!rpcUrl) throw new AppError('COMMAND_FAILED', 'Daemon HTTP endpoint is unavailable');
|
||||
if (!rpcUrl) throw new AppError('COMMAND_FAILED', DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE);
|
||||
const rpcPayload = JSON.stringify(buildHttpRpcPayload(req, { includeTokenParam: !info.baseUrl }));
|
||||
const headers: Record<string, string | number> = {
|
||||
'content-type': 'application/json',
|
||||
|
||||
@@ -662,25 +662,80 @@ test('runner session startup kills legacy ownerless xcodebuild before launching
|
||||
|
||||
test('runner session startup rejects live foreign runner lease', async () => {
|
||||
const device = { ...IOS_SIMULATOR, id: 'runner-session-busy-lease-sim' };
|
||||
const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR;
|
||||
process.env.AGENT_DEVICE_STATE_DIR = '/tmp/agent-device-current';
|
||||
writeRunnerLease(
|
||||
makeRunnerLease({
|
||||
deviceId: device.id,
|
||||
ownerToken: 'owner-foreign-live',
|
||||
ownerPid: process.pid,
|
||||
ownerStartTime: RUNNER_OWNER_START_TIME,
|
||||
ownerStateDir: '/tmp/agent-device-owner',
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => ensureRunnerSession(device, {}),
|
||||
/already owned by another agent-device daemon/,
|
||||
try {
|
||||
let thrown: unknown;
|
||||
await assert.rejects(async () => {
|
||||
try {
|
||||
await ensureRunnerSession(device, {});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
throw error;
|
||||
}
|
||||
}, /already owned by another agent-device daemon/);
|
||||
|
||||
assert.equal(
|
||||
(thrown as { details?: Record<string, unknown> }).details?.ownerStateDir,
|
||||
'/tmp/agent-device-owner',
|
||||
);
|
||||
assert.match(
|
||||
String((thrown as { details?: Record<string, unknown> }).details?.hint),
|
||||
/Do not run prepare ios-runner/,
|
||||
);
|
||||
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
|
||||
assert.equal(
|
||||
mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
if (previousStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR;
|
||||
else process.env.AGENT_DEVICE_STATE_DIR = previousStateDir;
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
const stateDir = '/tmp/agent-device-proxy-state';
|
||||
process.env.AGENT_DEVICE_STATE_DIR = stateDir;
|
||||
writeRunnerLease(
|
||||
makeRunnerLease({
|
||||
deviceId: device.id,
|
||||
ownerToken: 'owner-foreign-same-state',
|
||||
ownerPid: process.pid,
|
||||
ownerStartTime: RUNNER_OWNER_START_TIME,
|
||||
ownerStateDir: stateDir,
|
||||
runnerPid: 4_321,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
|
||||
assert.equal(
|
||||
mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'),
|
||||
false,
|
||||
);
|
||||
try {
|
||||
const session = await ensureRunnerSession(device, {});
|
||||
|
||||
assert.equal(session.deviceId, device.id);
|
||||
assert.equal(mockRunCmdBackground.mock.calls.length, 1);
|
||||
assert.deepEqual(mockCleanupTempFile.mock.calls, [
|
||||
[`/tmp/AgentDeviceRunner.env.session-${device.id}-owner-foreign-same-state-8123.xctestrun`],
|
||||
[`/tmp/AgentDeviceRunner.env.session-${device.id}-owner-foreign-same-state-8123.json`],
|
||||
]);
|
||||
const pkillCalls = mockRunAppleToolCommand.mock.calls.filter(isXcodebuildPkillCall);
|
||||
assert.ok(pkillCalls.length >= 2);
|
||||
assert.match(String(pkillCalls[0]?.[1]?.[2] ?? ''), /owner-foreign-same-state/);
|
||||
} finally {
|
||||
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 () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ export type RunnerLease = {
|
||||
ownerToken: string;
|
||||
ownerPid: number;
|
||||
ownerStartTime: string | null;
|
||||
ownerStateDir?: string;
|
||||
sessionId: string;
|
||||
runnerPid: number | null;
|
||||
port: number;
|
||||
@@ -61,6 +62,7 @@ export function buildRunnerLease(params: {
|
||||
ownerToken: RUNNER_OWNER_TOKEN,
|
||||
ownerPid: RUNNER_OWNER_PID,
|
||||
ownerStartTime: RUNNER_OWNER_START_TIME,
|
||||
ownerStateDir: readCurrentStateDir(),
|
||||
sessionId: params.sessionId,
|
||||
runnerPid: params.runnerPid ?? null,
|
||||
port: params.port,
|
||||
@@ -115,6 +117,10 @@ export async function prepareRunnerLeaseForStartup(
|
||||
return;
|
||||
}
|
||||
if (state.type === 'busy') {
|
||||
if (isSameStateDirRunnerLease(state.lease)) {
|
||||
await cleanupLeasedRunnerProcesses(state.lease, 'same-state-dir', cleanup);
|
||||
return;
|
||||
}
|
||||
throw new AppError(
|
||||
'COMMAND_FAILED',
|
||||
`iOS runner for ${deviceId} is already owned by another agent-device daemon`,
|
||||
@@ -122,15 +128,36 @@ export async function prepareRunnerLeaseForStartup(
|
||||
deviceId,
|
||||
ownerPid: state.lease.ownerPid,
|
||||
ownerStartTime: state.lease.ownerStartTime,
|
||||
ownerStateDir: state.lease.ownerStateDir,
|
||||
ownerToken: state.lease.ownerToken,
|
||||
sessionId: state.lease.sessionId,
|
||||
hint: 'Use a different simulator/session, wait for the other run to finish, or stop the owning daemon before retrying.',
|
||||
hint: buildBusyRunnerLeaseHint(state.lease),
|
||||
},
|
||||
);
|
||||
}
|
||||
await cleanupLeasedRunnerProcesses(state.lease, state.type, cleanup);
|
||||
}
|
||||
|
||||
function isSameStateDirRunnerLease(lease: RunnerLease): boolean {
|
||||
// Same-state reclaim assumes callers sharing AGENT_DEVICE_STATE_DIR are the same logical daemon owner.
|
||||
const currentStateDir = readCurrentStateDir();
|
||||
if (!currentStateDir || !lease.ownerStateDir) return false;
|
||||
return path.resolve(currentStateDir) === path.resolve(lease.ownerStateDir);
|
||||
}
|
||||
|
||||
function readCurrentStateDir(): string | undefined {
|
||||
return process.env.AGENT_DEVICE_STATE_DIR?.trim() || undefined;
|
||||
}
|
||||
|
||||
function buildBusyRunnerLeaseHint(lease: RunnerLease): string {
|
||||
const owner = `PID ${lease.ownerPid}`;
|
||||
const stateDir = lease.ownerStateDir ? ` with AGENT_DEVICE_STATE_DIR=${lease.ownerStateDir}` : '';
|
||||
return [
|
||||
`The Mac operator must stop the owning daemon (${owner}${stateDir}) or wait for that run to finish, then retry.`,
|
||||
'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(' ');
|
||||
}
|
||||
|
||||
export async function cleanupOwnedRunnerLease(
|
||||
deviceId: string,
|
||||
cleanup: RunnerLeaseCleanupAdapter,
|
||||
@@ -240,6 +267,7 @@ function normalizeRunnerLease(value: unknown, deviceId: string): RunnerLease | n
|
||||
deviceId,
|
||||
...fields,
|
||||
ownerStartTime: readOptionalString(raw.ownerStartTime),
|
||||
ownerStateDir: readOptionalString(raw.ownerStateDir) ?? undefined,
|
||||
runnerPid: readPositiveInteger(raw.runnerPid),
|
||||
};
|
||||
}
|
||||
@@ -286,15 +314,16 @@ function isRunnerLeaseOwnerAlive(lease: RunnerLease): boolean {
|
||||
|
||||
async function cleanupLeasedRunnerProcesses(
|
||||
lease: RunnerLease,
|
||||
reason: 'owned' | 'stale',
|
||||
reason: 'owned' | 'stale' | 'same-state-dir',
|
||||
cleanup: RunnerLeaseCleanupAdapter,
|
||||
): Promise<void> {
|
||||
emitDiagnostic({
|
||||
level: reason === 'stale' ? 'warn' : 'debug',
|
||||
level: reason === 'stale' || reason === 'same-state-dir' ? 'warn' : 'debug',
|
||||
phase: 'ios_runner_lease_cleanup',
|
||||
data: {
|
||||
deviceId: lease.deviceId,
|
||||
ownerPid: lease.ownerPid,
|
||||
ownerStateDir: lease.ownerStateDir,
|
||||
ownerToken: lease.ownerToken,
|
||||
runnerPid: lease.runnerPid,
|
||||
sessionId: lease.sessionId,
|
||||
|
||||
@@ -1096,6 +1096,10 @@ test('usage includes only global flags in the top-level global flags section', (
|
||||
|
||||
test('usage includes agent workflows, config, environment, and examples footers', () => {
|
||||
const usageText = usage();
|
||||
assert.ok(
|
||||
usageText.indexOf('Agent Workflows:') < usageText.indexOf('Commands:'),
|
||||
'Agent workflows should appear before the command list for agents that only read the top of help.',
|
||||
);
|
||||
assert.match(usageText, /Agent Quickstart:/);
|
||||
assert.match(usageText, /Default loop: devices\/apps -> open -> snapshot -i/);
|
||||
assert.match(usageText, /Use selectors or refs as positional targets/);
|
||||
@@ -1122,7 +1126,12 @@ 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 <ref> --button secondary/);
|
||||
assert.match(usageText, /Remote workflow profiles use --remote-config/);
|
||||
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, /app-owned back uses back/);
|
||||
assert.match(usageText, /Web browser sessions: read help web/);
|
||||
assert.match(
|
||||
@@ -1230,6 +1239,10 @@ test('usageForCommand documents prepare ios-runner', () => {
|
||||
assert.match(help, /XCTest runner/);
|
||||
assert.match(help, /separate daemon/);
|
||||
assert.match(help, /clean:daemon after prepare/);
|
||||
assert.match(
|
||||
help,
|
||||
/not a recovery step for "runner already owned by another agent-device daemon"/,
|
||||
);
|
||||
assert.match(help, /Runner build\/start output is written to the session runner\.log/);
|
||||
});
|
||||
|
||||
@@ -1321,6 +1334,10 @@ test('usageForCommand resolves workflow help topic', () => {
|
||||
assert.match(help, /metro prepare --kind expo/);
|
||||
assert.match(help, /agent-device prepare ios-runner --platform ios --timeout 240000/);
|
||||
assert.match(help, /prepare ios-runner builds\/reuses the XCTest runner/);
|
||||
assert.match(
|
||||
help,
|
||||
/not a recovery step for "runner already owned by another agent-device daemon"/,
|
||||
);
|
||||
assert.match(help, /prepared runner does not keep a live lease/);
|
||||
assert.match(help, /help react-devtools/);
|
||||
assert.match(help, /help react-native/);
|
||||
@@ -1443,7 +1460,11 @@ 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, /without --remote-config/);
|
||||
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, /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/);
|
||||
@@ -1453,11 +1474,15 @@ test('usageForCommand resolves remote help topic', () => {
|
||||
help,
|
||||
/--daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device --daemon-auth-token <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 platform selection on each command or workflow/);
|
||||
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/);
|
||||
assert.match(help, /same --remote-config to every operational command/);
|
||||
assert.match(help, /do not use agent-device auth for this direct proxy flow/);
|
||||
assert.match(help, /Do not use --remote-config unless you are using the tenant\/run\/lease/);
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -549,6 +549,47 @@ test('sendToDaemon prints a takeover notice before replacing an unreachable daem
|
||||
}
|
||||
});
|
||||
|
||||
test('sendToDaemon replaces socket-only daemon metadata when HTTP transport is requested', async (t) => {
|
||||
if (!(await supportsLoopbackBind())) {
|
||||
t.skip('loopback listeners are not permitted in this environment');
|
||||
return;
|
||||
}
|
||||
|
||||
const stateDir = makeTempStateDir('agent-device-daemon-http-takeover-');
|
||||
const paths = resolveDaemonPaths(stateDir);
|
||||
const freshDaemon = await startHttpDaemonFixture({ via: 'fresh-http-daemon' });
|
||||
vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir);
|
||||
installSpawnedHttpDaemon(paths, freshDaemon.port);
|
||||
writeDaemonInfo(paths, {
|
||||
port: 65_532,
|
||||
transport: 'socket',
|
||||
pid: 999_999,
|
||||
});
|
||||
const stderrCapture = captureStderr();
|
||||
|
||||
try {
|
||||
const response = await sendToDaemon({
|
||||
session: 'default',
|
||||
command: 'http-takeover-smoke',
|
||||
positionals: [],
|
||||
flags: { stateDir, daemonTransport: 'http' },
|
||||
meta: { requestId: 'req-http-takeover' },
|
||||
});
|
||||
|
||||
assert.deepEqual(response, { ok: true, data: { via: 'fresh-http-daemon' } });
|
||||
assert.equal(mockRunCmdDetached.mock.calls.length, 1);
|
||||
assert.deepEqual(freshDaemon.seenPaths, ['GET /health', 'POST /rpc']);
|
||||
assert.equal(
|
||||
stderrCapture.read(),
|
||||
`Replacing daemon (pid 999999, v${readVersion()}) in ${paths.baseDir}: unreachable\n`,
|
||||
);
|
||||
} finally {
|
||||
stderrCapture.restore();
|
||||
await closeLoopbackServer(freshDaemon.server);
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function captureStderr(): { read: () => string; restore: () => void } {
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
let captured = '';
|
||||
|
||||
+25
-17
@@ -56,7 +56,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 <ref> --button secondary, then snapshot -i. Longpress is for mobile hold gestures, not macOS secondary-click menus.',
|
||||
'Remote workflow profiles use --remote-config on operational commands. Do not substitute --config; --config only loads CLI defaults.',
|
||||
'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.',
|
||||
'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 <url> --platform web -> snapshot -i -> click/fill/get/is/find/wait/screenshot -> close.',
|
||||
@@ -130,7 +132,7 @@ Bootstrap:
|
||||
agent-device prepare ios-runner --platform ios --timeout 240000
|
||||
If app id is unknown, plan devices, apps, then open <discovered-app-id>. Discovery is not enough when the task asks to open/start the app.
|
||||
Install arguments are app/package id then artifact path. If the task says install, use install; use reinstall only when explicitly requested. Fresh runtime state is open --relaunch after install.
|
||||
In Apple CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner, health-checks it with a lightweight command, and retries one stuck/non-connecting runner launch before the first snapshot pays that setup cost. If the replay/test step starts a separate daemon, run clean:daemon after prepare so the prepared runner does not keep a live lease owned by the prepare daemon.
|
||||
In Apple CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner, health-checks it with a lightweight command, and retries one stuck/non-connecting runner launch before the first snapshot pays that setup cost. It is not a recovery step for "runner already owned by another agent-device daemon"; stop or clean the owning daemon on the Mac with simulator access instead. If the replay/test step starts a separate daemon, run clean:daemon after prepare so the prepared runner does not keep a live lease owned by the prepare daemon.
|
||||
CI may cache ~/.agent-device/ios-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch. Runner build/start output is written to the session's runner.log; daemon.log is for daemon lifecycle/startup issues.
|
||||
Do not open artifact paths or invent package ids. If apps lookup misses the target and no URL/artifact is provided, ask or stop.
|
||||
|
||||
@@ -242,7 +244,8 @@ 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/cloud: connect to discover a cloud profile, or connect --remote-config ./remote-config.json for a local profile; then open, snapshot, disconnect.
|
||||
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.
|
||||
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
|
||||
@@ -559,10 +562,22 @@ Android physical-device prerequisites:
|
||||
Android does not need the iOS runner signing setup. For React Native/Expo Metro reachability, read help react-native.`,
|
||||
},
|
||||
remote: {
|
||||
summary: 'Remote config, tenant, lease, and remote host flow',
|
||||
summary: 'Direct proxy, cloud profiles, and remote config',
|
||||
body: `agent-device help remote
|
||||
|
||||
Use remote config or the cloud connection profile when a profile owns daemon URL, auth, tenant, run, lease, device scope, and Metro hints. Do not restate those as individual flags unless overriding intentionally.
|
||||
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.
|
||||
|
||||
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 <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 <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 <token>
|
||||
agent-device close --session maps --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token <token>
|
||||
|
||||
Cloud profile flow:
|
||||
agent-device connect
|
||||
@@ -581,21 +596,14 @@ Script flow, per-command config:
|
||||
agent-device snapshot --remote-config ./remote-config.json
|
||||
agent-device disconnect --remote-config ./remote-config.json
|
||||
|
||||
Direct proxy flow for a remote Mac:
|
||||
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 <token>
|
||||
agent-device devices
|
||||
|
||||
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 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. Do not use --remote-config unless you are using the tenant/run/lease remote connection flow.
|
||||
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.
|
||||
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.
|
||||
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 <url> or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first.
|
||||
@@ -814,14 +822,14 @@ CLI to control iOS and Android devices for AI agents.
|
||||
const examplesSection = renderTextSection('Examples:', EXAMPLE_LINES);
|
||||
|
||||
return `${header}
|
||||
${workflowsSection}
|
||||
|
||||
${commandLines}
|
||||
|
||||
${flagsSection}
|
||||
|
||||
${quickstartSection}
|
||||
|
||||
${workflowsSection}
|
||||
|
||||
${configSection}
|
||||
|
||||
${environmentSection}
|
||||
|
||||
@@ -2060,6 +2060,39 @@ const SKILL_GUIDANCE_CASES: Case[] = [
|
||||
/adb shell/i,
|
||||
],
|
||||
}),
|
||||
makeCase({
|
||||
id: 'direct-proxy-remote-simulator-flow',
|
||||
contract: [
|
||||
'The agent is running in a cloud Linux environment',
|
||||
'The user says a remote iOS simulator is available through agent-device proxy',
|
||||
'Proxy daemon base URL: https://example.trycloudflare.com/agent-device',
|
||||
'Proxy daemon auth token: proxy-secret',
|
||||
'Platform: iOS',
|
||||
'Device: iPhone 17 Pro',
|
||||
'App: Maps',
|
||||
'This is direct proxy mode, not cloud/profile mode',
|
||||
'Use one explicit session for the whole flow: maps',
|
||||
],
|
||||
task: 'Plan commands to open Maps on the remote simulator through the direct proxy, capture interactive refs, and close the session.',
|
||||
outputs: [
|
||||
/open\s+Maps\b/i,
|
||||
/snapshot\b[^\n]*-i\b/i,
|
||||
/close\b/i,
|
||||
/--session maps/i,
|
||||
/--platform ios/i,
|
||||
/--device ["']iPhone 17 Pro["']/i,
|
||||
/--daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device/i,
|
||||
/--daemon-auth-token proxy-secret/i,
|
||||
],
|
||||
forbiddenOutputs: [
|
||||
plannedCommand('connect'),
|
||||
plannedCommand('disconnect'),
|
||||
/--remote-config/i,
|
||||
/--tenant/i,
|
||||
/--run-id/i,
|
||||
/--lease-id/i,
|
||||
],
|
||||
}),
|
||||
makeCase({
|
||||
id: 'remote-cloud-connect-flow',
|
||||
contract: [
|
||||
@@ -2120,6 +2153,26 @@ const SKILL_GUIDANCE_CASES: Case[] = [
|
||||
],
|
||||
forbiddenOutputs: [/--daemon-base-url/i, /--tenant/i, /--run-id/i],
|
||||
}),
|
||||
makeCase({
|
||||
id: 'remote-ios-runner-lease-retry-snapshot',
|
||||
contract: [
|
||||
'Direct proxy flow to a remote Mac is already configured',
|
||||
'Platform: iOS',
|
||||
'Device: iPhone 17 Pro',
|
||||
'The remote agent already opened Maps successfully',
|
||||
'The first interactive snapshot failed: iOS runner is already owned by another agent-device daemon',
|
||||
'The proxy daemon can reclaim stale same-state runner leases on retry',
|
||||
],
|
||||
task: 'Plan the next remote client command. Do not run prepare ios-runner or prescribe host process cleanup; retry the original interactive snapshot.',
|
||||
outputs: [/snapshot\b[^\n]*-i\b/i, /--platform ios/i, /--device ["']iPhone 17 Pro["']/i],
|
||||
forbiddenOutputs: [
|
||||
plannedCommand('prepare ios-runner'),
|
||||
/prepare\s+ios-runner/i,
|
||||
plannedCommand('open'),
|
||||
/\bkill\b/i,
|
||||
/clean:daemon/i,
|
||||
],
|
||||
}),
|
||||
makeCase({
|
||||
id: 'macos-menubar-surface',
|
||||
contract: [
|
||||
|
||||
Reference in New Issue
Block a user