Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ Validate native configuration and selected local overlays without starting workl
hack config validate [options]
```

Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay and routing settings. --file validates only the explicit document. Neither mode reads env values, writes state, or starts workloads.
Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay and routing settings. --file validates only the explicit document. With HACK_RUNTIME_BACKEND=native, a selected workload image that is not a sha256: content ID refuses with the native consumer's native_graph_admission code. Neither mode reads env values, writes state, or starts workloads.

### Options

Expand Down
15 changes: 10 additions & 5 deletions docs/reference/native-authored-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ This bounded native consumer requires each service's `image` to be an immutable
`sha256:` image **content ID**, such as the `image_id` returned by a successful
[`runtime ensure-image`](../guides/native-candidate.md#public-image-acquisition-without-docker)
acquisition. Keep that acquisition's pinned-reference and archive evidence. A
registry tag or `repository@sha256:manifest-digest` is not the content ID: it can
pass configuration planning while execution refuses `native_graph_admission`.
Do not substitute a mutable image or bypass the image identity guard.
registry tag or `repository@sha256:manifest-digest` is not the content ID. The
native planner (`graph native plan` and `frontend-plan`) refuses it with the same
closed `native_graph_admission` code that execution uses, so `up` fails at its
`native-plan` stage before it reserves a startup intent. `config validate` reports the
same refusal when `HACK_RUNTIME_BACKEND=native` is set; without it, validation stays
backend-neutral because Compose runs tags and digests. Do not substitute a mutable
image or bypass the image identity guard.

```sh
HACK_RUNTIME_BACKEND=native \
Expand Down Expand Up @@ -92,8 +96,9 @@ prove cleanup. Retained bindings and recovery requirements stay in force.
These diagnostics do not grant retry or cleanup authority.

A native consumer can refuse after the startup intent is reserved but before it
reserves any runtime journal. For example, a `repository@sha256:` image reaches
execution and refuses `native_graph_admission`. Such a run has no receipt, so the
reserves any runtime journal, for example when its review changes, the engine
cannot be reached or startup is canceled. (A `repository@sha256:` image used to
reach execution this way; the planner now refuses it first.) Such a run has no receipt, so the
Removed retirement inspection can never succeed. After the consumer process has
settled, startup asks the native runtime to inspect the exact run. Startup retires
the startup intent and its source envelope only when the runtime refuses
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/native-config-compiler.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,14 @@ not inspected, and no env values are read. Runtime/adoption commands remain fenc
`--file` validates only that document, ignores local/global policy files and performs
no project discovery or origin resolution. It cannot be combined with `--path`,
`--env` or `--domain`.

Image references are backend-neutral: a registry tag or `repository@sha256:` digest
is valid authored configuration, and the default Compose backend runs it. With an
explicit `HACK_RUNTIME_BACKEND=native`, both modes also refuse a selected service or
job image that is not a `sha256:` image content ID. The refusal is the native
consumer's own closed `native_graph_admission` code and message, so validation
predicts the refusal that `graph native plan` returns before startup reserves
anything. Only image identity is checked here, not the rest of the native subset.
`--json` returns the authored normalized plan, including
authored public literals and commands. Those values are intentionally visible;
diagnostic redaction does not turn the plan into a secret-safe storage format.
Expand Down
3 changes: 3 additions & 0 deletions packages/runtime-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ ordinary frontend selection yet and does not implement reactive health or hooks.
The optional feature exposes this bounded consumer through a distinct public CLI:
`graph native plan --source-file FILE --json`, then
`graph native run --source-file FILE --expect-review SHA --json`.
Selection, and so `plan`, `frontend-plan`, `run` and `serve`, refuses a selected
workload image that is not a `sha256:` content ID (a tag or `repository@sha256:`
digest) with the lowering's closed `native_graph_admission` code, before any state.
`graph native inspect|cleanup --run-id RUN --json` use its versioned native journal. On macOS,
`inspect` refuses `native_graph_unreserved` before guest access only when the run's journal
directory is exactly absent under private state directories and no foreground owner is
Expand Down
10 changes: 10 additions & 0 deletions packages/runtime-core/src/provider/graph/native/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,16 @@ fn select_frontend(
if matches!(git, GitMarker::Linked(_)) && inputs.local_resolution.inherit_local {
return Err(refused());
}
// The consumer runs only immutable `sha256:` content IDs. A tag or a
// `repository@sha256:` digest is not one, so plan/frontend-plan refuse it with
// the lowering's own closed code, before startup reserves any intent or source.
if inputs
.workloads
.values()
.any(|workload| !super::image_id(&workload.image))
{
return Err(super::refused());
}
let review = native_input::Review::new(
native_input::Scope {
namespace: &workspace.namespace,
Expand Down
57 changes: 57 additions & 0 deletions packages/runtime-core/src/provider/graph/native/selection/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,63 @@ fn branch_profile_and_checkout_local_selection_are_compiler_owned() {
);
}

/// HACK-1300: plan/frontend-plan refuse what serve's lowering refuses, with its
/// closed `native_graph_admission` code, before startup can reserve anything.
#[test]
fn repository_digest_and_tag_images_refuse_plan_with_the_lowering_code_and_no_state() {
let digest = format!("docker.io/library/busybox@sha256:{}", "e".repeat(64));
for (field, image) in [
("services", digest.as_str()),
("services", "busybox@sha256:not-a-content-id"),
("services", "busybox:1.37"),
("jobs", digest.as_str()),
] {
let fixture = Fixture::new();
let mut value = basic();
if field == "jobs" {
value["jobs"] = json!({"seed":{"image":image,"command":{"exec":["/bin/true"]}}});
} else {
value["services"]["web"]["image"] = json!(image);
}
fixture.write_project(value);
let mut options = fixture.options();
options.metadata = metadata(if field == "jobs" {
json!({"web":{},"seed":{}})
} else {
json!({"web":{}})
});
let error = select(&fixture.candidate, options).err().unwrap();
assert_eq!(error.code, "native_graph_admission");
// Byte-identical to the serve-time lowering refusal; never the authored value.
assert_eq!(
serde_json::to_string(&error).unwrap(),
serde_json::to_string(&super::super::refused()).unwrap()
);
assert!(!serde_json::to_string(&error).unwrap().contains("busybox"));
assert!(!fixture.candidate.state_root.exists());
}
// Control: the same digest on a workload outside the selected profiles is not
// lowered by serve, so plan admits it; selecting the profile refuses.
let fixture = Fixture::new();
let mut value = basic();
value["profiles"] = json!(["debug"]);
value["jobs"] =
json!({"debug":{"image":digest,"profiles":["debug"],"command":{"exec":["/bin/true"]}}});
fixture.write_project(value);
let mut plain = fixture.options();
plain.metadata = metadata(json!({"web":{},"debug":{}}));
select(&fixture.candidate, plain).unwrap();
let profiles = ["debug".into()];
let mut active = fixture.options();
active.profiles = &profiles;
active.metadata = metadata(json!({"web":{},"debug":{}}));
assert_eq!(
select(&fixture.candidate, active).err().unwrap().code,
"native_graph_admission"
);
assert!(!fixture.candidate.state_root.exists());
}

#[test]
fn inherited_linked_worktree_inputs_refuse_without_interpreting_git_or_primary_paths() {
let fixture = Fixture::new();
Expand Down
19 changes: 18 additions & 1 deletion src/commands/config-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
import { resolveRequestedEnvName } from "../cli/env-selection.ts";
import { optEnv, optJson, optPath } from "../cli/options.ts";
import { HackCliError } from "../lib/cli-result.ts";
import {
NATIVE_GRAPH_ADMISSION_CODE,
NATIVE_GRAPH_ADMISSION_MESSAGE,
nativeConsumerAdmitsImages,
} from "../lib/native-authored-image-admission.ts";
import {
compileNativeConfig,
type NativeConfigCompileResult,
Expand All @@ -23,7 +28,7 @@ const spec = defineCommand({
"Validate native configuration and selected local overlays without starting workloads",
group: "Project",
description:
"Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay and routing settings. --file validates only the explicit document. Neither mode reads env values, writes state, or starts workloads.",
"Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay and routing settings. --file validates only the explicit document. With HACK_RUNTIME_BACKEND=native, a selected workload image that is not a sha256: content ID refuses with the native consumer's native_graph_admission code. Neither mode reads env values, writes state, or starts workloads.",
options: [
defineOption({
name: "file",
Expand Down Expand Up @@ -102,6 +107,18 @@ export const configValidateCommand = withHandler(
profiles,
signal: controller.signal,
});
// A tag or repository digest is valid authored configuration for Compose,
// but the explicitly selected native consumer refuses it at plan and serve.
if (
result.ok &&
process.env.HACK_RUNTIME_BACKEND === "native" &&
!nativeConsumerAdmitsImages(result.plan)
) {
throw new NativeConfigCompilerError(
NATIVE_GRAPH_ADMISSION_CODE,
NATIVE_GRAPH_ADMISSION_MESSAGE
);
}
renderValidationResult({ result, json: args.options.json === true });
return result.ok ? 0 : 1;
} catch (error: unknown) {
Expand Down
52 changes: 52 additions & 0 deletions src/lib/native-authored-image-admission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { isRecord } from "./guards.ts";

/**
* Closed refusal of the bounded native authored consumer. The code and wording
* are byte-identical to runtime-core's `native_graph_admission`
* (`provider/graph/native/mod.rs`), which `graph native plan`, `frontend-plan`
* and `serve` all return for a non-content-ID image.
*/
export const NATIVE_GRAPH_ADMISSION_CODE = "native_graph_admission";
export const NATIVE_GRAPH_ADMISSION_MESSAGE =
"Native consumption requires its exact compiler review, immutable images, bounded process/readiness and a separately admitted live-source or persistent-storage contract; values omitted.";

const CONTENT_ID = /^sha256:[a-f0-9]{64}$/;

/**
* Whether every selected workload in a compiled plan names an immutable
* `sha256:` image content ID, the only image form the native consumer runs.
*
* A registry tag or `repository@sha256:` manifest digest is valid authored
* configuration (the Compose backend runs it) but is not a content ID. Only the
* compiler's selected `services` and `jobs` are checked, matching the workloads
* native lowering admits. A build-only workload has no image and is left to the
* native planner's own subset refusal; a non-string image or a malformed plan
* fails closed. This checks only image identity, not the rest of the native
* subset. Pure: no runtime, registry or state access.
*/
export function nativeConsumerAdmitsImages(plan: unknown): boolean {
if (!isRecord(plan)) {
return false;
}
for (const field of ["services", "jobs"] as const) {
const workloads = plan[field];
if (workloads === undefined) {
continue;
}
if (!isRecord(workloads)) {
return false;
}
for (const workload of Object.values(workloads)) {
if (!isRecord(workload)) {
return false;
}
if (
workload.image !== undefined &&
!(typeof workload.image === "string" && CONTENT_ID.test(workload.image))
) {
return false;
}
}
}
return true;
}
42 changes: 41 additions & 1 deletion tests/native-authored-project-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ type FixtureOptions = {
readonly liveStop?: boolean;
readonly semanticAfterHook?: boolean;
readonly planFailure?: boolean;
/** Closed native code for `planFailure` (default `native_graph_compile`). */
readonly planFailureCode?: string;
readonly failBeforeReceipt?: boolean;
/**
* Like `failBeforeReceipt`, but serve fails the way authored foreground
Expand Down Expand Up @@ -470,7 +472,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){
review.review_id=createHash('sha256').update('hack.native-graph-review/v1\\0').update(JSON.stringify(provenance)).digest('hex');
}
if(planning){
if(${options.planFailure === true}){console.error(JSON.stringify({code:'native_graph_compile',message:'private-rejected-plan-detail'}));process.exit(2)}
if(${options.planFailure === true}){console.error(JSON.stringify({code:${JSON.stringify(options.planFailureCode ?? "native_graph_compile")},message:'private-rejected-plan-detail'}));process.exit(2)}
if(${options.changeDuringPlan === true})await appendFile(${JSON.stringify(envFile)},'\\n');
console.log(JSON.stringify(review));process.exit(0);
}
Expand Down Expand Up @@ -1010,6 +1012,44 @@ for (const option of [
);
}

macTest(
"HACK-1300: a planner image refusal retains nothing and never reaches serve",
async () => {
// The native planner now refuses a repository@digest or tag image with the
// lowering's own native_graph_admission code, before any reservation.
const selected = await fixture({
planFailure: true,
planFailureCode: "native_graph_admission",
});
const error = await failure(
serveNativeAuthoredProject({
...selected,
run,
startupTimeoutMs: 10_000,
}),
"not-started"
);
expect(error).toHaveProperty("nativeCode", "native_graph_admission");
expect(error).toHaveProperty("stage", "native-plan");
expect(String(error)).not.toContain("private-rejected-plan-detail");
expect(await artifacts(selected.scope)).toEqual([]);
expect(await Bun.file(selected.delivery).exists()).toBe(false);
const calls = (await readFile(selected.calls, "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line)[4]);
expect(calls).toEqual(["plan"]);
const status = await nativeAuthoredProjectPs({
runtime: selected.runtime,
scope: selected.scope,
invoke: () => {
throw new Error("No runtime request for an absent attempt.");
},
});
expect(status.status).toBe("not_started");
}
);

macTest(
"unscoped native frontend binds the canonical project namespace",
async () => {
Expand Down
Loading
Loading