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
25 changes: 20 additions & 5 deletions docs/reference/native-authored-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,21 @@ HACK_NATIVE_HOME=/absolute/private/candidate-home \
./dist/hack --path /absolute/project up
```

The native planner admits image-only workloads, exec readiness, initializer jobs
and dependencies with the ordinary project network and outbound mode. Source
root must remain `.`; source acquisition, mounts, storage, authored networks, file inputs, routing, endpoints
and host effects remain outside this bounded frontend. Unsupported intent must
The native planner admits image workloads, exec readiness, initializer jobs
and dependencies with the ordinary project network and outbound mode. It also
admits one read-only project source mount per selected workload, using the existing
`host-mounted` source mode and root `.`. The provider pool must already contain
the exact explicitly approved unfiltered live project share. That existing
virtiofs share grants the guest writable access to the whole tree; only the
individual workload bind is read-only. This command does not enroll the share or
change pool mounts. Host edits remain visible. A selected
directory allows descendant edits; a selected regular file allows in-place edits
but refuses replacement of its inode. Selected path/ancestor aliases, identity
or permission changes refuse, while exact owned shutdown remains possible after
the host source is moved or deleted and preserves host data.

Writable/other mounts, source acquisition, storage, authored networks, file inputs,
routing, endpoints and host effects remain outside this bounded frontend. Unsupported intent must
refuse before managed value resolution and provider work. Early typed input
capability refusals retain `E_NATIVE_PROJECT_UNSUPPORTED` without exposing
compiler diagnostics or creating native source/start/run authority. `--detach`,
Expand Down Expand Up @@ -77,6 +88,10 @@ and an interrupted completed-history hardlink archive remain retained refusals;
this operation does not repair arbitrary partial lock or file publications.

The native receipt and source paths are distinct from strict Compose v1 artifacts.
No native hash substitutes for a normalized Compose hash. Source and fake-driver
Image-only graph receipts remain v2; source-bearing graph receipts use a closed v3
binding, distinct from the existing foreground publication-owner v3. Ready/control
envelopes remain v2. Dead-owner recovery of source-bearing receipts is not admitted
by the separately qualified image-only recovery path. No native hash substitutes
for a normalized Compose hash. Source and fake-driver
checks do not qualify an installed frontend, a live provider, the full authored
corpus, actual dead-owner recovery or resource overhead; those remain separate gates.
75 changes: 65 additions & 10 deletions packages/runtime-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ provider, image acquisition or receipt effects. This feature's compiler path
dependency requires the repository's pinned Rust 1.97.1. The default runtime
package keeps its declared Rust 1.85 minimum; enabling the adapter requires Rust 1.97.1.

The adapter accepts at most 32 selected services/jobs with immutable images, exec readiness,
The adapter accepts at most 32 selected pinned-image services/jobs, exec readiness,
explicit exec/shell commands, entrypoint clearing, init, exact shutdown intent and
working directories. Jobs require successful completion; services with readiness
require health, and other services require startup. Workload names, including dots,
remain exact. Omitted process fields preserve image/backend defaults. Source and
worktree declarations and local resolution remain intent only.
remain exact. Omitted process fields preserve image/backend defaults. One project-relative
read-only live source bind per workload is supported through an already-approved
pool share. Other source acquisition and worktree declarations and local resolution
remain intent only.
An explicit entrypoint requires an authored command, matching the bounded NC03
renderer; image CMD inheritance under an entrypoint override remains unqualified.

Expand Down Expand Up @@ -68,6 +70,56 @@ lowerer retains logical storage mount intent without fabricating provider volume
It checks owner shape and deadline, never real guest ownership, image presence,
combined capacity, private staging or engine effects.

The explicit native consumer additionally admits at most one compiler-normalized
read-only project source bind per selected workload. The authored source mode is
`host-mounted` with root `.`: content changes remain live, including atomic edits
of descendants inside a selected directory. A selected regular file may change
in place; replacing that file itself changes the selected identity and refuses.
Writable source binds and custom source roots remain unsupported. Source-bearing
projects cannot combine live source with persistent storage; the separate storage
intent contract remains inactive before provider admission.
This does not create an immutable snapshot or publish a new source revision.

Source consumption requires the provider pool to already contain the exact
explicitly approved unfiltered project share. The consumer never approves that
whole-tree writable share, changes pool mounts or enrolls a project implicitly.
Individual workload binds are read-only and `rprivate`. Selection pins the source
root, every selected path and its ancestors by device, inode, type, UID and full
mode; aliases, hardlinked selected files and permission changes refuse. Startup
and active observations recheck those host paths, the existing provider share,
virtiofs mapping and configured/runtime bind around engine work. Host editors are
not locked: these are bounded replacement checks, not an atomic host filesystem
fence. Descendant edits under a selected directory follow the approved live-share
policy; its whole tree can include local configuration.

Source-bearing native graph receipts use a closed v3 source binding; image-only
v2 receipts keep their prior fields and serialization. This graph v3 is separate
from the foreground publication-owner v3 and does not change the v2 ready/control
envelopes. Retained inspection/startup cannot recapture or adopt a new selected
path. Exact owned cleanup continues after the host source is moved or deleted:
it verifies the original provider share and read-only container bind, stops and
removes only the original resource inventory, and never deletes host source data.
Dead-owner recovery of source-bearing graph v3 remains outside this increment;
the separately qualified image-only recovery admission must refuse that version.

The maintained macOS ignored control
`native::runtime::tests::live_source::approved_live_source_preserves_host_edits_and_cleanup_after_selected_source_moves`
requires a caller-created synthetic `live-fixture/project` and isolated sibling
`native-home`. Prepare its provider through the existing explicit development
`--project-share PROJECT --unfiltered-source` contract, then load a pinned Linux
ARM64 Bun image. The fixture verifies a run-bound private inode/mode manifest and
exclusively claims the invocation before graph effects; it does not enroll shares.
Set `HACK_NATIVE_SOURCE_TEST_FIXTURE`, `HACK_NATIVE_SOURCE_TEST_PROJECT`,
`HACK_LOCAL_TEST_ROOT`, `HACK_LOCAL_TEST_IMAGE` and a fresh 32-character hex
`HACK_NATIVE_SOURCE_TEST_RUN`. Source files are public synthetic data under private
ancestors. The control requires live HTTP reads after host edits and atomic
descendant replacement, an `EROFS` container-write refusal, source-withdrawal
inspection refusal and normal exact cleanup preserving host data. It publishes
no host ports. Compile the exact test before the caller's 300-second watchdog;
uncertain failures retain the graph/pool for inspection and never replay cleanup.
Its filesystem admission controls run without a provider. This does not qualify
whole frontend parity, dead-owner recovery or performance.

`provider::graph::native::selection` selects only the exact absolute native project
root and reads bounded, stable regular `.hack/hack.project.json` and optional
`.hack/hack.local.json` files. It forwards raw authored text and owner-supplied
Expand All @@ -80,11 +132,13 @@ refuses until its primary-worktree verification is qualified; opted-out inherita
preserves the owning compiler's checkout-local semantics. This is read-only input
selection and private preparation, with no durable enrollment or runtime ownership.

`provider::graph::native::run` is an explicit library consumer for the bounded
image/process subset and the separate persistent-storage path. It retains the development guest mutation lease, requires an
`provider::graph::native::run` is an explicit library consumer for pinned images
with optional preapproved read-only live source; persistent-storage intent remains
a separate, inactive path. It retains the development guest mutation lease, requires an
admitted Internet or explicitly restricted outbound pool, verifies existing immutable
images and shared graph/allocation capacity, and reserves a distinct v2
`native-graph-runtime` journal in `run/native-graphs` before effects. Create/start
images and shared graph/allocation capacity, and reserves a distinct
`native-graph-runtime` journal in `run/native-graphs` before effects (v2 for image-only,
v3 for source-bearing graphs; the separate inactive storage contract uses v4). Create/start
intent is durable and never replayed or adopted. Network create intent precedes the
first network effect; its immutable ID, labels, bridge driver and outbound policy
are verified before container work. Containers bind the recorded network ID and exact
Expand Down Expand Up @@ -126,7 +180,8 @@ it does not compare process birth against mutable calendar boot time.
The closed version2 live-owner decoder remains available without a boot qualifier.
Version3 remains strictly qualified by its original `host_boot_micros`; it receives
no inferred UUID or migration. Each version rejects the other versions' qualifiers.
Native runtime receipts and the authenticated control/ready wire remain v2;
Native runtime receipts are v2 for image-only graphs and v3 for source-bearing
graphs; the authenticated control/ready wire remains v2;
Compose receipt and owner formats remain unchanged. This provenance alone grants
no dead-owner recovery authority.
The inactive read-only recovery selector admits only a complete Ready journal and
Expand Down Expand Up @@ -198,8 +253,8 @@ 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`.
`graph native inspect|cleanup --run-id RUN --json` use its v2 journal. These commands
are explicit image-only prerequisites, not ordinary project startup. Their project
`graph native inspect|cleanup --run-id RUN --json` use its versioned native journal. These commands
are explicit bounded prerequisites, not full ordinary project parity. Their project
network does not publish ports or grant host-service access. Normal native `hack up`
continues to require the full source/storage/routing/host and foreground-owner contract.

Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-core/src/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

pub use enrollment::{EnrollmentReceipt, enroll, status, status_with_branch};
#[cfg(feature = "native-config-plan")]
pub(crate) use source::resolve as resolve_source;
pub use source::{SourceEntry, SourceSelection};

pub struct PlanOptions<'a> {
Expand Down
57 changes: 46 additions & 11 deletions packages/runtime-core/src/project/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use hack_config_compiler::{
local::LocalResolution,
model::{
Access, Command, Dependency, EnvironmentValue, Mount, Plan, Readiness, ServiceCondition,
Source, Workload, WorktreePolicy,
Source, SourceMode, Workload, WorktreePolicy,
},
process::{Entrypoint, Restart, ShutdownSignal},
};
Expand Down Expand Up @@ -98,6 +98,7 @@ pub struct WorkloadInputs {
pub shutdown: Option<Shutdown>,
pub restart: Option<Restart>,
pub working_directory: Option<String>,
pub source_mount: Option<SourceMount>,
pub environment: BTreeMap<String, String>,
pub readiness: Option<ExecReadiness>,
pub mounts: Vec<StorageMount>,
Expand All @@ -111,6 +112,14 @@ pub struct StorageMount {
pub read_only: bool,
}

/// Compiler-normalized source and destination; live sharing is admitted separately.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceMount {
pub source: String,
pub target: String,
}

/// Millisecond precision is retained; a future backend must qualify signal/timing delivery.
pub struct Shutdown {
pub signal: Option<ShutdownSignal>,
Expand All @@ -128,7 +137,7 @@ pub struct ExecReadiness {
fn refused() -> CandidateError {
CandidateError::new(
"native_graph_subset",
"Native graph adapter requires image-only workloads, exec readiness and only persistent worktree storage mounts; acquisition, source/file mounts, custom networks, routing, endpoints, host effects and automatic restart remain refused. Values omitted.",
"Native graph adapter requires images, exec readiness and either one read-only live project-source mount per workload or persistent worktree storage intent; mixed source/storage, acquisition, other mounts, custom networks, routing, endpoints, host effects and automatic restart remain unsupported; values omitted.",
)
}

Expand Down Expand Up @@ -197,10 +206,17 @@ fn workload(value: Workload, kind: WorkloadKind) -> Result<WorkloadInputs, Candi
{
return Err(refused());
}
let mounts = value
.mounts
.into_iter()
.map(|mount| match mount {
let mut source_mount = None;
let mut mounts = Vec::new();
for mount in value.mounts {
match mount {
Mount::Source {
source,
target,
access: Access::ReadOnly,
} if source_mount.is_none() => {
source_mount = Some(SourceMount { source, target });
}
Mount::Storage {
storage,
target,
Expand All @@ -214,15 +230,18 @@ fn workload(value: Workload, kind: WorkloadKind) -> Result<WorkloadInputs, Candi
})
&& target != "/" =>
{
Ok(StorageMount {
mounts.push(StorageMount {
storage,
target,
read_only: matches!(access, Access::ReadOnly),
})
});
}
_ => Err(refused()),
})
.collect::<Result<Vec<_>, _>>()?;
_ => return Err(refused()),
}
}
if source_mount.is_some() && !mounts.is_empty() {
return Err(refused());
}
let readiness = value
.readiness
.map(|check| match check {
Expand Down Expand Up @@ -262,6 +281,7 @@ fn workload(value: Workload, kind: WorkloadKind) -> Result<WorkloadInputs, Candi
shutdown,
restart: value.restart,
working_directory: value.working_directory,
source_mount,
environment: BTreeMap::new(),
readiness,
mounts,
Expand Down Expand Up @@ -346,6 +366,21 @@ fn compile_inputs(
}
refuse_authored_network_intent(request)?;
let environment_policy_hash = policy_hash(&plan, &environment_plan)?;
let source_bearing = plan
.services
.values()
.chain(plan.jobs.values())
.any(|workload| {
workload
.mounts
.iter()
.any(|mount| matches!(mount, Mount::Source { .. }))
});
if source_bearing
&& (!matches!(&plan.source.mode, SourceMode::HostMounted) || !plan.storage.is_empty())
{
return Err(refused());
}
if plan.source.root != "."
|| !plan.configs.is_empty()
|| !plan.secrets.is_empty()
Expand Down
49 changes: 48 additions & 1 deletion packages/runtime-core/src/project/native/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,33 @@ fn source_root_subset_refuses_review_before_any_private_copy() {
assert_eq!(values, original);
}

#[test]
fn one_read_only_host_mounted_source_is_lowered_without_snapshot_or_share_effects() {
let mut project = basic();
project["source"] = json!({"root":".","mode":"host-mounted"});
project["services"]["web"]["mounts"] =
json!([{"source":"src","target":"/app","access":"read-only"}]);
let selected = lower(&project, json!({"web":{}}), &BTreeMap::new()).unwrap();
assert_eq!(
selected.workloads["web"].source_mount,
Some(SourceMount {
source: "src".into(),
target: "/app".into()
})
);
assert!(review(&request(&project, json!({"web":{}})), &[]).is_ok());
for mounts in [
json!([{"source":"src","target":"/app","access":"read-write"}]),
json!([{"source":"src","target":"/app","access":"read-only"},{"source":"other","target":"/other","access":"read-only"}]),
] {
project["services"]["web"]["mounts"] = mounts;
refusal(
lower(&project, json!({"web":{}}), &BTreeMap::new()),
"native_graph_subset",
);
}
}

fn refusal(result: Result<NativeInputs, CandidateError>, code: &str) {
let error = match result {
Ok(_) => panic!("expected refusal"),
Expand Down Expand Up @@ -749,7 +776,7 @@ fn entrypoint_overrides_without_authored_command_refuse_until_image_cmd_is_quali
fn unsupported_intent_is_never_dropped() {
let empty = BTreeMap::new();
for field in [
json!({"mounts":[{"source":".","target":"/app","access":"read-only"}]}),
json!({"mounts":[{"source":".","target":"/app","access":"read-write"}]}),
json!({"pull_policy":"never"}),
json!({"restart":{"kind":"on-failure","max_retries":2}}),
json!({"readiness":{"kind":"http","port":8080,"path":"/","interval":"1s","timeout":"1s","retries":1}}),
Expand Down Expand Up @@ -879,3 +906,23 @@ fn compiler_refuses_duplicate_keys_cycles_missing_readiness_and_malformed_input(
"graph_budget",
);
}

#[test]
fn mixed_live_source_and_persistent_storage_refuses_before_private_copy() {
let mut project = basic();
project["storage"] = json!({"database":{"kind":"persistent","scope":"worktree"}});
project["services"]["web"]["mounts"] =
json!([{"source":"src","target":"/app","access":"read-only"}]);
for mounts in [
json!([{"storage":"database","target":"/data","access":"read-write"}]),
json!([{"source":"other","target":"/other","access":"read-only"}]),
] {
project["jobs"] = json!({"seed":{"image":"seed","mounts":mounts}});
PRIVATE_COPIES.with(|copies| copies.set(0));
refusal(
lower(&project, json!({"web":{},"seed":{}}), &BTreeMap::new()),
"native_graph_subset",
);
assert_eq!(PRIVATE_COPIES.with(std::cell::Cell::get), 0);
}
}
Loading
Loading