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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/contracts/src/network-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function compileTimeNetworkProjectionProof(
void compileTimeNetworkProjectionProof;

function compileTimeCanonicalHostProof(host: NetworkRuntimeHost): void {
// @ts-expect-error Raw simctl argv cannot cross the Apple tool port; scope it in platform-apple.
void host.appleTools.run({ tool: 'simctl', args: ['spawn', 'sim-1', 'log', 'show'] });
void host.appleTools.run({
tool: 'simctl',
Expand Down
15 changes: 9 additions & 6 deletions packages/contracts/src/platform-runtime-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,15 @@ export type HostCommandRunner = Readonly<{

export type AppleXcrunTool = 'simctl' | 'devicectl' | 'xctrace';

export type AppleToolRequest = Readonly<{
tool: AppleXcrunTool;
args: readonly string[];
timeoutMs?: number;
allowFailure?: boolean;
}>;
declare const scopedSimctlArgs: unique symbol;
/** simctl argv (after the tool name) already scoped to its simulator set; minted only by platform-apple. */
export type ScopedSimctlArgs = readonly string[] & { readonly [scopedSimctlArgs]: true };

export type AppleToolRequest = Readonly<{ timeoutMs?: number; allowFailure?: boolean }> &
(
| Readonly<{ tool: 'simctl'; args: ScopedSimctlArgs }>
| Readonly<{ tool: Exclude<AppleXcrunTool, 'simctl'>; args: readonly string[] }>
);

/** Request-bound foreground Apple tooling backed by the selected scoped provider. */
export type AppleToolHost = Readonly<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ test('openIosDevice leaves the macOS desktop target alone', async () => {
const provider = createLocalAppleToolProvider({
simctl: {
run: async (args) => {
simctlCalls.push(args);
simctlCalls.push([...args]);
return { exitCode: 0, stdout: '', stderr: '' };
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { screenshotIos } from '../screenshot.ts';
import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts';
import { mkdtempForTest } from '../../__tests__/tmp-dir.ts';

function startsWithArgs(args: string[], expected: string[]): boolean {
function startsWithArgs(args: readonly string[], expected: string[]): boolean {
return expected.every((value, index) => args[index] === value);
}

Expand All @@ -29,7 +29,7 @@ test('screenshotIos caches simulator screen scale per device', async () => {
const provider = createLocalAppleToolProvider({
simctl: {
run: async (args) => {
calls.push(args);
calls.push([...args]);
if (startsWithArgs(args, ['getenv', device.id, 'SIMULATOR_MAINSCREEN_SCALE'])) {
return { exitCode: 0, stdout: '3\n', stderr: '' };
}
Expand Down
38 changes: 33 additions & 5 deletions packages/platform-apple/src/core/__tests__/simctl.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import {
buildSimctlArgs,
buildSimctlArgsForAddress,
buildSimctlArgsForDevice,
readSimctlDevicesByRuntime,
readSimctlDeviceState,
scopeSimctlArgs,
scopeSimctlArgsForDevice,
simulatorAddressFor,
type SimulatorAddress,
} from '../simctl.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';

Expand All @@ -18,10 +20,11 @@ const IOS_SIMULATOR: DeviceInfo = {
target: 'mobile',
};

test('buildSimctlArgs uses --set when simulator set path is provided', () => {
const args = buildSimctlArgs(['list', 'devices', '-j'], {
simulatorSetPath: '/tmp/tenant-a/simulator-set',
});
test('buildSimctlArgsForAddress uses --set when the address names a simulator set', () => {
const args = buildSimctlArgsForAddress(
simulatorAddressFor({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulator-set' }),
['list', 'devices', '-j'],
);
assert.deepEqual(args, [
'simctl',
'--set',
Expand Down Expand Up @@ -85,6 +88,31 @@ test('scopeSimctlArgsForDevice scopes simulators only', () => {
]);
});

test('simulatorAddressFor carries the set of iOS-family simulators only', () => {
const scoped = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-d/simulator-set' };
assert.deepEqual(simulatorAddressFor(scoped), {
udid: 'sim-1',
simulatorSetPath: '/tmp/tenant-d/simulator-set',
});
assert.deepEqual(simulatorAddressFor({ ...scoped, kind: 'device' }), {
udid: 'sim-1',
simulatorSetPath: undefined,
});
assert.deepEqual(simulatorAddressFor({ ...scoped, platform: 'android', kind: 'emulator' }), {
udid: 'sim-1',
simulatorSetPath: undefined,
});
});

function compileTimeSimulatorScopeProof(): void {
// @ts-expect-error A set-scope call states its set; leaving it out does not mean the default set.
void scopeSimctlArgs(['list']);
// @ts-expect-error A simulator address is minted from its DeviceInfo, never written by hand.
const forged: SimulatorAddress = { udid: 'sim-1', simulatorSetPath: undefined };
void forged;
}
void compileTimeSimulatorScopeProof;

const LISTING = JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ import { test } from 'vitest';
import {
createLocalAppleToolProvider,
readApplePlistJson,
resolveAppleToolProvider,
runAppleToolCommand,
runXcrun,
withAppleToolProvider,
} from '../tool-provider.ts';

function compileTimeScopedSimctlProof(): void {
// @ts-expect-error Raw simctl argv cannot reach the provider; scope it in core/simctl.ts.
void resolveAppleToolProvider().simctl.run(['spawn', 'sim-1', 'bridge']);
}
void compileTimeScopedSimctlProof;

test('scoped Apple tool provider handles xcrun execution', async () => {
const calls: Array<[string, string[]]> = [];
const provider = createLocalAppleToolProvider({
Expand Down Expand Up @@ -35,7 +42,7 @@ test('scoped Apple tool provider prefers semantic simctl and devicectl hooks', a
},
simctl: {
run: async (args) => {
calls.push(['simctl', args]);
calls.push(['simctl', [...args]]);
return { exitCode: 0, stdout: 'simctl', stderr: '' };
},
},
Expand Down
50 changes: 36 additions & 14 deletions packages/platform-apple/src/core/simctl.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,55 @@
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
import { type ExecOptions, type ExecResult } from '@agent-device/host-kit/command';
import { resolveIosSimulatorDeviceSetPath } from '@agent-device/kernel/device-isolation';
import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host';
import { runXcrun } from './tool-provider.ts';

type SimctlArgsOptions = {
simulatorSetPath?: string;
/** The set of a simctl call that names no device; `undefined` names the default set on purpose. */
export type SimulatorSetScope = Readonly<{ simulatorSetPath: string | undefined }>;

declare const simulatorAddress: unique symbol;
/** A simulator udid with the set that holds it; minted only from a DeviceInfo. */
export type SimulatorAddress = Readonly<{ udid: string; simulatorSetPath: string | undefined }> & {
readonly [simulatorAddress]: true;
};

/** Arguments that follow the `simctl` tool name, scoped to the simulator set when one is given. */
export function simulatorAddressFor(device: DeviceInfo): SimulatorAddress {
const simulatorSetPath =
isIosFamily(device) && device.kind === 'simulator' ? device.simulatorSetPath : undefined;
return Object.freeze({ udid: device.id, simulatorSetPath }) as SimulatorAddress;
}

/** Arguments that follow the `simctl` tool name for a call that names no device. */
export function scopeSimctlArgs(
args: readonly string[],
options: SimctlArgsOptions = {},
): string[] {
const simulatorSetPath = resolveIosSimulatorDeviceSetPath(options.simulatorSetPath);
if (!simulatorSetPath) return [...args];
return ['--set', simulatorSetPath, ...args];
scope: SimulatorSetScope,
): ScopedSimctlArgs {
const simulatorSetPath = resolveIosSimulatorDeviceSetPath(scope.simulatorSetPath);
const scoped = simulatorSetPath ? ['--set', simulatorSetPath, ...args] : [...args];
return Object.freeze(scoped) as ScopedSimctlArgs;
}

/** Arguments that follow the `simctl` tool name, scoped to the set holding the addressed simulator. */
export function scopeSimctlArgsForAddress(
address: SimulatorAddress,
args: readonly string[],
): ScopedSimctlArgs {
return scopeSimctlArgs(args, { simulatorSetPath: address.simulatorSetPath });
}

/** Arguments that follow the `simctl` tool name, scoped to the simulator set holding the device. */
export function scopeSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): string[] {
if (!isIosFamily(device) || device.kind !== 'simulator') return [...args];
return scopeSimctlArgs(args, { simulatorSetPath: device.simulatorSetPath });
export function scopeSimctlArgsForDevice(
device: DeviceInfo,
args: readonly string[],
): ScopedSimctlArgs {
return scopeSimctlArgsForAddress(simulatorAddressFor(device), args);
}

export function buildSimctlArgs(
export function buildSimctlArgsForAddress(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

scopeSimctlArgs* returns ScopedSimctlArgs (branded), but the adjacent buildSimctlArgsForAddress / buildSimctlArgsForDevice return plain string[] — an unexplained asymmetry between functions whose names differ by one word. The consequence is that the brand lands on the minority host.appleTools.run path, while the dominant build* + runSimctlForDevice / runXcrun path — and the cross-package façade @agent-device/platform-apple/simctl — hand out unbranded string[]. So R79, not the compiler, is what actually enforces the invariant on most call sites. If you brand the build* return (a branded readonly string[] is still assignable to the readonly string[] / string[] sinks those ~20 call sites use, so they compile untouched), the guarantee spans both execution paths and the ArrayExpression + '--set' clauses of R79 become deletable — its own kill criterion says exactly this. Even if you keep the current scope deliberately, this naming asymmetry should at least be explained in a comment.

address: SimulatorAddress,
args: readonly string[],
options: SimctlArgsOptions = {},
): string[] {
return ['simctl', ...scopeSimctlArgs(args, options)];
return ['simctl', ...scopeSimctlArgsForAddress(address, args)];
}

export function buildSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): string[] {
Expand Down
5 changes: 5 additions & 0 deletions packages/platform-apple/src/core/tool-provider-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AppsFilter } from '@agent-device/contracts/device';
import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host';
import { type ExecOptions, type ExecResult } from '@agent-device/host-kit/command';
import type { IosAppInfo } from './app-info.ts';

Expand All @@ -19,6 +20,10 @@ export type AppleXcrunToolProvider = {
run: AppleToolSubcommandExecutor;
};

export type AppleSimctlToolProvider = {
run: (args: ScopedSimctlArgs, options?: ExecOptions) => Promise<ExecResult>;
};

export type AppleMacOsHelperProvider = {
run: AppleToolSubcommandExecutor;
};
Expand Down
11 changes: 7 additions & 4 deletions packages/platform-apple/src/core/tool-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import {
type ExecOptions,
type ExecResult,
} from '@agent-device/host-kit/command';
import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host';
import { createScopedProvider } from '@agent-device/kernel/scoped-provider';
import { createLocalAppleMacOsHostProvider } from '../os/macos/host-provider.ts';
import type {
AppleMacOsHelperProvider,
AppleMacOsHostProvider,
ApplePlistProvider,
AppleSimctlToolProvider,
AppleToolAvailabilityChecker,
AppleToolCommandExecutor,
AppleToolSubcommandExecutor,
AppleXcrunToolProvider,
} from './tool-provider-types.ts';

Expand All @@ -29,7 +30,7 @@ export type {

export type AppleToolProvider = {
runCommand: AppleToolCommandExecutor;
simctl: AppleXcrunToolProvider;
simctl: AppleSimctlToolProvider;
devicectl: AppleXcrunToolProvider;
macosHelper?: AppleMacOsHelperProvider;
macosHost?: AppleMacOsHostProvider;
Expand Down Expand Up @@ -118,7 +119,7 @@ export async function runXcrun(args: string[], options?: ExecOptions): Promise<E
const provider = resolveAppleToolProvider();
const [tool, ...toolArgs] = args;
if (tool === 'simctl') {
return await provider.simctl.run(toolArgs, options);
return await provider.simctl.run(toolArgs as unknown as ScopedSimctlArgs, options);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This as unknown as ScopedSimctlArgs is the only double-cast the PR adds, and it sits at the exact spot that undercuts the thesis. The branded port has a single production implementation, and it hands the argv straight to runXcrun(args: string[]), which then reforges the brand right here. So the type error exists on the request side and is a no-op on the execution side: the brand is erased one hop below appleTools.run and re-minted unconditionally. That is also why ARGV_OWNERS has to include core/tool-provider.ts — the gate is whitelisting the forgery this placement forces. runXcrun is exported and unbranded, so every runXcrun(buildSimctlArgsFor*(...)) site is a legitimate bypass protected only by R79's ArrayExpression clause. Fix: let the tool-prefixed argv that buildSimctlArgsFor* already mints carry the brand, and split runXcrun's simctl branch to require it — that deletes this cast, deletes tool-provider.ts's ARGV_OWNERS exemption, and turns the ArrayExpression half of R79 into a tsc error.

}
if (tool === 'devicectl') {
return await provider.devicectl.run(toolArgs, options);
Expand Down Expand Up @@ -149,7 +150,9 @@ function coerceRunCommand(run: AppleToolCommandExecutor): AppleToolCommandExecut
return async (cmd, args, options) => coerceExecResult(await run(cmd, args, options));
}

function coerceRun(run: AppleToolSubcommandExecutor): AppleToolSubcommandExecutor {
function coerceRun<Args>(
run: (args: Args, options?: ExecOptions) => Promise<ExecResult>,
): (args: Args, options?: ExecOptions) => Promise<ExecResult> {
return async (args, options) => coerceExecResult(await run(args, options));
}

Expand Down
2 changes: 1 addition & 1 deletion packages/platform-apple/src/deployment/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ async function pushAppleNotification(
*/
async function runAppleTool(
host: PlatformRuntimeHost,
request: Omit<AppleToolRequest, 'allowFailure'>,
request: AppleToolRequest,
signal: AbortSignal,
message: string,
options?: Readonly<{ tolerate?: (result: HostCommandResult) => boolean }>,
Expand Down
15 changes: 15 additions & 0 deletions packages/platform-apple/src/logs/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ test('simulator doctor routes the exact simctl probe through appleTools', async
);
});

test('simulator doctor probes simctl without a set, even for a scoped-set simulator', async () => {
const appleToolRun = vi.fn(async () => ({ stdout: 'simctl help', stderr: '', exitCode: 0 }));
const fixture = hostFixture({ appleToolRun });

await doctorAppleAppLogs(
fixture.host,
appleDevice({ simulatorSetPath: '/tmp/scoped-set' }),
'com.example.app',
);
expect(appleToolRun).toHaveBeenCalledWith(
{ tool: 'simctl', args: ['help'], allowFailure: true },
undefined,
);
});

test('CoreDevice doctor routes version discovery through appleTools', async () => {
const appleToolRun = vi.fn(async (request) => ({
stdout: request.args.includes('--help')
Expand Down
3 changes: 2 additions & 1 deletion packages/platform-apple/src/logs/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
import type { AppLogRuntimeHost } from '@agent-device/contracts/app-log-runtime';
import { appLogCommandSucceeded, bestEffortAppLogCheck } from '@agent-device/capture-kit';
import { scopeSimctlArgs } from '../core/simctl.ts';
import { APPLE_XCTEST_LOGS_HINT, backendForAppleDevice } from './backend.ts';
import {
checkCoreDeviceConsoleCaptureSupport,
Expand All @@ -27,7 +28,7 @@ export async function doctorAppleAppLogs(
await host.appleTools.run(
{
tool: 'simctl',
args: ['help'],
args: scopeSimctlArgs(['help'], { simulatorSetPath: undefined }),
allowFailure: true,
},
signal,
Expand Down
17 changes: 11 additions & 6 deletions packages/platform-apple/src/logs/log-predicate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@ test('Apple app-log predicate covers bundle and executable provenance', () => {

test('Apple app-log arguments preserve simulator-set and CoreDevice launch semantics', () => {
assert.deepEqual(
buildIosSimulatorLogStreamArgs({
deviceId: 'sim-1',
appBundleId: 'com.example.app',
executableName: 'ExampleExec',
simulatorSetPath: '/tmp/tenant-a/simulators',
}),
buildIosSimulatorLogStreamArgs(
{
platform: 'apple',
id: 'sim-1',
name: 'iPhone 17',
kind: 'simulator',
target: 'mobile',
simulatorSetPath: '/tmp/tenant-a/simulators',
},
{ appBundleId: 'com.example.app', executableName: 'ExampleExec' },
),
[
'simctl',
'--set',
Expand Down
Loading
Loading