fix(portal): freeze the attended console and wire the decision portal to a deployment target - #78
Conversation
The mobile decision portal (#75) shipped dead in every signed installer. The frozen sidecar pinned `openadapt-flow==1.23.0`, which predates `openadapt_flow/console/human_decisions.py`, and requested no extras, so fastapi/uvicorn were absent and `openadapt-flow console` exited with an install hint instead of a capability banner. Verified against the built artifact rather than the pin: - 1.23.0 `console --attend` exits with "the operator console needs fastapi, uvicorn"; its `/api/attention/{run_id}` returns a bare attention item with no `task`/`presentation`, which the portal shell cannot render. - `openadapt-flow[console]==1.25.0` requires `openadapt-types>=0.6.0,<0.7.0`, which conflicted with Desktop's exact `openadapt-types==0.5.0`. The 0.6.1 control-overlay v2 schemas and enums are byte-identical to 0.5.0, so the regenerated projection changes only its provenance header. - 1.25.0 moved Playwright from a core Flow dependency to its `browser` extra. Without that extra the frozen driver became build-only and the artifact boundary gate correctly refused the archive. - PyInstaller does not reach uvicorn's run-time string imports (`uvicorn.loops.*`, `uvicorn.protocols.*`, `uvicorn.lifespan.*`) or Flow's lazily imported console package. - Flow prints the console's one-time capability banner with a plain `print`. A frozen executable block-buffers stdout, so the banner never reached the pipe the portal reads and a serving console still looked dead. `PYTHONUNBUFFERED` does not reach a PyInstaller-frozen interpreter; `--python-option u` does. The frozen-notice closure now resolves the same extras the installer freezes, so fastapi/starlette/uvicorn are audited runtime packages with mandatory notices instead of build-only imports, and one shared parser refuses a Flow pin that drops a required extra. `scripts/smoke_test_frozen_flow.py` proves the result behaviourally on every sidecar build: it starts the frozen attended console, parses the banner with the portal's own parser, and drives `/api/session`, `/api/attention/notification`, `/api/attention`, `/api/attention/{run_id}`, and an unauthenticated probe through `engine.portal.flow_client`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
CI update: Linux now confirms the same behaviourThe PR body was written from a macOS 14 arm64 build. The So the frozen console — banner over a pipe, Still unverified: Windows x86_64 and macOS Intel. PR builds only the Linux sidecar; the four-platform sidecar and installer matrix runs on exact All 14 checks pass; the native-installer matrix is skipped by design on PRs. 🤖 Generated with Claude Code |
…artup `portal_start` could not complete. Two portal-side defects, plus the open security question about the staged deployment config, are resolved here. **The staged config is not session-lived, and per-run re-staging would be theatre.** `data_dir/deployment.json` carries reusable credentials (`rdp_password`, `rdp_username`, `rdp_domain`, `agent_token`, `agent_tls_pin`) alongside PHI-capable selectors; `private_flow_config` already treats every `password`/`token`/`secret` key as sensitive. That rules out an hours-long window. But in the pinned `openadapt-flow==1.25.0`, `_attended_service_from_args` resolves `--config` eagerly through `load_deployment` *before* it yields, and `AttendedActionService` is built from the parsed `DeploymentConfig` and never sees the path again -- so re-writing the file per run would not change one byte of what the console executes with. The lifetime is therefore the console's startup: staged 0600, console spawned, file removed the instant the capability banner arrives. `serve()` prints that banner strictly downstream of the config load in the same `with` statement, so the banner is a happens-after proof, not a timing guess. Measured window on the frozen binary: 4.99s. This bounds what duration can actually bound -- a same-user backup, file-sync client, crash reporter, or support bundle sweeping the copy. It does not pretend to stop same-user code execution, which already has the operator's own file and the console's in-memory copy. Removal is guaranteed by `stage_private_yaml`'s `finally` on every exit path including a console that never announces itself and an unparseable config. A `finally` cannot survive SIGKILL, so start also sweeps `.deployment-*.yaml` older than `STALE_STAGING_AGE_S`; the age bound means a concurrent start can never have its own in-use file deleted. Also fixed: * `PortalService` passed `--allow-actions` with no deployment target, which Flow deliberately refuses. It now passes `--config <staged>`. A missing or unparseable config refuses before anything is spawned, naming the file. * `start` called `session` immediately after the banner, racing uvicorn's bind. `_await_console_session` polls to a real deadline and gives up at once if the console exited, rather than burning it. Behavioural evidence from the extended smoke, on the built artifact: `console_accepts_staged_config`, `console_read_staged_config` (Flow echoes a marker existing only inside the staged file), `console_attended_session`, `console_serves_without_staged_config` (every route driven with the file already deleted), `console_attended_banner_seconds: 4.992`, and `console_refuses_unbound_mutations` still true -- the no-target refusal is preserved, not worked around. Flow requires an injected `WindowClient` for this backend on Linux by design, so the headless leg proves the config read rather than pretending an attended session can attach. Nothing unpinned, loosened, or vendored; the `--collect-all openadapt_flow` refusal, the artifact gate, and `bundled_flow_pin()` are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
The frozen smoke previously decided whether an attended session could attach from a hardcoded platform tuple. That is guesswork on two of the three CI legs, and it can hide a real defect: a Windows build missing a frozen module would be indistinguishable from an unsupported host. It now attempts the attended start everywhere and classifies the outcome from Flow's own message. Flow implements a window-scoped replay client on macOS (Quartz) and Windows (Win32) and refuses elsewhere by design, so Linux records `console_attended_session: "no-host-window-client"`. Any other startup failure still fails the smoke. Every platform, Linux included, continues to prove the frozen binary read the staged config's bytes via the echoed `backend.kind` marker. Re-verified on the rebuilt macOS arm64 artifact: console_attended_session true, console_serves_without_staged_config true, console_attended_banner_seconds 4.992, console_refuses_unbound_mutations true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
…s from pins (#80) Two Windows-only defects turned `main` red after #78. Both only run post-merge, and both are fixed here with their root causes, not their symptoms. **1. `main` red: a fixture directory named after a version pin.** `test_frozen_flow_pin_must_request_the_console_and_browser_extras` created `tmp_path / pin`, and a pin contains `>` -- not a legal Windows filename character (`WinError 123`). Only the file's *contents* were ever under test, so the directory is now a counter. 1 failed / 733 passed on windows-latest 3.11; that one test is the whole delta. **2. `main` red, and a real product bug: the console outlived its terminate.** The Windows sidecar smoke printed every assertion green (`console_attended_session: true`, `console_serves_without_staged_config: true`, banner 7.454s -- the full attended path works on Windows) and then failed deleting its scratch directory: `PermissionError [WinError 5]` on `cv2.pyd`. The cause is not the temp directory. A PyInstaller one-file binary runs the application in a **child** of the process we spawn, and Windows `terminate()` is `TerminateProcess` on the outer bootloader only. The inner process survives. In the smoke that means a still-mapped DLL; in the product it means `PortalService.stop()` leaves an `--allow-actions` attended console **still serving** after the operator stopped the portal. That is the serious half. `_kill_tree` now stops the whole tree with `taskkill /F /T` on Windows and keeps the single `terminate()` on POSIX, where the bootloader `exec`s into the application rather than forking it. `PortalService._terminate` and the smoke both go through it. The smoke additionally passes `ignore_cleanup_errors=True` as a safety net, so a scratch directory can never again fail a run whose assertions have all passed. Local: 768 passed, ruff clean, frozen smoke green on macOS 14 arm64 (console_attended_session true, banner 5.319s). Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The defect, verified before changing anything
The mobile decision portal merged in #75 works from a source checkout and is dead in every signed installer. Both reported causes reproduce, and there are three more that only a behavioural test finds.
Verification was run against real installs of the exact pinned versions, not by reading code:
1.
openadapt-flow==1.23.0predates the module the portal renders.1.23.0's
/api/attention/{run_id}returnsitem.model_dump()— a bare attention item. 1.25.0 returnshuman_decisions.decision_detail(...). The portal shell (engine/portal/shell/app.js) readsbody.task.allowed_actions,body.task.capability_digest,body.task.signature,detail.task_digest, andpresentation.question; none of those keys exist in the 1.23.0 response, so the phone renders an empty decision and cannot submit one.2. The pin requested no extras, so the console cannot start at all.
PortalService._start_consolewaits for a capability banner that never comes and fails closed.3. (new)
openadapt-flow[console]==1.25.0is uninstallable against Desktop'sopenadapt-types==0.5.0.Flow's console extra requires
openadapt-types>=0.6.0,<0.7.0;human_decisions.pyimportsHUMAN_DECISION_TASK_SCHEMAandHumanDecisionTaskV1, neither of which exists in 0.5.0. The version bump was not optional.4. (new) 1.25.0 moved Playwright out of Flow's core dependencies into its
browserextra.With
[console]alone, the frozen Playwright driver became a build-only import andscripts/verify_build_artifact.py sidecarcorrectly refused the archive (build-only Python modules crossed frozen boundary: greenlet; playwright; …). The gate caught this; it is fixed by pinning[browser,console].5. (new) The capability banner never survives the pipe in a frozen build.
Flow prints the console's one-time bearer capability with a plain
printimmediately beforeuvicorn.run(). A PyInstaller-frozen interpreter block-buffers stdout, so with everything else fixed the console was serving while its supervisor saw nothing:PYTHONUNBUFFERED=1does not reach a PyInstaller-frozen interpreter (tested).--python-option udoes.What changed
pyproject.tomlopenadapt-flow==1.23.0→openadapt-flow[browser,console]==1.25.0;openadapt-types==0.5.0→==0.6.1. Exact pins kept — nothing unpinned, loosened, or vendored.uv.lockfastapi 0.140.7,starlette 1.3.1,uvicorn 0.51.0,annotated-doc 0.0.4; updates flow and types.scripts/build_frozen_engine.pyCONSOLE_COLLECTION:--collect-all uvicorn(its loop/protocol/lifespan implementations are resolved by string import at run time),--collect-submodules openadapt_flow.console, hidden imports forconsole.server/app/human_decisions,fastapi,starlette, and--copy-metadatafor all three. Plus--python-option ufor the banner. The deliberate refusal to--collect-all openadapt_flow(crown-jewel boundary) is untouched; only the narrowconsolesubpackage is collected.scripts/frozen_notices.pyFROZEN_RUNTIME_ROOTSnow carries extras (openadapt-flow[browser,console]) so the notice closure resolves what is actually frozen. fastapi/starlette/uvicorn become audited runtime packages with mandatory notices rather than build-only imports. New sharedbundled_flow_pin()refuses a pin missing a required extra.scripts/verify_build_artifact.py,scripts/smoke_test_native_installer.pyscripts/sync_control_overlay_contract.py+src/overlay/generated/contract.tsscripts/smoke_test_frozen_flow.pytests/,docs/docs/DECISION_PORTAL.mdupdated.How the frozen artifact was proved to serve the portal
The existing frozen smoke was extended rather than duplicated, so it runs in the existing
python-sidecarjob with no new workflow step and no second vision-runtime download (~15s added).It starts the frozen attended console with the exact argument shape
engine.portal.servicespawns, reads stdout with the portal's own_parse_console_banner, and drives the routes throughengine.portal.flow_client.FlowConsoleClient— Desktop's real seam, allowlist and header normalisation included:{"artifact_bytes": 108479616, "console_banner_seconds": 5.095, "console_decision_question": true, "console_refuses_unbound_mutations": true, "console_unauthenticated_status": 401, ...}Live responses from the frozen binary:
The
item/task/task_digest/presentationshape ishuman_decisions.decision_detail— the thing 1.23.0 could not produce. (taskisnullhere because the synthetic run carries no engine-issued pause capability; that is the correct fail-closed projection, and the shell's question comes frompresentation.)Archive inventory of the shipped PYZ:
Licensing / artifact boundary
scripts/verify_build_artifact.py sidecarpasses on the built artifact. Every new closure member (fastapi, starlette, uvicorn, anyio, click, h11, annotated-doc) ships a concrete license file in its own wheel, so all are hash-bound inNOTICE-INVENTORY.json; no reviewed-external-notice exception and no vendored file was added. All are MIT/BSD — the copyleft scan, the AGPL/openIMIS member scan, the separately-provisioned-vision scan, and the build-only-import rejection all still run and all pass.uv build+verify_build_artifact.py python-distributionpass on the wheel and sdist.Gates run locally with the exact pinned versions
uv sync --locked --extra dev --extra build→uv run pytest tests/ -q→ 757 passeduv run ruff check engine/ tests/ scripts/→ cleannpm ci && npm run test:ui && npm run build→ 37 passed, build cleanuv run python scripts/check_release_consistency.py→ synchronizeduv sync --locked --extra build(CI-exact) →build_frozen_engine.py→verify_build_artifact.py sidecar→smoke_test_frozen_flow.py→ all passWhat remains unvalidated, and what still needs a human
Platforms. Everything above was built and exercised on macOS 14 arm64 only (the local machine). Linux x86_64, Windows x86_64, and macOS Intel are unverified by me and rest on CI: PRs build the sidecar on
ubuntu-22.04only, and the full four-platform sidecar + installer matrix runs on exactmain. Two platform-specific risks are worth watching on the merge build: PyInstaller collection differences for uvicorn on Windows, and the--python-option ubehaviour under the Windows/Linux bootloaders.portal_startnow completes, and the open security question is resolved.Both remaining portal-side defects are fixed in this PR, and the staged-config
lifetime question is decided on evidence rather than handed back.
What the staged config contains.
data_dir/deployment.jsonis theoperator's file, and its schema carries reusable credentials —
rdp_password,rdp_username,rdp_domain,agent_token,agent_tls_pin—alongside PHI-capable selectors (
url,macos_window_title,rdp_readiness_text, …).engine/private_flow_config.pyalready treats everypassword/token/secretkey as sensitive when deriving log redactions. Thisis not "a backend and a URL".
Threat model. 0600 is owner-only, so an attacker with same-user code
execution already has the operator's own
deployment.jsonand the console'sin-memory copy; no file lifetime changes that, and this change does not pretend
to. What duration genuinely bounds is a same-user backup, file-sync client,
crash reporter, or support bundle sweeping the staged copy — an RDP password
does not rotate on being read, so a file that survives being read once is a real
exposure whose severity is proportional to how long it sits there.
Decision: neither session-lived nor per-run — staged for the console's
startup only.
Session-lived is ruled out by the contents. Per-run re-staging is ruled out
because it would be theatre, and that is worth stating plainly: in the exact
pin this installer ships,
openadapt_flow/__main__.py::_attended_service_from_argsresolves
--configeagerly throughload_deploymentbefore it yields, andAttendedActionServiceis constructed from the parsedDeploymentConfigobjectand never sees the path again. Re-writing the file per run would not change one
byte of what the console executes with; it would only put the same secret back
on disk more times.
So the file lives exactly as long as Flow needs to read it. It is staged 0600,
the console is spawned, and it is removed the instant the capability banner
arrives.
serve()— which prints that banner — runs strictly downstream of theconfig load inside the same
withstatement, so the banner is ahappens-after proof, not a timing guess. Measured window on the frozen binary:
4.992s, versus the hours a portal session can last.
Removal is guaranteed by the
finallyinprivate_flow_config.stage_private_yamlon every exit path — normal return, aconsole that never announces itself, an unparseable config, any exception. The
one thing a
finallycannot survive isSIGKILL, so portal start also sweeps.deployment-*.yamlolder thanSTALE_STAGING_AGE_S. That age bound isdeliberate: a config belonging to a console that is still starting is younger
than the start timeout, so a concurrent start can never have its own in-use file
deleted out from under it.
The two portal-side blockers, fixed.
PortalServicenow spawnsconsole --attend --allow-actions --config <staged>. A missing or unparseable deployment config refuses on Desktop'sside of the wire, before anything is spawned, naming the file the operator
has to write. Flow's no-target refusal is preserved and still asserted
against the frozen binary — it was not worked around.
PortalService.startno longer callssessioninto a closed port._await_console_sessionpolls to a real deadline (CONSOLE_READY_TIMEOUT_S)and gives up immediately if the console process exited, rather than burning
the whole deadline on a corpse.
Behavioural evidence: the portal starts on the frozen binary.
The existing smoke was extended (same job, no new workflow step). It stages a
config through the same
private_flow_configcall the portal uses, starts thefrozen console with the exact
portal_startargument shape, deletes thestaged file the moment the banner is parsed, and only then drives every portal
route through
engine.portal.flow_client:{"artifact_bytes": 111625184, "console_accepts_staged_config": true, "console_read_staged_config": true, "console_attended_session": true, "console_serves_without_staged_config": true, "console_attended_banner_seconds": 4.992, "console_refuses_unbound_mutations": true, "console_decision_question": true, "console_unauthenticated_status": 401, ...}console_read_staged_configis proof rather than inference: the staged configcarries a
backend.kindmarker that exists nowhere in Flow, and the frozenbinary echoes it back verbatim.
console_serves_without_staged_configmeansevery route above was served with the credential-bearing file already gone from
disk — so a future Flow that began re-reading the path would fail this smoke.
Flow requires an injected
WindowClientfor this backend on Linux bydesign, so no attended session can attach on a headless runner. The Linux PR
leg therefore proves the config read; the full attended path runs on the
exact-main macOS and Windows sidecar legs, and was run locally on macOS 14
arm64 (above).
Local gates re-run after the change: 766 passed (was 757), ruff clean,
37 frontend tests + build clean, release consistency synchronized,
verify_build_artifact.py sidecarpasses on the rebuilt artifact.Other runtime pin drift found (report only, not fixed here).
openadapt-capture>=1.1.0)>=1.2.0floor Flow's owncaptureextra requires. Recommend a separate bump — capture 1.2.x changed sdist packaging, andtests/test_capture_runtime_contract.pyasserts a native recorder contract that should be re-run against it.onnxruntimeopenadapt-privacy,playwright,pyinstaller,segnoFlow extras deliberately not requested.
privacyis intentionally omitted —engine/main.py::_normalize_flow_auto_scrub_capabilitydocuments Presidio/spaCy as an optional extra and keepsSCRUB=onfailing closed.captureadds onlyopenadapt-capture, already a direct Desktop dependency. Platform extras (macos/windows/linux) are unchanged by this PR and unverified either way; the Windows extra'srequestsdependency is worth checking if the frozen sidecar is ever expected to drive Flow's Windows agent backend.🤖 Generated with Claude Code
https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM