Skip to content

feat(portal): answer halts on a phone without any customer ingress - #85

Open
abrichr wants to merge 2 commits into
mainfrom
feat/phone-decisions-without-ingress
Open

feat(portal): answer halts on a phone without any customer ingress#85
abrichr wants to merge 2 commits into
mainfrom
feat/phone-decisions-without-ingress

Conversation

@abrichr

@abrichr abrichr commented Jul 28, 2026

Copy link
Copy Markdown
Member

The gap

The runner-local decision portal serves the whole local presentation — screenshot crops, the gated control label, the full halt detail — and reaching it from a phone requires an HTTPS origin the customer terminates themselves (engine/portal/ingress.py, customer_ingress mode; reachable_from_phone is False for the default loopback). Correct for an organisation with an IT department. Unavailable to a dental practice.

Flow's hosted lane needs none of it — the runner dials out, so no inbound port, no port forward, no certificate, no reverse proxy, no static address, works behind NAT on ordinary practice broadband. Desktop just has to turn it on. Until now nothing did.

What this does

--remote-decisions is passed to the attended console when — and only when — the operator's deployment sets human_decisions.remote.enabled: true.

The answer travels on the same snapshot as the config. PreparedPrivateYaml gains remote_decisions, derived inside prepare_flow_config from the mapping it is already serializing. Re-reading the operator's file to decide "is the phone lane on?" would reintroduce exactly the TOCTOU gap that class exists to close — Flow could be launched with the outbound lane on while executing a config that never enabled it.

enabled must be literally true. A truthy string, a 1, a missing section — all mean no. An outbound lane that carries decision context is not inferred. Parametrised over seven shapes.

The credential goes in the environment, never in argv, where it would sit in the process table for every user on the machine. Asserted.

Both preconditions are checked before the spawn

This is the part worth reviewing. Afterwards the two failures are indistinguishable and useless: an unknown flag makes argparse exit before the capability banner, so Desktop reports "the local decision service did not start" — naming neither the missing registration nor the old runtime.

So:

  • Not registered with the control planePortalError naming the host and saying to connect once.
  • Flow older than MIN_FLOW_FOR_REMOTE_DECISIONSPortalError naming both versions and the fix.

Neither starts a console whose phone lane is silently absent. That outcome is worse than the honest gap, because nothing on either surface says the phone will never ring.

Dependency and current effect

MIN_FLOW_FOR_REMOTE_DECISIONS = (1, 26, 0). The flag lands in Flow via OpenAdaptAI/openadapt-flow#297 (stacked on #296); the installer currently pins openadapt-flow==1.25.0, so until that pin moves the refusal is the entire behaviour — which is the correct behaviour, not a regression. A deployment that did not enable remote decisions is untouched either way.

I will add the pin bump to this branch once the Flow release exists, so this merges as one coherent change rather than a feature that always refuses.

Tests

tests/test_portal/test_service.py — the flag is absent without the config, refuses without a credential, refuses on an old Flow, and passes flag + credential when configured (credential in env, never in argv), each asserting nothing was spawned on the refusal paths. tests/test_engine/test_private_flow_config.py — same-snapshot derivation, and seven shapes that must read as off.

142 passing across tests/test_portal and tests/test_engine/test_private_flow_config.py; ruff check clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM

The runner-local decision portal serves the whole local `presentation` --
screenshot crops, the gated control label, the full halt detail -- and reaching
it from a phone requires an HTTPS origin the customer terminates themselves
(`engine/portal/ingress.py`, `customer_ingress`). That is right for an
organisation with an IT department and unavailable to a dental practice.

Flow's hosted lane needs none of it: the runner dials OUT, so there is no
inbound port, no port forward, no certificate, no reverse proxy and no static
address, and it works behind NAT on ordinary practice broadband. Desktop just
has to turn it on.

`--remote-decisions` is now passed to the attended console when, and only when,
the operator's deployment sets `human_decisions.remote.enabled: true`.
`PreparedPrivateYaml` carries that answer from the SAME snapshot it serializes,
rather than re-reading the operator's file: a second read would reintroduce the
exact TOCTOU gap that class exists to close, and could launch Flow with the
outbound lane on while it executes a config that never enabled it.

`enabled` must be literally `true`. A truthy string or a 1 means no. An outbound
lane that carries decision context is never inferred.

The runner credential goes to the child process in its environment, never as an
argument, where it would sit in the process table for every user on the machine.

Both preconditions are checked BEFORE the spawn, because the failure mode
afterwards is indistinguishable and useless: an unknown flag makes argparse exit
before the capability banner, and Desktop reports "the local decision service
did not start" -- naming neither the missing registration nor the old runtime.
So a deployment that enabled phone decisions on a computer that was never
registered with the control plane, or on a Flow older than the flag, stops with
a sentence that names the fix. It does not start a console whose phone lane is
silently absent, which is worse than the gap because nothing on either surface
says the phone will never ring.

`MIN_FLOW_FOR_REMOTE_DECISIONS` is 1.26.0. The installer currently pins 1.25.0,
so the refusal is the whole behaviour until that pin moves; a deployment that
did not enable remote decisions is untouched either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
…y one

The page opened by saying this portal is how a halt reaches a phone. That was
true when it was written and is now the narrower half of the answer: it serves
protected screen crops, which is exactly why it needs an HTTPS origin the
customer terminates themselves -- and why it is unavailable to the practice
this product is for.

Names the hosted lane, what Desktop does to turn it on, and the two
before-the-spawn refusals, so an operator reading this page is not left
believing an ingress is the only route to a phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
@abrichr

abrichr commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Sequencing note, so this is not lost.

This PR is complete and green, and until openadapt-flow ships --remote-decisions its entire effect is the pre-spawn refusal — which is the correct behaviour, not a regression, and only for a deployment that explicitly set human_decisions.remote.enabled: true.

The remaining chain, in order:

  1. openadapt-flow #296 → main (auto-merge armed, CI running)
  2. openadapt-flow #297 → main (retargets automatically once #296 lands)
  3. Dispatch CI on main so test-matrix runs on the exact head SHA — the release gate (scripts/check_release_ci.py) requires all four legs on that SHA
  4. Dispatch Release and PyPI Publishsemantic-release. The commits are feat:, so it cuts 1.26.0, which is exactly MIN_FLOW_FOR_REMOTE_DECISIONS here
  5. Add the pin bump (openadapt-flow==1.26.0) to this branch, then merge

Only after step 5 does a dental practice get the phone path from the Desktop app rather than from the CLI.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant