From d11abc396d068e267bb423a31c1d706e1680c467 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Sun, 27 Sep 2026 13:14:50 -0700 Subject: [PATCH 1/6] 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. From faa51edb164f0c7c21e49b75fc3ad1182735e887 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 13:46:55 -0700 Subject: [PATCH 2/6] fix: validate and redact iOS launch environments --- .../application-lifecycle-interaction.test.ts | 64 +++++++++++++++---- .../src/application-lifecycle-interaction.ts | 10 ++- packages/kernel/src/redaction.test.ts | 21 +++++- packages/kernel/src/redaction.ts | 50 ++++++++++++++- .../src/core/__tests__/apps.test.ts | 36 +++++++++++ .../platform-apple/src/core/app-launch.ts | 36 ++--------- src/commands/management/app.test.ts | 47 ++++++++++++++ src/commands/management/app.ts | 13 +++- .../provider-scenarios/ios-lifecycle.test.ts | 3 - .../provider-scenarios/ios-world.ts | 11 ++-- website/docs/docs/commands.md | 2 +- 11 files changed, 228 insertions(+), 65 deletions(-) diff --git a/packages/contracts/src/application-lifecycle-interaction.test.ts b/packages/contracts/src/application-lifecycle-interaction.test.ts index 5af2b38e8e..deb6051a07 100644 --- a/packages/contracts/src/application-lifecycle-interaction.test.ts +++ b/packages/contracts/src/application-lifecycle-interaction.test.ts @@ -17,6 +17,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,14 +39,6 @@ const LINUX_DEVICE: DeviceInfo = { booted: true, }; -const WEB_DEVICE: DeviceInfo = { - platform: 'web', - id: 'web-local', - name: 'Browser', - kind: 'device', - booted: true, -}; - const IOS_PHYSICAL_DEVICE: DeviceInfo = { ...IOS_SIMULATOR, id: 'ios-device', @@ -71,20 +77,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' }, + }, }), ); @@ -92,9 +102,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([ @@ -133,6 +145,13 @@ test.each([ 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, @@ -192,3 +211,26 @@ 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' } }); +}); diff --git a/packages/contracts/src/application-lifecycle-interaction.ts b/packages/contracts/src/application-lifecycle-interaction.ts index 3c5273083c..44da0493a1 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, resolveDeviceAppleOs } 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 { @@ -191,7 +191,7 @@ 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) { @@ -204,10 +204,7 @@ function assertLaunchEnvironmentSupport( device: DeviceInfo, launchEnvironment: ApplicationLifecycleExecution['launchEnvironment'], ): void { - if ( - launchEnvironment !== undefined && - !(resolveDeviceAppleOs(device) === 'ios' && device.kind === 'simulator') - ) { + if (launchEnvironment !== undefined && !isHandheldAppleSimulator(device)) { throw new AppError( 'UNSUPPORTED_OPERATION', '--launch-env is supported only for iOS Simulator app launches.', @@ -353,6 +350,7 @@ async function openDirectApplication( clearAppState: undefined, launchConsole: undefined, launchArgs: undefined, + launchEnvironment: undefined, }, }); } diff --git a/packages/kernel/src/redaction.test.ts b/packages/kernel/src/redaction.test.ts index 5dde20aeda..f3bc9a6699 100644 --- a/packages/kernel/src/redaction.test.ts +++ b/packages/kernel/src/redaction.test.ts @@ -10,9 +10,26 @@ test('redacts launch environment maps and CLI entries from structured diagnostic }); expect(redacted).toEqual({ - launchEnvironment: '[REDACTED]', - launchEnvironmentEntries: '[REDACTED]', + 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]'); }); diff --git a/packages/kernel/src/redaction.ts b/packages/kernel/src/redaction.ts index 9f31c16b39..10de28d3db 100644 --- a/packages/kernel/src/redaction.ts +++ b/packages/kernel/src/redaction.ts @@ -4,6 +4,8 @@ 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 = + /((?:SIMCTL_CHILD_)?[A-Za-z_][A-Za-z0-9_]*=)("[^"]*"|'[^']*'|\S+)/g; const URL_RE = /https?:\/\/[^\s"'<>]+/gi; const REDACTED_STRING_MAX_LENGTH = 400; const TRUNCATION_SUFFIX = '...'; @@ -34,11 +36,33 @@ function redactValue(value: unknown, seen: WeakSet, keyHint?: string): u seen.add(value as object); if (Array.isArray(value)) { - return value.map((entry) => redactValue(entry, seen)); + 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); + }); } const output: Record = {}; for (const [key, entry] of Object.entries(value as Record)) { + 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 +78,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 +86,31 @@ function redactString(value: string, keyHint?: string): string { return `${key}${separator}[REDACTED]`; }, ); + if (output.includes('--launch-env')) { + output = output.replaceAll(LAUNCH_ENV_ASSIGNMENT_RE, '$1[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 331831c488..afa4da3837 100644 --- a/packages/platform-apple/src/core/__tests__/apps.test.ts +++ b/packages/platform-apple/src/core/__tests__/apps.test.ts @@ -362,6 +362,23 @@ test('openIosApp translates launch environment keys for the iOS simulator child assert.equal(options?.envPatch?._XCAppClipURL, undefined); }); +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( () => '', @@ -458,6 +475,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 }); diff --git a/packages/platform-apple/src/core/app-launch.ts b/packages/platform-apple/src/core/app-launch.ts index 83b3082b7f..3c49986e0f 100644 --- a/packages/platform-apple/src/core/app-launch.ts +++ b/packages/platform-apple/src/core/app-launch.ts @@ -53,16 +53,16 @@ export async function openIosApp( const launchConsole = options?.launchConsole?.trim(); const launchArgs = options?.launchArgs; const launchEnvironment = options?.launchEnvironment; + if (launchEnvironment !== undefined && (!isIosFamily(device) || device.kind !== 'simulator')) { + 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); } 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', @@ -72,12 +72,6 @@ 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) { @@ -112,12 +106,6 @@ 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, @@ -135,12 +123,6 @@ export async function openIosApp( 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( @@ -167,12 +149,6 @@ export async function openIosApp( 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, diff --git a/src/commands/management/app.test.ts b/src/commands/management/app.test.ts index 4a9ad390d2..7269caace3 100644 --- a/src/commands/management/app.test.ts +++ b/src/commands/management/app.test.ts @@ -95,6 +95,7 @@ describe('open launch environment', () => { { 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 })), @@ -116,6 +117,52 @@ describe('open launch environment', () => { 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', () => { diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index ffc82f9843..bccbbdcc9f 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -30,12 +30,15 @@ function readLaunchEnvironment(value: unknown): Readonly> if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new AppError('INVALID_ARGS', 'launchEnvironment must be an object of string values.'); } - const result: Record = {}; + 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); @@ -45,7 +48,7 @@ function parseLaunchEnvironmentEntries( entries: readonly string[] | undefined, ): Readonly> | undefined { if (entries === undefined) return undefined; - const result: Record = {}; + 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.'); @@ -54,7 +57,11 @@ function parseLaunchEnvironmentEntries( if (Object.hasOwn(result, key)) { throw new AppError('INVALID_ARGS', `--launch-env contains duplicate key ${key}.`); } - result[key] = entry.slice(separator + 1); + 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); } diff --git a/test/integration/provider-scenarios/ios-lifecycle.test.ts b/test/integration/provider-scenarios/ios-lifecycle.test.ts index 36f5361a63..bb0d5355a0 100644 --- a/test/integration/provider-scenarios/ios-lifecycle.test.ts +++ b/test/integration/provider-scenarios/ios-lifecycle.test.ts @@ -68,9 +68,6 @@ test('Provider-backed integration iOS Settings flow uses scripted simctl and run }, }, expectData: { appBundleId: 'com.apple.Preferences' }, - assert: (response) => { - assert.doesNotMatch(JSON.stringify(response.json), /provider-test/); - }, }, { name: 'prepare iOS runner', diff --git a/test/integration/provider-scenarios/ios-world.ts b/test/integration/provider-scenarios/ios-world.ts index 640dbe5d7f..472062cb58 100644 --- a/test/integration/provider-scenarios/ios-world.ts +++ b/test/integration/provider-scenarios/ios-world.ts @@ -22,7 +22,7 @@ type IosSettingsWorld = { appleTool: { calls: FlatToolCall[] }; runnerTranscript: ProviderScenarioTranscript; inventoryRequests: DeviceInventoryRequest[]; - launchEnvironments: Array>>; + launchEnvironments: Array>>; appPath: string; close: () => Promise; }; @@ -180,7 +180,7 @@ export async function createIosSettingsWorld(): Promise { 'ios.runner', ); let clipboardText = ''; - const launchEnvironments: Array>> = []; + const launchEnvironments: Array>> = []; const appleTool = createRecordingAppleToolProvider({ plist: { readJson: async (plistPath) => { @@ -252,13 +252,10 @@ export async function createIosSettingsWorld(): Promise { function recordLaunchEnvironment( args: readonly string[], options: Readonly<{ envPatch?: Readonly> }> | undefined, - launchEnvironments: Array>>, + 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, - }); + launchEnvironments.push({ ...options.envPatch }); } type IosPhysicalReinstallWorld = { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 615e230abc..24b8364c50 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -80,7 +80,7 @@ agent-device fold open - `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. +- 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; launch environment is currently 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. From 7fd653b28a51fe3895adc4ba732894eb21799079 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 13:50:28 -0700 Subject: [PATCH 3/6] fix: reject unsupported provider launch environments --- .../application-lifecycle-interaction.test.ts | 24 ++++++++++++++++++- .../src/application-lifecycle-interaction.ts | 9 +++++++ .../platform-apple/src/core/app-launch.ts | 9 +++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/contracts/src/application-lifecycle-interaction.test.ts b/packages/contracts/src/application-lifecycle-interaction.test.ts index deb6051a07..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'; @@ -234,3 +235,24 @@ test('iPadOS Simulator accepts launch environment for app launches', async () => 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 44da0493a1..ae394ea28c 100644 --- a/packages/contracts/src/application-lifecycle-interaction.ts +++ b/packages/contracts/src/application-lifecycle-interaction.ts @@ -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); @@ -323,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({ diff --git a/packages/platform-apple/src/core/app-launch.ts b/packages/platform-apple/src/core/app-launch.ts index 3c49986e0f..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'; @@ -53,7 +58,7 @@ export async function openIosApp( const launchConsole = options?.launchConsole?.trim(); const launchArgs = options?.launchArgs; const launchEnvironment = options?.launchEnvironment; - if (launchEnvironment !== undefined && (!isIosFamily(device) || device.kind !== 'simulator')) { + if (launchEnvironment !== undefined && !isHandheldAppleSimulator(device)) { throw new AppError( 'UNSUPPORTED_OPERATION', '--launch-env is supported only for iOS Simulator app launches.', From 34af62dd7f1b424e21daad3ca4608076c28a2ae6 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 14:07:43 -0700 Subject: [PATCH 4/6] fix: tighten launch environment redaction --- packages/kernel/src/redaction.test.ts | 13 +++++++++++++ packages/kernel/src/redaction.ts | 12 ++++++++---- .../platform-apple/src/core/__tests__/apps.test.ts | 14 ++++++++++++++ .../provider-scenarios/ios-lifecycle.test.ts | 5 +++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/kernel/src/redaction.test.ts b/packages/kernel/src/redaction.test.ts index f3bc9a6699..baee9d2c1d 100644 --- a/packages/kernel/src/redaction.test.ts +++ b/packages/kernel/src/redaction.test.ts @@ -33,3 +33,16 @@ test('redacts launch environment values in command arguments and free-text diagn 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 10de28d3db..312f5c8b95 100644 --- a/packages/kernel/src/redaction.ts +++ b/packages/kernel/src/redaction.ts @@ -5,7 +5,7 @@ const SECRET_TOKEN_RE = 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 = - /((?:SIMCTL_CHILD_)?[A-Za-z_][A-Za-z0-9_]*=)("[^"]*"|'[^']*'|\S+)/g; + /(--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 = '...'; @@ -86,9 +86,13 @@ function redactString(value: string, keyHint?: string): string { return `${key}${separator}[REDACTED]`; }, ); - if (output.includes('--launch-env')) { - output = output.replaceAll(LAUNCH_ENV_ASSIGNMENT_RE, '$1[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]', diff --git a/packages/platform-apple/src/core/__tests__/apps.test.ts b/packages/platform-apple/src/core/__tests__/apps.test.ts index afa4da3837..bf2a0b6205 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'; @@ -362,6 +363,19 @@ test('openIosApp translates launch environment keys for the iOS simulator child 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'); diff --git a/test/integration/provider-scenarios/ios-lifecycle.test.ts b/test/integration/provider-scenarios/ios-lifecycle.test.ts index bb0d5355a0..a1e7191fb8 100644 --- a/test/integration/provider-scenarios/ios-lifecycle.test.ts +++ b/test/integration/provider-scenarios/ios-lifecycle.test.ts @@ -68,6 +68,11 @@ test('Provider-backed integration iOS Settings flow uses scripted simctl and run }, }, 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', From bc10d309e73be104c99025e3a3a915953e8bbf1e Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 15:27:25 -0700 Subject: [PATCH 5/6] docs(cli): clarify Android launch env alternative --- packages/command-registry/src/flag-definitions-target.ts | 2 +- website/docs/docs/commands.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index b000327fcf..a84db14c9d 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -383,7 +383,7 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ multiple: true, usageLabel: '--launch-env ', usageDescription: - 'open: repeatable iOS Simulator child-process environment entry; values are treated as sensitive', + 'open: repeatable iOS Simulator child-process environment entry; values are treated as sensitive. For Android intent extras, use --launch-args.', projectConfig: false, recorded: false, }, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 24b8364c50..7dfb159604 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -80,7 +80,7 @@ agent-device fold open - `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; launch environment is currently supported only for iOS Simulator app launches. +- 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. From 8971eba3f796de58eba0f2679155e97c944c66d2 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 17:00:59 -0700 Subject: [PATCH 6/6] fix: pass launch environment validation gates --- packages/kernel/src/redaction.ts | 34 ++++++++++++------ test/integration/smoke-web-platform.test.ts | 20 ++++++++++- test/wire-compat/ledger.json | 40 +++++++++++++++++++++ 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/packages/kernel/src/redaction.ts b/packages/kernel/src/redaction.ts index 312f5c8b95..4a21f36a11 100644 --- a/packages/kernel/src/redaction.ts +++ b/packages/kernel/src/redaction.ts @@ -35,20 +35,32 @@ 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, 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); + 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))}`; } - return redactValue(entry, seen); - }); - } + 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; 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/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 181e04ba82..2a914c27e5 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -300,6 +300,46 @@ "declaration": "packages/kernel/src/contracts.ts#daemonRuntimeSchema", "digest": "sha256:3b99926ce9deb5d66186a55b1168011dc1ae4d010ada5ac5d79c4c7225a1f80f", "rationale": "#2266 broadens runtime-hint validation to accept the additive HarmonyOS platform value; existing protocol-2 runtime hints remain valid and unchanged." + }, + { + "declaration": "src/daemon/server/http-server.ts#writeProgressEnvelope", + "digest": "sha256:ca661f6dd2de6e517c520dbd1e20b59b24b58d925157ed06123149ed2bc0fa3b", + "rationale": "The post-v0.21.16 change adjusts only when the local HTTP response writer skips a progress event (`writableEnded` is no longer an early-return condition). Emitted events still use the same `serializeDaemonProgressEnvelope` bytes and framing; no protocol-2 payload field or interpretation changes." + }, + { + "declaration": "src/remote/upload-client.ts#finalizeDirectUpload", + "digest": "sha256:5e32c87214f9f6140535b48d05de8565cb121370fdc334c05f7f0ad476b73a07", + "rationale": "The post-v0.21.16 change removes caller cancellation plumbing from this local upload client. It still sends the same POST to `upload/finalize` with `{ uploadId }` and parses the same response; a protocol-2 daemon sees the identical request and response contract." + }, + { + "declaration": "src/remote/upload-client.ts#requestUploadPreflight", + "digest": "sha256:df42c0eaef3a54f4388c99d74bf6030885239780c2ddc78117151dbd391afedd", + "rationale": "The post-v0.21.16 change removes caller cancellation plumbing from this local upload client. The preflight URL, method, headers, JSON body, response parser, and fallback to legacy upload are unchanged, so protocol-2 peers exchange the same bytes." + }, + { + "declaration": "src/remote/upload-client.ts#tryDirectUploadWithResume", + "digest": "sha256:0f57cf9bb1eefa3b988e5d723b2eebf2c4a622c3f7c808f35eae070771acafa6", + "rationale": "The post-v0.21.16 change removes caller cancellation plumbing from the local retry orchestration. Attempt IDs, preflight/finalize requests, resume behavior, and legacy fallback retain their existing protocol-2 shapes; the daemon contract is unchanged." + }, + { + "declaration": "src/remote/upload-client.ts#uploadDirectArtifact", + "digest": "sha256:5f26f8f7b6ada2fe2089a214fb8350e3a9f65725212952c17f6787b3ca8fe232", + "rationale": "The post-v0.21.16 change removes only the caller-provided AbortSignal from this local direct-upload request. The ticket URL, PUT method, headers, streamed bytes, and server response contract are unchanged for protocol-2 peers." + }, + { + "declaration": "src/remote/upload-client.ts#uploadLegacyArtifact", + "digest": "sha256:f3ccab9c5ba18fc2f2000ea8e2a4bbb3cc97f7b50f6111192e513013f635cdd7", + "rationale": "The post-v0.21.16 change removes only the caller-provided AbortSignal from this local fallback request. The legacy upload URL, method, headers, multipart body, and response parser remain unchanged for protocol-2 peers." + }, + { + "declaration": "src/remote/upload-stream.ts#streamFileToHttpRequest", + "digest": "sha256:eaf2ea49957034f6a7e17d34092dc6cb1998812bddfd68381a0ac34d8a2de58c", + "rationale": "The post-v0.21.16 change removes optional cancellation wiring from the local HTTP upload helper; it does not alter URL construction, request method, headers, body bytes, redirect handling, or response interpretation. The upload wire remains protocol-2 compatible." + }, + { + "declaration": "src/remote/upload-stream.ts#streamFileToHttpRequestAttempt", + "digest": "sha256:da39a79fa7c1f81e55caf613eedc347c0f3a9a9711b265a4db185677532d9552", + "rationale": "The post-v0.21.16 change stops attaching the caller AbortSignal to the local Node HTTP request and removes its cancellation-specific error branch. The serialized upload request and daemon response handling are otherwise unchanged, so a protocol-2 peer parses the same contract." } ] }