diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index d84aa1cc0e..a84db14c9d 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. For Android intent extras, use --launch-args.', + 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..a5c56884d6 100644 --- a/packages/contracts/src/application-lifecycle-interaction.test.ts +++ b/packages/contracts/src/application-lifecycle-interaction.test.ts @@ -1,10 +1,11 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor } from './interactor-types.ts'; import type { OpenApplicationInput } from './application-lifecycle-runtime.ts'; import { bindDirectApplicationLifecycle, bindLocalApplicationLifecycleInteractor, + bindProviderApplicationLifecycleInteractor, invokeApplicationOpen, } from './application-lifecycle-interaction.ts'; @@ -17,6 +18,20 @@ const IOS_SIMULATOR: DeviceInfo = { booted: true, }; +const IPADOS_SIMULATOR: DeviceInfo = { + ...IOS_SIMULATOR, + appleOs: 'ipados', + id: 'ipados-simulator', + name: 'iPad', +}; + +const TVOS_SIMULATOR: DeviceInfo = { + ...IOS_SIMULATOR, + appleOs: 'tvos', + id: 'tvos-simulator', + name: 'Apple TV', +}; + const LINUX_DEVICE: DeviceInfo = { platform: 'linux', id: 'linux-local', @@ -25,12 +40,10 @@ const LINUX_DEVICE: DeviceInfo = { booted: true, }; -const WEB_DEVICE: DeviceInfo = { - platform: 'web', - id: 'web-local', - name: 'Browser', +const IOS_PHYSICAL_DEVICE: DeviceInfo = { + ...IOS_SIMULATOR, + id: 'ios-device', kind: 'device', - booted: true, }; function interactorWithOpen(open: Interactor['open'] = async () => undefined): Interactor { @@ -65,20 +78,24 @@ test('direct lifecycle owners preserve the daemon runtime launch URL follow-up', calls.push({ app, options }); }); const binding = bindLocalApplicationLifecycleInteractor({ - device: WEB_DEVICE, + device: IOS_SIMULATOR, signal: new AbortController().signal, resolveInteractor: async () => interactor, }); const lifecycle = bindDirectApplicationLifecycle({ binding, - owner: 'Linux', + owner: 'iOS Simulator', openTargetIdentity: 'app-name', }); await lifecycle.openApplication( openInput({ runtimeLaunchUrl: 'example://after-open', - execution: { clearAppState: true, launchArgs: ['--first-launch'] }, + execution: { + clearAppState: true, + launchArgs: ['--first-launch'], + launchEnvironment: { MODE: 'test' }, + }, }), ); @@ -86,9 +103,11 @@ test('direct lifecycle owners preserve the daemon runtime launch URL follow-up', expect(calls[0]?.options).toMatchObject({ appBundleId: 'com.example.app', launchArgs: ['--first-launch'], + launchEnvironment: { MODE: 'test' }, }); expect(calls[1]?.options).toMatchObject({ appBundleId: 'com.example.app' }); expect(calls[1]?.options).toHaveProperty('launchArgs', undefined); + expect(calls[1]?.options).toHaveProperty('launchEnvironment', undefined); }); test.each([ @@ -113,6 +132,34 @@ 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 a non-iOS Apple simulator', + device: TVOS_SIMULATOR, + 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, @@ -165,3 +212,47 @@ test.each([ }), ).rejects.toThrow(message); }); + +test('iPadOS Simulator accepts launch environment for app launches', async () => { + const calls: Array<{ app: string; options: unknown }> = []; + const lifecycle = bindDirectApplicationLifecycle({ + binding: bindLocalApplicationLifecycleInteractor({ + device: IPADOS_SIMULATOR, + signal: new AbortController().signal, + resolveInteractor: async () => + interactorWithOpen(async (app, options) => { + calls.push({ app, options }); + }), + }), + owner: 'iPadOS Simulator', + openTargetIdentity: 'bundle-id', + }); + + await lifecycle.openApplication( + openInput({ execution: { launchEnvironment: { MODE: 'test' } } }), + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.options).toMatchObject({ launchEnvironment: { MODE: 'test' } }); +}); + +test('provider-owned iOS Simulator lifecycle rejects launch environment before dispatch', async () => { + const open = vi.fn(async () => undefined); + const lifecycle = bindDirectApplicationLifecycle({ + binding: bindProviderApplicationLifecycleInteractor({ + device: IOS_SIMULATOR, + signal: new AbortController().signal, + resolveInteractor: () => interactorWithOpen(open), + }), + owner: 'Limrun', + openTargetIdentity: 'bundle-id', + }); + + await expect( + lifecycle.openApplication(openInput({ execution: { launchEnvironment: { MODE: 'test' } } })), + ).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'unsupported-provider-mode' }, + }); + expect(open).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/application-lifecycle-interaction.ts b/packages/contracts/src/application-lifecycle-interaction.ts index 0db4ce162f..ae394ea28c 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 { isHandheldAppleSimulator } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { @@ -25,6 +25,7 @@ import type { */ export type ApplicationLifecycleInteractorBinding = Readonly<{ device: DeviceInfo; + providerOwned: boolean; signal: AbortSignal; resolveInteractor( execution: ApplicationLifecycleExecution, @@ -83,6 +84,7 @@ function bindApplicationLifecycleInteractor( const { device, signal, ownership } = params; return Object.freeze({ device, + providerOwned: ownership !== 'local', signal, resolveInteractor: async (execution, appBundleId) => { const runner = applicationLifecycleRunnerContext(execution, appBundleId, signal); @@ -181,6 +183,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(); } @@ -188,12 +193,25 @@ function assertOpenDeviceSupport( device: DeviceInfo, execution: ApplicationLifecycleExecution, ): void { - if (execution.launchConsole && (!isIosFamily(device) || device.kind !== 'simulator')) { + if (execution.launchConsole && !isHandheldAppleSimulator(device)) { throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); } 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 && !isHandheldAppleSimulator(device)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--launch-env is supported only for iOS Simulator app launches.', + ); + } } async function invokeApplicationUrlOpen( @@ -217,6 +235,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 +263,7 @@ async function invokeApplicationTargetOpen( appBundleId: params.appBundleId, launchConsole: execution.launchConsole, launchArgs: execution.launchArgs ? [...execution.launchArgs] : undefined, + launchEnvironment: execution.launchEnvironment, terminateRunningApp: params.terminateRunningApp, }); } @@ -305,6 +325,13 @@ async function openDirectApplication( input: OpenApplicationInput, ): Promise { const { binding } = params; + if (binding.providerOwned && input.execution.launchEnvironment !== undefined) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `Launch environment is not supported by the ${params.owner} application provider.`, + { reason: 'unsupported-provider-mode' }, + ); + } const interactor = await binding.resolveInteractor(input.execution, input.appBundleId); if (params.closeBeforeRelaunch && input.relaunch && input.target !== undefined) { await invokeApplicationClose({ @@ -332,6 +359,7 @@ async function openDirectApplication( clearAppState: undefined, launchConsole: undefined, launchArgs: undefined, + launchEnvironment: undefined, }, }); } 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..baee9d2c1d --- /dev/null +++ b/packages/kernel/src/redaction.test.ts @@ -0,0 +1,48 @@ +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: { _XCAppClipURL: '[REDACTED]' }, + launchEnvironmentEntries: ['_XCAppClipURL=[REDACTED]'], + safe: 'visible', + }); + expect(JSON.stringify(redacted)).not.toContain('secret-value'); + expect(JSON.stringify(redacted)).toContain('_XCAppClipURL'); +}); + +test('redacts launch environment values in command arguments and free-text diagnostics', () => { + const secret = 'https://example.com/private-clip?nonce=secret-value'; + const redacted = redactDiagnosticData({ + argv: ['--launch-env', `_XCAppClipURL=${secret}`, `--launch-env=MODE=${secret}`], + message: `invalid --launch-env _XCAppClipURL=${secret}`, + environment: { SIMCTL_CHILD_MODE: 'private-mode' }, + }); + const serialized = JSON.stringify(redacted); + + expect(serialized).not.toContain('secret-value'); + expect(serialized).not.toContain('private-mode'); + expect(serialized).toContain('_XCAppClipURL'); + expect(serialized).toContain('SIMCTL_CHILD_MODE'); + expect(serialized).toContain('[REDACTED]'); +}); + +test('redacts plain launch environment values without hiding unrelated assignments', () => { + const redacted = redactDiagnosticData({ + argv: ['--launch-env', 'MODE=plain-secret'], + message: '--launch-env requires KEY=VALUE. Example: PORT=8080 HOST=localhost', + }); + + expect(redacted).toEqual({ + argv: ['--launch-env', 'MODE=[REDACTED]'], + message: '--launch-env requires KEY=VALUE. Example: PORT=8080 HOST=localhost', + }); + expect(JSON.stringify(redacted)).not.toContain('plain-secret'); +}); diff --git a/packages/kernel/src/redaction.ts b/packages/kernel/src/redaction.ts index c002e17c31..4a21f36a11 100644 --- a/packages/kernel/src/redaction.ts +++ b/packages/kernel/src/redaction.ts @@ -1,9 +1,11 @@ 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 = /\b([a-z0-9_-]*(?:api[_-]?key|token|secret|password|user[_-]?code|device[_-]?code|refresh[_-]?credential)[a-z0-9_-]*)(\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi; +const LAUNCH_ENV_ASSIGNMENT_RE = + /(--launch-env(?:=\s*|\s+))((?:SIMCTL_CHILD_)?[A-Za-z_][A-Za-z0-9_]*=)("[^"]*"|'[^']*'|\S+)/; const URL_RE = /https?:\/\/[^\s"'<>]+/gi; const REDACTED_STRING_MAX_LENGTH = 400; const TRUNCATION_SUFFIX = '...'; @@ -33,12 +35,46 @@ function redactValue(value: unknown, seen: WeakSet, keyHint?: string): u if (seen.has(value as object)) return '[Circular]'; seen.add(value as object); - if (Array.isArray(value)) { - return value.map((entry) => redactValue(entry, seen)); - } + if (Array.isArray(value)) return redactArray(value, seen, keyHint); + return redactRecord(value as Record, seen); +} + +function redactArray( + value: readonly unknown[], + seen: WeakSet, + keyHint?: string, +): unknown[] { + return value.map((entry, index) => { + if (keyHint === 'argv' && typeof entry === 'string') { + if (entry.startsWith('--launch-env=')) { + return `--launch-env=${redactLaunchEnvironmentEntry(entry.slice('--launch-env='.length))}`; + } + if (value[index - 1] === '--launch-env') return redactLaunchEnvironmentEntry(entry); + } + return redactValue(entry, seen); + }); +} +function redactRecord( + value: Record, + seen: WeakSet, +): Record { const output: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { + for (const [key, entry] of Object.entries(value)) { + if (key === 'launchEnvironment') { + output[key] = redactLaunchEnvironmentMap(entry, seen); + continue; + } + if (key === 'launchEnvironmentEntries' && Array.isArray(entry)) { + output[key] = entry.map((raw) => + typeof raw === 'string' ? redactLaunchEnvironmentEntry(raw) : redactValue(raw, seen), + ); + continue; + } + if (key.startsWith('SIMCTL_CHILD_')) { + output[key] = typeof entry === 'string' ? '[REDACTED]' : redactValue(entry, seen, key); + continue; + } if (SENSITIVE_KEY_RE.test(key)) { output[key] = '[REDACTED]'; continue; @@ -54,7 +90,7 @@ function redactString(value: string, keyHint?: string): string { if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return '[REDACTED]'; let output = redactUrls(trimmed); output = output.replace(SECRET_TOKEN_RE, '[REDACTED]'); - output = output.replace( + output = output.replaceAll( SENSITIVE_ASSIGNMENT_RE, (match, key: string, separator: string, rawValue: string, offset: number, input: string) => { if (isSafeSetupUrlAssignment({ key, separator, rawValue, offset, input })) return match; @@ -62,9 +98,35 @@ function redactString(value: string, keyHint?: string): string { return `${key}${separator}[REDACTED]`; }, ); + output = output.replace( + LAUNCH_ENV_ASSIGNMENT_RE, + (_match, flag: string, assignment: string, rawValue: string) => + /^VALUE\.?$/i.test(rawValue) + ? `${flag}${assignment}${rawValue}` + : `${flag}${assignment}[REDACTED]`, + ); + output = output.replaceAll( + /(SIMCTL_CHILD_[A-Za-z_][A-Za-z0-9_]*=)("[^"]*"|'[^']*'|\S+)/g, + '$1[REDACTED]', + ); return boundRedactedString(output); } +function redactLaunchEnvironmentMap(value: unknown, seen: WeakSet): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return redactValue(value, seen); + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + typeof entry === 'string' ? '[REDACTED]' : redactValue(entry, seen, key), + ]), + ); +} + +function redactLaunchEnvironmentEntry(entry: string): string { + const separator = entry.indexOf('='); + return separator <= 0 ? redactString(entry) : `${entry.slice(0, separator + 1)}[REDACTED]`; +} + function boundRedactedString(value: string): string { if (value.length <= REDACTED_STRING_MAX_LENGTH) return value; return `${value.slice(0, REDACTED_STRING_MAX_LENGTH - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`; diff --git a/packages/platform-apple/src/core/__tests__/apps.test.ts b/packages/platform-apple/src/core/__tests__/apps.test.ts index 16102604bb..1b8a4bae6d 100644 --- a/packages/platform-apple/src/core/__tests__/apps.test.ts +++ b/packages/platform-apple/src/core/__tests__/apps.test.ts @@ -42,6 +42,7 @@ import { withFakeAppleTool, type FakeAppleToolResponse } from '../../__tests__/f import { IOS_TEST_DEVICE, IOS_TEST_SIMULATOR, + IPADOS_TEST_SIMULATOR, MACOS_TEST_DEVICE, } from './apple-core-stub-helpers.ts'; @@ -340,6 +341,58 @@ 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 translates launch environment keys for an iPadOS simulator', async () => { + mockEnsureBootedSimulator.mockResolvedValue(); + mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); + + await openIosApp(IPADOS_TEST_SIMULATOR, 'MyApp', { + appBundleId: 'com.example.app', + launchEnvironment: { MODE: 'ipad-test' }, + }); + + const [, , options] = mockRunCmd.mock.calls[0] ?? []; + assert.equal(options?.envPatch?.SIMCTL_CHILD_MODE, 'ipad-test'); +}); + +test('openIosApp captures launch console output when launch environment is set', async () => { + const tmpDir = await mkdtempForTest('agent-device-ios-console-env-test-'); + const launchConsolePath = path.join(tmpDir, 'console.log'); + mockEnsureBootedSimulator.mockResolvedValue(); + mockRunCmd.mockResolvedValue({ stdout: 'started', stderr: '', exitCode: 0 }); + + await openIosApp(IOS_TEST_SIMULATOR, 'MyApp', { + appBundleId: 'com.example.app', + launchConsole: launchConsolePath, + launchEnvironment: { MODE: 'private-mode' }, + }); + + const [, , options] = mockRunCmd.mock.calls[0] ?? []; + assert.equal(options?.envPatch?.SIMCTL_CHILD_MODE, 'private-mode'); + assert.equal(await fs.readFile(launchConsolePath, 'utf8'), 'started'); +}); + test('openIosApp appends launchArgs after the bundle id on iOS device', async () => { await withFakeAppleTool( () => '', @@ -436,6 +489,25 @@ test('openIosApp launches iOS simulator app before opening custom-scheme URL wit ]); }); +test('openIosApp applies launch environment before opening custom-scheme URL', async () => { + mockEnsureBootedSimulator.mockResolvedValue(); + mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); + + await openIosApp(IOS_TEST_SIMULATOR, 'MyApp', { + appBundleId: 'com.example.app', + url: 'myapp://item/42', + launchEnvironment: { MODE: 'test' }, + }); + + assert.equal(mockRunCmd.mock.calls.length, 2); + assert.equal(mockRunCmd.mock.calls[0]?.[2]?.envPatch?.SIMCTL_CHILD_MODE, 'test'); + assert.deepEqual(mockRunCmd.mock.calls[1], [ + 'xcrun', + ['simctl', 'openurl', 'sim-1', 'myapp://item/42'], + undefined, + ]); +}); + test('openIosApp launches iOS simulator app before opening https URL with launchArgs', async () => { mockEnsureBootedSimulator.mockResolvedValue(); mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); @@ -472,6 +544,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..a38b908767 100644 --- a/packages/platform-apple/src/core/app-launch.ts +++ b/packages/platform-apple/src/core/app-launch.ts @@ -1,5 +1,10 @@ import path from 'node:path'; -import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { + isHandheldAppleSimulator, + isIosFamily, + isMacOs, + type DeviceInfo, +} from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { execFailureDetails } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -33,8 +38,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 +49,7 @@ export async function openIosApp( appBundleId?: string; launchConsole?: string; launchArgs?: string[]; + launchEnvironment?: Readonly>; terminateRunningApp?: boolean; url?: string; runnerOptions?: AppleRunnerCommandOptions; @@ -51,6 +57,13 @@ export async function openIosApp( ): Promise { const launchConsole = options?.launchConsole?.trim(); const launchArgs = options?.launchArgs; + const launchEnvironment = options?.launchEnvironment; + if (launchEnvironment !== undefined && !isHandheldAppleSimulator(device)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--launch-env is supported only for iOS Simulator app launches.', + ); + } if (launchConsole && (!isIosFamily(device) || device.kind !== 'simulator')) { throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); } @@ -73,12 +86,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 { @@ -110,7 +125,7 @@ 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; } const bundleId = resolveIosDeviceDeepLinkBundleId(options?.appBundleId, deepLinkTarget); @@ -133,6 +148,7 @@ export async function openIosApp( await launchIosSimulatorApp(device, bundleId, { ...(launchConsole ? { launchConsole } : {}), ...(launchArgs ? { launchArgs } : {}), + ...(launchEnvironment ? { launchEnvironment } : {}), ...(options?.terminateRunningApp ? { terminateRunningApp: true } : {}), }); return; @@ -148,9 +164,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 +243,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 +270,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 +318,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 +339,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..7269caace3 100644 --- a/src/commands/management/app.test.ts +++ b/src/commands/management/app.test.ts @@ -64,6 +64,107 @@ 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_/ }, + { entries: ['MODE=bad\0value'], message: /cannot contain NUL/ }, + ])('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 }); + } + }); + + test('preserves __proto__ as an ordinary CLI environment variable name', () => { + const fromCli = openCommandFacet.cliReader( + ['com.example.app'], + flags({ launchEnvironmentEntries: ['__proto__=safe'] }), + ); + + expect(Object.hasOwn(fromCli.launchEnvironment ?? {}, '__proto__')).toBe(true); + expect((fromCli.launchEnvironment as Record | undefined)?.['__proto__']).toBe( + 'safe', + ); + }); + + test('rejects NUL-containing typed environment values', async () => { + const stateDir = tempStateDir(); + try { + const { client, calls } = createOpenClient({ stateDir, session: 'launch-env-nul' }); + await expect( + openCommandFacet.definition.invoke(client, { + app: 'com.example.app', + launchEnvironment: { MODE: 'bad\0value' }, + }), + ).rejects.toThrow(/cannot contain NUL/); + expect(calls).toHaveLength(0); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + test('preserves __proto__ on typed requests', async () => { + const stateDir = tempStateDir(); + try { + const { client, calls } = createOpenClient({ stateDir, session: 'launch-env-proto' }); + await openCommandFacet.definition.invoke(client, { + app: 'com.example.app', + launchEnvironment: JSON.parse('{"__proto__":"safe"}') as Record, + }); + const launchEnvironment = calls[0]?.flags?.launchEnvironment as + | Record + | undefined; + expect(Object.hasOwn(launchEnvironment ?? {}, '__proto__')).toBe(true); + expect(launchEnvironment?.['__proto__']).toBe('safe'); + } 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..bccbbdcc9f 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -21,6 +21,65 @@ 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 = Object.create(null); + 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.`); + } + if (entry.includes('\0')) { + throw new AppError('INVALID_ARGS', `launchEnvironment value for ${key} cannot contain NUL.`); + } + result[key] = entry; + } + return Object.freeze(result); +} + +function parseLaunchEnvironmentEntries( + entries: readonly string[] | undefined, +): Readonly> | undefined { + if (entries === undefined) return undefined; + const result: Record = Object.create(null); + 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}.`); + } + const value = entry.slice(separator + 1); + if (value.includes('\0')) { + throw new AppError('INVALID_ARGS', `launchEnvironment value for ${key} cannot contain NUL.`); + } + result[key] = value; + } + 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 +107,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 +167,10 @@ function toAppOpenOptions( launchUrl?: string; }, ): AppOpenOptions { - return withCommandRuntimeHints(input); + return withCommandRuntimeHints({ + ...input, + launchEnvironment: readLaunchEnvironment(input.launchEnvironment), + }); } const appsCliSchema = { @@ -115,6 +183,7 @@ const openCliSchema = { 'activity', 'launchConsole', 'launchArgs', + 'launchEnvironmentEntries', 'testIme', 'saveScript', 'force', @@ -147,6 +216,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..a1e7191fb8 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,24 @@ 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) => { + const serialized = JSON.stringify(response.json); + assert.equal(serialized.includes('https://example.com/clip?id=42'), false); + assert.equal(serialized.includes('provider-test'), false); + }, + }, { name: 'prepare iOS runner', command: 'prepare', @@ -277,6 +302,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..472062cb58 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,15 @@ 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({ ...options.envPatch }); +} + type IosPhysicalReinstallWorld = { daemon: ProviderScenarioHarness; appleTool: { calls: FlatToolCall[] }; diff --git a/test/integration/smoke-web-platform.test.ts b/test/integration/smoke-web-platform.test.ts index 7100ea9e6b..d08accadcf 100644 --- a/test/integration/smoke-web-platform.test.ts +++ b/test/integration/smoke-web-platform.test.ts @@ -62,7 +62,7 @@ test('web shutdown cleanup reaps the exact daemon that survived graceful shutdow const daemonPid = child.pid ?? 0; assert.ok(daemonPid > 0, 'expected the fake daemon to have a pid'); t.after(() => { - if (isProcessAlive(daemonPid)) process.kill(daemonPid, 'SIGKILL'); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); rmSync(root, { recursive: true, force: true }); }); @@ -101,9 +101,27 @@ test('web shutdown cleanup reaps the exact daemon that survived graceful shutdow true, 'expected cleanup to escalate after the child ignored SIGTERM', ); + await waitForChildExit(child, 1_000); assert.equal(isProcessAlive(daemonPid), false); }); +async function waitForChildExit(child: ReturnType, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.off('exit', onExit); + reject( + new Error(`child process ${child.pid ?? ''} did not exit within ${timeoutMs}ms`), + ); + }, timeoutMs); + const onExit = () => { + clearTimeout(timeout); + resolve(); + }; + child.once('exit', onExit); + }); +} + type StepRecord = { step: string; command: string; 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 d46a5df559..3353361754 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`. +- On iOS Simulator, 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. Android forwards launch arguments when opening a URL; use `--launch-args` for Android intent extras because launch environment is supported only for iOS Simulator app launches. - `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.