Skip to content

feat(build): add mediated JVM build executor - #192

Open
NoRiceToday wants to merge 48 commits into
mainfrom
feat/jvm-build-executor
Open

feat(build): add mediated JVM build executor#192
NoRiceToday wants to merge 48 commits into
mainfrom
feat/jvm-build-executor

Conversation

@NoRiceToday

@NoRiceToday NoRiceToday commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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

  • Add omac build for constrained Gradle requests with protected control state, isolated cache, JDK resolution, cancellation, and leaf-keyed serialization.
  • Let projects declare non-secret build capabilities in .omac/build.yaml, subject to host approval and policy ceilings.
  • Add host-side credential and Docker proxies so builds can resolve approved private Maven dependencies and use approved Testcontainers images without receiving credentials or the raw daemon socket.
  • Add the host build broker (internal/buildbroker): a session-scoped, in-process broker in the unsandboxed omac start/serve parent that accepts constrained build requests from a sandboxed omac build thin 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).
  • Add the managed-mode marker (OMAC_BUILD_BROKER_REQUIRED=1) with fail-closed behavior, the per-parent cryptographically random build token, and omac build approve (host-only, interactive-terminal-only) plus the parent-restart requirement for capability activation.
  • Add the pending-to-active daemon ownership handshake, platform-tested process identities (internal/procidentity), parent-startup reconciliation, and verified trusted daemon control for brokered omac build stop (never executing the repo wrapper with host authority, never removing the lockfile).
  • Add the model-free build brokered canary 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 dedicated e2e-build.yml CI workflow (unit leg macos-latest, IT leg macos-15-intel).
  • Correct stale comments and docs: per-worktree lock claims → leaf-keyed serialization with the lock in host-only build-control/; max-duration expiry described as immediate force → graceful-then-forced staged path; docs/build-command.md now describes the managed build path alongside the unchanged direct host path.
  • Document the supported workflow and the macOS/Linux network-boundary difference.

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 build keeps 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]
Loading
  • The executor receives no ambient Gradle home, host secrets, raw Docker socket, or unrelated host paths.
  • The Docker proxy is allowlist-based, injects executor ownership, and rejects privileged or host-escaping container options.
  • Stable proxy ports and post-build daemon recycling prevent stale daemon connections across runs.
  • Host build mediation runs in-process in the unsandboxed session parent; a clean broker/build-engine seam keeps a future managed-sidecar move straightforward without adding sidecar lifecycle now (YAGNI/KISS, ADR 0004).
  • Proxy-mediated dependency, credential, and container support is macOS-only in v1 (Shape A env-only filtering); on Linux the executor is kernel-blocked and those loopback proxies are not started — Linux proxy support is deferred.
  • Brokered execution is fail-closed: the parent injects OMAC_BUILD_BROKER_REQUIRED=1 even on setup failure; a partial broker tuple exits 10 with a restart/upgrade diagnostic; managed invocation never falls back to nested local execution.
  • Brokered omac build stop uses 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)

  1. Create and self-assign the focused issue.
  2. Extract internal/buildengine without changing ordering, exit behavior, or existing direct invocation (ticket 04).
  3. Add internal/buildbroker, the finite execute protocol, cancellation endpoint, explicit managed-mode marker, and start/serve wiring (ticket 05).
  4. Move lock acquisition before mutable control state; correct the lock's cache-leaf semantics; relocate the lock to host-only state; namespace worktree-specific records; freeze active capabilities in parent memory; add the host-only approval transition; enforce cross-platform read-only control-state projection; remove lockfile deletion (ticket 06).
  5. Keep post-build recycle inside the restricted executor lifecycle and replace brokered manual wrapper stop with the pending-to-active daemon handshake, platform-tested process identities, startup reconciliation, and verified trusted daemon control (ticket 07).
  6. Enable brokered build and stop only after all security and lifecycle gates pass on macOS and Linux (wired in ticket 07 Phase 5; e2e validation deferred to the e2e.yml macOS/Linux matrix).
  7. Update 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/procidentity passed.
  • go test -count=1 ./internal/cli (excluding the pre-existing sandbox-only TestDoctorHarnessBinarySection) passed.
  • git diff --check main...HEAD passed.
  • Full 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.
  • Ticket 08 is prose and comments only — no behavior changes. The affected packages' tests are green unchanged.

Follow-up

  • Authenticated package-registry access without exposing the token to the agent #92 remains open: this delivers the Gradle/Maven credential-lift tracer bullet, not the issue's generic multi-tool registry catalog.
  • Real-host validation remains for canonical worker checks, cold container compilation, and the multi-harness JVM build workflow.
  • E2E validation of the daemon ownership handshake + brokered build/stop (host-gated macOS keychain + fake Docker endpoint; Linux bwrap-from-parent + private-loopback) is deferred to internal/e2e/daemon_ownership_test.go driven by scripts/e2e-docker.sh and the e2e.yml macOS/Linux matrix (gate 6).

Reusable components

The executor introduces four components intended for reuse beyond the Gradle path:

  • Build capability approval (internal/buildmanifest): a project-declared .omac/build.yaml capability 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.
  • Credential lift (internal/credproxy): a host-side loopback proxy that injects keychain Authorization upstream 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.
  • Docker mediation (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.
  • Stable-port components (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.

NoRiceToday pushed a commit that referenced this pull request Aug 6, 2026
…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>
@NoRiceToday
NoRiceToday force-pushed the feat/jvm-build-executor branch from 21ec1e6 to 38aa758 Compare August 7, 2026 08:36
@NoRiceToday
NoRiceToday requested a review from nhuelstng August 7, 2026 08:57
@NoRiceToday

Copy link
Copy Markdown
Contributor Author

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...

@NoRiceToday

Copy link
Copy Markdown
Contributor Author

TODOS from sync, verify:

  • test whether file access in tests is limited as intended
  • on linux network access should be fully controllable

@nhuelstng

Copy link
Copy Markdown
Contributor

Blocking (security boundary does not hold)

  1. Docker proxy: case-variant JSON keys bypass the whole HostConfig policy → privileged container + host bind mount = host root. internal/containerproxy/policy.go:216-217,402 reads exact-case keys from a map[string]any and re-marshals the raw map; moby decodes case-insensitively. {"Image":"","hostconfig":{"Privileged":true,"Binds":["/:/host"]}} is forwarded with 201. Same trick spoofs labels/image/hostIp. Reproduced by the reviewer against a copy of the package. Fix: decode into moby's typed structs and re-serialize only the typed struct; reject any key that EqualFolds a known key without exact case.
  2. Docker proxy: %66ilters= defeats the ownership filter on every prune/list endpoint (policy.go:621,686 does HasPrefix(kv, "filters=") on the raw query; the daemon unescapes and takes the first value) → volumes/prune?%66ilters={"all":["true"]} wipes the developer's unused volumes/images/networks. Reproduced. Fix: url.ParseQuery, delete all filters, set the ownership filter.
  3. Credential-lift proxy: path traversal turns it into an authenticated GET oracle against the whole registry host. internal/credproxy/proxy.go:482-484 concatenates the client path onto the upstream base with no .. rejection or prefix check → GET /internal/../../api/security/apiKey returns the developer's Artifactory API key. Confirmed.
  4. Credential-lift proxy has no client authentication and no Host check (zero token handling in the file; deterministic FNV port persisted 0644, stableport.go:42-52,244). Any local process, other sandbox session, or browser via DNS rebinding can use the developer's registry credential. Confirmed.
  5. macOS executor runs under (allow network*) (internal/sandboxrun/sbpl.go:126-129, selected by internal/buildrun/grants.go:494-497); Seatbelt classifies AF_UNIX connect as network-outbound, so build/test code can DOCKER_HOST=unix://~/.colima/default/docker.sock and skip the proxy entirely. Needs confirmation on a Mac, but if it holds the container proxy is advisory, contradicting docs :642-650 "the executor NEVER sees the raw daemon socket".
  6. Direct host path runs the repo's mutable gradlew --stop unsandboxed with full host authority, after every direct build and on omac build stop (internal/buildrun/stop.go:92-102; internal/buildengine/engine.go:694 literally says UNSANDBOXED). A build that rewrites its own gradlew escapes the executor. Confirmed. Docs :424-443 don't mention it.
  7. Daemon-ownership handshake trusts a self-reported pid. internal/buildrun/daemon_handshake.go:342-360 takes pid from JSON written by sandboxed code; procidentity.Verify only proves "some Gradle daemon on this JDK", no SO_PEERCRED/LOCAL_PEERPID or ancestry check → brokered omac build stop can be steered to SIGKILL the developer's IDE Gradle daemon. Confirmed. And on Linux the JVM reports a namespace-local pid (bwrap.go:46 --unshare-pid) that the host-side verifier looks up in host /proc — the feature can't work there as written.

Should fix before merge

  1. Client-project-specific logic and naming committed into the tool. An omac-generated init script that retires tasks matching a specific client's checkstyle*Sandbox convention (internal/buildrun/control.go:244-308), plus that client's name in omac provenance output (internal/cli/provenance.go:247), CLI help (build.go:256) and docs — 26 mentions. This is both a client-name leak into a public repo and product-inappropriate behaviour (silently disabling users' Gradle tasks by name pattern). Remove; if needed, make it a manifest-declared opt-in.
  2. Every design rationale points at documents that aren't in the repo. ADRs 0001–0004, CONTEXT.md, and the .scratch/…/spec.md the PR body links are all gitignored (.gitignore:69). Committed docs cite "ADR 0003 Revision" etc. COLLABORATION.md requires decisions to live in docs//ADRs. Reviewers currently cannot read what the code claims to implement.
  3. Serve authorizer canonicalization mismatch — internal/cli/serve.go:1894,1258 keys active dirs by filepath.Abs, internal/buildbroker/authorizer.go:96-100 EvalSymlinks the client path before isActive → every brokered build under a symlinked dir (macOS /tmp, /var) fails 403. buildbroker.ServeActiveDirs (the type the tests cover) is dead code. Confirmed.
  4. Serve mode: /omac/activate is unauthenticated and agent-callable (serve.go:1908), and with no --root the authorizer allows any active dir → an agent can activate ~/other-repo and get its gradlew executed with rw over it. Bind the broker to host-configured roots.
  5. Docker proxy: containers stay on the default bridge (proxy.go:1256-1275 only connects to the executor network, never sets NetworkMode), and NetworkingConfig is unchecked → unrestricted egress and join-any-host-network. Image approval is tag-agnostic (policy.go:535-561, ?tag= ignored). Executor ID = worktree basename (build_proxy.go:444-453) → same-named worktrees scavenge each other's live containers. Upstream is hard-coded to Colima (proxy.go:153-156).
  6. Credproxy secondary: repo-controlled upstream + alias-only approval rendering (approval.go:316-317) lets a second repo redirect the internal credential to https://attacker/; http:// upstreams accepted (proxy.go:251); Set-Cookie/WWW-Authenticate passed through (proxy.go:598-607); default redirect following (proxy.go:507). TestStart_ScansWhenStablePortBusy never calls Start() (vacuous).
  7. Linux executor network posture breaks Gradle itself: grants.go:479-484 → Landlock --enforce with no ports denies loopback bind/connect, so client↔daemon fails; docs :539-560 describe a network namespace that doesn't exist (bwrap.go:22 "NOT --unshare-net"). e2e-build.yml is macOS-only so nothing exercises Linux.
  8. Manifest decode is not strict despite the doc claim (manifest.go:178 node.Decode without KnownFields): typos silently drop capabilities and the approved digest covers the typed projection, not the file. ceilingStillValid (session.go:535-546) treats a removed ceiling as valid, contrary to its own comment. .omac/build.yaml as FIFO hangs the parent at start.
  9. Control-state lifecycle: RetireDaemonOwnership is deferred unconditionally so a daemon surviving --stop becomes unowned/unstoppable (engine.go:645,873-885); startup reconcile deletes pending records without the leaf lock, killing a concurrent parent's in-flight handshake (reconcile.go:905-909); managed-mode Ctrl-C cancels the request ctx and returns exit 10 instead of the documented exit 4 (build_managed.go:200-215,287-299); two shutdown TOCTOUs (broker.go:294-297/359, 379-384 never closes req.done).
  10. Global changes justified by build-only needs: /usr/libexec added to the macOS read baseline for every session (sandboxprofile/baseline.go:94); ExpandExisting now skips un-stat-able paths with a notice instead of erroring (expand.go:73-86) — safe direction, but a silent behaviour change for all users, motivated by running tests nested.
  11. Docs drift (concrete): /.gradle//.m2 are not in protectedCommon() despite build-command.md:36,506,860; :266-271 "fail-closed allowlist" is advisory on macOS; CONFIGURATION.md +181-193 Linux claim false; bounds.go:82-85 says execute checks Accept: — it doesn't; authorizer.go:21-23 claims constant-time compare, uses !=.

Sajjad Ahmad and others added 24 commits August 27, 2026 17:39
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>
Sajjad Ahmad and others added 24 commits August 27, 2026 17:45
…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>
@mwtng
mwtng force-pushed the feat/jvm-build-executor branch from aae2ee1 to 958f016 Compare August 27, 2026 16:03
@VictoriaRuckerbauer

Copy link
Copy Markdown
Contributor

Mathias and I have now rebased the branch on the current main.
Do I understand correctly that you are specifically trying to solve issues with Gradle projects?
And also, for priorization: do we have any users that are currently blocked by this?

Concerning the documentation:

  • Likely, build_command.md now duplicates things already mentioned in other docs. We have not cleaned this up in the rebase.
  • In my opinion, we need to focus on keeping documentation concise and well-reviewed. I do not think that maintaining 1000 lines of build_command.md is reasonable (or likely to happen), so I have a strong preference towards shortening it significantly. I think that for every section, it should be clearly visible what the documentation wants to tell the reader and why it is relevant to them.

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.

@NoRiceToday

Copy link
Copy Markdown
Contributor Author

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?

@NoRiceToday

Copy link
Copy Markdown
Contributor Author

Fully agree on the documentation point

@VictoriaRuckerbauer

VictoriaRuckerbauer commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

One option to check: can we open ports after the test runner has started (when the port is defined)?
Other alternative that might work: can omac figure out the port that the runner wants and ask the user whether it can open it?
How did nono solve this problem?

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

Labels

None yet

Projects

None yet

3 participants