Skip to content

fix(apple): refuse fold on a scoped simulator set at runtime admission - #2887

Merged
thymikee merged 5 commits into
mainfrom
fix/fold-scoped-simulator-set-refusal
Sep 24, 2026
Merged

thymikee merged 5 commits into
mainfrom
fix/fold-scoped-simulator-set-refusal

Conversation

@thymikee

@thymikee thymikee commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

fold on an iPhone Duo scoped with --ios-simulator-device-set reached HID dispatch, then died at CoreDevice's display inventory: devicectl device info displays and devicectl device motion hinge-angle accept only --device and resolve a scoped simulator as not found, so ADR 0025's post-dispatch readback can never verify the pose.

Refuse it instead at the owning setFoldPose fact whenever device.simulatorSetPath is present, with a new typed reason unsupported-device-scope added to RuntimeOperationUnavailability and its parser. The routed fold response derives its code/message/hint/reason from that same fact, so capability and response cannot diverge. Existing kind/OS refusals and default-set behavior are unchanged; no display probe, simctl spawn, or helper build runs.

$ agent-device open --platform ios --ios-simulator-device-set <set> --udid <duid>
$ agent-device fold half-open --json
{ "success": false, "error": { "code": "UNSUPPORTED_OPERATION",
  "hint": "fold cannot resolve a simulator scoped to the set at \"<set>\" ... Run fold without --ios-simulator-device-set.",
  "details": { "reason": "unsupported-device-scope" } } }

9 files (+167; ~30 production lines, rest tests/help/docs). Closes #2871

Validation

Commits 693fd7ee3 (fix) + 6643e1e13 (review follow-up), rebased on 72862063c. pnpm check:affected --run: all runnable checks pass. pnpm typecheck clean.

Focused: src/daemon/__tests__/fold-runtime.test.ts adds a scoped-DeviceInfo route case (resolveBoundFoldRuntime → wire error carries details.reason: unsupported-device-scope + the set name, bindDevice/setFoldPose never called); new foldable/runtime.test.ts (fact, capability, no pose op bound, narrowed use carries the typed reason, and the existing kind/OS refusals are not outranked) plus an owner-level scoped-fact case in runtime.test.ts. Planted-failure check: the scoped tests fail without the fact guard.

Live (Xcode 27.1, throwaway iPhone Duo in a temp set): scoped fold half-open --json returned UNSUPPORTED_OPERATION / unsupported-device-scope naming the set in ~1s (a real fold is 10-16s). The request log recorded only request_start/request_failed (no apple_fold_pose_dispatched, helper build, or HID), and devicectl device motion hinge-angle on the scoped sim returns "The specified device was not found" — corroborating the un-verifiable pose. Throwaway sim + set deleted, session closed.

@github-actions

github-actions Bot commented Sep 24, 2026 •

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

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.80 MB 4.80 MB +960 B
Package (unpacked) 4.80 MB 4.80 MB +960 B
Package (download) 1.44 MB 1.44 MB +362 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.5 ms 26.2 ms -0.3 ms
CLI --help 79.1 ms 77.8 ms -1.3 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 4b87e09. The code looks good: the scoped-set refusal sits at the setFoldPose fact the issue named, before any display probe or HID send, and the new unsupported-device-scope reason reaches the response. I found nothing that needs fixing.

One question before the label: the fold unavailableResponse in src/daemon/fold-runtime.ts copies the shared default in admitRuntimeOperations and only adds details.reason. Is the fold override the smallest fix here, or should the shared default carry fact.reason for every route? That second option changes the wire shape of every runtime-admitted command, so it would be its own PR. A short answer on which way you want to go is enough.

Not blocking: the test at packages/platform-apple/src/foldable/runtime.test.ts:87 checks binding narrowing, not the daemon route, so its "not called" assertion holds trivially. A rename, or a scoped-DeviceInfo case in src/daemon/__tests__/fold-runtime.test.ts, would fix that. The devicectl / ADR 0025 reason is also repeated in four places.

I did not run the tests or repeat the live scoped iPhone Duo run; that reported run is the evidence that a scoped session carries simulatorSetPath on the real route.

The seven checks that were not passing were still running at review time, with no failures yet. Analyze (java-kotlin) does not touch this TypeScript-only diff.

@thymikee
thymikee force-pushed the fix/fold-scoped-simulator-set-refusal branch from 4b87e09 to 6643e1e Compare September 24, 2026 11:50
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto latest main (now includes #2858's fold-helper cache; only the commands.md bullet collided and is resolved). Two follow-up commits on top of the rebased fix.

A vs B (fold unavailableResponse vs shared admitRuntimeOperations): keeping the per-route override here. The issue scoped the typed reason to fold; making the shared default carry fact.reason changes the wire shape of every runtime-admitted command (conformance table, client error typings, any snapshot), which deserves its own PR and review. I agree the shared default is the better end state, and I'll take it as a follow-up that also folds in the shared refusal builder so this override stops copying the message.

Test at foldable/runtime.test.ts:87: fair — that assertion held trivially. Added a scoped-DeviceInfo case in src/daemon/__tests__/fold-runtime.test.ts that drives resolveBoundFoldRuntime with the real scope fact and asserts the wire error carries details.reason: unsupported-device-scope + the set name, with bindDevice and setFoldPose never called (the real before-dispatch proof). The colocated test is renamed to what it actually checks (the narrowed use carries the typed fact reason + hint) and the trivial call assertion is gone.

Repeated rationale: collapsed the code-side copy to the single owning foldScopeUnavailable doc comment (appleFoldFact now just links it). The remaining three are distinct required surfaces: the runtime hint (product message shown to the user), agent-device help foldable, and commands.md — the repo's help/docs each own user-facing wording, so I left them.

@thymikee

Copy link
Copy Markdown
Member Author

Follow-up 1f6e8c17f (safe, behavior-identical) for the reuse question:

The fold unavailableResponse was restating the exact UNSUPPORTED_OPERATION + "<command> is not supported on this device" + hint that admitRuntimeOperations's default already builds, just to add details.reason. That refusal wording is inlined across ~10 daemon routes with no generic-route builder (the session seam has unavailableRuntimeOperationResponse, but its => DaemonResponse | undefined shape doesn't fit the generic unavailableResponse contract). So I owned the generic-route refusal once as unsupportedOperationResponse(command, unavailable, details?) in runtime-admission.ts:

  • the admission default calls it → byte-identical output for every other route (still no details),
  • fold calls it with { reason } → one line instead of a hand-built error.

No route's wire shape changes; check:affected --run, tsc, lint pass. The bigger dedupe — folding all ~10 sites and/or making the default carry fact.reason everywhere (which deletes this override entirely) — stays a separate PR, since it changes the refusal contract of every command and needs the conformance table + exact-match tests updated together.

@thymikee

Copy link
Copy Markdown
Member Author

Closing the one remaining non-blocking nit (repeated rationale) in 2976bab3d: the terse fold command description no longer restates the CoreDevice-scope "why" — it now states only the refusal, unsupported-device-scope, and the fix. The full rationale stays on the surfaces a user opens to read more (agent-device help foldable, commands.md) plus the runtime hint and the single owning foldScopeUnavailable comment. Docs-only string change; help/doc gates + check:affected --run green.

No open review threads or new comments, and CI is green on the head.

@thymikee

Copy link
Copy Markdown
Member Author

This follows up on the earlier review (4b87e09, evidence-pending). At 2976bab, the fold refusal itself looks right; one duplication issue remains.

unsupportedOperationResponse(command, unavailable, details?) in https://github.com/callstack/agent-device/blob/2976bab/src/daemon/runtime-admission.ts#L51 copies unavailableRuntimeOperationResponse(command, fact) in src/daemon/session-runtime-admission.ts:22: same UNSUPPORTED_OPERATION code, same ${command} is not supported on this device template, same hint mapping. The existing helper always attaches details: { reason: fact.reason }, which is exactly what the fold call site passes through the new helper's details parameter, so the fold response is byte-identical to unavailableRuntimeOperationResponse('fold', unavailable). The docstring says the wording is owned here, but a second owner of that wording already exists, and the two can drift apart over time. Should fold-runtime.ts instead call unavailableRuntimeOperationResponse('fold', unavailable), the way screenshot-runtime-binding.ts:118 already does for its own unavailable fact? One shared builder should own this response shape, not two.

No live scoped-simulator fold run ("iPhone Duo" with --ios-simulator-device-set) was done for this review. The refusal fires from DeviceInfo.simulatorSetPath before any devicectl or HID call, and that field is already populated and read by several existing, unrelated Apple simulator paths (simctl scoping, runner device-set, snapshot-target, session-observation, log-predicate), so the premise looks well established rather than newly asserted here. This review read the source and tests and relied on the author's stated tsc/lint/check:affected --run results for this delta; the test suite was not run locally.

The diff only touches packages/contracts/src/platform-runtime.ts, packages/platform-apple/src/foldable/runtime.ts (plus tests), src/daemon/fold-runtime.ts, src/daemon/runtime-admission.ts, and docs/help strings, with no Android file changed. The failing Android job (job-107629229622.log) fails at sdkmanager unpacking the Android Emulator package ("Error on ZipFile unknown archive") during infra setup, before any test runs, so that failure is unrelated to this change. Coverage was still running at review time. The next step is to resolve the duplicate response helper.

@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. The Apple-side fix is right and I'd take it as-is: refusal in exactly one owning fact, precedence as ordered guards (kind -> leaf -> scope) pinned by a test that scope does not outrank kind/OS, typed reason end-to-end with a real parser round-trip (narrowDeviceBinding -> requireRuntimeFact -> normalizeRuntimeFact), and no display probe / helper build / HID on the refused path. simulatorSetPath truthiness matches the kernel's own scoped-set convention, and unsupported-device-scope is handled by both consumers with no silent fall-through.

The only thing I would not merge as-is is the daemon half: this PR extracts a shared refusal builder while a byte-identical sibling that already carries the reason stays live and serves more routes — so the UNSUPPORTED_OPERATION wire shape is still seam-dependent. Two inline (the second mostly dissolves once the first is taken).

Two NITs (not inline):

  • simulator-hid.test.ts still asserts a scoped sendSimulatorFoldPose path that production can no longer reach — setAppleFoldPose is only bound through whenAdmitted, which now refuses a scoped device before dispatch. The test is still a valid statement about runSimctlForDevice scoping, but a reader who lands there first concludes scoped fold is partially supported. Retire it or add a line naming the owning fact that refuses the scope, so the layer's capability and the operation's admission are cross-linked rather than silently contradicting.
  • fold-runtime.test.ts's scoped case hard-codes a paraphrase of the production hint sentence ("fold cannot resolve a simulator scoped to the set at ..."). Assert propagation instead of echoing wording (hint: expect.stringContaining('/tmp/scoped-set'), or a sentinel the route could never produce) so a real hint edit doesn't invite someone to "fix" the fake.

Comment thread src/daemon/runtime-admission.ts
Comment thread src/daemon/fold-runtime.ts Outdated
fold on an iPhone Duo inside --ios-simulator-device-set failed after the
HID send because CoreDevice's display inventory and hinge-angle readback
resolve a scoped simulator as not found, so ADR 0025's post-dispatch
verification is impossible. Refuse it instead at the owning setFoldPose
fact when device.simulatorSetPath is present, with the typed
unsupported-device-scope reason; the routed response derives the same
reason and a hint naming the set. The default set and the existing
kind/OS refusals are unchanged.
Move the zero-side-effect proof onto the real fold route: a scoped
setFoldPose fact is refused at admission, so the wire error carries the
typed unsupported-device-scope reason and the set name and the device is
never bound. Refocus the colocated narrowDeviceBinding test to what it
proves, and collapse the repeated CoreDevice-scope rationale to one
owning comment.
The fold override restated the same UNSUPPORTED_OPERATION + '<command> is
not supported on this device' + hint the admission default already built,
only to add details.reason. Own that refusal once in admitRuntimeOperations
and reuse it: the default is byte-identical, fold passes its typed reason
as details, and no other route's wire shape changes.
The terse fold command description restated the same CoreDevice-scope
rationale the deep help topic and commands.md carry. The description now
states only the refusal, its typed reason, and the fix; the why lives on
the surfaces a user opens to read more.
…ime seams build

The generic route and the request-scoped session handlers each owned a
byte-identical UNSUPPORTED_OPERATION refusal, but only the session seam
attached details.reason, so the shared default emitted the same sentence
with a different wire shape and fold had to re-add the reason through a
custom unavailableResponse. unsupportedOperationResponse now always attaches
details: { reason } and unavailableRuntimeOperationResponse delegates to it,
so one builder owns sentence + reason + hint for both seams. fold drops its
override and its second 'fold' mention; conformance asserts the single shape.

Nits: cross-link the scoped HID transport test to the owning fact that
refuses the scope, and assert hint propagation with a sentinel instead of
echoing production wording.
@thymikee
thymikee force-pushed the fix/fold-scoped-simulator-set-refusal branch from 2976bab to f9a85fe Compare September 24, 2026 12:56
@thymikee

Copy link
Copy Markdown
Member Author

Addressed the structural pass in f9a85feb0 (rebased on latest main, 0 behind). check:affected --run is green (4542 tests) plus command-docs.

Inline (both) — one refusal builder now owns sentence + typed details.reason + hint for both seams:

  • unsupportedOperationResponse always attaches details: { reason }; the details? bag is dropped.
  • unavailableRuntimeOperationResponse (session seam, live on perf / snapshot / deploy / push / install_from_source) is now a one-line availability check that delegates to it — no second copy of the sentence or shape.
  • fold's unavailableResponse override is deleted, so command: 'fold' is the single mention.
  • runtime-binding-conformance.ts asserts the single shape, so routes inherit rather than re-type it.

NITs:

  • simulator-hid.test.ts: the scoped case is now framed as transport-only and cross-linked to appleFoldFact, which refuses unsupported-device-scope at admission before the dispatch — so it reads as a runSimctlForDevice scoping statement, not partial scoped-fold support.
  • fold-runtime.test.ts: the scoped case feeds a sentinel hint and asserts propagation instead of echoing production's hint wording.

The Android job still fails inside sdkmanager unpacking the emulator package during setup, before any test — same pre-existing infra failure, unrelated to this diff.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at f9a85fe. The earlier point is fixed: the fold refusal details now go through the shared daemon error-response builder, and I found nothing new to fix. All checks pass, and there are no conflicts.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee
thymikee merged commit b916605 into main Sep 24, 2026
20 checks passed
@thymikee
thymikee deleted the fix/fold-scoped-simulator-set-refusal branch September 24, 2026 13:51
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.

fold: a scoped simulator set fails at the devicectl display inventory before HID dispatch

1 participant