Skip to content

refactor(ios): make unscoped simctl argv a type error at the Apple tool port - #2898

Merged
thymikee merged 3 commits into
mainfrom
refactor/2878-simctl-scope
Sep 24, 2026
Merged

thymikee merged 3 commits into
mainfrom
refactor/2878-simctl-scope

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

Makes an unscoped simctl argv a type error at the Apple tool port, closing the gap behind the
--set-drop bugs fixed by #2818 and #2824.

  • AppleToolRequest brands simctl argv as ScopedSimctlArgs; raw argv no longer compiles on
    host.appleTools.run.
  • core/simctl.ts adds a SimulatorAddress brand (simulatorAddressFor(device)) and requires an
    explicit set scope on scopeSimctlArgs; buildSimctlArgs is replaced by address/device builders.
  • AppleToolProvider.simctl.run takes the branded argv.
  • log-predicate.ts and snapshot-source/host.ts take the set from the device's SimulatorAddress.
  • Layering rule R79 apple-simulator-scope fails CI on a hand-built simctl array, a forged brand
    cast, a stray --set literal, or scopeSimctlArgs outside its three owners.

No argv output changes. 36 files touched, +609/-136 gross, net +22 production lines.

Closes #2878

Validation

Tested at 2cee7ed3eb (base origin/main 0749551b71).

  • pnpm typecheck, check:layering (zero R79 violations), check:fallow,
    check:production-exports: pass.
  • check:affected --run: run 1 had one unrelated timeout (screenshot-density.test.ts, also
    reproduces pre-branch under equal load); run 2 passed, 457 files / 3283 tests.
  • Targeted unit vitest: 233 tests pass.
  • Live: default-set open/snapshot/logs proven end to end, argv unchanged. Scoped-set
    bridge-spawn and log-stream argv proven live with --set, bridge answered from inside the set.
    A full clean scoped snapshot wasn't reached: an existing, unrelated defect deletes the scoped
    simulator during runner fallback under high host load (evidence kept for follow-up).
  • Passed a 3-lens adversarial review and a final ship review before this stage.
  • Remaining risk: the two issues above pre-exist this branch, out of scope for Enforce simulator-set scope for simctl: device scope for any udid, set scope only for calls that name no device #2878.

Brand scoped simctl argv in contracts (ScopedSimctlArgs) so the Apple
tool port and the provider simctl executor refuse raw argv. Mint a
SimulatorAddress from a DeviceInfo, make the set-scope builder take an
explicit scope, and delete buildSimctlArgs.

Move the log-stream and AX bridge spawns off the set-scope builder:
the log stream takes the set from its DeviceInfo, and the snapshot
target carries a SimulatorAddress in place of udid and simulatorSetPath.
Doctor keeps set scope for 'simctl help'. No argv changes.

Refs #2878
Refuse hand-built 'simctl' argv arrays, a '--set' literal, casts that
forge ScopedSimctlArgs or SimulatorAddress, and any reference to the
set-scope builder outside its owners (core/simctl.ts,
simulator-inventory.ts, logs/doctor.ts).

Refs #2878
@github-actions

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.80 MB 4.80 MB -1.4 kB
Package (unpacked) 4.80 MB 4.80 MB -1.4 kB
Package (download) 1.44 MB 1.44 MB -799 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 21.1 ms 21.3 ms +0.2 ms
CLI --help 60.1 ms 60.9 ms +0.8 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 2cee7ed. The code looks good: simctl argv on the Apple tool port now needs a ScopedSimctlArgs brand, and the migrated call sites keep the same argv with and without a set.

Two questions, not blocking. Does the appleDevice fixture in doctor.test resolve to a simulator? If not, the new doctor test should also check device scope. And runXcrun(args: string[]) in https://github.com/callstack/agent-device/blob/2cee7ed/packages/platform-apple/src/core/tool-provider.ts#L118 still casts raw argv to ScopedSimctlArgs, so a non-literal first element such as const t = 'simctl' passes both the type check and R79. A later runXcrun overload that takes ScopedSimctlArgs would close that, once the brand moves into a leaf module.

Not blocking: the PR body says "+22 production lines", but the change is +259/-103 with the 128-line R79 rule, and it leaves out the two manual layering mutations that #2878 asks to record.

I did not run typecheck or the layering check. The scoped-set live run in the PR body did not reach a clean scoped snapshot, so scoped argv equivalence rests on unit tests. Smoke, Integration, Repo Guards and Coverage were still running at review time; this diff touches their routes, so a failure there would likely be related.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026

@thymikee thymikee left a comment

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.

Thermo-nuclear structural pass on a 36-file type-branding refactor. Genuinely good parts, said plainly: Object.freeze + mint-once simulatorAddressFor (called once, threaded rather than recomputed), buildIosSimulatorLogStreamArgs taking DeviceInfo instead of a four-field bag with a hand-passed set (deletes a real bypass), removing the in-place push on a now-frozen argv, Omit<AppleToolRequest, 'allowFailure'> -> AppleToolRequest restoring the discriminated union, the @ts-expect-error compile-time proofs matching the existing compileTimeNetworkProjectionProof idiom, and NET +22 production lines to make a class of --set-drop bug impossible. Cheap. No file crossed 1k.

The one question this PR lives or dies by is whether the brand earns its ceremony as placed. My read: as currently placed it mostly does not — the guarantee sits on one port whose own implementation discards it, so the AST gate (not tsc) carries most of the load. Three inline below. Fix #1; I'd take #2 and #3 as the follow-ups that let you shrink R79.

Smaller notes (not inline):

  • logs/doctor.ts writes scopeSimctlArgs(['help'], { simulatorSetPath: undefined }) at a site where device is in scope, and was admitted to SET_SCOPE_OWNERS solely to be allowed to spell that undefined — the same "blank the set next to a device" footgun the PR set out to make loud, now permitted by path allowlist. Give the no-device case a named mint (DEFAULT_SIMULATOR_SET_SCOPE / simctlAvailabilityProbeArgs()) so no owner spells undefined and the allowlist collapses toward core/simctl.ts.
  • The policy header claims it catches a call "scoped through the set-scope builder with the set written out as undefined" — no such check exists; that line is legal only because of the path exemption. Fix the comment or add the check; a header that overclaims coverage is the false confidence the rule was meant to remove.
  • Un-export what has no consumer: scopeSimctlArgsForAddress and SimulatorSetScope have no importers outside core/simctl.ts yet are exported — and scopeSimctlArgs's exportability is exactly what R79's identifier clause must police. Also simulator-inventory.ts imports ScopedSimctlArgs only to re-annotate an inferred return; drop it so the brand stops leaking into forwarder modules.
  • scripts/ is exempt from the rule and scripts/ios-snapshot-benchmark/lifecycle.ts still hand-builds runXcrun(['simctl', <verb>, udid]) from a bare udid — the exact shape the gate targets; the exemption is pinned by the rule's own test.

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.

}

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.

) {
report(node, ARGV_MESSAGE);
}
if (file.startsWith(APPLE_SRC) && file !== SIMCTL_OWNER && isStringLiteral(node, '--set')) {

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.

Two fragility problems with how the gate encodes the boundary. (a) This line rejects the string literal '--set' anywhere under the apple src tree except the simctl owner — an unrelated xcrun flag, an args.includes('--set') diagnostic, or a user-facing message would fail CI with "use scopeSimctlArgsForDevice"; and it is trivially evaded (a `--set` template literal, '--' + 'set', a const FLAG). That is a repo-wide string ban, not "a simctl argv built by hand". AGENTS.md is explicit about keying on typed structure, never text — this clause is precisely the text-sniffing kind. (b) The owner model is hardcoded path sets (ARGV_OWNERS, SET_SCOPE_OWNERS); every new device-less simctl call has to edit this allowlist, so the boundary accretes by path rather than by construction. Both mostly dissolve if the type from the finding above carries the invariant — the strongest clause should be "a simctl argv not produced by the branded builder", asserted on the call node, not a global '--set' match. Worth narrowing now even if you keep the gate.

@thymikee
thymikee merged commit a363a02 into main Sep 24, 2026
18 checks passed
@thymikee
thymikee deleted the refactor/2878-simctl-scope branch September 24, 2026 13:52
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-24 13:52 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enforce simulator-set scope for simctl: device scope for any udid, set scope only for calls that name no device

1 participant