Files
callstack__agent-device/src/daemon-client.ts
T
2026-04-13 14:11:40 +02:00

1431 lines
45 KiB
TypeScript

import net from 'node:net';
import http from 'node:http';
import https from 'node:https';
import fs from 'node:fs';
import path from 'node:path';
import { AppError } from './utils/errors.ts';
import type {
DaemonArtifact,
DaemonRequest as SharedDaemonRequest,
DaemonResponse as SharedDaemonResponse,
} from './daemon/types.ts';
import { runCmdDetached, runCmdSync } from './utils/exec.ts';
import { findProjectRoot, readVersion } from './utils/version.ts';
import { createRequestId, emitDiagnostic, withDiagnosticTimer } from './utils/diagnostics.ts';
import { isAgentDeviceDaemonProcess, stopProcessForTakeover } from './utils/process-identity.ts';
import {
resolveDaemonPaths,
resolveDaemonServerMode,
resolveDaemonTransportPreference,
type DaemonPaths,
type DaemonServerMode,
type DaemonTransportPreference,
} from './daemon/config.ts';
import { uploadArtifact } from './upload-client.ts';
import { computeDaemonCodeSignature } from './daemon/code-signature.ts';
export { computeDaemonCodeSignature } from './daemon/code-signature.ts';
export type DaemonRequest = SharedDaemonRequest;
export type DaemonResponse = SharedDaemonResponse;
export type OpenAppOptions = {
session?: string;
app?: string;
url?: string;
lockPolicy?: NonNullable<DaemonRequest['meta']>['lockPolicy'];
lockPlatform?: NonNullable<DaemonRequest['meta']>['lockPlatform'];
platform?: NonNullable<DaemonRequest['flags']>['platform'];
target?: NonNullable<DaemonRequest['flags']>['target'];
device?: NonNullable<DaemonRequest['flags']>['device'];
udid?: NonNullable<DaemonRequest['flags']>['udid'];
serial?: NonNullable<DaemonRequest['flags']>['serial'];
activity?: NonNullable<DaemonRequest['flags']>['activity'];
out?: NonNullable<DaemonRequest['flags']>['out'];
saveScript?: NonNullable<DaemonRequest['flags']>['saveScript'];
relaunch?: boolean;
runtime?: DaemonRequest['runtime'];
meta?: Omit<NonNullable<DaemonRequest['meta']>, 'uploadedArtifactId' | 'clientArtifactPaths'>;
};
type DaemonInfo = {
port?: number;
httpPort?: number;
transport?: 'socket' | 'http' | 'dual';
token: string;
pid: number;
version?: string;
codeSignature?: string;
processStartTime?: string;
baseUrl?: string;
};
type DaemonLockInfo = {
pid: number;
processStartTime?: string;
startedAt?: number;
};
type DaemonMetadataState = {
hasInfo: boolean;
hasLock: boolean;
};
type DaemonClientSettings = {
paths: DaemonPaths;
transportPreference: DaemonTransportPreference;
serverMode: DaemonServerMode;
remoteBaseUrl?: string;
remoteAuthToken?: string;
};
type ResolvedDaemonTransport = 'socket' | 'http';
const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs();
const DAEMON_STARTUP_TIMEOUT_MS = resolveDaemonStartupTimeoutMs();
const DAEMON_STARTUP_ATTEMPTS = resolveDaemonStartupAttempts();
const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000;
const LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS = 500;
const REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS = 3000;
const IOS_RUNNER_XCODEBUILD_KILL_PATTERNS = [
'xcodebuild .*AgentDeviceRunnerUITests/RunnerTests/testCommand',
'xcodebuild .*AgentDeviceRunner\\.env\\.session-',
'xcodebuild build-for-testing .*ios-runner/AgentDeviceRunner/AgentDeviceRunner\\.xcodeproj',
];
const LOOPBACK_BLOCK_LIST = new net.BlockList();
LOOPBACK_BLOCK_LIST.addSubnet('127.0.0.0', 8, 'ipv4');
LOOPBACK_BLOCK_LIST.addAddress('::1', 'ipv6');
LOOPBACK_BLOCK_LIST.addSubnet('::ffff:127.0.0.0', 104, 'ipv6');
export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
const requestId = req.meta?.requestId ?? createRequestId();
const debug = Boolean(req.meta?.debug || req.flags?.verbose);
const settings = resolveClientSettings(req);
const requestTimeoutMs = resolveDaemonRequestTimeoutForCommand(req.command);
const info = await withDiagnosticTimer(
'daemon_startup',
async () => await ensureDaemon(settings),
{ requestId, session: req.session },
);
const preparedRemoteRequest = await prepareRemoteRequest(req, info);
const request = {
...req,
positionals: preparedRemoteRequest.positionals,
flags: preparedRemoteRequest.flags,
token: info.token,
meta: {
...(req.meta ?? {}),
requestId,
debug,
cwd: req.meta?.cwd,
tenantId: req.meta?.tenantId ?? req.flags?.tenant,
runId: req.meta?.runId ?? req.flags?.runId,
leaseId: req.meta?.leaseId ?? req.flags?.leaseId,
sessionIsolation: req.meta?.sessionIsolation ?? req.flags?.sessionIsolation,
lockPolicy: req.meta?.lockPolicy,
lockPlatform: req.meta?.lockPlatform,
...(preparedRemoteRequest.uploadedArtifactId
? { uploadedArtifactId: preparedRemoteRequest.uploadedArtifactId }
: {}),
...(preparedRemoteRequest.clientArtifactPaths
? { clientArtifactPaths: preparedRemoteRequest.clientArtifactPaths }
: {}),
...(preparedRemoteRequest.installSource
? { installSource: preparedRemoteRequest.installSource }
: {}),
},
};
emitDiagnostic({
level: 'info',
phase: 'daemon_request_prepare',
data: {
requestId,
command: req.command,
session: req.session,
},
});
return await withDiagnosticTimer(
'daemon_request',
async () => await sendRequest(info, request, settings.transportPreference, requestTimeoutMs),
{ requestId, command: req.command },
);
}
export async function openApp(options: OpenAppOptions = {}): Promise<DaemonResponse> {
const {
session = 'default',
app,
url,
lockPolicy,
lockPlatform,
platform,
target,
device,
udid,
serial,
activity,
out,
saveScript,
relaunch,
runtime,
meta,
} = options;
const positionals = app ? (url ? [app, url] : [app]) : url ? [url] : [];
return await sendToDaemon({
session,
command: 'open',
positionals,
flags: {
...(platform !== undefined ? { platform } : {}),
...(target !== undefined ? { target } : {}),
...(device !== undefined ? { device } : {}),
...(udid !== undefined ? { udid } : {}),
...(serial !== undefined ? { serial } : {}),
...(activity !== undefined ? { activity } : {}),
...(out !== undefined ? { out } : {}),
...(saveScript !== undefined ? { saveScript } : {}),
...(relaunch ? { relaunch: true } : {}),
},
...(runtime !== undefined ? { runtime } : {}),
meta: {
...(meta ?? {}),
...(lockPolicy !== undefined ? { lockPolicy } : {}),
...(lockPlatform !== undefined ? { lockPlatform } : {}),
},
});
}
async function prepareRemoteRequest(
req: Omit<DaemonRequest, 'token'>,
info: DaemonInfo,
): Promise<{
positionals: string[];
flags?: DaemonRequest['flags'];
installSource?: NonNullable<DaemonRequest['meta']>['installSource'];
uploadedArtifactId?: string;
clientArtifactPaths?: Record<string, string>;
}> {
const positionals = [...(req.positionals ?? [])];
let flags = req.flags ? { ...req.flags } : undefined;
let installSource = req.meta?.installSource;
const clientArtifactPaths: Record<string, string> = {};
let uploadedArtifactId: string | undefined;
if (isRemoteDaemon(info)) {
const remoteArtifact = prepareRemoteArtifactCommand(req, positionals);
if (remoteArtifact) {
if (remoteArtifact.positionalPath !== undefined) {
positionals[remoteArtifact.positionalIndex] = remoteArtifact.positionalPath;
}
if (remoteArtifact.flagPath !== undefined) {
flags ??= {};
flags.out = remoteArtifact.flagPath;
}
clientArtifactPaths[remoteArtifact.field] = remoteArtifact.localPath;
}
const remoteInstallSource = await prepareRemoteInstallSource(req, info);
if (remoteInstallSource) {
installSource = remoteInstallSource.installSource;
uploadedArtifactId = remoteInstallSource.uploadedArtifactId ?? uploadedArtifactId;
}
}
if (
!isRemoteDaemon(info) ||
(req.command !== 'install' && req.command !== 'reinstall') ||
positionals.length < 2
) {
return {
positionals,
flags,
installSource,
uploadedArtifactId,
...(Object.keys(clientArtifactPaths).length > 0 ? { clientArtifactPaths } : {}),
};
}
const rawPath = positionals[1]!;
if (rawPath.startsWith('remote:')) {
positionals[1] = rawPath.slice('remote:'.length);
return {
positionals,
flags,
...(Object.keys(clientArtifactPaths).length > 0 ? { clientArtifactPaths } : {}),
};
}
const localPath = path.isAbsolute(rawPath)
? rawPath
: path.resolve(req.meta?.cwd ?? process.cwd(), rawPath);
if (!fs.existsSync(localPath)) {
return {
positionals,
flags,
...(Object.keys(clientArtifactPaths).length > 0 ? { clientArtifactPaths } : {}),
};
}
uploadedArtifactId = await uploadArtifact({
localPath,
baseUrl: info.baseUrl!,
token: info.token,
});
return {
positionals,
flags,
installSource,
uploadedArtifactId,
...(Object.keys(clientArtifactPaths).length > 0 ? { clientArtifactPaths } : {}),
};
}
async function prepareRemoteInstallSource(
req: Omit<DaemonRequest, 'token'>,
info: DaemonInfo,
): Promise<{
installSource: NonNullable<DaemonRequest['meta']>['installSource'];
uploadedArtifactId?: string;
} | null> {
const source = req.meta?.installSource;
if (req.command !== 'install_source' || !source || source.kind !== 'path') {
return null;
}
const rawPath = source.path.trim();
if (!rawPath) {
return { installSource: source };
}
if (rawPath.startsWith('remote:')) {
return {
installSource: {
...source,
path: rawPath.slice('remote:'.length),
},
};
}
const localPath = path.isAbsolute(rawPath)
? rawPath
: path.resolve(req.meta?.cwd ?? process.cwd(), rawPath);
if (!fs.existsSync(localPath)) {
return {
installSource: {
...source,
path: localPath,
},
};
}
const uploadedArtifactId = await uploadArtifact({
localPath,
baseUrl: info.baseUrl!,
token: info.token,
});
return {
installSource: {
...source,
path: localPath,
},
uploadedArtifactId,
};
}
function prepareRemoteArtifactCommand(
req: Omit<DaemonRequest, 'token'>,
positionals: string[],
): {
field: string;
localPath: string;
positionalIndex: number;
positionalPath?: string;
flagPath?: string;
} | null {
if (req.command === 'screenshot') {
const localPath = resolveClientArtifactOutputPath(req, 'path', '.png');
if (positionals[0]) {
return {
field: 'path',
localPath,
positionalIndex: 0,
positionalPath: buildRemoteTempArtifactPath('screenshot', '.png'),
};
}
return {
field: 'path',
localPath,
positionalIndex: 0,
flagPath: buildRemoteTempArtifactPath('screenshot', '.png'),
};
}
if (req.command === 'record' && (positionals[0] ?? '').toLowerCase() === 'start') {
const localPath = resolveClientArtifactOutputPath(req, 'outPath', '.mp4', 1);
return {
field: 'outPath',
localPath,
positionalIndex: 1,
positionalPath: buildRemoteTempArtifactPath('recording', path.extname(localPath) || '.mp4'),
};
}
return null;
}
function resolveClientArtifactOutputPath(
req: Omit<DaemonRequest, 'token'>,
field: 'path' | 'outPath',
fallbackExtension: string,
positionalIndex: number = 0,
): string {
const requested = req.positionals?.[positionalIndex] ?? req.flags?.out;
const fallbackName = `${field === 'path' ? 'screenshot' : 'recording'}-${Date.now()}${fallbackExtension}`;
const rawPath = requested && requested.trim().length > 0 ? requested : fallbackName;
return path.isAbsolute(rawPath) ? rawPath : path.resolve(req.meta?.cwd ?? process.cwd(), rawPath);
}
function buildRemoteTempArtifactPath(prefix: string, extension: string): string {
const safeExtension = extension.startsWith('.') ? extension : `.${extension}`;
return path.posix.join(
'/tmp',
`agent-device-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safeExtension}`,
);
}
function resolveClientSettings(req: Omit<DaemonRequest, 'token'>): DaemonClientSettings {
const stateDir = req.flags?.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR;
const remoteBaseUrl = resolveRemoteDaemonBaseUrl(
req.flags?.daemonBaseUrl ?? process.env.AGENT_DEVICE_DAEMON_BASE_URL,
);
const remoteAuthToken = req.flags?.daemonAuthToken ?? process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
validateRemoteDaemonTrust(remoteBaseUrl, remoteAuthToken);
const rawTransport = req.flags?.daemonTransport ?? process.env.AGENT_DEVICE_DAEMON_TRANSPORT;
const transportPreference = resolveDaemonTransportPreference(rawTransport);
if (remoteBaseUrl && transportPreference === 'socket') {
throw new AppError(
'INVALID_ARGS',
'Remote daemon base URL only supports HTTP transport. Remove --daemon-transport socket.',
{ daemonBaseUrl: remoteBaseUrl },
);
}
const rawServerMode =
req.flags?.daemonServerMode ??
process.env.AGENT_DEVICE_DAEMON_SERVER_MODE ??
(rawTransport === 'dual' ? 'dual' : undefined);
const serverMode = resolveDaemonServerMode(rawServerMode);
return {
paths: resolveDaemonPaths(stateDir),
transportPreference,
serverMode,
remoteBaseUrl,
remoteAuthToken,
};
}
async function ensureDaemon(settings: DaemonClientSettings): Promise<DaemonInfo> {
if (settings.remoteBaseUrl) {
const remoteInfo: DaemonInfo = {
transport: 'http',
// Remote mode reuses the auth token as the daemon token so the existing JSON-RPC contract still works.
token: settings.remoteAuthToken ?? '',
pid: 0,
baseUrl: settings.remoteBaseUrl,
};
if (await canConnect(remoteInfo, 'http')) return remoteInfo;
throw new AppError('COMMAND_FAILED', 'Remote daemon is unavailable', {
daemonBaseUrl: settings.remoteBaseUrl,
hint: 'Verify AGENT_DEVICE_DAEMON_BASE_URL points to a reachable daemon with GET /health and POST /rpc.',
});
}
const existing = readDaemonInfo(settings.paths.infoPath);
const localVersion = readVersion();
const localCodeSignature = resolveLocalDaemonCodeSignature();
const existingReachable = existing
? await canConnect(existing, settings.transportPreference)
: false;
if (
existing &&
existing.version === localVersion &&
existing.codeSignature === localCodeSignature &&
existingReachable
) {
return existing;
}
if (
existing &&
(existing.version !== localVersion ||
existing.codeSignature !== localCodeSignature ||
!existingReachable)
) {
await stopDaemonProcessForTakeover(existing);
removeDaemonInfo(settings.paths.infoPath);
}
cleanupStaleDaemonLockIfSafe(settings.paths);
let lockRecoveryCount = 0;
for (let attempt = 1; attempt <= DAEMON_STARTUP_ATTEMPTS; attempt += 1) {
await startDaemon(settings);
const started = await waitForDaemonInfo(DAEMON_STARTUP_TIMEOUT_MS, settings);
if (started) return started;
if (await recoverDaemonLockHolder(settings.paths)) {
lockRecoveryCount += 1;
continue;
}
const metadataState = getDaemonMetadataState(settings.paths);
const hasAnotherAttempt = attempt < DAEMON_STARTUP_ATTEMPTS;
if (!hasAnotherAttempt) break;
// Detached daemon startup can race on busy CI hosts; retry when no metadata exists yet.
if (!metadataState.hasInfo && !metadataState.hasLock) {
await sleepMs(150);
continue;
}
}
const state = getDaemonMetadataState(settings.paths);
throw new AppError('COMMAND_FAILED', 'Failed to start daemon', {
kind: 'daemon_startup_failed',
infoPath: settings.paths.infoPath,
lockPath: settings.paths.lockPath,
startupTimeoutMs: DAEMON_STARTUP_TIMEOUT_MS,
startupAttempts: DAEMON_STARTUP_ATTEMPTS,
lockRecoveryCount,
metadataState: state,
hint: resolveDaemonStartupHint(state, settings.paths),
});
}
async function waitForDaemonInfo(
timeoutMs: number,
settings: DaemonClientSettings,
): Promise<DaemonInfo | null> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const info = readDaemonInfo(settings.paths.infoPath);
if (info && (await canConnect(info, settings.transportPreference))) return info;
await new Promise((resolve) => setTimeout(resolve, 100));
}
return null;
}
async function sleepMs(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function recoverDaemonLockHolder(paths: DaemonPaths): Promise<boolean> {
const state = getDaemonMetadataState(paths);
if (!state.hasLock || state.hasInfo) return false;
const lockInfo = readDaemonLockInfo(paths.lockPath);
if (!lockInfo) {
removeDaemonLock(paths.lockPath);
return true;
}
if (!isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime)) {
removeDaemonLock(paths.lockPath);
return true;
}
await stopProcessForTakeover(lockInfo.pid, {
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
expectedStartTime: lockInfo.processStartTime,
});
removeDaemonLock(paths.lockPath);
return true;
}
async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise<void> {
await stopProcessForTakeover(info.pid, {
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
expectedStartTime: info.processStartTime,
});
}
function readDaemonInfo(infoPath: string): DaemonInfo | null {
const data = readJsonFile(infoPath);
if (!data || typeof data !== 'object') return null;
const parsed = data as Partial<DaemonInfo>;
const token = typeof parsed.token === 'string' && parsed.token.length > 0 ? parsed.token : null;
if (!token) return null;
const hasSocket = Number.isInteger(parsed.port) && Number(parsed.port) > 0;
const hasHttp = Number.isInteger(parsed.httpPort) && Number(parsed.httpPort) > 0;
if (!hasSocket && !hasHttp) return null;
const transport = parsed.transport;
const version = typeof parsed.version === 'string' ? parsed.version : undefined;
const codeSignature = typeof parsed.codeSignature === 'string' ? parsed.codeSignature : undefined;
const processStartTime =
typeof parsed.processStartTime === 'string' ? parsed.processStartTime : undefined;
const hasPid = Number.isInteger(parsed.pid) && Number(parsed.pid) > 0;
return {
token,
port: hasSocket ? Number(parsed.port) : undefined,
httpPort: hasHttp ? Number(parsed.httpPort) : undefined,
transport:
transport === 'socket' || transport === 'http' || transport === 'dual'
? transport
: undefined,
pid: hasPid ? Number(parsed.pid) : 0,
version,
codeSignature,
processStartTime,
};
}
function readDaemonLockInfo(lockPath: string): DaemonLockInfo | null {
const data = readJsonFile(lockPath);
if (!data || typeof data !== 'object') return null;
const parsed = data as Partial<DaemonLockInfo>;
const hasPid = Number.isInteger(parsed.pid) && Number(parsed.pid) > 0;
if (!hasPid) {
return null;
}
return {
pid: Number(parsed.pid),
processStartTime:
typeof parsed.processStartTime === 'string' ? parsed.processStartTime : undefined,
startedAt: typeof parsed.startedAt === 'number' ? parsed.startedAt : undefined,
};
}
function removeDaemonInfo(infoPath: string): void {
removeFileIfExists(infoPath);
}
function removeDaemonLock(lockPath: string): void {
removeFileIfExists(lockPath);
}
function cleanupStaleDaemonLockIfSafe(paths: DaemonPaths): void {
const state = getDaemonMetadataState(paths);
if (!state.hasLock || state.hasInfo) return;
const lockInfo = readDaemonLockInfo(paths.lockPath);
if (!lockInfo) {
removeDaemonLock(paths.lockPath);
return;
}
if (isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime)) {
return;
}
removeDaemonLock(paths.lockPath);
}
function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState {
return {
hasInfo: fs.existsSync(paths.infoPath),
hasLock: fs.existsSync(paths.lockPath),
};
}
function readJsonFile(filePath: string): unknown | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
} catch {
return null;
}
}
function removeFileIfExists(filePath: string): void {
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {
// Best-effort cleanup only.
}
}
async function canConnect(
info: DaemonInfo,
preference: DaemonTransportPreference,
): Promise<boolean> {
const transport = chooseTransport(info, preference);
if (transport === 'http') {
return await canConnectHttp(info);
}
return await canConnectSocket(info.port);
}
function canConnectSocket(port: number | undefined): Promise<boolean> {
if (!port) return Promise.resolve(false);
return new Promise((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port }, () => {
socket.destroy();
resolve(true);
});
socket.on('error', () => {
resolve(false);
});
});
}
function canConnectHttp(info: DaemonInfo): Promise<boolean> {
const endpoint = info.baseUrl
? buildDaemonHttpUrl(info.baseUrl, 'health')
: info.httpPort
? `http://127.0.0.1:${info.httpPort}/health`
: null;
if (!endpoint) return Promise.resolve(false);
const url = new URL(endpoint);
const transport = url.protocol === 'https:' ? https : http;
const timeoutMs = info.baseUrl
? REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS
: LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS;
return new Promise((resolve) => {
const req = transport.request(
{
protocol: url.protocol,
host: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: 'GET',
timeout: timeoutMs,
},
(res) => {
res.resume();
resolve((res.statusCode ?? 500) < 500);
},
);
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.on('error', () => {
resolve(false);
});
req.end();
});
}
async function startDaemon(settings: DaemonClientSettings): Promise<void> {
const launchSpec = resolveDaemonLaunchSpec();
const args = launchSpec.useSrc
? ['--experimental-strip-types', launchSpec.srcPath]
: [launchSpec.distPath];
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR: settings.paths.baseDir,
AGENT_DEVICE_DAEMON_SERVER_MODE: settings.serverMode,
};
runCmdDetached(process.execPath, args, { env });
}
type DaemonLaunchSpec = {
root: string;
distPath: string;
srcPath: string;
useSrc: boolean;
};
function resolveDaemonLaunchSpec(): DaemonLaunchSpec {
const root = findProjectRoot();
const distPath = path.join(root, 'dist', 'src', 'daemon.js');
const srcPath = path.join(root, 'src', 'daemon.ts');
const hasDist = fs.existsSync(distPath);
const hasSrc = fs.existsSync(srcPath);
if (!hasDist && !hasSrc) {
throw new AppError('COMMAND_FAILED', 'Daemon entry not found', { distPath, srcPath });
}
const runningFromSource = process.execArgv.includes('--experimental-strip-types');
const useSrc = runningFromSource ? hasSrc : !hasDist && hasSrc;
return { root, distPath, srcPath, useSrc };
}
function resolveLocalDaemonCodeSignature(): string {
const launchSpec = resolveDaemonLaunchSpec();
const entryPath = launchSpec.useSrc ? launchSpec.srcPath : launchSpec.distPath;
return computeDaemonCodeSignature(entryPath, launchSpec.root);
}
async function sendRequest(
info: DaemonInfo,
req: DaemonRequest,
preference: DaemonTransportPreference,
timeoutMs: number | undefined,
): Promise<DaemonResponse> {
const transport = chooseTransport(info, preference);
if (transport === 'http') {
return await sendHttpRequest(info, req, timeoutMs);
}
return await sendSocketRequest(info, req, timeoutMs);
}
function chooseTransport(
info: DaemonInfo,
preference: DaemonTransportPreference,
): ResolvedDaemonTransport {
if (info.baseUrl) {
// Defensive guard: resolveClientSettings rejects this earlier for normal CLI flow.
if (preference === 'socket') {
throw new AppError('COMMAND_FAILED', 'Remote daemon endpoint only supports HTTP transport', {
daemonBaseUrl: info.baseUrl,
});
}
return 'http';
}
if (preference === 'http' || preference === 'socket') {
return requireDaemonTransport(info, preference);
}
const autoOrder: ResolvedDaemonTransport[] =
info.transport === 'socket' || info.transport === 'dual'
? ['socket', 'http']
: ['http', 'socket'];
const available = autoOrder.find((transport) => hasDaemonTransport(info, transport));
if (available) return available;
throw new AppError('COMMAND_FAILED', 'Daemon metadata has no reachable transport');
}
function hasDaemonTransport(info: DaemonInfo, transport: ResolvedDaemonTransport): boolean {
return transport === 'http' ? Boolean(info.httpPort) : Boolean(info.port);
}
function requireDaemonTransport(
info: DaemonInfo,
transport: ResolvedDaemonTransport,
): ResolvedDaemonTransport {
if (hasDaemonTransport(info, transport)) return transport;
throw new AppError(
'COMMAND_FAILED',
transport === 'http'
? 'Daemon HTTP endpoint is unavailable'
: 'Daemon socket endpoint is unavailable',
);
}
function handleRequestTimeout(
info: DaemonInfo,
statePaths: DaemonPaths,
requestId: string | undefined,
command: string | undefined,
remote: boolean,
timeoutMs: number,
): AppError {
const cleanup = remote ? { terminated: 0 } : cleanupTimedOutIosRunnerBuilds();
const daemonReset = remote ? { forcedKill: false } : resetDaemonAfterTimeout(info, statePaths);
emitDiagnostic({
level: 'error',
phase: 'daemon_request_timeout',
data: {
timeoutMs,
requestId,
command,
timedOutRunnerPidsTerminated: cleanup.terminated,
timedOutRunnerCleanupError: cleanup.error,
daemonPidReset: remote ? undefined : info.pid,
daemonPidForceKilled: remote ? undefined : daemonReset.forcedKill,
daemonBaseUrl: info.baseUrl,
},
});
return new AppError('COMMAND_FAILED', 'Daemon request timed out', {
timeoutMs,
requestId,
hint: remote
? 'Retry with --debug and verify the remote daemon URL, auth token, and remote host logs.'
: 'Retry with --debug and check daemon diagnostics logs. Timed-out iOS runner xcodebuild processes were terminated when detected.',
});
}
function handleTransportError(
err: unknown,
requestId: string | undefined,
remote: boolean,
): AppError {
emitDiagnostic({
level: 'error',
phase: 'daemon_request_socket_error',
data: {
requestId,
message: err instanceof Error ? (err as Error).message : String(err),
},
});
return new AppError(
'COMMAND_FAILED',
'Failed to communicate with daemon',
{
requestId,
hint: remote
? 'Retry command. If this persists, verify the remote daemon URL, auth token, and remote host reachability.'
: 'Retry command. If this persists, clean stale daemon metadata and start a fresh session.',
},
err instanceof Error ? err : undefined,
);
}
async function sendSocketRequest(
info: DaemonInfo,
req: DaemonRequest,
timeoutMs: number | undefined,
): Promise<DaemonResponse> {
const port = info.port;
if (!port) throw new AppError('COMMAND_FAILED', 'Daemon socket endpoint is unavailable');
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: '127.0.0.1', port }, () => {
socket.write(`${JSON.stringify(req)}\n`);
});
const statePaths = resolveDaemonPaths(
req.flags?.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR,
);
const timeoutHandle =
typeof timeoutMs === 'number'
? setTimeout(() => {
socket.destroy();
reject(
handleRequestTimeout(
info,
statePaths,
req.meta?.requestId,
req.command,
false,
timeoutMs,
),
);
}, timeoutMs)
: undefined;
let buffer = '';
socket.setEncoding('utf8');
socket.on('data', (chunk) => {
buffer += chunk;
const idx = buffer.indexOf('\n');
if (idx === -1) return;
const line = buffer.slice(0, idx).trim();
if (!line) return;
try {
const response = JSON.parse(line) as DaemonResponse;
socket.end();
if (timeoutHandle) clearTimeout(timeoutHandle);
resolve(response);
} catch (err) {
if (timeoutHandle) clearTimeout(timeoutHandle);
reject(
new AppError(
'COMMAND_FAILED',
'Invalid daemon response',
{
requestId: req.meta?.requestId,
line,
},
err instanceof Error ? err : undefined,
),
);
}
});
socket.on('error', (err) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
reject(handleTransportError(err, req.meta?.requestId, false));
});
});
}
async function sendHttpRequest(
info: DaemonInfo,
req: DaemonRequest,
timeoutMs: number | undefined,
): Promise<DaemonResponse> {
const rpcUrl = info.baseUrl
? new URL(buildDaemonHttpUrl(info.baseUrl, 'rpc'))
: 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');
const rpcPayload = JSON.stringify(buildHttpRpcPayload(req, { includeTokenParam: !info.baseUrl }));
const headers: Record<string, string | number> = {
'content-type': 'application/json',
'content-length': Buffer.byteLength(rpcPayload),
};
if (info.baseUrl && info.token) {
headers.authorization = `Bearer ${info.token}`;
headers['x-agent-device-token'] = info.token;
}
return await new Promise((resolve, reject) => {
const statePaths = resolveDaemonPaths(
req.flags?.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR,
);
const transport = rpcUrl.protocol === 'https:' ? https : http;
const request = transport.request(
{
protocol: rpcUrl.protocol,
host: rpcUrl.hostname,
port: rpcUrl.port,
method: 'POST',
path: rpcUrl.pathname + rpcUrl.search,
headers,
},
(res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
if (timeoutHandle) clearTimeout(timeoutHandle);
try {
const parsed = JSON.parse(body) as {
result?: DaemonResponse;
error?: {
message?: string;
data?: Record<string, unknown>;
};
};
if (parsed.error) {
const data = parsed.error.data ?? {};
reject(
new AppError(
String(data.code ?? 'COMMAND_FAILED') as any,
String(data.message ?? parsed.error.message ?? 'Daemon RPC request failed'),
{
...(typeof data.details === 'object' && data.details ? data.details : {}),
hint: typeof data.hint === 'string' ? data.hint : undefined,
diagnosticId:
typeof data.diagnosticId === 'string' ? data.diagnosticId : undefined,
logPath: typeof data.logPath === 'string' ? data.logPath : undefined,
requestId: req.meta?.requestId,
},
),
);
return;
}
if (!parsed.result || typeof parsed.result !== 'object') {
reject(
new AppError('COMMAND_FAILED', 'Invalid daemon RPC response', {
requestId: req.meta?.requestId,
}),
);
return;
}
if (info.baseUrl && parsed.result.ok) {
void materializeRemoteArtifacts(info, req, parsed.result).then(resolve).catch(reject);
return;
}
resolve(parsed.result);
} catch (err) {
if (timeoutHandle) clearTimeout(timeoutHandle);
reject(
new AppError(
'COMMAND_FAILED',
'Invalid daemon response',
{
requestId: req.meta?.requestId,
line: body,
},
err instanceof Error ? err : undefined,
),
);
}
});
},
);
const remote = isRemoteDaemon(info);
const timeoutHandle =
typeof timeoutMs === 'number'
? setTimeout(() => {
request.destroy();
reject(
handleRequestTimeout(
info,
statePaths,
req.meta?.requestId,
req.command,
remote,
timeoutMs,
),
);
}, timeoutMs)
: undefined;
request.on('error', (err) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
reject(handleTransportError(err, req.meta?.requestId, remote));
});
request.write(rpcPayload);
request.end();
});
}
function buildHttpRpcPayload(
req: DaemonRequest,
options: { includeTokenParam: boolean },
): {
jsonrpc: '2.0';
id: string;
method: string;
params: DaemonRequest | Record<string, unknown>;
} {
const id = req.meta?.requestId ?? createRequestId();
if (!isLeaseRpcCommand(req.command)) {
return {
jsonrpc: '2.0',
id,
method: 'agent_device.command',
params: req,
};
}
return {
jsonrpc: '2.0',
id,
method: leaseRpcMethodForCommand(req.command),
params: buildLeaseRpcParams(req, req.command, options),
};
}
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'
);
}
function leaseRpcMethodForCommand(command: LeaseRpcCommand): string {
switch (command) {
case 'lease_allocate':
return 'agent_device.lease.allocate';
case 'lease_heartbeat':
return 'agent_device.lease.heartbeat';
case 'lease_release':
return 'agent_device.lease.release';
}
}
function buildLeaseRpcParams(
req: DaemonRequest,
command: LeaseRpcCommand,
options: { includeTokenParam: boolean },
): Record<string, unknown> {
const common = {
...(options.includeTokenParam ? { token: req.token } : {}),
session: req.session,
tenantId: req.meta?.tenantId,
runId: req.meta?.runId,
};
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,
};
}
}
function cleanupTimedOutIosRunnerBuilds(): { terminated: number; error?: string } {
let terminated = 0;
try {
for (const pattern of IOS_RUNNER_XCODEBUILD_KILL_PATTERNS) {
const result = runCmdSync('pkill', ['-f', pattern], { allowFailure: true });
if (result.exitCode === 0) terminated += 1;
}
return { terminated };
} catch (error) {
return {
terminated,
error: error instanceof Error ? error.message : String(error),
};
}
}
function resetDaemonAfterTimeout(info: DaemonInfo, paths: DaemonPaths): { forcedKill: boolean } {
let forcedKill = false;
try {
if (isAgentDeviceDaemonProcess(info.pid, info.processStartTime)) {
process.kill(info.pid, 'SIGKILL');
forcedKill = true;
}
} catch {
void stopProcessForTakeover(info.pid, {
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
expectedStartTime: info.processStartTime,
});
} finally {
removeDaemonInfo(paths.infoPath);
removeDaemonLock(paths.lockPath);
}
return { forcedKill };
}
function isRemoteDaemon(info: DaemonInfo): boolean {
return typeof info.baseUrl === 'string' && info.baseUrl.length > 0;
}
function resolveRemoteDaemonBaseUrl(raw: string | undefined): string | undefined {
if (!raw) return undefined;
let parsed: URL;
try {
parsed = new URL(raw);
} catch (error) {
throw new AppError(
'INVALID_ARGS',
'Invalid daemon base URL',
{
daemonBaseUrl: raw,
},
error instanceof Error ? error : undefined,
);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new AppError('INVALID_ARGS', 'Daemon base URL must use http or https', {
daemonBaseUrl: raw,
});
}
return parsed.toString().replace(/\/+$/, '');
}
function validateRemoteDaemonTrust(
remoteBaseUrl: string | undefined,
remoteAuthToken: string | undefined,
): void {
if (!remoteBaseUrl) return;
const hostname = new URL(remoteBaseUrl).hostname;
if (isLoopbackHostname(hostname)) return;
if (typeof remoteAuthToken === 'string' && remoteAuthToken.trim().length > 0) return;
throw new AppError(
'INVALID_ARGS',
'Remote daemon base URL for non-loopback hosts requires daemon authentication',
{
daemonBaseUrl: remoteBaseUrl,
hint: 'Provide --daemon-auth-token or AGENT_DEVICE_DAEMON_AUTH_TOKEN when using a non-loopback remote daemon URL.',
},
);
}
function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname
.trim()
.toLowerCase()
.replace(/^\[(.*)\]$/, '$1');
if (normalized === 'localhost') return true;
if (net.isIPv4(normalized)) return LOOPBACK_BLOCK_LIST.check(normalized, 'ipv4');
if (net.isIPv6(normalized)) return LOOPBACK_BLOCK_LIST.check(normalized, 'ipv6');
return false;
}
function buildDaemonHttpUrl(baseUrl: string, route: 'health' | 'rpc'): string {
// URL(base, relative) treats a base without trailing slash as a file path, so normalize to a directory-like base.
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
return new URL(route, normalizedBase).toString();
}
function buildDaemonArtifactUrl(baseUrl: string, artifactId: string): string {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
return new URL(`upload/${encodeURIComponent(artifactId)}`, normalizedBase).toString();
}
async function materializeRemoteArtifacts(
info: DaemonInfo,
req: DaemonRequest,
response: Extract<DaemonResponse, { ok: true }>,
): Promise<DaemonResponse> {
const artifacts = Array.isArray(response.data?.artifacts) ? response.data.artifacts : [];
if (artifacts.length === 0 || !info.baseUrl) return response;
const nextData = response.data ? { ...response.data } : {};
const nextArtifacts: DaemonArtifact[] = [];
for (const artifact of artifacts) {
if (!artifact || typeof artifact !== 'object' || typeof artifact.artifactId !== 'string') {
nextArtifacts.push(artifact);
continue;
}
const localPath = resolveMaterializedArtifactPath(artifact, req);
await downloadRemoteArtifact({
baseUrl: info.baseUrl,
token: info.token,
artifactId: artifact.artifactId,
destinationPath: localPath,
requestId: req.meta?.requestId,
});
nextData[artifact.field] = localPath;
nextArtifacts.push({
...artifact,
localPath,
});
}
nextData.artifacts = nextArtifacts;
return { ok: true, data: nextData };
}
function resolveMaterializedArtifactPath(artifact: DaemonArtifact, req: DaemonRequest): string {
if (artifact.localPath && artifact.localPath.trim().length > 0) {
return artifact.localPath;
}
const fallbackName = artifact.fileName?.trim() || `${artifact.field}-${Date.now()}`;
return path.resolve(req.meta?.cwd ?? process.cwd(), fallbackName);
}
export async function downloadRemoteArtifact(params: {
baseUrl: string;
token: string;
artifactId: string;
destinationPath: string;
requestId?: string;
timeoutMs?: number;
}): Promise<void> {
const artifactUrl = new URL(buildDaemonArtifactUrl(params.baseUrl, params.artifactId));
const transport = artifactUrl.protocol === 'https:' ? https : http;
await fs.promises.mkdir(path.dirname(params.destinationPath), { recursive: true });
await new Promise<void>((resolve, reject) => {
let settled = false;
const timeoutMs = params.timeoutMs ?? REQUEST_TIMEOUT_MS;
const settle = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle);
if (error) {
void fs.promises.rm(params.destinationPath, { force: true }).finally(() => reject(error));
return;
}
resolve();
};
const request = transport.request(
{
protocol: artifactUrl.protocol,
host: artifactUrl.hostname,
port: artifactUrl.port,
method: 'GET',
path: artifactUrl.pathname + artifactUrl.search,
headers: params.token
? {
authorization: `Bearer ${params.token}`,
'x-agent-device-token': params.token,
}
: undefined,
},
(res) => {
if ((res.statusCode ?? 500) >= 400) {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
settle(
new AppError('COMMAND_FAILED', 'Failed to download remote artifact', {
artifactId: params.artifactId,
statusCode: res.statusCode,
requestId: params.requestId,
body,
}),
);
});
return;
}
const output = fs.createWriteStream(params.destinationPath);
output.on('error', (error) => {
settle(error instanceof Error ? error : new Error(String(error)));
});
res.on('error', (error) => {
settle(error instanceof Error ? error : new Error(String(error)));
});
res.on('aborted', () => {
settle(
new AppError('COMMAND_FAILED', 'Remote artifact download was interrupted', {
artifactId: params.artifactId,
requestId: params.requestId,
}),
);
});
output.on('finish', () => {
output.close(() => settle());
});
res.pipe(output);
},
);
const timeoutHandle = setTimeout(() => {
request.destroy(
new AppError('COMMAND_FAILED', 'Remote artifact download timed out', {
artifactId: params.artifactId,
requestId: params.requestId,
timeoutMs,
}),
);
}, timeoutMs);
request.on('error', (error) => {
if (error instanceof AppError) {
settle(error);
return;
}
settle(
new AppError(
'COMMAND_FAILED',
'Failed to download remote artifact',
{
artifactId: params.artifactId,
requestId: params.requestId,
timeoutMs,
},
error instanceof Error ? error : undefined,
),
);
});
request.end();
});
}
export function resolveDaemonRequestTimeoutMs(
raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS,
): number {
if (!raw) return 90000;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return 90000;
return Math.max(1000, Math.floor(parsed));
}
export function resolveDaemonRequestTimeoutForCommand(
command: string | undefined,
raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS,
): number | undefined {
if (command === 'test') return undefined;
return resolveDaemonRequestTimeoutMs(raw);
}
export function resolveDaemonStartupTimeoutMs(
raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS,
): number {
if (!raw) return 15000;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return 15000;
return Math.max(1000, Math.floor(parsed));
}
export function resolveDaemonStartupAttempts(
raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_STARTUP_ATTEMPTS,
): number {
if (!raw) return 2;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return 2;
return Math.min(5, Math.max(1, Math.floor(parsed)));
}
export function resolveDaemonStartupHint(
state: { hasInfo: boolean; hasLock: boolean },
paths: Pick<DaemonPaths, 'infoPath' | 'lockPath'> = resolveDaemonPaths(
process.env.AGENT_DEVICE_STATE_DIR,
),
): string {
if (state.hasLock && !state.hasInfo) {
return `Detected ${paths.lockPath} without ${paths.infoPath}. If no agent-device daemon process is running, delete ${paths.lockPath} and retry.`;
}
if (state.hasLock && state.hasInfo) {
return `Daemon metadata may be stale. If no agent-device daemon process is running, delete ${paths.infoPath} and ${paths.lockPath}, then retry.`;
}
return `Daemon metadata is missing or stale. Delete ${paths.infoPath} if present and retry.`;
}