Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
30e71c9
perf(storage): reuse unchanged storage witness proofs across warm com…
roodboi Oct 12, 2026
0853a13
ci: keep the Bun crash loop inside its budget and default to the focu…
roodboi Oct 12, 2026
dbaf032
fix(native): wait only for active services at native up
roodboi Oct 12, 2026
11c44e6
feat(macos): explain the Local Network grant for .local URLs
roodboi Oct 12, 2026
efb278e
feat(native): repair the hack.local DNS route to the native relay
roodboi Oct 12, 2026
aca2215
fix(native): deliver runtime metadata env to native containers
roodboi Oct 12, 2026
0b5ba59
fix(native): make the hack.local repair recoverable and byte-exact
roodboi Oct 12, 2026
81a8720
Merge origin/next into wf/hack-local-native-repair-b11
roodboi Oct 12, 2026
a8bf46d
Merge branch 'next' into wf/bun-loop-b9
roodboi Oct 12, 2026
97dca06
fix(native): refuse uninspected and unreadable DNS sources in the hac…
roodboi Oct 12, 2026
e9f8e08
test(macos): cover every Local Network hint call site
roodboi Oct 12, 2026
255b7af
test(storage): pin first file up finalization and isolate harness HOME
roodboi Oct 12, 2026
d320f20
fix(native): count user-declared runtime metadata in hostname changes
roodboi Oct 12, 2026
a889387
Merge branch 'wf/bun-loop-b9' into wf/witness-reuse-b8
roodboi Oct 12, 2026
1e6b892
Merge branch 'wf/witness-reuse-b8' into wf/native-inactive-readiness-b10
roodboi Oct 12, 2026
d0c8884
Merge branch 'wf/native-inactive-readiness-b10' into wf/local-network…
roodboi Oct 12, 2026
44eb2a1
Merge branch 'wf/local-network-hint-b11' into wf/hack-local-native-re…
roodboi Oct 12, 2026
877f0df
Merge branch 'wf/hack-local-native-repair-b11' into wf/native-runtime…
roodboi Oct 12, 2026
3eb1345
Merge branch 'next' into wf/native-runtime-env-b10
roodboi Oct 12, 2026
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
4 changes: 2 additions & 2 deletions .codex/skills/hack-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Use `hack` as the primary interface for local-first development.

- These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness.
- Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit.
- Content revision: `5108f34edd26` (version alone is not a freshness guarantee).
- Content revision: `f4120f477794` (version alone is not a freshness guarantee).

## Product boundary

Expand Down Expand Up @@ -160,7 +160,7 @@ Use `hack` as the primary interface for local-first development.
- Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch.
- Target one with `--branch <name>` on up/open/logs/down (for example: `hack up --branch <name> --detach`).
- Linked worktrees pick a branch instance automatically (see Linked git worktrees).
- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.
- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.

## Project targeting

Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/hack.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is

- These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness.
- Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit.
- Content revision: `5108f34edd26` (version alone is not a freshness guarantee).
- Content revision: `f4120f477794` (version alone is not a freshness guarantee).

## Product boundary

Expand Down
32 changes: 27 additions & 5 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,9 +390,11 @@ deliberately marks that exact Compose volume disposable. Hack deliberately does

### Runtime host metadata

Containers started by `hack up`, `hack restart`, and `hack run` receive the effective instance and
public-route metadata. This keeps server-generated links, OAuth callbacks, webhooks, and other
browser-facing URLs isolated when the same project runs in multiple worktrees.
Containers started by `hack up`, `hack restart`, and `hack run` for a `.hack/docker-compose.yml`
project receive the effective instance and public-route metadata. This applies to the default
Compose backend and to the native backend (`HACK_RUNTIME_BACKEND=native`). It keeps
server-generated links, OAuth callbacks, webhooks, and other browser-facing URLs isolated when the
same project runs in multiple worktrees. Both backends emit the same keys and document shape.

| Variable | Value |
| --- | --- |
Expand Down Expand Up @@ -434,11 +436,31 @@ over generated metadata for backward compatibility. Existing containers receive
their next `hack up` or `hack restart`; `hack exec` observes the values already stored in the running
container.

Hack materializes this contract in generated, machine-local Compose overrides. Do not edit or
commit `.hack/.internal/compose.runtime.override.yml` or
With the Compose backend, Hack materializes this contract in generated, machine-local Compose
overrides. Do not edit or commit `.hack/.internal/compose.runtime.override.yml` or
`.hack/.branch/compose.<branch>.runtime.override.yml`; runtime commands refresh them and Hack's
managed `.hack/.gitignore` covers them.

With the native backend, foreground `hack up` and `hack restart` add the same values to the
normalized Compose input that native review checks. They are public container environment, not
private managed values, so `hack exec` and native `hack run` see them too. The service URLs come
from the reviewed route labels, and startup refuses before serving if they differ from the reviewed
route plan. When a native HTTPS frontend is selected with a port other than 443
(`HACK_NATIVE_HTTPS_PORT`), `HACK_DEV_URL`, `HACK_ALIAS_URL` and every service URL include that
port; host variables never do. Native-specific limits:

- A graph whose data was retained by an earlier release is restored exactly as it was admitted and
does not gain these variables. It receives them after it is next started without retained data.
- These values are part of the retained graph's reviewed input. A retaining restart that changes
only route hostnames (for example a domain migration) remains supported, because the generated
metadata keys are excluded from the hostname-change fingerprint. Values you declare for these
keys in `.hack/docker-compose.yml` still count. Changing the native HTTPS port, or selecting or
removing the native HTTPS frontend when its port is not 443, changes the metadata without a
hostname change, so the retained graph refuses to start. Start it with its original HTTPS
frontend selection and port.
- Authored `.hack/hack.project.json` configuration does not deliver these variables yet, on either
backend.

Linked worktrees also inherit the project secret key automatically from the primary checkout
through the shared git common dir, so you don't need to copy `.hack.secret.key` by hand. Set
`HACK_ENV_SECRET_KEY` for CI or fully detached environments. `hack doctor` flags divergent secret
Expand Down
11 changes: 11 additions & 0 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,17 @@ New normalized shared-source graphs also capture a separate hostname-change
contract. A retaining restart can change only the literal hostnames of already
routed services: the service set, upstream ports, TLS settings, probes, commands,
environment declarations, images, mounts, networks and volumes must stay the same.
The one exception is the reserved
[runtime host metadata](../cli.md#runtime-host-metadata) keys (`HACK_BRANCH`,
`HACK_COMPOSE_PROJECT`, `HACK_DEV_*`, `HACK_ALIAS_*`, `HACK_SERVICE_*` and
`HACK_RUNTIME_METADATA`) that Hack generates into the normalized Compose: their
public values are derived from the hostnames, so the normalized fingerprint ignores
them. A value you declare for one of these keys in `.hack/docker-compose.yml` is an
ordinary literal, so changing it still refuses. A metadata change without a hostname
change also still refuses. This includes changing `HACK_NATIVE_HTTPS_PORT`, and also
selecting or removing the native HTTPS frontend when its port is not 443, because the
advertised URLs gain or lose that port. Start the retained graph with the HTTPS
frontend selection and port it was admitted with.
Both original and normalized Compose declarations are checked; only fingerprints
are retained. Changed dependency-cache inputs still refuse. The old ownership
plan and named data identities remain fixed, while normalized input provenance and
Expand Down
4 changes: 2 additions & 2 deletions examples/basic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos
Integration freshness:
- These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness.
- Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit.
- Content revision: `5108f34edd26` (version alone is not a freshness guarantee).
- Content revision: `f4120f477794` (version alone is not a freshness guarantee).

Product boundary:
- Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon.
Expand Down Expand Up @@ -136,7 +136,7 @@ Branch instances (parallel envs):
- Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch.
- Target one with `--branch <name>` on up/open/logs/down (for example: `hack up --branch <name> --detach`).
- Linked worktrees pick a branch instance automatically (see Linked git worktrees).
- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.
- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.

Project targeting:
- From repo root, commands use that project automatically.
Expand Down
4 changes: 2 additions & 2 deletions examples/basic/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos
Integration freshness:
- These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness.
- Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit.
- Content revision: `5108f34edd26` (version alone is not a freshness guarantee).
- Content revision: `f4120f477794` (version alone is not a freshness guarantee).

Product boundary:
- Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon.
Expand Down Expand Up @@ -136,7 +136,7 @@ Branch instances (parallel envs):
- Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch.
- Target one with `--branch <name>` on up/open/logs/down (for example: `hack up --branch <name> --detach`).
- Linked worktrees pick a branch instance automatically (see Linked git worktrees).
- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.
- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.

Project targeting:
- From repo root, commands use that project automatically.
Expand Down
144 changes: 141 additions & 3 deletions packages/runtime-core/src/project/hostname_change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,46 @@ use super::{compose, yaml};
use serde_json::Value;
use sha2::{Digest, Sha256};

fn masked(bytes: &[u8]) -> Option<Value> {
/// Hack-owned public runtime host metadata keys (the CLI's runtime host metadata
/// contract). Hack generates them only into the normalized document, with values derived
/// from the effective route hostnames, branch and HTTPS ingress port, so a hostname
/// change necessarily changes them. They are excluded from the normalized document's
/// fingerprint only: a value the user declares in the original Compose file is a literal
/// like any other and still counts (Hack never overrides a declared key).
const RUNTIME_METADATA_KEYS: [&str; 10] = [
"HACK_ALIAS_HOST",
"HACK_ALIAS_URL",
"HACK_BRANCH",
"HACK_COMPOSE_PROJECT",
"HACK_DEV_HOST",
"HACK_DEV_URL",
"HACK_RUNTIME_METADATA",
"HACK_SERVICE_NAME",
"HACK_SERVICE_URL",
"HACK_SERVICE_URLS",
];

fn without_runtime_metadata(service: &mut Value) {
let reserved = |key: &str| RUNTIME_METADATA_KEYS.contains(&key);
match service.get_mut("environment") {
Some(Value::Object(environment)) => environment.retain(|key, _| !reserved(key)),
Some(Value::Array(environment)) => environment.retain(|entry| {
!entry
.as_str()
.is_some_and(|text| reserved(text.split_once('=').map_or(text, |(key, _)| key)))
}),
_ => {}
}
}

fn masked(bytes: &[u8], generated_metadata: bool) -> Option<Value> {
let mut value = yaml::parse(bytes).ok()?;
let services = value.get_mut("services")?.as_object_mut()?;
let mut routed = false;
for service in services.values_mut() {
if generated_metadata {
without_runtime_metadata(service);
}
let Some(labels) = service.get_mut("labels") else {
continue;
};
Expand Down Expand Up @@ -37,8 +72,8 @@ fn masked(bytes: &[u8]) -> Option<Value> {
/// Unsupported original routing simply cannot opt into future hostname changes.
/// Both documents matter: normalization must not conceal unrelated original edits.
pub(super) fn fingerprint(original: &[u8], normalized: &[u8]) -> Option<String> {
let original = masked(original)?;
let normalized = masked(normalized)?;
let original = masked(original, false)?;
let normalized = masked(normalized, true)?;
let bytes = zeroize::Zeroizing::new(
serde_json::to_vec(&("hack-hostname-raw-v1", original, normalized)).ok()?,
);
Expand Down Expand Up @@ -91,4 +126,107 @@ mod tests {
assert_eq!(fingerprint(before, before), fingerprint(after, after));
assert!(fingerprint(b"services: {web: {image: alpine}}", after).is_none());
}

#[test]
fn runtime_metadata_follows_hostnames_but_other_environment_still_counts() {
let expected = fingerprint(BASE.as_bytes(), BASE.as_bytes()).unwrap();
let metadata = |host: &str, port: &str| {
BASE.replace("caddy: web.hack", &format!("caddy: {host}"))
.replace(
"environment: {TOKEN: literal}",
&format!(
"environment: {{TOKEN: literal, HACK_DEV_URL: 'https://{host}{port}', HACK_SERVICE_URLS: '[\"https://{host}{port}\"]', HACK_RUNTIME_METADATA: '{{\"version\":1}}', HACK_BRANCH: ''}}"
),
)
};
for normalized in [
metadata("web.hack", ""),
metadata("web.v5.hack.gy", ""),
metadata("web.hack", ":8443"),
] {
assert_eq!(
fingerprint(BASE.as_bytes(), normalized.as_bytes()),
Some(expected.clone())
);
}
let list = |url: &str| {
format!(
"services: {{web: {{environment: ['TOKEN=literal', 'HACK_DEV_URL={url}', HACK_SERVICE_NAME], labels: ['caddy=a.hack', 'caddy.reverse_proxy={{{{upstreams 80}}}}', 'caddy.tls=internal']}}}}"
)
};
let plain = list("https://a.hack");
assert_eq!(
fingerprint(plain.as_bytes(), plain.as_bytes()),
fingerprint(
plain.as_bytes(),
list("https://b.hack:8443")
.replace("caddy=a.hack", "caddy=b.hack")
.as_bytes()
)
);
for changed in [
metadata("web.hack", "").replace("TOKEN: literal", "TOKEN: different"),
metadata("web.hack", "").replace("HACK_BRANCH", "HACK_OTHER"),
plain.replace("TOKEN=literal", "TOKEN=different"),
] {
assert_ne!(
fingerprint(BASE.as_bytes(), changed.as_bytes()),
Some(expected.clone())
);
assert_ne!(
fingerprint(plain.as_bytes(), changed.as_bytes()),
fingerprint(plain.as_bytes(), plain.as_bytes())
);
}
}

#[test]
fn user_declared_metadata_keys_still_count_in_the_original() {
let declared = |host: &str, url: &str| {
BASE.replace("caddy: web.hack", &format!("caddy: {host}"))
.replace(
"environment: {TOKEN: literal}",
&format!("environment: {{TOKEN: literal, HACK_DEV_URL: '{url}'}}"),
)
};
// Hack skips a declared key, so the normalized document carries the user's value.
let before = declared("web.hack", "https://custom.example");
let expected = fingerprint(before.as_bytes(), before.as_bytes()).unwrap();
let moved = declared("web.v5.hack.gy", "https://custom.example");
assert_eq!(
fingerprint(moved.as_bytes(), moved.as_bytes()),
Some(expected.clone())
);
let edited = declared("web.v5.hack.gy", "https://other.example");
assert_ne!(
fingerprint(edited.as_bytes(), edited.as_bytes()),
Some(expected)
);
let list = |url: &str| {
format!(
"services: {{web: {{environment: ['HACK_DEV_URL={url}'], labels: ['caddy=a.hack', 'caddy.reverse_proxy={{{{upstreams 80}}}}', 'caddy.tls=internal']}}}}"
)
};
let plain = list("https://custom.example");
let edited = list("https://other.example");
assert_ne!(
fingerprint(plain.as_bytes(), plain.as_bytes()),
fingerprint(edited.as_bytes(), edited.as_bytes())
);
// Generated metadata differences in the normalized document still do not count.
let base = fingerprint(BASE.as_bytes(), BASE.as_bytes()).unwrap();
for (host, port) in [("web.hack", ""), ("web.v5.hack.gy", ":8443")] {
let original = BASE.replace("caddy: web.hack", &format!("caddy: {host}"));
let generated = original.replace(
"environment: {TOKEN: literal}",
&format!(
"environment: {{TOKEN: literal, HACK_DEV_URL: 'https://{host}{port}', HACK_DEV_HOST: {host}}}"
),
);
assert_eq!(
fingerprint(original.as_bytes(), generated.as_bytes()),
Some(base.clone())
);
}
}
}
2 changes: 1 addition & 1 deletion plugins/hack/rules/hack.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is

- These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness.
- Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit.
- Content revision: `5108f34edd26` (version alone is not a freshness guarantee).
- Content revision: `f4120f477794` (version alone is not a freshness guarantee).

## Product boundary

Expand Down
4 changes: 2 additions & 2 deletions plugins/hack/skills/hack-branches/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ description: >
- Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch.
- Target one with `--branch <name>` on up/open/logs/down (for example: `hack up --branch <name> --detach`).
- Linked worktrees pick a branch instance automatically (see Linked git worktrees).
- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.
- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.

## Branch aliases

Expand All @@ -35,4 +35,4 @@ description: >

- These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness.
- Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit.
- Content revision: `5108f34edd26` (version alone is not a freshness guarantee).
- Content revision: `f4120f477794` (version alone is not a freshness guarantee).
Loading
Loading