feat(build): add mediated JVM build executor - #192
Conversation
…red build path (ticket 08) Prose and code-comment corrections only — no behavior changes (gate 7 of the host build broker follow-ups, spec .scratch/jvm-build-executor-follow-ups/spec.md): - docs/build-command.md now describes the managed build path (brokered execution via the start/serve parent, OMAC_BUILD_BROKER_REQUIRED=1 managed-mode marker, fail-closed on partial broker tuple, omac build approve + parent-restart requirement) alongside the unchanged direct host-terminal path. The Health/authentication contract row is updated to reflect the shipped broker token + loopback-only endpoints (no longer deferred). The stale v1 approval-limitation section (no omac build approve subcommand) is replaced with the real approve flow. - Code comments claiming per-worktree queue locks are corrected to leaf-keyed serialization with the authoritative lock in host-only build-control/ (buildrun/queue.go, buildengine/engine.go StopOptions + Stop doc, cli/build.go + build_stop.go help text, containerproxy/proxy.go scavenger comment, README). The legacy in-leaf lock fallback is documented as the unmigrated no-parent direct path only. - Comments/help text describing --max-duration expiry as a FORCED cancel (grouped with the second signal) are corrected to the graceful-then-staged-kill path (run.go OnForcedCancel doc, build.go help text, docs/build-command.md Cancellation section) per spec §241. - PR #192 body updated via gh pr edit to reference the broker work and its gates (closes #191, refs #92, implements the broker follow-ups gates 1-7); verification notes now reflect the broker packages. - build_stop.go + engine.go now document that the leaf-keyed lock is persistent and never unlinked (ticket 06) and that the brokered stop (ticket 07) uses verified daemon control via procidentity, never executing the repo wrapper with host authority. Verification: go build + go vet clean; go test green for internal/buildrun, buildengine, buildbroker, buildcontrol, procidentity; internal/cli green except the pre-existing sandbox-only TestDoctorHarnessBinarySection (unchanged baseline failure). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
21ec1e6 to
38aa758
Compare
|
Finally in a state where I can safely say, that the client setup works fully with omac: unit tests, IT, test containers spawned using gradle, it all works now. Just writing a E2E test to assert that. What remains is to review this independently from my agent reviews and properly test it on Linux @nhuelstng - which is not easy given how big the diff has become... |
|
TODOS from sync, verify:
|
|
Blocking (security boundary does not hold)
Should fix before merge
|
Evidence-gathering spike for ADR 0003 guarded executor loopback: - 9 SBPL posture profiles (blocked/exact/dynamic/dynamic+deny/order- control/v4-literal/v6-literal/env-only/open) under internal/sandboxrun/testdata/loopback-spike/ - Probe.java: child+grandchild JVM dynamic loopback, guarded-listener v4/v6 reachability, external egress probes - run-matrix.sh: host-side posture matrix runner with v4+v6 listeners - RUN-ME-FIRST.md: host execution steps (sandbox-exec cannot be nested from inside an omac sandbox, so the kernel run is host-side) Gradle Worker API reproducer skeleton and REPORT.md pending the host run. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
env -i plus deny-default Seatbelt breaks the jenv shim chain (bash script reading /dev/fd process-substitution). Resolve through to the active jenv version's real bin/java before sandboxing; without this every posture falsely reads as fully denied. Finding recorded in the ticket 01 report. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Any command-capable harness can now submit a Gradle build request: omac build --root backend -- gradle <tasks...> - internal/buildrun: request grammar (adapter seam; gradle only in v0), canonical-worktree containment with traversal/symlink-escape rejection, executor grants (worktree + <cache>/gradle leaf + private temp rw; network blocked), one restricted process per request reusing sandboxrun's SBPL generation and launcher, staged cancellation (SIGINT -> graceful -> guarded group kill), stream-through stdio. - Exit contract: 0 success; passthrough gradle rc on build failure; 3 policy denial; 4 cancellation (with stderr marker); 10 service failure (collision-free vs gradle/shell codes). - Audit events adopt a new ModeBuild entrypoint. - Cache resolution reuses start.go's prepareLaunchCache. - docs/build-command.md maps CLI/transport/streaming/cancellation/ health/auth/audit/error onto established OMAC patterns with the deferred pieces named (health/auth await a supervisor layer; cold- cache wrapper bootstrap requires a pre-seeded distribution while network is blocked — host-side ./gradlew :help validation pending because nested sandbox-exec is unavailable in dev sandboxes). Co-Authored-By: opencode <noreply@opencode.ai> Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
… queue (ticket 04) A repeatable red-green loop keeps one Gradle daemon warm per worktree under the session-scoped GRADLE_USER_HOME leaf, serializes requests within a worktree, and tears down cleanly. - internal/buildrun/jdk: resolve the REAL JDK (bypass jenv shims; /usr/libexec/java_home fallback only), set JAVA_HOME + prepend the JDK bin to PATH in the child env. Seatbelt kills jenv /dev/fd process substitution (01-loopback/REPORT.md); the executor must never see shims. - internal/buildrun/queue: per-worktree flock on <leaf>/.omac-build.lock serializes requests; cancellable acquire (AcquireCtx) lets a queued request be individually cancelled; timed-out wait -> ExitServiceFailure, cancelled-while-waiting -> ExitCancelled. Independent worktrees resolve to independent leaves -> concurrent. Auto-released on crash. - internal/buildrun/control: OMAC-owned control state (init.d/, gradle.properties, .omac-control/) is read-only to the executor via WriteDenyPaths; init.d/ created 0o500 so the executor cannot plant init scripts. Denial README names the supported alternatives (project build.gradle, .omac/build.yaml). - internal/buildrun/run: proxy injection via GRADLE_OPTS (systemProp -Dhttp/https.proxyHost/Port + nonProxyHosts=localhost|127.*|[::1]), NEVER JAVA_TOOL_OPTIONS (spec.md:180 — JVM prints it). Cancellation staging: first signal graceful (preserves warm daemon), second signal / max-duration forced (SIGKILL group + recycle the daemon via gradlew --stop). stageKill helper dedups the kill sequence. - internal/buildrun/stop: 'omac build stop' runs gradlew --stop under the same isolated env as the build (no HOME, isolated GRADLE_USER_HOME, JDK-resolved PATH/JAVA_HOME), then force-kills a wedged daemon by pid from the leaf's daemon registry. - internal/cli/build_proxy: start the omac filtered proxy (netproxy) for the build path; proxy filter tightening is ticket-06 work. - internal/sandboxrun: new Grants.WriteDenyPaths for read-only control state (deny-beneath-allow in the SBPL); build posture is macOS env-only (Shape A, filesystem-only kernel boundary) and Linux kernel-blocked. - --max-duration flag denies an over-budget request before start. - docs/build-command.md: architecture rewritten for the warm-daemon model, queue, stop, control-state protection, Shape A provenance, and the Linux daemon-cohabitation known item. Kernel-sandbox integration tests (TestBuildHarnessIndependence, TestBuildStreaming, TestBuildCancellation) skip inside the nested omac sandbox by design; host/CI validation pending. Co-Authored-By: opencode <noreply@opencode.ai> Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Add an optional, committed .omac/build.yaml manifest that declares non-secret, non-standard build capabilities (build roots, approved image references, registry identities, optional resource requests) so teams share them without secrets or absolute worktree paths. New internal/buildmanifest package: parse + structural validation (secret/forbidden-field/absolute-path/embedded-credential rejection at the decode boundary), SHA-256 content digest over canonical YAML, post-ceiling capability set, digest-based approval record + frozen-for-session active record under <cache leaf>/.omac-control/, consolidated capability diff, and spec-exact MissingCapabilityError / HostForbiddenError diagnostics. Host policy is the ceiling; a request above it (or against an unset dimension) fails closed before executor startup (exit 3). Wire into omac build between Resolve and GrantsFor: Load -> Validate -> frozen-for-session Gate -> thread approved caps into BuildConfig. A missing manifest is the normal case (gate skipped). A changed manifest records approval AND fails with the consolidated diff + restart instruction; the next run starts unattended. Effective policy stays frozen for the session even if the worktree file changes. Two-axis code review run; findings fixed: deduped control-state constants (import buildmanifest, no cycle), removed byte-identical denyManifest, exported GradleLeafName + reused GradleLeaf, removed broken Is methods + dead sentinels (callers use errors.As), made MissingCapabilityError.Render emit ProposedChange (spec.md:234), fail-closed a request against a zero host ceiling with an actionable message, fixed test-quality issues (unused import, convoluted assertion, discarded exit code). Kernel-sandbox integration tests skip in-sandbox; pre-existing TestDoctorHarnessBinarySection / sandboxrun workflow failures unchanged. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…ticket 06) Tighten the build-path filtered proxy from allow-all (ticket 04) to an allowlist of public Gradle/Maven endpoints with build-scan upload hosts denied, and add a scoped host-side credential-lift proxy for private Maven registries (GitHub #92). The developer's long-lived registry credential stays in the OMAC keychain; Gradle sees only a non-secret local loopback URL per alias. The credential proxy authenticates upstream on Gradle's behalf, is read-only (GET/HEAD; rejects PUT/POST/DELETE publish), and redacts the credential from all logs. New internal/credproxy package: a loopback HTTP forward server that injects Authorization: Basic upstream from a held secrets.Secret, plus LookupRegistries (joins the manifest's non-secret alias+upstream with the keychain credential by alias) and a typed RegistryCredentialError. The credential is read once at proxy startup (host-side, unsandboxed) and never enters executor env, GRADLE_OPTS, gradle.properties, the init.d control script, process args, stdout/stderr, or audit. Wire into omac build: the credential-lift proxy starts after the manifest gate (ticket 05) using the approved registry aliases; an OMAC-authored read-only init.d/registry-credentials.gradle script points Gradle at the local proxy URLs. A missing keychain credential fails closed with a structured denial naming the alias and the keychain setup, never the credential (exit 3). Two-axis code review run; findings fixed: removed the private-registry upstream hosts from the filtered-proxy allowlist (a bypass path contradicting spec.md:174 — private registries route through the credential-lift proxy only), deduped the public-Maven allowlist, removed dead code (CredentialValueFormat/parseCredentialValue, the now-unused registryUpstreamHosts/upstreamHost helper), replaced a brittle substring match in restartHint with a typed CredentialErrKind, fixed a dead no-op assertion in the end-to-end credential test, removed a redundant Range header Set, and extended the red-team leak test to assert the credential is absent from process arguments and stdout (spec.md:291). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…e (ticket 07) Run yarp3's canonical worker-based Gradle static gate (Checkstyle via the Gradle Worker API process isolation) through the JVM build executor on both macOS and Linux without OMAC-specific replacement tasks or a host init script. ADR 0003 Revision retired guarded executor loopback on macOS, so the machine-local checkstyle*Sandbox twins and the host init script they needed are no longer necessary. OMAC now authors an unconditional, read-only init script at <leaf>/init.d/retire-checkstyle-twins.gradle (control state, granted read + write-deny, same pattern as the ticket-06 credential-lift script). It runs via Gradle's beforeProject hook (before the task graph is materialized), matches the yarp3 checkstyle*Sandbox twin convention with task configuration avoidance, logs the retirement at configuration time via the init-script logger (NOT task.doFirst — the subsequent task.actions = [] would clear a doFirst closure), and neutralizes each twin by clearing its action list. The canonical checkstyleMain / checkstyleTest tasks are untouched (the regex requires a trailing Sandbox) and run unchanged through the Worker API. The script is a defensive no-op when no twins exist (try/catch wraps the whole hook). Provenance now reports the JVM build executor's network posture via a new build_executor section in 'omac provenance' (text + JSON), distinguishing the two platforms and stating the accepted macOS residual verbatim: macOS = env-only filtered, filesystem confinement only, no kernel network mediation, raw-socket-capable build code can reach host loopback and external egress, no host-listener monitoring/guarding claimed (ADR 0003 Revision); Linux = kernel-blocked private sandbox loopback, host-loopback services unreachable. The network posture in grants.go is unchanged (already macOS=env-only, Linux=kernel-blocked); no new loopback capability is granted to the main agent sandbox. docs/build-command.md gains a 'Canonical worker-based checks' section and corrects the 'Network posture (Shape A)' section to state the accepted residual plainly and disclaim any macOS loopback protection or guarding. printBuildUsage in build.go states the residual and the ADR 0003 Revision disclaimer. Host-side validation pending: the retire script's Groovy idiom and the canonical localQuickCheck task graph are NOT Gradle-verified in-sandbox (nested sandbox-exec is impossible). Checkboxes 1 and 2 are claimed by the retirement script + docs and asserted by string-matching tests, not demonstrated by running Gradle; a host run against real yarp3 is the acceptance gate for those two. Checkboxes 3-6 PASS by code inspection. Two-axis code review run (reviews/07-review.md); findings fixed: dead doFirst log-line ordering (now logs at configuration time before the actions clear), redundant init.d ensureDir consolidated, canonical-task guard test tightened to reject broader regexes, retirement test strengthened to catch the doFirst/actions ordering class of bug. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Add a filtered Docker-compatible endpoint the JVM build executor reaches
as an ORIGIN server (DOCKER_HOST=loopback proxy URL), NEVER the raw
daemon socket. The proxy forwards only the ticket-02 measured v1
allowlist to the existing Docker/Colima daemon and fails closed on
unknown endpoints and unknown security-relevant create-body fields.
New package internal/containerproxy/ (mirrors internal/credproxy/
discipline — a read-only forward HTTP server with a policy gate, NOT a
netproxy CONNECT tunnel which never reads HTTP):
- Allowlist (REPORT.md §'Proposed v1 allowlist'): /_ping, /version, /info,
/images/json, /images/{ref}/json (approved refs only), /containers/
create (body-validated), /containers/{id}/{start,kill,wait,json,logs},
/containers/json (label filter rewritten server-side), /containers/
{id} DELETE (ownership-checked), /images/create (fromImage ∈ approved,
X-Registry-Auth denied). Everything else denied fail-closed with a
structured ContainerPolicyError (not opaque 404), incl. all prune
endpoints, /build, /commit, /exec*, /archive, /attach, /networks/*,
/volumes/*, swarm/node/service/secret/config/plugin/daemon.
- Create-body validation is ALLOWLIST-based (spec.md:222 / ADR 0002):
unknown HostConfig fields denied via allowedHostConfigKeys. Validated
values: Privileged/Binds/Mounts empty; NetworkMode/PidMode/IpcMode/
UsernsMode/CgroupnsMode/Runtime/UTSMode empty/default; CapAdd/Devices/
SecurityOpt/Dns/ExtraHosts/CgroupParent empty; AutoRemove false (evades
cleanup tracking); Init/DeviceRequests denied. PortBindings HostIp
REWRITTEN to 127.0.0.1 (loopback-only). omac.executor ownership label
INJECTED; client-set omac.* labels REJECTED (forgeable). Ryuk image
rejected fail-closed. X-Registry-Auth STRIPPED on all paths (create +
images-create) — private registry auth is issue #92 territory.
- Ownership enforcement: every {id}-bearing op ownership-checked via
Config.Labels (the real Docker inspect shape — NOT top-level Labels,
which the review found was the buggy parse). In-memory fast path for
proxy-created containers; inspect fallback re-discovers + caches.
GET /containers/json filter rewritten server-side (client label filter
forgeable → stripped, ownership label injected).
- Executor-owned internal network: created Internal:true (no outbound
route) + omac.executor label, host-side (NOT exposed to the executor's
allowlist). Containers attached via /networks/{id}/connect; attach
failure KILLS+REMOVES the container (silent fallback to the default
bridge would give it an outbound route — checkbox 5 violation).
- forwardCreate registers the container id synchronously on 2xx BEFORE
the post-response inspect/attach so Cleanup cannot orphan it and
concurrent follow-up ops see it (review race fix).
- Cleanup removes only proxy-tracked containers + the executor network
(never lists untracked containers, never trusts client labels). Wired
via defer stopContainerProxy() in runBuild (fires on normal completion
AND forced cancel via the defer chain).
- Audit: container.create / container.denied / container.cleanup events
carry executor/image/id/ports — NEVER env values (POSTGRES_PASSWORD etc.
pass through to the daemon but are absent from audit by construction).
Wired into internal/cli/build.go runBuild: container proxy started ONLY
when approved images are declared (manifest) on macOS (Linux kernel-
blocked → not started). DOCKER_HOST + TESTCONTAINERS_RYUK_DISABLED=true
injected into ChildEnv only when the proxy is enabled. Executor ID is a
stable non-secret omac-<worktree-base-name>.
internal/buildrun/control.go: add unconditional read-only
init.d/mockito-agent.gradle (spec.md:168; REPORT.md item 4 — yarp3 tests
need -javaagent:mockito-core.jar; without it Mockito inline mock-maker
can't self-attach). Mirrors the captured
02-testcontainers-capture/gradle-home/init.d/mockito-agent.gradle.
docs/build-command.md: 'Mediated container access (ticket 08)' section.
Two-axis code review run (reviews/08-review.md); critical + major
findings fixed: Config.Labels ownership parse (was reading top-level
Labels — false-denied every non-cached inspect against a real daemon),
X-Registry-Auth strip on create (was forwarded verbatim), allowlist-based
HostConfig validation (was denylist — unknown fields passed through),
AutoRemove/UTSMode/Init/DeviceRequests denial, network-attach failure
kills+removes the container, forwardCreate tracking race, image capture
for audit, build.go comment accuracy, docs audit-redaction wording.
Host-side validation pending: real Docker/Colima cold compile + jOOQ
generation, ownership isolation across concurrent executors, cleanup on
forced cancel, and Mockito-agent attach are NOT verified in-sandbox
(nested sandbox-exec impossible). Checkboxes 2/3/4/6 PASS by unit-tested
policy logic; 1/5/7 are claimed by unit tests but not by a real-daemon
run (host validation pending).
Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Container proxy (ticket 08) owns runtime enforcement; ticket 09 adds: - Startup scavenger (checkbox 6): on Start, BEFORE binding the listener (eliminates the first-request/scavenger race), query the daemon for containers + networks labeled omac.executor=<id> and DELETE the matches. Label filter built with json.Marshal (not fmt.Sprintf) so worktree names with JSON-special characters are correctly encoded. Unrelated host resources are never listed (server-side label filter, never trusted from the client). Best-effort; audited as container.scavenge.summary + per-item container.scavenge events (force=true recorded). - Denial correlation (checkbox 7, spec §254): thread a build request id (newBuildRequestID in build.go, b<unix-hex>-<4 random bytes>) from runBuild -> startContainerProxy -> Proxy.SetBuildRequestID -> deny -> ContainerPolicyError.BuildRequestID -> Render. The correlation prefix names the request id AND the actionable cause on line 1 so Gradle/ Testcontainers summary-truncation cannot hide the OMAC fix hint. The build.request audit event carries request=<id>; container.denied audit carries request=<id> + kind=<name> (PolicyErrKind.String added). - Crash/cancel cleanup (checkbox 5): defer stopContainerProxy handles graceful + forced cancel (ticket 08, unchanged). The scavenger on the NEXT startup handles crash + simulated supervisor restart. Tests: TestCrashRestart_ScavengerRemovesOrphanedContainer (faithful: fake daemon persists proxy-created container, scavenger finds it via daemon list, no re-seeding) + TestCrashRestart_ScavengerRemovesOrphanedNetwork. - Fake daemon (proxy_test.go): preseededContainers/preseededNetworks/ deletedContainers/deletedNetworks/createdContainers for scavenger + crash tests; /networks GET + /containers/json label-filter handlers; filterFakeContainers/filterFakeNetworks/labelMatches/parseCreateBodyLabels helpers. 9 new tests covering scavenger safety (only owned removed), empty daemon no-op, special-char executor id, startup invocation, denial correlation (with + without build request id), crash-restart container + network. Two-axis review (09-review.md): critical listener-race + major label-JSON + dead code + correlation-prefix + crash-test fidelity + audit + docs findings fixed. Checkboxes 1-4 (full IT validation) are host-side pending; checkboxes 5/6/7 PASS in-sandbox. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Replace legacy --no-daemon/open_port guidance in README.md with a pointer to the supported `omac build` executor workflow (keeping the ad-hoc- invocation table for non-omac-build Gradle use). Add to docs/build-command.md: - Startup scavenger (ticket 09) section: scavenge-before-bind, json.Marshal label filter, force=true audit, crash-recovery half of ADR 0002. - Denial correlation (ticket 09, spec §254) section: build request id threaded end-to-end, correlation prefix (cause + id on line 1), audit request= + kind=name. - Team-ready yarp3 TDD workflow section: what a colleague does, what OMAC owns, release notes (v1 scope): Gradle v1 / Maven deferred, macOS v1 env-only (filesystem-only) with raw-socket residual, Linux kernel boundary, unsupported TC features (Ryuk/socket nesting/reusable/bind mounts/privileged/host namespaces/devices/egress/unknown HostConfig), cache-scope poisoning boundaries, cancel/crash/teardown. Add TestBuildExecutorSecurityBoundary (consolidated regression test, ticket 10 checkbox 6): DOCKER_HOST absent without approved images (macOS), container proxy URL loopback + no userinfo, executor id stable/non-secret/ distinct, build request id non-empty/b-prefixed/non-colliding. Individual pieces (control-state write denial, egress, scavenging, container policy denials) covered in owning packages; kernel-level pieces host-side-deferred. Fix per-network scavenger audit event to record force=true (matches the per-container event + the doc claim). Two-axis review (10-review.md): SHIP with nits — macOS-skip + rename for the no-images subtest, grants_test.go path fix, time-ordered rename, format-faithful doc example id — all fixed. Checkboxes 6/7/8/9 PASS in-sandbox; 1-5 host-side pending. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…ction Gradle's macOS toolchain auto-detection execs /usr/libexec/java_home -V to enumerate installed JDKs. Inside the build executor sandbox the binary runs but finds nothing: java_home uses LaunchServices/Spotlight for enumeration, which the sandbox breaks at a level path grants cannot fix (verified: java_home exits 1, not EPERM; the JDK dirs and plists are readable; yet it reports zero JDKs). The build then sees only the JAVA_HOME daemon JDK (Homebrew OpenJDK, vendor=Homebrew), which does not match yarp3's pinned vendor=Eclipse Temurin. Gradle falls back to foojay auto-download, which the build proxy network-denies (api.foojay.io not on the public allowlist) -> compileJava FAILED. Fix: the supervisor enumerates ALL host JDK installations UNSANDBED at grant-prep time (EnumerateHostJDKs, running java_home -V with a directory-scan fallback), writes the roots to gradle.properties as org.gradle.java.installations.paths (read-only control state), and read-grants each JDK's bin+lib. Gradle matches the pinned toolchain spec against the declared paths without calling java_home inside the sandbox at all. Also grants /usr/libexec in the darwin read baseline (necessary for java_home to exec, though not sufficient on its own), and makes ExpandExisting skip unstatable paths instead of hard-failing so a single restricted baseline entry does not abort the whole grant computation under a nested sandbox. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…d Kafka tmpdir Three host-side IT blockers for yarp3's EndpointDeprecationRegistryIT, all surfaced and fixed via the omac build executor iteration loop. 1. Keychain double-omac/ prefix (credential-lift) The credential-lift KeychainLookup queried the keychain at omac/omac/build/registry/<alias> (double prefix) because keychain.Get treats its first arg as a skill name and prepends omac/ via Service(). The docs and the structured diagnostic both tell the developer to store at omac/build/registry/<alias> (single prefix), so every stored credential was invisible -> ExitBuildPolicyDenied (3). Fix: new keychain.GetByService/SetByService/DeleteByService (raw service name, no prefix); KeychainLookup switched to GetByService. Regression test: round-trip at the documented service, skips in-sandbox (macOS keychain blocked) and headless Linux (dbus unavailable). 2. networks/volumes/images prune allowlist gaps (container proxy) Testcontainers' JVMHookResourceReaper (the in-process cleanup hook, distinct from the Ryuk *container* reaper that TESTCONTAINERS_RYUK_DISABLED disables) calls POST /networks/prune, /volumes/prune, AND /images/prune on every JVM shutdown. The v1 policy denied all prune endpoints fail-closed -> DockerException Status 403 on the cleanup thread after every test run. Fix: all three prune endpoints are now allowed with an injected omac.executor=<id> label filter (shared rewritePruneFilter impl) so only THIS executor's resources are pruned. /containers/prune stays denied (no caller needs it). Tests: TestNetworksPrune_RewritesFilter, TestVolumesPrune_RewritesFilter, TestImagesPrune_RewritesFilter. 3. Embedded Kafka broker never starts (java.io.tmpdir mismatch) Spring Boot 3.5's GlobalEmbeddedKafkaTestExecutionListener starts an in-process Kafka broker (KRaft) whose log dir is written via org.apache.kafka.test.TestUtils.tempDirectory() under java.io.tmpdir. The JVM defaults java.io.tmpdir to the macOS /var/folders/.../T/ leaf, which the sandbox deliberately does NOT grant writable (only the private temp $TMPDIR is writable, per grants.go:246). Result: FileSystemException: Operation not permitted -> broker fails silently -> spring.embedded.kafka.brokers unset -> bootstrap.servers = [] -> ConfigException -> ApplicationContext fails to load. Confirmed via the TestEventLogger DEBUG stack trace at EmbeddedKafkaKraftBroker.start -> TestUtils.tempDirectory. Fix: the mockito-agent init script (RenderMockitoAgentInitScript) now forces -Djava.io.tmpdir=$TMPDIR on every Test task, aligning the JVM temp with the sandbox-granted private temp. $TMPDIR is set in ChildEnv (grants.go:572) and is non-empty in the executor env; the init script guards against a misconfigured env blanking the JVM default. Fixes the embedded Kafka broker and any other tool that assumes java.io.tmpdir == $TMPDIR. Verification: yarp3 EndpointDeprecationRegistryIT now passes under omac (BUILD SUCCESSFUL, ~2m15s) with no prune DENY lines and no Kafka ConfigException. All package tests green; gofmt + go vet clean. Pre-existing unrelated failures unchanged (TestDoctorHarnessBinarySection reads ~/.config/omac/config.yaml, sandbox-blocked; 3 sandboxrun workflow tests, nested sandbox impossible). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
… validation Two changes from the post-commit review of 7b30a37 plus the durable stable-port UX fix (handoff remaining-blockers #2). 1. Stable container proxy port per worktree (durable UX fix) The container proxy bound a random ephemeral port each run (net.Listen tcp 127.0.0.1:0). The warm Gradle daemon caches DOCKER_HOST from its first run, so when a build exits and defer stopContainerProxy() tears down the listener, the next run starts a new proxy on a NEW port but the warm daemon keeps trying the dead old port -> Connection refused. Today's workaround was 'omac build stop' before every run (recycle the daemon). Fix: derive a deterministic port from the canonical worktree path (FNV-1a into [30000,40000)) and persist it to <leaf>/.omac-control/containerproxy-port. On Start: prefer the control-file port -> hash -> forward scan of 50 ports (wrapping at the range) -> random ephemeral fallback. Correctness over determinism: the build never wedges; the warm-daemon bug may resurface only in the rare full-window case, logged as a warning. The port file is supervisor-owned (unsandboxed) and lives under the .omac-control dir already WriteDenyPaths'd for the executor, so build code cannot tamper with it. New: internal/containerproxy/port.go (stablePortFor, selectPort, portIsFree, randomFreePort, readPreferredPort, writePreferredPort). Config gains WorktreePath/ControlLeaf; Start calls choosePort and persists the assigned port. startContainerProxy passes the worktree path + control leaf. 14 new tests (determinism, in-range, symlink canonicalization, scan, wrap, fallback, cross-restart persistence). 2. Sysctls/LxcConf validation no-ops (blocking review findings B1, B2) Found by code review of 7b30a37: Sysctls was validated with nonEmptyStrSlice (matches []any), but Docker serializes HostConfig.Sysctls as map[string]string (JSON object) — so a non-empty map silently passed through to the daemon even though 'Sysctls' was in the allowlist. The dual bug for LxcConf: validated as map[string]any, but Docker serializes HostConfig.LxcConf as []string (JSON array of key=value). Both are host-namespace escape vectors the comment said 'Must be empty/absent' but the validation never fired. Fix: check Sysctls as a map and LxcConf as an array. Regression tests: TestCreateBody_SysctlsMapDenied (sends {"net.ipv4.ip_forward":"1"}, asserts 403), TestCreateBody_LxcConfArrayDenied (sends ["lxc.aa_profile=unconfined"], asserts 403). 3. Test + doc nits from review (I4, N1) - control_test.go: pin the tmpdir guard (if (omacTmp != null && !omacTmp.isEmpty())) in the want slice, not just the getenv/jvmArgs substrings, so a regression that unconditionally sets a blank java.io.tmpdir is caught. - policy.go: rewritePruneFilter comment now names all three callers (networks/volumes/images prune), not two. Verification: go build + all package tests green (containerproxy, buildrun, cli except the pre-existing TestDoctorHarnessBinarySection sandbox-block), gofmt + go vet clean. Real Gradle/Docker end-to-end validation deferred to the host IT run (sandbox has no Docker/Gradle). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
GlobalEmbeddedKafkaTestExecutionListener (spring-kafka-test) starts an in-process Kafka broker via testPlanExecutionStarted and stops it at testPlanExecutionFinished, but the JUnit Platform listener discovery goes stale on a warm Gradle daemon — the second run's bootstrap.servers comes back empty. Fix: recycle the Gradle daemon after every build via gradlew --stop (SAFE when no build is running, unlike --no-daemon which deadlocks). Every run gets a cold daemon with fresh env, init scripts, and listeners. Also threads TmpDir through GradlePropertiesConfig into the executor-tmpdir control file (read by the mockito-agent init script in doFirst), fixing the warm-daemon stale-TMPDIR bug. it14: EXIT14a=0, EXIT14b=0 (two consecutive builds, no omac build stop) Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
internal/credproxy bound 127.0.0.1:0 (random ephemeral) each run. The init-script repository URL Gradle is pointed at (registry-credentials.gradle) is rewritten per-run to the new port, but any holdover (a warm Gradle daemon/worker caching a prior run's URL, or a build that errored out before PrepareControlState rewrote the script) left requests hitting a dead port — it9a surfaced this as 'Read timed out' on an ephemeral port. Extract the container proxy's stable-port helpers into a shared internal/stableport package and wire the credential-lift proxy through it: deterministic stableport.For(worktree) in [30000,40000), recorded at <GradleLeaf>/.omac-control/credproxy-port, scan window on collision, random ephemeral fallback with a logged warning (correctness over determinism). Only the stable (non-fallback) port is persisted: writing a fallback ephemeral port would poison the control file and destabilize the next run. Both proxies now share this rule (previously containerproxy wrote the fallback port too). Harden both TestStart_ScansWhenStablePortBusy tests: occupy the full scan window (preferred + PortScanWindow neighbors) so the TOCTOU window in stableport.IsFree's bind/close/release can't race the occupier; assert the bound port never collides with a held window port. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Ticket 01 — daemon-recycle-error-path: TestDaemonRecycle_ErrorLogsButBuildContinues
asserts StopGradleDaemon returns an *exec.ExitError when gradlew --stop
exits 1 (error logged, build continues).
Ticket 02 — start-credential-proxy-wiring: TestStartCredentialProxy_WiresNewServerWithConfig
calls the 5-arg signature directly with a fake credential lookup; asserts
non-empty loopback URL map. Bonus: expanded TestRunBuild_MissingRegistryCredentialDenial
to confirm denial originates from the credential-lookup path.
Ticket 03 — port-file-fallback-guard: TestStart_ControlFileNotPersistedOnFallback
occupies the full stable window and asserts ReadPreferred returns 0 (port
file not written on fallback).
Ticket 04 — read-preferred-edge-cases: TestReadPreferred_{EmptyFile,GarbageFile,
OutOfRangeLow,OutOfRangeHigh,MissingFile} cover every error path.
Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Supersede the warm-daemon lifecycle: every omac build recycles the Gradle daemon post-build (gradlew --stop after RunBuild); each build starts cold; --no-daemon forbidden. Remove the stale Linux daemon-cohabitation caveat and ADR 0001's warm-reuse decision with a revision note. Remove the unenforceable manifest resource controls MaxCPU/MaxProcesses (dead surface: host ceilings never set, validator always fail-closed, no consumer). v1 resources surface is maxHeap + maxDuration only; docs, digest, validation, and tests updated. Document the shipped scoped pruning: networks/volumes/images prune allowed with server-injected executor-ownership label filter (JVMHookResourceReaper shutdown hook); containers/prune and all other prunes stay denied. Archive operations remain denied. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…oose credproxy and containerproxy duplicated the preferred/scanned/ephemeral port-selection policy (control file -> worktree hash -> scan window -> random fallback) and had drifted. Move the policy into one tested function, stableport.Choose, that both proxies now call. Choose also surfaces WHY the preferred port could not be bound via an onReason callback carrying the actual IsFree listen error (issue #191: EADDRINUSE vs EPERM vs sandbox-blocked); both proxies log it as "preferred stable port N unavailable: <err>". Behavior preserved: scanned in-range neighbors persist, out-of-range ephemeral fallbacks do not, the empty-worktree legacy path returns (0, false), and Start's bind-retry-once on 127.0.0.1:0 is unchanged. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…ments The per-worktree queue serializes builds on the shared leaf; a quick predecessor finishing means the caller proceeds, not that it reuses a warm daemon (post-build recycling gives every build a cold daemon). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
… staticcheck - containerproxy: guard fakeDaemon recorded state with a mutex; the HTTP handler goroutines raced the test goroutine's reads/resets (go test -race failed on every Test/Caches job). - cli: run the credential-lookup denial on every platform. The darwin gate previously skipped startCredentialProxy before LookupRegistries, so on Linux an approved private registry with no keychain credential slipped past the denial into the bwrap sandbox launch, where the gradlew stub never exits (5m test timeout). - staticcheck: S1011 (grants), SA4004 (run SignalContext), U1000 dead types (policy, proxy), S1039 string concat (control), S1031 nil-range (proxy test); all PR-owned packages now lint clean. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…roxy port tests - cli build integration tests: the kernel-sandboxed launch path (Linux bwrap) creates the read-only <leaf>/gradle/init.d control dir inside HOME/.cache/omac/<digest>; t.TempDir's RemoveAll then fails with EPERM on the always-written mockito-agent.gradle. Register the existing chmodBuildLeafInitDForCleanup for the test cache homes so init.d is writable again at cleanup, matching build_stop/build_manifest tests (macOS only skipped these paths, so only the Linux jobs failed). - credproxy TestStart_ControlFileNotPersistedOnFallback: the full scan window is the test's precondition; a stray listener on the shared CI host leaves a hole Select can legitimately land on as an in-window neighbor, which Start persists (issue #191 semantics), failing the 'no port file' assertion. Skip (like TestStart_FallbackRandomWhen WindowFull) when the window cannot be fully occupied instead of asserting against a broken precondition. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…window, forced-cancel race Three CI failures from run 30902773935 (commit caa3792), all platform- specific flakes that local macOS runs can't fully reproduce: - cli build integration tests (Linux x2): chmodBuildLeafInitDForCleanup chmodded <cacheHome>/gradle/init.d, but the subprocess omac binary resolves its global cache scope at /Users/sajjadtng/.cache/omac/<sha256('v1:shared')> (digest 5e555cb2... from the error), so the cleanup was a no-op and t.TempDir's RemoveAll hit the read-only init.d (EPERM). The helper now resolves the digests under <home>/.cache/omac at cleanup. - credproxy TestStart_ControlFileNotPersistedOnFallback (WSL2): RandomFree binds 127.0.0.1:0, and on Linux the ephemeral range (32768-60999) overlaps the stable window [30000,40000); the kernel handed back 38587, Choose classified the in-range result as not-fallback, and the proxy persisted it — failing the 'no port file' assertion. RandomFree now retries (bounded) for a port below StablePortMin, keeping the fallback=out-of-range contract true on every OS. - buildrun TestRunBuildForcedCancelRecyclesDaemon (macOS): the fixture 'trap '' TERM INT; sleep 30' can reap via the graceful TERM on a loaded runner (sleep dies, sh exits) before the force fires, skipping the recycle legitimately; and RunBuild's select can see waitErr before a simultaneously-ready stageKillCh, missing the forced flag (S3 daemon-recycle gap). The fixture now loops so the group survives until SIGKILL, and run.go drains a pending stageKillCh on the reap path. Verification: staticcheck clean (containerproxy/buildrun/stableport/ credproxy/cli); go test -race passes for all non-sandbox-gated packages (sandboxrun + TestDoctorHarnessBinarySection fail on baseline in the nested sandbox only — unchanged); go build ./... and go vet ./... clean. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Run 30910640795 (c2a95eb): Test ubuntu + WSL2 failed on TestRandomFree_OutOfRange (RandomFree returned in-window 36131/35957) and TestStart_ControlFileNotPersistedOnFallback (port file poisoned with 34885). Root cause: the Linux kernel ephemeral range (32768-60999) never allocates below StablePortMin, so the bounded 32-draw retry of 127.0.0.1:0 is futile — and the exhaustion path returned an in-window port, violating Choose's 'fallback means out-of-range' contract. RandomFree now probes the low range [1024, StablePortMin) explicitly (step 7), which lies outside every common kernel ephemeral range, and returns 0 on exhaustion — the callers (containerproxy, credproxy) already retry a raw 127.0.0.1:0 bind on port==0, which always yields an out-of-window ephemeral. TestRandomFree_OutOfRange accepts 0 as a legitimate exhaustion result and doubles the draw count. Verification: build/vet/gofmt clean; staticcheck clean for stableport/credproxy/containerproxy; go test -race -count=3 passes for stableport + credproxy; baseline cli/sandboxrun failures unchanged (known nested-sandbox-only). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Run 30912338771 (568f6b7): WSL2 job failed TestCleanup_RemovesOwnedContainersAndNetwork — 'cleanup did not DELETE the executor network'. Root cause: forwardCreate wrote the create response to the client BEFORE the post-response inspect/attach ran; the test's waitForCall(/networks/create) observes the daemon RECEIVING the request, but p.networkID is only set after the response round-trips. On a loaded runner, Cleanup() (which snapshots p.networkID) ran in that window -> netID empty -> no DELETE /networks/{id}. Fix: attachToNetwork now runs BEFORE writing the create response, so a client that sees a 201 is guaranteed the container is tracked and network-attached. A failed attach now refuses the create (deny) instead of returning 201 and asynchronously killing — fail-closed, consistent with checkbox 5. Cleanup can no longer race the network registration. Tests: waitForCall no longer needed before crash simulation; cleanup test keeps it as a cheap safety net. Verification: build/vet/gofmt/staticcheck clean; go test -race -count=3 containerproxy ok; full ./... only known sandbox-only failures (cli TestIntegration*/TestDoctor, sandboxrun). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…dential An approved private registry's credential exists in the host keychain (verified: security find-generic-password -s omac/build/registry/id-gitlabcom-yarp3), yet omac build denied it as 'missing' with 'Run omac secrets set <alias>' from inside the omac sandbox. Root cause: keychain.GetByService flattened ErrNotFound AND an unreachable backend (IsUnavailable) into the same ErrNotFound, so credproxy.KeychainLookup could only ever classify a read failure as CredentialMissing. A credential that exists but cannot be READ (the sandbox denies the keychain-daemon socket) was misreported as absent, and the diagnostic sent the user to re-add a credential that is already there. Fix: - keychain.GetByService now returns ErrBackendUnavailable (new sentinel) for an unreachable backend, keeping ErrNotFound for genuinely-absent entries. - credproxy.LookupRegistries maps the sentinel to CredentialBackendUnavailable, whose hint is 'Start the OS keychain backend' — never the misleading 'omac secrets set'. - credproxy.KeychainLookup passes the sentinel through (no collapse). Tests: TestLookupRegistries_BackendUnavailableSentinel (Kind + no-'secrets set' hint) and TestKeychainLookup_BackendUnavailablePassesThrough (survival of the sentinel). Verification: build/vet/gofmt/staticcheck clean for keychain+credproxy; go test -race passes for keychain, credproxy, buildrun, containerproxy, stableport; cli only the known sandbox-only TestDoctor failure. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…efactor, ticket 04) Extract one transport-independent build/stop engine from internal/cli/build*.go and the cancellation/daemon-lifecycle wiring around internal/buildrun. Both brokered and direct host invocation call through it (broker is a later ticket). Engine surface: - buildengine.Run + buildengine.Stop: one complete invocation each, behind a concrete function + options value (no speculative interface hierarchy). - ResultClass (success/build_failure/policy_denial/cancelled/service_failure) assigned at the outcome site; callers translate via Result.ExitCode, never inferring class from a numeric code. Raw wrapper exits 3/4/10 classify as build_failure. - SnapshotProvider seam with two adapters: DirectSnapshotProvider (calls the existing buildmanifest.Gate, preserving the direct-host gate semantics including approval recording on first use) and a parent-owned snapshot (broker path; simulated in tests — never writes, digest mismatch = denial). The engine cannot write approvals or replace snapshots. - ProxyStarter seam wires the existing cli startBuildProxy / startCredentialProxy / startContainerProxy; the engine owns startup ordering and the defer cleanup chain. A missing-credential *RegistryCredentialError is surfaced as policy_denial (criterion 7). CLI keeps public command dispatch, local help rendering, the stop subcommand route, signal handling (SignalContext), and exit-code translation. Behavior-preserving: no ordering, exit-code, lock-location, or direct-host semantics change. All existing internal/buildrun and internal/cli/build* tests stay green. New engine tests cover raw wrapper exits 3/4/10 → build_failure, gate/manifest errors → policy_denial, and parent-owned snapshot digest mismatch → policy_denial. buildrun.RunOptions gains a Cancelled *bool out-param so the engine can disambiguate a raw wrapper exit 4 from an OMAC cancellation without sniffing stderr (the numeric code 4 alone is ambiguous). The flag is set by RunBuild only when it actually cancelled the build; existing callers pass nil and see no change. buildrun.NewBuildRequestID is the single source of truth for the build request id (previously duplicated byte-identical in cli and engine). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Adds internal/buildbroker (host build broker) and wires it into the omac start/serve parent so a sandboxed omac build submits to the broker over the loopback control plane instead of running build orchestration in the sandboxed CLI process. The broker contains NO build policy/execution logic — it converts wire requests into internal/buildengine.Run invocations and frames the outcomes. Direct host-terminal omac build still runs in-process through internal/buildengine; public syntax, exit codes, help, and audit correlation are unchanged. Brokered omac build stop is carried through the execute operation but refused in this gate (a later gate enables it). This is gate 3 of the 7-gate delivery plan in .scratch/jvm-build-executor-follow-ups/spec.md. 🤖 Generated with opencode Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Spec: brokered stop refusal now returns 403 (was 400) so the CLI's
existing 403→exit-3 policy-denial mapping surfaces it as exit 3,
matching the spec result-class table and the comment that was wrong.
The 400 default branch in the CLI now only covers genuine bad-body
cases.
Standards (smell-baseline judgement calls, no behavior change):
- Extract injectBuildBrokerEnv + newBuildBroker factory: dedup the
env-injection block and broker-construction shape duplicated between
serve.go and start.go.
- Drop the serveServer.cacheScopeDirOrEmpty() method (collided with the
free fn of the same name); the factory reads the field directly.
- Delete the newRequestID wrapper (one-line delegate to mintRequestID).
- Delete `var _ = buildrun.ExitServiceFailure` + the now-unused
buildrun import in build_broker_wiring.go (dead code by admission).
- Introduce brokerEndpoint{Base,Token} for the (base, token) data clump
that travelled through decideManagedMode → runBuildManaged → postCancel.
- Delete the dead `ids` slice in registry.drainForShutdown.
Pushed back on two findings: the StopRefuser seam (deliberate gate-3
extension point, removed in gate 5) and the BuildToken primitive type
(single call site, a wrapper would be speculative).
Verification:
go build ./... EXIT=0
go vet ./internal/buildbroker/ ./internal/cli/ EXIT=0
gofmt -l internal/buildbroker/ internal/cli/ clean
go test -race -count=3 ./internal/buildbroker/ ok
go test -race -count=1 ./internal/cli/ only TestDoctorHarnessBinarySection
(pre-existing nested-sandbox baseline)
managed-mode + stop-refusal tests: all PASS (incl. updated 403 expectation)
🤖 Generated with opencode
Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Gate 4 of the host-build-broker initiative. The trust-boundary hardening required before brokered build is enabled: - Authoritative lock keyed by resolved Gradle cache leaf (not worktree) at <cacheRoot>/build-control/locks/<sha256(leaf)>.lock; shared-leaf requests serialize, distinct-leaf requests may run concurrently. Brokered and direct derive the same canonical-leaf key. - Engine acquires the cancellable leaf lock before any mutable control state, proxy startup, grants derivation, container scavenging, or execution. - Lockfile persistent and never unlinked; omac build stop no longer removes it (unlinking a flocked path can create a second inode). - Trusted state namespaced by canonical worktree identity: approvals/<wt-hash>.json, ports/<wt-hash>/, daemons/<leaf-hash>.json. - Host-only `omac build approve [--root <rel>]` transition: refused in managed sessions, requires interactive TTY, renders consolidated capability diff, stores durable approval only after explicit confirmation, never executes build code. - Parent freezes in-memory capability snapshot keyed by canonical worktree before launch (start) / at first activation (serve) when canonical identity + current digest match a durable approval; freeze-once per parent lifetime (agent-callable activate/reload cannot refresh the snapshot). Build request only compares against the snapshot; unapproved directory has build unavailable with diagnostic requiring `omac build approve` + parent restart. New package internal/buildcontrol owns the host-only build-control root layout + leaf-keyed persistent lock. internal/buildmanifest gains location-aware approval storage (BuildControl layout stores approvals under build-control/approvals/<sha256(wt)>.json, mode 0600; legacy OnLeaf layout preserved for backward compat). internal/buildengine gains ParentSnapshotStore (thread-safe, in-memory, keyed by canonical worktree). Two sandbox-profile items deferred to a follow-up sub-gate of gate 4 (the broker route stays disabled until gate 6, so no production path exercises them yet): outer-agent leaf write-deny, and per-request control bundle + read-only projection onto Gradle-leaf paths. The build-control root is already a sibling of cache-scope dirs and never in outer-agent or executor grants (verified by layout-invariant tests); the remaining hardening is the leaf-level write deny and the projection mechanics. Tests added: buildcontrol layout + lock serialization + persistent lockfile; buildmanifest BuildControl location round-trip; ParentSnapshotStore (freeze, lookup, ErrNoSnapshot, distinct worktrees, freeze-once, read-only provider); freezeSnapshotFromDurable Approval (no-manifest zero snapshot, digest-mismatch no snapshot, matching-digest freeze, no-approval no snapshot, malformed-manifest no snapshot); omac build approve (managed refusal, partial-env refusal, non-TTY refusal, no-manifest no-op, render-diff + abort + never executes + no durable write, arg parsing, isInteractive); serve freezeBuildSnapshot (freeze-once per parent lifetime, deactivate + reactivate does not refresh, no cache scope no-op, unapproved no snapshot, no-manifest zero snapshot); outer-agent inaccessibility (build-control root is a sibling of cache-scope dirs, not an ancestor; trusted paths not under cache-scope; not in executor grants). Verification: go build ./... green; go test green on buildcontrol, buildmanifest, buildengine, buildbroker, buildrun, credproxy, containerproxy, stableport, cli (only the pre-existing sandbox-only TestDoctorHarnessBinarySection fails — baseline-confirmed on ec0179f). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…t 07)
Ticket 07 (gate 5 of the host build broker). Adds a pending-to-active
daemon ownership handshake, platform-verified process identity, an
in-sandbox post-build daemon recycle, and brokered
that never executes repository-controlled code with host authority and
never signals an unverified PID.
New packages:
- internal/procidentity: platform process identity (Linux /proc,
macOS libproc cgo). A process qualifies as the leaf's Gradle daemon
only if executable == resolved JDK, main class == Gradle daemon
bootstrap, and OS start identity is unchanged. PID alone, command-line
substring matching, and registry.bin heuristics are never sufficient.
internal/buildcontrol:
- DaemonRecord + atomic pending/active/retired lifecycle at
daemons/<sha256(leaf)>.json (write-temp + rename).
- ReconcileDaemonRecords: parent-startup sweep (dead/PID-reused →
retire; live+matching → kept; unverifiable → block leaf + fail closed).
internal/buildrun:
- DaemonOwnerMarker (crypto/rand, 256-bit) injected into Gradle daemon
JVM args (-Domac.daemon.owner).
- init.d/daemon-owner-handshake.gradle: the daemon sends {pid,marker}
over a private Unix socket before project configuration; blocks on a
one-byte ack; throws GradleException on timeout/EOF (fail closed).
- DaemonHandshakeChannel: the host-side Unix socket; AwaitHandshake
verifies the marker (constant-time) + calls the procidentity verify
seam (promote happens INSIDE the closure, before the ack). Cancel
interrupts a blocked Accept so a wrapper that exits before a daemon
registers does not hang for the full deadline.
- RunStopInSandbox: the post-build runs under the
same restricted executor lifecycle (same grants, same Linux netns),
preserving ADR 0001's cold-start-per-build without an unsandboxed
host wrapper invocation.
- SUN_LEN fallback: a private 0o700 temp dir when the canonical socket
path exceeds macOS's 104-byte limit.
internal/buildengine:
- Run wires DaemonOwnership: marker → pending record before launch →
channel listen → wrapper launch → await handshake (concurrent with
RunBuild) → verify + promote (inside the closure, before ack) → ack
→ in-sandbox recycle → retire. Failure cancels the wrapper (fail
closed). ErrHandshakeCancelled (wrapper exited, no daemon) is not a
handshake failure — the wrapper's exit code is authoritative.
- StopBrokered: the distinct engine op for . Acquires
the same leaf lock; reads the ownership record; re-verifies via
procidentity; SIGTERM + bounded wait + re-verify + SIGKILL only on a
still-verified identity. No-record → idempotent success. Pending →
service_failure, signal nothing. Active+alive-unverified /
unverifiable → service_failure, signal nothing. Never executes the
repo wrapper, never applies a relaxed profile, never removes the
lockfile.
internal/buildbroker:
- Removed StopRefuser; the broker dispatches args to the
StopBrokered engine op via the production EngineInvoker.
internal/cli:
- start/serve reconcile daemon ownership at parent startup (fail-soft
at startup; fail-closed at build time).
- brokerEngineInvoker wires DaemonOwnership into brokered builds and
fails closed when the cache root is unavailable (the gate).
Deferred to e2e (gate 6): the host-gated macOS keychain + fake-Docker
test and the Linux bwrap-from-parent test (need real keychain/bwrap +
the e2e build tag; belong in internal/e2e/daemon_ownership_test.go).
Verified green (only the pre-existing sandbox-only
TestDoctorHarnessBinarySection fails, identical to the 5cebcfd baseline):
go build -buildvcs=false ./... EXIT=0
go vet -buildvcs=false ./... EXIT=0
go test ./internal/build... ./internal/procidentity/ ok
Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…red build path (ticket 08) Prose and code-comment corrections only — no behavior changes (gate 7 of the host build broker follow-ups, spec .scratch/jvm-build-executor-follow-ups/spec.md): - docs/build-command.md now describes the managed build path (brokered execution via the start/serve parent, OMAC_BUILD_BROKER_REQUIRED=1 managed-mode marker, fail-closed on partial broker tuple, omac build approve + parent-restart requirement) alongside the unchanged direct host-terminal path. The Health/authentication contract row is updated to reflect the shipped broker token + loopback-only endpoints (no longer deferred). The stale v1 approval-limitation section (no omac build approve subcommand) is replaced with the real approve flow. - Code comments claiming per-worktree queue locks are corrected to leaf-keyed serialization with the authoritative lock in host-only build-control/ (buildrun/queue.go, buildengine/engine.go StopOptions + Stop doc, cli/build.go + build_stop.go help text, containerproxy/proxy.go scavenger comment, README). The legacy in-leaf lock fallback is documented as the unmigrated no-parent direct path only. - Comments/help text describing --max-duration expiry as a FORCED cancel (grouped with the second signal) are corrected to the graceful-then-staged-kill path (run.go OnForcedCancel doc, build.go help text, docs/build-command.md Cancellation section) per spec §241. - PR #192 body updated via gh pr edit to reference the broker work and its gates (closes #191, refs #92, implements the broker follow-ups gates 1-7); verification notes now reflect the broker packages. - build_stop.go + engine.go now document that the leaf-keyed lock is persistent and never unlinked (ticket 06) and that the brokered stop (ticket 07) uses verified daemon control via procidentity, never executing the repo wrapper with host authority. Verification: go build + go vet clean; go test green for internal/buildrun, buildengine, buildbroker, buildcontrol, procidentity; internal/cli green except the pre-existing sandbox-only TestDoctorHarnessBinarySection (unchanged baseline failure). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
The CI lint job runs and fails on any non-clean file. 14 files landed unformatted across the build-control / build-engine / build-manifest / build-run / cli / procidentity packages from tickets 04-08, so the lint gate would be red. Pure whitespace/alignment fixes ( struct field padding, single-line function spacing ); no behavior change. is now empty; and clean. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Ticket 07's tests were authored on macOS (where the omac sandbox skips AF_UNIX dial) and never ran on Linux CI — the missing-CI-trigger issue left them unverified since Aug 6. Now that CI ran, five real Linux-only defects surfaced. No band-aids; each fix addresses the root cause: 1. staticcheck SA4006 (ownership.go): drop the dead first assignment of sockPath (overwritten before any read); declare it once at the resolveDaemonSockPath call. 2. staticcheck U1000 (engine_test.go): sockPathForRequest was unused — the engine tests discover the request ID by polling requests/, not ahead of time. Replaced the inline filepath.Join path construction at both call sites with the canonical buildrun.DaemonHandshakeSockPath(buildcontrol.RequestDir(...)) and deleted the dead helper. 3. staticcheck SA4000 (buildcontrol_test.go): HashLeaf(x)!=HashLeaf(x) and LockPath(r,l)!=LockPath(r,l) are tautologically false (always false), so the stability assertions never ran. Capture the first call into a local and compare against a fresh call so the comparison is real (and SA4000-clean). 4. Verify-error wrapping (daemon_handshake.go): AwaitHandshake wrapped ErrHandshakeVerifyFailed with %w but the verify error with %v, losing it — errors.Is(err, verifyErr) was false even though the error string contained the text. Use %w for both (Go 1.20+ supports multiple %w) so both errors.Is checks succeed. 5. Engine VerifyReady gate (engine.go): the gate required a resolved JDKExecutable unconditionally, but the Test (ubuntu/macos) and WSL2 CI jobs install no JDK. The gate exists for the DEFAULT verifier (procidentity.Verify needs the executable); a custom Verify closure (tests) owns its own logic and ignores JDKExecutable. Only enforce the gate when own.Verify == nil. This unblocks the 5 engine ownership tests on JDK-less CI runners without weakening the production fail-closed contract. 6. Test dialHandshake EOF (run_ownership_test.go): dialHandshake t.Fatalf'd on a read error, but the no-ack tests (verify=false / verify error) expect the host to close without acking → EOF is the expected outcome. Return 0 on EOF (matching dialHandshakeOnce's pattern) instead of fataling, so the no-ack tests reach their errors.Is assertions. 7. keychain.IsUnavailable sentinel (keychain.go): IsUnavailable string-matched the underlying dbus/socket messages but did NOT recognize its own ErrBackendUnavailable sentinel — IsUnavailable(ErrBackendUnavailable) returned false, failing TestKeychainLookup_MissingMapsToErrCredentialMissing on the WSL2 dead-bus runner. Add an errors.Is(err, ErrBackendUnavailable) check at the top so the sentinel is recognized. Verified: go build + go vet clean, gofmt -l empty, build-control / build-engine / build-run / keychain / credproxy tests green (the AF_UNIX ownership tests skip under the omac sandbox, same as before). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
The 5 TestRun_DaemonOwnership_* integration tests in internal/buildengine
never ran on Linux CI before (ticket 07 was authored on macOS where the
omac sandbox skips AF_UNIX dial, and CI never triggered until now). Now
that CI ran, three real test-design defects surfaced:
1. exit-0 wrapper race (HappyPath, PostBuildRecycle): the stub wrapper
exits immediately, so RunBuild returns and the
engine tears the handshake socket down (ownerCh.Cancel) BEFORE the
test's 20ms poll catches it — on fast/Linux runners the socket is
gone before the dial. A real Gradle build blocks on the handshake
ack inside project configuration. Add blockingWrapper: a stub that
blocks on a continue file (created by the test after dialing) with a
60s safety bound, simulating a daemon build that waits for the ack.
The two exit-0 tests now block until the test signals, so the socket
stays alive for the dial.
2. pid mismatch (PostBuildRecycle, GracefulCancel, ForcedCancel):
dialHandshakeOnce hardcoded pid=4321, but each test's Verify closure
expects its own pid (5555/5556/5557). The dial sent 4321, verify
rejected it → no ack → \x00. The dead param (never called
inside the dialer — the closure runs in the engine's handshake
goroutine) is replaced with a param so the dialer sends
the pid the test's verify closure expects.
3. marker filename (PendingPublishedBeforeLaunch): writePendingMarkerWrapper
wrote to ".started-marker", but is the first gradle arg (not the
wrapper name), so the file was never named gradlew.started-marker and
the test's poll never saw it. Write to a fixed
in the wrapper's cwd (the workdir).
Verified: go build + go vet clean, gofmt -l empty; buildengine tests
skip under the omac sandbox (AF_UNIX dial blocked) as before and pass
where the socket is available.
Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Two more daemon-ownership engine test defects surfaced on the second CI run (head aa8dcb8), both Linux+macOS, both pre-existing since ticket 07 (tests never ran on CI until now): 1. break-in-select (PendingPublishedBeforeLaunch): the success branch used a bare break inside a select to exit the polling for loop. In Go, break inside a select breaks the SELECT, not the enclosing for, so on the success path (pending record observed before the marker, exactly the invariant the test pins) the loop busy-spun on the closed markerSeen channel for the remaining ~15s, then fell through to t.Fatal("marker never appeared") reporting failure on the success path. Replace with a labeled break pollLoop and drain Run on success. (Root-caused via subagent; the wrapper DID write the marker, the path-mismatch theory was a red herring.) 2. --stop recycle timeout (GracefulCancel, ForcedCancel): the stub wrapper "sleep 30" (ForcedCancel traps SIGTERM too) is reused for the in-sandbox "gradlew --stop" recycle the engine runs after the build. The stub ignores --stop and sleeps through the recycle 30s bound, causing a mandatory-cleanup service_failure that overrides the expected ClassCancelled/ClassSuccess. Real "gradlew --stop" exits quickly; add sleepOnBuildStopOnStopWrapper (and the --stop guard for the SIGTERM-trapping variant) so the stub exits 0 on --stop and sleeps only for a real build. Verified: go build + go vet clean, gofmt -l empty; buildengine tests skip under the omac sandbox (AF_UNIX dial blocked) as before. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Root-caused in the brokered-ownership-debug handoff; each fix is minimal and TDD'd (failing test first, fix second, suite green after). KISS/YAGNI: no speculative plumbing. Bug 1 — brokered build always died with "pending daemon record missing required field (... jdk_executable ...)": PrepareDaemonOwnership wrote the pending DaemonRecord with an EMPTY JDKExecutable (the engine resolved it only AFTER GrantsFor, but the write ran BEFORE), and WritePendingDaemonRecord requires it non-empty. Fixed by pre-resolving the JDK BEFORE the prepare step via a new buildrun.ResolveJDKExecutable(getenv) helper — the same ResolveJDK GrantsFor uses with the same env, so the pending record always carries the exact value grants.JDKExecutable() later computes for the verify closure. The pre-resolution runs only when the DEFAULT verifier is in use (own.Verify == nil), mirroring the VerifyReady gate so JDK-less CI with a custom Verify (b73535b) is unaffected. Tests: buildrun unit pins the eager contract; buildengine dial test asserts the pending record carries the resolved java path for the brokered-wiring shape (CacheRoot only). Skips locally (AF_UNIX blocked under the omac sandbox); runs in CI. Bug 2 — CLI silent on brokered failure (exit 10, zero diagnostic): runBuildManaged read the result frame's Class/Exit but dropped Message. Print "omac build: "+Message (the broker already sanitizes), matching the direct path's prefix. Bug 3 — bare `omac build stop` policy-denied when the wrapper lives in a subdirectory (yarp3's backend/gradlew): StopBrokered resolved the wrapper via buildrun.Resolve, coupling it to a gradlew it never executes. spec.md §240: the brokered stop stops the daemon via the ownership record + procidentity-verified signals, NOT the repo wrapper. Removed the ParseArgs+Resolve wrapper validation; the op now keys on buildrun.GradleLeaf(opts.CacheDir) (cache-scope-keyed, --root-irrelevant) and treats --root as a parsed-but-ignored value. New test: bare stop succeeds with wrapper only under backend/. The direct-host Stop keeps its wrapper requirement (it executes gradlew --stop) — untouched. Also fixed the ownership.go doc comments that asserted the deferred JDK resolution was safe (the claim that produced Bug 1) and threaded Options.Getenv through BuildConfig.SetGetenv so GrantsFor's JDK resolution and the pre-resolution read the same env. Verified: go build, go vet, full tests for buildrun/buildengine/ buildbroker/buildcontrol/procidentity green; cli green except TestDoctorHarnessBinarySection (the documented sandbox-only baseline — reads ~/.config/omac, pre-existing, unrelated). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…erministic CI surfaced two issues in the previous commit's new test (TestRun_DaemonOwnership_JDKExecutableInPendingRecord): 1. Lint: a misaligned struct-field comment left the file not gofmt-clean. 2. Test (ubuntu/macos/WSL2): the poll-the-pending-record loop raced the blocking wrapper's file-poll release, so the record could be retired before the poll observed it. Moved the invariant assertion INSIDE the verify closure: the engine writes the pending record before launching the wrapper, so by handshake time it is guaranteed present — no timing window. The assertion now fails the build (and the test) deterministically when the record lacks the resolved JDK executable; the "never appeared within 10s" poll-loop timeout is gone. A Bug-1 regression now manifests as a prepare-step failure (the handshake channel never comes up, so the dial times out and the test reports a clear failure). Skips locally (AF_UNIX dial blocked under the omac sandbox); runs in CI. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
CI surfaced a Bug-1 regression in the test itself: the new TestRun_DaemonOwnership_JDKExecutableInPendingRecord wires the brokered shape (JDKExecutable unset, custom Verify closure), but the pre-resolution gate ran only when own.Verify == nil, so the eager JDK resolve was skipped, PrepareDaemonOwnership hit WritePendingDaemonRecord's missing-field reject, and the handshake channel never started — the 15s dial timeout on every Test job (ubuntu, macos, WSL2). The gate's Verify == nil clause was test accommodation (b73535b): JDK-less CI runners wire Enabled()+custom-Verify and set JDKExecutable explicitly to skip JDK discovery. That intent is preserved by keying the gate on JDKExecutable == "" instead — the record requires a non-empty value no matter which verifier is in use, so an empty value must always be resolved eagerly. Verified locally: build, vet, gofmt clean; buildrun + buildengine suites green (dial tests skip under the omac sandbox, run in CI). Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Two defects from the local-install gradle-daemon run (no issue; filed #206 in error and was closed): 1. RenderDaemonOwnerHandshakeInitScript emitted the pid as a Groovy String (split() returns String[]), so JsonOutput.toJson produced {"pid":"12345",...} (quoted) while the Go side unmarshals DaemonHandshakePID.PID int -> json.Unmarshal failed the handshake on every daemon-spawning build. gradle --version escaped it only because it spawns no daemon. Fix: '.toInteger()' so the pid serializes unquoted; pinned in the render test. 2. PrepareControlState wrote the daemon-handshake-sock control file only when a socket was wired, never removing a stale one. A daemon reused out-of-band (no host omac build) inherited -Domac.daemon.owner from gradle.properties and failed closed against a dead socket. Now removes the stale file when the sock path is empty, restoring the designed no-op path. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Drives the full omac build loop exactly as an agent does — macos brokered build through the host broker into a real Gradle wrapper — with a committed synthetic Gradle fixture (JUnit 5 + Mockito + Testcontainers, no Spring), approval pre-seed via the exported buildmanifest/buildcontrol API, cold-cache pre-seed with loud failure, an approval-gate negative subtest, and an IT leg that asserts executor-owned container/network cleanup through the container proxy (ADR 0002). Nested sandbox runs take the loud exit-10 exposure branch; unit/IT legs run on host/CI via scripts/e2e-local.sh build and the new e2e-build.yml workflow. Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
The canary reused writeCacheTestProfile (network mode "blocked"). On macOS the Seatbelt generator emits (deny network*) under blocked and ignores open-port exceptions there, so the --open-port start.go injects for the loopback build broker was inert: every brokered build died with "connect: operation not permitted" before the request reached the engine — masking even the approval-gate diagnostic the negative subtest asserts. Both e2e-build.yml legs (unit + it) failed this way. Switch the canary to a dedicated profile: filtered mode (honors the injected --open-port and whitelists loopback for the Gradle daemon's worker protocol), allow_domain covering loopback + Maven Central + gradle services, proxy_injection ["jvm"] so the supervisor routes JVM traffic through the omac filtering proxy, a read grant on ~/.colima for the IT leg's staged socket, and a read grant on the real JDK home so gradlew's launcher JVM can read java.security (the executor-scoped buildrun grants don't apply to the outer sandbox shell). Refs #207 Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
The JVM-build canary (TestE2EJvmBuild, issue #207) failed on both unit and IT legs with java.lang.InternalError "Error loading java.security file". Root cause: the build executor's Seatbelt grants covered the resolved JDK's bin/lib/libexec/lib64 but not conf/ — since JDK 9 the JVM's Security.initialize() reads $JAVA_HOME/conf/security/java.security, so under deny-default Seatbelt the gradlew launcher (and the post-build in-sandbox daemon recycle) died before doing anything. Restore conf/ to jdkReadPaths (present in the upstream fix d154293 but absent from this branch), and pin the exact failure mode with regression tests: makeFakeJDK now builds the real JDK 9+ layout and both the JDK-resolution and GrantsFor-level tests assert the conf/ grant. Refs #207 Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…T from the unit leg The JVM-build canary (TestE2EJvmBuild, issue #207) failed on both legs with 'cannot find symbol: PostgreSQLContainer' at PostgresIT.java compileTestJava. Root cause 1: PostgreSQLContainer moved out of org.testcontainers:testcontainers into the per-database org.testcontainers:postgresql module (>= 1.15); the fixture's build.gradle never declared it, so the import did not resolve. Root cause 2: once the module made PostgresIT compile, modern Gradle's default 'test' scan includes **/*IT.class — the fixture relied on the (wrong) filename-convention comment, so the unit leg would run PostgresIT and fail with no Docker daemon. Exclude **/*IT.class explicitly; the IT leg still gets PostgresIT via the dedicated integrationTest task. Both fixes match the upstream canary-stabilization commits (9a98d62, 794dbe8) already present on the passing reference branch. Refs #207 Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
…rsion bump) The TestE2EJvmBuild it-leg-loop failed with testcontainers 'Could not find a valid Docker environment' because Docker 29.6.2 (Colima 0.10.3) raised defaultMinAPIVersion from 1.24 to 1.40, but testcontainers 1.20.4 pins docker-java to v1.32 unconditionally. The daemon's version middleware rejects GET /v1.32/info with 400 'client version 1.32 is too old', testcontainers exhausts its strategies, and the IT leg fails. Layer 1 (primary, source-side): the container proxy discovers the daemon's max API version at startup via GET /version, threads it through ContainerProxyHandle.APIVersion -> BuildConfig -> BuildGrants -> ChildEnv, and injects api.version=<max> into the executor env. docker-java reads that env var (literally named 'api.version' with a dot), pins to it, and testcontainers does not override to v1.32. Layer 2 (defense-in-depth): the proxy clamps a client request's /vX.Y/ version prefix into [MinAPIVersion, APIVersion] in forward(), handling too-old AND too-new. Disabled (verbatim passthrough) if the startup /version probe failed. Ports the upstream fix 6ab572e (present on the passing reference branch, absent from this PR's branch). The fix is version-agnostic — future MinAPIVersion bumps (Docker 30.x+) need no code changes. Refs #207 Signed-off-by: Sajjad Ahmad <sajjad.ahmad@tngtech.com>
Signed-off-by: Mathias Wagner <mathias.wagner@tngtech.com>
aae2ee1 to
958f016
Compare
|
Mathias and I have now rebased the branch on the current main. Concerning the documentation:
Concerning the problem that is solved here: Is this something that could also be solved with VMs or similar, in case there are some workarounds that do not require such massive PRs? I think that we have a major conflict here between keeping things simple and well-reviewed, and supporting as many options as possible, so we should at least discuss any other options, if available. |
|
I had discussed this with Mathieu Desponds and @nhuelstng before building: I became a maintainer of omac because it's my daily driver sandbox and we need gradle to test in our $client project. Without it, agent is mostly guessing. I also showed omac in an AI meeting at $client and the colleagues were interested, and some TNG colleagues also tried it out, but never started using it, because of the lack of gradle support and (hopefully all fixed) setup issues. So my assumption would be that at least a few colleagues would also use it with this feature. And I'm sure many client projects use gradle. However, it is still quite a lot of complexity and a clear drawback (due to mac seatbelt limitations) that come with this, so we should all be certain that we want to add and maintain this feature. Regarding Architecture: AFAIK the only better way to solve this is using a (micro)VM. However, if we introduce a VM 'connection' to omac, I would ask myself as a user (and maintainer): Why bother with omac if I already have a VM then? At that point, wouldn't it be simpler to fully commit a (micro)VM sandbox solution - both from usage and maintainer perspective? |
|
Fully agree on the documentation point |
|
One option to check: can we open ports after the test runner has started (when the port is defined)? |
Issue: Closes #191 · Closes #207 (build brokered canary) · Refs #92 · Implements the host build broker follow-ups (spec:
.scratch/jvm-build-executor-follow-ups/spec.md, gates 1–7)What
omac buildfor constrained Gradle requests with protected control state, isolated cache, JDK resolution, cancellation, and leaf-keyed serialization..omac/build.yaml, subject to host approval and policy ceilings.internal/buildbroker): a session-scoped, in-process broker in the unsandboxedomac start/serveparent that accepts constrained build requests from a sandboxedomac buildthin client over the loopback control plane. The broker owns host-only orchestration (manifest/policy evaluation, keychain access, proxy lifecycle, leaf-keyed serialization, restricted executor launch, cancellation, cleanup) while repository-controlled build code still runs only inside the restricted JVM build executor (ADRs 0001, 0002, 0004).OMAC_BUILD_BROKER_REQUIRED=1) with fail-closed behavior, the per-parent cryptographically random build token, andomac build approve(host-only, interactive-terminal-only) plus the parent-restart requirement for capability activation.internal/procidentity), parent-startup reconciliation, and verified trusted daemon control for brokeredomac build stop(never executing the repo wrapper with host authority, never removing the lockfile).TestE2EJvmBuild(issue Build brokered canary: e2e test for the full omac build loop (Gradle + Testcontainers) #207): a committed synthetic Gradle fixture (JUnit 5 + Mockito + Testcontainers, no Spring) driving the full brokered loop through a real Gradle wrapper, with approval pre-seed, cold-cache loud failure, an approval-gate negative subtest, an IT leg asserting executor-owned container/network cleanup, and a dedicatede2e-build.ymlCI workflow (unit legmacos-latest, IT legmacos-15-intel).build-control/; max-duration expiry described as immediate force → graceful-then-forced staged path;docs/build-command.mdnow describes the managed build path alongside the unchanged direct host path.Why
Direct Gradle execution in the agent sandbox is unreliable for daemon, worker, and Testcontainers workflows (macOS keychain access is denied by the outer Seatbelt profile; the Colima socket is unavailable to the container proxy; a nested Gradle Seatbelt profile is rejected). Running it unsandboxed would execute repository-controlled build code with host-user authority.
omac buildkeeps that code in a restricted executor while supplying only the capabilities a declared build needs. The broker moves host-only orchestration (keychain, raw daemon, proxies) into the unsandboxed parent so the agent sandbox never receives that authority, while the build executor stays restricted.How
flowchart LR H[Harness] -->|omac build| G[Manifest and host-policy gate] G -->|approved| E[Restricted Gradle executor] G -->|denied| D[Actionable policy denial] E --> C[Protected control state<br/>queue, cache, JDK, temp] E -->|public dependencies| P[Filtered network proxy] E -->|private Maven| R[Credential-lift proxy] R --> K[Host keychain] E -->|approved images| X[Docker policy proxy] X --> Docker[Host Docker daemon]OMAC_BUILD_BROKER_REQUIRED=1even on setup failure; a partial broker tuple exits 10 with a restart/upgrade diagnostic; managed invocation never falls back to nested local execution.omac build stopuses verified daemon control (procidentity.Verify+ host-only ownership records) — it never executes the repo wrapper, never applies a relaxed profile, never removes the lockfile.Broker work gates (spec
.scratch/jvm-build-executor-follow-ups/spec.md)internal/buildenginewithout changing ordering, exit behavior, or existing direct invocation (ticket 04).internal/buildbroker, the finite execute protocol, cancellation endpoint, explicit managed-mode marker, andstart/servewiring (ticket 05).e2e.ymlmacOS/Linux matrix).docs/build-command.md, code comments that claim per-worktree locks or immediate max-duration force, and PR feat(build): add mediated JVM build executor #192's issue link and verification (ticket 08 — this commit).Verification
go vet -buildvcs=false ./...passed.go build -buildvcs=false ./...passed.go test -count=1 ./internal/buildrun ./internal/buildengine ./internal/buildbroker ./internal/buildcontrol ./internal/procidentitypassed.go test -count=1 ./internal/cli(excluding the pre-existing sandbox-onlyTestDoctorHarnessBinarySection) passed.git diff --check main...HEADpassed.go test ./...remains blocked in this nested sandbox: the doctor test cannot read~/.config/omac/config.yaml, and macOS integration tests cannot apply a nested Seatbelt profile. These run on host/CI.Follow-up
internal/e2e/daemon_ownership_test.godriven byscripts/e2e-docker.shand thee2e.ymlmacOS/Linux matrix (gate 6).Reusable components
The executor introduces four components intended for reuse beyond the Gradle path:
internal/buildmanifest): a project-declared.omac/build.yamlcapability contract vetted by host policy against a frozen-for-session gate — a seam for future build tools (e.g. a Maven adapter) rather than Gradle-only logic.internal/credproxy): a host-side loopback proxy that injects keychainAuthorizationupstream for approved registries without the credential ever entering the executor — a partial step toward the generic authenticating mirror in Authenticated package-registry access without exposing the token to the agent #92, usable beyond Maven.internal/containerproxy): an allowlisted, executor-owned Docker-compatible endpoint with scoped prune and denial correlation — reusable by any sandboxed workflow needing mediated container lifecycle without raw daemon access.internal/stableport): per-worktree loopback ports with a persisted control file and scan/ephemeral fallback — reusable by any host-side loopback service that must stay reachable across runs.Related JVM proxy-routing work for proxy-unaware clients is tracked in #119 but not addressed in this PR.