From 5455a6f47edc55e338fdf4769eaf8bcfe37b802f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 15:51:53 +0200 Subject: [PATCH 1/9] fix(ios): attach devicectl hints to physical install/uninstall failures Physical iOS install and uninstall ran through devicectl without ever consulting resolveIosDevicectlHint, so a failure lost the Developer Mode, developer-disk-image, and pairing guidance every other devicectl call site attaches. Route both failures through the same resolver. Delete the install/uninstall/assertAppInstallationSupported members of IosPhysicalDeviceControl and their XCTest rejection functions: nothing calls them since #1758 moved physical install to deployment/runtime.ts. Delete pushIosNotification, which deployment/runtime.ts's pushAppleNotification replaced and only its own tests still called. Repoint the C.push iOS-simulator coverage declaration at the deployment-runtime contract test that now exercises simctl push. --- .../src/core/__tests__/app-device-io.test.ts | 69 ---------------- .../src/core/__tests__/apps.test.ts | 35 +------- .../platform-apple/src/core/app-device-io.ts | 27 ------- .../src/core/physical-device-control.ts | 27 ------- .../src/deployment/runtime.test.ts | 79 +++++++++++++++++++ .../platform-apple/src/deployment/runtime.ts | 26 +++++- .../command-coverage/declarations.ts | 6 +- 7 files changed, 108 insertions(+), 161 deletions(-) delete mode 100644 packages/platform-apple/src/core/__tests__/app-device-io.test.ts diff --git a/packages/platform-apple/src/core/__tests__/app-device-io.test.ts b/packages/platform-apple/src/core/__tests__/app-device-io.test.ts deleted file mode 100644 index 374026368c..0000000000 --- a/packages/platform-apple/src/core/__tests__/app-device-io.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { beforeEach, expect, test, vi } from 'vitest'; -import type { DeviceInfo } from '@agent-device/kernel/device'; - -const { ensureBootedSimulator, runSimctl } = vi.hoisted(() => ({ - ensureBootedSimulator: vi.fn(), - runSimctl: vi.fn(), -})); - -vi.mock('../simulator.ts', () => ({ - ensureBootedSimulator, - requireSimulatorDevice: (device: DeviceInfo) => { - if (device.kind !== 'simulator') throw new Error('simulator required'); - }, -})); -vi.mock('../simctl.ts', async (importOriginal) => ({ - ...(await importOriginal()), - runSimctlForDevice: runSimctl, -})); - -import { pushIosNotification } from '../app-device-io.ts'; - -const device: DeviceInfo = { - platform: 'apple', - appleOs: 'ios', - id: 'apple-push-abort', - name: 'Apple push abort', - kind: 'simulator', - target: 'mobile', - booted: true, -}; - -beforeEach(() => { - ensureBootedSimulator.mockReset(); - ensureBootedSimulator.mockResolvedValue(undefined); - runSimctl.mockReset(); -}); - -test('aborts an in-flight simctl push with the deployment binding signal', async () => { - const controller = new AbortController(); - const aborted = new Error('request aborted'); - runSimctl.mockImplementation( - async (_device: DeviceInfo, _args: string[], options: Readonly<{ signal?: AbortSignal }>) => - await rejectWhenAborted(options.signal), - ); - - const pending = pushIosNotification( - device, - 'com.example.app', - { aps: {} }, - { - signal: controller.signal, - }, - ); - await vi.waitFor(() => expect(runSimctl).toHaveBeenCalledOnce()); - expect(runSimctl).toHaveBeenCalledWith( - device, - ['push', device.id, 'com.example.app', expect.stringContaining('payload.apns')], - { signal: controller.signal }, - ); - controller.abort(aborted); - - await expect(pending).rejects.toBe(aborted); -}); - -async function rejectWhenAborted(signal: AbortSignal | undefined): Promise { - return await new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); - }); -} diff --git a/packages/platform-apple/src/core/__tests__/apps.test.ts b/packages/platform-apple/src/core/__tests__/apps.test.ts index 1602dd0c50..4104358338 100644 --- a/packages/platform-apple/src/core/__tests__/apps.test.ts +++ b/packages/platform-apple/src/core/__tests__/apps.test.ts @@ -1,6 +1,6 @@ import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import { promises as fs, readFileSync, writeFileSync } from 'node:fs'; +import { promises as fs, writeFileSync } from 'node:fs'; import path from 'node:path'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; @@ -26,7 +26,7 @@ const retryActual = await vi.importActual('../simulator.ts'); import { closeIosApp, openIosApp } from '../app-launch.ts'; -import { pushIosNotification, readIosClipboardText } from '../app-device-io.ts'; +import { readIosClipboardText } from '../app-device-io.ts'; import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId } from '../app-resolution.ts'; import { screenshotIos } from '../screenshot.ts'; import { withMockedMacOsHelper } from './macos-helper-test-utils.ts'; @@ -571,37 +571,6 @@ test('openIosApp with app and URL on iOS device launches app bundle with payload ); }); -test('pushIosNotification uses simctl push with temporary payload file', async () => { - const device: DeviceInfo = { - platform: 'apple', - id: 'sim-1', - name: 'iPhone', - kind: 'simulator', - booted: true, - }; - - let capturedPayload: string | undefined; - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; - if (args[0] === 'simctl' && args[1] === 'push') { - capturedPayload = readFileSync(args[4] ?? '', 'utf8'); - return ''; - } - return ''; - }, - async ({ calls }) => { - await pushIosNotification(device, 'com.example.app', { aps: { alert: 'hello', badge: 4 } }); - const pushCall = calls.find((args) => args[0] === 'simctl' && args[1] === 'push'); - assert.ok(pushCall); - assert.equal(pushCall[2], 'sim-1'); - assert.equal(pushCall[3], 'com.example.app'); - assert.match(pushCall[4] ?? '', /payload\.apns$/); - assert.deepEqual(JSON.parse(capturedPayload ?? ''), { aps: { alert: 'hello', badge: 4 } }); - }, - ); -}); - test('resolveIosApp resolves app display name on iOS physical devices', async () => { const device: DeviceInfo = { platform: 'apple', diff --git a/packages/platform-apple/src/core/app-device-io.ts b/packages/platform-apple/src/core/app-device-io.ts index 42ea707965..56bf0bac73 100644 --- a/packages/platform-apple/src/core/app-device-io.ts +++ b/packages/platform-apple/src/core/app-device-io.ts @@ -1,11 +1,5 @@ -import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import { requireExecSuccess } from '@agent-device/host-kit/command'; -import { - makeHostTemporaryDirectory, - removeHostPath, - writeHostTextFile, -} from '@agent-device/host-kit/host-file'; import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts'; import { readMacOsClipboardText, writeMacOsClipboardText } from '../os/macos/apps.ts'; import { runSimctlForDevice } from './simctl.ts'; @@ -38,24 +32,3 @@ export async function writeIosClipboardText(device: DeviceInfo, text: string): P 'Failed to write iOS simulator clipboard', ); } - -export async function pushIosNotification( - device: DeviceInfo, - bundleId: string, - payload: Record, - options: Readonly<{ signal?: AbortSignal }> = {}, -): Promise { - requireSimulatorDevice(device, 'push'); - options.signal?.throwIfAborted(); - await ensureBootedSimulator(device, { signal: options.signal }); - const tempDir = await makeHostTemporaryDirectory('agent-device-ios-push-'); - const payloadPath = path.join(tempDir, 'payload.apns'); - try { - await writeHostTextFile(payloadPath, `${JSON.stringify(payload)}\n`); - await runSimctlForDevice(device, ['push', device.id, bundleId, payloadPath], { - signal: options.signal, - }); - } finally { - await removeHostPath(tempDir); - } -} diff --git a/packages/platform-apple/src/core/physical-device-control.ts b/packages/platform-apple/src/core/physical-device-control.ts index a0feeecc14..9d174219c1 100644 --- a/packages/platform-apple/src/core/physical-device-control.ts +++ b/packages/platform-apple/src/core/physical-device-control.ts @@ -4,11 +4,9 @@ import { execFailureDetails } from '@agent-device/host-kit/command'; import type { AppsFilter } from '@agent-device/contracts/device'; import type { IosAppInfo, IosDeviceAppProcesses } from './app-info.ts'; import { - installCoreDeviceApp, listCoreDeviceApps, resolveCoreDeviceAppProcesses, terminateCoreDeviceApp, - uninstallCoreDeviceApp, } from './physical-device-apps.ts'; import { ensureCoreDeviceReady, @@ -42,11 +40,8 @@ type IosPhysicalDeviceLaunchOptions = { }; export type IosPhysicalDeviceControl = IosPhysicalDeviceRunnerControl & { - assertAppInstallationSupported(device: DeviceInfo): void; ensureReady(device: DeviceInfo, signal?: AbortSignal): Promise; listApps(device: DeviceInfo, filter: AppsFilter): Promise; - installApp(device: DeviceInfo, installablePath: string, signal?: AbortSignal): Promise; - uninstallApp(device: DeviceInfo, bundleId: string, signal?: AbortSignal): Promise; launchApp( device: DeviceInfo, bundleId: string, @@ -77,11 +72,8 @@ export type IosPhysicalDeviceControl = IosPhysicalDeviceRunnerControl & { const CONTROLS: Record = { coredevice: { backend: 'coredevice', - assertAppInstallationSupported: () => {}, ensureReady: ensureCoreDeviceReady, listApps: listCoreDeviceApps, - installApp: installCoreDeviceApp, - uninstallApp: uninstallCoreDeviceApp, launchApp: launchCoreDeviceApp, terminateApp: async (device, bundleId) => await terminateCoreDeviceApp(device, bundleId), resolveAppProcesses: resolveCoreDeviceAppProcesses, @@ -94,11 +86,8 @@ const CONTROLS: Record = { }, xctest: { backend: 'xctest', - assertAppInstallationSupported: assertXctestAppInstallationUnsupported, ensureReady: ensureXctestDeviceReady, listApps: rejectXctestAppInventory, - installApp: rejectXctestAppInstallation, - uninstallApp: rejectXctestAppInstallation, launchApp: launchXctestDeviceApp, terminateApp: terminateXctestDeviceApp, resolveAppProcesses: rejectXctestProcessLookup, @@ -113,22 +102,6 @@ export function resolveIosPhysicalDeviceControl(device: DeviceInfo): IosPhysical return CONTROLS[device.iosPhysicalDeviceBackend === 'xctest' ? 'xctest' : 'coredevice']; } -function assertXctestAppInstallationUnsupported(device: DeviceInfo): never { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'Installing apps is unavailable on this XCTest-backed physical iOS device.', - { - deviceId: device.id, - backend: 'xctest', - hint: 'Install the app with Xcode, then open it in agent-device by bundle ID.', - }, - ); -} - -async function rejectXctestAppInstallation(device: DeviceInfo): Promise { - return assertXctestAppInstallationUnsupported(device); -} - async function rejectXctestAppInventory(device: DeviceInfo): Promise { throw new AppError( 'UNSUPPORTED_OPERATION', diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index f477cd7954..a96a7a216c 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -2,6 +2,7 @@ import { expect, test, vi } from 'vitest'; import type { AppleAppDeploymentExecutor } from '@agent-device/contracts/app-deployment-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { assertRejectsAppError } from '../__tests__/app-error.ts'; import { appleAppDeploymentFacts, createAppleAppDeploymentOperations } from './runtime.ts'; function appleDevice(overrides: Partial = {}): DeviceInfo { @@ -262,6 +263,84 @@ test('exposes only fact-admitted Apple deployment operations', async () => { ).toEqual({}); }); +test('physical iOS install failure surfaces the devicectl Developer Mode hint', async () => { + const prepareArtifact = vi.fn(async () => ({ + installablePath: '/tmp/App.app', + bundleId: 'com.example.app', + appName: 'Example', + cleanup: vi.fn(async () => {}), + })); + const executor = { + prepareArtifact, + resolveAppBundleId: vi.fn(), + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as AppleAppDeploymentExecutor; + const run = vi.fn(async (request: { args: readonly string[] }) => + request.args.includes('install') + ? { + stdout: '', + stderr: 'Unable to install "com.example.app": Developer Mode is disabled on this device.', + exitCode: 1, + } + : { stdout: '', stderr: '', exitCode: 0 }, + ); + const host = deploymentHost(executor, run); + const device = appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }); + const operations = createAppleAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await assertRejectsAppError( + async () => + await operations.deployApp?.({ + app: 'com.example.app', + appPath: '/tmp/App.app', + replaceExisting: false, + }), + { code: 'COMMAND_FAILED', hint: /Developer Mode/ }, + ); + expect(run.mock.calls.some(([request]) => request.args.includes('install'))).toBe(true); +}); + +test('physical iOS uninstall failure surfaces the devicectl Developer Mode hint', async () => { + const resolveAppBundleId = vi.fn(async () => 'com.example.app'); + const executor = { + prepareArtifact: vi.fn(), + resolveAppBundleId, + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as AppleAppDeploymentExecutor; + const run = vi.fn(async (request: { args: readonly string[] }) => + request.args.includes('uninstall') + ? { + stdout: '', + stderr: + 'Unable to uninstall "com.example.app": Developer Mode is disabled on this device.', + exitCode: 1, + } + : { stdout: '', stderr: '', exitCode: 0 }, + ); + const host = deploymentHost(executor, run); + const device = appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }); + const operations = createAppleAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await assertRejectsAppError( + async () => + await operations.deployApp?.({ + app: 'com.example.app', + appPath: '/tmp/replacement.app', + replaceExisting: true, + }), + { code: 'COMMAND_FAILED', hint: /Developer Mode/ }, + ); + expect(run.mock.calls.some(([request]) => request.args.includes('uninstall'))).toBe(true); +}); + test('preserves Apple reinstall partial-failure ordering', async () => { const order: string[] = []; const resolveAppBundleId = vi.fn(async () => { diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 394310eb70..5aa1b61429 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -10,6 +10,7 @@ import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runti import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from '../core/devicectl.ts'; import { ensureAppleReady } from '../readiness/runtime.ts'; import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; @@ -148,7 +149,7 @@ async function installAppleApp( }, signal, ); - assertAppleToolSuccess(result, 'Apple app install failed'); + assertAppleToolSuccess(result, 'Apple app install failed', devicectlHintDetails(device, result)); } async function uninstallAppleApp( @@ -173,7 +174,11 @@ async function uninstallAppleApp( signal, ); if (result.exitCode === 0 || isMissingAppOutput(`${result.stdout}\n${result.stderr}`)) return; - assertAppleToolSuccess(result, `Apple app uninstall failed for ${bundleId}`); + assertAppleToolSuccess( + result, + `Apple app uninstall failed for ${bundleId}`, + devicectlHintDetails(device, result), + ); } async function pushAppleNotification( @@ -217,15 +222,32 @@ function isMissingAppOutput(output: string): boolean { function assertAppleToolSuccess( result: Readonly<{ stdout: string; stderr: string; exitCode: number | null }>, message: string, + details: Readonly<{ hint?: string }> = {}, ): void { if (result.exitCode === 0) return; throw new AppError('COMMAND_FAILED', message, { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, + ...details, }); } +/** + * Physical iOS install/uninstall runs through devicectl (#2785): a failure gets the same + * Developer Mode, developer-disk-image, and pairing hints the other devicectl call sites attach. + * Simulator installs go through simctl, which this resolver does not classify. + */ +function devicectlHintDetails( + device: DeviceInfo, + result: Readonly<{ stdout: string; stderr: string }>, +): Readonly<{ hint?: string }> { + if (device.kind === 'simulator') return {}; + return { + hint: resolveIosDevicectlHint(result.stdout, result.stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, + }; +} + function appleDeployFact(device: DeviceInfo): RuntimeOperationFact { if (!isSupportedAppleDeploymentLeaf(device)) return unsupportedLeaf; if (device.kind !== 'simulator' && device.kind !== 'device') return unsupportedKind; diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index 7495497e7a..b3adbb26c4 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -508,9 +508,9 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'typed broadcast extras are persisted by the fixture receiver and rendered after refresh', ), iosSimulator: iosSimulator.contract( - 'packages/platform-apple/src/core/__tests__/apps.test.ts', - 'pushIosNotification uses simctl push with temporary payload file', - 'simctl push dispatch; fixture has no notification entitlement or UI oracle', + 'packages/platform-apple/src/deployment/runtime.test.ts', + 'exposes only fact-admitted Apple deployment operations', + 'simctl push dispatch through the shared Apple deployment operations; fixture has no notification entitlement or UI oracle', ), macos: macos.contract( 'packages/platform-apple/src/deployment/runtime.test.ts', From d5cd853ce589ab885b849409db2cf1b320eaf662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 15:59:15 +0200 Subject: [PATCH 2/9] fix(ios): force allowFailure on devicectl install and push requests installAppleApp's devicectl and simctl branches, and pushAppleNotification's simctl request, omitted allowFailure. The host command runner rejects a non-zero exit before assertAppleToolSuccess runs, so its curated message and the devicectl Developer Mode hint were dead code on those paths. uninstallAppleApp already set allowFailure on both branches. Route every appleTools.run request that feeds assertAppleToolSuccess through a single runAppleTool helper that forces allowFailure: true, so the invariant holds at every call site instead of per-branch. --- .../src/deployment/runtime.test.ts | 35 +++++++++++++++++-- .../platform-apple/src/deployment/runtime.ts | 33 ++++++++++++----- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index a96a7a216c..f6005bea37 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -1,10 +1,39 @@ import { expect, test, vi } from 'vitest'; import type { AppleAppDeploymentExecutor } from '@agent-device/contracts/app-deployment-runtime'; +import type { + AppleToolRequest, + HostCommandResult, +} from '@agent-device/contracts/platform-runtime-host'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; import { assertRejectsAppError } from '../__tests__/app-error.ts'; import { appleAppDeploymentFacts, createAppleAppDeploymentOperations } from './runtime.ts'; +/** + * Mirrors runXcrun's own contract (host-kit exec.ts): a non-zero exit rejects with a bare + * COMMAND_FAILED and no hint unless the request set `allowFailure`. A fake that always resolves + * cannot catch a call site that forgot `allowFailure` before handing the result to the caller's + * curated message and hint (#2785). + */ +function xcrunLikeRun( + respond: ( + request: AppleToolRequest, + ) => Readonly<{ stdout: string; stderr: string; exitCode: number }>, +) { + return vi.fn(async (request: AppleToolRequest): Promise => { + const result = respond(request); + if (result.exitCode !== 0 && !request.allowFailure) { + throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + return result; + }); +} + function appleDevice(overrides: Partial = {}): DeviceInfo { return { platform: 'apple', @@ -27,7 +56,7 @@ async function withoutInvalidatingAppResolutionCache( function deploymentHost( appleDeployment: AppleAppDeploymentExecutor, - run = vi.fn(async (request: { args: readonly string[] }) => ({ + run = xcrunLikeRun((request) => ({ stdout: request.args.includes('list') ? '{"devices":{"runtime":[{"udid":"apple-deployment-fact","state":"Booted"}]}}' : '', @@ -275,7 +304,7 @@ test('physical iOS install failure surfaces the devicectl Developer Mode hint', resolveAppBundleId: vi.fn(), withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, } as AppleAppDeploymentExecutor; - const run = vi.fn(async (request: { args: readonly string[] }) => + const run = xcrunLikeRun((request) => request.args.includes('install') ? { stdout: '', @@ -311,7 +340,7 @@ test('physical iOS uninstall failure surfaces the devicectl Developer Mode hint' resolveAppBundleId, withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, } as AppleAppDeploymentExecutor; - const run = vi.fn(async (request: { args: readonly string[] }) => + const run = xcrunLikeRun((request) => request.args.includes('uninstall') ? { stdout: '', diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 5aa1b61429..b6a703e125 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -6,6 +6,10 @@ import type { MaterializeAppSourceInput, PushNotificationInput, } from '@agent-device/contracts/app-deployment-runtime'; +import type { + AppleToolRequest, + HostCommandResult, +} from '@agent-device/contracts/platform-runtime-host'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; @@ -136,7 +140,8 @@ async function installAppleApp( signal: AbortSignal, ): Promise { await ensureAppleReady(host, device, signal); - const result = await host.appleTools.run( + const result = await runAppleTool( + host, device.kind === 'simulator' ? { tool: 'simctl', @@ -159,17 +164,13 @@ async function uninstallAppleApp( signal: AbortSignal, ): Promise { await ensureAppleReady(host, device, signal); - const result = await host.appleTools.run( + const result = await runAppleTool( + host, device.kind === 'simulator' - ? { - tool: 'simctl', - args: scopeSimctlArgsForDevice(device, ['uninstall', device.id, bundleId]), - allowFailure: true, - } + ? { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['uninstall', device.id, bundleId]) } : { tool: 'devicectl', args: ['device', 'uninstall', 'app', '--device', device.id, bundleId], - allowFailure: true, }, signal, ); @@ -197,7 +198,8 @@ async function pushAppleNotification( }); try { await payload.writeText(`${JSON.stringify(input.payload)}\n`); - const result = await host.appleTools.run( + const result = await runAppleTool( + host, { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['push', device.id, input.appId, payload.path]), @@ -210,6 +212,19 @@ async function pushAppleNotification( } } +/** + * Every result this module hands to assertAppleToolSuccess must come from a request that + * tolerates a non-zero exit, or the host's command runner throws before the caller's curated + * message and devicectl hint are attached (#2785). + */ +async function runAppleTool( + host: PlatformRuntimeHost, + request: Omit, + signal: AbortSignal, +): Promise { + return await host.appleTools.run({ ...request, allowFailure: true }, signal); +} + function isMissingAppOutput(output: string): boolean { const normalized = output.toLowerCase(); return ( From 4fa689d71084d9bd5e5a3f404e43446ca4f1fcac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 16:24:29 +0200 Subject: [PATCH 3/9] fix(ios): delete installCoreDeviceApp/uninstallCoreDeviceApp orphaned by #2785 Their only caller was the IosPhysicalDeviceControl install/uninstall wiring, which the prior commit removed as dead. fallow dead-code --unused-exports now flags both as unused; delete them and their now-unused devicectl/config/apps-simctl imports. --- .../src/core/physical-device-apps.ts | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/packages/platform-apple/src/core/physical-device-apps.ts b/packages/platform-apple/src/core/physical-device-apps.ts index 256782d280..fc3f252027 100644 --- a/packages/platform-apple/src/core/physical-device-apps.ts +++ b/packages/platform-apple/src/core/physical-device-apps.ts @@ -1,10 +1,8 @@ import type { AppsFilter } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { IOS_DEVICE_INSTALL_TIMEOUT_MS } from './config.ts'; import { listIosDeviceApps, resolveIosDeviceAppProcesses, - runIosDevicectl, terminateIosDeviceApp, } from './devicectl.ts'; import type { IosAppInfo, IosDeviceAppProcesses } from './app-info.ts'; @@ -16,40 +14,6 @@ export async function listCoreDeviceApps( return await listIosDeviceApps(device, filter); } -export async function installCoreDeviceApp( - device: DeviceInfo, - installablePath: string, - signal?: AbortSignal, -): Promise { - await runIosDevicectl( - ['device', 'install', 'app', '--device', device.id, installablePath], - { - action: 'install iOS app', - deviceId: device.id, - }, - { - signal, - timeoutMs: IOS_DEVICE_INSTALL_TIMEOUT_MS, - }, - ); -} - -export async function uninstallCoreDeviceApp( - device: DeviceInfo, - bundleId: string, - signal?: AbortSignal, -): Promise { - await runIosDevicectl( - ['device', 'uninstall', 'app', '--device', device.id, bundleId], - { action: `uninstall iOS app ${bundleId}`, deviceId: device.id }, - { - signal, - tolerateOutput: (stdout, stderr) => - isMissingAppErrorOutput(`${stdout}\n${stderr}`.toLowerCase()), - }, - ); -} - export async function terminateCoreDeviceApp(device: DeviceInfo, bundleId: string): Promise { await terminateIosDeviceApp(device, bundleId); } From e4b2fe9a407c740ac03d4b91632dfe277da95d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 18:21:30 +0200 Subject: [PATCH 4/9] fix(ios): keep the exit-error shape when forcing allowFailure runAppleTool forced allowFailure: true on every request so the devicectl hint would attach, but that made the host command runner return the failed result instead of rejecting it. The local assertAppleToolSuccess then built COMMAND_FAILED details by hand, without processExitError, so normalizeError no longer appended the stderr excerpt to simulator install, physical install, and simulator push failures, and their details lost cmd/args. Uninstall enrichment was pre-existing dead code before this branch; the other three requests regressed on it. Fold assertAppleToolSuccess into runAppleTool: it still forces allowFailure, but now also guards the result and throws through execFailureDetails itself, so a result can't be tolerated without also being checked. A caller passes the message and an optional hint function; uninstall additionally passes a tolerate predicate for its "already missing" case, which is the only one that still needs to inspect a non-zero result before deciding to fail. Mirror the same exit-error shape in the runtime.test.ts xcrunLikeRun fake (cmd/args/processExitError via execFailureDetails, matching createExitError) so it can catch a call site that drops the excerpt. Add regression tests for simulator install and simulator push failures, and assert the normalized message on the existing physical install/uninstall Developer Mode tests, not just the hint. --- .../platform-apple/src/__tests__/app-error.ts | 11 +- .../src/deployment/runtime.test.ts | 123 ++++++++++++++++-- .../platform-apple/src/deployment/runtime.ts | 72 +++++----- 3 files changed, 160 insertions(+), 46 deletions(-) diff --git a/packages/platform-apple/src/__tests__/app-error.ts b/packages/platform-apple/src/__tests__/app-error.ts index 1a35896125..537dad2ecd 100644 --- a/packages/platform-apple/src/__tests__/app-error.ts +++ b/packages/platform-apple/src/__tests__/app-error.ts @@ -1,7 +1,13 @@ import assert from 'node:assert/strict'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; -type ExpectedAppError = { code: string; message?: RegExp; hint?: string | RegExp }; +type ExpectedAppError = { + code: string; + message?: RegExp; + /** Checked against `normalizeError(error).message`, e.g. the stderr excerpt a command failure appends. */ + normalizedMessage?: RegExp; + hint?: string | RegExp; +}; function assertAppError(error: unknown, expected: ExpectedAppError): true { assert.ok( @@ -10,6 +16,9 @@ function assertAppError(error: unknown, expected: ExpectedAppError): true { ); assert.equal(error.code, expected.code); if (expected.message) assert.match(error.message, expected.message); + if (expected.normalizedMessage) { + assert.match(normalizeError(error).message, expected.normalizedMessage); + } if (expected.hint !== undefined) { const { hint } = normalizeError(error); assert.ok(typeof hint === 'string', `expected a hint on ${error.code}, got ${String(hint)}`); diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index f6005bea37..0b9af256e8 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -7,14 +7,16 @@ import type { import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { execFailureDetails } from '@agent-device/host-kit/command'; import { assertRejectsAppError } from '../__tests__/app-error.ts'; import { appleAppDeploymentFacts, createAppleAppDeploymentOperations } from './runtime.ts'; /** - * Mirrors runXcrun's own contract (host-kit exec.ts): a non-zero exit rejects with a bare - * COMMAND_FAILED and no hint unless the request set `allowFailure`. A fake that always resolves - * cannot catch a call site that forgot `allowFailure` before handing the result to the caller's - * curated message and hint (#2785). + * Mirrors runXcrun's own contract (host-kit exec.ts): a non-zero exit rejects with the same + * `execFailureDetails` shape `createExitError` builds — `processExitError: true` plus `cmd` and + * `args` — and no hint, unless the request set `allowFailure`. A fake that throws a bare + * COMMAND_FAILED cannot catch a call site that forgot `allowFailure`, or one whose caller drops + * the stderr excerpt `normalizeError` would otherwise surface (#2785). */ function xcrunLikeRun( respond: ( @@ -24,11 +26,11 @@ function xcrunLikeRun( return vi.fn(async (request: AppleToolRequest): Promise => { const result = respond(request); if (result.exitCode !== 0 && !request.allowFailure) { - throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + `xcrun exited with code ${result.exitCode}`, + execFailureDetails(result, { cmd: 'xcrun', args: [request.tool, ...request.args] }), + ); } return result; }); @@ -54,6 +56,18 @@ async function withoutInvalidatingAppResolutionCache( return await operation(); } +function bootedSimulatorListResult( + request: AppleToolRequest, +): Readonly<{ stdout: string; stderr: string; exitCode: number }> { + return { + stdout: request.args.includes('list') + ? '{"devices":{"runtime":[{"udid":"apple-deployment-fact","state":"Booted"}]}}' + : '', + stderr: '', + exitCode: 0, + }; +} + function deploymentHost( appleDeployment: AppleAppDeploymentExecutor, run = xcrunLikeRun((request) => ({ @@ -328,11 +342,94 @@ test('physical iOS install failure surfaces the devicectl Developer Mode hint', appPath: '/tmp/App.app', replaceExisting: false, }), - { code: 'COMMAND_FAILED', hint: /Developer Mode/ }, + { + code: 'COMMAND_FAILED', + hint: /Developer Mode/, + normalizedMessage: /Developer Mode is disabled on this device/, + }, ); expect(run.mock.calls.some(([request]) => request.args.includes('install'))).toBe(true); }); +test('simulator install failure surfaces the simctl stderr excerpt with no devicectl hint', async () => { + const prepareArtifact = vi.fn(async () => ({ + installablePath: '/tmp/App.app', + bundleId: 'com.example.app', + appName: 'Example', + cleanup: vi.fn(async () => {}), + })); + const executor = { + prepareArtifact, + resolveAppBundleId: vi.fn(), + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as AppleAppDeploymentExecutor; + const run = xcrunLikeRun((request) => + request.args.includes('install') + ? { + stdout: '', + stderr: 'Failed to install the requested application', + exitCode: 1, + } + : bootedSimulatorListResult(request), + ); + const host = deploymentHost(executor, run); + const device = appleDevice(); + const operations = createAppleAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await assertRejectsAppError( + async () => + await operations.deployApp?.({ + app: 'com.example.app', + appPath: '/tmp/App.app', + replaceExisting: false, + }), + { + code: 'COMMAND_FAILED', + normalizedMessage: /Failed to install the requested application/, + }, + ); + const [request] = run.mock.calls.find(([call]) => call.args.includes('install'))!; + expect(request.allowFailure).toBe(true); +}); + +test('simulator push failure surfaces the simctl stderr excerpt', async () => { + const executor = { + prepareArtifact: vi.fn(), + resolveAppBundleId: vi.fn(), + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as AppleAppDeploymentExecutor; + const run = xcrunLikeRun((request) => + request.args.includes('push') + ? { + stdout: '', + stderr: 'Invalid device state: Booted', + exitCode: 1, + } + : bootedSimulatorListResult(request), + ); + const host = deploymentHost(executor, run); + const device = appleDevice(); + const operations = createAppleAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await assertRejectsAppError( + async () => await operations.sendPushNotification?.({ appId: 'com.example.app', payload: {} }), + { + code: 'COMMAND_FAILED', + normalizedMessage: /Invalid device state: Booted/, + }, + ); + const [request] = run.mock.calls.find(([call]) => call.args.includes('push'))!; + expect(request.allowFailure).toBe(true); +}); + test('physical iOS uninstall failure surfaces the devicectl Developer Mode hint', async () => { const resolveAppBundleId = vi.fn(async () => 'com.example.app'); const executor = { @@ -365,7 +462,11 @@ test('physical iOS uninstall failure surfaces the devicectl Developer Mode hint' appPath: '/tmp/replacement.app', replaceExisting: true, }), - { code: 'COMMAND_FAILED', hint: /Developer Mode/ }, + { + code: 'COMMAND_FAILED', + hint: /Developer Mode/, + normalizedMessage: /Developer Mode is disabled on this device/, + }, ); expect(run.mock.calls.some(([request]) => request.args.includes('uninstall'))).toBe(true); }); diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index b6a703e125..3103a861b9 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -14,6 +14,7 @@ import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runti import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { execFailureDetails } from '@agent-device/host-kit/command'; import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from '../core/devicectl.ts'; import { ensureAppleReady } from '../readiness/runtime.ts'; import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; @@ -140,7 +141,7 @@ async function installAppleApp( signal: AbortSignal, ): Promise { await ensureAppleReady(host, device, signal); - const result = await runAppleTool( + await runAppleTool( host, device.kind === 'simulator' ? { @@ -153,8 +154,9 @@ async function installAppleApp( timeoutMs: 120_000, }, signal, + 'Apple app install failed', + { hint: (result) => devicectlHint(device, result) }, ); - assertAppleToolSuccess(result, 'Apple app install failed', devicectlHintDetails(device, result)); } async function uninstallAppleApp( @@ -164,7 +166,7 @@ async function uninstallAppleApp( signal: AbortSignal, ): Promise { await ensureAppleReady(host, device, signal); - const result = await runAppleTool( + await runAppleTool( host, device.kind === 'simulator' ? { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['uninstall', device.id, bundleId]) } @@ -173,12 +175,11 @@ async function uninstallAppleApp( args: ['device', 'uninstall', 'app', '--device', device.id, bundleId], }, signal, - ); - if (result.exitCode === 0 || isMissingAppOutput(`${result.stdout}\n${result.stderr}`)) return; - assertAppleToolSuccess( - result, `Apple app uninstall failed for ${bundleId}`, - devicectlHintDetails(device, result), + { + hint: (result) => devicectlHint(device, result), + tolerate: (result) => isMissingAppOutput(`${result.stdout}\n${result.stderr}`), + }, ); } @@ -198,31 +199,50 @@ async function pushAppleNotification( }); try { await payload.writeText(`${JSON.stringify(input.payload)}\n`); - const result = await runAppleTool( + await runAppleTool( host, { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['push', device.id, input.appId, payload.path]), }, signal, + 'Apple push notification failed', ); - assertAppleToolSuccess(result, 'Apple push notification failed'); } finally { await payload[Symbol.asyncDispose](); } } /** - * Every result this module hands to assertAppleToolSuccess must come from a request that - * tolerates a non-zero exit, or the host's command runner throws before the caller's curated - * message and devicectl hint are attached (#2785). + * The one request path this module uses to run a tolerated Apple tool call: it forces + * `allowFailure`, then guards the result itself so a non-zero exit always throws through + * `execFailureDetails` (the same shape `runCmd`'s own exit error carries) with the caller's + * curated message and, optionally, a devicectl hint. `tolerate` lets a caller accept a specific + * non-zero result (uninstall's "already missing" case) without losing that guard for every + * other outcome (#2785). */ async function runAppleTool( host: PlatformRuntimeHost, request: Omit, signal: AbortSignal, + message: string, + options?: Readonly<{ + hint?: (result: HostCommandResult) => string | undefined; + tolerate?: (result: HostCommandResult) => boolean; + }>, ): Promise { - return await host.appleTools.run({ ...request, allowFailure: true }, signal); + const result = await host.appleTools.run({ ...request, allowFailure: true }, signal); + if (result.exitCode === 0 || options?.tolerate?.(result)) return result; + const hint = options?.hint?.(result); + throw new AppError( + 'COMMAND_FAILED', + message, + execFailureDetails(result, { + cmd: 'xcrun', + args: [request.tool, ...request.args], + ...(hint ? { hint } : {}), + }), + ); } function isMissingAppOutput(output: string): boolean { @@ -234,33 +254,17 @@ function isMissingAppOutput(output: string): boolean { ); } -function assertAppleToolSuccess( - result: Readonly<{ stdout: string; stderr: string; exitCode: number | null }>, - message: string, - details: Readonly<{ hint?: string }> = {}, -): void { - if (result.exitCode === 0) return; - throw new AppError('COMMAND_FAILED', message, { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - ...details, - }); -} - /** * Physical iOS install/uninstall runs through devicectl (#2785): a failure gets the same * Developer Mode, developer-disk-image, and pairing hints the other devicectl call sites attach. * Simulator installs go through simctl, which this resolver does not classify. */ -function devicectlHintDetails( +function devicectlHint( device: DeviceInfo, result: Readonly<{ stdout: string; stderr: string }>, -): Readonly<{ hint?: string }> { - if (device.kind === 'simulator') return {}; - return { - hint: resolveIosDevicectlHint(result.stdout, result.stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, - }; +): string | undefined { + if (device.kind === 'simulator') return undefined; + return resolveIosDevicectlHint(result.stdout, result.stderr) ?? IOS_DEVICECTL_DEFAULT_HINT; } function appleDeployFact(device: DeviceInfo): RuntimeOperationFact { From 9dfb12fa6eb24bf013f88abb17302b5af5aca68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 18:29:30 +0200 Subject: [PATCH 5/9] fix(ios): route runAppleTool's exit guard through requireExecSuccess runAppleTool rebuilt the exec-failure shape locally instead of reusing requireExecSuccess, so the two copies could drift (as the deleted assertAppleToolSuccess already had). Widen requireExecSuccess to accept any result shaped like HostCommandResult (exitCode: number | null) and call it from runAppleTool, keeping one COMMAND_FAILED shape for every allowFailure exec call site. --- packages/host-kit/src/internal/exec.ts | 10 ++++--- .../platform-apple/src/deployment/runtime.ts | 28 ++++++++----------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/host-kit/src/internal/exec.ts b/packages/host-kit/src/internal/exec.ts index 7f1f9f8356..d5f759c9d0 100644 --- a/packages/host-kit/src/internal/exec.ts +++ b/packages/host-kit/src/internal/exec.ts @@ -688,11 +688,13 @@ function createExitError( * than relying on the spawn layer to throw. `extra` accepts a function so * failure-only work (hint classification) is not paid on the success path. */ -export function requireExecSuccess( - result: ExecResult, +export function requireExecSuccess< + R extends Pick & Readonly<{ exitCode: number | null }>, +>( + result: R, message: string, - extra?: Record | ((result: ExecResult) => Record), -): ExecResult { + extra?: Record | ((result: R) => Record), +): R { if (result.exitCode === 0) return result; throw new AppError( 'COMMAND_FAILED', diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 3103a861b9..90099f8d34 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -14,7 +14,7 @@ import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runti import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { execFailureDetails } from '@agent-device/host-kit/command'; +import { requireExecSuccess } from '@agent-device/host-kit/command'; import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from '../core/devicectl.ts'; import { ensureAppleReady } from '../readiness/runtime.ts'; import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; @@ -215,11 +215,11 @@ async function pushAppleNotification( /** * The one request path this module uses to run a tolerated Apple tool call: it forces - * `allowFailure`, then guards the result itself so a non-zero exit always throws through - * `execFailureDetails` (the same shape `runCmd`'s own exit error carries) with the caller's - * curated message and, optionally, a devicectl hint. `tolerate` lets a caller accept a specific - * non-zero result (uninstall's "already missing" case) without losing that guard for every - * other outcome (#2785). + * `allowFailure`, then guards the result itself through `requireExecSuccess` so a non-zero + * exit always throws the same COMMAND_FAILED shape as every other exec call site, with the + * caller's curated message and, optionally, a devicectl hint. `tolerate` lets a caller accept + * a specific non-zero result (uninstall's "already missing" case) without losing that guard + * for every other outcome (#2785). */ async function runAppleTool( host: PlatformRuntimeHost, @@ -232,17 +232,11 @@ async function runAppleTool( }>, ): Promise { const result = await host.appleTools.run({ ...request, allowFailure: true }, signal); - if (result.exitCode === 0 || options?.tolerate?.(result)) return result; - const hint = options?.hint?.(result); - throw new AppError( - 'COMMAND_FAILED', - message, - execFailureDetails(result, { - cmd: 'xcrun', - args: [request.tool, ...request.args], - ...(hint ? { hint } : {}), - }), - ); + if (options?.tolerate?.(result)) return result; + return requireExecSuccess(result, message, (failed) => { + const hint = options?.hint?.(failed); + return { cmd: 'xcrun', args: [request.tool, ...request.args], ...(hint ? { hint } : {}) }; + }); } function isMissingAppOutput(output: string): boolean { From 979b4dcf98078b72af2d0f6711016195db82add1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 19:52:25 +0200 Subject: [PATCH 6/9] fix(ios): reuse the install timeout and missing-app checks instead of re-declaring them runtime.ts's runAppleTool consolidation left IOS_DEVICE_INSTALL_TIMEOUT_MS (core/config.ts) and isMissingAppErrorOutput (core/apps-simctl.ts) with no reader after the deleted CoreDevice install/uninstall paths were removed, while runtime.ts kept a local 120_000 literal and a duplicate substring check. Import and reuse both instead, and drop the issue-number references from the surrounding doc comments. --- .../platform-apple/src/deployment/runtime.ts | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 90099f8d34..3971e404ae 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -15,6 +15,8 @@ import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runt import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { requireExecSuccess } from '@agent-device/host-kit/command'; +import { isMissingAppErrorOutput } from '../core/apps-simctl.ts'; +import { IOS_DEVICE_INSTALL_TIMEOUT_MS } from '../core/config.ts'; import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from '../core/devicectl.ts'; import { ensureAppleReady } from '../readiness/runtime.ts'; import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; @@ -151,7 +153,7 @@ async function installAppleApp( : { tool: 'devicectl', args: ['device', 'install', 'app', '--device', device.id, installablePath], - timeoutMs: 120_000, + timeoutMs: IOS_DEVICE_INSTALL_TIMEOUT_MS, }, signal, 'Apple app install failed', @@ -178,7 +180,8 @@ async function uninstallAppleApp( `Apple app uninstall failed for ${bundleId}`, { hint: (result) => devicectlHint(device, result), - tolerate: (result) => isMissingAppOutput(`${result.stdout}\n${result.stderr}`), + tolerate: (result) => + isMissingAppErrorOutput(`${result.stdout}\n${result.stderr}`.toLowerCase()), }, ); } @@ -219,7 +222,7 @@ async function pushAppleNotification( * exit always throws the same COMMAND_FAILED shape as every other exec call site, with the * caller's curated message and, optionally, a devicectl hint. `tolerate` lets a caller accept * a specific non-zero result (uninstall's "already missing" case) without losing that guard - * for every other outcome (#2785). + * for every other outcome. */ async function runAppleTool( host: PlatformRuntimeHost, @@ -239,18 +242,9 @@ async function runAppleTool( }); } -function isMissingAppOutput(output: string): boolean { - const normalized = output.toLowerCase(); - return ( - normalized.includes('not installed') || - normalized.includes('not found') || - normalized.includes('no such file') - ); -} - /** - * Physical iOS install/uninstall runs through devicectl (#2785): a failure gets the same - * Developer Mode, developer-disk-image, and pairing hints the other devicectl call sites attach. + * Physical iOS install/uninstall runs through devicectl: a failure gets the same Developer + * Mode, developer-disk-image, and pairing hints the other devicectl call sites attach. * Simulator installs go through simctl, which this resolver does not classify. */ function devicectlHint( From 9b7d9dddf1c6852909f2270f6dd1eacc449c2828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 20:44:48 +0200 Subject: [PATCH 7/9] fix(ios): repoint the missing-app check at the export the base rename left behind The rebase onto origin/main landed on top of the rename that deleted core/apps-simctl.ts and moved isMissingAppErrorOutput into physical-device-apps.ts as a private function. runtime.ts kept importing from the deleted module, and the base's copy in physical-device-apps.ts lost its only reader once this branch deletes uninstallCoreDeviceApp, leaving an unused private declaration. Export isMissingAppErrorOutput from physical-device-apps.ts and import it there from runtime.ts, so the missing-app check has exactly one definition and one caller: the uninstall tolerate predicate. --- packages/platform-apple/src/core/physical-device-apps.ts | 2 +- packages/platform-apple/src/deployment/runtime.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/platform-apple/src/core/physical-device-apps.ts b/packages/platform-apple/src/core/physical-device-apps.ts index fc3f252027..fd2df11594 100644 --- a/packages/platform-apple/src/core/physical-device-apps.ts +++ b/packages/platform-apple/src/core/physical-device-apps.ts @@ -25,7 +25,7 @@ export async function resolveCoreDeviceAppProcesses( return await resolveIosDeviceAppProcesses(device, bundleId); } -function isMissingAppErrorOutput(output: string): boolean { +export function isMissingAppErrorOutput(output: string): boolean { return ( output.includes('not installed') || output.includes('not found') || diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 3971e404ae..c7f5b60444 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -15,7 +15,7 @@ import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runt import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { requireExecSuccess } from '@agent-device/host-kit/command'; -import { isMissingAppErrorOutput } from '../core/apps-simctl.ts'; +import { isMissingAppErrorOutput } from '../core/physical-device-apps.ts'; import { IOS_DEVICE_INSTALL_TIMEOUT_MS } from '../core/config.ts'; import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from '../core/devicectl.ts'; import { ensureAppleReady } from '../readiness/runtime.ts'; @@ -171,7 +171,10 @@ async function uninstallAppleApp( await runAppleTool( host, device.kind === 'simulator' - ? { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['uninstall', device.id, bundleId]) } + ? { + tool: 'simctl', + args: scopeSimctlArgsForDevice(device, ['uninstall', device.id, bundleId]), + } : { tool: 'devicectl', args: ['device', 'uninstall', 'app', '--device', device.id, bundleId], From 6d5566359c6912488336b674ade898ce76b69073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 20:44:53 +0200 Subject: [PATCH 8/9] test(ios): pin the reinstall uninstall-tolerance branch through deployApp The uninstall tolerate predicate and its lowercasing had no test driving createAppleAppDeploymentOperations, so removing either left the whole suite green. Add a reinstall test, for both a physical CoreDevice and a simulator, where uninstall exits non-zero with mixed-case stderr ("ERROR: App Not Installed"): deployApp must still resolve and the install request must have run. --- .../src/deployment/runtime.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index 0b9af256e8..0af0fcf81d 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -471,6 +471,53 @@ test('physical iOS uninstall failure surfaces the devicectl Developer Mode hint' expect(run.mock.calls.some(([request]) => request.args.includes('uninstall'))).toBe(true); }); +test.each([ + [ + 'physical iOS CoreDevice', + appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }), + ], + ['iOS simulator', appleDevice()], +] as const)( + 'reinstall tolerates an already-missing %s uninstall with mixed-case stderr and still installs', + async (_name, device) => { + const resolveAppBundleId = vi.fn(async () => 'com.example.app'); + const prepareArtifact = vi.fn(async () => ({ + installablePath: '/tmp/App.app', + bundleId: 'com.example.app', + appName: 'Example', + cleanup: vi.fn(async () => {}), + })); + const executor = { + prepareArtifact, + resolveAppBundleId, + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as AppleAppDeploymentExecutor; + const run = xcrunLikeRun((request) => { + if (request.args.includes('uninstall')) { + return { stdout: '', stderr: 'ERROR: App Not Installed', exitCode: 1 }; + } + return bootedSimulatorListResult(request); + }); + const host = deploymentHost(executor, run); + const operations = createAppleAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await expect( + operations.deployApp?.({ + app: 'com.example.app', + appPath: '/tmp/App.app', + replaceExisting: true, + }), + ).resolves.toMatchObject({ bundleId: 'com.example.app' }); + + expect(run.mock.calls.some(([request]) => request.args.includes('uninstall'))).toBe(true); + expect(run.mock.calls.some(([request]) => request.args.includes('install'))).toBe(true); + }, +); + test('preserves Apple reinstall partial-failure ordering', async () => { const order: string[] = []; const resolveAppBundleId = vi.fn(async () => { From edd6977b26036afce88ec457b659bac403bb922e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 09:14:12 +0200 Subject: [PATCH 9/9] fix(ios): key the install hint on the devicectl tool, not the device kind --- .../platform-apple/src/__tests__/app-error.ts | 9 +++-- .../src/deployment/runtime.test.ts | 2 + .../platform-apple/src/deployment/runtime.ts | 40 +++++++------------ 3 files changed, 22 insertions(+), 29 deletions(-) diff --git a/packages/platform-apple/src/__tests__/app-error.ts b/packages/platform-apple/src/__tests__/app-error.ts index 537dad2ecd..c7eb8aa3d6 100644 --- a/packages/platform-apple/src/__tests__/app-error.ts +++ b/packages/platform-apple/src/__tests__/app-error.ts @@ -1,12 +1,13 @@ import assert from 'node:assert/strict'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { AppError, defaultHintForCode, normalizeError } from '@agent-device/kernel/errors'; type ExpectedAppError = { code: string; message?: RegExp; /** Checked against `normalizeError(error).message`, e.g. the stderr excerpt a command failure appends. */ normalizedMessage?: RegExp; - hint?: string | RegExp; + /** `null` asserts the failure attached no hint of its own, so normalization falls back to the code default. */ + hint?: string | RegExp | null; }; function assertAppError(error: unknown, expected: ExpectedAppError): true { @@ -19,7 +20,9 @@ function assertAppError(error: unknown, expected: ExpectedAppError): true { if (expected.normalizedMessage) { assert.match(normalizeError(error).message, expected.normalizedMessage); } - if (expected.hint !== undefined) { + if (expected.hint === null) { + assert.equal(normalizeError(error).hint, defaultHintForCode(error.code)); + } else if (expected.hint !== undefined) { const { hint } = normalizeError(error); assert.ok(typeof hint === 'string', `expected a hint on ${error.code}, got ${String(hint)}`); if (typeof expected.hint === 'string') assert.equal(hint, expected.hint); diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index 0af0fcf81d..e56a876049 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -390,6 +390,7 @@ test('simulator install failure surfaces the simctl stderr excerpt with no devic { code: 'COMMAND_FAILED', normalizedMessage: /Failed to install the requested application/, + hint: null, }, ); const [request] = run.mock.calls.find(([call]) => call.args.includes('install'))!; @@ -424,6 +425,7 @@ test('simulator push failure surfaces the simctl stderr excerpt', async () => { { code: 'COMMAND_FAILED', normalizedMessage: /Invalid device state: Booted/, + hint: null, }, ); const [request] = run.mock.calls.find(([call]) => call.args.includes('push'))!; diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index c7f5b60444..65f9d27973 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -157,7 +157,6 @@ async function installAppleApp( }, signal, 'Apple app install failed', - { hint: (result) => devicectlHint(device, result) }, ); } @@ -182,7 +181,6 @@ async function uninstallAppleApp( signal, `Apple app uninstall failed for ${bundleId}`, { - hint: (result) => devicectlHint(device, result), tolerate: (result) => isMissingAppErrorOutput(`${result.stdout}\n${result.stderr}`.toLowerCase()), }, @@ -223,39 +221,29 @@ async function pushAppleNotification( * The one request path this module uses to run a tolerated Apple tool call: it forces * `allowFailure`, then guards the result itself through `requireExecSuccess` so a non-zero * exit always throws the same COMMAND_FAILED shape as every other exec call site, with the - * caller's curated message and, optionally, a devicectl hint. `tolerate` lets a caller accept - * a specific non-zero result (uninstall's "already missing" case) without losing that guard - * for every other outcome. + * caller's curated message. A devicectl failure gets the same Developer Mode, + * developer-disk-image, and pairing hints the other devicectl call sites attach. `tolerate` + * lets a caller accept a specific non-zero result (uninstall's "already missing" case) + * without losing that guard for every other outcome. */ async function runAppleTool( host: PlatformRuntimeHost, request: Omit, signal: AbortSignal, message: string, - options?: Readonly<{ - hint?: (result: HostCommandResult) => string | undefined; - tolerate?: (result: HostCommandResult) => boolean; - }>, + options?: Readonly<{ tolerate?: (result: HostCommandResult) => boolean }>, ): Promise { const result = await host.appleTools.run({ ...request, allowFailure: true }, signal); if (options?.tolerate?.(result)) return result; - return requireExecSuccess(result, message, (failed) => { - const hint = options?.hint?.(failed); - return { cmd: 'xcrun', args: [request.tool, ...request.args], ...(hint ? { hint } : {}) }; - }); -} - -/** - * Physical iOS install/uninstall runs through devicectl: a failure gets the same Developer - * Mode, developer-disk-image, and pairing hints the other devicectl call sites attach. - * Simulator installs go through simctl, which this resolver does not classify. - */ -function devicectlHint( - device: DeviceInfo, - result: Readonly<{ stdout: string; stderr: string }>, -): string | undefined { - if (device.kind === 'simulator') return undefined; - return resolveIosDevicectlHint(result.stdout, result.stderr) ?? IOS_DEVICECTL_DEFAULT_HINT; + return requireExecSuccess(result, message, (failed) => ({ + cmd: 'xcrun', + args: [request.tool, ...request.args], + ...(request.tool === 'devicectl' + ? { + hint: resolveIosDevicectlHint(failed.stdout, failed.stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, + } + : {}), + })); } function appleDeployFact(device: DeviceInfo): RuntimeOperationFact {