Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <KEY=VALUE>',
usageDescription:
'open: repeatable iOS Simulator child-process environment entry; values are treated as sensitive',
projectConfig: false,
recorded: false,
},
{
key: 'header',
names: ['--header'],
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions packages/contracts/src/application-lifecycle-interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion packages/contracts/src/application-lifecycle-interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -181,6 +181,9 @@ async function invokeDeviceOpen(params: DirectOpenParameters): Promise<void> {
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();
}

Expand All @@ -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')

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: assertLaunchEnvironmentSupport gates on resolveDeviceAppleOs(device) === 'ios', which excludes iPadOS simulators (appleOs: 'ipados'). iPad Simulator launches are iOS Simulator app launches — discovery classifies iPad models as 'ipados' (inventory-classification.ts:62) and openIosApp accepts any kind === 'simulator' — so --launch-env is rejected with "supported only for iOS Simulator" on exactly the devices the platform layer supports. Use isIosFamily(device) && device.kind === 'simulator' to match the launchConsole gate (also resolveDeviceAppleOs falls back to 'ios' for non-Apple legacy records, making the check both too narrow and accidentally permissive).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/contracts/src/application-lifecycle-interaction.ts, line 209:

<comment>`assertLaunchEnvironmentSupport` gates on `resolveDeviceAppleOs(device) === 'ios'`, which excludes iPadOS simulators (`appleOs: 'ipados'`). iPad Simulator launches are iOS Simulator app launches — discovery classifies iPad models as `'ipados'` (inventory-classification.ts:62) and `openIosApp` accepts any `kind === 'simulator'` — so `--launch-env` is rejected with "supported only for iOS Simulator" on exactly the devices the platform layer supports. Use `isIosFamily(device) && device.kind === 'simulator'` to match the `launchConsole` gate (also `resolveDeviceAppleOs` falls back to `'ios'` for non-Apple legacy records, making the check both too narrow and accidentally permissive).</comment>

<file context>
@@ -194,6 +197,22 @@ function assertOpenDeviceSupport(
+): void {
+  if (
+    launchEnvironment !== undefined &&
+    !(resolveDeviceAppleOs(device) === 'ios' && device.kind === 'simulator')
+  ) {
+    throw new AppError(
</file context>
Fix with cubic

) {
throw new AppError(
'UNSUPPORTED_OPERATION',
'--launch-env is supported only for iOS Simulator app launches.',
);
}
}

async function invokeApplicationUrlOpen(
Expand All @@ -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,
});
Expand Down Expand Up @@ -244,6 +264,7 @@ async function invokeApplicationTargetOpen(
appBundleId: params.appBundleId,
launchConsole: execution.launchConsole,
launchArgs: execution.launchArgs ? [...execution.launchArgs] : undefined,
launchEnvironment: execution.launchEnvironment,

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This forwards launchEnvironment into the separate runtimeLaunchUrl follow-up, so an app launch with --launch-env succeeds but its URL follow-up is rejected as a configured bare URL open. Clear launchEnvironment in the follow-up execution, matching the Apple lifecycle path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/contracts/src/application-lifecycle-interaction.ts, line 267:

<comment>This forwards `launchEnvironment` into the separate `runtimeLaunchUrl` follow-up, so an app launch with `--launch-env` succeeds but its URL follow-up is rejected as a configured bare URL open. Clear `launchEnvironment` in the follow-up execution, matching the Apple lifecycle path.</comment>

<file context>
@@ -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,
   });
</file context>
Fix with cubic

terminateRunningApp: params.terminateRunningApp,
});
}
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,6 +41,7 @@ export type ApplicationLifecycleExecution = Readonly<{
activity?: string;
launchConsole?: string;
launchArgs?: readonly string[];
launchEnvironment?: LaunchEnvironment;
clearAppState?: boolean;
iosXctestrunFile?: string;
iosXctestDerivedDataPath?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export type CliFlags = CloudProviderProfileFields &
activity?: string;
launchConsole?: string;
launchArgs?: string[];
launchEnvironmentEntries?: string[];
header?: string[];
githubActionsArtifact?: string;
installSource?: DaemonInstallSource;
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/client-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/contracts/src/command-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ export type MaestroRuntimeFlags = {
screenshotCaptureBackend?: 'runner';
};

export type CommandFlags = Omit<CliFlags, DaemonExcludedCliFlag> & {
export type CommandFlags = Omit<CliFlags, DaemonExcludedCliFlag | 'launchEnvironmentEntries'> & {
batchSteps?: DaemonBatchStep[];
clearAppState?: boolean;
interactionOutcome?: {
retryOnNoChange?: boolean;
};
launchArgs?: string[];
launchEnvironment?: Readonly<Record<string, string>>;
kind?: string;
maestro?: MaestroRuntimeFlags;
postGestureStabilization?: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export type Interactor = {
appBundleId?: string;
launchConsole?: string;
launchArgs?: string[];
launchEnvironment?: Readonly<Record<string, string>>;
terminateRunningApp?: boolean;
url?: string;
},
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/launch-environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type LaunchEnvironment = Readonly<Record<string, string>>;
2 changes: 2 additions & 0 deletions packages/contracts/src/request-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScreenshotRequestFlags> &
ReplayRequestFields &
Expand Down Expand Up @@ -68,6 +69,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
activity?: string;
launchConsole?: string;
launchArgs?: string[];
launchEnvironment?: LaunchEnvironment;
relaunch?: boolean;
shutdown?: boolean;
saveScript?: boolean | string;
Expand Down
13 changes: 13 additions & 0 deletions packages/host-kit/src/internal/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () =>
Expand Down
15 changes: 11 additions & 4 deletions packages/host-kit/src/internal/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>;
allowFailure?: boolean;
binaryStdout?: boolean;
stdin?: string | Buffer;
Expand Down Expand Up @@ -91,6 +93,11 @@ export type CommandExecutorOverride = (

const commandExecutorOverrideScope = new AsyncLocalStorage<CommandExecutorOverride | undefined>();

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<T>(
override: CommandExecutorOverride | undefined,
fn: () => Promise<T>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions packages/kernel/src/redaction.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
2 changes: 1 addition & 1 deletion packages/kernel/src/redaction.ts
Original file line number Diff line number Diff line change
@@ -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;

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Only SENSITIVE_KEY_RE was extended; SENSITIVE_ASSIGNMENT_RE (free-text KEY=VALUE redaction) was not, so launch-environment values leak when they appear inside unstructured strings rather than under the launchEnvironment/launchEnvironmentEntries keys. Verified: redactDiagnosticData({ message: 'invalid --launch-env entry _XCAppClipURL=' + secret }) and argv: ['--launch-env', 'SOME_VAR=' + secret] both return the secret verbatim (URL values get only their query dropped; non-URL values unchanged). The flag is documented as "values are treated as sensitive", and the SIMCTL_CHILD_-prefixed entries produced at the iOS Simulator host boundary benefit from this only when the key itself contains a sensitive keyword. If any error message, argv echo, or recorded command line carries these entries, the secret crosses the diagnostic boundary. Redact at the source by passing each raw entry/value through redaction (or registering them in host-kit's scope-sensitive-values set) before they are embedded in strings.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/kernel/src/redaction.ts, line 2:

<comment>Only SENSITIVE_KEY_RE was extended; SENSITIVE_ASSIGNMENT_RE (free-text `KEY=VALUE` redaction) was not, so launch-environment values leak when they appear inside unstructured strings rather than under the `launchEnvironment`/`launchEnvironmentEntries` keys. Verified: `redactDiagnosticData({ message: 'invalid --launch-env entry _XCAppClipURL=' + secret })` and `argv: ['--launch-env', 'SOME_VAR=' + secret]` both return the secret verbatim (URL values get only their query dropped; non-URL values unchanged). The flag is documented as "values are treated as sensitive", and the `SIMCTL_CHILD_`-prefixed entries produced at the iOS Simulator host boundary benefit from this only when the key itself contains a sensitive keyword. If any error message, argv echo, or recorded command line carries these entries, the secret crosses the diagnostic boundary. Redact at the source by passing each raw entry/value through redaction (or registering them in host-kit's scope-sensitive-values set) before they are embedded in strings.</comment>

<file context>
@@ -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;
</file context>
Fix with cubic

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The key-level match discards the entire launchEnvironment map (and the whole launchEnvironmentEntries array) as [REDACTED], hiding benign keys and values (e.g., MODE: 'test') that are the most useful diagnostic context. The stated intent is to redact values, and only values are sensitive. Redact each map value (via redactString) while preserving keys, e.g. launchEnvironment: { _XCAppClipURL: '[REDACTED]' }, to keep diagnostics actionable without leaking secrets.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/kernel/src/redaction.ts, line 2:

<comment>The key-level match discards the entire `launchEnvironment` map (and the whole `launchEnvironmentEntries` array) as `[REDACTED]`, hiding benign keys and values (e.g., `MODE: 'test'`) that are the most useful diagnostic context. The stated intent is to redact values, and only values are sensitive. Redact each map value (via redactString) while preserving keys, e.g. `launchEnvironment: { _XCAppClipURL: '[REDACTED]' }`, to keep diagnostics actionable without leaking secrets.</comment>

<file context>
@@ -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;
</file context>
Fix with cubic

const SECRET_TOKEN_RE =
/\b(?:bearer\s+[a-z0-9._-]+|adc_(?:agent|live|refresh|cli)_[a-z0-9._-]+)\b/gi;
const SENSITIVE_ASSIGNMENT_RE =
Expand Down
53 changes: 53 additions & 0 deletions packages/platform-apple/src/core/__tests__/apps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new launchEnvironment tests only cover the plain launch path. Two other code paths in openIosApp also carry the env patch and are untested: launchIosSimulatorApp routes through runIosSimulatorConsoleLaunch when launchConsole is set (app-launch.ts:292-297, which also forwards envPatch at line 380), and the launch-before-openurl path when url is given with launchEnvironment (app-launch.ts:91, shouldLaunchAppBeforeUrl includes launchEnvironment !== undefined, forwarding it at line 97). A regression in env forwarding through either path would pass the suite silently. Add tests for launchConsole + launchEnvironment and for url + launchEnvironment on the simulator, mirroring the existing launchArgs + url tests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/core/__tests__/apps.test.ts, line 343:

<comment>The new launchEnvironment tests only cover the plain launch path. Two other code paths in openIosApp also carry the env patch and are untested: `launchIosSimulatorApp` routes through `runIosSimulatorConsoleLaunch` when `launchConsole` is set (app-launch.ts:292-297, which also forwards envPatch at line 380), and the launch-before-openurl path when `url` is given with launchEnvironment (app-launch.ts:91, `shouldLaunchAppBeforeUrl` includes `launchEnvironment !== undefined`, forwarding it at line 97). A regression in env forwarding through either path would pass the suite silently. Add tests for `launchConsole` + `launchEnvironment` and for `url` + `launchEnvironment` on the simulator, mirroring the existing `launchArgs` + `url` tests.</comment>

<file context>
@@ -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 });
</file context>
Fix with cubic

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(
() => '',
Expand Down Expand Up @@ -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(
() =>
Expand Down
Loading