Skip to content

on-prem: build the runner image from a signed release with no network, test it in CI - #1923

Open
abrichr wants to merge 23 commits into
agent/142-cfrom
agent/142-c3
Open

abrichr wants to merge 23 commits into
agent/142-cfrom
agent/142-c3

Conversation

@abrichr

@abrichr abrichr commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Part of the on-prem deployment work. Stacked on #1918 (base agent/142-c2), which adds bin/build-release.sh and the flow-onprem-offline CI job.

What this does

The container option in deploy/on-prem didn't work as shipped. The Dockerfile installed openadapt-flow[privacy] from a ./wheels directory that nothing produced and that wasn't pinned to the lock. Its command, deploy/on-prem/bin/run-queue.sh (and bin/run-queue.sh in docker-compose.yml), was a relative path resolved from the storage volume, so the runner could not start. Nothing built or ran the image.

  • bin/build-image.sh builds the image from a signed release archive (the one build-release.sh makes). It checks the signature against the pinned vendor key with the same code as install.sh --update, refuses a bad archive before calling docker, extracts the archive with the bounded extractor, and runs docker build --network none. pip only sees the release's own lock-pinned wheels, so a missing wheel fails the build instead of reaching an index. It tags openadapt-flow-onprem:<version> and can docker save it.
  • The Dockerfile installs exactly the declared version from those wheels, the same way install.sh does, then runs pip check. It uses an absolute command path. The base image defaults to python:3.12-slim pinned by digest and must already be loaded on the host.
  • docker-compose.yml no longer builds anything. It runs the image named in OPENADAPT_FLOW_IMAGE (required, no default) with pull_policy: never and the absolute run-queue path. The internal network and the no-ports rule are unchanged.
  • bin/test-container.sh runs in the flow-onprem-offline job, after the releases are staged and before the egress-blocked install. It checks that:
    • a tampered archive is refused and no image is built;
    • the same release with the presidio_analyzer wheel removed fails to build, on that package;
    • the signed release builds;
    • inside the image, with no network, the engine and privacy extra import from the image's venv at the release version, and the CLI reports it;
    • the default command resolves, and run-queue.sh files a malformed job under failed/ and records it in the audit log on a bind-mounted storage root;
    • run-queue.sh refuses to start with scrubbing off;
    • compose refuses to start without the image named, never pulls, publishes no ports, and uses an internal network;
    • a TCP connect to 1.1.1.1:443 works from the same image on Docker's default network (the control) and fails from the compose runner;
    • the compose runner stays up watching the queue.
  • README.md and docs/ON_PREM.md describe the new build step.

Testing

  • Locally (macOS, no Docker daemon): shellcheck and actionlint are clean.
  • I ran build-image.sh against a 2.6.0 release built from this lock (89 wheels), signed with a throwaway key, with a stub docker that records its arguments and the build context:
    • The tampered copy was refused by the signature check, and docker was never called.
    • The signed archive passed checksum and signature checks. It produced a context with release/ (VERSION, requirements.lock.txt, 89 wheels) and on-prem/, and called docker build --network none --pull=false.
  • Contract tests: packages/flow test_ci_workflow_contract and test_onprem_service 43 passed. Root test_ci_required_check, test_flow_shards, test_ci_locked_installs and test_nightly 46 passed. check_release_consistency is OK.
  • CI, flow-onprem-offline job on ubuntu-26.04 (run 41941053864, head d6db42e): test-update.sh 51 passed, 0 failed; test-container.sh 18 passed, 0 failed; the egress-blocked install and verify 21 passed, 0 failed. That is one green Linux run of the container checks.
  • The first CI run of test-container.sh (head 977dfd7) failed 2 checks, both test bugs, fixed in d6db42e. With docker build -q, pip's error never reached the log, so the missing-wheel build failed for the right reason but the test couldn't tell; it now uses --progress=plain. The runner is root in the image and audit-log.sh makes the audit directory 0700, so the host could not read the log; the test now reads it from inside the image.
  • Earlier runs on this stack failed "concurrent audit writers preserve one hash chain". The cause was the uutils mkdir on Ubuntu 26.04, which can report success to two racing callers, and that broke the audit and update locks. The fix is on-prem locks: hold on Ubuntu 26.04 (uutils mkdir) and never break a live writer's lock #1922, merged into this branch from on-prem: install, update and roll back the real engine offline in CI #1918.

Not tested

  • A real bundle run inside the container. The test only drives the queue with a malformed job.
  • The VLM appliance profile. It still needs a GPU host and a local model, and it reuses the same image.
  • Arm64 hosts. The release has to be built on the target's architecture and Python minor version, as build-release.sh says.

bin/build-release.sh builds a release archive from uv.lock (pinned wheels,
hash-checked) plus the flow wheel, a checksum and an openssl signature.
bin/test-offline-install.sh builds two signed releases, then as root inside
run-egress-blocked.sh installs the first from its wheelhouse, refuses a
tampered second, updates to the signed second, rolls back and runs the
air-gap attestation. The new flow-onprem-offline job runs it, and runs
bin/test-update.sh, which no job ran before.
…kout

The version check ran python -c from packages/flow, so it imported the
source checkout and reported 2.6.0 after the update to 2.6.0.post1. Run it
isolated from /, require the module to live in the venv, and compare the
module with the installed distribution and the CLI's --version.
A writer that found the audit lock owned by an exited process removed it
without re-checking. If the owner released between the read and the
removal and another writer took the lock, the breaker deleted the live
lock and two writers could chain to the same parent. Break stale locks
only under a second lock and only if the lock still names the same dead
owner. Add a deterministic test that forces the handover, and print the
verifier output when the concurrent-writer check fails.
…, and test it in CI

The Dockerfile installed from an unpinned ./wheels directory that nothing
produced, and its command (bin/run-queue.sh relative to the storage volume)
could not resolve. build-image.sh now verifies a signed release archive
against the pinned vendor key, extracts it, and builds with --network none
from the release's lock-pinned wheels. docker-compose.yml runs a named,
never-pulled image with an absolute command. test-container.sh builds it in
CI, refuses a tampered archive, shows a missing wheel fails the build, and
runs the runner on the compose network with no route out.
@abrichr abrichr added the review-needed Needs an independent review from another vendor's agent label Nov 4, 2026
…r(1)

Ubuntu 26.04 ships uutils mkdir, which reports success to both callers when
two processes race to create one directory. The audit-log writer lock and the
release update lock both treated a zero exit from mkdir as ownership, so on
that image concurrent audit writers chained to the same parent (the
"concurrent audit writers preserve one hash chain" CI failure) and a second
update could proceed beside a live one.

Ownership is now the shell's own noclobber create of the pid file inside the
lock directory (O_CREAT|O_EXCL), and the stale-lock breaker lock is an
exclusive file too. Tests model the uutils behavior with a mkdir shim that
never reports EEXIST; on the previous code they fail.
…ad the root-owned audit log from inside the image
@abrichr

abrichr commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review: BLOCK

P2: Keep unrelated local files out of the image. build-image.sh:69–70 copies the deployment directory with a short exclusion list; Dockerfile:38 then embeds everything copied. With an unchanged signed archive, adding an owned synthetic runtime-local.txt beside the scripts makes that file readable inside the real built image. Local site configuration or other data could therefore travel with a saved image. Stage only the required deployment files. The named storage/wheels/release exclusions passed; no confidential data was used.

P2: Refuse a missing base before invoking the builder. build-image.sh:73–76 does not enforce the documented local-base requirement. With an absent --base pointing to an owned loopback registry listener, the real Docker daemon opened a TCP connection and attempted HTTPS metadata resolution despite --network none --pull=false. The build then failed. Those flags did not prevent base resolution over the network. Check the base locally and enforce the advertised offline boundary before building.

Validation on WSL / Docker 29.9.3: the exact shipped container suite passed 18/18, including real engine/privacy imports, malformed-job audit, and Compose egress controls. The 43 Flow + 46 root contract tests passed on head d6db42e0a, literal parent 977dfd702, and stacked base 7770839e3. Frozen recorder probes produced 1 failure/8 passes on head and parent; three native Docker probes produced 2 failures/1 pass on each. The cached-base/package-version control passed. The stacked base lacks the new build entry point; its nine probe passes only confirm that absence. Source hashes stayed stable. Bash syntax, release consistency, generality and source-boundary checks passed; shellcheck/actionlint were unavailable. Initial runner PATH and listener-accounting errors are retained separately. The native canary image used a minimal synthetic wheel; the separate shipped suite used the real locked release. No real workflow bundle, VLM/GPU or Arm64 run is claimed.

@abrichr abrichr removed the review-needed Needs an independent review from another vendor's agent label Nov 5, 2026
…plicate dialog) (#1615)

* identity: read the band of a target inside a dialog on the dialog alone (#1347)

The recorder stores the box of the topmost dialog holding a clicked target
(PlaywrightBackend.dialog_region_at). The compiler reads that step's identity
band on the frame blanked outside the box and stores Anchor.dialog_region.
Replay blanks the live frame outside the live dialog the same way, and fails
the identity check closed when no dialog holds the resolved point.

Also: the throughput harness retries its form setup once after a navigation
race destroys the evaluate context (#1348).

* identity: address the dialog-scope review

Scope to modal dialogs only (dialog:modal, aria-modal, alertdialog, a shown
Bootstrap modal) and treat an opacity-0 dialog as gone. Require the box to
hold the probed point. Mask the extra identity looks' fresh frames with the
same dialog box; give the signal quorum the whole frame. Heal re-reads a dialog
target's band on the dialog and moves dialog_region with the point. The
render-scaled view maps the probe. The harness retry tolerates a frame that
detaches while it waits.

* identity: a dialog target's band is the whole dialog

The row of a dialog button often holds nothing but the other buttons, so on
OpenEMR's duplicate dialog the scoped row band was too short to arm identity
and clinical-write would refuse the save. Read every line of the dialog
instead (its result text and count included), at compile and replay, with no
row re-read, and skip the row census for a dialog target.

* throughput harness: --backend, to replay with the backend the bundle was recorded with

A bundle the demo's record command makes carries the OpenEMR adapter's form
field identity; replayed through the base Playwright backend, its first step
reads only the field label and halts.

* benchmark: identity at OpenEMR's duplicate dialog, before and after #1347

On the OCR band path the main bundle refused the confirm step at 8 of 8
form scroll positions behind the dialog; the dialog-scoped bundle verified at
all 8 and still refused a dialog that lists a possible duplicate. 26-item
worklists: 26/26 correct on both.

* Register the dialog identity measurement and add its capability row

* fix: preserve dialog row identity (untested WIP)

Signed-off-by: abrichr <[email protected]>

* test: pin dialog row and mask refusal regressions

Signed-off-by: abrichr <[email protected]>

* fix: keep dialog identity checks generic and typed

Signed-off-by: abrichr <[email protected]>

* test: separate modal row text from button crops

Signed-off-by: abrichr <[email protected]>

* fix: publish repair hold metadata atomically

Signed-off-by: abrichr <[email protected]>

---------

Signed-off-by: abrichr <[email protected]>
Base automatically changed from agent/142-c2 to agent/142-c November 5, 2026 04:45
* chore(release): prepare openadapt 1.20.0 on Flow 2.6

Signed-off-by: abrichr <[email protected]>

* chore: refresh published platform components for launcher draft

Signed-off-by: abrichr <[email protected]>

* chore: synchronize launcher candidate lock version

Signed-off-by: abrichr <[email protected]>

* chore(release): lock launcher against published Flow 2.6.0

Signed-off-by: abrichr <[email protected]>

---------

Signed-off-by: abrichr <[email protected]>
* feat: collect explicit benchmark timing observations

Signed-off-by: abrichr <[email protected]>

* fix: narrow optional timing values before comparison

Signed-off-by: abrichr <[email protected]>

* test: preserve timing coverage and outer phase diagnostics

Signed-off-by: abrichr <[email protected]>

* fix: bind untimed observation fields by name

Signed-off-by: abrichr <[email protected]>

* ci: check the generic benchmark timing contract

Signed-off-by: abrichr <[email protected]>

* fix: retain missing named phase coverage explicitly

Signed-off-by: abrichr <[email protected]>

---------

Signed-off-by: abrichr <[email protected]>
…script-sent saves (#1851)

* flow: API steps for multipart saves, the clicked button's value, and script-sent saves

* flow: types and formatting for the new API step shapes

* benchmark: API-step generalization harness for five apps (Redmine, WordPress, NetBox, Dolibarr, Trac)

* flow: real-app test of the new API step shapes on the lab fixtures (opt-in)

* llms-full: refresh for the API-step capabilities

* fix: bind API form shapes to native submission evidence

Signed-off-by: abrichr <[email protected]>

* fix: preserve refusal wording and settle synthetic form capture

Signed-off-by: abrichr <[email protected]>

* fix: compare form destination with transport URL construction

Signed-off-by: abrichr <[email protected]>

* benchmark: generate the NetBox fixture's superuser token per run; pin the old synthetic literal's exact gitleaks finding

---------

Signed-off-by: abrichr <[email protected]>
* No command falls back to the demo app on its own

replay with no --url (and no backend.url) served the bundled MockMed
app; it now needs --demo-app for that, and refuses otherwise. --drift
needs --demo-app. The launcher help, quickstart docs, the quickstart
lifecycle script, e2e tests and Desktop's frozen smoke test pass it.
The variant router's first-screen probe returns nothing without a URL.

qualify accept's break-it gate ran every workflow's proposed oracle
against MockMed (its banner text, /api/encounter, /api/db). It now
builds the lie from the workflow: an in-process store that did not
change, and a screen that shows the workflow's own success text, every
typed value, and whatever a screen read looks for.

The reference Execute worker ran the MockMed tutorial driver against
any admission's target_url (resetting and reading it as MockMed). It
now refuses such an admission before anything runs.

* Review fixes: --demo-app tests, attached-browser refusal, synthetic targets

Adversarial review of the fallback change: two tests replayed without a
target (now --demo-app); --demo-app with an attached browser was
silently ignored (now refused); a synthetic Execute admission that names
a target_url was reported as verified without running (now refused
before anything runs); the bug template shows --demo-app.

* Frozen smoke test passes --demo-app only to a Flow that has it; format

Desktop's frozen engine pins a released Flow, which serves the demo app
whenever no URL is given and doesn't know --demo-app.

* Re-run CI

* Lower the engine generality baseline: no command names the demo app now

---------

Signed-off-by: abrichr <[email protected]>
…al systems of record (#1937)

* feat(policy): add record-write, a general write policy for non-clinical systems of record

* release: register record-write.yaml in the public artifact inventory

check_release_consistency.py refused the tree: the new policy file was not
in public-artifacts.json.
@abrichr

abrichr commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Answering BLOCK 6648883346 at 62c4849.

P2, unrelated local files: build-image.sh no longer copies the deploy directory with an exclusion list. It copies a named list into the build context: Dockerfile, docker-compose.yml, onprem.example.yaml, README.md, COMPLIANCE.md, UPDATE.md, and the four scripts the image runs (run-queue.sh, audit-log.sh, verify-airgap.sh, run-egress-blocked.sh). The scripts keep their modes. A named file that is missing, or is a symlink, stops the build. Host-only installers, units and build or test scripts stay out too.

P2, network base resolution: before docker build, it now runs docker image inspect on the base (--base, or the Dockerfile's digest-pinned default) and refuses if the base isn't loaded locally. It always passes that same base as BASE_IMAGE, so the image it checks is the one it builds.

test-container.sh now:

  • Builds the signed release from a copy of the deploy directory with bin/site-notes.txt, vendor.key, onprem.yaml and storage/left.job placed beside the scripts. It checks that the image's /opt/openadapt-flow/deploy/on-prem holds exactly the 10 named files.
  • Checks that an absent --base is refused with "is not in the local image store" before "== building", and that no image is tagged.

Testing, and what wasn't tested: Docker isn't available to me (the Mac's daemon isn't running, and I couldn't install it on my Linux box), so I haven't run the new test-container.sh checks against real Docker. Since this PR targets agent/142-c, the flow-onprem-offline job doesn't run on it either. Instead I ran build-image.sh against a recording docker shim, with a signed placeholder release and a site copy holding those stray files:

  • Here, the context is the 10 named files. A default or --base image that isn't loaded is refused after one image inspect and before any build, and a loaded one builds with BASE_IMAGE set.
  • At d6db42e, the context also held bin/site-notes.txt, vendor.key, onprem.yaml, the installers, launchd/systemd and the test scripts, and both absent-base cases went straight to docker build.

shellcheck finds no warnings. test-update.sh passes 49, and test_onprem_reboot.py plus test_onprem_service.py pass 41. GitHub reports this PR as conflicting with agent/142-c in .github/workflows/packages.yml. That conflict was already there, and this commit doesn't touch that file. I left it alone.

@abrichr abrichr added the review-needed Needs an independent review from another vendor's agent label Nov 5, 2026
…e version authors (#1854 a+b) (#1873)

* ensure_record: bind the read-back to the last version written, and name each version's author (#1854)

* ensure_record: keep the version_trail docstring app-neutral for the engine generality check

* fix(flow): order ensure-record versions by instant, not timestamp text (#1925)

* fix(flow): order ensure-record versions by instant, not timestamp text

version_trail sorted versions by when.isoformat(), which keeps each
timestamp's own offset, so versions written with mixed offsets came out
in the wrong order and the audit trail's n-th write named the wrong
version's author. bookkeeping_only sorted its chain by the raw
lastUpdated text the same way; with a later version's time out of
place, a bookkeeping stamp ten minutes from its own version's time was
checked against the later version and excused.

Both now sort by the parsed instant. In bookkeeping_only a version time
that cannot be read (unparseable or with no time zone) now refuses the
bookkeeping excuse instead of being sorted as text.

* fix(flow): name version authors from the audit order only when it is unambiguous

* fix(flow): refuse bookkeeping when two versions share an instant

Versions with the same lastUpdated instant have no known order, and the
server's return order is not a chronology. bookkeeping_only now refuses
the excuse instead of keeping response order for ties, so a newest-first
history can no longer hide a final stamp change.

* fix(flow): narrow version instants to datetime for the sort key
…h trial (#1936)

* fix(qualify): let verifier.session renew FHIR and GraphQL read-back credentials

* fix(qualify): refuse a session hook that opens no client
…1944)

* fix(flow): read bundle manifest and API proposal sidecars as UTF-8 (#1845)

* fix(flow): read and write the deploy config and its workflow inputs as UTF-8
@abrichr

abrichr commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

From: Grok, To: coordinator

Merged origin/main at bc5357a (conflict-free locally after deepening the shallow main fetch). onprem_service+reboot: 41 passed.

This branch has not been deployed

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

Labels

review-needed Needs an independent review from another vendor's agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant