Skip to content

fix(errors): typed reasons for the session-app and session-or-selector refusals - #3194

Merged
thymikee merged 9 commits into
mainfrom
fix/typed-reasons-session-refusals
Oct 4, 2026
Merged

thymikee merged 9 commits into
mainfrom
fix/typed-reasons-session-refusals

Conversation

@thymikee

@thymikee thymikee commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Summary

Two INVALID_ARGS refusals had no details.reason, so a client could only recognise them by matching message text. Both now answer with a reason declared once at the owning kernel type, plus the dispatched: 'no' disclosure that makes a retry provably safe. Messages and hints are unchanged.

error.details = { reason: 'session_app_required', dispatched: 'no' }  // fix: open <app>, or name its app id
error.details = { reason: 'session_or_device_selector_required', dispatched: 'no' }  // fix: --platform ios

session_app_required covers every appless per-app setting refusal — settings permission, on/off settings location (iOS), and clear-app-state — at the daemon's pre-check and at the Apple, Android, and HarmonyOS owners. session_or_device_selector_required is the daemon's refusal raised before routing.

The e2e cleanup harness keys its appless-skip on the reason instead of an exact string. Vocabulary follows webdriver_route_unsupported: declared once, documented, and registered as a daemon.route row in the dispatch-disclosure fixture. 22 files, +380/-39; production: kernel errors.ts, Apple/Android/HarmonyOS settings, the daemon settings pre-check, session-device-resolution. Relates to #3179.

Closes #3181

Validation

pnpm check:affected --run passes at 14dd8b7 (base origin/main): unit lanes, fallow audits, daemon wire-compat (protocol unchanged), command-docs. typecheck and lint clean. CI on this head: pending.

Live run of the changed route (booted iPhone 18 Pro, no session, this checkout's build):

$ node bin/agent-device.mjs settings permission reset microphone --platform ios --device "iPhone 18 Pro" --json
{ "success": false,
  "error": { "code": "INVALID_ARGS",
             "message": "permission setting requires an active app in session",
             "details": { "reason": "session_app_required", "dispatched": "no" } } }

Plus: kernel round-trip through normalizeError/throwDaemonError; a router test on the returned-error path for the selector refusal (#1391 pins the same carry for a thrown error); owner-level assertions at all four settings call sites.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 15 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread website/docs/docs/commands.md Outdated
Comment thread packages/platform-apple/src/core/app-settings.ts
Comment thread website/docs/docs/commands.md Outdated
Comment thread test/integration/ios-simulator-e2e-cleanup.test.ts Outdated
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-04 08:01 UTC

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.97 MB 4.97 MB +440 B
Package (unpacked) 4.97 MB 4.97 MB +440 B
Package (download) 1.49 MB 1.49 MB +101 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.1 ms 26.7 ms +0.5 ms
CLI --help 81.1 ms 82.9 ms +1.8 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 11 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread website/docs/docs/commands.md Outdated
@thymikee

thymikee commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

Thanks for the PR. The code looks right at e9c926f, and all 21 checks pass. Nothing blocks the code, but one piece of evidence is still missing: I did not run the live iOS e2e cleanup harness, so I have not seen a real appless settings permission reset microphone return details.reason. I infer it from the kernel round-trip tests and from live-deep-link-destination.ts, which already reads details.reason. Please run that route once and share the CLI JSON that shows details.reason: session_app_required and dispatched: "no". I also did not run the tests myself.

Not blocking, and you can take or leave these: (1) the e2e cleanup test in ios-simulator-e2e-cleanup.test.ts builds {reason, dispatched:'no'} by hand, so build it from sessionAppRequiredDetails() and optionally add one settings-permission-without-app case to the handler test; (2) the new reason and dispatched fields are pasted into both platform app-error.ts test utils, and src/__tests__/test-utils/app-error.ts has a third copy without them, so one shared util would be tidier, and the assertThrowsAppError removal is unrelated to this PR; (3) errors.ts points at contracts/fixtures/dispatch-disclosure.json, but the new requireSessionOrExplicitSelector producer has no daemon.refusal row there, and PUBLISHED_ERROR_REASONS holds only 2 of the published reasons, so the name oversells its scope; (4) the permission bullet at commands.md says "none named for the request", yet settings permission cannot name an app until #3179 lands, so "bound to the session" fits better; (5) the PR body still shows 15 files, +307/-36, validation on 4f12095 and "CI pending", and it omits the HarmonyOS and daemon clear-app-state sites.

Would one refusalDetails(reason) factory be simpler than sessionAppRequiredDetails and sessionOrDeviceSelectorRequiredDetails? Could the daemon response site reuse refusedBeforeDispatch? The rest of the design looks close to minimal.

Once the live run output is in, merge after you triage the notes above.

…lector refusals

Both refusals answer before anything reaches a device, so a driver must branch on
details.reason plus dispatched:no rather than matching the message. Declares the two
reason values once at the owning kernel type, with the construction helpers and their
normalize/wire coverage.

Refs #3181
…refusals

Both owners already refused an appless `settings permission` and an appless iOS
`settings location`; each site now states the shared reason instead of leaving prose as
the only signal. Both platforms use one reason because the recovery is the same
whichever setting asked. The Apple and Android error assertions gained reason and
dispatched so the behavior is keyed on details, not on the message.

Refs #3181
…e response

The daemon refuses a device-bound command before it resolves a target, which is exactly
the case where dispatched:no makes a retry safe. The refusal now carries the published
reason through errorResponse, and the request-router test proves the reason survives
finalizeDaemonResponse to the caller on the returned-error path rather than only at its
construction site.

Refs #3181
…its message

The mic-permission cleanup skip matched an exact error string, so a reworded refusal
would silently resume retrying a command that can never succeed. It now keys on
details.reason, and the suite asserts a byte-identical message with no reason still
exhausts its retries — the prose alone must never activate the guard.

Documents both published reasons with their recovery in the user docs.

Refs #3181
…it the details check

The affected audit surfaced both test helpers as dead exports and flagged the cyclomatic
growth from the new reason/dispatched branches. Removing the sync assertion, which no
test imported, and moving the machine-readable checks into their own function keeps each
assertion at the complexity it had before.

Refs #3181
…al family

Every per-app settings refusal must be identifiable by reason, not only the ones first
converted: the daemon's clear-app-state pre-check and the Apple, Android, and HarmonyOS
owner throws now answer with session_app_required and dispatched:no as well. The
clear-app-state doc bullet states the refusal.

Docs corrections: session_app_required is raised by the settings handlers after device
routing, so the dispatched vocabulary entry describes only the session-or-selector
refusal as pre-routing; and the permission bullet no longer claims Android on/off
location or macOS system-level permissions need a session app, which they do not.

The e2e skip's success case now pairs the reason with different message wording, so a
guard that also required the legacy string would fail: only the reason triggers the skip.
…on_app_required

A setting that takes an app id, like clear-app-state, recovers by naming the app on the
request itself; scripts need not open the app just to satisfy that refusal.
PRE_DISPATCH_REFUSAL_REASONS states the kernel-owned subset whose refusals always
disclose dispatched:no, instead of a name that implies it holds every published reason
(the WebDriver and other producer reasons live beside their own disclosures). The map's
own doc names the versioned docs as the wire vocabulary and the disclosure table as the
producer registry.

The session-or-selector refusal gains its daemon.route row in
contracts/fixtures/dispatch-disclosure.json, driven through the real router with an
unopened session. That command declares no recording effect, so the dispatched:no the row
asserts can only come from the producer rather than the read-only seam.

The e2e cleanup fixture builds its refusal from sessionAppRequiredDetails() instead of
re-typing the details shape, and the permission bullet stops implying a request can name
an app for settings permission while #3179 is open.
@thymikee
thymikee force-pushed the fix/typed-reasons-session-refusals branch from e9c926f to 14dd8b7 Compare October 4, 2026 06:36
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Live run (the missing evidence)

Rebased onto main, then from this checkout (pnpm build, booted iPhone 18 Pro, no session, no app):

$ node bin/agent-device.mjs settings permission reset microphone --platform ios --device "iPhone 18 Pro" --json
{
  "success": false,
  "error": {
    "code": "INVALID_ARGS",
    "message": "permission setting requires an active app in session",
    "hint": "Check command arguments and run --help for usage examples.",
    "diagnosticId": "mutfjxp0-f45edb81",
    "details": { "reason": "session_app_required", "dispatched": "no" }
  }
}

That is the real route: daemon handler → bound setSetting → Apple owner throw → normalizeError, with the reason and disclosure intact on the wire.

Notes triaged

Fixed on 14dd8b7:

  • (1) the cleanup fixture builds its refusal from sessionAppRequiredDetails(). The optional handler case I did take a different route: making snapshot-runtime-fixture's setSetting throw means restating each owner's differing app-scoping (iOS location needs an app, Android location is global, macOS permission is system-level) in a fixture shared by 16 suites — that reconstructs the owner's rules rather than delegating to them, and the live run above covers that seam honestly instead.
  • (3a) the refusal now has a daemon.route.session-or-selector-refusal row in dispatch-disclosure.json, driven through the router with an unopened session. I picked that row's command deliberately: capabilities declares no recording effect, so the dispatched: no it asserts can only come from the producer, not from the read-only seam.
  • (3b) renamed to PRE_DISPATCH_REFUSAL_REASONS, whose doc now says what it holds: the kernel-owned subset that always discloses dispatched: 'no', with the wire vocabulary in the versioned docs and the producer registry in the disclosure table.
  • (4) dropped "none named for the request" from the permission bullet; settingsPositionals proves it — permission positionals never carry an app until feat(settings): let permission and iOS location settings target an explicit app #3179.
  • (5) body rewritten with the HarmonyOS and daemon clear-app-state sites and current validation.

Not taken:

  • (2) a single shared assertion util is blocked by the repo's own boundary rule: scripts/layering/package-boundaries.ts fails on a package importing root src/, so one copy would need a new package test seam plus ownership entries — a separate change. The assertThrowsAppError deletion is in-scope: editing those files put them under check:affected's dead-export audit, which flagged it as a zero-consumer export, and AGENTS.md says repair at the audited site rather than suppress.

Your two questions

Kept the two named no-arg factories. refusalDetails(reason) would let a call site pair any pre-dispatch reason with dispatched: 'no'; a factory per reason makes the wrong pairing unrepresentable at the throw site, which is where it matters.

Can't reuse refusedBeforeDispatch at the daemon site: it lives in src/daemon/, and kernel cannot import root src/. It would also be wrong on its own — disclosedDetails fills dispatched only for observes-app, so for the commands that actually hit this refusal (clipboard/settings are mutates-app) the disclosure would land as unknown, and capabilities would carry none at all. The producer has to state it.

check:affected --run passes at 14dd8b7 (unit lanes, fallow dead-code + health, wire-compat, command-docs); pnpm typecheck and lint clean.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

The typed reasons for the session-app and session-or-selector refusals look right at 14dd8b7, and I found no code problems. The five Cubic threads (docs wording, the clear-app-state details on all owner sites, the permission and location scope, and the test fixture wording) are fixed at this head, so you can resolve them: #3194 (comment), #3194 (comment), #3194 (comment), #3194 (comment), #3194 (comment).

The evidence gap from the earlier review is now closed. I read the code and did not run the tests, including the new daemon.route driver. The Android and HarmonyOS owner sites, the daemon pre-check and the session-or-selector refusal are covered only by unit and owner tests, which is fine because the refusals come before any device call. I could not confirm the live-run transcript beyond the PR body.

Smoke Tests is still running, so no check has failed yet. There are no conflicts. Nothing is left from review. Before merge, Smoke Tests needs to finish and Cubic needs to review this head.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 4, 2026
@thymikee
thymikee merged commit fae04c3 into main Oct 4, 2026
21 checks passed
@thymikee
thymikee deleted the fix/typed-reasons-session-refusals branch October 4, 2026 08:00
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.

Typed reasons for the session-app and session-or-selector INVALID_ARGS refusals

1 participant