diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 7d29dac14..6088d8a6d 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -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: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). ## Product boundary @@ -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 ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- 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. +- Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Project targeting diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index d80ad06fa..ac63cee5f 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -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: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). ## Product boundary diff --git a/docs/cli.md b/docs/cli.md index 659866c5f..6a0ee13cb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -397,11 +397,12 @@ deliberately marks that exact Compose volume disposable. Hack deliberately does ### Runtime host metadata -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. +Containers started by `hack up`, `hack restart`, and `hack run` receive the effective instance and +public-route metadata. This applies to `.hack/docker-compose.yml` projects and to authored +`.hack/hack.project.json` projects, on the default Compose backend and on 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. Every path +emits the same keys and document shape from the same shared builder. | Variable | Value | | --- | --- | @@ -465,8 +466,31 @@ port; host variables never do. Native-specific limits: 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. + +For an authored `.hack/hack.project.json` project, the configuration compiler carries the values as +public `runtime` bindings (see the [compiler reference](reference/native-config-compiler.md)), so +they are reviewed with the rest of the plan and are never managed or secret values. The precedence +matches Compose-file projects: a managed project env value or any authored `environment` directive +for one of these keys (literal, default, reference, endpoint or `unset`) wins over the generated +value. The hosts come from the compiler's routing resolution: + +- `HACK_DEV_HOST` is the resolved project origin's host (for example + `feature-x.demo.hack.local`). Without a routing resolution it is the compiler's generated + `.hack.local`, prefixed with the selected branch when there is one. +- `HACK_ALIAS_HOST` is the host of the alias named by `routes.oauth_alias`, when set. +- Service URLs list each `routes.http` route for that service in route-name order, the route origin + before its aliases, so `HACK_SERVICE_URL` is the development origin. +- `HACK_BRANCH` is the routing resolution's branch, or the selected `--branch` instance when no + routing resolution exists. +- On the Compose backend `HACK_COMPOSE_PROJECT` is the instance's actual Compose project + (`hack-nc-...`), and URLs are portless. On the native backend it is `` or + `--`, as for native image projects, and URLs carry a selected non-443 + `HACK_NATIVE_HTTPS_PORT`. Authored native graphs are not routed, so they have no alias and no + service URLs; the port appears only in `HACK_DEV_URL`. + +Authored native `hack exec` sees the values stored in the running container, and native one-off +`hack run` uses the admitted service environment. A graph started before this release gains them +on its next `hack up` or `hack restart`. 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 diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index cca0bfe49..e68bb6171 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -596,6 +596,16 @@ rest of this section describes the older Compose-file native frontend for `.hack/hack.config.json` projects with a Compose file, which keeps its own command coverage below. +Authored native `up` and `restart` deliver the same +[runtime host metadata](../cli.md#runtime-host-metadata) as native image projects. +The configuration compiler carries it as public `runtime` bindings in the reviewed +source selection, below managed values and authored directives, and the runtime +adds it to each workload's public environment, never to private delivery. A +selected non-443 `HACK_NATIVE_HTTPS_PORT` appears in `HACK_DEV_URL` (authored +native graphs are not routed, so they have no alias or service URLs); a partial HTTPS selection refuses before any input or runtime +work. `exec` observes the values stored in the running container, and one-off +`run` uses the admitted environment. + Explicit `HACK_RUNTIME_BACKEND=native` with absolute `HACK_NATIVE_BINARY` and `HACK_NATIVE_HOME` selects native observations for normal `hack ps` and bounded `hack logs SERVICE --no-follow`. They verify the project/branch run mapping diff --git a/docs/reference/native-compose-renderer.md b/docs/reference/native-compose-renderer.md index 432ce3422..0f9a0f269 100644 --- a/docs/reference/native-compose-renderer.md +++ b/docs/reference/native-compose-renderer.md @@ -60,6 +60,7 @@ portable semantic hashes, or write it into the authored configuration directory. | Successful job dependency | `service_completed_successfully`; jobs share the generated Compose services namespace. | | Exec readiness | `CMD` plus ordered argv; explicit shell source becomes `CMD /bin/sh -c`. | | Literal, default, managed reference, unset | Deliver exactly the compiler's effective destination bindings; absent destinations stay absent and empty strings stay present. | +| Generated runtime host metadata | Public `runtime` bindings render as plain environment values. The renderer refuses one for a key outside the reserved `HACK_*` metadata set, or where an authored directive or a supplied managed value owns the key. | | HTTP/HTTPS service endpoint | Explicit `protocol://service-name:port` using project-scoped Compose service DNS. | | HTTP/HTTPS external binding | Explicit `protocol://hostname:port`; external loopback retains its container-context meaning and is not translated into a host gateway. | diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md index 112d0c8d0..cfb112212 100644 --- a/docs/reference/native-config-compiler.md +++ b/docs/reference/native-config-compiler.md @@ -605,6 +605,13 @@ For host declarations, metadata additionally includes `host:{default?:{KEY:{scope,secret}},workloads:{TARGET:{KEY:{scope,secret}}}}`. The default map is present exactly when requested, and workload maps exactly match `host_env_targets.workloads`, including empty maps. Extra target authority refuses. +Optional `runtime:{WORKLOAD:{KEY:string}}` carries Hack-generated, non-secret +[runtime host metadata](../cli.md#runtime-host-metadata) for declared workloads. +Only the ten reserved `HACK_*` metadata keys are accepted, each value at most +64 KiB without NUL. The compiler reports them as public `{kind:"runtime",value}` +bindings below managed values and every authored directive, so explicit values +win. Host entries never receive them, and they do not change any hash in the +authored plan or local resolution. Protocol capability: `runtime_env_plan_version: 1`. The compiler shares an 8 MiB output safety budget across the complete planning envelope. It charges symbolic baselines before cloning them for each invocation, plus authored directives and diagnostics before report growth. Accounting is diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index 6f94c3479..74509c9de 100644 --- a/examples/basic/AGENTS.md +++ b/examples/basic/AGENTS.md @@ -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: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (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. @@ -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 ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- 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. +- Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. 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. diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index 6f94c3479..74509c9de 100644 --- a/examples/basic/CLAUDE.md +++ b/examples/basic/CLAUDE.md @@ -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: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (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. @@ -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 ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- 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. +- Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. 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. diff --git a/packages/config-compiler/README.md b/packages/config-compiler/README.md index e0e151160..05254f907 100644 --- a/packages/config-compiler/README.md +++ b/packages/config-compiler/README.md @@ -119,11 +119,28 @@ and encoded-request bounds. Managed values, ciphertext and paths are unsupported Successful parsing returns `ok: true` and the unchanged plan, semantic hash, local resolution and declared namespace, plus `environment_plan`. That object contains `plan_version: 1`, selection metadata, `complete`, selected `workloads`, `warnings` -and `diagnostics`. Bindings have one of three forms: +and `diagnostics`. Bindings have one of these forms: - `managed`: symbolic `key`, winning `scope`, and `secret` flag; - `literal`: the explicit public authored `value`; -- `default`: the explicit public fallback `value` used when no baseline key exists. +- `default`: the explicit public fallback `value` used when no baseline key exists; +- `endpoint`: a typed endpoint reference and target (see endpoint planning); +- `runtime`: a Hack-generated public runtime host metadata `value`. + +Metadata may also include optional `runtime: {WORKLOAD: {KEY: value}}`: public, +Hack-generated runtime host metadata (`HACK_BRANCH`, `HACK_COMPOSE_PROJECT`, +`HACK_DEV_HOST`, `HACK_DEV_URL`, `HACK_ALIAS_HOST`, `HACK_ALIAS_URL`, +`HACK_SERVICE_NAME`, `HACK_SERVICE_URL`, `HACK_SERVICE_URLS` and +`HACK_RUNTIME_METADATA`). Only those closed keys are accepted, only for declared +workloads, with string values up to 64 KiB that contain no NUL. Anything else +refuses with a fixed request diagnostic that does not echo values. These values +are never managed or secret: they become `runtime` bindings for that selected +workload only where neither a managed baseline key nor an authored directive owns +the key. Managed values replace them, and every authored directive (literal, +default, reference, endpoint or unset) wins over them. Host process entries never +receive runtime bindings. The protocol advertises `runtime_env_plan_version: 1`; +an older compiler refuses the unknown field. Supplying runtime metadata changes +neither the authored plan nor the semantic or resolution hashes. Managed baseline presence includes an empty managed value. `unset` removes a binding. References always read the immutable owning baseline before authored diff --git a/packages/config-compiler/generated/native-config.ts b/packages/config-compiler/generated/native-config.ts index 28c48d4f0..3a3405d9d 100644 --- a/packages/config-compiler/generated/native-config.ts +++ b/packages/config-compiler/generated/native-config.ts @@ -1,10 +1,10 @@ // Generated by hack-config-compiler; do not edit. export type ManagedBindingMetadata = { scope: string, secret: boolean, }; -export type EnvMetadata = { metadata_version: 1, overlay: string | null, overlay_exists: boolean, workloads: { [key in string]: { [key in string]: ManagedBindingMetadata } }, inactive_scopes: Array, host?: HostMetadata, }; +export type EnvMetadata = { metadata_version: 1, overlay: string | null, overlay_exists: boolean, workloads: { [key in string]: { [key in string]: ManagedBindingMetadata } }, inactive_scopes: Array, host?: HostMetadata, runtime?: { [key in string]: { [key in string]: string } }, }; export type HostMetadata = { default?: { [key in string]: ManagedBindingMetadata }, workloads: { [key in string]: { [key in string]: ManagedBindingMetadata } }, }; export type HostEnvironmentPlan = { env_target: HostEnvTarget, bindings: { [key in string]: EnvironmentBinding }, }; export type EnvPlanRequest = { request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, global_domain?: string, explicit_domain?: string, branch?: string, env_metadata: EnvMetadata, }; -export type EnvironmentBinding = { "kind": "managed", key: string, scope: string, secret: boolean, } | { "kind": "literal", value: string, } | { "kind": "default", value: string, } | { "kind": "endpoint", reference: EndpointReference, target: EndpointTarget, }; +export type EnvironmentBinding = { "kind": "managed", key: string, scope: string, secret: boolean, } | { "kind": "literal", value: string, } | { "kind": "default", value: string, } | { "kind": "endpoint", reference: EndpointReference, target: EndpointTarget, } | { "kind": "runtime", value: string, }; export type FileConfig = { file: string, }; export type FileSecret = { file: string, } | { env_ref: string, }; export type FileKind = "config" | "secret"; diff --git a/packages/config-compiler/src/environment.rs b/packages/config-compiler/src/environment.rs index 1f115ab3c..9d711df25 100644 --- a/packages/config-compiler/src/environment.rs +++ b/packages/config-compiler/src/environment.rs @@ -35,7 +35,34 @@ pub struct EnvMetadata { #[schemars(with = "HostMetadata")] #[ts(optional, type = "HostMetadata")] pub host: Option, + // Hack-generated, non-secret runtime host metadata per declared workload. + // Values are public plan data, never managed or private-delivered values. + // Only the closed RUNTIME_METADATA_KEYS set is accepted; authored directives + // and managed project values for the same key take precedence. + #[serde( + default, + deserialize_with = "crate::model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "BTreeMap>")] + #[ts(optional, type = "{ [key in string]: { [key in string]: string } }")] + pub runtime: Option>>, } +/// The closed environment keys Hack generates as public runtime host metadata. +pub 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", +]; +/// Per-value bound for generated runtime metadata; the whole request remains bounded separately. +pub const MAX_RUNTIME_METADATA_VALUE_BYTES: usize = 64 * 1024; #[derive(Debug, Deserialize, Serialize, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct HostMetadata { @@ -98,6 +125,11 @@ pub enum EnvironmentBinding { reference: crate::endpoint::EndpointReference, target: crate::endpoint::EndpointTarget, }, + /// Hack-generated public runtime host metadata. Never secret or private-delivered; + /// present only where no authored directive or managed value owns the key. + Runtime { + value: String, + }, } #[derive(Debug, Serialize, JsonSchema, TS)] pub struct EnvironmentPlan { @@ -234,6 +266,18 @@ fn read_metadata(document: &mut json::Document) -> Result bool return false; } } - if !validate_host_metadata(metadata, resolved) { + if !validate_host_metadata(metadata, resolved) || !validate_runtime_metadata(metadata, resolved) + { return false; } let mut seen = BTreeSet::new(); @@ -325,6 +370,19 @@ fn validate_metadata(metadata: &EnvMetadata, resolved: &local::Resolved) -> bool || !seen.insert(scope) }) } +/// Generated metadata may target only declared workloads and only the closed key set. +fn validate_runtime_metadata(metadata: &EnvMetadata, resolved: &local::Resolved) -> bool { + metadata.runtime.as_ref().is_none_or(|runtime| { + runtime.iter().all(|(name, values)| { + resolved.compiled.declared_workloads.contains_key(name) + && values.iter().all(|(key, value)| { + RUNTIME_METADATA_KEYS.contains(&key.as_str()) + && value.len() <= MAX_RUNTIME_METADATA_VALUE_BYTES + && !value.contains('\0') + }) + }) + }) +} fn plan_inner( bytes: &[u8], profiles: &[String], @@ -424,6 +482,10 @@ fn bind( BindingOwner { cache_key: &format!("workload/{name}"), endpoint_context: crate::endpoint::EndpointContext::Workload, + runtime: metadata + .runtime + .as_ref() + .and_then(|runtime| runtime.get(name)), }, )?; output.workloads.insert(name.clone(), bindings); @@ -456,6 +518,7 @@ fn bind( BindingOwner { cache_key: &cache_key, endpoint_context: crate::endpoint::EndpointContext::Host, + runtime: None, }, )?; plans.insert( @@ -476,6 +539,8 @@ fn bind( struct BindingOwner<'a> { cache_key: &'a str, endpoint_context: crate::endpoint::EndpointContext, + /// Generated container metadata; host processes never receive it. + runtime: Option<&'a BTreeMap>, } fn bind_environment( resolved: &local::Resolved, @@ -487,10 +552,28 @@ fn bind_environment( owner: BindingOwner<'_>, ) -> Result, local::ResolveDiagnostic> { budget.baseline(owner.cache_key, baseline, resolved, pointer)?; - let mut bindings: BTreeMap = baseline - .iter() - .map(|(key, b)| (key.clone(), managed(key, b))) - .collect(); + // Precedence, lowest first: generated runtime metadata, managed project values, + // then authored directives. Explicit authored and managed values always win. + let mut bindings: BTreeMap = BTreeMap::new(); + if let Some(runtime) = owner.runtime { + budget.value(runtime, resolved, pointer)?; + budget.charge(32 * runtime.len(), resolved, pointer)?; + for (key, value) in runtime { + if !baseline.contains_key(key) { + bindings.insert( + key.clone(), + EnvironmentBinding::Runtime { + value: value.clone(), + }, + ); + } + } + } + bindings.extend( + baseline + .iter() + .map(|(key, b)| (key.clone(), managed(key, b))), + ); for (dest, directive) in directives { let pointer = json::child(&format!("{pointer}/environment"), dest); // Conservatively retain charges for overwritten/unset entries rather than refunding allocation. diff --git a/packages/config-compiler/src/lib.rs b/packages/config-compiler/src/lib.rs index a68c72c22..4be8dc765 100644 --- a/packages/config-compiler/src/lib.rs +++ b/packages/config-compiler/src/lib.rs @@ -406,5 +406,5 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { } pub fn protocol() -> Value { - serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1,"process_plan_version":1,"acquisition_plan_version":1,"network_plan_version":1,"file_plan_version":1}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1,"process_plan_version":1,"acquisition_plan_version":1,"network_plan_version":1,"file_plan_version":1,"runtime_env_plan_version":1}) } diff --git a/packages/config-compiler/tests/environment.rs b/packages/config-compiler/tests/environment.rs index 558b6a76e..d9fa71806 100644 --- a/packages/config-compiler/tests/environment.rs +++ b/packages/config-compiler/tests/environment.rs @@ -285,3 +285,128 @@ fn missing_overlay_warning_tracks_local_and_explicit_selection_provenance() { assert_eq!(warning["line"], line + 1); assert_eq!(warning["column"], content.find("\"qa\"").unwrap() + 1); } +fn runtime_values(service: &str) -> Value { + json!({ + "HACK_BRANCH":"feature", + "HACK_COMPOSE_PROJECT":"example--feature", + "HACK_DEV_HOST":"feature.example.hack.local", + "HACK_DEV_URL":"https://feature.example.hack.local:8443", + "HACK_RUNTIME_METADATA":"{\"version\":1}", + "HACK_SERVICE_NAME":service, + "HACK_SERVICE_URLS":"[]" + }) +} +#[test] +fn runtime_metadata_binds_public_values_below_managed_and_authored_env() { + let mut p = project(); + p["services"]["web"]["environment"] = json!({ + "HACK_DEV_URL":{"literal":"https://authored.example"}, + "HACK_BRANCH":{"default":"authored-default"}, + "HACK_SERVICE_URLS":{"unset":true}, + "HACK_RUNTIME_METADATA":{"env_ref":"SOURCE"} + }); + let mut m = metadata(); + m["workloads"]["web"] = + json!({"HACK_COMPOSE_PROJECT":entry("web",false),"SOURCE":entry("global",false)}); + m["runtime"] = json!({"web":runtime_values("web"),"init":runtime_values("init")}); + let out = output(&request(p, m), &[]); + assert_eq!(out["ok"], true, "{out}"); + assert_eq!(out["environment_plan"]["complete"], true, "{out}"); + let web = &out["environment_plan"]["workloads"]["web"]; + // Authored directives win over generated metadata. + assert_eq!( + web["HACK_DEV_URL"], + json!({"kind":"literal","value":"https://authored.example"}) + ); + assert_eq!( + web["HACK_BRANCH"], + json!({"kind":"default","value":"authored-default"}) + ); + assert!(web.get("HACK_SERVICE_URLS").is_none()); + assert_eq!(web["HACK_RUNTIME_METADATA"]["kind"], "managed"); + // Managed project values win over generated metadata. + assert_eq!( + web["HACK_COMPOSE_PROJECT"], + json!({"kind":"managed","key":"HACK_COMPOSE_PROJECT","scope":"web","secret":false}) + ); + // Keys nothing else owns receive the public runtime binding. + assert_eq!( + web["HACK_DEV_HOST"], + json!({"kind":"runtime","value":"feature.example.hack.local"}) + ); + assert_eq!( + web["HACK_SERVICE_NAME"], + json!({"kind":"runtime","value":"web"}) + ); + let init = &out["environment_plan"]["workloads"]["init"]; + assert_eq!( + init["HACK_SERVICE_NAME"], + json!({"kind":"runtime","value":"init"}) + ); + assert_eq!( + init["HACK_DEV_URL"], + json!({"kind":"runtime","value":"https://feature.example.hack.local:8443"}) + ); + assert_eq!(init.as_object().unwrap().len(), 7); +} +#[test] +fn runtime_metadata_is_absent_without_input_and_never_reaches_host_env() { + let p = project(); + let without = output(&request(p.clone(), metadata()), &[]); + let mut m = metadata(); + m["runtime"] = json!({"web":runtime_values("web")}); + let with = output(&request(p.clone(), m.clone()), &[]); + assert_eq!(without["environment_plan"]["workloads"]["web"], json!({})); + assert_eq!(with["semantic_hash"], without["semantic_hash"]); + assert_eq!(with["plan"], without["plan"]); + assert_eq!( + with["local_resolution"]["resolution_hash"], + without["local_resolution"]["resolution_hash"] + ); + assert_eq!( + with["environment_plan"]["workloads"]["init"], + json!({}), + "only workloads with supplied metadata receive it" + ); + let mut hosted = project(); + hosted["host"] = json!({"processes":{"watch":{"command":{"shell":"exit 0"}}}}); + m["host"] = json!({"default":{},"workloads":{}}); + let out = output(&request(hosted, m), &[]); + assert_eq!(out["ok"], true, "{out}"); + assert_eq!( + out["environment_plan"]["host"]["watch"]["bindings"], + json!({}) + ); + assert_eq!( + out["environment_plan"]["workloads"]["web"]["HACK_BRANCH"]["kind"], + "runtime" + ); +} +#[test] +fn runtime_metadata_shape_keys_workloads_and_values_are_closed() { + for runtime in [ + json!([]), + json!({"web":[]}), + json!({"web":{"HACK_BRANCH":1}}), + json!({"web":{"HACK_BRANCH":null}}), + json!({"web":{"SECRET_TOKEN":"private-value"}}), + json!({"web":{"HACK_OTHER":"private-value"}}), + json!({"absent":{"HACK_BRANCH":"private-value"}}), + json!({"web":{"HACK_BRANCH":"private-value\u{0}"}}), + json!({"web":{"HACK_RUNTIME_METADATA":"x".repeat(64 * 1024 + 1)}}), + Value::Null, + ] { + let mut m = metadata(); + m["runtime"] = runtime.clone(); + let v = output(&request(project(), m), &[]); + assert_eq!(v["ok"], false, "{runtime}"); + assert_eq!(v["diagnostics"][0]["document"], "request", "{runtime}"); + assert!( + !v.to_string().contains("private-value"), + "values are not echoed" + ); + } + let mut m = metadata(); + m["runtime"] = json!({"web":{"HACK_RUNTIME_METADATA":"x".repeat(64 * 1024)}}); + assert_eq!(output(&request(project(), m), &[])["ok"], true); +} diff --git a/packages/config-compiler/tests/protocol.rs b/packages/config-compiler/tests/protocol.rs index 5a9f34f3a..3476c7ec2 100644 --- a/packages/config-compiler/tests/protocol.rs +++ b/packages/config-compiler/tests/protocol.rs @@ -21,7 +21,7 @@ fn handshake_and_compile_need_no_environment_or_host_tools() { let protocol: Value = serde_json::from_slice(&handshake.stdout).unwrap(); assert_eq!( protocol, - serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1,"process_plan_version":1,"acquisition_plan_version":1,"network_plan_version":1,"file_plan_version":1}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1,"process_plan_version":1,"acquisition_plan_version":1,"network_plan_version":1,"file_plan_version":1,"runtime_env_plan_version":1}) ); let result = run(&["compile"], br#"{"schema_version":1,"name":"example"}"#); assert!(result.status.success()); diff --git a/packages/runtime-core/src/project/native.rs b/packages/runtime-core/src/project/native.rs index ea08b6061..f2b599be3 100644 --- a/packages/runtime-core/src/project/native.rs +++ b/packages/runtime-core/src/project/native.rs @@ -553,8 +553,10 @@ fn compile_inputs( return Err(private_refused()); } } + // Generated runtime metadata is public plan data, never private delivery. EnvironmentBinding::Literal { value } - | EnvironmentBinding::Default { value } => { + | EnvironmentBinding::Default { value } + | EnvironmentBinding::Runtime { value } => { inputs .environment .insert(destination.clone(), value.clone()); diff --git a/packages/runtime-core/src/project/native/tests.rs b/packages/runtime-core/src/project/native/tests.rs index 0723bb3fb..ff206e588 100644 --- a/packages/runtime-core/src/project/native/tests.rs +++ b/packages/runtime-core/src/project/native/tests.rs @@ -1063,3 +1063,49 @@ fn persistent_host_capability_is_exact_ephemeral_and_keeps_standalone_and_mixed_ ); } } + +#[test] +fn generated_runtime_metadata_is_public_environment_below_managed_and_authored_values() { + let mut project = basic(); + project["services"]["web"]["environment"] = + json!({"HACK_DEV_URL":{"literal":"https://authored.example"}}); + let metadata = json!({"web":{"HACK_BRANCH":{"scope":"web","secret":true}}}); + let mut wire: Value = serde_json::from_slice(&request(&project, metadata.clone())).unwrap(); + wire["env_metadata"]["runtime"] = json!({"web":{ + "HACK_BRANCH":"generated-branch", + "HACK_DEV_HOST":"feature.fixture.hack.local", + "HACK_DEV_URL":"https://feature.fixture.hack.local:8443", + "HACK_SERVICE_NAME":"web" + }}); + let bytes = serde_json::to_vec(&wire).unwrap(); + let values = BTreeMap::from([( + "web".into(), + BTreeMap::from([("HACK_BRANCH".into(), "synthetic-private-branch".into())]), + )]); + let inputs = compile(CompileOptions { + request: &bytes, + profiles: &[], + managed_values: &values, + }) + .unwrap(); + assert_eq!( + inputs.workloads["web"].environment, + BTreeMap::from([ + ("HACK_DEV_HOST".into(), "feature.fixture.hack.local".into()), + ("HACK_DEV_URL".into(), "https://authored.example".into()), + ("HACK_SERVICE_NAME".into(), "web".into()), + ]) + ); + assert_eq!( + inputs.managed_environment["web"], + BTreeMap::from([("HACK_BRANCH".into(), "synthetic-private-branch".into())]) + ); + // Generated values are part of the reviewed public policy identity. + let reviewed = review(&bytes, &[]).unwrap(); + let without = review(&request(&project, metadata), &[]).unwrap(); + assert_eq!(reviewed.semantic_hash, without.semantic_hash); + assert_ne!( + reviewed.environment_policy_hash, + without.environment_policy_hash + ); +} diff --git a/plugins/hack/rules/hack.mdc b/plugins/hack/rules/hack.mdc index 168845512..4d221a373 100644 --- a/plugins/hack/rules/hack.mdc +++ b/plugins/hack/rules/hack.mdc @@ -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: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). ## Product boundary diff --git a/plugins/hack/skills/hack-branches/SKILL.md b/plugins/hack/skills/hack-branches/SKILL.md index c508938b6..9223b872a 100644 --- a/plugins/hack/skills/hack-branches/SKILL.md +++ b/plugins/hack/skills/hack-branches/SKILL.md @@ -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 ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- 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. +- Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Branch aliases @@ -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: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). diff --git a/plugins/hack/skills/hack-cli/SKILL.md b/plugins/hack/skills/hack-cli/SKILL.md index 069562770..77243c01e 100644 --- a/plugins/hack/skills/hack-cli/SKILL.md +++ b/plugins/hack/skills/hack-cli/SKILL.md @@ -16,7 +16,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: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). ## Product boundary @@ -162,7 +162,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 ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- 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. +- Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Project targeting diff --git a/plugins/hack/skills/hack-diagnose/SKILL.md b/plugins/hack/skills/hack-diagnose/SKILL.md index 5eb98da54..1cc9297ec 100644 --- a/plugins/hack/skills/hack-diagnose/SKILL.md +++ b/plugins/hack/skills/hack-diagnose/SKILL.md @@ -37,7 +37,7 @@ 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: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). ## References diff --git a/plugins/hack/skills/hack-env/SKILL.md b/plugins/hack/skills/hack-env/SKILL.md index d804b55e1..db99abd99 100644 --- a/plugins/hack/skills/hack-env/SKILL.md +++ b/plugins/hack/skills/hack-env/SKILL.md @@ -34,4 +34,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: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 7ef0b7ff5..beb584031 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -293,7 +293,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "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 ` on up/open/logs/down (for example: `hack up --branch --detach`).", "Linked worktrees pick a branch instance automatically (see Linked git worktrees).", - "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.", + "Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.", ], }, { diff --git a/src/agents/integration-ownership.ts b/src/agents/integration-ownership.ts index fa8db2b83..c6f9b3559 100644 --- a/src/agents/integration-ownership.ts +++ b/src/agents/integration-ownership.ts @@ -116,6 +116,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "eba974f66328605864507beb", // next: focused plugin skills + plugin-first agent setup "cf91ca873a00ad4743198e11", // next: plugin-first agent setup + refresh-only setup sync "0e99791aa0edfd1895e31748", // next: runtime metadata backend scope + refresh-only setup sync + "124ca278acd4be97747bac9b", // next: runtime metadata for authored projects ]), "hack-init-skill": new Set([ "cd15fb6d8ed8e25071d05e75", // v3.1.0..v4.0.3 @@ -154,6 +155,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "270cd2b3fdcc325e04c76d43", // next: focused plugin skills + plugin-first agent setup "92ca4aa8f28ba1c93093c04a", // next: plugin-first agent setup + refresh-only setup sync "0b82281933af514de8a1b8b8", // next: runtime metadata backend scope + refresh-only setup sync + "05f095e17267519ad3660da5", // next: runtime metadata for authored projects ]), }; diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 5dfd1fdfe..fde8a0dce 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "f4120f477794"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "8f9bff2d631a"; diff --git a/src/backends/native-authored-project-restart.ts b/src/backends/native-authored-project-restart.ts index b653bd464..a73cccd27 100644 --- a/src/backends/native-authored-project-restart.ts +++ b/src/backends/native-authored-project-restart.ts @@ -68,6 +68,8 @@ export type NativeAuthoredProjectRestartOptions = { readonly run: string; readonly profiles?: readonly string[]; readonly overlay?: string | null; + /** Native HTTPS port for the start half's generated runtime metadata URLs. */ + readonly httpsPort?: ServeOptions["httpsPort"]; /** Budget of each half; the stop half does not lend time to the start half. */ readonly startupTimeoutMs: number; readonly signal?: AbortSignal; @@ -161,6 +163,7 @@ export async function restartNativeAuthoredProject( run: opts.run, profiles: opts.profiles, overlay: opts.overlay, + httpsPort: opts.httpsPort, startupTimeoutMs: opts.startupTimeoutMs, signal: opts.signal, forceSignal: opts.forceSignal, diff --git a/src/backends/native-authored-project-start.ts b/src/backends/native-authored-project-start.ts index 8550afb42..58aaa02e1 100644 --- a/src/backends/native-authored-project-start.ts +++ b/src/backends/native-authored-project-start.ts @@ -153,6 +153,11 @@ type Options = { readonly run: string; readonly profiles?: readonly string[]; readonly overlay?: string | null; + /** + * Selected native HTTPS frontend port for the generated runtime host metadata + * URLs; omitted, null or 443 keeps them portless, as for native image projects. + */ + readonly httpsPort?: number | null; readonly startupTimeoutMs: number; readonly signal?: AbortSignal; readonly forceSignal?: AbortSignal; @@ -719,6 +724,13 @@ export async function serveNativeAuthoredProject( !isAbsolute(opts.runtime.binary) || opts.runtime.home !== opts.scope.nativeHome || !RUN.test(opts.run) || + (opts.httpsPort !== undefined && + opts.httpsPort !== null && + !( + Number.isSafeInteger(opts.httpsPort) && + opts.httpsPort >= 1 && + opts.httpsPort <= 65_535 + )) || !Number.isSafeInteger(opts.startupTimeoutMs) || opts.startupTimeoutMs < 1 || opts.startupTimeoutMs > 300_000 || @@ -803,6 +815,10 @@ export async function serveNativeAuthoredProject( profiles: opts.profiles, explicitOverlay: opts.overlay, compilerBranch: opts.scope.branch ?? undefined, + runtimeMetadata: { + branch: opts.scope.branch, + httpsPort: opts.httpsPort ?? null, + }, signal, }); let inputs = await acquire(controller.signal); diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 9748913fd..06365e026 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -25,6 +25,7 @@ import { NativeAuthoredProjectStartError, serveNativeAuthoredProject, } from "../backends/native-authored-project-start.ts"; +import { parseNativeHttpsSelection } from "../backends/native-project-start.ts"; import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; import { CliUsageError } from "../cli/command.ts"; import { display } from "../ui/display.ts"; @@ -406,6 +407,22 @@ async function runStop(input: LifecycleInput): Promise { return 0; } +/** + * The native HTTPS frontend port selected the same way native image projects + * select it, so authored runtime host metadata URLs carry the same port. + */ +function nativeHttpsPort( + env: Readonly> +): number | null { + try { + return parseNativeHttpsSelection(env)?.httpsPort ?? null; + } catch { + throw new CliUsageError( + "Native HTTPS selection requires HACK_NATIVE_CADDY_BINARY, HACK_NATIVE_CADDY_SHA256 and HACK_NATIVE_HTTPS_PORT together; values omitted." + ); + } +} + /** Fresh foreground selection shared by up and the start half of restart. */ function startupSelection( input: LifecycleInput @@ -416,6 +433,7 @@ function startupSelection( run: randomBytes(16).toString("hex"), profiles: input.options.profiles, overlay: input.options.overlay, + httpsPort: nativeHttpsPort(input.env), startupTimeoutMs: input.startupTimeoutMs, signal: input.signal, forceSignal: input.forceSignal, @@ -552,12 +570,19 @@ export async function tryNativeAuthoredCommand( HACK_NATIVE_BINARY: sourceEnv.HACK_NATIVE_BINARY, HACK_NATIVE_HOME: sourceEnv.HACK_NATIVE_HOME, HACK_COMPOSE_STARTUP_TIMEOUT_MS: sourceEnv.HACK_COMPOSE_STARTUP_TIMEOUT_MS, + HACK_NATIVE_CADDY_BINARY: sourceEnv.HACK_NATIVE_CADDY_BINARY, + HACK_NATIVE_CADDY_SHA256: sourceEnv.HACK_NATIVE_CADDY_SHA256, + HACK_NATIVE_HTTPS_PORT: sourceEnv.HACK_NATIVE_HTTPS_PORT, }; if (env.HACK_RUNTIME_BACKEND !== "native") { return null; } const options = captureCommandOptions(opts.options); const mode = commandMode(options); + if (mode === "start" || mode === "restart") { + // Refuse a partial HTTPS selection before any input or runtime work. + nativeHttpsPort(env); + } let runtime: ReturnType; try { runtime = resolveNativeRuntimeSelection(env); diff --git a/src/lib/native-authored-runtime-metadata.ts b/src/lib/native-authored-runtime-metadata.ts new file mode 100644 index 000000000..7e728ee24 --- /dev/null +++ b/src/lib/native-authored-runtime-metadata.ts @@ -0,0 +1,178 @@ +import { DEFAULT_NEW_PROJECT_TLD } from "../constants.ts"; +import type { NativeDeclaredWorkloads } from "./native-env-plan-protocol.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; +import { + buildEffectiveRuntimeHostMetadataEnvironments, + RUNTIME_HOST_METADATA_KEY_NAMES, + type RuntimeHostMetadataService, + runtimeHostMetadataComposeProject, +} from "./runtime-host-metadata.ts"; + +/** Instance identity the command owner selects; hosts come from compiler routing. */ +export type NativeAuthoredRuntimeMetadataSelection = { + /** Selected instance branch; a compiler routing resolution's branch takes precedence. */ + readonly branch: string | null; + /** + * Actual Compose project for the Compose backend. Omitted reports + * `` or `--`, the same name native image projects report. + */ + readonly composeProject?: string; + /** Public HTTPS ingress port; null or 443 keeps URLs portless. */ + readonly httpsPort: number | null; +}; + +/** + * The Compose backend's selection: the selected `--branch` instance, the + * instance's actual Compose project, and portless URLs behind shared ingress. + * Every authored Compose planner (command inputs and file material sources) + * uses this one mapping so their plans stay byte-identical. + */ +export function nativeComposeRuntimeMetadataSelection(identity: { + readonly instance: string | null; + readonly composeProject: string; +}): NativeAuthoredRuntimeMetadataSelection { + return { + branch: identity.instance, + composeProject: identity.composeProject, + httpsPort: null, + }; +} + +/** Public generated values per declared workload. Never secret and never managed. */ +export type NativeRuntimeMetadataValues = Readonly< + Record>> +>; + +const RESERVED = new Set(RUNTIME_HOST_METADATA_KEY_NAMES); + +function originHost(origin: string | undefined): string | null { + if (origin === undefined) { + return null; + } + try { + const host = new URL(origin).hostname; + return host.length > 0 ? host : null; + } catch { + return null; + } +} + +/** + * Public route hostnames per service: routes in name order, each route origin + * before its aliases (in alias name order), so the first service URL is the + * development origin, as in a Compose route label that lists the dev host first. + */ +function serviceHosts( + routing: NativeRoutingResolution | undefined +): Map { + const hosts = new Map(); + if (!routing) { + return hosts; + } + for (const name of Object.keys(routing.routes).sort()) { + const route = routing.routes[name]; + if (!route) { + continue; + } + const selected = hosts.get(route.service) ?? []; + const aliases = Object.keys(route.aliases) + .sort() + .map((alias) => route.aliases[alias]); + for (const origin of [route.origin, ...aliases]) { + const host = originHost(origin); + if (host !== null) { + selected.push(host); + } + } + hosts.set(route.service, selected); + } + return hosts; +} + +/** + * Hack-generated runtime host metadata for an authored `.hack/hack.project.json` + * project, computed by the same shared builder Compose-backed and native image + * projects use. The compiler's routing resolution supplies the effective + * (already branch-applied) development, OAuth alias and service hosts; without + * one, the development host is the compiler's generated `.hack.local` + * origin. Every declared workload receives the full key set: the compiler binds + * these values below managed and authored env, so explicit values win there. + */ +export function nativeAuthoredRuntimeMetadata(opts: { + readonly projectName: string; + readonly declaredWorkloads: NativeDeclaredWorkloads; + readonly routing: NativeRoutingResolution | undefined; + readonly selection: NativeAuthoredRuntimeMetadataSelection; +}): NativeRuntimeMetadataValues { + const { routing, selection } = opts; + const branch = routing ? (routing.branch ?? null) : selection.branch; + const effectiveDevHost = + originHost(routing?.project_origin) ?? + [branch, opts.projectName, DEFAULT_NEW_PROJECT_TLD] + .filter((part): part is string => typeof part === "string") + .join("."); + const effectiveAliasHost = + routing?.oauth_alias === null || routing?.oauth_alias === undefined + ? null + : originHost(routing.aliases[routing.oauth_alias]); + const hosts = serviceHosts(routing); + const services = new Map( + Object.keys(opts.declaredWorkloads).map((name) => [ + name, + { hosts: hosts.get(name) ?? [], declaredEnvironmentKeys: new Set() }, + ]) + ); + const environments = buildEffectiveRuntimeHostMetadataEnvironments({ + services, + branch, + effectiveDevHost, + effectiveAliasHost, + composeProject: + selection.composeProject ?? + runtimeHostMetadataComposeProject({ + projectName: opts.projectName, + branch, + }), + httpsPort: selection.httpsPort, + }); + return Object.fromEntries(environments); +} + +/** Closed wire projection: declared workloads, reserved keys and plain text values only. */ +export function parseNativeRuntimeMetadataValues( + value: unknown, + workloadName: (name: string) => boolean +): NativeRuntimeMetadataValues | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const output: Record> = {}; + for (const [name, entries] of Object.entries(value)) { + if ( + !workloadName(name) || + typeof entries !== "object" || + entries === null || + Array.isArray(entries) + ) { + return null; + } + const projected: Record = {}; + for (const [key, entry] of Object.entries(entries)) { + if ( + !RESERVED.has(key) || + typeof entry !== "string" || + entry.includes("\0") + ) { + return null; + } + projected[key] = entry; + } + output[name] = projected; + } + return output; +} + +/** Whether `key` is a reserved generated runtime metadata key. */ +export function isNativeRuntimeMetadataKey(key: string): boolean { + return RESERVED.has(key); +} diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index 02e674912..224e0f359 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -12,6 +12,7 @@ import { import { resolveComposeStartupTimeoutMs } from "./compose-startup-budget.ts"; import { isRecord } from "./guards.ts"; import { tryNativeAuthoredCommand } from "./native-authored-command.ts"; +import { nativeComposeRuntimeMetadataSelection } from "./native-authored-runtime-metadata.ts"; import { tryLegacyComposeAdoptedCommand } from "./native-compose-adoption-command.ts"; import { assertNativeComposeAfterInputsUnchanged, @@ -63,6 +64,7 @@ import { runNativeComposeOwnedFileChild, } from "./native-compose-file-command.ts"; import { + expectedNativeComposeProject, type NativeComposeGeneration, NativeComposeGenerationError, type NativeComposeGenerationStore, @@ -1874,11 +1876,22 @@ async function prepareCommand(opts: { readonly signal: AbortSignal; }): Promise { const { options, projectRoot, signal } = opts; + // Generated runtime host metadata reports the instance's actual Compose project. + // The opened store's identity is authoritative and is compared below. + const composeProject = await expectedNativeComposeProject({ + projectRoot, + instance: options.instance ?? null, + }); + const runtimeMetadata = nativeComposeRuntimeMetadataSelection({ + instance: options.instance ?? null, + composeProject, + }); const acquire = () => acquireNativeComposeFilePlanningInputs({ projectRoot, profiles: options.profiles, explicitOverlay: options.overlay, + runtimeMetadata, signal, }); let inputs = await acquire(); @@ -1923,6 +1936,9 @@ async function prepareCommand(opts: { mode: "prepare", }); try { + if (store.identity.composeProject !== composeProject) { + return invalid(); + } return await store.withMutation(async (mutation) => { const current = await store.loadCurrent(); assertStartupAvailable(current); @@ -1999,6 +2015,7 @@ async function prepareCommand(opts: { inputs, profiles: options.profiles, explicitOverlay: options.overlay, + runtimeMetadata, signal, }) : null; diff --git a/src/lib/native-compose-file-command.ts b/src/lib/native-compose-file-command.ts index 59e3c1b0d..042d2e1de 100644 --- a/src/lib/native-compose-file-command.ts +++ b/src/lib/native-compose-file-command.ts @@ -3,6 +3,7 @@ import { HackCliError } from "./cli-result.ts"; import { resolveComposeStartupTimeoutMs } from "./compose-startup-budget.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { isRecord } from "./guards.ts"; +import type { NativeAuthoredRuntimeMetadataSelection } from "./native-authored-runtime-metadata.ts"; import { nativeComposeEffectReason, retainNativeComposeEffectRefusal, @@ -270,6 +271,8 @@ export async function prepareNativeComposeCommandFiles(opts: { readonly inputs: NativeComposeExecutionInputs; readonly profiles?: readonly string[]; readonly explicitOverlay?: string | null; + /** The selection `inputs` planned runtime host metadata with, if any. */ + readonly runtimeMetadata?: NativeAuthoredRuntimeMetadataSelection; readonly signal: AbortSignal; }) { const { mutation, store, reservation, inputs, signal } = opts; @@ -285,6 +288,7 @@ export async function prepareNativeComposeCommandFiles(opts: { reservation, profiles, explicitOverlay, + runtimeMetadata: opts.runtimeMetadata, signal, }); const owner = createNativeComposeFileOwner({ diff --git a/src/lib/native-compose-file-sources.ts b/src/lib/native-compose-file-sources.ts index 691880a8b..9d6dc15dd 100644 --- a/src/lib/native-compose-file-sources.ts +++ b/src/lib/native-compose-file-sources.ts @@ -2,6 +2,10 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; import type { FileBinding } from "../../packages/config-compiler/generated/native-config.ts"; import { deepFreeze } from "./guards.ts"; +import { + type NativeAuthoredRuntimeMetadataSelection, + nativeAuthoredRuntimeMetadata, +} from "./native-authored-runtime-metadata.ts"; import { type HeldNativeComposeFile, holdNativeComposeFile, @@ -187,6 +191,33 @@ async function acquireMembers(opts: { } } } +function runtimeValues( + result: { + readonly plan: Readonly>; + readonly declared_workloads?: Parameters< + typeof nativeAuthoredRuntimeMetadata + >[0]["declaredWorkloads"]; + readonly routing_resolution?: Parameters< + typeof nativeAuthoredRuntimeMetadata + >[0]["routing"]; + }, + selection: NativeAuthoredRuntimeMetadataSelection | undefined +) { + if (selection === undefined) { + return undefined; + } + const declared = result.declared_workloads; + const name = result.plan.name; + if (!declared || typeof name !== "string") { + return refuseNativeComposeFile(); + } + return nativeAuthoredRuntimeMetadata({ + projectName: name, + declaredWorkloads: declared, + routing: result.routing_resolution, + selection, + }); +} /** * Actual acquisition, not a caller-provided plan/value resolver. It anchors the current * checkout and managed baseline, preserves empty/binary bytes and holds source FDs. @@ -198,6 +229,8 @@ export async function acquireNativeComposeFileSources(opts: { readonly profiles?: readonly string[]; readonly explicitOverlay?: string | null; readonly explicitDomain?: string; + /** The selection the command inputs planned runtime host metadata with. */ + readonly runtimeMetadata?: NativeAuthoredRuntimeMetadataSelection; readonly signal?: AbortSignal; }): Promise { const authority = opts.authority; @@ -257,9 +290,13 @@ export async function acquireNativeComposeFileSources(opts: { } const envSelection = selectionForEnv(prepared); const env = await acquireProjectEnvForNativeExecution(envSelection); + // The command inputs' runtime metadata selection, so both plans stay + // byte-identical; the delivery owner compares them before any effect. + const runtime = runtimeValues(prepared.result, opts.runtimeMetadata); const planned = await planPreparedNativeProject({ prepared, metadata: env.metadata, + runtime, signal, }); if ( diff --git a/src/lib/native-compose-generation.ts b/src/lib/native-compose-generation.ts index 727c59eaa..5bf1e1874 100644 --- a/src/lib/native-compose-generation.ts +++ b/src/lib/native-compose-generation.ts @@ -583,6 +583,44 @@ type Manifest = { function hash(text: string): string { return createHash("sha256").update(text).digest("hex"); } +function nativeComposeInstanceId(opts: { + readonly checkoutRoot: string; + readonly dev: number; + readonly ino: number; + readonly instance: string | null; +}): string { + return hash( + JSON.stringify([opts.checkoutRoot, opts.dev, opts.ino, opts.instance]) + ); +} +function nativeComposeProjectName(instanceId: string): string { + return `hack-nc-${instanceId.slice(0, 32)}`; +} + +/** + * The Compose project name a store opened for this checkout and instance reports. + * Read-only (one stat); it creates no state and grants no ownership. The opened + * store's identity stays authoritative: callers that use this before opening must + * compare it with `store.identity.composeProject` and refuse on a mismatch. + */ +export async function expectedNativeComposeProject(opts: { + readonly projectRoot: string; + readonly instance: string | null; +}): Promise { + const checkoutRoot = resolve(opts.projectRoot); + const info = await lstat(checkoutRoot).catch(() => refuse()); + if (!info.isDirectory()) { + refuse(); + } + return nativeComposeProjectName( + nativeComposeInstanceId({ + checkoutRoot, + dev: info.dev, + ino: info.ino, + instance: opts.instance, + }) + ); +} function identityMatches(left: unknown, right: NativeComposeIdentity): boolean { return ( isRecord(left) && @@ -1361,14 +1399,12 @@ export async function openNativeComposeGenerationStore(opts: { directories.push(await ownedDirectory(root)); const ignorePath = join(root, ".gitignore"); const ignore = await privateIgnore(ignorePath, opts.mode !== "saved"); - const instanceId = hash( - JSON.stringify([ - checkoutRoot, - checkout.info.dev, - checkout.info.ino, - opts.instance, - ]) - ); + const instanceId = nativeComposeInstanceId({ + checkoutRoot, + dev: checkout.info.dev, + ino: checkout.info.ino, + instance: opts.instance, + }); const instanceRoot = join(root, instanceId); directories.push(await ownedDirectory(instanceRoot)); const generationsRoot = join(instanceRoot, "generations"); @@ -1432,7 +1468,7 @@ export async function openNativeComposeGenerationStore(opts: { repositoryRoot, instance: opts.instance, instanceId, - composeProject: `hack-nc-${instanceId.slice(0, 32)}`, + composeProject: nativeComposeProjectName(instanceId), ownerToken: token(), }; value = { @@ -1459,7 +1495,7 @@ export async function openNativeComposeGenerationStore(opts: { repositoryRoot, instance: opts.instance, instanceId, - composeProject: `hack-nc-${instanceId.slice(0, 32)}`, + composeProject: nativeComposeProjectName(instanceId), ownerToken: value.identity.ownerToken, }); parseReceipt(value, identity, checkoutAnchor); diff --git a/src/lib/native-compose-renderer.ts b/src/lib/native-compose-renderer.ts index 2360c76cc..87e27a2f1 100644 --- a/src/lib/native-compose-renderer.ts +++ b/src/lib/native-compose-renderer.ts @@ -6,6 +6,7 @@ import type { Workload, } from "../../packages/config-compiler/generated/native-config.ts"; import { isRecord } from "./guards.ts"; +import { isNativeRuntimeMetadataKey } from "./native-authored-runtime-metadata.ts"; import { type NativeComposeFileProjection, nativeComposeFileProjectionMatches, @@ -1038,6 +1039,11 @@ export function resolveNativeComposeEnvironment(opts: { } else if (binding.kind === "endpoint") { assert(Object.hasOwn(directives, key), "E_COMPOSE_ENV_MISMATCH"); entries.push([key, opts.endpointValue(binding)]); + } else if (binding.kind === "runtime") { + entries.push([ + key, + runtimeValue({ key, binding, directives, values: opts.values }), + ]); } else { const authored = directives[key]; assert( @@ -1053,6 +1059,23 @@ export function resolveNativeComposeEnvironment(opts: { return Object.fromEntries(entries); } +/** Generated public metadata never shadows an authored directive or a managed value. */ +function runtimeValue(opts: { + readonly key: string; + readonly binding: Extract; + readonly directives: Record; + readonly values?: Readonly>; +}): string { + assert( + isNativeRuntimeMetadataKey(opts.key) && + !Object.hasOwn(opts.directives, opts.key) && + (opts.values === undefined || !Object.hasOwn(opts.values, opts.key)), + "E_COMPOSE_ENV_MISMATCH" + ); + assert(text(opts.binding.value), "E_COMPOSE_ENV"); + return opts.binding.value; +} + function verifyBaseline( key: string, directive: Record, diff --git a/src/lib/native-env-plan-protocol.ts b/src/lib/native-env-plan-protocol.ts index 6bcda60bb..e90e71d61 100644 --- a/src/lib/native-env-plan-protocol.ts +++ b/src/lib/native-env-plan-protocol.ts @@ -6,6 +6,11 @@ import type { WorkloadKind, } from "../../packages/config-compiler/generated/native-config.ts"; import { isRecord } from "./guards.ts"; +import { + isNativeRuntimeMetadataKey, + type NativeRuntimeMetadataValues, + parseNativeRuntimeMetadataValues, +} from "./native-authored-runtime-metadata.ts"; import type { NativeConfigDiagnostic } from "./native-config-compiler.ts"; import { parseNativeEndpointBinding } from "./native-endpoint-plan-protocol.ts"; import { @@ -24,13 +29,15 @@ const OVERLAY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export type NativeDeclaredWorkloads = Readonly>; export type NativeManagedEntry = Readonly; export type NativeEnvMetadata = Readonly< - Omit + Omit > & { readonly workloads: Readonly< Record>> >; readonly inactive_scopes: readonly string[]; readonly host?: NativeHostMetadata; + /** Hack-generated public runtime host metadata; never managed or secret values. */ + readonly runtime?: NativeRuntimeMetadataValues; }; export type NativeEnvBinding = Readonly; export type NativeEnvironmentPlan = Readonly< @@ -86,6 +93,7 @@ export function parseNativeEnvMetadata( "workloads", "inactive_scopes", "host", + "runtime", ]) ) || value.metadata_version !== 1 || @@ -113,6 +121,14 @@ export function parseNativeEnvMetadata( if (host === null) { return null; } + const runtime = Object.hasOwn(value, "runtime") + ? parseNativeRuntimeMetadataValues(value.runtime, (name) => + Object.hasOwn(workloads, name) + ) + : undefined; + if (runtime === null) { + return null; + } return { metadata_version: 1, overlay: value.overlay, @@ -120,6 +136,7 @@ export function parseNativeEnvMetadata( workloads, inactive_scopes: inactiveScopes, ...(host === undefined ? {} : { host }), + ...(runtime === undefined ? {} : { runtime }), }; } @@ -152,6 +169,13 @@ function parseWorkloadMetadata( return workloads; } +/** Host process reports never carry generated container runtime metadata. */ +function parseHostBinding(value: unknown): NativeEnvBinding | null { + return isRecord(value) && value.kind === "runtime" + ? null + : parseBinding(value); +} + function parseBinding(value: unknown): NativeEnvBinding | null { if (!isRecord(value)) { return null; @@ -182,6 +206,14 @@ function parseBinding(value: unknown): NativeEnvBinding | null { ) { return { kind: value.kind, value: value.value }; } + if ( + value.kind === "runtime" && + hasOnly(value, ["kind", "value"]) && + typeof value.value === "string" && + !value.value.includes("\0") + ) { + return { kind: "runtime", value: value.value }; + } return null; } @@ -223,7 +255,10 @@ export function parseNativeEnvironmentPlan(opts: { const bindings: Record = {}; for (const [key, entry] of Object.entries(entries)) { const binding = parseBinding(entry); - if (!(AUTHORED_KEY.test(key) && binding)) { + if ( + !(AUTHORED_KEY.test(key) && binding) || + (binding.kind === "runtime" && !isNativeRuntimeMetadataKey(key)) + ) { return null; } // Authored destinations may include __proto__; define an own data key. @@ -239,7 +274,10 @@ export function parseNativeEnvironmentPlan(opts: { const warnings = value.warnings.map(opts.parseDiagnostic); const diagnostics = value.diagnostics.map(opts.parseDiagnostic); const host = Object.hasOwn(value, "host") - ? parseNativeHostReports({ value: value.host, parseBinding }) + ? parseNativeHostReports({ + value: value.host, + parseBinding: parseHostBinding, + }) : undefined; if ( host === null || diff --git a/src/lib/native-execution-inputs.ts b/src/lib/native-execution-inputs.ts index 82f4a8b1a..730b53e9c 100644 --- a/src/lib/native-execution-inputs.ts +++ b/src/lib/native-execution-inputs.ts @@ -1,4 +1,8 @@ import { createHash } from "node:crypto"; +import { + type NativeAuthoredRuntimeMetadataSelection, + nativeAuthoredRuntimeMetadata, +} from "./native-authored-runtime-metadata.ts"; import { assertNativeComposeFileSubset } from "./native-compose-file-subset.ts"; import { NativeConfigCompilerError } from "./native-config-compiler.ts"; import { authoredFilePlanningRequired } from "./native-file-plan-protocol.ts"; @@ -78,9 +82,21 @@ type NativeExecutionInputSelection = { readonly explicitOverlay?: string | null; readonly explicitDomain?: string; readonly compilerBranch?: string; + /** + * Instance identity for Hack-generated public runtime host metadata. When + * present, every declared workload's plan carries the shared metadata keys as + * public `runtime` bindings below managed and authored env. + */ + readonly runtimeMetadata?: NativeAuthoredRuntimeMetadataSelection; readonly signal?: AbortSignal; }; +function projectName(plan: Readonly>): string { + return typeof plan.name === "string" && plan.name.length > 0 + ? plan.name + : refused(); +} + /** Graph inputs always refuse raw file intent before environment acquisition. */ export function acquireNativeExecutionInputs( opts: NativeExecutionInputSelection @@ -147,14 +163,25 @@ async function acquireInputs(input: { signal, }; const env = await acquireProjectEnvForNativeExecution(envSelection); + const runtime = + opts.runtimeMetadata === undefined + ? undefined + : nativeAuthoredRuntimeMetadata({ + projectName: projectName(resolved.plan), + declaredWorkloads: declared, + routing: resolved.routing_resolution, + selection: opts.runtimeMetadata, + }); const metadata = nativeEnvironmentMetadata({ metadata: env.metadata, declaredWorkloads: declared, + runtime, }); freezePlan(metadata); const planned = await planPreparedNativeProject({ prepared, metadata: env.metadata, + runtime, signal, }); if ( diff --git a/src/lib/native-project-validation.ts b/src/lib/native-project-validation.ts index 41f893061..d98d98fb4 100644 --- a/src/lib/native-project-validation.ts +++ b/src/lib/native-project-validation.ts @@ -1,4 +1,5 @@ import { isRecord } from "./guards.ts"; +import type { NativeRuntimeMetadataValues } from "./native-authored-runtime-metadata.ts"; import { compileNativeConfig, NativeConfigCompilerError, @@ -98,6 +99,8 @@ export async function planNativeProject( export async function planPreparedNativeProject(opts: { readonly prepared: NativePreparedProject; readonly metadata: NativeProjectEnvMetadata; + /** Hack-generated public runtime host metadata; never managed or secret values. */ + readonly runtime?: NativeRuntimeMetadataValues; readonly signal?: AbortSignal; }): Promise { const { prepared, metadata } = opts; @@ -121,6 +124,7 @@ export async function planPreparedNativeProject(opts: { envMetadata: nativeEnvironmentMetadata({ metadata, declaredWorkloads: declared, + runtime: opts.runtime, }), }); if ( @@ -145,6 +149,8 @@ export async function planPreparedNativeProject(opts: { export function nativeEnvironmentMetadata(opts: { readonly metadata: NativeProjectEnvMetadata; readonly declaredWorkloads: NativeDeclaredWorkloads; + /** Generated public runtime metadata; omitted keeps the prior wire unchanged. */ + readonly runtime?: NativeRuntimeMetadataValues; }): NativeEnvMetadata { const { metadata, declaredWorkloads } = opts; return { @@ -161,6 +167,7 @@ export function nativeEnvironmentMetadata(opts: { ...(metadata.hostMetadata === undefined ? {} : { host: metadata.hostMetadata }), + ...(opts.runtime === undefined ? {} : { runtime: opts.runtime }), }; } diff --git a/src/lib/runtime-host-metadata.ts b/src/lib/runtime-host-metadata.ts index 140447c8a..ef66162c3 100644 --- a/src/lib/runtime-host-metadata.ts +++ b/src/lib/runtime-host-metadata.ts @@ -145,6 +145,35 @@ export function buildRuntimeHostMetadataEnvironments(opts: { baseHosts, }) : null; + return buildEffectiveRuntimeHostMetadataEnvironments({ + services: opts.services, + branch: opts.branch, + effectiveDevHost, + effectiveAliasHost, + composeProject: opts.composeProject, + httpsPort: opts.httpsPort, + }); +} + +/** + * The shared metadata document and key set for callers whose instance hosts are + * already effective (branch-applied), such as authored projects whose compiler + * resolves branch origins itself. {@link buildRuntimeHostMetadataEnvironments} + * delegates here after applying the branch, so every backend emits identical keys, + * values and document shape for the same effective identity. + */ +export function buildEffectiveRuntimeHostMetadataEnvironments(opts: { + readonly services: ReadonlyMap; + readonly branch: string | null; + /** Effective development host, already branch-applied. */ + readonly effectiveDevHost: string; + /** Effective OAuth alias host, already branch-applied, or null when disabled. */ + readonly effectiveAliasHost: string | null; + readonly composeProject: string; + /** Public HTTPS ingress port; omitted, null or 443 keeps URLs portless. */ + readonly httpsPort?: number | null; +}): Map> { + const { effectiveDevHost, effectiveAliasHost } = opts; const url = (host: string) => runtimeHostUrl({ host, httpsPort: opts.httpsPort }); const publicServices: Record = diff --git a/tests/helpers/native-compose-command.ts b/tests/helpers/native-compose-command.ts index 2aebc693f..5fb8e696c 100644 --- a/tests/helpers/native-compose-command.ts +++ b/tests/helpers/native-compose-command.ts @@ -38,6 +38,15 @@ export async function fixture( readonly hooks?: unknown; readonly noHooks?: boolean; readonly oneoff?: boolean; + /** + * Echo request `env_metadata.runtime` as public `runtime` bindings below + * managed values, as the real compiler does, and record each plan request's + * runtime selection in `/compiler-runtime`. Off keeps the stand-in + * ignoring runtime metadata. + */ + readonly runtimeMetadata?: boolean; + /** Stand-in source run on each `plan` request, before it responds. */ + readonly onPlan?: string; readonly cleanupAllowed?: () => boolean; readonly storage?: Readonly< Record< @@ -143,7 +152,10 @@ else { if(operation!=="compile") result.local_resolution={overlay:null,origin:"project",auto_branch:false,inherit_local:false,resolution_hash:"b".repeat(64)}; if(operation==="plan") { const metadata=request.env_metadata;const bindings=entries=>Object.fromEntries(Object.entries(entries).map(([key,entry])=>[key,{kind:"managed",key,scope:entry.scope,secret:entry.secret}])); - result.environment_plan={plan_version:1,overlay:metadata.overlay,overlay_exists:metadata.overlay_exists,complete:true,workloads:{web:bindings(metadata.workloads.web)},host:Object.fromEntries(allHooks.map(h=>[h.name,{env_target:h.env_target,bindings:bindings(metadata.host.default)}])),warnings:[],diagnostics:[]}; + ${opts.onPlan ?? ""} + ${opts.runtimeMetadata ? 'await import("node:fs/promises").then(m=>m.appendFile("compiler-runtime",JSON.stringify(metadata.runtime??null)+"\\n"));' : ""} + const runtime=${opts.runtimeMetadata ? 'Object.fromEntries(Object.entries(metadata.runtime?.web??{}).map(([key,value])=>[key,{kind:"runtime",value}]))' : "{}"}; + result.environment_plan={plan_version:1,overlay:metadata.overlay,overlay_exists:metadata.overlay_exists,complete:true,workloads:{web:{...runtime,...bindings(metadata.workloads.web)}},host:Object.fromEntries(allHooks.map(h=>[h.name,{env_target:h.env_target,bindings:bindings(metadata.host.default)}])),warnings:[],diagnostics:[]}; } if(result.environment_plan && allHooks.length===0) delete result.environment_plan.host; console.log(JSON.stringify(result)); diff --git a/tests/native-authored-command.test.ts b/tests/native-authored-command.test.ts index 6bef00309..cbb484264 100644 --- a/tests/native-authored-command.test.ts +++ b/tests/native-authored-command.test.ts @@ -8,6 +8,7 @@ import { NativeAuthoredProjectStartError, type serveNativeAuthoredProject, } from "../src/backends/native-authored-project-start.ts"; +import { CliUsageError } from "../src/cli/command.ts"; import { HackCliError } from "../src/lib/cli-result.ts"; import { tryNativeAuthoredCommand } from "../src/lib/native-authored-command.ts"; import type { NativeComposeCommandOptions } from "../src/lib/native-compose-command.ts"; @@ -718,3 +719,55 @@ macTest( expect(await readdir(join(selected.root, ".hack"))).toEqual([]); } ); + +macTest( + "foreground up and restart carry the selected native HTTPS port for runtime metadata", + async () => { + const selected = await fixture(); + const https = { + HACK_NATIVE_CADDY_BINARY: "/opt/native/caddy", + HACK_NATIVE_CADDY_SHA256: "a".repeat(64), + HACK_NATIVE_HTTPS_PORT: "8443", + }; + const ports: (number | null | undefined)[] = []; + for (const [operation, env] of [ + ["up", { ...selected.env, ...https }], + ["up", selected.env], + ["restart", { ...selected.env, ...https }], + ] as const) { + expect( + await tryNativeAuthoredCommand({ + ...selected, + options: { ...selected.options, operation }, + env, + serve: (startup) => { + ports.push(startup.httpsPort); + return Promise.resolve(0); + }, + stop: () => Promise.resolve(), + }) + ).toBe(0); + } + expect(ports).toEqual([8443, null, 8443]); + } +); + +macTest( + "a partial native HTTPS selection refuses before input or owner work", + async () => { + const selected = await fixture(); + let calls = 0; + const error: unknown = await tryNativeAuthoredCommand({ + ...selected, + env: { ...selected.env, HACK_NATIVE_HTTPS_PORT: "8443" }, + serve: () => { + calls++; + return Promise.resolve(0); + }, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(CliUsageError); + expect(String(error)).not.toContain("8443"); + expect(calls).toBe(0); + expect(await readdir(join(selected.root, ".hack"))).toEqual([]); + } +); diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index 2ed1462e4..9b4a51feb 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -380,6 +380,7 @@ console.log(JSON.stringify(result)); const journal = join(root, "journal.json"); const calls = join(root, "calls.jsonl"); const delivery = join(root, "delivery.json"); + const sources = join(root, "sources.jsonl"); const ownerPid = join(root, "owner.pid"); // Each stand-in owner appends its PID; a detached owner leads its own group. settlement.record({ @@ -401,6 +402,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ const planning=action==='plan'||action==='frontend-plan'; if(!planning)await appendFile(${JSON.stringify(ownerPid)},process.pid+'\\n'); const path=args[args.indexOf('--source-file')+1];const input=JSON.parse(await readFile(path,'utf8')); + if(planning)await appendFile(${JSON.stringify(sources)},JSON.stringify(input.env_metadata)+'\\n'); const digest=createHash('sha256'); if(input.branch!==null)digest.update('hack-native-branch-namespace-v1\\0'); digest.update(input.project);if(input.branch!==null)digest.update('\\0').update(input.branch); @@ -485,6 +487,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ runtime: { binary, home: nativeHome }, calls, delivery, + sources, }; } @@ -1513,3 +1516,41 @@ macTest( }, 30_000 ); + +for (const [httpsPort, branch, url] of [ + [8443, "main", "https://main.fixture.hack.local:8443"], + [undefined, "main", "https://main.fixture.hack.local"], + [8443, null, "https://fixture.hack.local:8443"], +] as const) { + macTest( + `authored native source carries public runtime host metadata (port ${httpsPort ?? "none"}, branch ${branch ?? "none"})`, + async () => { + const selected = await fixture({ branch }); + expect( + await serveNativeAuthoredProject({ + ...selected, + run, + httpsPort, + startupTimeoutMs: 10_000, + }) + ).toBe(0); + const recorded = (await Bun.file(selected.sources).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(recorded.length).toBeGreaterThan(0); + for (const metadata of recorded) { + expect(Object.keys(metadata.runtime).sort()).toEqual(["off", "web"]); + const web = metadata.runtime.web; + expect(web.HACK_BRANCH).toBe(branch ?? ""); + expect(web.HACK_COMPOSE_PROJECT).toBe( + branch ? `fixture--${branch}` : "fixture" + ); + expect(web.HACK_DEV_URL).toBe(url); + expect(web.HACK_SERVICE_NAME).toBe("web"); + expect(metadata.runtime.off.HACK_SERVICE_NAME).toBe("off"); + expect(JSON.stringify(metadata)).not.toContain(CANARY); + } + } + ); +} diff --git a/tests/native-authored-runtime-metadata.test.ts b/tests/native-authored-runtime-metadata.test.ts new file mode 100644 index 000000000..e698c72a6 --- /dev/null +++ b/tests/native-authored-runtime-metadata.test.ts @@ -0,0 +1,415 @@ +import { expect, test } from "bun:test"; +import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; +import { applyNativeRuntimeMetadata } from "../src/backends/native-runtime-metadata.ts"; +import { isRecord } from "../src/lib/guards.ts"; +import { + type NativeAuthoredRuntimeMetadataSelection, + nativeAuthoredRuntimeMetadata, +} from "../src/lib/native-authored-runtime-metadata.ts"; +import { + NativeComposeRenderError, + renderNativeCompose, +} from "../src/lib/native-compose-renderer.ts"; +import { + type NativeConfigPlanResult, + planNativeConfig, + resolveNativeConfig, +} from "../src/lib/native-config-compiler.ts"; +import type { NativeEnvMetadata } from "../src/lib/native-env-plan-protocol.ts"; +import { + RUNTIME_HOST_METADATA_KEY_NAMES, + type RuntimeHostMetadata, +} from "../src/lib/runtime-host-metadata.ts"; +import { requireTestConfigCompiler } from "./helpers/test-config-compiler.ts"; + +const binary = requireTestConfigCompiler(); +const encode = (value: unknown) => + new TextEncoder().encode(JSON.stringify(value)); + +/** Real compiler: resolve routing, derive the shared metadata, then plan with it. */ +async function authoredPlan(opts: { + readonly project: Record; + readonly branch?: string; + readonly selection: NativeAuthoredRuntimeMetadataSelection; + readonly workloads?: NativeEnvMetadata["workloads"]; +}): Promise> { + const input = encode(opts.project); + const resolved = await resolveNativeConfig({ + input, + binary, + branch: opts.branch, + requireRoutingPlanning: opts.branch !== undefined, + }); + if (!(resolved.ok && resolved.declared_workloads)) { + throw new Error("fixture project did not resolve"); + } + const runtime = nativeAuthoredRuntimeMetadata({ + projectName: String(resolved.plan.name), + declaredWorkloads: resolved.declared_workloads, + routing: resolved.routing_resolution, + selection: opts.selection, + }); + const planned = await planNativeConfig({ + input, + binary, + branch: opts.branch, + envMetadata: { + metadata_version: 1, + overlay: null, + overlay_exists: false, + workloads: + opts.workloads ?? + Object.fromEntries( + Object.keys(resolved.declared_workloads).map((name) => [name, {}]) + ), + inactive_scopes: [], + runtime, + }, + }); + if (!(planned.ok && planned.environment_plan.complete)) { + throw new Error("fixture project did not plan"); + } + return planned; +} + +function runtimeBindings( + planned: Extract, + workload: string +): Record { + return Object.fromEntries( + Object.entries(planned.environment_plan.workloads[workload] ?? {}).flatMap( + ([key, binding]) => + binding.kind === "runtime" ? [[key, binding.value] as const] : [] + ) + ); +} + +function imageInput(services: Record): NativeProjectInput { + return { + originalSha256: "d".repeat(64), + normalizedComposeJson: JSON.stringify({ services }), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + effectiveEnvName: null, + environmentFiles: [], + serviceNames: Object.keys(services), + }; +} + +function imageEnvironment( + input: NativeProjectInput, + service: string +): Record { + const compose: unknown = JSON.parse(input.normalizedComposeJson); + if (!(isRecord(compose) && isRecord(compose.services))) { + throw new Error("missing services"); + } + const selected = compose.services[service]; + if (!(isRecord(selected) && isRecord(selected.environment))) { + throw new Error(`missing ${service} environment`); + } + return selected.environment; +} + +test("authored native metadata matches the native image path for a branch instance on HTTPS port 8443", async () => { + // Native image project: dev host app.hack.local, branch feature, port 8443. + const image = applyNativeRuntimeMetadata({ + input: imageInput({ + web: { image: "example" }, + worker: { image: "example" }, + }), + selection: { + devHost: "app.hack.local", + aliasHost: null, + projectName: "app", + }, + branch: "feature", + httpsPort: 8443, + }).input; + // Authored project named app: the compiler generates feature.app.hack.local. + const planned = await authoredPlan({ + project: { + schema_version: 1, + name: "app", + services: { web: { image: "example:1" }, worker: { image: "example:1" } }, + }, + branch: "feature", + selection: { branch: "feature", httpsPort: 8443 }, + }); + for (const service of ["web", "worker"]) { + const authored = runtimeBindings(planned, service); + expect(authored).toEqual( + imageEnvironment(image, service) as Record + ); + expect(Object.keys(authored).sort()).toEqual( + RUNTIME_HOST_METADATA_KEY_NAMES.filter( + (key) => + key !== "HACK_ALIAS_HOST" && + key !== "HACK_ALIAS_URL" && + key !== "HACK_SERVICE_URL" + ).sort() + ); + } + const web = runtimeBindings(planned, "web"); + expect(web.HACK_BRANCH).toBe("feature"); + expect(web.HACK_COMPOSE_PROJECT).toBe("app--feature"); + expect(web.HACK_DEV_HOST).toBe("feature.app.hack.local"); + expect(web.HACK_DEV_URL).toBe("https://feature.app.hack.local:8443"); + expect(web.HACK_SERVICE_NAME).toBe("web"); + expect(web.HACK_SERVICE_URLS).toBe("[]"); + expect(JSON.parse(web.HACK_RUNTIME_METADATA ?? "")).toEqual({ + version: 1, + branch: "feature", + composeProject: "app--feature", + hosts: { dev: "feature.app.hack.local", alias: null }, + services: {}, + } satisfies RuntimeHostMetadata); +}); + +test("authored base instance without routing uses the compiler's generated origin and portless URLs", async () => { + const planned = await authoredPlan({ + project: { + schema_version: 1, + name: "app", + services: { web: { image: "example:1" } }, + jobs: { seed: { image: "example:1" } }, + }, + selection: { branch: null, httpsPort: 443 }, + }); + expect(planned.routing_resolution).toBeUndefined(); + const web = runtimeBindings(planned, "web"); + expect(web.HACK_BRANCH).toBe(""); + expect(web.HACK_COMPOSE_PROJECT).toBe("app"); + expect(web.HACK_DEV_URL).toBe("https://app.hack.local"); + expect(runtimeBindings(planned, "seed").HACK_SERVICE_NAME).toBe("seed"); +}); + +test("explicit authored and managed env win over generated metadata through the real compiler", async () => { + const planned = await authoredPlan({ + project: { + schema_version: 1, + name: "app", + services: { + web: { + image: "example:1", + environment: { + HACK_DEV_URL: { literal: "https://authored.example" }, + HACK_SERVICE_URLS: { unset: true }, + }, + }, + }, + }, + branch: "feature", + selection: { branch: "feature", httpsPort: 8443 }, + workloads: { + web: { HACK_BRANCH: { scope: "web", secret: false } }, + }, + }); + const bindings = planned.environment_plan.workloads.web ?? {}; + expect(bindings.HACK_DEV_URL).toEqual({ + kind: "literal", + value: "https://authored.example", + }); + expect(bindings.HACK_BRANCH).toEqual({ + kind: "managed", + key: "HACK_BRANCH", + scope: "web", + secret: false, + }); + expect(bindings.HACK_SERVICE_URLS).toBeUndefined(); + expect(bindings.HACK_DEV_HOST).toEqual({ + kind: "runtime", + value: "feature.app.hack.local", + }); +}); + +const ROUTED = { + schema_version: 1, + name: "app", + routes: { + aliases: { oauth: { domain: "hack.gy" } }, + oauth_alias: "oauth", + http: { + web: { service: "web", port: 3000, hostname: "project" }, + api: { service: "api", port: 4000, hostname: "api" }, + }, + }, + services: { + web: { + image: "example:1", + environment: { HACK_DEV_URL: { literal: "https://authored.example" } }, + }, + api: { image: "example:1" }, + worker: { image: "example:1" }, + }, +}; + +async function routedRender(withRuntime: boolean) { + const composeProject = `hack-nc-${"a".repeat(32)}`; + const planned = await authoredPlan({ + project: ROUTED, + branch: "feature", + selection: { branch: "feature", composeProject, httpsPort: null }, + }); + const workloads = withRuntime + ? planned.environment_plan.workloads + : Object.fromEntries( + Object.entries(planned.environment_plan.workloads).map( + ([name, bindings]) => [ + name, + Object.fromEntries( + Object.entries(bindings).filter( + ([, binding]) => binding.kind !== "runtime" + ) + ), + ] + ) + ); + return renderNativeCompose({ + plan: planned.plan, + environmentPlan: { ...planned.environment_plan, workloads }, + projectRoot: "/verified/checkout", + runtimeIdentity: composeProject, + ownerToken: "c".repeat(32), + generationIdentity: "b".repeat(32), + managedValues: { web: {}, api: {}, worker: {} }, + routingResolution: planned.routing_resolution, + declaredWorkloads: planned.declared_workloads, + }); +} + +test("authored Compose render gains route-derived metadata while every other byte stays the same", async () => { + const rendered = await routedRender(true); + const baseline = await routedRender(false); + const services = rendered.document.services; + const env = (name: string) => { + const service = services[name]; + if (!(service && isRecord(service.environment))) { + throw new Error(`missing ${name} environment`); + } + return service.environment as Record; + }; + expect(env("web").HACK_DEV_URL).toBe("https://authored.example"); + expect(env("web").HACK_SERVICE_URL).toBe("https://feature.app.hack.local"); + expect(JSON.parse(env("web").HACK_SERVICE_URLS ?? "")).toEqual([ + "https://feature.app.hack.local", + "https://feature.app.hack.gy", + ]); + expect(env("api").HACK_DEV_URL).toBe("https://feature.app.hack.local"); + expect(env("api").HACK_SERVICE_URL).toBe( + "https://api.feature.app.hack.local" + ); + expect(env("api").HACK_ALIAS_HOST).toBe("feature.app.hack.gy"); + expect(env("api").HACK_ALIAS_URL).toBe("https://feature.app.hack.gy"); + expect(env("api").HACK_COMPOSE_PROJECT).toBe(`hack-nc-${"a".repeat(32)}`); + expect(env("worker").HACK_SERVICE_URL).toBeUndefined(); + expect(env("worker").HACK_SERVICE_URLS).toBe("[]"); + const document = JSON.parse( + env("worker").HACK_RUNTIME_METADATA ?? "" + ) as RuntimeHostMetadata; + expect(document).toEqual({ + version: 1, + branch: "feature", + composeProject: `hack-nc-${"a".repeat(32)}`, + hosts: { dev: "feature.app.hack.local", alias: "feature.app.hack.gy" }, + services: { + api: { + urls: [ + "https://api.feature.app.hack.local", + "https://api.feature.app.hack.gy", + ], + }, + web: { + urls: ["https://feature.app.hack.local", "https://feature.app.hack.gy"], + }, + }, + }); + // Removing exactly the generated keys yields the metadata-free render, byte for byte. + const stripped = structuredClone(rendered.document) as Record< + string, + unknown + >; + for (const service of Object.values( + stripped.services as Record> + )) { + const environment = service.environment as Record; + for (const key of RUNTIME_HOST_METADATA_KEY_NAMES) { + if ( + key !== "HACK_DEV_URL" || + environment[key] !== "https://authored.example" + ) { + delete environment[key]; + } + } + } + expect(`${JSON.stringify(stripped, null, 2)}\n`).toBe(baseline.json); + expect(rendered.json).not.toBe(baseline.json); +}); + +test("the renderer refuses generated bindings that shadow owned keys or leave the reserved set", async () => { + const planned = await authoredPlan({ + project: ROUTED, + branch: "feature", + selection: { + branch: "feature", + composeProject: `hack-nc-${"a".repeat(32)}`, + httpsPort: null, + }, + }); + const render = ( + web: Record, + managedValues: Record> = { + web: {}, + api: {}, + worker: {}, + } + ) => + renderNativeCompose({ + plan: planned.plan, + environmentPlan: { + ...planned.environment_plan, + workloads: { ...planned.environment_plan.workloads, web }, + }, + projectRoot: "/verified/checkout", + runtimeIdentity: `hack-nc-${"a".repeat(32)}`, + ownerToken: "c".repeat(32), + generationIdentity: "b".repeat(32), + managedValues, + routingResolution: planned.routing_resolution, + declaredWorkloads: planned.declared_workloads, + }); + const web = planned.environment_plan.workloads.web ?? {}; + const expectRefusal = (action: () => unknown, code: string) => { + try { + action(); + } catch (error: unknown) { + expect(error).toBeInstanceOf(NativeComposeRenderError); + expect((error as NativeComposeRenderError).code).toBe(code); + return; + } + throw new Error("expected a renderer refusal"); + }; + // Positive control: the compiler output itself renders. + expect(() => render(web)).not.toThrow(); + // A generated binding for a non-reserved key fails the closed plan parse. + expectRefusal( + () => render({ ...web, OTHER: { kind: "runtime", value: "x" } }), + "E_COMPOSE_ENV_INCOMPLETE" + ); + // A generated binding where the authored directive owns the key. + expectRefusal( + () => + render({ ...web, HACK_DEV_URL: { kind: "runtime", value: "forged" } }), + "E_COMPOSE_ENV_MISMATCH" + ); + // A generated binding where a managed value owns the key. + expectRefusal( + () => + render(web, { + web: { HACK_BRANCH: "managed" }, + api: {}, + worker: {}, + }), + "E_COMPOSE_ENV_MISMATCH" + ); +}); diff --git a/tests/native-compose-runtime-metadata-command.test.ts b/tests/native-compose-runtime-metadata-command.test.ts new file mode 100644 index 000000000..c6b8cd287 --- /dev/null +++ b/tests/native-compose-runtime-metadata-command.test.ts @@ -0,0 +1,155 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { lstat, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { RUNTIME_HOST_METADATA_KEY_NAMES } from "../src/lib/runtime-host-metadata.ts"; +import { fixture, invoke } from "./helpers/native-compose-command.ts"; + +/** + * Authored Compose `up` through the real source CLI and `prepareCommand`. The + * compiler and Docker are stand-ins: the compiler echoes the requested runtime + * metadata as `runtime` bindings, as the real compiler does, and Docker records + * the rendered Compose document it was asked to start. + */ + +const moved: string[] = []; +afterEach(async () => { + await Promise.all( + moved.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ); +}); + +type Engine = { + readonly name: string; + readonly services: { + readonly web: { readonly environment: Record }; + }; +}; + +async function engine(root: string): Promise { + return await Bun.file(join(root, "engine")).json(); +} + +async function composeStarts(root: string): Promise { + const path = join(root, "requests"); + if (!(await Bun.file(path).exists())) { + return []; + } + return (await Bun.file(path).text()) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as string[]) + .filter((args) => args[0] === "compose" && args.includes("up")); +} + +/** Independent recomputation of the generation store's Compose project identity. */ +async function storeComposeProject( + root: string, + instance: string | null +): Promise { + const info = await lstat(root); + const id = createHash("sha256") + .update(JSON.stringify([root, info.dev, info.ino, instance])) + .digest("hex"); + return `hack-nc-${id.slice(0, 32)}`; +} + +function generated(environment: Record) { + return Object.fromEntries( + Object.entries(environment).filter(([key]) => + RUNTIME_HOST_METADATA_KEY_NAMES.includes(key) + ) + ); +} + +test.each([ + { name: "default instance", instance: null }, + { name: "branch instance", instance: "feature" }, +] as const)("authored Compose up renders runtime metadata with the store's Compose project: $name", async ({ + instance, +}) => { + const root = await fixture("", false, { + noHooks: true, + runtimeMetadata: true, + }); + const args = [ + "up", + ...(instance === null ? [] : ["--branch", instance]), + "--detach", + "--json", + ]; + const result = await invoke(root, args); + expect(result.code).toBe(0); + + const composeProject = await storeComposeProject(root, instance); + const document = await engine(root); + // The rendered document is named by the opened store's identity. + expect(document.name).toBe(composeProject); + const starts = await composeStarts(root); + expect(starts).toHaveLength(1); + expect(starts[0]?.[starts[0].indexOf("-p") + 1]).toBe(composeProject); + + const environment = document.services.web.environment; + const devHost = + instance === null ? "fixture.hack.local" : `${instance}.fixture.hack.local`; + const expected = { + HACK_BRANCH: instance ?? "", + HACK_COMPOSE_PROJECT: composeProject, + HACK_DEV_HOST: devHost, + HACK_DEV_URL: `https://${devHost}`, + HACK_SERVICE_NAME: "web", + HACK_SERVICE_URLS: "[]", + HACK_RUNTIME_METADATA: JSON.stringify({ + version: 1, + branch: instance, + composeProject, + hosts: { dev: devHost, alias: null }, + services: {}, + }), + }; + expect(generated(environment)).toEqual(expected); + // Managed env still delivers beside the generated metadata. + expect(environment.TOKEN).toBe("global"); + + // The compiler planned exactly this selection, once per planning request. + const requested = (await Bun.file(join(root, "compiler-runtime")).text()) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); + expect(requested.length).toBeGreaterThan(0); + for (const runtime of requested) { + expect(runtime).toEqual({ web: expected }); + } +}, 30_000); + +test("authored Compose up refuses before any engine effect when the checkout identity changes after metadata selection", async () => { + const root = await fixture("", false, { + noHooks: true, + runtimeMetadata: true, + // Once, while the first plan request is in flight (after prepareCommand + // selected the runtime metadata and before it opens the generation store), + // replace the checkout with a copy: same path, new inode, so the store + // derives a different Compose project than the metadata reports. + onPlan: `{const fs=await import("node:fs/promises");const checkout=process.cwd(); + if(!(await Bun.file(checkout+".swapped").exists())){await fs.writeFile(checkout+".swapped","");await fs.rename(checkout,checkout+".moved");await fs.cp(checkout+".moved",checkout,{recursive:true});}}`, + }); + moved.push(`${root}.moved`, `${root}.swapped`); + const selected = await storeComposeProject(root, null); + + const result = await invoke(root); + + expect(await Bun.file(`${root}.swapped`).exists()).toBe(true); + expect(await storeComposeProject(root, null)).not.toBe(selected); + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { + code: "E_CONFIG_INVALID", + message: "The saved native Compose selection is invalid; values omitted.", + }, + }); + expect(result.stdout).not.toContain(selected); + expect(await composeStarts(root)).toEqual([]); + expect(await composeStarts(`${root}.moved`)).toEqual([]); + expect(await Bun.file(join(root, "engine")).exists()).toBe(false); +}, 30_000); diff --git a/tests/native-env-plan-transport.test.ts b/tests/native-env-plan-transport.test.ts index 1bb0f6b52..c9cc8e477 100644 --- a/tests/native-env-plan-transport.test.ts +++ b/tests/native-env-plan-transport.test.ts @@ -9,7 +9,10 @@ import { planNativeConfig, resolveNativeConfig, } from "../src/lib/native-config-compiler.ts"; -import type { NativeEnvMetadata } from "../src/lib/native-env-plan-protocol.ts"; +import { + type NativeEnvMetadata, + parseNativeEnvironmentPlan, +} from "../src/lib/native-env-plan-protocol.ts"; import { restoreEnv } from "./helpers/env.ts"; const PROTOCOL = { @@ -301,6 +304,95 @@ test("rejects oversized metadata before spawning", async () => { expect(await Bun.file(receipt).exists()).toBe(false); }); +test("sends reserved runtime metadata for declared workloads and refuses any other runtime entry before spawning", async () => { + const receipt = join(directory, "spawn-receipt"); + const request = join(directory, "request"); + const binary = await fixture({ + onInvocation: `await Bun.write(${JSON.stringify(receipt)}, 'spawned');`, + body: `await Bun.write(${JSON.stringify(request)}, await Bun.stdin.text()); process.stdout.write(${JSON.stringify(JSON.stringify(SUCCESS))});`, + }); + const refused = [ + { name: "non-reserved key", runtime: { web: { APP_MODE: CANARY } } }, + { name: "lowercase key", runtime: { web: { hack_branch: CANARY } } }, + { + name: "undeclared workload", + runtime: { other: { HACK_BRANCH: CANARY } }, + }, + { name: "NUL value", runtime: { web: { HACK_BRANCH: `${CANARY}\0` } } }, + { name: "non-string value", runtime: { web: { HACK_BRANCH: 1 } } }, + { name: "array workload", runtime: { web: [CANARY] } }, + { name: "array runtime", runtime: [CANARY] }, + ]; + for (const { runtime } of refused) { + await expectFailure({ + operation: planNativeConfig({ + input: INPUT, + envMetadata: { ...METADATA, runtime } as unknown as NativeEnvMetadata, + binary, + }), + code: "E_CONFIG_METADATA", + message: + "Native environment metadata is invalid or exceeds its budget; values omitted.", + }); + expect(await Bun.file(receipt).exists()).toBe(false); + } + // Positive control: the same wire with reserved keys reaches the compiler. + const runtime = { + web: { HACK_BRANCH: "feature", HACK_SERVICE_NAME: "web" }, + }; + expect( + await planNativeConfig({ + input: INPUT, + envMetadata: { ...METADATA, runtime }, + binary, + }) + ).toEqual(SUCCESS); + expect(await Bun.file(receipt).exists()).toBe(true); + expect( + JSON.parse(await Bun.file(request).text()).env_metadata.runtime + ).toEqual(runtime); +}); + +test("accepts runtime bindings for reserved workload keys and refuses them in host reports", () => { + const parse = (value: unknown) => + parseNativeEnvironmentPlan({ + value, + parseDiagnostic: () => { + throw new Error("No diagnostics are expected"); + }, + }); + const host = (binding: unknown) => ({ + ...ENVIRONMENT_PLAN, + host: { + before: { + env_target: { kind: "host" }, + bindings: { HACK_BRANCH: binding }, + }, + }, + }); + const workload = (key: string) => ({ + ...ENVIRONMENT_PLAN, + workloads: { + web: { + ...ENVIRONMENT_PLAN.workloads.web, + [key]: { kind: "runtime", value: "feature" }, + }, + }, + }); + // Positive controls: the same host report shape with an authored literal, + // and a reserved runtime binding on a workload. + expect( + parse(host({ kind: "literal", value: "feature" }))?.host?.before?.bindings + ).toEqual({ HACK_BRANCH: { kind: "literal", value: "feature" } }); + expect(parse(workload("HACK_BRANCH"))?.workloads.web?.HACK_BRANCH).toEqual({ + kind: "runtime", + value: "feature", + }); + // Generated container metadata never reaches host processes. + expect(parse(host({ kind: "runtime", value: "feature" }))).toBeNull(); + expect(parse(workload("APP_MODE"))).toBeNull(); +}); + test("bounds original documents and UTF-8 before spawning", async () => { const receipt = join(directory, "spawn-receipt"); const binary = await fixture({