feat(deploy): L5 journey suite, fleet substrate, CI gates (owner-gated merge) - #346
Draft
Yambr wants to merge 119 commits into
Draft
feat(deploy): L5 journey suite, fleet substrate, CI gates (owner-gated merge)#346Yambr wants to merge 119 commits into
Yambr wants to merge 119 commits into
Conversation
A runnable walkthrough of each architectural security and data-flow guarantee, one step at a time, with the command and the expected observable. Aspects 2, 3, and 4 carry a firsthand run: a green baseline and a neutered counter-case that reds (the IaC gate's own --self-test, a red-probe for the cross-tenant and F9 guards), against the merged/shipped code. Front-matter and curl examples use env-var placeholders so no secret-shaped literal enters the history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
45 end-user-journey scenarios (7 groups: auth/bootstrap, docx create+download, upload/edit/download, authz boundary, auto-disconnect/lifecycle, agentic load, adversarial). Each scenario is paired: it runs against the PoC (Open WebUI + computer-use-server) and the fleet (gateway mTLS -> control -> gVisor guest -> FUSE -> egress edge -> filestore -> MinIO), and asserts the per-backend end state plus a keystone that stays reproducibly reddable. A pytest spine drives real Backend verbs; scenarios.yaml is the single source of truth and renders CONTRAST.md. The fleet leg runs live only under Lima + runsc and loud-skips elsewhere (never a mocked green); the exec_sh chokepoint busybox-prefixes every in-guest command, guarded by a meta-test that greps the suite for bare sh/python3 argv. Two watchable bash demos narrate the two journeys. Run firsthand against the live fleet, the suite exposed three real defects, recorded in FINDINGS.md as strict issue-linked xfails (keystones not weakened): the storage-write plane does not round-trip (mount Put omits the required declared_size_bytes; the stand-in read plane is 501), the concurrency counter leaks under the kill-switch and wedges the deployment at the tier cap, and the mount-facing network is internal:false so a guest reaches the public internet. Two deploy fixes land alongside: control now depends_on harness-init (it writes the CA the control plane latches at boot, so a clean down -v + up self-heals) and allow-lists the busybox-carrying demo guest image. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZogNpHHSvgo41RsoBmsxH
The fleet compose healthcheck called /ocu-mcp-gatewayd but the gateway image entrypoint is /usr/local/bin/ocu-mcp-gatewayd, so the check failed its stat and the container never went healthy though the process served MCP on :8080. Point the healthcheck at the entrypoint path (canonical and permanent, mirrors control). G3 and E8 issued a guest exec immediately after create, before the guest boot-child brings up the FUSE mount and exec plane, so the op was denied (exit -1) and the test read a boot race as a failure. Wait for the exec plane with await_fleet_exec_ready before the audited/keystone exec, the same gate the B group already uses; both now record their remaining gaps as honest xfails rather than a boot-race red. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZogNpHHSvgo41RsoBmsxH
Add the OpenWebUI client leg to the fleet compose, repointed at OUR MCP gateway instead of the old monolithic Computer Use Server. The old tool already speaks MCP tools/call; here it targets mcp-gateway:8080 with an sk-ocu- boot-set key and MCP-Protocol-Version. A tool-call flows OpenWebUI -> gateway (auth + validate) -> control (create) -> gVisor guest, and executes behind the gateway; the control plane owns the session lifecycle (the tool never destroys). - open-webui + its OWN openwebui-db (never control-db) on ocu-frontend, host port 3001 (3000 is the fleet webui BFF). - mcp-gateway now runs the G4+G6+G7 combined image with the G7 durable audit sink (-audit-sink) so a valid keyed create reaches 201 instead of the fail-closed 500, plus the G6 readiness healthcheck. - mcp-gateway-audit-init chowns the one writable audit-journal mount to the gateway uid, mirroring webui-audit-init. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZogNpHHSvgo41RsoBmsxH
The exec-forward runs a bash_tool command as an argv in the guest. The demo guest shipped only /bin/busybox with no /bin/sh, so a tool command spawned via a shell path failed ENOENT and the exec fail-closed with a 409. An image that supports bash_tool must guarantee a POSIX /bin/sh so the gateway need not know the image internals; symlink /bin/sh onto the static busybox to provide it. Pairs with the gateway sending /bin/sh -c (not bash -lc: -l is non-POSIX and a login shell is not needed for a tool-call). Follow-up: the prod assembled image must also ship a /bin/sh or it hits the same ENOENT.
The File Pane (component-08) is an embeddable SPA that never self-issues its bootstrap credential: it trusts exactly one parent origin (NEXT_PUBLIC_OCU_PARENT_ORIGIN, strict equality) and waits for the parent to postMessage a peer-minted embed token. The fleet had no such parent, and the build-arg was unset, so the pane could never bootstrap in a browser — it hung on "Loading files…" forever. embed-portal is that parent: a tiny Go service that iframes the pane, mints a short-lived HS256 embed token server-side (aud ocu-webui, exp 60s under the 120s ceiling, sub/filesystem_id/intent claims the BFF requires), and postMessages it into the iframe at the pane's literal origin. It holds the embed-verify secret in a separate origin/process, preserving the invariant that the webui origin never mints its own bootstrap credential — the same role a customer portal/IdP fills in production. Compose wiring: portal published on :3003; webui gets NEXT_PUBLIC_OCU_PARENT_ORIGIN as a build-arg (NEXT_PUBLIC_* inlines at build, not runtime) and the portal origin in its frame-ancestors allowlist. Open the demo at http://localhost:3003 (localhost, not 127.0.0.1 — strict origin equality plus the SameSite=None;Secure session cookie requires a localhost http origin). Proven live in a real browser: bootstrap -> list -> upload round-trips end to end; the download 403 on an uploaded file is the NFR-SEC-73 control (downloadable is a read-time egress tag, default false), not a defect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZogNpHHSvgo41RsoBmsxH
…gateway requires The tool's manual `/mcp` initialize preflight sent protocolVersion "2024-11-05" in the JSON-RPC body and omitted the MCP-Protocol-Version HTTP header. The next/v1 MCP gateway negotiates the version through that header and rejects a request that carries the wrong version, or none, with -32602 "unsupported or missing protocol version" (HTTP 400) before auth. The preflight treats anything other than 401/403 as a broken server, so every tool call surfaced [CONFIG ERROR] and never reached the real MCP SDK call (which sets the header itself). Pin the version as `_MCP_PROTOCOL_VERSION = "2025-06-18"` and send it in both the header and the initialize body. Verified live against the fleet stand: the browser bash tool now runs into the guest and returns output (no [CONFIG ERROR]). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…etworks The PoC guest image installs ~24 npm globals with binary postinstalls (phantomjs via markdown-pdf, sharp, playwright). Under an emulated (qemu amd64) or otherwise slow network, npm's default 300s idle timeout aborts mid-fetch (EIDLETIMEOUT), failing the build. Seed the assistant user's npmrc once after useradd so every later global install retries with a 600s ceiling. No effect when the network is fast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
Running `glab config set check_update false --global` at build time crashes under qemu-user emulation: glab is a pure-Go binary, and Go's lock-free stack hits its 48-bit pointer-packing assertion (lfstack.push) when qemu-user returns higher addresses. Building the x86_64 image with --platform linux/amd64 on an arm64 host runs that step emulated. Replace the execution with a direct write of the config.yml/aliases.yml artifacts glab would have produced (check_update:false is the only non-default line the step set). Shipping the artifact instead of running the producer is byte-identical on the emulated and native paths — no image drift, and no guard that could mask a real native failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
The image was x86_64-hardcoded (node linux-x64, ttyd.x86_64, glab_amd64,
JAVA_HOME .../-amd64, a vendored x86_64 extract-text ELF), so it built
only under --platform linux/amd64 — and on an arm64 host that means qemu
emulation, where Go binaries crash at build time and every download is
slower. Parameterize each arch-specific site on the buildx-provided
TARGETARCH so one Dockerfile builds both amd64 (x86_64-faithful) and arm64
(native on aarch64 hosts, incl. gVisor):
- node: linux-${x64|arm64}
- ttyd: ttyd.${x86_64|aarch64}
- glab: glab_..._linux_${amd64|arm64}
- JAVA_HOME: a stable symlink to java-21-openjdk-${TARGETARCH}
- extract-text: the vendored binary is x86_64-only; install it on amd64,
omit it on arm64 with an explicit build note (never ship a broken ELF
that pretends to be present). Ship an arm64 build to close that gap.
bun and codex resolve their own arch at install; playwright installs
per-arch — no change. Verified firsthand under linux/arm64 buildx: node
v22.11.0, glab and ttyd extract as ARM aarch64 ELFs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…grants Seed a workspace-model record per surviving catalog model with base_model_id=null so meta.toolIds surfaces into the resolved /api/models (Open WebUI's get_all_models applies a null-base record as a direct override; a self-referential base_model_id lands on the skip path and the tool never reaches the model). The catalog is trimmed to Qwen + DeepSeek flash, native function-calling is set, and the fresh-chat default is deepseek-flash — so any chat a user opens has the Computer Use tool live without a manual toggle. Also seed public read access_grants on each model record: a seeded record with empty grants is dropped by get_filtered_models for non-admin users, who would otherwise see zero models. Verified firsthand in the real browser on the default deepseek-flash chat: bash_tool runs and returns real guest output (echo 7*7 -> 49, pwd -> /), and create_file surfaces the gateway's clean "unimplemented tool" error in the UI (no hang) then falls back to bash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…nly) markdown-pdf pulls phantomjs-prebuilt, which ships x86_64 binaries only (abandoned, predates arm64 Linux); its postinstall hard-fails on a native arm64 build. Filter it out of the global install when TARGETARCH=arm64, so the arm64 image builds clean. The markdown->PDF skill helper is then unavailable on arm64 — a documented host-ISA gap, symmetric to the x86_64-only extract-text helper; amd64 keeps it. Verified the filter: amd64 installs 21 globals (markdown-pdf included), arm64 installs 20. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
Cover the MCP tool surface (bash_tool, str_replace, create_file, view) end-to-end against the live fleet, derived from the PoC behavior in computer-use-server/mcp_tools.py. mcp_tool_surface.feature is the Gherkin source of truth; @L4 scenarios are proven in the gateway forward e2e, @l5 here as live journeys through the real gateway on 127.0.0.1:8080. Group I proves what only a live guest can settle: I1 a real non-zero exit transports to isError, empty-output non-zero carries the synthesized "[Exit code: N]" marker (verified live) I2b a moderate output returns whole; I2 (oversize) is a bounded result I3 a command past the exec-timeout is KILLED (enforcement holds) I4 create_file EACCES is a guest-identity contrast + writable keystone I5 one chat maps to one persistent workspace; a different chat is isolated (per-session, not a shared global fs) I6 the four tools compose over one workspace (create->view->str_replace ->bash = ALPHA EDITED), gated on a python3-bearing guest I2 and I3b are xfail(strict) pending DEFECT #127: oversized output and a timed-out command surface as a 502 forward-refusal that loses the whole result, where the PoC returns a bounded/timeout-noted tool result. The strict marker XPASSes -> reds the suite the moment the fix lands, forcing the marker's removal in the verifying PR. Root cause: gateway maxReplyBytes=64KiB read-cap vs control 8MiB stream-cap — a cross-component sizing-invariant the two repos never cross-checked. Firsthand on the live Lima stand: 6 passed, 2 xfailed. I5 persistence is red-probed non-vacuous (break the session -> the journal reds). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…t to #129 #127 step 1 (gateway raise maxReplyBytes) kills the 502 for outputs up to the read-cap but does not deliver the 64KiB caller ceiling (that is step 2, control-side, #128). Per the multi-step-xfail rule, split so each xfail tracks exactly one un-landed step and none sits red-hidden across PRs: I2 -> plain green: the step-1 "nothing lost" contract (a 120k output that used to 502 now returns HTTP 200 with the output intact, len>=120000). Strict whole-return is a designed paired-flip with I2c: when step 2 lands, I2 reds as I2c strict-xpasses, forcing the step-2 PR to merge them into one bounded-contract test. I2c -> new strict-xfail (#128): the 64KiB caller ceiling + truncation marker. i2b -> unchanged 30k whole-return keystone; stale base64 NOTE dropped. I3b -> re-pointed from "#127 same class" to #129: the timeout 502 is a SHAPING defect, not the size class. Firsthand on the live stand: control returns HTTP 409 on a timed-out exec -> gateway 502; a "echo MARKER; sleep 600" loses the partial output (the PoC preserves it + a timeout notice). sleep 600 emits zero stdout, so #127's maxReplyBytes raise could not have touched this path. Firsthand on the live Lima stand (gateway rebuilt from PR #43): 7 passed, 2 xfailed. The 502 for oversized output is dead; the timeout 502 (#129) and the 64KiB ceiling (#128) remain, each tracked by exactly one strict-xfail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
#129 fixed) Control PR #63 shapes a host exec-timeout into a valid exit-124 reply with the pre-kill partial output + a "[Command timed out after Ns]" notice in the stream the gateway relays on isError — so the killed command is a usable Tier-2 tool result, not a 502 that loses everything. I3b was strict-xfail(#129); the fix made it XPASS (strict -> reds the suite), so per the multi-step-xfail rule it flips to a plain green assert: a timed-out command returns HTTP 200 + isError, carrying its PARTIAL output AND the notice. Firsthand-verified on the live Lima stand (control rebuilt from PR #63): "echo MARKER; sleep 600" -> HTTP 200, isError:true, content "MARKER\n\n[Command timed out after 30s]\n" (was 502, MARKER lost). Group I on the live stand: 8 passed, 1 xfailed (I2c still tracks #128). I3 still proves the KILL; I3b now proves the RESULT SHAPE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…nded) Control PR #64 bounds each F5 exec-reply stream at 64KiB at the source (stdioCap 8MiB->64<<10) + a truncation marker, so an oversized output is a bounded tool result — not a 502 (#127) and not relayed whole (#128). The designed paired flip fired: I2's whole-return-of-120k assert reds AND I2c's 64KiB-ceiling strict-xfail xpasses at the same moment #128 lands, so per the multi-step-xfail rule they merge into ONE plain-green bounded-contract test: HTTP 200 + isError:false + DATA truncated to <=64KiB + a truncation marker + a keystone that a small output comes back whole and un-truncated. Firsthand-verified on the live Lima stand (control rebuilt from PR #64, FAT-guest override re-applied): "yes X | head -c 120000" -> 65535 bytes of data + "[output truncated at 65536 bytes]" (was 502, then whole at 8MiB). Group I: 8 passed, 0 xfailed — the tool-surface arc's three defects ([Exit code:N], large-output 502, timeout 502) are all closed end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…un journeys The fleet stand was runnable only on one laptop: the compose bind-mounts ./fixtures/guest-config.json, the guest-image Dockerfile, the demo scripts, gen-gateway-pki.sh, README, .env.example, and the group-H journeys were all UNTRACKED, and the compose diff carried a security default (downloadable prefixes -> /outputs, NFR-SEC-73) that lived nowhere in git. A clone could not build or run anything. This commits the deployable substrate: - docker-compose.fleet.yml: the -downloadable-prefixes /outputs exfil default + OCU_DOWNLOADABLE_PREFIXES / OCU_GUEST_IMAGE env seams. - .env.example: documents OCU_GUEST_IMAGE (the file tools need a python3-bearing FAT guest; the stripped default runs bash-only — see #122) + the prefixes. - fixtures/guest-config.json: the mount config the guest reads (placeholders rendered at bring-up; no secrets). - guest-image/Dockerfile, gen-gateway-pki.sh, exec-demo.sh, storage-chain-demo.sh, g7-visualizer/, README.md: the bring-up + demo surface. - test_h_gateway.py: the group-H MCP gateway auth-edge journeys. Scrub: process_api kept only where load-bearing (the real guest-agent binary COPY/ENTRYPOINT + the real image tag in executable build/create commands); every prose/comment mention genericized to "the guest agent" (identifier-vs- provenance rule). The H1 forged-key fixture uses the allowlisted sk-ocu-wrong placeholder. Secrets (gateway-pki/, secrets/, .env) stay gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…le comment The fleet journey suite (deploy/tests/journeys/) ran only when a human remembered to, on a live Lima stand — no CI ever imported it, so import rot or fixture drift would land silently. journeys-collect.yml adds a tier-1 gate on every PR touching deploy/: compileall + `pytest --collect-only` (115 tests collected firsthand, 0.18s), no live run. The tier-2 live-stand gate (needs a real fleet a GH runner lacks) is a separate follow-up. Also correct a stale compose comment: the mount root_path fix it called "task tracked" was in fact REJECTED (ocu-control #113) in favor of storage-engine scope resolution from the token intent claim (ADR-0029, shipped #116/#117/#118). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…cap invariant Every mcp_tool_surface.feature scenario now maps to a named covering test — the 13 @L4 to ocu-mcp-gateway forward-e2e (TestL4*), the 10 @l5 to group-I journeys (test_i/test_h). 23 scenarios, 0 unmapped (the "18" I'd claimed miscounted the Scenario Outline). Pins the large-output cap invariant in one committed place: gateway.maxReplyBytes >= 2*ceil(control.replyCeiling*4/3)+envelope, control 64KiB < gateway 256KiB; the F5 exec-reply schema follow-up is tracked as #344. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
… a clean clone The final-proof run from a fresh in-Lima clone @bc8faa3 caught it: the journey suite needs pytest + pyyaml, undocumented, so a clean clone stops at `ModuleNotFoundError: yaml` before any test runs. requirements.txt pins both; README adds the venv + install + pytest steps and notes the file-tool legs need the OCU_GUEST_IMAGE=poc-fat-arm64 override (#345). Verified: from the clean clone with these deps installed, the full A-I suite is 23 passed, 92 skipped, 0 failed against the live stand (gateway f7b6e5c, control 37d6492) — the arc's coverage runs from pushed state, not just this laptop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…eview A fresh-clone review (Fable) found the arc's own tests were not reproducible from pushed state. Four fixes: 1. Vendor scripts/mint_boot_set.py into deploy/fleet/scripts/ — the H/I journeys need a minted boot-set + bearer, but the minter lived only in ocu-mcp-gateway, so a clean clone of THIS repo could never render it and every gateway journey skipped (green-by-skip on missing substrate). It is standalone stdlib; the vendor note points at the gateway as the formula owner. 2. journeys-collect.yml installed only pytest while conftest imports yaml — the tier-1 gate would ImportError on its first run. Install -r requirements.txt. 3. Dead ticket refs in pushed files: FINDINGS cited "ocu-mcp-gateway #131" (the real anchor is PR #44/f7b6e5c); the compose comment cited a bare "ocu-control #113" + an ADR path absent from this branch — both de-numbered to repo-neutral descriptions a reader here can actually resolve. 4. README documents the mint step before the pytest run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…int runbook
mint_boot_set.py validates its rendered boot-set against
contracts/mcp/mcp-key-set.schema.json resolved next to the script, but that
schema was never committed here — it lived only in the gateway/control repos.
A clean clone therefore crashed the minter with FileNotFoundError before it
could render a boot-set, so the H (gateway auth-edge) and I (tool-surface)
journeys skipped for lack of a boot-set: green-by-skip, not a pass.
Vendor the schema alongside the minter (its doc-comment already promised it
sits there) and fix two runbook gaps in the README that would leave a clean
clone unable to run H/I:
- --deployment is required and MUST equal the gateway's -deployment
(fleet-local); a foreign-deployment record 401s (ADR-0027).
- the minter prints the bearer to stdout; the tests read it from bearer.txt,
so the runbook now captures the printed line there and recreates the
gateway to reload the boot-set.
Verified from a truly clean in-Lima clone that renders its own boot-set: the
minter exits 0, the live gateway binary accepts the clean-clone-minted key
(200, not 401; a forged key still 401s), and H (5) + I (8) run green against
the live stand instead of skipping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
… order) The prior runbook redirected the bearer into deploy/fleet/secrets/gateway/bearer.txt, but the shell opens that redirect target before the minter creates --out-dir, so a clean clone failed with "No such file or directory" and a BrokenPipeError. mkdir -p the out-dir first, write the minter's stdout to a temp file, then take the last line as the bearer — so a mint failure leaves a traceback in the temp file, never a half-written bearer.txt. Verified verbatim from a fresh in-Lima clone of the pushed tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…at-guest build Two closeout gaps a final review surfaced: 1. Nothing exercised the minter, so the vendored contracts/mcp schema could be deleted or drift and CI would stay green while a clean clone crashed with the same FileNotFoundError the vendoring just fixed. Add a mint-smoke step to journeys-collect.yml: it runs mint_boot_set.py against a throwaway deployment + out-dir (stdlib-only, no live stand, no pip) and reds if a boot-set cannot render. Red-probed: hiding the schema makes the step fail; restoring it passes. 2. The group-I file-tool legs need a python3-bearing guest, but no build recipe for one lived in the tree — the "13 passed" outcome leaned on a Lima-local image tag. Document the build (layer deploy/guest-image/Dockerfile over the repo-root PoC userland base) and state the expected counts so a third party can tell honest-green (13 passed / 0 skipped with a python3 guest) from green-by-skip (10 passed / 3 skipped on the stripped default). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…d .env.example Follow-on cleanup after the fat-guest recipe landed: .env.example cited ocu-guest:poc-fat-arm64 (task #122), but the README build recipe produces ocu-guest:poc-fat (task #345) — a reader following the .env.example comment would set a tag the recipe never builds. Point the comment at the README recipe and the poc-fat tag. Add --platform linux/amd64 to the base-build step per repo policy, with the Lima-native drop note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The first tool call in a fresh Open WebUI chat wrote to /home/assistant and died on the read-only rootfs: the model gets zero path guidance. The old filter's /system-prompt fetch points at the MCP gateway, which fronts tools/call only, so the injection degrades to nothing. Bind a fleet-true system prompt (filesystem map, session semantics, verify-your-work) onto every seeded model via params.system in init.sh. Raise the default -session-idle-ttl 1m -> 15m (the NFR-SEC-40 ceiling): a 1m window reaped the guest while the user was still reading the reply, wiping /tmp and the scratch home mid-conversation. Short values stay one env var away for reaper demos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
A re-minted boot-set updates what the gateway accepts, but the chat leg keeps presenting the OLD bearer from its tool Valve (seeded from MCP_API_KEY on first boot). Every chat tool-call then dies 401 wrapped in the MCP SDK's opaque cancel-scope transport error while the journey suite - which reads bearer.txt directly - stays green. Add the .env update + Valve re-seed steps to the runbook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
…122) The compose default was the distroless assembled tag: no shell, no python3, so the first chat input in a fresh deploy hit a dead guest (file tools project onto python3). Default to ocu-guest:poc-fat (build recipe already in the README journey-suite section) and keep the stripped tags one env var away for the minimal rung and the storage-chain demo scripts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLSUfSnuBtUftkxB7RvA72
… semantics The per-model seeding loop walks base-catalog ids only, so a legacy derived record (unique id + base_model_id set, e.g. an ocu-* alias of a kept base) kept stale params forever. When such a record is the fresh-chat default, every new chat runs with NO system prompt while directly-seeded models carry one. Step 2b now walks workspace records and re-binds tool + native FC + prompt onto every record whose base points into the kept set. The /mnt/user-data prompt bullet now states the exchange semantics the guest actually observes: user uploads are readable in place, saved deliverables write through to the user's Files panel and may leave the guest's listing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014g5EttcR8mqYDn8EByShUL
…hand The valves endpoint REPLACES the stored object. Listing only the keys a change cares about drops the rest: adding the two resolve-scope valves silently removed PREVIEW_MODE and both label valves, and with no PREVIEW_MODE the outlet appends no preview link at all. The panel stops offering Preview and nothing says why — the failure surfaces two layers from its cause. Measured on the stand: the stored key set was exactly the seven this script wrote, and PREVIEW_MODE read back empty. Co-Authored-By: Claude <noreply@anthropic.com>
M12 walks the scenario the owner reported broken, in one browser session with nothing staged: a save-request in a fresh chat, the model emitting the download marker ITSELF, the filter minting a link carrying that chat's own storage scope, the panel opening inside the chat, listing this chat's file and no other's, Preview clicked and rendering the real content, Download returning bytes, the chat-emitted link returning the SAME bytes, and both the no-session and wrong-scope requests refused. Openness of the panel is measured from the frame's bounding box against the viewport, not from is_visible(): a slide-in panel parked at translateX(+width) is "visible" to the DOM while sitting entirely off-screen, so is_visible() reports a closed panel as open. Two hops carry a recorded red-probe. Rebuilding the panel without NEXT_PUBLIC_PREVIEW_RENDER_ENABLED reds the Preview assertion verbatim and restoring the flag greens it, which also proves the compose build-arg is load-bearing. Emptying the RESOLVE_SCOPE_URL valve reds the per-chat-scope assertion with the base scope named in the message. The docstring records only those two, and records that the filter's PREVIEW_MODE valve does not red this test in either broken form: it governs the preview button appended to a chat message, not the panel row's control. Co-Authored-By: Claude <noreply@anthropic.com>
The foreign-scope leg ran on the same sessionless context as the no-session
leg. A caller with no pane session is refused whatever scope it asks for, so
that request re-measured the no-session refusal: the assertion would have
stayed green through a total scope leak, and the test's own passing output
showed it, since the line above it gets 401 for the CORRECT link from that
same context.
The refusal now runs on the session that just downloaded successfully, and a
control asserts that same session is served its OWN scope. If the control
does not pass, the refusal is unattributable and the control fails saying so,
rather than the refusal passing for the wrong reason.
Red-probed: replacing the foreign scope with the caller's own reds the
assertion verbatim ("a live session for fs-fleet-00cc2794 was served the
foreign scope ..."), so it discriminates the scope rather than the session.
Co-Authored-By: Claude <noreply@anthropic.com>
The panel hop asserted only that the chat's own file appears. That check survives the failure it exists to catch: when the panel falls back to the shared tree the chat's file is still among the rows, so a total loss of per-chat isolation reads as green. The docstring already claimed "and no other's" while nothing measured it. The panel now must offer exactly one Download control, the number of files this chat produced. The bound is measured rather than assumed: the same panel opened on the base scope offers 925 Download controls and on a fresh chat scope 0, so requiring 1 discriminates against the shared tree. Also recorded, from probing it: breaking the portal's scope resolution empties the panel instead of widening it, so that lever reds the presence assertion rather than the count, and no configuration widens the panel without reddening an earlier hop first. Co-Authored-By: Claude <noreply@anthropic.com>
Dropping the chat's origin from OCU_FRAME_ANCESTORS and leaving the portal's in place reds M12 with "the pane frame never attached inside the panel", so the panel hop binds the allowlist rather than merely running alongside it. The two-origin default is the reason: frame-ancestors is evaluated against the whole ancestor chain, and the chat frames the portal which frames the pane. Co-Authored-By: Claude <noreply@anthropic.com>
Pointing the chat link at an object that does not exist in the same scope never produces a download, so the byte comparison reds. The link resolves the object it names rather than serving whatever the scope happens to hold. The comparison was already anchored outside the two delivery surfaces: the body carries a per-run random marker, so panel bytes containing it can only be the file this run produced, and the link's bytes are then held to that. Co-Authored-By: Claude <noreply@anthropic.com>
_pane_session minted a portal token with no chat, so it always bound the base scope, while _guest_exec writes into the chat's own subtree. Under per-chat isolation those are different trees, so a list issued through this helper could not see what the guest had just written. Measured, not inferred: the same chat id gives scope fs-fleet (no chat) versus fs-fleet-7e502c54c4e845d0 (with one), and a guest that writes probe.txt into /mnt/user-data/outputs is then listed as ['probe.txt'] by a pane session bound to that chat — the write propagates and the chat-scoped pane sees it. The four M call sites pass their own chat id. This does not by itself green M1-M11; their remaining failure has a separate, still unmeasured cause. Co-Authored-By: Claude <noreply@anthropic.com>
…scope The browser leg navigated to the portal with no chat, so the pane it framed bound the base scope while the guest had written into the chat's own subtree. The file was in the store and in the north-face list, and the browser still could not see it. M6 fails before this change and passes after it, with M12 unaffected in the same run. The list-side half (01c9fbc) moved M6's failure from "never appeared in GET /v1/files" to "waiting for locator to be visible"; this closes the browser-side half of the same mismatch. Co-Authored-By: Claude <noreply@anthropic.com>
a9bb0f7 rewrote every portal navigation to name a chat, including ones inside helpers and tests that never define chat_id, and one where the navigation precedes the binding. That turned three tests into NameError/UnboundLocalError and took M12 down with them — a blind edit across seven sites, checked on two. Reverted at the five sites with no chat in scope. M2b, M6 and M12 pass together after this; M2 is back to the AssertionError it failed with before any of this work, not to a crash this change introduced. Co-Authored-By: Claude <noreply@anthropic.com>
01c9fbc passed chat_id=chat_id at four list call sites; two of them sit in functions that never bind it, so those tests raised NameError instead of running. a2aa3af repaired the portal-navigation half of the same blind edit and missed this one, which the next full run surfaced. The argument stays where the name is a parameter or is assigned before use, and is dropped where it is not. M6 and M12 pass together after this; M3 fails as it did before any of this work. Co-Authored-By: Claude <noreply@anthropic.com>
Three times today a broad edit threaded an argument into call sites whose functions never bind it. Each time the tests raised NameError instead of running, and each time the only thing that surfaced it was a full browser run of twenty-five minutes — twice, because the first repair was verified on two tests out of fourteen. pyflakes answers the same question in 0.2 seconds. The guard runs it over every journey module and fails on any undefined name, and skips loudly rather than passing when pyflakes is absent. Red-probed: a planted reference to an unbound name fails the guard, and removing it greens it again. Co-Authored-By: Claude <noreply@anthropic.com>
M12's panel hop rules out the shared tree — 925 rows against 1 — but not a second chat: two chats whose scopes collided would each show one row and pass it. M13 names the property directly. A and B each write a file; A's pane must list A's and not B's, and B's the reverse. Red-probed: binding A's pane to B's chat id reds with "chat A's pane does not list its own file: ['m13b-....txt']" — one row, and it is B's, so the pane binds the chat it is given rather than ignoring the argument. Runs in seven seconds; it drives the pane API, not a browser. Co-Authored-By: Claude <noreply@anthropic.com>
m11 guards that the background poll fires, and recognised a poll tick by the ?order=desc query. ocu-webui 5a93ffe reaches newest-first a different way: it reads EVERY page client-side and sorts, deliberately sending no order parameter, and ships tests of its own that fail a first-page-only pane. The marker therefore vanished from a pane that is correct, and m11 red on it 0 of 22, three runs out of three — while all 22 polls fired. A red that means the implementation changed, not that the product broke. It now asserts what its name says: the poll issued more than one list request in the window without a navigation. Probed by raising the bound, which reds and prints the live count (22), so the assertion reads the measurement rather than a constant. Co-Authored-By: Claude <noreply@anthropic.com>
_preview_text_in_pane navigated to the portal with no chat, so the pane it framed bound the base scope while the file lived in the chat's subtree. m5 died on the first preview, never reaching what it exists to check. This does not make m5 green, and the commit does not claim it does. It moves the failure from "the file never appears in the pane" to the assertion the test is named for: after a str_replace, the pane preview still shows the old text. The first preview now passes; the second is where it stops. Measured on m5's own creation path: create_file through MCP lands the object, the chat-scoped list returns it, and the pane frame renders it at 6 B text/plain — so create, list and render are not the defect. Co-Authored-By: Claude <noreply@anthropic.com>
_wait_content_contains bootstrapped a pane session with no chat, so it polled the base scope for an object that lives in a chat subtree, and waited out its whole 45s budget every time. That is the last of the four places the chat had to be threaded through: the list, the portal navigation, the preview helper, and now the content read. m5 goes from failing at 53s to passing at 13.65s — it stops burning the timeout and finds what it was looking for. The product half was already sound: sampled directly, the pane preview shows the replaced text at 10s, 25s, 45s and 60s after a str_replace. Co-Authored-By: Claude <noreply@anthropic.com>
m3 built its chat id inline in the _guest_exec argument, so the guest wrote into a subtree whose name existed nowhere afterwards. The pane could not be opened on that scope by construction, and the list and preview both looked at the base scope instead. The chat is now a variable and travels to both the list wait and the image preview. m3 goes from failing at 62s to passing at 8.27s. The image preview helper regains the chat parameter it was given and then had reverted when no caller could supply one; m3 now can. Co-Authored-By: Claude <noreply@anthropic.com>
Creating a guest session churns the container network. A browser that navigates inside that window loses its lazy chunks mid-load: the server still serves the full 17 KB of HTML, the container never restarts and its log is clean, but SvelteKit does not mount and the body comes back empty. The five live-model tests all create a guest session and then drive the browser, so all five died on Page.fill waiting for an input that was not rendered. Measured, both directions and order-controlled: body empty at 0s after a guest exec, normal at 20s and 40s; three clean loads before a preamble and three empty after it, then the same with the order reversed. The browser reports ERR_NETWORK_CHANGED and "Failed to fetch dynamically imported module". The login now reloads until the app mounts. m7 moves from a 30s fill timeout to reaching its actual subject — the model runs, drives a bash_tool call, and the assertion that fails is the artifact one. M12 is unaffected. Co-Authored-By: Claude <noreply@anthropic.com>
…rk churn" This reverts commit 8689aa2.
The read-back polled through a fresh _guest_exec chat id, and the comment a few lines up still explains why that was safe: cross-chat was off, so any id saw the same outputs tree. Per-chat isolation retired that. A fresh id now opens an empty tree, so the artifact was invisible no matter how well the turn went — and the assertion blamed the model for a scope the test chose. It now reads in the chat OpenWebUI settled in the URL. Measured on that path before the change: the model issued a correct bash_tool call, the PNG existed in the chat's own scope (m7-318fccf1.png under fs-fleet-d2b42caa), and a direct guest exec in the same chat wrote and listed a file fine. m7 passes for the first time. M12 red in the same run went green after an OpenWebUI restart with no code change, so that was stand wear, not this edit. Co-Authored-By: Claude <noreply@anthropic.com>
…ed them Same stale assumption m7 carried: the read-back polled through a fresh _guest_exec chat id, which used to see the same outputs tree and no longer does. The artifact was invisible however well the turn went, and the assertion blamed the model for a scope the test chose. All three now read in the chat OpenWebUI settled in the URL, the shape already proven on m7. m8, m9, m10 and M12 pass together. Co-Authored-By: Claude <noreply@anthropic.com>
The browser opened the portal with no ?chat= and uploaded into the base scope, while the guest carried X-Chat-Id and mounted the chat's own subtree. The bytes were never where the guest looked: 23 m2 uploads sit in fs-fleet/uploads and none in any per-chat scope. The pane barrier waited for a Download row, which a brand-new chat can never show -- it only ever passed by reading the shared base tree. It now waits for the empty-state line and asserts zero rows, so the barrier states what this chat's scope actually holds. Both legs red-probed: opening the portal with no chat fails the barrier; pointing the guest at another chat fails the byte assertion. Co-Authored-By: Claude <noreply@anthropic.com>
M13 proves the isolation at the API and never opens a browser; M12 opens the panel and stays in one chat. Switching chats is a client-side navigation: nothing reloads and the iframe the panel already holds keeps pointing at the chat the user left. The panel re-checks the path on an interval and remounts. Nothing exercised that. Two real chats, one file each. The panel is opened in the second, then the first is clicked in the sidebar, and the panel must list the first chat's file and not the second's. A page-scoped marker set before the click must survive it, so a full reload cannot pass this by rebuilding the panel from scratch. Red-probed against the live image: dropping the remount call from the panel's interval fails the post-switch assertions and nothing else. Three probe artifacts fixed while writing it, each recorded where it bit: page.url lags the document across replaceState (ask location.href), the portal frame is the one carrying ?chat= and the pane is a frame deeper, and a reader that returns the first non-empty text accepts the empty-state as an answer. Co-Authored-By: Claude <noreply@anthropic.com>
The pane is bound to a chat by the ?chat= the panel puts on the portal URL. With no chat there is nothing to put there, and a portal opened without one binds the base scope: the whole tree, every chat's files, rendered under a control that says "Files produced in this chat". The label asserts the opposite of what is on screen. The panel already declines to mount and shows a hint. M12 and M14 both arrive with a chat open, so neither reaches that branch. The test asserts the page is not a chat before anything else, so a stray chat id cannot make it vacuous. Red-probed against the live image: removing the no-chat guard embeds one pane iframe and fails on the count. Co-Authored-By: Claude <noreply@anthropic.com>
Four files conflicted. Each was resolved by reading both parents against the merge base rather than the markers alone, and each resolution was then checked by an independent pass whose job was to find behaviour that had been dropped. None was found. - .github/workflows/build.yml: next/v1's digest-then-tag sign/promote architecture is kept whole; this branch's per-image SLSA provenance is folded into its sign matrix at the same action SHA, and the attestations: write permission next/v1 lacked comes along. The system-prompt/skills contract test joins the test job. - openwebui/functions/computer_link_filter.py: both branches landed the same scope-segment fix independently, so this branch is a strict superset. Kept whole. The new RESOLVE_SCOPE_URL valve defaults empty, which reduces the scope expression to exactly next/v1's. - openwebui/functions/test_computer_link_filter_download.py: next/v1's 174 lines are a byte-exact prefix of this branch's 269. All ten of its tests survive at the same line numbers. - openwebui/init.sh: next/v1's clearer comment on the download base wins; the valve payload keeps all five of its keys at identical values plus this branch's five. Nothing executable changed for the three runtime files this branch's live measurements were taken against: two are byte-identical, init.sh differs only in comment prose. Co-Authored-By: Claude <noreply@anthropic.com>
Every other M test gates on the browser or on a configured model and skips where neither exists. M13 talks to the gateway directly and had no gate, so on a runner with no fleet it raised "curl transport failure rc=7" and the suite reported a failure where the truth was that nothing had been deployed to test. That is what the collect job hit. The I-suite's equivalent is an autouse fixture, which pytest refuses to let another module call, so its three conditions are mirrored. The asymmetry is kept: an absent gateway skips, a gateway answering 401 fails -- that means the bearer and the running boot-set are from different trees, and skipping there once let a stale bearer pass as a clean capability skip. Both sides measured: with the stand up M13 passes; with the boot-set path pointed at nothing it skips with that reason instead of raising. Every M test that reaches the stand is now gated -- checked by walking each test body for a stand call and for a gate, not by fixing the one that failed. Co-Authored-By: Claude <noreply@anthropic.com>
Both callers reached for shlex.quote, which is the tell that a shell was in the loop at all: with shell=True the quoting was the only thing standing between an image name or a compose path and a second command. Passing a list hands the words to execve, so a value carrying a quote, a semicolon or a newline stays one argument by construction rather than by escaping. docker_cmd is split rather than concatenated because it is a command line, not a word: deployments set it to "sudo docker". Measured both ways with the same hostile input "probe; touch <marker>": through the list form /bin/echo printed it verbatim and no marker appeared; through the old shell form the shell cut it in two and touch ran. Closes the only semgrep subprocess-shell-true finding on this branch. The remaining subprocess findings are the harness driving docker and curl with argv it builds itself, which is a separate call. Co-Authored-By: Claude <noreply@anthropic.com>
The SCA gate runs two passes: CRITICAL including unfixed, then HIGH fixable-only. The second is what reds this branch -- a fixable HIGH has a bump answer, so the gate enforces bump-not-ignore. It printed no findings because format: sarif sends them to a file, which is why the log reads as an unexplained exit 1. pillow 12.2.0 -> 12.3.0 10 CVE mcp 1.27.0 -> 1.28.1 3 CVE pypdf 5.9.0 -> 6.14.2 2 CVE httplib2 0.20.4 -> 0.32.0 1 CVE pypdf 5 -> 6 is a major, so its consumers were checked rather than assumed: the four skill scripts and the server import PdfReader, PdfWriter, generic.DictionaryObject, constants.FieldDictionaryAttributes and annotations.FreeText -- no removed camelCase shim. All five resolve against an installed 6.14.2. Measured with the gate's own flags: fixable HIGH 16 -> 0, CRITICAL 0. The same pillow bump merged separately on main. It does not reach here: main and this lineage share no ancestor, and this branch still pinned 12.2.0 after that merge. Co-Authored-By: Claude <noreply@anthropic.com>
Control answers every unclassified refusal with a bare 409 and an EMPTY body on purpose: the reason lives in the audit stream, never in the response. A guest image the daemon does not have fails at materialize and surfaces here identical to an exhausted quota. The bare "denied:409" sent this investigation through the quota source, the counter schema, the control database and the audit volume before the audit named it -- and the counters could never have answered, because a refused charge is refunded to zero by design. Measured on the live stand, one variable changed: FLEET_GUEST_IMAGE=ocu-guest:assembled-demo -> 32 failed / 57 passed FLEET_GUEST_IMAGE=ocu-guest:poc-fat -> 2 failed / 81 passed The stand's control runs -guest-image ocu-guest:poc-fat and merely allow-lists assembled-demo, which was never built. Thirty of the thirty-two failures were that one absent image. Co-Authored-By: Claude <noreply@anthropic.com>
Both opened the pane with no chat -- which binds the BASE storage tree -- and then named a fresh chat for the guest, whose mount is that chat's own subtree. The bytes land correctly and the guest looks somewhere else, so j2 reads "No such file or directory" and j5b fails on its own precondition instead of on the tamper property it exists to guard. Same defect m2 carried this morning: under per-chat isolation both halves must name the same chat or the test silently measures two trees. Red-probed one side each: pointing the guest at another chat reds j2; binding the pane to another chat reds j5b. Co-Authored-By: Claude <noreply@anthropic.com>
Folding the image name into SessionRef.status broke every test that compares that field for equality -- test_e_lifecycle asserts status == "denied:409" exactly, and the appended "(image=...)" made it never match. The whole e-module went red on a change meant to make one message clearer. status is a contract value, not a message. The diagnosis moves to a new `detail` field nothing compares, so a refused create still names the image without redefining the code beside it. Measured after the change with no other run contending: test_e_lifecycle 7 passed, 0 failed. The 6 failures seen while a full suite ran in parallel were session contention, not this fix. Co-Authored-By: Claude <noreply@anthropic.com>
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.
Forked from next/v1 at a6b48bd. No changes under
docs/architecture/orcontracts/— canon untouched (the three-dot PR diff under those paths is empty; agit merge-treeagainst next/v1 HEAD keeps ADR-0028/0029 and both openapi contracts intact). Merge simulates clean against current next/v1 HEAD.What lands
The L5 user-journey e2e suite + the fleet deploy substrate + tier-1 CI gates. The three MCP tool-surface output-losing defects the suite found are already merged to their component mains and live-verified — this PR carries only the deploy/test tooling that proves them end-to-end:
deploy/tests/journeys/— PoC-vs-fleet journey groups A-I; groups H (gateway auth-edge) + I (MCP tool-surface) close the sk-ocu keyed-create + exit/truncation/timeout/EACCES/persistence coverage below the exec contract.deploy/fleet/— the assembled 8-component stand: guest image, embed-portal IdP stand-in, gateway PKI, the vendored boot-set minter + its key-set schema,.env.example, README run-book..github/workflows/journeys-collect.yml— tier-1 gate: import/collection health + a mint-smoke step (red-probed on schema delete/corrupt/drift).Proof (firsthand, live Lima stand)
From a truly clean in-Lima clone that renders its OWN boot-set via the vendored minter (no symlinked secrets): H (5) + I (8) = 13 passed, 0 skipped. Full A-I: 23 passed, 92 loud-skip (A-G loud-skip when the backend is absent — never mocked green).
Verify it yourself (zero trust in the author)
In Lima
ocu-linux, followdeploy/fleet/README.md-> "Running the journey suite": clean clone, mint the boot-set,.venv/bin/pytest deploy/tests/journeys, compare against the pinned expected counts (13/0 with a python3 guest; 10 passed + 3 skipped on the stripped default).Merged component fixes (already on their mains)
Tracked non-blocking follow-ups
#344 canon exec-reply schema (owner-gated next/v1), #345 demo default python3 guest, tier-2 live-CI gate, JOURNEYS_REQUIRE_FLEET fail-loud knob,
deploy/fleet/contractslint-path gap.Draft: merge is owner-gated.
🤖 Generated with Claude Code