Skip to content

fix(portal): freeze the attended console and wire the decision portal to a deployment target - #78

Merged
abrichr merged 3 commits into
mainfrom
fix/frozen-console-runtime-pin
Jul 27, 2026
Merged

fix(portal): freeze the attended console and wire the decision portal to a deployment target#78
abrichr merged 3 commits into
mainfrom
fix/frozen-console-runtime-pin

Conversation

@abrichr

@abrichr abrichr commented Jul 27, 2026

Copy link
Copy Markdown
Member

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.0 predates the module the portal renders.

$ pip install 'openadapt-flow==1.23.0'
$ ls site-packages/openadapt_flow/console/
__init__.py  actions.py  app.py  attention.py  data.py  server.py     # no human_decisions.py

1.23.0's /api/attention/{run_id} returns item.model_dump() — a bare attention item. 1.25.0 returns human_decisions.decision_detail(...). The portal shell (engine/portal/shell/app.js) reads body.task.allowed_actions, body.task.capability_digest, body.task.signature, detail.task_digest, and presentation.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.

$ openadapt-flow console --attend --port 7899        # flow 1.23.0, no console extra
the operator console needs fastapi, uvicorn — install the console extra:  pip install 'openadapt-flow[console]'
$ echo $?
1

PortalService._start_console waits for a capability banner that never comes and fails closed.

3. (new) openadapt-flow[console]==1.25.0 is uninstallable against Desktop's openadapt-types==0.5.0.
Flow's console extra requires openadapt-types>=0.6.0,<0.7.0; human_decisions.py imports HUMAN_DECISION_TASK_SCHEMA and HumanDecisionTaskV1, 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 browser extra.
With [console] alone, the frozen Playwright driver became a build-only import and scripts/verify_build_artifact.py sidecar correctly 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 print immediately before uvicorn.run(). A PyInstaller-frozen interpreter block-buffers stdout, so with everything else fixed the console was serving while its supervisor saw nothing:

[  5.0s ERR] INFO:     Uvicorn running on http://127.0.0.1:7876
              ... no #token= line, ever ...

PYTHONUNBUFFERED=1 does not reach a PyInstaller-frozen interpreter (tested). --python-option u does.

What changed

File Change
pyproject.toml openadapt-flow==1.23.0openadapt-flow[browser,console]==1.25.0; openadapt-types==0.5.0==0.6.1. Exact pins kept — nothing unpinned, loosened, or vendored.
uv.lock Regenerated. Adds exactly fastapi 0.140.7, starlette 1.3.1, uvicorn 0.51.0, annotated-doc 0.0.4; updates flow and types.
scripts/build_frozen_engine.py New CONSOLE_COLLECTION: --collect-all uvicorn (its loop/protocol/lifespan implementations are resolved by string import at run time), --collect-submodules openadapt_flow.console, hidden imports for console.server/app/human_decisions, fastapi, starlette, and --copy-metadata for all three. Plus --python-option u for the banner. The deliberate refusal to --collect-all openadapt_flow (crown-jewel boundary) is untouched; only the narrow console subpackage is collected.
scripts/frozen_notices.py FROZEN_RUNTIME_ROOTS now 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 shared bundled_flow_pin() refuses a pin missing a required extra.
scripts/verify_build_artifact.py, scripts/smoke_test_native_installer.py Both now read the pin through that one parser, so the gate, the installer smoke, and the notice closure cannot disagree about what the installer ships.
scripts/sync_control_overlay_contract.py + src/overlay/generated/contract.ts Regenerated for types 0.6.1. The only change is the provenance header — the v2 schema JSON hashes and every enum are byte-identical between 0.5.0 and 0.6.1 (verified before bumping).
scripts/smoke_test_frozen_flow.py Extended with the behavioural acceptance test (below).
tests/, docs/ Regression tests for the pin/extras/collection invariants; installer notes and docs/DECISION_PORTAL.md updated.

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-sidecar job 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.service spawns, reads stdout with the portal's own _parse_console_banner, and drives the routes through engine.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:

session       200  {"version": "1.25.0", "attend": true, "csrf_token": "..."}
notification  200  {"title": "OpenAdapt needs attention", "open_count": 1}   # passes assert_generic_notification
tasks         200  [{"id": "7dfbd8a7dbf135ff167c3639", "status": "halted", ...}]
task_detail   200  {"item": {...}, "task": null, "task_digest": null,
                    "presentation": {"question": "Is the live application ready for OpenAdapt to verify and continue?", ...}}
unauthenticated GET /api/session → 401

The item/task/task_digest/presentation shape is human_decisions.decision_detail — the thing 1.23.0 could not produce. (task is null here because the synthetic run carries no engine-issued pause capability; that is the correct fail-closed projection, and the shell's question comes from presentation.)

Archive inventory of the shipped PYZ:

fastapi: 38 modules      starlette: 22      uvicorn: 41      openadapt_flow.console: 8 (incl. human_decisions)
uvicorn dynamic: lifespan.{on,off}, loops.{auto,asyncio,uvloop},
                 protocols.http.{auto,h11_impl,httptools_impl},
                 protocols.websockets.{auto,websockets_impl,wsproto_impl}

Licensing / artifact boundary

scripts/verify_build_artifact.py sidecar passes 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 in NOTICE-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-distribution pass on the wheel and sdist.

Gates run locally with the exact pinned versions

  • uv sync --locked --extra dev --extra builduv run pytest tests/ -q757 passed
  • uv run ruff check engine/ tests/ scripts/ → clean
  • npm ci && npm run test:ui && npm run build → 37 passed, build clean
  • uv run python scripts/check_release_consistency.py → synchronized
  • uv sync --locked --extra build (CI-exact) → build_frozen_engine.pyverify_build_artifact.py sidecarsmoke_test_frozen_flow.py → all pass

What 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.04 only, and the full four-platform sidecar + installer matrix runs on exact main. Two platform-specific risks are worth watching on the merge build: PyInstaller collection differences for uvicorn on Windows, and the --python-option u behaviour under the Windows/Linux bootloaders.

portal_start now 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.json is the
operator'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.py already treats every
password/token/secret key as sensitive when deriving log redactions. This
is 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.json and the console's
in-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_args
resolves --config eagerly through load_deployment before it yields, and
AttendedActionService is constructed from the parsed DeploymentConfig object
and 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 the
config load inside the same with statement, so the banner is a
happens-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 finally in
private_flow_config.stage_private_yaml on every exit path — normal return, a
console that never announces itself, an unparseable config, any exception. The
one thing a finally cannot survive is SIGKILL, so portal start also sweeps
.deployment-*.yaml older than STALE_STAGING_AGE_S. That age bound is
deliberate: 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.

  1. PortalService now spawns console --attend --allow-actions --config <staged>. A missing or unparseable deployment config refuses on Desktop's
    side 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.
  2. PortalService.start no longer calls session into a closed port.
    _await_console_session polls 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_config call the portal uses, starts the
frozen console with the exact portal_start argument shape, deletes the
staged 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_config is proof rather than inference: the staged config
carries a backend.kind marker that exists nowhere in Flow, and the frozen
binary echoes it back verbatim. console_serves_without_staged_config means
every 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 WindowClient for this backend on Linux by
design
, 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 sidecar passes on the rebuilt artifact.

Other runtime pin drift found (report only, not fixed here).

Component Installer ships Latest Note
openadapt-capture 1.1.1 (floor >=1.1.0) 1.2.1 Two minors behind, and below the >=1.2.0 floor Flow's own capture extra requires. Recommend a separate bump — capture 1.2.x changed sdist packaging, and tests/test_capture_runtime_contract.py asserts a native recorder contract that should be re-run against it.
onnxruntime 1.27.0 1.28.0 One minor behind; the pin is deliberate and macOS-Intel-aware.
openadapt-privacy, playwright, pyinstaller, segno 1.0.0 / 1.61.0 / 6.21.0 / 1.6.6 same current

Flow extras deliberately not requested. privacy is intentionally omitted — engine/main.py::_normalize_flow_auto_scrub_capability documents Presidio/spaCy as an optional extra and keeps SCRUB=on failing closed. capture adds only openadapt-capture, already a direct Desktop dependency. Platform extras (macos/windows/linux) are unchanged by this PR and unverified either way; the Windows extra's requests dependency 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

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
@abrichr

abrichr commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

CI update: Linux now confirms the same behaviour

The PR body was written from a macOS 14 arm64 build. The Python sidecar (ubuntu-22.04) job has now reproduced it independently on Linux x86_64 — the artifact boundary/notice gate and the extended frozen smoke both pass on a machine that never saw my local environment:

Verified sidecar artifact: .../dist/openadapt-engine (130954408 bytes)
{"artifact_bytes": 130954408, "console_banner_seconds": 3.242,
 "console_decision_question": true, "console_refuses_unbound_mutations": true,
 "console_unauthenticated_status": 401, "outcome": "COMPLETED_UNVERIFIED", ...}

So the frozen console — banner over a pipe, human_decisions projection, capability enforcement, and the fail-closed refusal of unbound mutations — is now verified on macOS arm64 and Linux x86_64.

Still unverified: Windows x86_64 and macOS Intel. PR builds only the Linux sidecar; the four-platform sidecar and installer matrix runs on exact main. Worth watching on the merge build: uvicorn collection under the Windows bootloader, --python-option u on Windows/Linux installers, and the macOS Intel cryptography/onnxruntime universal2 lane.

All 14 checks pass; the native-installer matrix is skipped by design on PRs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM

abrichr and others added 2 commits July 27, 2026 17:23
…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
@abrichr abrichr changed the title fix(packaging): freeze the attended console the decision portal needs fix(portal): freeze the attended console and wire the decision portal to a deployment target Jul 27, 2026
@abrichr
abrichr merged commit ccd608d into main Jul 27, 2026
15 checks passed
@abrichr
abrichr deleted the fix/frozen-console-runtime-pin branch July 27, 2026 21:39
abrichr added a commit that referenced this pull request Jul 27, 2026
…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>
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