feat(portal): answer halts on a phone without any customer ingress - #85
Open
abrichr wants to merge 2 commits into
Open
feat(portal): answer halts on a phone without any customer ingress#85abrichr wants to merge 2 commits into
abrichr wants to merge 2 commits into
Conversation
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
Member
Author
|
Sequencing note, so this is not lost. This PR is complete and green, and until The remaining chain, in order:
Only after step 5 does a dental practice get the phone path from the Desktop app rather than from the CLI. |
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_ingressmode;reachable_from_phoneisFalsefor the defaultloopback). 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-decisionsis passed to the attended console when — and only when — the operator's deployment setshuman_decisions.remote.enabled: true.The answer travels on the same snapshot as the config.
PreparedPrivateYamlgainsremote_decisions, derived insideprepare_flow_configfrom 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.enabledmust be literallytrue. A truthy string, a1, 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:
PortalErrornaming the host and saying to connect once.MIN_FLOW_FOR_REMOTE_DECISIONS→PortalErrornaming 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 pinsopenadapt-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_portalandtests/test_engine/test_private_flow_config.py;ruff checkclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM