From d11abc396d068e267bb423a31c1d706e1680c467 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Sun, 27 Sep 2026 13:14:50 -0700 Subject: [PATCH] feat: support iOS launch environment --- .../src/flag-definitions-target.ts | 11 +++ packages/contracts/package.json | 4 + .../application-lifecycle-interaction.test.ts | 27 ++++++ .../src/application-lifecycle-interaction.ts | 23 ++++- .../src/application-lifecycle-runtime.ts | 2 + packages/contracts/src/cli-flags.ts | 1 + packages/contracts/src/client-app.ts | 2 + packages/contracts/src/command-flags.ts | 3 +- packages/contracts/src/interactor-types.ts | 1 + packages/contracts/src/launch-environment.ts | 1 + packages/contracts/src/request-envelope.ts | 2 + packages/host-kit/src/internal/exec.test.ts | 13 +++ packages/host-kit/src/internal/exec.ts | 15 +++- packages/kernel/src/redaction.test.ts | 18 ++++ packages/kernel/src/redaction.ts | 2 +- .../src/core/__tests__/apps.test.ts | 53 ++++++++++++ .../platform-apple/src/core/app-launch.ts | 83 +++++++++++++++++-- packages/platform-apple/src/interactor.ts | 1 + packages/platform-apple/src/lifecycle.ts | 4 +- scripts/integration-progress-model.ts | 1 + .../layering/contracts-exports.snapshot.json | 1 + src/commands/command-flags.ts | 1 + src/commands/management/app.test.ts | 54 ++++++++++++ src/commands/management/app.ts | 65 ++++++++++++++- src/core/dispatch-context.ts | 1 + .../application-lifecycle-runtime-fixture.ts | 3 + src/daemon/application-lifecycle-execution.ts | 1 + .../__tests__/session-open-runtime.test.ts | 11 ++- .../provider-scenarios/ios-lifecycle.test.ts | 39 ++++++++- .../provider-scenarios/ios-world.ts | 16 ++++ website/docs/docs/client-api.md | 2 + website/docs/docs/commands.md | 2 + 32 files changed, 443 insertions(+), 20 deletions(-) create mode 100644 packages/contracts/src/launch-environment.ts create mode 100644 packages/kernel/src/redaction.test.ts diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index d84aa1cc0e..b000327fcf 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -376,6 +376,17 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ projectConfig: true, recorded: false, }, + { + key: 'launchEnvironmentEntries', + names: ['--launch-env'], + type: 'string', + multiple: true, + usageLabel: '--launch-env ', + usageDescription: + 'open: repeatable iOS Simulator child-process environment entry; values are treated as sensitive', + projectConfig: false, + recorded: false, + }, { key: 'header', names: ['--header'], diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 9582f1ada8..fa7a3ef8c8 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -144,6 +144,10 @@ "types": "./src/clipboard-runtime.ts", "default": "./src/clipboard-runtime.ts" }, + "./launch-environment": { + "types": "./src/launch-environment.ts", + "default": "./src/launch-environment.ts" + }, "./command": { "types": "./src/facades/command.ts", "default": "./src/facades/command.ts" diff --git a/packages/contracts/src/application-lifecycle-interaction.test.ts b/packages/contracts/src/application-lifecycle-interaction.test.ts index 2e42310f7c..5af2b38e8e 100644 --- a/packages/contracts/src/application-lifecycle-interaction.test.ts +++ b/packages/contracts/src/application-lifecycle-interaction.test.ts @@ -33,6 +33,12 @@ const WEB_DEVICE: DeviceInfo = { booted: true, }; +const IOS_PHYSICAL_DEVICE: DeviceInfo = { + ...IOS_SIMULATOR, + id: 'ios-device', + kind: 'device', +}; + function interactorWithOpen(open: Interactor['open'] = async () => undefined): Interactor { return { open, @@ -113,6 +119,27 @@ test.each([ execution: { launchArgs: ['--flag'] }, message: /launch-args requires an app target/, }, + { + name: 'launch environment without an app', + device: IOS_SIMULATOR, + positionals: [], + execution: { launchEnvironment: { MODE: 'test' } }, + message: /launch-env requires an app target/, + }, + { + name: 'launch environment on a physical iOS device', + device: IOS_PHYSICAL_DEVICE, + positionals: ['com.example.app'], + execution: { launchEnvironment: { MODE: 'test' } }, + message: /only for iOS Simulator/, + }, + { + name: 'launch environment on Linux', + device: LINUX_DEVICE, + positionals: ['org.example.App'], + execution: { launchEnvironment: { MODE: 'test' } }, + message: /only for iOS Simulator/, + }, { name: 'launch console outside an iOS simulator', device: LINUX_DEVICE, diff --git a/packages/contracts/src/application-lifecycle-interaction.ts b/packages/contracts/src/application-lifecycle-interaction.ts index 0db4ce162f..3c5273083c 100644 --- a/packages/contracts/src/application-lifecycle-interaction.ts +++ b/packages/contracts/src/application-lifecycle-interaction.ts @@ -4,7 +4,7 @@ import { LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE, } from './launch-console.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { isIosFamily } from '@agent-device/kernel/device'; +import { isIosFamily, resolveDeviceAppleOs } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { @@ -181,6 +181,9 @@ async function invokeDeviceOpen(params: DirectOpenParameters): Promise { if (params.execution.launchArgs && params.execution.launchArgs.length > 0) { throw new AppError('INVALID_ARGS', '--launch-args requires an app target'); } + if (params.execution.launchEnvironment !== undefined) { + throw new AppError('INVALID_ARGS', '--launch-env requires an app target'); + } await params.interactor.openDevice(); } @@ -194,6 +197,22 @@ function assertOpenDeviceSupport( if (device.platform === 'linux' && execution.launchArgs && execution.launchArgs.length > 0) { throw new AppError('UNSUPPORTED_OPERATION', '--launch-args is not supported on Linux.'); } + assertLaunchEnvironmentSupport(device, execution.launchEnvironment); +} + +function assertLaunchEnvironmentSupport( + device: DeviceInfo, + launchEnvironment: ApplicationLifecycleExecution['launchEnvironment'], +): void { + if ( + launchEnvironment !== undefined && + !(resolveDeviceAppleOs(device) === 'ios' && device.kind === 'simulator') + ) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--launch-env is supported only for iOS Simulator app launches.', + ); + } } async function invokeApplicationUrlOpen( @@ -217,6 +236,7 @@ async function invokeApplicationUrlOpen( activity: params.execution.activity, appBundleId: params.appBundleId, launchArgs: params.execution.launchArgs ? [...params.execution.launchArgs] : undefined, + launchEnvironment: params.execution.launchEnvironment, terminateRunningApp: params.terminateRunningApp, url, }); @@ -244,6 +264,7 @@ async function invokeApplicationTargetOpen( appBundleId: params.appBundleId, launchConsole: execution.launchConsole, launchArgs: execution.launchArgs ? [...execution.launchArgs] : undefined, + launchEnvironment: execution.launchEnvironment, terminateRunningApp: params.terminateRunningApp, }); } diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 98504a649b..a2b190aeec 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -6,6 +6,7 @@ import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; import type { SessionSurface } from './session-surface.ts'; import type { ProviderPortReverseOptions } from './provider-device-runtime.ts'; import type { TargetShutdownResult } from './target-shutdown-contract.ts'; +import type { LaunchEnvironment } from './launch-environment.ts'; /** * A deliberately neutral runtime-hint payload. Daemon policy owns parsing and @@ -40,6 +41,7 @@ export type ApplicationLifecycleExecution = Readonly<{ activity?: string; launchConsole?: string; launchArgs?: readonly string[]; + launchEnvironment?: LaunchEnvironment; clearAppState?: boolean; iosXctestrunFile?: string; iosXctestDerivedDataPath?: string; diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index 8cd4412922..8a6e59d371 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -122,6 +122,7 @@ export type CliFlags = CloudProviderProfileFields & activity?: string; launchConsole?: string; launchArgs?: string[]; + launchEnvironmentEntries?: string[]; header?: string[]; githubActionsArtifact?: string; installSource?: DaemonInstallSource; diff --git a/packages/contracts/src/client-app.ts b/packages/contracts/src/client-app.ts index 09bd9f698b..31726f53b9 100644 --- a/packages/contracts/src/client-app.ts +++ b/packages/contracts/src/client-app.ts @@ -2,6 +2,7 @@ import type { AppsFilter } from './app-inventory.ts'; import type { JsonObject } from './json.ts'; +import type { LaunchEnvironment } from './launch-environment.ts'; import type { SessionSurface } from './session-surface.ts'; import type { TargetShutdownResult } from './target-shutdown-contract.ts'; import type { DaemonInstallSource, SessionRuntimeHints } from '@agent-device/kernel/contracts'; @@ -65,6 +66,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides & activity?: string; launchConsole?: string; launchArgs?: string[]; + launchEnvironment?: LaunchEnvironment; relaunch?: boolean; /** Startup budget in milliseconds: bounds the Simulator boot wait on a cold device. */ timeoutMs?: number; diff --git a/packages/contracts/src/command-flags.ts b/packages/contracts/src/command-flags.ts index f7777c1bd5..7859360235 100644 --- a/packages/contracts/src/command-flags.ts +++ b/packages/contracts/src/command-flags.ts @@ -25,13 +25,14 @@ export type MaestroRuntimeFlags = { screenshotCaptureBackend?: 'runner'; }; -export type CommandFlags = Omit & { +export type CommandFlags = Omit & { batchSteps?: DaemonBatchStep[]; clearAppState?: boolean; interactionOutcome?: { retryOnNoChange?: boolean; }; launchArgs?: string[]; + launchEnvironment?: Readonly>; kind?: string; maestro?: MaestroRuntimeFlags; postGestureStabilization?: boolean; diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index dd5cb82363..a614972604 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -285,6 +285,7 @@ export type Interactor = { appBundleId?: string; launchConsole?: string; launchArgs?: string[]; + launchEnvironment?: Readonly>; terminateRunningApp?: boolean; url?: string; }, diff --git a/packages/contracts/src/launch-environment.ts b/packages/contracts/src/launch-environment.ts new file mode 100644 index 0000000000..85469c35c6 --- /dev/null +++ b/packages/contracts/src/launch-environment.ts @@ -0,0 +1 @@ +export type LaunchEnvironment = Readonly>; diff --git a/packages/contracts/src/request-envelope.ts b/packages/contracts/src/request-envelope.ts index e7fd3d8c76..55b306111c 100644 --- a/packages/contracts/src/request-envelope.ts +++ b/packages/contracts/src/request-envelope.ts @@ -18,6 +18,7 @@ import type { SnapshotCommandOptionFields } from '@agent-device/kernel/snapshot' import type { DaemonBatchStep } from './batch-step.ts'; import type { ReplayRequestFields } from './replay-request-fields.ts'; import type { AgentDeviceClientConfig, AgentDeviceSelectionOptions } from './client-connection.ts'; +import type { LaunchEnvironment } from './launch-environment.ts'; export type CommandExecutionOptions = Partial & ReplayRequestFields & @@ -68,6 +69,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig & activity?: string; launchConsole?: string; launchArgs?: string[]; + launchEnvironment?: LaunchEnvironment; relaunch?: boolean; shutdown?: boolean; saveScript?: boolean | string; diff --git a/packages/host-kit/src/internal/exec.test.ts b/packages/host-kit/src/internal/exec.test.ts index 6273a3c457..80f83a8b10 100644 --- a/packages/host-kit/src/internal/exec.test.ts +++ b/packages/host-kit/src/internal/exec.test.ts @@ -101,6 +101,19 @@ test('runCmd writes stdin through pipeline', async () => { assert.equal(result.stdout, String(stdin.length)); }); +test('runCmd envPatch inherits the host environment and applies child overrides', async () => { + const result = await runCmd( + process.execPath, + [ + '-e', + 'process.stdout.write(JSON.stringify({ path: Boolean(process.env.PATH), mode: process.env.AGENT_DEVICE_TEST_MODE }))', + ], + { envPatch: { AGENT_DEVICE_TEST_MODE: 'app-clip' } }, + ); + + assert.deepEqual(JSON.parse(result.stdout), { path: true, mode: 'app-clip' }); +}); + test.sequential('runCmdBackground emits bounded exec_command diagnostics when AGENT_DEVICE_EXEC_TRACE is enabled', async () => { const diagnosticsPath = await withExecTraceEnv( async () => diff --git a/packages/host-kit/src/internal/exec.ts b/packages/host-kit/src/internal/exec.ts index d5f759c9d0..42ac503ba0 100644 --- a/packages/host-kit/src/internal/exec.ts +++ b/packages/host-kit/src/internal/exec.ts @@ -19,6 +19,8 @@ export type ExecResult = { export type ExecOptions = { cwd?: string; env?: NodeJS.ProcessEnv; + /** Inherit the selected environment and apply these entries without acquiring host state upstream. */ + envPatch?: Readonly>; allowFailure?: boolean; binaryStdout?: boolean; stdin?: string | Buffer; @@ -91,6 +93,11 @@ export type CommandExecutorOverride = ( const commandExecutorOverrideScope = new AsyncLocalStorage(); +function resolveExecEnvironment(options: ExecOptions): NodeJS.ProcessEnv | undefined { + if (options.envPatch === undefined) return options.env; + return { ...(options.env ?? process.env), ...options.envPatch }; +} + export async function withCommandExecutorOverride( override: CommandExecutorOverride | undefined, fn: () => Promise, @@ -154,7 +161,7 @@ function runSpawnedCommand( return new Promise((resolve, reject) => { const child = spawn(executable, args, { cwd: options.cwd, - env: options.env, + env: resolveExecEnvironment(options), stdio: ['pipe', 'pipe', 'pipe'], detached: options.detached, windowsHide: true, @@ -324,7 +331,7 @@ export function runCmdSync( const executable = normalizeExecutableCommand(cmd); const result = spawnSync(executable, args, { cwd: options.cwd, - env: options.env, + env: resolveExecEnvironment(options), stdio: ['pipe', 'pipe', 'pipe'], encoding: options.binaryStdout ? undefined : 'utf8', input: options.stdin, @@ -391,7 +398,7 @@ export function runCmdDetachedMonitored( const executable = normalizeExecutableCommand(cmd); const child = spawn(executable, args, { cwd: options.cwd, - env: options.env, + env: resolveExecEnvironment(options), stdio: options.stdio ?? 'ignore', detached: true, windowsHide: true, @@ -423,7 +430,7 @@ export function runCmdBackground( const execTrace = createExecTraceContext(); const child = spawn(executable, args, { cwd: options.cwd, - env: options.env, + env: resolveExecEnvironment(options), stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], detached: options.detached, windowsHide: true, diff --git a/packages/kernel/src/redaction.test.ts b/packages/kernel/src/redaction.test.ts new file mode 100644 index 0000000000..5dde20aeda --- /dev/null +++ b/packages/kernel/src/redaction.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'vitest'; +import { redactDiagnosticData } from './redaction.ts'; + +test('redacts launch environment maps and CLI entries from structured diagnostics', () => { + const secret = 'https://example.com/private-clip?nonce=secret-value'; + const redacted = redactDiagnosticData({ + launchEnvironment: { _XCAppClipURL: secret }, + launchEnvironmentEntries: [`_XCAppClipURL=${secret}`], + safe: 'visible', + }); + + expect(redacted).toEqual({ + launchEnvironment: '[REDACTED]', + launchEnvironmentEntries: '[REDACTED]', + safe: 'visible', + }); + expect(JSON.stringify(redacted)).not.toContain('secret-value'); +}); diff --git a/packages/kernel/src/redaction.ts b/packages/kernel/src/redaction.ts index c002e17c31..9f31c16b39 100644 --- a/packages/kernel/src/redaction.ts +++ b/packages/kernel/src/redaction.ts @@ -1,5 +1,5 @@ const SENSITIVE_KEY_RE = - /(token|secret|password|authorization|cookie|api[_-]?key|access[_-]?key|private[_-]?key|user[_-]?code|device[_-]?code|refresh[_-]?credential)/i; + /(token|secret|password|authorization|cookie|api[_-]?key|access[_-]?key|private[_-]?key|user[_-]?code|device[_-]?code|refresh[_-]?credential|launch[_-]?environment)/i; const SECRET_TOKEN_RE = /\b(?:bearer\s+[a-z0-9._-]+|adc_(?:agent|live|refresh|cli)_[a-z0-9._-]+)\b/gi; const SENSITIVE_ASSIGNMENT_RE = diff --git a/packages/platform-apple/src/core/__tests__/apps.test.ts b/packages/platform-apple/src/core/__tests__/apps.test.ts index 4104358338..331831c488 100644 --- a/packages/platform-apple/src/core/__tests__/apps.test.ts +++ b/packages/platform-apple/src/core/__tests__/apps.test.ts @@ -340,6 +340,28 @@ test('openIosApp emits a clean simctl launch when launchArgs is an empty array', ); }); +test('openIosApp translates launch environment keys for the iOS simulator child process', async () => { + mockEnsureBootedSimulator.mockResolvedValue(); + mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); + + await openIosApp(IOS_TEST_SIMULATOR, 'MyApp', { + appBundleId: 'com.example.app', + launchArgs: ['-FeatureFlag', 'YES'], + launchEnvironment: { + _XCAppClipURL: 'https://example.com/clip?id=42', + MODE: 'test', + }, + }); + + assert.equal(mockRunCmd.mock.calls.length, 1); + const [command, args, options] = mockRunCmd.mock.calls[0] ?? []; + assert.equal(command, 'xcrun'); + assert.deepEqual(args, ['simctl', 'launch', 'sim-1', 'com.example.app', '-FeatureFlag', 'YES']); + assert.equal(options?.envPatch?.SIMCTL_CHILD__XCAppClipURL, 'https://example.com/clip?id=42'); + assert.equal(options?.envPatch?.SIMCTL_CHILD_MODE, 'test'); + assert.equal(options?.envPatch?._XCAppClipURL, undefined); +}); + test('openIosApp appends launchArgs after the bundle id on iOS device', async () => { await withFakeAppleTool( () => '', @@ -472,6 +494,37 @@ test('openIosApp rejects launchArgs combined with bare URL deep link on iOS simu ); }); +test('openIosApp rejects launchEnvironment combined with bare URL deep link', async () => { + mockEnsureBootedSimulator.mockResolvedValue(); + await assertRejectsAppError( + () => + openIosApp(IOS_TEST_SIMULATOR, 'myapp://item/42', { + launchEnvironment: { MODE: 'test' }, + }), + { code: 'INVALID_ARGS', message: /simctl openurl/ }, + ); +}); + +test('openIosApp rejects launchEnvironment on a physical iOS device', async () => { + await assertRejectsAppError( + () => + openIosApp(IOS_TEST_DEVICE, 'MyApp', { + launchEnvironment: { MODE: 'test' }, + }), + { code: 'UNSUPPORTED_OPERATION', message: /iOS Simulator/ }, + ); +}); + +test('openIosApp rejects launchEnvironment on macOS', async () => { + await assertRejectsAppError( + () => + openIosApp(MACOS_TEST_DEVICE, 'TextEdit', { + launchEnvironment: { MODE: 'test' }, + }), + { code: 'UNSUPPORTED_OPERATION', message: /iOS Simulator/ }, + ); +}); + test('openIosApp rejects launchArgs on macOS', async () => { await assertRejectsAppError( () => diff --git a/packages/platform-apple/src/core/app-launch.ts b/packages/platform-apple/src/core/app-launch.ts index f32f89ea72..83b3082b7f 100644 --- a/packages/platform-apple/src/core/app-launch.ts +++ b/packages/platform-apple/src/core/app-launch.ts @@ -33,8 +33,8 @@ import { resolveIosApp } from './app-resolution.ts'; import { buildSimctlArgsForDevice, runSimctlForDevice } from './simctl.ts'; const IOS_SIMULATOR_CONSOLE_CAPTURE_MS = 25_000; -const IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE = - '--launch-args is not supported with iOS simulator URL opens (simctl openurl ignores launch args). Launch the app first with --launch-args, then issue the URL open in a separate call.'; +const IOS_SIMULATOR_LAUNCH_CONFIGURATION_WITH_URL_MESSAGE = + 'Launch arguments and environment are not supported with bare iOS simulator URL opens (simctl openurl cannot configure the app process). Launch the app first, then issue the URL open in a separate call.'; // fallow-ignore-next-line complexity export async function openIosApp( @@ -44,6 +44,7 @@ export async function openIosApp( appBundleId?: string; launchConsole?: string; launchArgs?: string[]; + launchEnvironment?: Readonly>; terminateRunningApp?: boolean; url?: string; runnerOptions?: AppleRunnerCommandOptions; @@ -51,10 +52,17 @@ export async function openIosApp( ): Promise { const launchConsole = options?.launchConsole?.trim(); const launchArgs = options?.launchArgs; + const launchEnvironment = options?.launchEnvironment; if (launchConsole && (!isIosFamily(device) || device.kind !== 'simulator')) { throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); } if (isMacOs(device)) { + if (launchEnvironment !== undefined) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--launch-env is supported only for iOS Simulator app launches.', + ); + } if (launchArgs && launchArgs.length > 0) { throw new AppError( 'UNSUPPORTED_OPERATION', @@ -64,6 +72,12 @@ export async function openIosApp( await openMacOsApp(device, app, options); return; } + if (launchEnvironment !== undefined && device.kind !== 'simulator') { + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--launch-env is supported only for iOS Simulator app launches.', + ); + } const explicitUrl = options?.url?.trim(); if (explicitUrl) { if (launchConsole) { @@ -73,12 +87,14 @@ export async function openIosApp( throw new AppError('INVALID_ARGS', 'open requires a valid URL target'); } if (device.kind === 'simulator') { - const shouldLaunchAppBeforeUrl = Boolean(launchArgs) || isWebUrl(explicitUrl); + const shouldLaunchAppBeforeUrl = + Boolean(launchArgs) || launchEnvironment !== undefined || isWebUrl(explicitUrl); if (options?.terminateRunningApp || shouldLaunchAppBeforeUrl) { const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app)); if (shouldLaunchAppBeforeUrl) { await launchIosSimulatorApp(device, bundleId, { ...(launchArgs ? { launchArgs } : {}), + ...(launchEnvironment ? { launchEnvironment } : {}), ...(options?.terminateRunningApp ? { terminateRunningApp: true } : {}), }); } else { @@ -96,6 +112,12 @@ export async function openIosApp( 'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.', ); } + if (launchEnvironment !== undefined) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--launch-env is supported only for iOS Simulator app launches.', + ); + } await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl, launchArgs, @@ -110,9 +132,15 @@ export async function openIosApp( throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); } if (device.kind === 'simulator') { - await openIosSimulatorUrl(device, deepLinkTarget, launchArgs); + await openIosSimulatorUrl(device, deepLinkTarget, launchArgs, launchEnvironment); return; } + if (launchEnvironment !== undefined) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--launch-env is supported only for iOS Simulator app launches.', + ); + } const bundleId = resolveIosDeviceDeepLinkBundleId(options?.appBundleId, deepLinkTarget); if (!bundleId) { throw new AppError( @@ -133,11 +161,18 @@ export async function openIosApp( await launchIosSimulatorApp(device, bundleId, { ...(launchConsole ? { launchConsole } : {}), ...(launchArgs ? { launchArgs } : {}), + ...(launchEnvironment ? { launchEnvironment } : {}), ...(options?.terminateRunningApp ? { terminateRunningApp: true } : {}), }); return; } + if (launchEnvironment !== undefined) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--launch-env is supported only for iOS Simulator app launches.', + ); + } await launchIosDeviceProcess(device, bundleId, { launchArgs, runnerOptions: options?.runnerOptions, @@ -148,9 +183,10 @@ async function openIosSimulatorUrl( device: DeviceInfo, url: string, launchArgs: string[] | undefined, + launchEnvironment?: Readonly>, ): Promise { - if (launchArgs && launchArgs.length > 0) { - throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE); + if ((launchArgs && launchArgs.length > 0) || launchEnvironment !== undefined) { + throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_CONFIGURATION_WITH_URL_MESSAGE); } await ensureBootedSimulator(device); await runSimctlForDevice(device, ['openurl', device.id, url]); @@ -226,7 +262,12 @@ async function terminateIosSimulatorApp(device: DeviceInfo, bundleId: string): P async function launchIosSimulatorApp( device: DeviceInfo, bundleId: string, - options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean }, + options?: { + launchConsole?: string; + launchArgs?: string[]; + launchEnvironment?: Readonly>; + terminateRunningApp?: boolean; + }, ): Promise { await assertNotSystemSurfaceHost(bundleId); await ensureBootedSimulator(device); @@ -248,10 +289,18 @@ async function launchIosSimulatorApp( device, buildIosSimulatorLaunchArgs(device.id, bundleId, options), ); + const launchEnvironmentPatch = iosSimulatorLaunchEnvironmentPatch( + options?.launchEnvironment, + ); const result = options?.launchConsole - ? await runIosSimulatorConsoleLaunch(launchArgs, options.launchConsole) + ? await runIosSimulatorConsoleLaunch( + launchArgs, + options.launchConsole, + launchEnvironmentPatch, + ) : await runXcrun(launchArgs, { allowFailure: true, + ...(launchEnvironmentPatch ? { envPatch: launchEnvironmentPatch } : {}), }); if (result.exitCode === 0) return; @@ -288,7 +337,12 @@ async function launchIosSimulatorApp( function buildIosSimulatorLaunchArgs( deviceId: string, bundleId: string, - options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean }, + options?: { + launchConsole?: string; + launchArgs?: string[]; + launchEnvironment?: Readonly>; + terminateRunningApp?: boolean; + }, ): string[] { const args = ['launch']; // `--console-pty` is the console mode this path needs: simctl writes the app's bytes to its own @@ -304,15 +358,26 @@ function buildIosSimulatorLaunchArgs( return args; } +function iosSimulatorLaunchEnvironmentPatch( + launchEnvironment: Readonly> | undefined, +): Readonly> | undefined { + if (launchEnvironment === undefined) return undefined; + return Object.fromEntries( + Object.entries(launchEnvironment).map(([key, value]) => [`SIMCTL_CHILD_${key}`, value]), + ); +} + async function runIosSimulatorConsoleLaunch( launchArgs: ScopedSimctlCommand, logPath: string, + envPatch?: Readonly>, ): Promise>> { await ensureHostDirectory(path.dirname(logPath)); try { const result = await runXcrun(launchArgs, { allowFailure: true, timeoutMs: IOS_SIMULATOR_CONSOLE_CAPTURE_MS, + ...(envPatch ? { envPatch } : {}), }); await writeIosSimulatorConsoleLog(logPath, result.stdout, result.stderr); return result; diff --git a/packages/platform-apple/src/interactor.ts b/packages/platform-apple/src/interactor.ts index 0d960fea39..937836a7aa 100644 --- a/packages/platform-apple/src/interactor.ts +++ b/packages/platform-apple/src/interactor.ts @@ -60,6 +60,7 @@ export function createAppleInteractor( appBundleId: options?.appBundleId, launchConsole: options?.launchConsole, launchArgs: options?.launchArgs, + launchEnvironment: options?.launchEnvironment, terminateRunningApp: options?.terminateRunningApp, url: options?.url, runnerOptions: runnerOpts, diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 3f562c3a8a..41dc81ff7a 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -225,6 +225,7 @@ async function dispatchAppleLaunchUrl( clearAppState: undefined, launchConsole: undefined, launchArgs: undefined, + launchEnvironment: undefined, }; const startedAtMs = Date.now(); await invokeApplicationOpen({ @@ -439,7 +440,8 @@ function isDirectAppLaunch(input: OpenApplicationInput): boolean { return ( input.execution.clearAppState === true || Boolean(input.execution.launchConsole?.trim()) || - Boolean(input.execution.launchArgs?.length) + Boolean(input.execution.launchArgs?.length) || + input.execution.launchEnvironment !== undefined ); } diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 3a1be8a0b8..370fcec399 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -302,6 +302,7 @@ function summarizeProviderScenarioFlagExclusions() { keys: [ 'kind', 'launchArgs', + 'launchEnvironmentEntries', 'perfTemplate', 'iosXctestrunFile', 'iosXctestDerivedDataPath', diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 3095a7699e..a3b19ee673 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -64,6 +64,7 @@ "@agent-device/contracts/is-predicate", "@agent-device/contracts/keyboard", "@agent-device/contracts/keyboard-runtime", + "@agent-device/contracts/launch-environment", "@agent-device/contracts/lease-scope", "@agent-device/contracts/local-interactor-operation-set", "@agent-device/contracts/logs-runtime-plan", diff --git a/src/commands/command-flags.ts b/src/commands/command-flags.ts index 4699c2c0bc..4f67ac8e2f 100644 --- a/src/commands/command-flags.ts +++ b/src/commands/command-flags.ts @@ -60,6 +60,7 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { activity: options.activity, launchConsole: options.launchConsole, launchArgs: options.launchArgs, + launchEnvironment: options.launchEnvironment, relaunch: options.relaunch, shutdown: options.shutdown, saveScript: options.saveScript, diff --git a/src/commands/management/app.test.ts b/src/commands/management/app.test.ts index 31e7655fcf..4a9ad390d2 100644 --- a/src/commands/management/app.test.ts +++ b/src/commands/management/app.test.ts @@ -64,6 +64,60 @@ describe('open startup budget', () => { }); }); +describe('open launch environment', () => { + test('parses repeatable entries and preserves equals signs in values', () => { + const parsed = parseArgs( + [ + 'open', + 'com.example.app', + '--launch-env', + '_XCAppClipURL=https://example.com/clip?id=42', + '--launch-env', + 'MODE=test', + ], + { strictFlags: true }, + ); + + expect(parsed.flags.launchEnvironmentEntries).toEqual([ + '_XCAppClipURL=https://example.com/clip?id=42', + 'MODE=test', + ]); + expect(openCommandFacet.cliReader(parsed.positionals, parsed.flags)).toMatchObject({ + launchEnvironment: { + _XCAppClipURL: 'https://example.com/clip?id=42', + MODE: 'test', + }, + }); + }); + + test.each([ + { entries: ['MISSING_SEPARATOR'], message: /KEY=VALUE/ }, + { entries: ['=value'], message: /non-empty/ }, + { entries: ['MODE=one', 'MODE=two'], message: /duplicate key MODE/ }, + { entries: ['SIMCTL_CHILD_MODE=test'], message: /omit the SIMCTL_CHILD_/ }, + ])('rejects invalid CLI entries: $entries', ({ entries, message }) => { + expect(() => + openCommandFacet.cliReader(['com.example.app'], flags({ launchEnvironmentEntries: entries })), + ).toThrow(message); + }); + + test('rejects non-string typed values before sending the daemon request', async () => { + const stateDir = tempStateDir(); + try { + const { client, calls } = createOpenClient({ stateDir, session: 'launch-env' }); + await expect( + openCommandFacet.definition.invoke(client, { + app: 'com.example.app', + launchEnvironment: { MODE: 42 } as unknown as Record, + }), + ).rejects.toThrow(/value for MODE must be a string/); + expect(calls).toHaveLength(0); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); +}); + describe('open command metro session hints', () => { test('CLI parser accepts --metro-host/--metro-port/--bundle-url/--launch-url on open', () => { const parsed = parseArgs( diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 0e8e048b53..ffc82f9843 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -21,6 +21,58 @@ import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { withCommandRuntimeHints } from '../runtime-hints.ts'; import { managementCliOutputFormatters } from './output.ts'; +import { AppError } from '@agent-device/kernel/errors'; + +const SIMCTL_CHILD_PREFIX = 'SIMCTL_CHILD_'; + +function readLaunchEnvironment(value: unknown): Readonly> | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('INVALID_ARGS', 'launchEnvironment must be an object of string values.'); + } + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + assertLaunchEnvironmentKey(key); + if (typeof entry !== 'string') { + throw new AppError('INVALID_ARGS', `launchEnvironment value for ${key} must be a string.`); + } + result[key] = entry; + } + return Object.freeze(result); +} + +function parseLaunchEnvironmentEntries( + entries: readonly string[] | undefined, +): Readonly> | undefined { + if (entries === undefined) return undefined; + const result: Record = {}; + for (const entry of entries) { + const separator = entry.indexOf('='); + if (separator < 0) throw new AppError('INVALID_ARGS', '--launch-env requires KEY=VALUE.'); + const key = entry.slice(0, separator); + assertLaunchEnvironmentKey(key); + if (Object.hasOwn(result, key)) { + throw new AppError('INVALID_ARGS', `--launch-env contains duplicate key ${key}.`); + } + result[key] = entry.slice(separator + 1); + } + return Object.freeze(result); +} + +function assertLaunchEnvironmentKey(key: string): void { + if (key.trim().length === 0 || key.includes('=') || key.includes('\0')) { + throw new AppError( + 'INVALID_ARGS', + 'launchEnvironment keys must be non-empty environment names.', + ); + } + if (key.startsWith(SIMCTL_CHILD_PREFIX)) { + throw new AppError( + 'INVALID_ARGS', + `launchEnvironment keys must omit the ${SIMCTL_CHILD_PREFIX} transport prefix.`, + ); + } +} const appsCommandMetadata = defineFieldCommandMetadata( 'apps', @@ -48,6 +100,12 @@ const openCommandMetadata = defineFieldCommandMetadata( launchArgs: stringArrayField( 'Launch arguments forwarded verbatim to the platform launch command.', ), + launchEnvironment: jsonSchemaField>>({ + type: 'object', + description: + 'iOS Simulator child-process environment. Provide child variable names without SIMCTL_CHILD_. Values are sensitive.', + additionalProperties: { type: 'string' }, + }), relaunch: booleanField('Force relaunch.'), timeoutMs: integerField( 'Startup budget in milliseconds. Bounds the Simulator boot wait, so a never-booted Simulator can finish its first-boot migration; omit for the default startup behavior.', @@ -102,7 +160,10 @@ function toAppOpenOptions( launchUrl?: string; }, ): AppOpenOptions { - return withCommandRuntimeHints(input); + return withCommandRuntimeHints({ + ...input, + launchEnvironment: readLaunchEnvironment(input.launchEnvironment), + }); } const appsCliSchema = { @@ -115,6 +176,7 @@ const openCliSchema = { 'activity', 'launchConsole', 'launchArgs', + 'launchEnvironmentEntries', 'testIme', 'saveScript', 'force', @@ -147,6 +209,7 @@ const openCliReader: CliReader = (positionals, flags) => ({ activity: flags.activity, launchConsole: flags.launchConsole, launchArgs: flags.launchArgs, + launchEnvironment: parseLaunchEnvironmentEntries(flags.launchEnvironmentEntries), relaunch: flags.relaunch, foreground: flags.foreground, timeoutMs: flags.timeoutMs, diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 0c143e4ccd..de96067c7a 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -30,6 +30,7 @@ export const DISPATCH_CONTEXT_FLAG_KEYS = [ 'activity', 'launchConsole', 'launchArgs', + 'launchEnvironment', 'clearAppState', 'verbose', 'iosXctestrunFile', diff --git a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts index 8e061f1d15..648767a628 100644 --- a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts +++ b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts @@ -241,6 +241,7 @@ export function applicationLifecycleFixtureInteractor( clearAppState, launchArgs: options?.launchArgs, launchConsole: options?.launchConsole, + launchEnvironment: options?.launchEnvironment, terminateRunningApp: options?.terminateRunningApp, }), ); @@ -278,6 +279,7 @@ function lifecycleEffectContext( clearAppState?: boolean; launchArgs?: readonly string[]; launchConsole?: string; + launchEnvironment?: Readonly>; terminateRunningApp?: boolean; }>, ): LifecycleEffectContext { @@ -290,6 +292,7 @@ function lifecycleEffectContext( clearAppState: input.clearAppState, launchArgs: input.launchArgs, launchConsole: input.launchConsole, + launchEnvironment: input.launchEnvironment, iosXctestrunFile: runner.iosXctestrunFile, iosXctestDerivedDataPath: runner.iosXctestDerivedDataPath, iosXctestEnvDir: runner.iosXctestEnvDir, diff --git a/src/daemon/application-lifecycle-execution.ts b/src/daemon/application-lifecycle-execution.ts index 17d2d4855e..a961963ce9 100644 --- a/src/daemon/application-lifecycle-execution.ts +++ b/src/daemon/application-lifecycle-execution.ts @@ -17,6 +17,7 @@ export function applicationLifecycleExecutionFromRequest( activity: req.flags?.activity, launchConsole: req.flags?.launchConsole, launchArgs: req.flags?.launchArgs, + launchEnvironment: req.flags?.launchEnvironment, clearAppState: req.flags?.clearAppState, iosXctestrunFile: req.flags?.iosXctestrunFile, iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts index 7f61090b65..b9b04b477e 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts @@ -219,6 +219,7 @@ test('open applies launch-only flags only to the direct app launch before runtim positionals: string[]; launchConsole?: string; launchArgs?: readonly string[]; + launchEnvironment?: Readonly>; }> = []; sessionStore.setRuntimeHints('launch-console-runtime', { @@ -232,6 +233,7 @@ test('open applies launch-only flags only to the direct app launch before runtim positionals, launchConsole: context?.launchConsole, launchArgs: context?.launchArgs, + launchEnvironment: context?.launchEnvironment, }); return undefined; }); @@ -242,7 +244,12 @@ test('open applies launch-only flags only to the direct app launch before runtim session: 'launch-console-runtime', command: 'open', positionals: ['Demo'], - flags: { platform: 'ios', launchConsole: launchConsolePath, launchArgs: ['-Flag', 'YES'] }, + flags: { + platform: 'ios', + launchConsole: launchConsolePath, + launchArgs: ['-Flag', 'YES'], + launchEnvironment: { MODE: 'test' }, + }, }, sessionName: 'launch-console-runtime', logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -257,12 +264,14 @@ test('open applies launch-only flags only to the direct app launch before runtim positionals: ['Demo'], launchConsole: launchConsolePath, launchArgs: ['-Flag', 'YES'], + launchEnvironment: { MODE: 'test' }, }, { command: 'open', positionals: ['myapp://dev-client'], launchConsole: undefined, launchArgs: undefined, + launchEnvironment: undefined, }, ]); }); diff --git a/test/integration/provider-scenarios/ios-lifecycle.test.ts b/test/integration/provider-scenarios/ios-lifecycle.test.ts index 2becd3f0c5..36f5361a63 100644 --- a/test/integration/provider-scenarios/ios-lifecycle.test.ts +++ b/test/integration/provider-scenarios/ios-lifecycle.test.ts @@ -18,7 +18,14 @@ import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from './test-timeouts.ts'; test('Provider-backed integration iOS Settings flow uses scripted simctl and runner providers', async () => { await withProviderScenarioResource( createIosSettingsWorld, - async ({ appPath, appleTool, daemon, inventoryRequests, runnerTranscript }) => { + async ({ + appPath, + appleTool, + daemon, + inventoryRequests, + launchEnvironments, + runnerTranscript, + }) => { const scopedDevices = await daemon.client().devices.list({ platform: 'ios', iosSimulatorDeviceSet: '/tmp/provider-scenario-simulators', @@ -49,6 +56,22 @@ test('Provider-backed integration iOS Settings flow uses scripted simctl and run ios_simulator_device_set: null, }, }, + { + name: 'relaunch app with App Clip environment and launch arguments', + command: 'open', + positionals: ['com.apple.Preferences'], + flags: { + launchArgs: ['-FixtureMode', 'app-clip'], + launchEnvironment: { + _XCAppClipURL: 'https://example.com/clip?id=42', + MODE: 'provider-test', + }, + }, + expectData: { appBundleId: 'com.apple.Preferences' }, + assert: (response) => { + assert.doesNotMatch(JSON.stringify(response.json), /provider-test/); + }, + }, { name: 'prepare iOS runner', command: 'prepare', @@ -277,6 +300,20 @@ test('Provider-backed integration iOS Settings flow uses scripted simctl and run runnerTranscript.assertComplete(); assertFlatToolCall(appleTool.calls, ['simctl', 'launch', 'sim-1', 'com.apple.Preferences']); + assertFlatToolCall(appleTool.calls, [ + 'simctl', + 'launch', + 'sim-1', + 'com.apple.Preferences', + '-FixtureMode', + 'app-clip', + ]); + assert.deepEqual(launchEnvironments, [ + { + SIMCTL_CHILD__XCAppClipURL: 'https://example.com/clip?id=42', + SIMCTL_CHILD_MODE: 'provider-test', + }, + ]); assertFlatToolCall(appleTool.calls, [ 'simctl', 'launch', diff --git a/test/integration/provider-scenarios/ios-world.ts b/test/integration/provider-scenarios/ios-world.ts index a012acebb2..640dbe5d7f 100644 --- a/test/integration/provider-scenarios/ios-world.ts +++ b/test/integration/provider-scenarios/ios-world.ts @@ -22,6 +22,7 @@ type IosSettingsWorld = { appleTool: { calls: FlatToolCall[] }; runnerTranscript: ProviderScenarioTranscript; inventoryRequests: DeviceInventoryRequest[]; + launchEnvironments: Array>>; appPath: string; close: () => Promise; }; @@ -179,6 +180,7 @@ export async function createIosSettingsWorld(): Promise { 'ios.runner', ); let clipboardText = ''; + const launchEnvironments: Array>> = []; const appleTool = createRecordingAppleToolProvider({ plist: { readJson: async (plistPath) => { @@ -193,6 +195,7 @@ export async function createIosSettingsWorld(): Promise { }, }, simctl: async (args, options) => { + recordLaunchEnvironment(args, options, launchEnvironments); if (args.join(' ') === 'pbcopy sim-1') { clipboardText = String(options?.stdin ?? ''); return { stdout: '', stderr: '', exitCode: 0 }; @@ -235,6 +238,7 @@ export async function createIosSettingsWorld(): Promise { appleTool, runnerTranscript, inventoryRequests, + launchEnvironments, appPath, close: async () => { if (closed) return; @@ -245,6 +249,18 @@ export async function createIosSettingsWorld(): Promise { }; } +function recordLaunchEnvironment( + args: readonly string[], + options: Readonly<{ envPatch?: Readonly> }> | undefined, + launchEnvironments: Array>>, +): void { + if (args[0] !== 'launch' || !options?.envPatch) return; + launchEnvironments.push({ + SIMCTL_CHILD__XCAppClipURL: options.envPatch.SIMCTL_CHILD__XCAppClipURL, + SIMCTL_CHILD_MODE: options.envPatch.SIMCTL_CHILD_MODE, + }); +} + type IosPhysicalReinstallWorld = { daemon: ProviderScenarioHarness; appleTool: { calls: FlatToolCall[] }; diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 2bcbc00342..38d99f2715 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -113,6 +113,8 @@ The canonical client example is embedded below. It is also runnable from [`examp For direct iOS simulator app launches, `client.apps.open({ app, platform: 'ios', launchConsole: './artifacts/app.console.log' })` captures launch-time stdout/stderr. The option mirrors `open --launch-console` and is not valid for URL opens or non-simulator targets. +`client.apps.open({ app, platform: 'ios', launchEnvironment: { _XCAppClipURL: clipUrl } })` sets child-process environment for an iOS Simulator app launch. Keys omit the `SIMCTL_CHILD_` transport prefix. Values are treated as sensitive and are not included in ordinary diagnostics or traces. The option is rejected on physical devices, macOS, and non-Apple platforms. + `client.sessions.stateDir()` mirrors `session state-dir` and returns the resolved daemon state directory as a pure local resolution — it never starts or contacts the daemon. Pass `{ stateDir }` to resolve an explicit override the same way the CLI resolves `--state-dir`. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 2b4ad186af..615e230abc 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -79,6 +79,8 @@ agent-device fold open - `open ` opens a deep link on iOS. - `open --launch-console ` captures launch-time stdout/stderr for direct iOS simulator app launches. It is not valid for URL opens or non-simulator targets. +- `open --launch-env KEY=VALUE` sets one child-process environment variable for an iOS Simulator app launch. Repeat the flag for multiple variables. Use child names such as `_XCAppClipURL`; do not add the `SIMCTL_CHILD_` transport prefix. Duplicate or empty keys are rejected, values are redacted from diagnostics, and physical iOS devices, macOS, and other platforms report `UNSUPPORTED_OPERATION`. +- Launch environment can be combined with repeatable `--launch-args`. With `open `, agent-device launches the app with both settings before opening the URL. A bare `open ` cannot accept launch arguments or environment because `simctl openurl` does not configure an app process. - `open --platform macos --surface app|frontmost-app|desktop|menubar` selects the macOS session surface explicitly. `app` is the default when an app argument is provided. - `back` now defaults to app-owned back navigation. On Apple targets that means visible in-app back UI only. On Android this currently maps to the same back keyevent because Android routes in-app back through that platform event. - `back --in-app` is an explicit alias for the default app-owned behavior.