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
7 changes: 5 additions & 2 deletions packages/ad-script/src/internal/vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,18 @@ export function readReplayCliEnvEntries(raw: unknown): string[] {
: [];
}

export function readReplayShellEnvSource(raw: unknown): NodeJS.ProcessEnv {
export function readReplayShellEnvSource(
raw: unknown,
fallbackEnv: NodeJS.ProcessEnv,
): NodeJS.ProcessEnv {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const result: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(raw)) {
if (typeof value === 'string') result[key] = value;
}
return result;
}
return process.env;
return fallbackEnv;
}

// `${NAME}` / `${NAME:-fallback}` / `\${` interpolation, as a single-pass
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/replay-backend.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DaemonError } from '@agent-device/kernel/errors';
import type { DaemonResponse, SessionRuntimeHints } from '@agent-device/kernel/contracts';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value';
import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot';
import type { CommandFlags } from './command-flags.ts';
import type { DaemonWireRequest } from './daemon-wire-request.ts';
Expand Down Expand Up @@ -119,6 +120,8 @@ export type ReplayBackendSession = Readonly<{
/** One replay run as a backend sees it: the prepared request, its session, and the dispatch port. */
export type ReplayBackendCommand = Readonly<{
request: DaemonWireRequest;
/** The daemon's host facts (env and working directory); a backend never reads the process itself. */
host: HostEnvironment;
/**
* True when the request reached the daemon over its public HTTP surface only: flow scripts are
* then untrusted and `runScript` HTTP calls may not reach private addresses. Absent means a
Expand Down Expand Up @@ -233,6 +236,8 @@ export type ReplayBackendHost = Readonly<{
req: DaemonWireRequest;
sessionName: string;
resolvedPath: string;
/** The command's host working directory, which relative flow paths resolve against. */
hostCwd: string;
}): Record<string, string>;
/** Reads one file out of the request's script source bundle; throws when it was not sent. */
readScriptSourceFile(bundle: ReplayScriptSourceBundle, resolvedPath: string): string;
Expand All @@ -246,6 +251,7 @@ export type ReplayBackendHost = Readonly<{
resolveTargetDevice(
flags: CommandFlags,
binding: ReplayBackendDeviceBinding,
env: EnvMap,
): Promise<DeviceInfo>;
/** The abort signal of the in-flight daemon request. */
requestSignal(requestId: string | undefined): AbortSignal | undefined;
Expand Down
3 changes: 2 additions & 1 deletion packages/daemon/src/core/snapshot-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export async function resolveSessionDevice(
const { requireNamedSessionHoldsDevice } = await import('./explicit-session-resolution.ts');
requireNamedSessionHoldsDevice({ req, sessionAddress: sessionName, session });
}
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
const device =
session?.device ?? (await resolveTargetDevice(req.flags ?? {}, { env: sessionStore.host.env }));
return { ref, session, device };
}

Expand Down
7 changes: 6 additions & 1 deletion packages/daemon/src/execution/handlers/session-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ export async function handleSessionClipboardCommand(params: {
return errorResponse('INVALID_ARGS', 'clipboard requires a subcommand: read or write');
}

const device = await resolveCommandDevice({ session, flags, platformServices });
const device = await resolveCommandDevice({
session,
flags,
env: sessionStore.host.env,
platformServices,
});
if (ref) sessionStore.requireCurrent(ref);
const bound = await resolveBoundClipboardRuntime({
device,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ async function runSessionOrSelectorDispatch(params: {
const device = await resolveCommandDevice({
session,
flags,
env: sessionStore.host.env,
platformServices: params.platformServices,
});
if (ref) sessionStore.requireCurrent(ref);
Expand Down
4 changes: 3 additions & 1 deletion packages/daemon/src/execution/selector-runtime-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ async function resolveSelectorRuntimeDevice(
});
}
if (!session && requireSession) return { ok: false, response: noActiveSessionError() };
const device = session?.device ?? (await resolveTargetDevice(params.req.flags ?? {}));
const device =
session?.device ??
(await resolveTargetDevice(params.req.flags ?? {}, { env: params.sessionStore.host.env }));
return { ok: true, ref, session, device };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ async function withMockedPlatform<T>(platform: NodeJS.Platform, fn: () => Promis

test('refreshSessionDeviceIfNeeded keeps iOS simulator session device on non-mac hosts', async () => {
const device = await withMockedPlatform('linux', async () =>
refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices),
refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices, process.env),
);

expect(device).toBe(iosSimulatorSession.device);
Expand All @@ -85,6 +85,7 @@ test('resolveCommandDevice keeps an existing session for a platform-only filter'
platformServices: platformServices,
session: iosSimulatorSession,
flags: { platform: 'ios' },
env: process.env,
}),
);

Expand All @@ -99,6 +100,7 @@ test('resolveCommandDevice does not prepare a sessionless device', async () => {
await resolveCommandDevice({
session: undefined,
flags: { platform: 'ios' },
env: process.env,
platformServices: platformServices,
});

Expand All @@ -113,6 +115,7 @@ test('refreshSessionDeviceIfNeeded keeps provider-owned iOS simulators out of lo
refreshSessionDeviceIfNeeded(
{ ...iosSimulatorSession.device, id: 'limrun:ios:lease-1' },
platformServices,
process.env,
),
);

Expand All @@ -127,7 +130,7 @@ test('refreshSessionDeviceIfNeeded skips re-resolve while the iOS runner session
});

const device = await withMockedPlatform('darwin', async () =>
refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices),
refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices, process.env),
);

expect(device).toEqual({ ...iosSimulatorSession.device, booted: true });
Expand All @@ -143,7 +146,7 @@ test('refreshSessionDeviceIfNeeded re-resolves when the iOS runner session is go
mockResolveTargetDevice.mockResolvedValue(resolved);

const device = await withMockedPlatform('darwin', async () =>
refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices),
refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices, process.env),
);

expect(device).toBe(resolved);
Expand Down
27 changes: 19 additions & 8 deletions packages/daemon/src/resources/session-device-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DaemonResponse } from '@agent-device/kernel/contracts';
import type { EnvMap } from '@agent-device/kernel/source-value';
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
import { AppError, sessionOrDeviceSelectorRequiredDetails } from '@agent-device/kernel/errors';
import { isActiveProviderDevice } from '../core/provider-device-admission.ts';
Expand Down Expand Up @@ -31,22 +32,29 @@ export function requireSessionOrExplicitSelector(
export async function resolveCommandDevice(params: {
session: SessionState | undefined;
flags: DaemonRequest['flags'] | undefined;
env: EnvMap;
androidAvdSelection?: 'running-only' | 'include-stopped';
platformServices: DaemonPlatformServices;
}): Promise<DeviceInfo> {
const shouldUseExplicitIdentity = hasExplicitDeviceSelector(params.flags);
const device =
shouldUseExplicitIdentity || !params.session
? await resolveTargetDevice(params.flags ?? {}, {
env: params.env,
androidAvdSelection: params.androidAvdSelection,
})
: await refreshSessionDeviceIfNeeded(params.session.device, params.platformServices);
: await refreshSessionDeviceIfNeeded(
params.session.device,
params.platformServices,
params.env,
);
return device;
}

export async function refreshSessionDeviceIfNeeded(
device: DeviceInfo,
platformServices: DaemonPlatformServices,
env: EnvMap,
): Promise<DeviceInfo> {
if (isActiveProviderDevice(device)) {
return device;
Expand All @@ -71,19 +79,22 @@ export async function refreshSessionDeviceIfNeeded(
...(device.simulatorSetPath ? { iosSimulatorDeviceSet: device.simulatorSetPath } : {}),
};
try {
return await resolveTargetDevice(exactSelector);
return await resolveTargetDevice(exactSelector, { env });
} catch (error) {
if (!(error instanceof AppError) || error.code !== 'DEVICE_NOT_FOUND') {
throw error;
}
}

return await resolveTargetDevice({
platform: 'ios',
target: device.target,
device: device.name,
...(device.simulatorSetPath ? { iosSimulatorDeviceSet: device.simulatorSetPath } : {}),
});
return await resolveTargetDevice(
{
platform: 'ios',
target: device.target,
device: device.name,
...(device.simulatorSetPath ? { iosSimulatorDeviceSet: device.simulatorSetPath } : {}),
},
{ env },
);
}

export function selectorTargetsSessionDevice(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@ test('replay leaves deep-link opens to normal device resolution', async () => {
fs.writeFileSync(replayPath, 'open demo://checkout\n');

expect(
await buildReplayTargetDeviceResolution({
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [replayPath],
flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) },
meta: { cwd: root },
}),
await buildReplayTargetDeviceResolution(
{
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [replayPath],
flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) },
meta: { cwd: root },
},
process.env,
),
).toBeUndefined();
});

Expand All @@ -51,14 +54,17 @@ test('native replay uses its authored Android runtime setting without an iOS app
'runtime set --platform android --metro-port 8081\nopen com.example.demo\n',
);

const resolution = await buildReplayTargetDeviceResolution({
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [replayPath],
flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) },
meta: { cwd: root },
});
const resolution = await buildReplayTargetDeviceResolution(
{
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [replayPath],
flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) },
meta: { cwd: root },
},
process.env,
);

// The request's own flags ride through untouched, so compare the decision this function makes:
// the authored platform, and no iOS app-probe options.
Expand All @@ -81,17 +87,20 @@ test('a replay whose wire bundle has a malformed entry stays advisory', async ()

for (const bundle of malformedBundles) {
await expect(
buildReplayTargetDeviceResolution({
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [],
flags: {
replayBackend: 'maestro',
replayScriptSource: bundle,
buildReplayTargetDeviceResolution(
{
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [],
flags: {
replayBackend: 'maestro',
replayScriptSource: bundle,
},
meta: { cwd: mkdtempForTestSync('agent-device-replay-device-selection-') },
},
meta: { cwd: mkdtempForTestSync('agent-device-replay-device-selection-') },
}),
process.env,
),
).resolves.toBeUndefined();
}
});
Expand All @@ -101,19 +110,22 @@ test('a Maestro flow pre-binds an iOS replay to its static appId', async () => {
const flowPath = path.join(root, 'checkout.yaml');
fs.writeFileSync(flowPath, 'appId: com.example.demo\n---\n- launchApp\n');

const resolution = await buildReplayTargetDeviceResolution({
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [flowPath],
flags: {
platform: 'ios',
replayBackend: 'maestro',
replayScriptSource: await maestroScriptSourceBundleFor(flowPath),
const resolution = await buildReplayTargetDeviceResolution(
{
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [flowPath],
flags: {
platform: 'ios',
replayBackend: 'maestro',
replayScriptSource: await maestroScriptSourceBundleFor(flowPath),
},
meta: { cwd: root },
},
meta: { cwd: root },
});
process.env,
);

expect(resolution?.options).toEqual(appTargetResolutionOptions('com.example.demo'));
expect(resolution?.options).toEqual(appTargetResolutionOptions('com.example.demo', process.env));
expect(resolution?.options).toBeDefined();
});
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,8 @@ test('fresh open uses app-aware device selection for advisory locking and dispat

expect(response.ok).toBe(true);
expect(mockResolveTargetDevice.mock.calls).toEqual([
[{ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo' }],
[{ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo' }],
[{ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo', env: process.env }],
[{ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo', env: process.env }],
]);
expect(sessionStore.get('session-app-aware')?.device.id).toBe(appDevice.id);
});
Expand Down Expand Up @@ -208,7 +208,7 @@ test('fresh replay reserves its authored app simulator before any replay step',
expect(keys).toEqual(['session:fresh-replay', 'device:SIM-WITH-APP']);
expect(mockResolveTargetDevice).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'ios' }),
{ appleSimulatorAppTarget: 'com.example.demo' },
{ appleSimulatorAppTarget: 'com.example.demo', env: process.env },
);
});

Expand Down Expand Up @@ -265,7 +265,7 @@ test('fresh replay preserves an authored Android platform before advisory lockin
expect(keys).toEqual(['session:fresh-replay-android', 'device:ANDROID-EMULATOR']);
expect(mockResolveTargetDevice).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'android' }),
undefined,
expect.objectContaining({ env: process.env }),
);
});

Expand Down
Loading
Loading