Skip to content

Commit 51a56a6

Browse files
thymikeeclaude
andcommitted
fix(open): refuse --launch-console on a provider-owned device before the relaunch close
A provider-owned iOS simulator has the same device shape as a local one, so open --relaunch --launch-console passed the existing shape check, closed the running app, and only then failed. The provider branch of the lifecycle binding now refuses launch-console with UNSUPPORTED_OPERATION and the typed reason launch-console-owner-unadmitted before it resolves an interactor, so no close or launch runs. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 61ce711 commit 51a56a6

9 files changed

Lines changed: 87 additions & 35 deletions

File tree

‎packages/command-registry/src/flag-definitions-target.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,8 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
363363
names: ['--launch-console'],
364364
type: 'string',
365365
usageLabel: '--launch-console <path>',
366-
usageDescription: 'open: capture the initial iOS simulator launch console window to a file',
366+
usageDescription:
367+
'open: capture the initial local iOS simulator launch console window to a file',
367368
projectConfig: false,
368369
recorded: false,
369370
},

‎packages/contracts/src/application-lifecycle-interaction.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { OpenApplicationInput } from './application-lifecycle-runtime.ts';
55
import {
66
bindDirectApplicationLifecycle,
77
bindLocalApplicationLifecycleInteractor,
8+
bindProviderApplicationLifecycleInteractor,
89
invokeApplicationOpen,
910
} from './application-lifecycle-interaction.ts';
1011

@@ -165,3 +166,30 @@ test.each([
165166
}),
166167
).rejects.toThrow(message);
167168
});
169+
170+
test('provider interactor resolution refuses launch-console before a relaunch close', async () => {
171+
const calls: string[] = [];
172+
const binding = bindProviderApplicationLifecycleInteractor({
173+
device: IOS_SIMULATOR,
174+
signal: new AbortController().signal,
175+
resolveInteractor: () => {
176+
calls.push('resolve');
177+
return interactorWithOpen();
178+
},
179+
});
180+
const lifecycle = bindDirectApplicationLifecycle({
181+
binding,
182+
owner: 'provider',
183+
openTargetIdentity: 'bundle-id',
184+
closeBeforeRelaunch: true,
185+
});
186+
await expect(
187+
lifecycle.openApplication(
188+
openInput({ relaunch: true, execution: { launchConsole: '/tmp/app.log' } }),
189+
),
190+
).rejects.toMatchObject({
191+
code: 'UNSUPPORTED_OPERATION',
192+
details: { reason: 'launch-console-owner-unadmitted', deviceId: IOS_SIMULATOR.id },
193+
});
194+
expect(calls).toEqual([]);
195+
});

‎packages/contracts/src/application-lifecycle-interaction.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@ function bindApplicationLifecycleInteractor(
8787
resolveInteractor: async (execution, appBundleId) => {
8888
const runner = applicationLifecycleRunnerContext(execution, appBundleId, signal);
8989
if (ownership === 'local') return await params.resolveLocalInteractor(device, runner);
90+
if (execution.launchConsole) {
91+
throw new AppError(
92+
'UNSUPPORTED_OPERATION',
93+
'--launch-console is supported only for a local iOS simulator app launch',
94+
{
95+
reason: 'launch-console-owner-unadmitted',
96+
deviceId: device.id,
97+
hint: 'Remove --launch-console, or use a local iOS simulator for launch output.',
98+
},
99+
);
100+
}
90101
const interactor = ownership.resolveInteractor(runner);
91102
if (interactor) return interactor;
92103
throw new AppError(

‎src/commands/management/app.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const openCommandMetadata = defineFieldCommandMetadata(
4545
),
4646
activity: stringField('Android activity name.'),
4747
launchConsole: stringField(
48-
'File path on the daemon host for the stdout and stderr of the initial app launch. iOS simulator direct app launches only; not for URL opens.',
48+
'File path on the daemon host for the stdout and stderr of the initial app launch. Local iOS simulator direct app launches only; not for URL opens.',
4949
),
5050
launchArgs: stringArrayField(
5151
'Launch arguments forwarded verbatim to the platform launch command.',
@@ -199,7 +199,7 @@ export const openCommandFacet = defineCommandFacet({
199199
text: {
200200
summary: 'Open an app, deep link or URL, save replays',
201201
cliDetail:
202-
'Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console <path> on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+<slug> scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in <App>?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; for url-owner-unresolved, answer the prompt with alert accept; for alert-unrecognized, read the alert with alert get first, because it can be the app\'s own alert (such as a permission prompt) rather than a translated launch confirmation, and answer with alert accept only when it is the launch confirmation; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait <ms> to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.',
202+
'Use --platform to bind URL/deep-link opens to the target platform. For local iOS simulator initial stdout/stderr, put --launch-console <path> on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+<slug> scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in <App>?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; for url-owner-unresolved, answer the prompt with alert accept; for alert-unrecognized, read the alert with alert get first, because it can be the app\'s own alert (such as a permission prompt) rather than a translated launch confirmation, and answer with alert accept only when it is the launch confirmation; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait <ms> to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.',
203203
mcpDetail:
204204
"metroHost, metroPort, and bundleUrl choose the app's Metro server and save its address for later reloads. launchUrl selects the app's launch URL and does not select the reload server. A fresh open without Metro hints clears an older same-name binding; close also clears it.",
205205
},

‎test/integration/provider-scenarios/provider-device-runtime.fixtures.ts‎

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ import {
1919
} from '@agent-device/contracts/application-lifecycle-runtime';
2020
import {
2121
bindProviderApplicationLifecycleInteractor,
22-
invokeApplicationClose,
23-
invokeApplicationOpen,
22+
bindDirectApplicationLifecycle,
2423
} from '@agent-device/contracts/application-lifecycle-interaction';
2524
import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime';
2625
import { bindProviderSnapshotInteractor } from '@agent-device/contracts/snapshot-runtime';
@@ -201,39 +200,20 @@ function providerScenarioApplicationLifecycle(
201200
signal,
202201
resolveInteractor: (runner) => runtime.getInteractor(device, runner),
203202
});
203+
const direct = bindDirectApplicationLifecycle({
204+
binding,
205+
owner: 'scenario-provider',
206+
openTargetIdentity: 'bundle-id',
207+
closeBeforeRelaunch: true,
208+
});
204209
return Object.freeze({
210+
...direct,
205211
resolveOpenTarget: async (input) => ({
206212
...(input.target?.includes('.') ? { appBundleId: input.target } : {}),
207213
...(input.target === undefined ? {} : { appName: input.target }),
208214
}),
209-
prepareApplicationOpen: async () => undefined,
210-
openApplication: async (input) => {
211-
if (input.relaunch && input.target) {
212-
await invokeApplicationClose({
213-
device,
214-
interactor: await binding.resolveInteractor(input.execution, input.appBundleId),
215-
positionals: [input.appBundleId ?? input.target],
216-
});
217-
}
218-
await invokeApplicationOpen({
219-
device,
220-
interactor: await binding.resolveInteractor(input.execution, input.appBundleId),
221-
positionals: input.positionals,
222-
appBundleId: input.appBundleId,
223-
execution: input.execution,
224-
});
225-
return { appBundleId: input.appBundleId, timing: {} };
226-
},
227215
applyRuntimeHints: async () => undefined,
228216
clearRuntimeHints: async () => undefined,
229-
closeApplication: async (input) => {
230-
await invokeApplicationClose({
231-
device,
232-
interactor: await binding.resolveInteractor(input.execution, input.appBundleId),
233-
positionals: input.positionals,
234-
});
235-
},
236-
finalizeApplicationClose: async () => ({}),
237217
prepareAppleRunner: async () => {
238218
throw new AppError('UNSUPPORTED_OPERATION', 'Fake provider does not prepare Apple runners.');
239219
},

‎test/integration/provider-scenarios/provider-device-runtime.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,38 @@ test('provider-owned iOS simulators relaunch without local CoreSimulator refresh
8989
);
9090
});
9191

92+
test('provider-owned relaunch refuses launch-console without closing the running app', async () => {
93+
await withProviderScenarioResource(
94+
async () => await createFakeProviderWorld('ios'),
95+
async ({ daemon, runtime }) => {
96+
const lease = await allocateIosFakeProviderLease(daemon);
97+
const flags = iosLeaseFlags(lease.leaseId);
98+
const options = { meta: iosLeaseMeta(lease.leaseId) };
99+
assertRpcOk(await daemon.callCommand('open', ['com.example.demo'], flags, options));
100+
const before = runtime.calls.length;
101+
const error = assertRpcError(
102+
await daemon.callCommand(
103+
'open',
104+
['com.example.demo'],
105+
{ ...flags, relaunch: true, launchConsole: '/tmp/app.log' },
106+
options,
107+
),
108+
500,
109+
'UNSUPPORTED_OPERATION',
110+
/local iOS simulator/,
111+
);
112+
assert.equal(
113+
(error.details as Record<string, unknown>).reason,
114+
'launch-console-owner-unadmitted',
115+
);
116+
assert.deepEqual(
117+
runtime.calls.slice(before).filter((call) => call.type === 'open' || call.type === 'close'),
118+
[],
119+
);
120+
},
121+
);
122+
});
123+
92124
test('provider lease allocation is refused when no registered runtime claims the provider', async () => {
93125
const daemon = await createProviderScenarioHarness({
94126
providerRuntimeIds: [FAKE_PROVIDER],

‎website/docs/docs/client-api.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ This example opens an app, takes a snapshot, interacts, handles typed errors, an
6666

6767
`client.devices.capabilities()` returns `{ device, availableCommands }`, using the same capability matrix as the CLI. Use it to check which command names the selected target supports.
6868

69-
For direct iOS simulator app launches, `client.apps.open({ app, platform: 'ios', launchConsole: './artifacts/app.console.log' })` captures launch-time
70-
stdout/stderr. The option mirrors `open --launch-console` and is not valid for URL opens or non-simulator targets.
69+
For direct local iOS simulator app launches, `client.apps.open({ app, platform: 'ios', launchConsole: './artifacts/app.console.log' })` captures launch-time
70+
stdout/stderr. The option mirrors `open --launch-console` and is not valid for URL opens, provider-owned simulators, or non-simulator targets.
7171

7272
`client.sessions.stateDir()` mirrors `session state-dir` and returns the resolved daemon state directory without starting
7373
or contacting the daemon. Pass `{ stateDir }` to resolve an explicit override the same way the CLI resolves `--state-dir`.

‎website/docs/docs/commands.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Open apps and links:
9898
- `open [app|url] [url]` boots the selected device when needed and opens the target in a session. `open <url>` opens deep links on Android and iOS, and `open <app> <url>` opens a deep link in that app on iOS. See [Desktop targets](#desktop-targets) for macOS activation behavior.
9999
- On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom-scheme URLs need an active app in the session.
100100
- `open <app> --wait <ms>` waits up to that budget for a device another session holds, then fails with `DEVICE_IN_USE`. The wait extends the command's timeout, so you don't need a longer `--timeout`. A claim held by another workspace's daemon is not waited for; `open` returns its recovery command at once. Device-mutating commands like `install` and `reinstall` never wait either: a claim held by another session fails them at once, and when the caller sends a workspace, another workspace's claim under the same session name fails too. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device).
101-
- `open <app> --launch-console <path>` captures launch-time stdout/stderr for iOS simulator app launches. It is not valid for URL opens or other targets.
101+
- `open <app> --launch-console <path>` captures launch-time stdout/stderr for local iOS simulator app launches. It is not valid for URL opens or other targets.
102102
- `open --platform macos --surface app|frontmost-app|desktop|menubar` selects the macOS session surface; `app` is the default when you name an app. See [Desktop targets](#desktop-targets). `--save-script` records the surface so `replay` reopens it; with no surface given, replay opens an app session.
103103
- Android React Native: `open --relaunch` needs an installed package name. Install or reinstall the APK first; `open <apk|aab> --relaunch` is rejected. For Metro-backed JS changes, run `metro reload` before reaching for `open <app> --relaunch`.
104104
- Android (adb): an intent target such as `open settings` records the package that opened as `appBundleId`. `open settings --relaunch` stops and restarts that app. If the intent matches more than one activity, `--relaunch` fails.

‎website/docs/docs/debugging-profiling.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ On iOS simulators, `logs` scope by bundle id and the resolved app executable. Fo
148148
agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/app.console.log
149149
```
150150
151-
`--launch-console` is only for direct iOS simulator app launches, not URL opens.
151+
`--launch-console` is only for direct local iOS simulator app launches, not URL opens.
152152
153153
## Crash symbolication
154154

0 commit comments

Comments
 (0)