Skip to content

fix(native): merge train batch 7 (#636, #638, #637) - #641

Merged
roodboi merged 230 commits into
nextfrom
claude/train-batch-7
Nov 7, 2026
Merged

roodboi merged 230 commits into
nextfrom
claude/train-batch-7

Conversation

@roodboi

@roodboi roodboi commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

What this is

Merge train batch 7 into next: three real-project trial fixes. Each component PR's reviewed head is merged with git merge --no-ff, in this order (#638 is stacked on #636). Integration conflicts are resolved in the batch's merge commits, described below. Hosted CI on this branch is the full-suite gate for the combined result.

PR Title Linear Head included Review at that head
#636 fix(native): compile host aliases like execution and name unbound aliases HACK-1939 4d25fa5a Codex code + security: no findings; 0 open threads
#638 fix(native): run stock image processes in wrappers like Docker, naming refusals HACK-1939 08f2d8c3 Codex code + security: no findings; 0 open threads
#637 fix(native): reconcile edited graphs that have a dependency cache HACK-1937 592a9b1d Codex code + security: no findings (the security review confirms the cache-poisoning fix); 0 open threads; real-VM proof passed

Integration changes

Validation on this branch (458e0a93)

  • Clean CLI typecheck: pass. Biome on changed files: no errors.
  • RUSTUP_TOOLCHAIN=1.97.1 bun run check:local: pass. All-features clippy -D warnings (Zig 0.15.2): pass.
  • Cargo --lib: 1375 pass. --features native-config-plan provider::graph: 688 pass. Contracts: project_contract 66, compose_fields_contract 10, interpolation_budget_contract 1, local_contract 14.
  • bun run test:models (TLA+ 1.7.4): exit 0, 177 controls verified.
  • Focused bun tests, bounded runner, the changed test files: 260 pass, 0 fail.
  • docs:prepare + docs:check, privacy:check, gitleaks over origin/next..HEAD: pass.

Not run locally: the full bun suite, the Linux cargo jobs, docker-e2e and native host qualification. Hosted CI covers the first three.

After this merges, each component PR is closed with a link to the merge commit.

🤖 Generated with Claude Code


Run Proof on this PR
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

roodboi and others added 30 commits November 5, 2026 11:11
A 14-service retaining cleanup sent every container stop at once. Each stop
holds one engine connection for up to its grace period, and the batch failed
at the connect stage while the provider host held most of its descriptor
limit, leaving the graph in cleanup-intent with containers running.

The stop batch now runs through at most four workers that share one queue
and are all joined before it returns. A connect that fails or times out sent
no request, so it is retried with capped backoff inside the batch deadline
and a 30-second window from that stop's first connect failure. Once one stop
exhausts the window, later connect failures return at once. Response,
timeout and transport failures are still never retried. A stop whose worker
panicked or never ran is reported with the worker stage.

The existing stop budget already covers fully serialized grace periods, so
bounded dispatch fits it. A partial failure keeps the reached stops, and the
next cleanup stops only the unreached instances.

Refs HACK-1302

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native Compose review no longer aborts on an unsupported port. Each port
or expose entry outside the modeled subset becomes an invalid_port error
diagnostic at services.<name>.ports[<i>] (or .expose[<i>]), collected with
the rest of the review; the entry is omitted from the plan so authored text
is never echoed, and enrollment stays incompatible.

Import preview reviews each environment entry on its own: only an
interpolated or ambiguous entry is refused at its pointer, and literal
siblings stay mapped.

Doctor's compose backend check names the isolatedNetworks adaptation for
an external network, warns (with nativeUpRefusals: ["basic_build"]) for a
basic build that native image-project up refuses, and the config
validate/plan input-family refusal now names the next step.

Refs HACK-1907

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`hack doctor --migrate-env-config` deleted the committed hack.env.json and
edited the tracked Compose file in non-interactive runs, because every step
accepted its default. A run that can't prompt now refuses with
E_INTERACTIVE_REQUIRED before any fix step writes, naming each git-tracked
file it would delete or edit, unless --accept-tracked-env-changes is passed.
Interactive runs list those files and ask per step; tracked legacy files get
their own removal prompt.

Native doctor now runs the same project-file migration instead of pointing
to v4. The implicit migration (env exec, sessions, lifecycle env) reports each
moved key value-free and removes a fully delivered, untracked legacy dotenv
file that Compose no longer names, so no plaintext copy stays behind.
Refusals no longer claim .hack/.env files exist.

Refs HACK-1904

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bels and nested defaults

Native Compose review and execution now accept four Compose forms that
previously refused the whole project:

- depends_on.<svc>.required: false. An optional edge to a service the
  selected profiles disable is skipped with optional_dependency_inactive,
  as in Compose. An active optional target is enforced like a required one
  (fail closed) and reported as optional_dependency_enforced. restart: true
  and missing targets keep their refusals.
- stdin_open and tty. stdin_open keeps an open, unattached stdin
  (OpenStdin true, AttachStdin/StdinOnce/Tty false). tty is accepted, but
  no pseudo-terminal is allocated (terminal_not_allocated warning), so logs
  stay multiplexed.
- caddy hostnames with caddy.tls internal and no caddy.reverse_proxy. The
  Compose router proxies nothing for this form, so native records it as
  inert: no route, hostname claim or relay, an inert_route_declaration
  warning, and graph admission accepts exactly those two labels. Runtime
  host metadata still advertises the hostnames, as on Compose, but the TS
  route map and additionalHostnames adaptation exclude inert services.
  Every other partial or extra caddy label set is still refused.
- Nested interpolation defaults such as ${A:-${B:-}}. Inner :- and :+
  expressions resolve recursively, up to four levels, within the shared
  1 MiB budget. Unselected words are validated without requiring inputs.
  ?, unbraced $NAME and $(...) inside a word stay refused. A reference to
  a managed value of the same service now refuses with
  execution_environment_owner instead of resolving to a default or empty.

New plan fields are omitted when unused, so existing plan IDs do not
change. Older bundles refuse plans that use them (deny_unknown_fields).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native down always retained data, and a Compose edit that changes graph
resources left retained data that restore cannot adopt, with no frontend
way to start fresh. `hack down --remove-data` (Compose parity:
`down --volumes`) now discards it explicitly:

- Native Compose-file projects only. Authored native-config projects and
  the Compose backend refuse the option as a usage error.
- The project must be stopped; a running graph refuses and names
  `hack down` first. Removal runs no lifecycle hooks and never stops
  compute itself.
- It asks once after a read-only check of the exact saved run; `--yes`
  skips the question and `--json` requires `--yes`
  (E_INTERACTIVE_REQUIRED before any runtime request otherwise).
- Under the restart lock (a pending restart refuses), after the original
  frontend finalization, it requests `graph cleanup --remove-data`, which
  deletes only receipt-owned volumes behind a durable cleanup intent and
  releases shared dependency caches without deleting them.
- The run mapping is retired only after the receipt is `removed`, every
  owned volume is recorded and observed absent and caches are only
  released. A replaced mapping is never removed.
- Any failure keeps the mapping; a rerun resumes a durable cleanup intent
  (compute absent) or an already removed receipt without replaying
  deletion. Refusals carry at most a closed native code.

Restore refusals that say retained data was not adopted now name the two
remedies: revert the change, or run `hack down --remove-data`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native image-project up and restart now resolve every HACK_NATIVE_*
selection from three places, once per command:

- the committed .hack/native/adaptation.json, plus optional
  adaptation.<env>.json, holding the existing closed adaptation schema;
- a per-machine settings file outside every project share,
  ${XDG_CONFIG_HOME:-$HOME/.config}/hack/native/<project>/settings.json,
  for the share grant, AWS profile, host dependency files, HTTPS,
  prepared base and approved hosts (closed v1 schema, 0700 directory,
  0600 owner-only regular file, refused inside the project root);
- the existing variables, which still win per setting, whole-value.

Per-env files are picked by the run's effective env inside native start
and restart preflight, so fresh, retained and restarted runs select the
same files. Without --env the Compose-backend pre-check counts a network
as isolated only when every candidate adaptation isolates it.

Native doctor adds a value-free "native settings" check: the origin of
each setting (env, machine, project, unset) and closed warnings for
configuration native does not apply (internal DNS/TLS, internal
extra_hosts, routes without probes, ambient COMPOSE_PROFILES, an AWS
region without a profile, unread .hack/native files, missing share
grant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ations

The private graph environment envelope gains version 2 with a `files` map:
per service, one to four `{name, target, mode: "0400", content_b64}`
members. Version 1 with files, version 2 without them, and malformed,
duplicate or unknown member fields refuse, so an older runtime refuses
file delivery instead of dropping it.

Members are decoded into zeroized memory, bounded to 16 KiB each and to
the existing 32 KiB per-service payload together with values, and staged
in the same per-container tmpfs allocation as values.json: a root-only
0700 `files` directory with each member owned by the container's numeric
UID at mode 0400. They are bound read-only at their declared absolute
targets. Targets under /proc, /sys, /dev, /run and /var/run, nested or
duplicate targets, and any compiled mount or tmpfs that equals, contains
or sits under a target refuse before effects. Members live as long as the
allocation and are retired with it by the existing cleanup.

A service may receive files without values. It then keeps its own process
(no launcher wrap) and needs only a numeric image or Compose user. Live
owner restore and normalized serve-restore deliver fresh members; graph
exec and one-off runs refuse envelopes carrying files. Errors and receipts
stay value-free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A host dependency selection may now be version 2, where one entry names
services: [...] instead of a single service. Each listed service expands
to its own version 1 entry with the same binding, ports, aliases and host
authority, in order, and the expanded entries are then validated exactly
as version 1: the 128-binding limit counts expanded entries, duplicates
refuse, and an entry sets exactly one of service and services. Version 1
files are unchanged and both forms give the same dependency plan.

Startup, restart preflight and the stopped-restart intent reader all
expand before validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compose-file native graphs refused every image that declares a VOLUME,
which blocked stock database images even when a named volume covered the
path. The graph now admits an image VOLUME when exactly one explicit,
whole (no subpath) named volume owned by the same receipt is mounted at
each declared target. Missing, duplicate, bind, subpath, tmpfs and
dependency-cache covers refuse with graph_image_volume_uncovered before
any container is created; foreign OS, architecture or image identity
still refuse with graph_image. Start, restart, restore and one-off share
the predicate through verify_images. It reads only reviewed config, adds
no capability and does not change plan or receipt identities.

The per-graph volume budget rises from 8 to 16 (dependency caches still
count, since each volume costs per-graph inspection and cleanup work;
caches keep their separate 64-volume owner limit).

Native doctor adds a warning-only "native data services" check:
native_data_owner_handoff_unqualified for a service that runs a
published image's own entrypoint (no build, no entrypoint override) with
a named data volume and no explicit user:, and native_retained_data_mutable_image for
one on a latest or untagged image without a digest. Reports carry codes
and service names only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Groundwork for native image builds; no command builds images yet and native
up still refuses build services.

- provider-pins.json pins buildx 0.34.1 (darwin-arm64, Developer ID signed)
  by SHA-256. `runtime prepare-builder --archive <file>` checks the pin
  before creating any state, copies the file into private candidate state,
  rehashes the owned copy, verifies its code signature and records a tree
  digest. An existing tree is re-verified and reused; an interrupted
  preparation is retained and refused. `runtime probe` reports
  `builder_prepared` and refuses a changed tree.
- provider/build_scan.rs is a pure scanner that reports the images a build
  of the selected target can pull (FROM, COPY --from, RUN --mount from=,
  ONBUILD triggers of internal stages). It follows the Dockerfile frontend
  embedded in the pinned engine: directives, escape token, continuations,
  heredoc bodies, global ARG substitution with literal build-argument
  overrides, stage names and indexes, target reachability. It refuses what
  it cannot resolve exactly (syntax directives, variable modifiers, quotes,
  uncertain platform arguments, non-arm64 platforms, stage/image name
  collisions, unknown instructions and flags, non-Docker-Hub references,
  size and count bounds) and never echoes Dockerfile text or values.

Refs HACK-1270

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ependencies

Native up and restart now declare the effective internal.extra_hosts and
checkout-local extra-host aliases on every service, as the Compose override
does, and require a reviewed host dependency binding for every profile-active
(service, alias) pair. An alias without a binding, a non-host-gateway target,
an inexact name or a conflicting service mapping refuses value-free instead
of being silently ignored. Lifecycle hooks that change the aliases refuse
after hook cleanup.

Host dependency selections gain a declarative version 2 form: one entry per
binding applies to every active service that declares its aliases (or to
listed services) and expands to version 1 bindings before discovery, review
and the existing limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ctrl-C or SIGTERM on a ready native Compose-import graph whose up.after
completed now runs down.before, graph cleanup, lifecycle-process
retirement and down.after, matching hack down on the Compose backend and
the authored native path. A second interrupt cancels a running down hook
and forces cleanup. A failed down.before still stops the graph and is
reported as E_LIFECYCLE_FAILED. Ctrl-C during up.after runs no down hook.

A private value-free journal beside the run mapping (run, writer
generation, phase; compare-and-swap under a lock) records intent before
each hook and known completion after it exits. hack down skips completed
phases, runs owed ones once after the frontend's finalization, and
refuses unknown completion without replay; the next up refuses it too.
Projects without down hooks keep the attached graph owner and write no
journal.

Adds the native-foreground-down-hooks TLA+ model (1,246 states) with a
journal-guard negative control, unknown-generation and owe-withdrawal
controls, and stop and crash-recovery reachability controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The privacy check flags Linux home paths; the fixture only needs a nested
absolute target with a space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HACK_NATIVE_GCLOUD_ADC=file selects a gcloud Application Default
Credentials adapter on the native Compose foreground. It runs after the
login hooks, beside the AWS adapter. Services that bind
~/.config/gcloud/application_default_credentials.json read-only (short or
long syntax; ~, ${HOME}, $HOME or the absolute home) lose that mount from
public Compose. They receive the same bytes as a private mode-0400 file
member at the authored target through the version 2 private envelope.

The adapter reads only the fixed home ADC path: no symlink, owned by the
user, not group- or world-writable, 1 to 16 KiB of UTF-8 JSON, unchanged
during the read. It admits authorized_user and impersonated user
credentials and refuses service_account keys, external accounts and any
credential_source. GOOGLE_APPLICATION_CREDENTIALS must equal the target;
CLOUDSDK_CONFIG, metadata-host and token-file selectors refuse. One
`gcloud auth application-default print-access-token` proves the login
(output discarded); an ADC change during startup refuses. The run mapping
records only gcloud {mode: "file"}. Restart and retained restore keep it,
and exec rechecks selectors without delivering anything.

The AWS and gcloud adapters now share one private CLI helper: clean
environment, stdout only with a 64 KiB cap, own process group, SIGKILL on
a 30 s timeout, zero-filled buffers, no retry.

AWS credentials stay a startup snapshot. The foreground now prints one
value-free stderr line ten minutes before expiry and one at expiry, naming
only the service count and the local time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… adopt

The native Compose review refused Hack's own `hack.service.one-shot: "true"`
label as an ownership label, while `hack config import --dry-run` accepts it
as a native job. The review now consumes that exact key and literal value as
a completion role: the plan marks the service `one_shot`, the frontend gives
it a `completed` readiness goal, and the graph driver admits the marker only
with completion readiness and adds no container label. Ownership namespaces
and every other `hack.*` key stay refused; an invalid value, an unconditional
restart policy or routing labels on a one-shot refuse with their own codes.

`hack config adopt`, including `--dry-run`, still requires the Compose
backend because it reads that backend's existing containers and volumes. Its
refusal on another backend now names the rerun, for example
`HACK_RUNTIME_BACKEND=compose hack config adopt --dry-run`.

Refs HACK-1906

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prepareNativeProjectServices refused every service labeled
hack.service.one-shot before the native review could classify it, so the
reviewed completion goal was unreachable through hack up. The frontend
label allowlist now admits only the exact key with the literal value
"true" (map form) or the exact entry hack.service.one-shot=true (list
form), matching the native review. Other values, bare keys, sibling keys
and ownership labels still refuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nested default and alternative words were built in full before the
top-level resolve charged them, so repeated or nested references could
allocate far past the 1 MiB budget (multiplying per nesting level) before
execution_input_budget refused. Each literal, escape and nested value is
now checked against the remaining budget before it is appended; the
budget is still charged once, when the finished value is appended at top
level.

Tests: a peak-heap contract proves single-level and nested repetition
refuse without building past the budget, and an exact-fit case proves
nested output is charged exactly once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A one-off job keeps the parent service's mounts, but its image check saw
only the new job container, so an image VOLUME covered by the parent's
owned named volume was always refused with graph_image_volume_uncovered.
Verify against the parent receipt's volume resources plus the job
container; parent containers are still not re-inspected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native Compose review now parses port entries that use one interpolation
per component with a literal default, such as
"${BIND:-127.0.0.1}:${PORT:-8108}:8108", instead of reporting them as
invalid. Review stays value-free: native publication uses the literal
default, warns with interpolated_port_default at services.<s>.ports[<i>],
and records only the variable names (published_reference,
host_ip_reference; omitted when unused, so literal plans keep their
identity).

A reference without a literal default, a nested default, an interpolated
target or protocol, or a reference spanning components is a located
unresolved_port_binding error, so the rest of the review still runs and a
profile-gated service no longer blocks the default plan.

Short-form splitting follows Docker's port-spec rule after interpolation,
so bracketed and bare IPv6 hosts parse. ::1 is kept, :: is narrowed to ::1,
and a specific non-loopback host stays an unsupported_host_binding error,
now located at its entry with a clearer message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A contract key with `services: [...]` migrates only to a service scope,
and service values never reach Compose interpolation; Hack passes Compose
only global values under their own names. Compose reads `.hack/.env` for
interpolation on its own, so deleting it after the migration dropped a
`${KEY}` value that launches still needed.

The migration now keeps `.hack/.env` when a service-only key in it is
named in the Compose file or another env_file it lists (or is a COMPOSE_
setting), and says why in both the implicit and doctor reports, without
values. Compose input this check can't fully read keeps the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`hack down --remove-data` took the shared native operation lock as a bare
directory, so a removal killed while waiting for finalization or cleanup
left a lock every rerun refused, and SIGINT/SIGTERM bypassed its release.

Removal now links a complete owner record (operation, pid, nonce) into the
same lock directory. A rerun reclaims the lock only when that recorded
owner is gone (ESRCH), under a separate recovery guard that re-reads the
owner, following the projects-registry lock protocol. Live, reused or
inaccessible owners, owner-less locks (restart, filesystem recovery) and a
busy guard still refuse. The command turns SIGINT/SIGTERM into an abort
signal that kills and awaits the owned runtime request and ends the
finalization wait, so the lock is released before the command fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
x-hack-disposable-cache names regenerable directories inside a volume,
and an empty list is valid; everything outside those directories is
retained data. Doctor no longer treats such a volume as a cache, so a
database volume with a declared cache directory keeps both advisories.
Only hack.dependencies.cache-volume volumes are excluded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An optional dependency on a profile-disabled service is dropped from the
execution graph, but review still applied its condition requirements: a
service_healthy edge without a target healthcheck raised
missing_healthcheck, and a service_completed_successfully edge to an
always-restarting target raised nonterminating_completion_dependency.
Either made the whole plan enrollment-incompatible although the target is
never started or awaited. Review now stops after the
optional_dependency_inactive warning for such edges; active edges and
required edges to disabled targets are validated as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native Compose-file graph containers ran with every capability dropped,
so stock root entrypoints that chown their data directory and then switch
users (database images) failed on first start.

Compose-file services now get exactly moby's default capability set for
the pinned engine (docker-29.5.2), lowered explicitly as CapDrop ALL plus
the effective CapAdd set, with no-new-privileges kept. cap_add and
cap_drop follow Docker's normalization and precedence. cap_add accepts the
default names plus NET_ADMIN, NET_BROADCAST, SYS_NICE, IPC_LOCK and
IPC_OWNER; review refuses ALL and capabilities that act on the shared VM
kernel or on Hack's in-container helpers. privileged stays refused.
Services without capability fields keep their existing plan identity.

Authored native services, the persistent-data process policy and Hack's
helper containers keep every capability dropped. The guide documents the
security model: the development VM is the isolation boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A global `ARG BUILDPLATFORM=...` or `ARG TARGETPLATFORM=...` default
replaces the frontend's platform value, and the frontend expands
`--platform` against the global arguments. The scanner accepted
`--platform=$BUILDPLATFORM` by spelling alone, so such a stage could
select another platform. The spelling is now accepted only while no
global default overrides the argument; an overridden one must expand to
linux/arm64 like any other value. A bare global ARG still keeps the
frontend's value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-slim

The native file and storage helper image was the last place next still
shipped Bun 1.4.2. Catalog the registry-derived oven/bun:1.4.4-slim index,
platform manifests, configs and Bun/libc byte pins for linux/arm64 and
linux/amd64, and use the 1.4.4-slim tag for new VM file preparation.
Selection refuses any catalog entry whose Bun version is not current.

Saved storage records and VM file manifests written under the earlier
1.4.2 helper still parse, so saved stop and recovery stay available. That
artifact is no longer cataloged: storage enrolled under it refuses
verification as artifact drift and is never relabeled or re-seeded.

Refs HACK-1758

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ble maps

The native post-hook alias check passed the configuration snapshot read
before `up.before`, so a hook that edited `internal.extra_hosts` in
hack.config.json compared stale static aliases and startup proceeded. The
check now re-reads and validates project configuration itself
(`requireNativeProjectInternalExtraHostsUnchanged` takes no snapshot) and
refuses after hook cleanup when the effective aliases changed.

`readLocalExtraHosts` collapsed every read error into an empty map, so an
existing but unreadable checkout-local `.internal/extra-hosts.json` silently
dropped configured aliases. It now uses `readOptionalTextFile`: an absent
map is empty, an unreadable one refuses with `E_CONFIG_INVALID`. This also
covers Compose `up` and the `hack internal extra-hosts` commands, which no
longer overwrite a map they could not read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A down hook run by a foreground Ctrl-C now leads its own process group
with stdin closed. Canceling it (a second interrupt) sends SIGTERM to the
whole group, then SIGKILL once the shell exits or after a two-second
grace, and output draining after cancellation is bounded by the same
grace. A hook such as `sleep 600; echo finished` no longer leaves its
child running with the output pipes open, which blocked the forced stop
and the journal settlement. Hooks run by `hack down` keep the caller's
process group and terminal stdin.

The frontend's stop listeners now stay installed until down.after and the
finalization acknowledgement settle (disposed in an enclosing finally), so
a further interrupt cancels a running down.after through the force signal
instead of terminating the frontend with an after-started journal record.

Real-process regressions cover shell plus sleep child cancellation,
SIGTERM-ignoring descendants, an escaped process holding the pipes, the
non-owned path, and a second Ctrl-C during a real down.after through
startNativeProject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Engine copy-up seeds an empty named volume from the image's VOLUME
directory before a read-only mount applies, so a read-only cover still
lets the image write into that volume, which another service may share.
Image VOLUME targets now need a writable owned named-volume cover; a
read-only or malformed ReadOnly cover refuses with
graph_image_volume_uncovered for services and one-off jobs alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi and others added 21 commits November 6, 2026 22:05
Merge refs/remotes/pr/636 (4d25fa5: alias de-duplication, refusals naming
the alias, up/Ctrl-C output) onto claude/train-batch-6.

Resolutions:
- native-project-restart-preflight.ts: keep #608 dependencyOwners and add
  #636 internalExtraHostSources to the options.
- project.ts (handleNativeUp): keep #597 resolveNativeHostSettings values
  (allowedHosts, preparedBase, https, aws) and add internalExtraHostSources.
- project.ts (restart preflight): pass both dependencyOwners and
  internalExtraHostSources.
- native-project-start.ts imports: drop NATIVE_GRAPH_UNRESERVED; the image
  path inspects with `graph inspect`, which refuses `graph_unreserved`
  (#615), not the native provider's `native_graph_unreserved`.
- native-project-start.ts unreserved startup: one finalInspectionFailure
  that requires #615's `graph_unreserved` refusal plus #636's guards (a
  settled failure, not a restore, no mapping attempted) and marks graph
  cleanup confirmed. The message joins both: "nothing was created and no
  cleanup is needed" followed by #615's startup diagnostic.
- native-project-start.ts ready/stop output: #636's ready summary and
  confirmed-stop line ride #611's progress events instead of a second
  deps.notice channel. The ready event carries the run id and the
  endpoint lines; the stopped event carries the retention line when a
  mapping was published. project.ts renders them under the single ready
  and stopped lines. The notice dependency and its helpers are removed.

Semantic adaptations:
- nativeReadySummary returns only the indented endpoint lines; the ready
  line (with services and run id) is rendered from the progress event.
- #636 tests use `graph_unreserved` and observe progress events; the #615
  test expects the joined message. docs/guides/native-candidate.md
  describes one ready/stop output.

Tests (base + ours + theirs = merged): native-project-start 98+14+5=117,
native-project-internal-hosts 12+0+4=16, internal-extra-hosts-worktree
9+0+1=10, native-startup-logs 7+0+1=8, native-compose-interpolation
0+0+2=2. Focused run: 210 pass, 0 fail across 7 files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge refs/remotes/pr/638 (08f2d8c, stacked on #636: Docker-style
relative entrypoint and health-check resolution in the process wrappers,
named process refusals, doctor process-wrapper check).

Resolutions:
- runtime-core error.rs: keep HEAD's ErrorMessage (fixed-literal flag) and
  add #638's ProcessField, ProcessRefusal and ProcessRefusalsDiagnostic.
- native-project-start.ts cleanupUnconfirmed: keep HEAD's
  startupDiagnostic() form; #636's nothingReserved() was already folded
  into finalInspectionFailure by the #636 merge.
- native-project-start.ts nothing-reserved report: append #638's named
  process refusals (refusedProcesses helper) after the startup diagnostic
  in the unified finalInspectionFailure.
- doctor.ts: keep #609 amd64 emulation and #597 native settings checks,
  report fields and JSON, and add #638's native process wrappers check,
  report field and JSON; imports from both.

Semantic adaptations:
- process_refusals.rs: Refused::new takes a &'static str message, since
  HEAD's CandidateError::new accepts only literals or owned Strings (and
  marks literals as fixed, which these value-free refusals are).
- #638's runtime-refusal test uses `graph_unreserved` and expects the
  unified nothing-reserved message.

Tests (base + ours + theirs = merged): native-project-start
103+14+4=121, native-project-process 12+0+2=14, native-process-wrappers
0+0+8=8; Rust #[test]: image_process 7+0+3=10, launcher 7+0+1=8,
process_refusals 0+0+1=1, error.rs 0+2+0=2. Focused run: 264 pass, 0
fail across 8 files; cargo check --all-targets --all-features passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge refs/remotes/pr/637 (592a9b1: dependency-cache graphs reconcile
after edits; per-cache identity bound to the cache's own inputs and full
mount layout; caches with a writable non-initializer consumer scoped to
one instance, the fix against cross-worktree cache poisoning).

Resolutions:
- runtime-core error.rs: keep HEAD's ErrorMessage and #638's process
  refusal types, add #637's PlanChangeField/PlanChangeDiagnostic; the
  CandidateError gains plan_change beside process_refusals, and
  with_plan_change sits beside HEAD's with_service.
- native-runtime-client.ts: NativeFailure keeps HEAD's fixed message,
  service and process refusals and adds planChange; replies carry both
  nativeFailureDetail and planChange.
- native-runtime-client.ts nativeFailureMessage: #637's closed plan-change
  guidance replaces the generic "inspect owned state" sentence, and HEAD's
  value-free runtime note and stop-failure summary still follow it.
- tests/native-runtime-client.test.ts: keep both fixture branches
  (fixed-message/unsafe-fixed and plan-change/plan-change-unsafe).

Security check: the merged dependency_cache.rs is #637's file unchanged;
resolve_with_legacy is the only cache-identity source (config.rs
prepare_delivery) and plan_reconcile's ordinary_layout keeps sending
instance-scoped caches through reconciliation. No HEAD change adds a
cache-volume mount kind outside instance_scoped_volumes, and #610
refuses restart policies on cache initializers.

Tests (base + ours + theirs = merged): native-runtime-client
19+1+1=21, native-project-retained-images 13+2+1=16,
native-project-restore 7+0+1=8, native-plan-change 0+0+2=2; Rust
#[test]: plan_reconcile 6+0+8=14, dependency_cache 6+0+1=7,
hostname_change 2+0+1=3. Focused bun run: 62 pass, 0 fail; cargo lib
dependency_cache 7, plan_reconcile 14, hostname_change 3, restore 15,
error 2 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HEAD's fixed-message flag and service name (#615), #638's
process_refusals and #637's plan_change together grew CandidateError
from 104 to 120 bytes, so StopBatchFailure (error plus a boxed slice)
reached 136 bytes and process_refusals::Refused 128. Clippy's
result_large_err then failed check:local and the all-features clippy
gate.

- cause_code is an Option<Box<str>> instead of Option<String>;
  with_cause_code keeps its String argument.
- process_refusals and plan_change share one boxed RefusalDiagnostics,
  serialized flat with serde(flatten), so the reply JSON keeps both
  top-level keys. Callers use with_process_refusals, with_plan_change and
  the process_refusals()/plan_change() accessors.
- A new error.rs test checks the flat serialization and that
  CandidateError stays at most 104 bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The native settings doctor test asserted that no path under its temp root appears in doctor output. The native home sits inside that root, and next's hack.local route check legitimately reports its own receipt path there. The test now checks that no absolute project or machine-settings path (and none of the setting values) appears, which is what settings reporting promises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project with hack.env.default.yaml but no hack.env.<env>.yaml can start
with --env <env> (for example to pick adaptation.<env>.json); the base
values apply. Startup now records that case as baseOnlyEnvironment, next
to the existing unmanagedEnvironment marker, so restart preflight, the
next retained up, fresh exec and down hooks accept the unchanged files.

The availability rule compares what startup found with what exists now:
an overlay that existed must still exist, a base-only run must still lack
the overlay, and an unmanaged run must still have no managed env file. A
legacy mapping without a marker keeps the overlay requirement. A retained
run keeps its recorded marker and is never relabeled.

The mapping validator accepts the complete selector set plus any subset of
gcloud, runtimeMetadata and one environment marker, instead of a listed
set of combinations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restart preflight, retained up and fresh exec now say which managed env
file appeared or was removed since the run started, by its project-relative
name only, and how to apply it: run hack down, then hack up, for an
appeared file; restore a removed overlay to keep the run, or start again
without it.

hack down stays the way to apply such a change. Its hooks use the
selection the run recorded: no values for a run started without managed
env files, and the base values for a run started base-only, even after an
overlay or env file appeared. A run started with its overlay refuses its
down hooks before cleanup when the overlay is gone, naming it and asking
to restore it, as it did before without the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up batch 5's review fixes: the doctor test scope, base-only env selections and named env-change refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next holds batch 5 (#635), whose tree equals the batch 5 head this branch already contains, plus Kit's CI shard and SIGKILL harness changes (#624, #627). The merge keeps this branch's content and adds exactly those Kit changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up batch 5's review fixes and next with batch 5 and Kit's CI changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine and BuildKit decode image configurations with Go's encoding/json,
which matches object keys to fields case-insensitively and keeps the last of
repeated keys. Exact-key lookups here could therefore miss what the engine
acts on: a base config with "onbuild" or "ONBUILD" passed the stored-trigger
refusal while BuildKit still ran its triggers.

A new go_json reader keeps every member and returns the one member a Go field
would receive, refusing when more than one key names it.
- ONBUILD: case variants count at the top level and under config and the
  legacy container_config (those keys are also matched case-insensitively).
  A repeated or conflicting field, or one that is neither an array nor null,
  refuses. An empty array still passes.
- Platform (os, architecture, variant) in config_platform, and architecture,
  os and rootfs.diff_ids in archive validation, are read the same way.
  Repetition refuses.
- Manifests and indexes: every member this module reads (schemaVersion,
  mediaType, manifests, config, layers, descriptor digest, size, mediaType,
  platform, urls, and platform os, architecture, variant) must be spelled
  exactly once, so index platform selection cannot diverge from the engine's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Go's encoding/json folds some non-ASCII runes onto ASCII field names (for
example U+0131 dotless i, U+017F long s and U+212A Kelvin sign), with rules
that have changed between Go versions. So "OnBuıld", "archıtecture" or
"diff_ıds" could name a field for the engine while a check here missed it.

Every object Hack reads from image metadata now refuses, fail closed and
value-free, when any of its member names contains a non-ASCII character:
- the image config top level, and its config and container_config scopes;
- platform fields;
- the archive config and its rootfs;
- manifest, index, descriptor and platform objects.

ASCII case-fold matching and the duplicate and ambiguity refusals are kept.
The special U+017F and U+212A folds are removed, since those keys now refuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
resolve_build_base fetched only the config and handed BuildKit a pinned
docker-image reference, so acquisition's archive budget never covered base
layers. The selected manifest's config and layer sizes are now summed (with
overflow checked) and must fit the per-image compressed archive budget
(ImagePolicy archive_limit, the budget ensure-image applies). The manifest
must also have 1 to MAX_LAYERS layers. Either check fails before any build,
with a coded refusal naming the pinned base reference and the declared total.
Descriptor sizes stay required unsigned integers, so a missing, negative or
fractional size refuses.

Bounded unpacked acquisition of build bases is tracked in HACK-1945.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stage names are stored lowercase, and BuildKit lowercases a FROM base name
before its stage lookup. The FROM lookup compared the expanded name
unchanged, so `FROM alpine AS Base` followed by `FROM Base` was refused. It
now lowercases the name the way stage_by_name already does for COPY --from,
RUN --mount from= and the build target. A name that matches no earlier
stage still falls through as an image reference, where uppercase is
invalid, and a stage named Scratch is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts input

Docker runs credential helpers through Go's os/exec, which ignores a broken
pipe when the helper exits without reading the server address it was given.
Hack refused that case, so a helper that answered and exited quickly could
fail a registry pull depending on timing, and the registry identity-token
test failed intermittently on loaded CI runners. A broken pipe on that write
is no longer an error; the helper's exit status and reply decide the result.
Other write errors still refuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up batch 6's review fixes: Go-style image config key reading, non-ASCII key refusal, the build base size bound and case-insensitive stage names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ition

BuildKit fetches a build base's layers itself, so the preflight only
summed their sizes. A malicious base could carry foreign-layer urls or
non-distributable layer types that direct acquisition refuses, and BuildKit
would then fetch them from the guest's own namespace, outside the build
network's isolation and the source policy.

Before any build, the base's config descriptor and every layer descriptor
now go through the checks direct acquisition applies:
- the config: an image-config media type, then Fetch::checked with the
  metadata bound;
- every layer: a distributable gzip, zstd or uncompressed media type
  (layer_extension), then Fetch::checked with the archive ceiling, which
  covers a valid digest, a size within limits and no urls.

An empty urls array is treated as absent, matching acquisition. Refusals are
coded native_build_base_image, name the base reference and the reason, and
never echo URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up batch 6's final review fixes, including #640.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next holds batch 6 (#639) with the same tree as the batch 6 head this branch already contains, so the merge keeps this branch's content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T09:09:02.807688Z 458e0a9 Manual request
🔒 Security Review ✅ Completed 2026-11-07T09:10:44.242742Z 458e0a9 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 458e0a939f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 458e0a939f

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant