diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4a33b719..7ed2c384b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,9 @@ jobs: bunx ultracite check scripts/check-native-env-plan-cli.ts tests/native-env-plan-transport.test.ts tests/native-env-metadata.test.ts bun test tests/native-env-plan-transport.test.ts tests/native-env-metadata.test.ts tests/project-env-config.test.ts tests/project-env-metadata.test.ts bun scripts/check-native-env-plan-cli.ts + bunx ultracite check scripts/check-native-host-plan-cli.ts tests/native-host-plan-transport.test.ts src/lib/native-host-plan-protocol.ts + bun test tests/native-host-plan-transport.test.ts + bun scripts/check-native-host-plan-cli.ts state-models: name: Runtime state models diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md index 58f58b28b..7bf147d6e 100644 --- a/docs/reference/native-config-compiler.md +++ b/docs/reference/native-config-compiler.md @@ -107,12 +107,81 @@ backslashes and drive syntax. Lexical `.` and repeated separators normalize; no filesystem or symlink resolution occurs. Working directories and mount targets must be absolute POSIX container paths without `..`. -Routes, shutdown/restart policies, host hooks/processes, endpoint references, +Routes, container shutdown/restart policies, endpoint references, network/security/resources, cache protocols, backend options, arbitrary extensions, and local settings other than environment selection are not yet implemented. They refuse rather than being silently dropped. This foundation does not replace the full native contract or qualify a migrated advanced project. +## Host declarations + +The optional `host` namespace declares ordered lifecycle hooks and named processes: + +```json +{ + "schema_version": 1, + "name": "example", + "services": { "web": { "image": "example/web:1" } }, + "host": { + "up": { + "before": [{ + "name": "prepare", + "command": { "exec": ["./scripts/prepare"] }, + "cwd": "." + }] + }, + "processes": { + "tunnel": { + "command": { "shell": "./scripts/tunnel" }, + "env_target": { "kind": "workload", "name": "web" }, + "environment": { "TOKEN": { "env_ref": "TOKEN" } } + } + } + } +} +``` + +`host.up` and `host.down` accept `before`/`after` arrays. Each hook requires a +canonical `name` and explicit `command`; array order is preserved. `host.processes` +is a map of canonical names to process declarations with required commands. Names +must be unique across all hooks and processes, in a namespace separate from +services/jobs. Commands use the same explicit exec/shell forms as workloads. +`cwd` defaults to `.` and is lexically normalized relative to the project checkout, +not `source.root`, `.hack` or the caller's working directory. No filesystem lookup +or process execution occurs. + +A process's `startup` defaults to `up` and `exit` to `stop_on_down`; these are the +only supported values in this slice. They preserve lifecycle intent in the plan, +without enabling a controller. Optional process `singleton` requires nonempty, +unique ports in 1–65,535, normalized as a sorted set, and `on_conflict` of `fail` +(default) or `adopt`. Adoption still requires the existing lifecycle owner's +ownership proof at execution. Hook singleton settings, restart/readiness policies +and additional startup/exit policies currently refuse. + +Each hook/process has `env_target` of `{kind:"host"}` (the omitted default) or +`{kind:"workload",name:"declared-service-or-job"}`. An explicit workload target may +name an inactive declaration; it selects env ownership and does not enable that +workload. Matching host and workload names confers no implicit env scope. Unknown +targets and per-reference scope overrides refuse. Each entry's `environment` +accepts the same literal/default/env_ref/unset directives as workloads. There is +no per-process managed store scope. Local settings cannot inject host commands. + +The compiler omits an empty host namespace from the normalized plan; hostless +plans keep their existing serialized identity. Validation preserves symbolic refs +without reading managed files. Explicit planning checks each host entry against +its selected owner's immutable baseline. Generic host selection uses global plus +host overrides; a workload target uses global plus that workload plus host +overrides, with the existing layer-first precedence. If any declared workload is +named `host`, including an inactive service/job, that scope belongs to the workload: +generic selection then uses global only and other workload targets receive no +generic host override. All target maps share the bounded metadata budget. + +The environment report adds `host:{NAME:{env_target,bindings}}`, separate from its +`workloads` map. Required refs and remapping collisions report the original host +declaration pointer. No managed values or ciphertext enter the report or either +portable identity. This is offline planning; execution, post-hook generation +checks, secret delivery and locked adoption remain later integration work. + ## Protocol and diagnostics The CLI supports project-aware and explicit-document validation: @@ -138,10 +207,12 @@ authored public literals and commands. Those values are intentionally visible; diagnostic redaction does not turn the plan into a secret-safe storage format. - `hack-config-compiler --protocol` emits - `{"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1}`. + `{"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1}`. Project-aware validation requires local resolution capabilities; explicit metadata planning additionally requires `env_plan_version:1`. Older matching compilers can still serve their supported validation modes. + Host planning requires `host_env_plan_version:1` before acquiring host metadata; + hostless env-plan-v1 compilers remain supported. - `hack-config-compiler compile [--profile NAME]...` reads one UTF-8 JSON document from stdin through EOF. Input is limited to 1 MiB and 64 nested containers. These are parser safety bounds, not container resource or workload-count limits. @@ -152,6 +223,9 @@ diagnostic redaction does not turn the plan into a secret-safe storage format. one-based line/byte-column. Semantic errors use the nearest authored value's location; errors for a missing property or CLI-selected profile may point to its containing object. Input contents and parser excerpts never appear in messages. + Host declarations additionally produce `host_env_targets:{include_default, + workloads:[...]}`, a names-only owner selection derived in Rust. It is separate + from the authored identity and includes explicit targets from inactive workloads. - Invalid invocation exits 2 with fixed usage on stderr and no JSON on stdout. - `hack-config-compiler resolve [--profile NAME]...` reads a versioned request: `{request_version:1,project:"original JSON text",primary_local?:"original JSON text",checkout_local?:"original JSON text",explicit_overlay?:null|string}`. @@ -208,6 +282,17 @@ workloads:{NAME:{KEY:{scope:string,secret:boolean}}},inactive_scopes:string[]}`. Every declared workload must appear, including inactive ones. Unknown fields, target names, invalid scopes and mismatched selections refuse. Serialized metadata is limited to 1 MiB in addition to the existing document and encoded-request bounds. +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. +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 +conservative and does not refund overwritten or removed entries. Oversized +expansion refuses with a fixed `plan_too_large` diagnostic at the original project +pointer. This bounds report memory; it does not cap project resources or workload +counts. ## Local settings and worktrees diff --git a/packages/config-compiler/README.md b/packages/config-compiler/README.md index 9ae96d9c0..6ec484893 100644 --- a/packages/config-compiler/README.md +++ b/packages/config-compiler/README.md @@ -142,3 +142,65 @@ The process exits `1` for incomplete bindings even when `ok: true`, and `0` only for a complete metadata plan. Completeness is not runtime admission: this operation cannot decrypt, verify secret availability, apply config, run hooks, or start a workload. No metadata-derived or secret-derived hash is added to the portable plan. + +## Typed host intent and environment targets + +Optional project `host` contains `up` and `down` hooks and named `processes`. +Each hook phase has ordered `before` and `after` arrays. Hooks require a canonical +`name` and explicit `command: {exec: [...]}` or `{shell: "..."}`. Processes use +the map key as their name and require the same command forms. Names must be unique +across all host stages and processes, but occupy a separate namespace from services +and jobs. Hook order is preserved in the plan and semantic hash. + +Every hook/process has optional `cwd` (default `.`), `environment` (the existing +tagged directives) and `env_target` (default `{kind: "host"}`). Cwd is relative to +the checkout root, normalized, and cannot escape through `..` or absolute paths. +Explicit `{kind: "workload", name: "declared-service-or-job"}` selects an existing +workload's host environment, including inactive profile declarations. Matching a +host process name to a workload gives no implicit environment scope. Names such as +`global` and `host` remain ordinary identities in the host entry namespace. + +Processes support `startup: "up"` and `exit: "stop_on_down"` only, both defaulted. +An optional process `singleton` declares a nonempty unique set of ports 1–65535 +and `on_conflict: "fail" | "adopt"` (default `fail`). Port order is normalized. +These declarations are intent only: no process is run, port checked, or process +adopted. A later runtime owner must prove adoption eligibility. Hook singletons, +restart policies, readiness and other unsupported fields refuse. + +Normalized host plans materialize defaults. Empty host declarations normalize to +omission and preserve the previous hostless plan and semantic hash. A nonempty host +adds `host_env_targets: {include_default, workloads}` to compile, resolve and plan +success envelopes. This Rust-derived projection has sorted unique workload targets; +it is omitted for hostless projects. Protocol capability `host_env_plan_version: 1` +must be present before requesting host owner metadata. + +Environment metadata gains optional `host: {default?, workloads}`. `default` is +required exactly when generic host targeting is requested; `workloads` must contain +exactly the projected target names, with no extra targets. Every map contains only +key names and winning `{scope, secret}` metadata. Generic host accepts global and +generic host scopes; an explicit workload target accepts global, its owning workload +scope, and generic host scope. When a workload named `host` is declared, generic +host override disappears: default accepts only global, and each workload target +accepts only global and its owning name. Inactive workload declarations participate +in this rule. Unknown fields, null objects, arrays and malformed metadata refuse. + +`environment_plan.host` maps unique hook/process names to `{env_target, bindings}` +and is omitted when there are no host entries. Host binding shares the immutable +baseline and literal/default/reference/unset rules used for workloads. A generic +host reference cannot read another workload's metadata. Missing-reference and +collision diagnostics retain original project pointers, including ordered hook +indices. Host metadata never enters semantic or resolution hashes. A complete +metadata plan still does not qualify secret delivery, process supervision, singleton +ownership, shutdown, runtime execution or application readiness. + +Environment planning enforces a shared 8 MiB serialized-response safety budget. +It reserves portable plan, namespace, local-resolution and envelope space first, +then counts symbolic baseline bytes once per metadata target and charges each +workload/host copy before allocating its binding map. New directives and diagnostics +are charged before insertion. Counting does not allocate a serialized expanded +report. Charges are conservative and are not refunded for overwritten/unset +bindings. This may refuse near-limit reports before their final encoding reaches +8 MiB; refusal is the fixed redacted `plan_too_large` diagnostic at the original +project location. The bound prevents input-to-output amplification and is shared +across workload and host reporting; it is not a process-count or runtime-resource +limit. No metadata-derived hash or managed value is exposed by the budget. diff --git a/packages/config-compiler/generated/hack.project.schema.json b/packages/config-compiler/generated/hack.project.schema.json index 61dfab448..6273cf241 100644 --- a/packages/config-compiler/generated/hack.project.schema.json +++ b/packages/config-compiler/generated/hack.project.schema.json @@ -157,6 +157,203 @@ } ] }, + "HostConfig": { + "additionalProperties": false, + "properties": { + "down": { + "$ref": "#/$defs/HostHooks", + "default": { + "after": [], + "before": [] + } + }, + "processes": { + "additionalProperties": { + "$ref": "#/$defs/HostProcess" + }, + "default": {}, + "type": "object" + }, + "up": { + "$ref": "#/$defs/HostHooks", + "default": { + "after": [], + "before": [] + } + } + }, + "type": "object" + }, + "HostConflict": { + "enum": [ + "fail", + "adopt" + ], + "type": "string" + }, + "HostEnvTarget": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "host", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workload", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "kind", + "name" + ], + "type": "object" + } + ] + }, + "HostExit": { + "enum": [ + "stop_on_down" + ], + "type": "string" + }, + "HostHook": { + "additionalProperties": false, + "properties": { + "command": { + "$ref": "#/$defs/Command" + }, + "cwd": { + "default": ".", + "type": "string" + }, + "env_target": { + "$ref": "#/$defs/HostEnvTarget", + "default": { + "kind": "host" + } + }, + "environment": { + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + }, + "default": {}, + "type": "object" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "command" + ], + "type": "object" + }, + "HostHooks": { + "additionalProperties": false, + "properties": { + "after": { + "default": [], + "items": { + "$ref": "#/$defs/HostHook" + }, + "type": "array" + }, + "before": { + "default": [], + "items": { + "$ref": "#/$defs/HostHook" + }, + "type": "array" + } + }, + "type": "object" + }, + "HostProcess": { + "additionalProperties": false, + "properties": { + "command": { + "$ref": "#/$defs/Command" + }, + "cwd": { + "default": ".", + "type": "string" + }, + "env_target": { + "$ref": "#/$defs/HostEnvTarget", + "default": { + "kind": "host" + } + }, + "environment": { + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + }, + "default": {}, + "type": "object" + }, + "exit": { + "$ref": "#/$defs/HostExit", + "default": "stop_on_down" + }, + "singleton": { + "$ref": "#/$defs/HostSingleton" + }, + "startup": { + "$ref": "#/$defs/HostStartup", + "default": "up" + } + }, + "required": [ + "command" + ], + "type": "object" + }, + "HostSingleton": { + "additionalProperties": false, + "properties": { + "on_conflict": { + "$ref": "#/$defs/HostConflict", + "default": "fail" + }, + "ports": { + "items": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "ports" + ], + "type": "object" + }, + "HostStartup": { + "enum": [ + "up" + ], + "type": "string" + }, "JobCondition": { "enum": [ "completed" @@ -470,6 +667,9 @@ "$ref": "#/$defs/EnvironmentSelection", "default": {} }, + "host": { + "$ref": "#/$defs/HostConfig" + }, "jobs": { "additionalProperties": { "$ref": "#/$defs/Workload" diff --git a/packages/config-compiler/generated/native-config.ts b/packages/config-compiler/generated/native-config.ts index 01f824ea3..71fd1b3d0 100644 --- a/packages/config-compiler/generated/native-config.ts +++ b/packages/config-compiler/generated/native-config.ts @@ -1,10 +1,22 @@ // 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, }; +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 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 = { env_metadata: EnvMetadata, request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, }; export type EnvironmentBinding = { "kind": "managed", key: string, scope: string, secret: boolean, } | { "kind": "literal", value: string, } | { "kind": "default", value: string, }; -export type EnvironmentPlan = { plan_version: 1, overlay: string | null, overlay_exists: boolean, complete: boolean, workloads: { [key in string]: { [key in string]: EnvironmentBinding } }, warnings: Array, diagnostics: Array, }; -export type PlanResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, local_resolution: LocalResolution, environment_plan: EnvironmentPlan, } | { transport_version: 1, ok: false, diagnostics: Array, }; +export type EnvironmentPlan = { plan_version: 1, overlay: string | null, overlay_exists: boolean, complete: boolean, workloads: { [key in string]: { [key in string]: EnvironmentBinding } }, host?: { [key in string]: HostEnvironmentPlan }, warnings: Array, diagnostics: Array, }; +export type PlanResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, local_resolution: LocalResolution, environment_plan: EnvironmentPlan, } | { transport_version: 1, ok: false, diagnostics: Array, }; +export type HostConfig = { up?: HostHooks, down?: HostHooks, processes?: { [key in string]: HostProcess }, }; +export type HostHooks = { before?: Array, after?: Array, }; +export type HostHook = { name: string, command: Command, cwd?: string, environment?: { [key in string]: EnvironmentValue }, env_target?: HostEnvTarget, }; +export type HostProcess = { command: Command, cwd?: string, environment?: { [key in string]: EnvironmentValue }, env_target?: HostEnvTarget, startup?: HostStartup, exit?: HostExit, singleton?: HostSingleton, }; +export type HostEnvTarget = { "kind": "host", } | { "kind": "workload", name: string, }; +export type HostStartup = "up"; +export type HostExit = "stop_on_down"; +export type HostSingleton = { ports: Array, on_conflict?: HostConflict, }; +export type HostConflict = "fail" | "adopt"; +export type HostEnvTargets = { include_default: boolean, workloads: Array, }; export type WorktreePolicy = { auto_branch?: boolean, inherit_local?: boolean, }; export type SourceMode = "host-mounted"; export type Source = { root?: string, mode?: SourceMode, }; @@ -23,11 +35,11 @@ export type JobCondition = "completed"; export type Dependency = { service: string, condition: ServiceCondition, } | { job: string, condition: JobCondition, }; export type Readiness = { "kind": "exec", command: Command, interval: string, timeout: string, retries: number, } | { "kind": "http", port: number, path: string, interval: string, timeout: string, retries: number, } | { "kind": "tcp", port: number, interval: string, timeout: string, retries: number, }; export type Workload = { image?: string, build?: Build, command?: Command, working_directory?: string, mounts?: Array, environment?: { [key in string]: EnvironmentValue }, depends_on?: Array, profiles?: Array, readiness?: Readiness, }; -export type Project = { schema_version: 1, name: string, source?: Source, services?: { [key in string]: Workload }, jobs?: { [key in string]: Workload }, storage?: { [key in string]: Storage }, profiles?: Array, environment?: EnvironmentSelection, worktree?: WorktreePolicy, }; -export type Plan = { plan_version: 1, name: string, source: Source, environment: EnvironmentSelection, worktree: WorktreePolicy, selected_profiles: Array, storage: { [key in string]: Storage }, services: { [key in string]: Workload }, jobs: { [key in string]: Workload }, }; +export type Project = { schema_version: 1, name: string, source?: Source, services?: { [key in string]: Workload }, jobs?: { [key in string]: Workload }, storage?: { [key in string]: Storage }, profiles?: Array, environment?: EnvironmentSelection, worktree?: WorktreePolicy, host?: HostConfig, }; +export type Plan = { plan_version: 1, name: string, source: Source, environment: EnvironmentSelection, worktree: WorktreePolicy, host?: HostConfig, selected_profiles: Array, storage: { [key in string]: Storage }, services: { [key in string]: Workload }, jobs: { [key in string]: Workload }, }; export type Diagnostic = { code: string, message: string, pointer: string, line: number, column: number, }; export type WorkloadKind = "service" | "job"; -export type CompileResult = { transport_version: 1, ok: true, plan: Plan, declared_workloads: { [key in string]: WorkloadKind }, semantic_hash: string, } | { transport_version: 1, ok: false, diagnostics: Array, }; +export type CompileResult = { transport_version: 1, ok: true, plan: Plan, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, semantic_hash: string, } | { transport_version: 1, ok: false, diagnostics: Array, }; export type LocalEnvironment = { default_overlay?: string | null, }; export type LocalConfig = { schema_version: 1, environment?: LocalEnvironment, }; export type ResolveRequest = { request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, }; @@ -35,4 +47,4 @@ export type DocumentRole = "project" | "primary_local" | "checkout_local" | "req export type ResolveDiagnostic = { document: DocumentRole, code: string, message: string, pointer: string, line: number, column: number, }; export type OverlayOrigin = "project" | "primary_local" | "checkout_local" | "explicit"; export type LocalResolution = { overlay: string | null, origin: OverlayOrigin, auto_branch: boolean, inherit_local: boolean, resolution_hash: string, }; -export type ResolveResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, local_resolution: LocalResolution, } | { transport_version: 1, ok: false, diagnostics: Array, }; +export type ResolveResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, local_resolution: LocalResolution, } | { transport_version: 1, ok: false, diagnostics: Array, }; diff --git a/packages/config-compiler/src/environment.rs b/packages/config-compiler/src/environment.rs index ce7de12cf..21dffe06e 100644 --- a/packages/config-compiler/src/environment.rs +++ b/packages/config-compiler/src/environment.rs @@ -27,6 +27,32 @@ pub struct EnvMetadata { pub overlay_exists: bool, pub workloads: BTreeMap>, pub inactive_scopes: Vec, + #[serde( + default, + deserialize_with = "crate::model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "HostMetadata")] + #[ts(optional, type = "HostMetadata")] + pub host: Option, +} +#[derive(Debug, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct HostMetadata { + #[serde( + default, + deserialize_with = "crate::model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "BTreeMap")] + #[ts(optional, type = "{ [key in string]: ManagedBindingMetadata }")] + pub default: Option>, + pub workloads: BTreeMap>, +} +#[derive(Debug, Serialize, JsonSchema, TS)] +pub struct HostEnvironmentPlan { + pub env_target: crate::host::HostEnvTarget, + pub bindings: BTreeMap, } /// Projection only; parsing retains strict resolve fields and separately validates metadata. #[derive(Debug, Serialize, JsonSchema, TS)] @@ -58,6 +84,9 @@ pub struct EnvironmentPlan { pub overlay_exists: bool, pub complete: bool, pub workloads: BTreeMap>, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "{ [key in string]: HostEnvironmentPlan }")] + pub host: Option>, pub warnings: Vec, pub diagnostics: Vec, } @@ -72,8 +101,11 @@ pub enum PlanResult { plan: Box, semantic_hash: String, declared_workloads: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "HostEnvTargets")] + host_env_targets: Option, local_resolution: local::LocalResolution, - environment_plan: EnvironmentPlan, + environment_plan: Box, }, Failure { #[ts(type = "1")] @@ -101,11 +133,17 @@ pub fn plan(bytes: &[u8], profiles: &[String]) -> PlanResult { Ok((resolved, environment_plan)) => PlanResult::Success { transport_version: 1, ok: true, + host_env_targets: resolved + .compiled + .plan + .host + .as_ref() + .map(crate::host::HostConfig::targets), plan: Box::new(resolved.compiled.plan), semantic_hash: resolved.compiled.semantic_hash, declared_workloads: resolved.compiled.declared_workloads, local_resolution: resolved.local_resolution, - environment_plan, + environment_plan: Box::new(environment_plan), }, Err(error) => PlanResult::failure(error), } @@ -146,6 +184,21 @@ fn read_metadata(document: &mut json::Document) -> Result Result bool { + value + .as_object() + .is_some_and(|bindings| bindings.values().all(serde_json::Value::is_object)) +} +fn validate_host_metadata(metadata: &EnvMetadata, resolved: &local::Resolved) -> bool { + let Some(host) = &resolved.compiled.plan.host else { + return metadata.host.is_none(); + }; + let Some(metadata) = &metadata.host else { + return false; + }; + let targets = host.targets(); + if metadata.default.is_some() != targets.include_default + || metadata.workloads.keys().ne(targets.workloads.iter()) + { + return false; + } + let generic_host = !resolved.compiled.declared_workloads.contains_key("host"); + let valid = |bindings: &BTreeMap, target: Option<&str>| { + bindings.iter().all(|(key, b)| { + valid_key(key) + && (b.scope == "global" + || target == Some(b.scope.as_str()) + || (generic_host && b.scope == "host")) + }) + }; + if metadata + .default + .as_ref() + .is_some_and(|bindings| !valid(bindings, None)) + { + return false; + } + metadata + .workloads + .iter() + .all(|(name, bindings)| valid(bindings, Some(name))) +} fn valid_scope(name: &str) -> bool { !name.is_empty() && name.bytes().enumerate().all(|(i, b)| { @@ -187,6 +279,9 @@ fn validate_metadata(metadata: &EnvMetadata, resolved: &local::Resolved) -> bool return false; } } + if !validate_host_metadata(metadata, resolved) { + return false; + } let mut seen = BTreeSet::new(); !metadata.inactive_scopes.iter().any(|scope| { !valid_scope(scope) @@ -210,7 +305,7 @@ fn plan_inner( metadata_location, )); } - let output = bind(&resolved, metadata); + let output = bind(&resolved, metadata)?; Ok((resolved, output)) } fn project_diagnostic( @@ -230,13 +325,18 @@ fn managed(key: &str, binding: &ManagedBindingMetadata) -> EnvironmentBinding { secret: binding.secret, } } -fn bind(resolved: &local::Resolved, metadata: EnvMetadata) -> EnvironmentPlan { +fn bind( + resolved: &local::Resolved, + metadata: EnvMetadata, +) -> Result { + let mut budget = ReportBudget::new(resolved, &metadata)?; let mut output = EnvironmentPlan { plan_version: 1, overlay: metadata.overlay.clone(), overlay_exists: metadata.overlay_exists, complete: true, workloads: BTreeMap::new(), + host: None, warnings: Vec::new(), diagnostics: Vec::new(), }; @@ -257,61 +357,243 @@ fn bind(resolved: &local::Resolved, metadata: EnvMetadata) -> EnvironmentPlan { let Some(baseline) = metadata.workloads.get(name) else { continue; }; - let mut bindings: BTreeMap = baseline - .iter() - .map(|(key, b)| (key.clone(), managed(key, b))) - .collect(); - for (dest, directive) in &workload.environment { - let pointer = json::child( - &format!("{}/environment", json::child(&format!("/{kind}"), name)), - dest, - ); - match directive { - EnvironmentValue::Literal { literal } => { - bindings.insert( - dest.clone(), - EnvironmentBinding::Literal { - value: literal.clone(), - }, - ); - } - EnvironmentValue::Default { default } => { - if !baseline.contains_key(dest) { - bindings.insert( - dest.clone(), - EnvironmentBinding::Default { - value: default.clone(), - }, - ); - } - } - EnvironmentValue::Unset { .. } => { - bindings.remove(dest); + let pointer = json::child(&format!("/{kind}"), name); + budget.value(name, resolved, &pointer)?; + budget.charge(16, resolved, &pointer)?; + let bindings = bind_environment( + resolved, + baseline, + &workload.environment, + &pointer, + &mut output.diagnostics, + &mut budget, + &format!("workload/{name}"), + )?; + output.workloads.insert(name.clone(), bindings); + } + } + if let (Some(host), Some(host_metadata)) = (&resolved.compiled.plan.host, &metadata.host) { + let mut plans = BTreeMap::new(); + for entry in host.entries() { + let baseline = match entry.target { + crate::host::HostEnvTarget::Host {} => host_metadata.default.as_ref(), + crate::host::HostEnvTarget::Workload { name } => host_metadata.workloads.get(name), + }; + if let Some(baseline) = baseline { + budget.value(entry.name, resolved, &entry.pointer)?; + budget.value(entry.target, resolved, &entry.pointer)?; + budget.charge(128, resolved, &entry.pointer)?; + let cache_key = match entry.target { + crate::host::HostEnvTarget::Host {} => "host/default".to_owned(), + crate::host::HostEnvTarget::Workload { name } => { + format!("host/workload/{name}") } - EnvironmentValue::Reference { env_ref } => { - if let Some(binding) = baseline.get(env_ref) { - if env_ref != dest && baseline.contains_key(dest) { - output.diagnostics.push(project_diagnostic( - resolved, - "env_reference_collision", - &pointer, - )); - } else { - bindings.insert(dest.clone(), managed(env_ref, binding)); - } - } else { - output.diagnostics.push(project_diagnostic( - resolved, - "missing_env_reference", - &pointer, - )); - } + }; + let bindings = bind_environment( + resolved, + baseline, + entry.environment, + &entry.pointer, + &mut output.diagnostics, + &mut budget, + &cache_key, + )?; + plans.insert( + entry.name.to_owned(), + HostEnvironmentPlan { + env_target: entry.target.clone(), + bindings, + }, + ); + } + } + output.host = Some(plans); + } + output.complete = output.diagnostics.is_empty(); + Ok(output) +} + +fn bind_environment( + resolved: &local::Resolved, + baseline: &BTreeMap, + directives: &BTreeMap, + pointer: &str, + diagnostics: &mut Vec, + budget: &mut ReportBudget, + baseline_key: &str, +) -> Result, local::ResolveDiagnostic> { + budget.baseline(baseline_key, baseline, resolved, pointer)?; + let mut bindings: BTreeMap = baseline + .iter() + .map(|(key, b)| (key.clone(), managed(key, b))) + .collect(); + for (dest, directive) in directives { + let pointer = json::child(&format!("{pointer}/environment"), dest); + // Conservatively retain charges for overwritten/unset entries rather than refunding allocation. + budget.value(dest, resolved, &pointer)?; + budget.value(directive, resolved, &pointer)?; + budget.charge(64, resolved, &pointer)?; + match directive { + EnvironmentValue::Literal { literal } => { + bindings.insert( + dest.clone(), + EnvironmentBinding::Literal { + value: literal.clone(), + }, + ); + } + EnvironmentValue::Default { default } => { + if !baseline.contains_key(dest) { + bindings.insert( + dest.clone(), + EnvironmentBinding::Default { + value: default.clone(), + }, + ); + } + } + EnvironmentValue::Unset { .. } => { + bindings.remove(dest); + } + EnvironmentValue::Reference { env_ref } => { + if let Some(binding) = baseline.get(env_ref) { + if env_ref != dest && baseline.contains_key(dest) { + let diagnostic = + project_diagnostic(resolved, "env_reference_collision", &pointer); + budget.value(&diagnostic, resolved, &pointer)?; + diagnostics.push(diagnostic); + } else { + budget.value( + &BorrowedManaged::new(env_ref, binding), + resolved, + &pointer, + )?; + bindings.insert(dest.clone(), managed(env_ref, binding)); } + } else { + let diagnostic = + project_diagnostic(resolved, "missing_env_reference", &pointer); + budget.value(&diagnostic, resolved, &pointer)?; + diagnostics.push(diagnostic); } } - output.workloads.insert(name.clone(), bindings); } } - output.complete = output.diagnostics.is_empty(); - output + Ok(bindings) +} + +/// Shared whole-response safety ceiling, not a process count or runtime resource limit. +pub const MAX_PLAN_OUTPUT_BYTES: usize = 8 * 1024 * 1024; +#[derive(Serialize)] +struct BorrowedManaged<'a> { + kind: &'static str, + key: &'a str, + scope: &'a str, + secret: bool, +} +impl<'a> BorrowedManaged<'a> { + fn new(key: &'a str, binding: &'a ManagedBindingMetadata) -> Self { + Self { + kind: "managed", + key, + scope: &binding.scope, + secret: binding.secret, + } + } +} +struct CountingWriter { + bytes: usize, +} +impl std::io::Write for CountingWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.bytes = self + .bytes + .checked_add(bytes.len()) + .filter(|size| *size <= MAX_PLAN_OUTPUT_BYTES) + .ok_or_else(|| std::io::Error::other("plan size limit"))?; + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} +fn serialized_size(value: &(impl Serialize + ?Sized)) -> Result { + let mut counter = CountingWriter { bytes: 0 }; + serde_json::to_writer(&mut counter, value).map_err(|_| ())?; + Ok(counter.bytes) +} +struct ReportBudget { + remaining: usize, + baselines: BTreeMap, +} +impl ReportBudget { + fn new( + resolved: &local::Resolved, + metadata: &EnvMetadata, + ) -> Result { + let mut result = Self { + remaining: MAX_PLAN_OUTPUT_BYTES, + baselines: BTreeMap::new(), + }; + // Includes fixed envelope/map/array punctuation, warning diagnostics and trailing newline. + result.charge(4096, resolved, "")?; + result.value(&resolved.compiled.plan, resolved, "")?; + result.value(&resolved.compiled.semantic_hash, resolved, "")?; + result.value(&resolved.compiled.declared_workloads, resolved, "")?; + result.value(&resolved.local_resolution, resolved, "")?; + result.value(&metadata.overlay, resolved, "")?; + if let Some(host) = &resolved.compiled.plan.host { + result.value(&host.targets(), resolved, "")?; + } + Ok(result) + } + fn charge( + &mut self, + bytes: usize, + resolved: &local::Resolved, + pointer: &str, + ) -> Result<(), local::ResolveDiagnostic> { + self.remaining = self + .remaining + .checked_sub(bytes) + .ok_or_else(|| project_diagnostic(resolved, "plan_too_large", pointer))?; + Ok(()) + } + fn value( + &mut self, + value: &(impl Serialize + ?Sized), + resolved: &local::Resolved, + pointer: &str, + ) -> Result<(), local::ResolveDiagnostic> { + let bytes = serialized_size(value) + .map_err(|_| project_diagnostic(resolved, "plan_too_large", pointer))?; + self.charge(bytes, resolved, pointer) + } + fn baseline( + &mut self, + key: &str, + baseline: &BTreeMap, + resolved: &local::Resolved, + pointer: &str, + ) -> Result<(), local::ResolveDiagnostic> { + let bytes = if let Some(bytes) = self.baselines.get(key) { + *bytes + } else { + let mut bytes = 2; + for (name, binding) in baseline { + bytes += serialized_size(name) + .and_then(|name_size| { + serialized_size(&BorrowedManaged::new(name, binding)) + .map(|binding_size| name_size + binding_size + 2) + }) + .map_err(|_| project_diagnostic(resolved, "plan_too_large", pointer))?; + if bytes > MAX_PLAN_OUTPUT_BYTES { + return Err(project_diagnostic(resolved, "plan_too_large", pointer)); + } + } + self.baselines.insert(key.to_owned(), bytes); + bytes + }; + self.charge(bytes, resolved, pointer) + } } diff --git a/packages/config-compiler/src/host.rs b/packages/config-compiler/src/host.rs new file mode 100644 index 000000000..2cfcd5a72 --- /dev/null +++ b/packages/config-compiler/src/host.rs @@ -0,0 +1,246 @@ +//! Typed host intent only. No process execution, port observation or adoption occurs here. +use crate::{ + Diagnostic, + json::child, + model::{Command, EnvironmentValue}, + validate, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use ts_rs::TS; + +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct HostConfig { + #[serde(default)] + #[ts(optional, as = "Option")] + pub up: HostHooks, + #[serde(default)] + #[ts(optional, as = "Option")] + pub down: HostHooks, + #[serde(default)] + #[ts(optional, as = "Option>")] + pub processes: BTreeMap, +} +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct HostHooks { + #[serde(default)] + #[ts(optional, as = "Option>")] + pub before: Vec, + #[serde(default)] + #[ts(optional, as = "Option>")] + pub after: Vec, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct HostHook { + pub name: String, + pub command: Command, + #[serde(default = "crate::model::dot")] + #[ts(optional, as = "Option")] + pub cwd: String, + #[serde(default)] + #[ts(optional, as = "Option>")] + pub environment: BTreeMap, + #[serde(default)] + #[ts(optional, as = "Option")] + pub env_target: HostEnvTarget, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct HostProcess { + pub command: Command, + #[serde(default = "crate::model::dot")] + #[ts(optional, as = "Option")] + pub cwd: String, + #[serde(default)] + #[ts(optional, as = "Option>")] + pub environment: BTreeMap, + #[serde(default)] + #[ts(optional, as = "Option")] + pub env_target: HostEnvTarget, + #[serde(default)] + #[ts(optional, as = "Option")] + pub startup: HostStartup, + #[serde(default)] + #[ts(optional, as = "Option")] + pub exit: HostExit, + #[serde( + default, + deserialize_with = "crate::model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "HostSingleton")] + #[ts(optional, type = "HostSingleton")] + pub singleton: Option, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum HostEnvTarget { + Host {}, + Workload { name: String }, +} +impl Default for HostEnvTarget { + fn default() -> Self { + Self::Host {} + } +} +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum HostStartup { + #[default] + Up, +} +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum HostExit { + #[default] + StopOnDown, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct HostSingleton { + #[schemars(length(min=1),extend("uniqueItems"=true))] + pub ports: Vec, + #[serde(default)] + #[ts(optional, as = "Option")] + pub on_conflict: HostConflict, +} +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum HostConflict { + #[default] + Fail, + Adopt, +} +#[derive(Debug, Clone, Serialize, JsonSchema, TS)] +pub struct HostEnvTargets { + pub include_default: bool, + pub workloads: Vec, +} + +pub(crate) struct Entry<'a> { + pub name: &'a str, + pub pointer: String, + pub environment: &'a BTreeMap, + pub target: &'a HostEnvTarget, +} +impl HostConfig { + pub(crate) fn entries(&self) -> Vec> { + let mut result = Vec::new(); + for (phase, hooks) in [("up", &self.up), ("down", &self.down)] { + for (stage, items) in [("before", &hooks.before), ("after", &hooks.after)] { + for (i, hook) in items.iter().enumerate() { + result.push(Entry { + name: &hook.name, + pointer: format!("/host/{phase}/{stage}/{i}"), + environment: &hook.environment, + target: &hook.env_target, + }); + } + } + } + for (name, process) in &self.processes { + result.push(Entry { + name, + pointer: child("/host/processes", name), + environment: &process.environment, + target: &process.env_target, + }); + } + result + } + pub(crate) fn targets(&self) -> HostEnvTargets { + let mut include_default = false; + let mut workloads = BTreeSet::new(); + for entry in self.entries() { + match entry.target { + HostEnvTarget::Host {} => include_default = true, + HostEnvTarget::Workload { name } => { + workloads.insert(name.clone()); + } + } + } + HostEnvTargets { + include_default, + workloads: workloads.into_iter().collect(), + } + } + pub(crate) fn empty(&self) -> bool { + self.up.before.is_empty() + && self.up.after.is_empty() + && self.down.before.is_empty() + && self.down.after.is_empty() + && self.processes.is_empty() + } +} + +pub(crate) fn normalize( + host: &mut HostConfig, + workloads: &BTreeSet, + at: &dyn Fn(&str, &str) -> Diagnostic, +) -> Result<(), Diagnostic> { + let mut names = BTreeSet::new(); + for entry in host.entries() { + if !validate::name(entry.name) || !names.insert(entry.name.to_owned()) { + return Err(at("invalid_name", &entry.pointer)); + } + if let HostEnvTarget::Workload { name } = entry.target + && !workloads.contains(name) + { + return Err(at( + "unknown_env_target", + &child(&entry.pointer, "env_target"), + )); + } + } + for (phase, hooks) in [("up", &mut host.up), ("down", &mut host.down)] { + for (stage, items) in [("before", &mut hooks.before), ("after", &mut hooks.after)] { + for (i, hook) in items.iter_mut().enumerate() { + invocation( + &hook.command, + &mut hook.cwd, + &hook.environment, + &format!("/host/{phase}/{stage}/{i}"), + at, + )?; + } + } + } + for (name, process) in &mut host.processes { + let pointer = child("/host/processes", name); + invocation( + &process.command, + &mut process.cwd, + &process.environment, + &pointer, + at, + )?; + if let Some(singleton) = &mut process.singleton { + let mut ports = BTreeSet::new(); + if singleton.ports.is_empty() + || singleton.ports.iter().any(|p| *p == 0 || !ports.insert(*p)) + { + return Err(at( + "invalid_singleton", + &format!("{pointer}/singleton/ports"), + )); + } + singleton.ports.sort(); + } + } + Ok(()) +} +fn invocation( + command: &Command, + cwd: &mut String, + environment: &BTreeMap, + pointer: &str, + at: &dyn Fn(&str, &str) -> Diagnostic, +) -> Result<(), Diagnostic> { + validate::command(command, &child(pointer, "command"), at)?; + *cwd = validate::relative(cwd).ok_or_else(|| at("invalid_path", &child(pointer, "cwd")))?; + validate::environment(environment, pointer, at) +} diff --git a/packages/config-compiler/src/lib.rs b/packages/config-compiler/src/lib.rs index 9874af923..29626684f 100644 --- a/packages/config-compiler/src/lib.rs +++ b/packages/config-compiler/src/lib.rs @@ -1,5 +1,6 @@ //! Pure, bounded native configuration compiler. It performs no host admission or secret lookup. pub mod environment; +pub mod host; mod json; pub mod local; pub mod model; @@ -50,6 +51,7 @@ fn diagnostic_message(code: &str) -> &'static str { "Stored environment scopes outside the declared workload namespace are inactive." } "unsupported_request_version" => "The resolution request version is not supported.", + "plan_too_large" => "The expanded plan exceeds the compiler output safety limit.", "input_too_large" => "Configuration exceeds the compiler input byte limit.", "invalid_utf8" => "Configuration must be UTF-8.", "invalid_json" => "Configuration is not a complete valid JSON document.", @@ -60,6 +62,8 @@ fn diagnostic_message(code: &str) -> &'static str { "invalid_shape" => "The value does not match the supported configuration shape.", "invalid_name" => "Use a unique canonical name within the declared namespace.", "invalid_path" => "The path must use the required portable relative or absolute form.", + "unknown_env_target" => "The host environment target must name a declared workload.", + "invalid_singleton" => "Singleton ports must be distinct nonzero ports.", "unknown_profile" => "The profile must be declared by the project.", "duplicate_workload" => "Services and jobs must have distinct names.", "unknown_dependency" => { @@ -97,6 +101,9 @@ pub enum CompileResult { ok: bool, plan: Box, declared_workloads: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "HostEnvTargets")] + host_env_targets: Option, semantic_hash: String, }, Failure { @@ -123,6 +130,7 @@ pub fn compile(bytes: &[u8], profiles: &[String]) -> CompileResult { Ok(compiled) => CompileResult::Success { transport_version: 1, ok: true, + host_env_targets: compiled.plan.host.as_ref().map(host::HostConfig::targets), plan: Box::new(compiled.plan), declared_workloads: compiled.declared_workloads, semantic_hash: compiled.semantic_hash, @@ -223,15 +231,29 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { {"required":["image"], "not":{"required":["build"]}}, {"required":["build"], "not":{"required":["image"]}} ]); + schema["$defs"]["HostSingleton"]["properties"]["ports"]["items"]["minimum"] = + serde_json::json!(1); let schema = serde_json::to_string_pretty(&schema)? + "\n"; let cfg = ts_rs::Config::default(); let declarations = [ environment::ManagedBindingMetadata::decl(&cfg), environment::EnvMetadata::decl(&cfg), + environment::HostMetadata::decl(&cfg), + environment::HostEnvironmentPlan::decl(&cfg), environment::EnvPlanRequest::decl(&cfg), environment::EnvironmentBinding::decl(&cfg), environment::EnvironmentPlan::decl(&cfg), environment::PlanResult::decl(&cfg), + host::HostConfig::decl(&cfg), + host::HostHooks::decl(&cfg), + host::HostHook::decl(&cfg), + host::HostProcess::decl(&cfg), + host::HostEnvTarget::decl(&cfg), + host::HostStartup::decl(&cfg), + host::HostExit::decl(&cfg), + host::HostSingleton::decl(&cfg), + host::HostConflict::decl(&cfg), + host::HostEnvTargets::decl(&cfg), WorktreePolicy::decl(&cfg), SourceMode::decl(&cfg), Source::decl(&cfg), @@ -278,5 +300,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}) + 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}) } diff --git a/packages/config-compiler/src/local.rs b/packages/config-compiler/src/local.rs index 536cae0e6..70588ebb0 100644 --- a/packages/config-compiler/src/local.rs +++ b/packages/config-compiler/src/local.rs @@ -113,6 +113,9 @@ pub enum ResolveResult { plan: Box, semantic_hash: String, declared_workloads: std::collections::BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "HostEnvTargets")] + host_env_targets: Option, local_resolution: LocalResolution, }, Failure { @@ -227,6 +230,12 @@ pub fn resolve(bytes: &[u8], profiles: &[String]) -> ResolveResult { Ok(resolved) => ResolveResult::Success { transport_version: 1, ok: true, + host_env_targets: resolved + .compiled + .plan + .host + .as_ref() + .map(crate::host::HostConfig::targets), plan: Box::new(resolved.compiled.plan), semantic_hash: resolved.compiled.semantic_hash, declared_workloads: resolved.compiled.declared_workloads, diff --git a/packages/config-compiler/src/model.rs b/packages/config-compiler/src/model.rs index 893578f05..2dbcd0416 100644 --- a/packages/config-compiler/src/model.rs +++ b/packages/config-compiler/src/model.rs @@ -3,10 +3,12 @@ use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; use ts_rs::TS; -fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { +pub(crate) fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + d: D, +) -> Result, D::Error> { T::deserialize(d).map(Some) } -fn dot() -> String { +pub(crate) fn dot() -> String { ".".into() } @@ -38,6 +40,14 @@ pub struct Project { #[serde(default)] #[ts(as = "Option", optional)] pub worktree: WorktreePolicy, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "crate::host::HostConfig")] + #[ts(optional, type = "HostConfig")] + pub host: Option, } fn enabled() -> bool { @@ -333,6 +343,9 @@ pub struct Plan { pub source: Source, pub environment: EnvironmentSelection, pub worktree: WorktreePolicy, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "HostConfig")] + pub host: Option, pub selected_profiles: Vec, pub storage: BTreeMap, pub services: BTreeMap, diff --git a/packages/config-compiler/src/shape.rs b/packages/config-compiler/src/shape.rs index a6d9b876f..d785bac87 100644 --- a/packages/config-compiler/src/shape.rs +++ b/packages/config-compiler/src/shape.rs @@ -62,5 +62,46 @@ pub(crate) fn project(document: &json::Document) -> Result<(), Diagnostic> { } } } + optional_object(document, "/host")?; + for phase in ["up", "down"] { + optional_object(document, &format!("/host/{phase}"))?; + for stage in ["before", "after"] { + let pointer = format!("/host/{phase}/{stage}"); + if let Some(items) = document.value.pointer(&pointer).and_then(Value::as_array) { + for index in 0..items.len() { + host_invocation(document, &format!("{pointer}/{index}"))?; + } + } + } + } + if let Some(processes) = document + .value + .pointer("/host/processes") + .and_then(Value::as_object) + { + for name in processes.keys() { + host_invocation(document, &json::child("/host/processes", name))?; + } + } + Ok(()) +} + +fn host_invocation(document: &json::Document, pointer: &str) -> Result<(), Diagnostic> { + object(document, pointer)?; + for field in ["command", "env_target", "singleton"] { + optional_object(document, &json::child(pointer, field))?; + } + if let Some(environment) = document + .value + .pointer(&format!("{pointer}/environment")) + .and_then(Value::as_object) + { + for name in environment.keys() { + object( + document, + &json::child(&format!("{pointer}/environment"), name), + )?; + } + } Ok(()) } diff --git a/packages/config-compiler/src/validate.rs b/packages/config-compiler/src/validate.rs index a60f4ff66..4d977e0fb 100644 --- a/packages/config-compiler/src/validate.rs +++ b/packages/config-compiler/src/validate.rs @@ -2,7 +2,7 @@ use crate::{Diagnostic, json::child, model::*}; use std::collections::{BTreeMap, BTreeSet}; type At<'a> = dyn Fn(&str, &str) -> Diagnostic + 'a; -fn name(value: &str) -> bool { +pub(crate) fn name(value: &str) -> bool { !value.is_empty() && value.len() <= 63 && value.bytes().enumerate().all(|(i, c)| { @@ -37,7 +37,7 @@ fn managed_key(value: &str) -> bool { .enumerate() .all(|(i, b)| b.is_ascii_uppercase() || b == b'_' || (i > 0 && b.is_ascii_digit())) } -fn relative(value: &str) -> Option { +pub(crate) fn relative(value: &str) -> Option { if value.starts_with('/') || value.contains(['\\', '\0', ':']) || value.is_empty() { return None; } @@ -88,7 +88,7 @@ fn duration(value: &str) -> Option { let n = digits.parse::().ok()?.checked_mul(factor)?; (n > 0 && n <= u32::MAX as u64).then(|| format!("{n}ms")) } -fn command(command: &Command, pointer: &str, at: &At) -> Result<(), Diagnostic> { +pub(crate) fn command(command: &Command, pointer: &str, at: &At) -> Result<(), Diagnostic> { let valid = match command { Command::Exec { exec } => { !exec.is_empty() && !exec[0].is_empty() && exec.iter().all(|s| !s.contains('\0')) @@ -185,6 +185,14 @@ pub fn lower(mut project: Project, profiles: &[String], at: &At) -> Result Result { - return Err(at("invalid_environment_key", &child(&path, "env_ref"))); - } - EnvironmentValue::Literal { literal: value } - | EnvironmentValue::Default { default: value } - if value.contains('\0') => - { - return Err(at("invalid_environment_value", &path)); - } - _ => {} - } - } + environment(&workload.environment, pointer, at)?; let mut targets = BTreeSet::new(); for (index, mount) in workload.mounts.iter_mut().enumerate() { let path = format!("{pointer}/mounts/{index}"); @@ -383,3 +375,29 @@ fn check_cycles(all: &BTreeMap<&str, (&str, &Workload)>, at: &At) -> Result<(), } Ok(()) } + +pub(crate) fn environment( + environment: &BTreeMap, + pointer: &str, + at: &At, +) -> Result<(), Diagnostic> { + for (key, value) in environment { + let path = child(&child(pointer, "environment"), key); + if !env_name(key) { + return Err(at("invalid_environment_key", &path)); + } + match value { + EnvironmentValue::Reference { env_ref } if !managed_key(env_ref) => { + return Err(at("invalid_environment_key", &child(&path, "env_ref"))); + } + EnvironmentValue::Literal { literal: value } + | EnvironmentValue::Default { default: value } + if value.contains('\0') => + { + return Err(at("invalid_environment_value", &path)); + } + _ => {} + } + } + Ok(()) +} diff --git a/packages/config-compiler/tests/fixtures/schema-corpus.json b/packages/config-compiler/tests/fixtures/schema-corpus.json index 5fa979b6c..2f1144df6 100644 --- a/packages/config-compiler/tests/fixtures/schema-corpus.json +++ b/packages/config-compiler/tests/fixtures/schema-corpus.json @@ -1327,5 +1327,695 @@ } } } + }, + { + "name": "host empty", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "host": {} + } + }, + { + "name": "host hook", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "up": { + "before": [ + { + "name": "prepare", + "command": { + "exec": ["true"] + } + } + ] + } + } + } + }, + { + "name": "host process", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + } + } + } + } + } + }, + { + "name": "host all stages", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "up": { + "after": [ + { + "name": "prepare", + "command": { + "exec": ["true"] + } + } + ] + }, + "down": { + "before": [ + { + "name": "clean", + "command": { + "exec": ["true"] + } + } + ] + } + } + } + }, + { + "name": "host ports min max", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "singleton": { + "ports": [1, 65535] + } + } + } + } + } + }, + { + "name": "host root null", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": null + } + }, + { + "name": "host up null", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "up": null + } + } + }, + { + "name": "host down null", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "down": null + } + } + }, + { + "name": "host process null", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": null + } + } + } + }, + { + "name": "host hook null", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "up": { + "before": [null] + } + } + } + }, + { + "name": "host env_target null", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "env_target": null + } + } + } + } + }, + { + "name": "host command null", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": null + } + } + } + } + }, + { + "name": "host singleton null", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "singleton": null + } + } + } + } + }, + { + "name": "host environment directive null", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "environment": { + "KEY": null + } + } + } + } + } + }, + { + "name": "host root array", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": [] + } + }, + { + "name": "host up array", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "up": [] + } + } + }, + { + "name": "host down array", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "down": [] + } + } + }, + { + "name": "host process array", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": [] + } + } + } + }, + { + "name": "host hook array", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "up": { + "before": [[]] + } + } + } + }, + { + "name": "host env_target array", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "env_target": [] + } + } + } + } + }, + { + "name": "host command array", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": [] + } + } + } + } + }, + { + "name": "host singleton array", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "singleton": [] + } + } + } + } + }, + { + "name": "host environment directive array", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "environment": { + "KEY": [] + } + } + } + } + } + }, + { + "name": "host unknown root", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "extra": {} + } + } + }, + { + "name": "host unknown phase", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "up": { + "during": [] + } + } + } + }, + { + "name": "host hook name missing", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "up": { + "before": [ + { + "command": { + "exec": ["true"] + } + } + ] + } + } + } + }, + { + "name": "host process name forbidden", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "name": "watch" + } + } + } + } + }, + { + "name": "host host target extras", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "env_target": { + "kind": "host", + "name": "web" + } + } + } + } + } + }, + { + "name": "host unknown target kind", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "env_target": { + "kind": "remote" + } + } + } + } + } + }, + { + "name": "host workload target name missing", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "env_target": { + "kind": "workload" + } + } + } + } + } + }, + { + "name": "host startup unknown", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "startup": "manual" + } + } + } + } + }, + { + "name": "host exit unknown", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "exit": "restart" + } + } + } + } + }, + { + "name": "host hook singleton forbidden", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "up": { + "before": [ + { + "name": "prepare", + "command": { + "exec": ["true"] + }, + "singleton": { + "ports": [80] + } + } + ] + } + } + } + }, + { + "name": "host restart forbidden", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "restart": true + } + } + } + } + }, + { + "name": "host readiness forbidden", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "readiness": {} + } + } + } + } + }, + { + "name": "host ports empty", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "singleton": { + "ports": [] + } + } + } + } + } + }, + { + "name": "host ports zero", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "singleton": { + "ports": [0] + } + } + } + } + } + }, + { + "name": "host ports overflow", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "singleton": { + "ports": [65536] + } + } + } + } + } + }, + { + "name": "host ports duplicate", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "singleton": { + "ports": [80, 80] + } + } + } + } + } + }, + { + "name": "host ports string", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "singleton": { + "ports": ["80"] + } + } + } + } + } + }, + { + "name": "host singleton conflict unknown", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "host": { + "processes": { + "watch": { + "command": { + "exec": ["true"] + }, + "singleton": { + "ports": [80], + "on_conflict": "ignore" + } + } + } + } + } } ] diff --git a/packages/config-compiler/tests/host.rs b/packages/config-compiler/tests/host.rs new file mode 100644 index 000000000..c391b9cd5 --- /dev/null +++ b/packages/config-compiler/tests/host.rs @@ -0,0 +1,361 @@ +use hack_config_compiler::{compile, environment::plan, local::resolve}; +use serde_json::{Value, json}; +fn project() -> Value { + json!({"schema_version":1,"name":"example","profiles":["dev"],"services":{"web":{"image":"web:1"}},"jobs":{"init":{"image":"init:1","profiles":["dev"]}}}) +} +fn hook(name: &str) -> Value { + json!({"name":name,"command":{"exec":["true"]}}) +} +fn process() -> Value { + json!({"command":{"shell":"exit 93"}}) +} +fn compiled(p: &Value) -> Value { + serde_json::to_value(compile(p.to_string().as_bytes(), &[])).unwrap() +} +fn metadata() -> Value { + json!({"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{"web":{},"init":{}},"inactive_scopes":[],"host":{"default":{},"workloads":{}}}) +} +fn planned(p: &Value, m: &Value) -> Value { + serde_json::to_value(plan( + json!({"request_version":1,"project":p.to_string(),"env_metadata":m}) + .to_string() + .as_bytes(), + &[], + )) + .unwrap() +} +fn binding(scope: &str) -> Value { + json!({"scope":scope,"secret":true}) +} +#[test] +fn empty_host_preserves_hostless_plan_hash_and_wire() { + let p = project(); + let expected = compiled(&p); + assert!(expected.get("host_env_targets").is_none()); + assert!(expected["plan"].get("host").is_none()); + for host in [ + json!({}), + json!({"up":{},"down":{"before":[],"after":[]},"processes":{}}), + ] { + let mut p = p.clone(); + p["host"] = host; + assert_eq!(compiled(&p), expected); + } + let mut m = metadata(); + m.as_object_mut().unwrap().remove("host"); + let output = planned(&p, &m); + assert!(output["environment_plan"].get("host").is_none()); + assert!(output.get("host_env_targets").is_none()); +} +#[test] +fn host_normalization_preserves_order_and_explicit_defaults_identity() { + let mut p = project(); + p["host"] = json!({"up":{"before":[hook("z"),hook("a")]},"processes":{"watch":process()}}); + let out = compiled(&p); + assert_eq!(out["ok"], true, "{out}"); + assert_eq!( + out["host_env_targets"], + json!({"include_default":true,"workloads":[]}) + ); + let host = &out["plan"]["host"]; + assert_eq!(host["up"]["before"][0]["name"], "z"); + assert_eq!(host["up"]["before"][1]["name"], "a"); + assert_eq!(host["processes"]["watch"]["cwd"], "."); + assert_eq!( + host["processes"]["watch"]["env_target"], + json!({"kind":"host"}) + ); + assert_eq!(host["processes"]["watch"]["startup"], "up"); + assert_eq!(host["processes"]["watch"]["exit"], "stop_on_down"); + p["host"]["processes"]["watch"]["cwd"] = json!("./"); + p["host"]["processes"]["watch"]["env_target"] = json!({"kind":"host"}); + p["host"]["processes"]["watch"]["startup"] = json!("up"); + p["host"]["processes"]["watch"]["exit"] = json!("stop_on_down"); + assert_eq!(compiled(&p)["semantic_hash"], out["semantic_hash"]); + p["host"]["up"]["before"].as_array_mut().unwrap().reverse(); + assert_ne!(compiled(&p)["semantic_hash"], out["semantic_hash"]); + let resolved = serde_json::to_value(resolve( + json!({"request_version":1,"project":p.to_string()}) + .to_string() + .as_bytes(), + &[], + )) + .unwrap(); + assert_eq!(resolved["host_env_targets"], out["host_env_targets"]); +} +#[test] +fn all_host_names_share_one_namespace_separate_from_workloads() { + for (phase, stage) in [ + ("up", "before"), + ("up", "after"), + ("down", "before"), + ("down", "after"), + ] { + let mut p = project(); + p["host"] = json!({"processes":{"same":process()}}); + p["host"][phase] = json!({stage:[hook("same")]}); + assert_eq!(compiled(&p)["ok"], false); + } + let mut p = project(); + p["host"] = json!({"up":{"before":[hook("same")],"after":[hook("same")]}}); + assert_eq!(compiled(&p)["ok"], false); + p["host"] = json!({"processes":{"web":process(),"host":process(),"global":process()}}); + let out = compiled(&p); + assert_eq!(out["ok"], true); + assert_eq!(out["host_env_targets"]["include_default"], true); + assert_eq!(out["host_env_targets"]["workloads"], json!([])); +} +#[test] +fn targets_include_inactive_jobs_and_refuse_unknown_names() { + let mut p = project(); + let mut x = process(); + x["env_target"] = json!({"kind":"workload","name":"init"}); + let mut y = process(); + y["env_target"] = json!({"kind":"workload","name":"web"}); + p["host"] = json!({"processes":{"first":y,"second":x.clone(),"third":x}}); + let out = compiled(&p); + assert_eq!(out["ok"], true); + assert_eq!( + out["host_env_targets"], + json!({"include_default":false,"workloads":["init","web"]}) + ); + assert!(out["plan"]["jobs"].get("init").is_none()); + p["host"]["processes"]["second"]["env_target"]["name"] = json!("absent"); + let out = compiled(&p); + assert_eq!(out["diagnostics"][0]["code"], "unknown_env_target"); +} +#[test] +fn singleton_intent_is_sorted_unique_and_process_only() { + let mut p = project(); + let mut x = process(); + x["singleton"] = json!({"ports":[9001,9000],"on_conflict":"adopt"}); + p["host"] = json!({"processes":{"watch":x}}); + let out = compiled(&p); + assert_eq!(out["ok"], true); + assert_eq!( + out["plan"]["host"]["processes"]["watch"]["singleton"]["ports"], + json!([9000, 9001]) + ); + for ports in [json!([]), json!([0]), json!([65536]), json!([80, 80])] { + p["host"]["processes"]["watch"]["singleton"]["ports"] = ports; + assert_eq!(compiled(&p)["ok"], false); + } + let mut h = hook("hook"); + h["singleton"] = json!({"ports":[80]}); + p["host"] = json!({"up":{"before":[h]}}); + assert_eq!(compiled(&p)["ok"], false); +} +#[test] +fn host_bindings_reuse_immutable_baseline_and_report_ordered_hook_pointer() { + let mut p = project(); + let mut h = hook("prepare"); + h["environment"] = json!({"A":{"literal":"public"},"B":{"env_ref":"A"},"C":{"default":"fallback"},"D":{"default":"unused"},"E":{"unset":true},"SELF":{"env_ref":"SELF"},"MISSING":{"env_ref":"ABSENT"}}); + p["host"] = json!({"up":{"before":[hook("first"),h]}}); + let mut m = metadata(); + m["host"]["default"] = json!({"A":binding("host"),"D":binding("global"),"E":binding("host"),"SELF":binding("global")}); + let out = planned(&p, &m); + assert_eq!(out["ok"], true); + assert_eq!(out["environment_plan"]["complete"], false); + let b = &out["environment_plan"]["host"]["prepare"]["bindings"]; + assert_eq!(b["A"]["kind"], "literal"); + assert_eq!(b["B"]["kind"], "managed"); + assert_eq!(b["B"]["key"], "A"); + assert_eq!(b["C"]["kind"], "default"); + assert_eq!(b["D"]["kind"], "managed"); + assert!(b.get("E").is_none()); + assert_eq!(b["SELF"]["key"], "SELF"); + let d = &out["environment_plan"]["diagnostics"][0]; + assert_eq!(d["pointer"], "/host/up/before/1/environment/MISSING"); + assert_eq!(d["document"], "project"); + assert!(d["column"].as_u64().unwrap() > 1); + m["host"]["default"]["B"] = binding("host"); + let out = planned(&p, &m); + assert_eq!( + out["environment_plan"]["diagnostics"][0]["code"], + "env_reference_collision" + ); +} +#[test] +fn generic_host_cannot_read_other_workloads_or_gain_scope_from_its_name() { + let mut p = project(); + let mut x = process(); + x["environment"] = json!({"DEST":{"env_ref":"KEY"}}); + p["host"] = json!({"processes":{"web":x}}); + let mut m = metadata(); + m["workloads"]["web"]["KEY"] = binding("web"); + let out = planned(&p, &m); + assert_eq!(out["environment_plan"]["complete"], false); + m["host"]["default"]["KEY"] = binding("web"); + assert_eq!(planned(&p, &m)["ok"], false); + p["host"]["processes"]["web"]["env_target"] = json!({"kind":"workload","name":"web"}); + m["host"] = json!({"workloads":{"web":{"KEY":binding("host")}}}); + let out = planned(&p, &m); + assert_eq!(out["environment_plan"]["complete"], true); + assert_eq!( + out["environment_plan"]["host"]["web"]["bindings"]["DEST"]["scope"], + "host" + ); +} +#[test] +fn workload_named_host_disables_generic_host_scope_for_all_other_targets() { + let mut p = project(); + p["services"]["host"] = json!({"image":"host:1"}); + let mut target = process(); + target["env_target"] = json!({"kind":"workload","name":"web"}); + p["host"] = json!({"processes":{"generic":process(),"target":target}}); + let mut m = metadata(); + m["workloads"]["host"] = json!({}); + m["host"]["workloads"]["web"] = json!({}); + m["host"]["default"]["KEY"] = binding("host"); + assert_eq!(planned(&p, &m)["ok"], false); + m["host"]["default"] = json!({}); + m["host"]["workloads"]["web"]["KEY"] = binding("host"); + assert_eq!(planned(&p, &m)["ok"], false); + p["host"]["processes"]["target"]["env_target"]["name"] = json!("host"); + m["host"]["workloads"] = json!({"host":{"KEY":binding("host")}}); + assert_eq!(planned(&p, &m)["environment_plan"]["complete"], true); +} +#[test] +fn host_metadata_requires_exact_requested_shapes_without_values() { + let mut p = project(); + p["host"] = json!({"processes":{"watch":process()}}); + for host in [ + json!(null), + json!([]), + json!({"workloads":{}}), + json!({"default":{},"workloads":{"web":{}}}), + json!({"default":[],"workloads":{}}), + json!({"default":{"KEY":[]},"workloads":{}}), + json!({"default":{"KEY":{"scope":"host","secret":true,"value":"private-sentinel"}},"workloads":{}}), + ] { + let mut m = metadata(); + m["host"] = host; + let out = planned(&p, &m); + assert_eq!(out["ok"], false, "{out}"); + assert!(!out.to_string().contains("private-sentinel")); + } + let mut m = metadata(); + m.as_object_mut().unwrap().remove("host"); + assert_eq!(planned(&p, &m)["ok"], false); + let m = metadata(); + assert_eq!(planned(&project(), &m)["ok"], false); +} +#[test] +fn host_metadata_changes_do_not_enter_portable_hashes() { + let mut p = project(); + p["host"] = json!({"processes":{"watch":process()}}); + let mut m = metadata(); + let first = planned(&p, &m); + m["host"]["default"]["KEY"] = binding("host"); + let second = planned(&p, &m); + assert_eq!(first["semantic_hash"], second["semantic_hash"]); + assert_eq!(first["local_resolution"], second["local_resolution"]); + assert_eq!(first["plan"], second["plan"]); +} + +#[test] +fn host_semantic_refusals_cover_names_paths_commands_and_environment() { + for name in ["", "Bad", "a/b", "_bad"] { + let mut p = project(); + p["host"] = json!({"processes":{name:process()}}); + assert_eq!(compiled(&p)["ok"], false); + } + for cwd in [ + "../outside", + "/absolute", + "a/../../outside", + "C:\\outside", + "nul\0path", + ] { + let mut p = project(); + let mut x = process(); + x["cwd"] = json!(cwd); + p["host"] = json!({"processes":{"watch":x}}); + assert_eq!(compiled(&p)["diagnostics"][0]["code"], "invalid_path"); + } + for command in [ + json!({"exec":[]}), + json!({"exec":["bad\0command"]}), + json!({"shell":"bad\0command"}), + json!({"exec":["true"],"shell":"true"}), + ] { + let mut p = project(); + p["host"] = json!({"processes":{"watch":{"command":command}}}); + assert_eq!(compiled(&p)["ok"], false); + } + for environment in [ + json!({"BAD-KEY":{"literal":"public"}}), + json!({"KEY":{"env_ref":"lowercase"}}), + json!({"KEY":{"literal":"bad\0value"}}), + json!({"KEY":{"env_ref":"KEY","scope":"web"}}), + ] { + let mut p = project(); + let mut x = process(); + x["environment"] = environment; + p["host"] = json!({"processes":{"watch":x}}); + assert_eq!(compiled(&p)["ok"], false); + } +} + +#[test] +fn duplicate_host_json_keys_refuse_before_map_insertion() { + let input=br#"{"schema_version":1,"name":"example","host":{"processes":{"watch":{"command":{"exec":["true"]}},"watch":{"command":{"shell":"private-sentinel"}}}}}"#; + let output = serde_json::to_value(compile(input, &[])).unwrap(); + assert_eq!(output["diagnostics"][0]["code"], "duplicate_key"); + assert!(!output.to_string().contains("private-sentinel")); +} + +fn large_baseline(count: usize, key_length: usize) -> Value { + let mut result = serde_json::Map::new(); + for index in 0..count { + let prefix = format!("K{index:05}"); + let key = format!("{prefix}{}", "A".repeat(key_length - prefix.len())); + result.insert(key, binding("global")); + } + Value::Object(result) +} +fn many_processes(count: usize) -> Value { + let mut result = serde_json::Map::new(); + for index in 0..count { + result.insert(format!("p{index:04}"), process()); + } + Value::Object(result) +} +#[test] +fn small_metadata_cannot_amplify_into_an_unbounded_host_report() { + let mut p = project(); + p["host"] = json!({"processes":many_processes(1000)}); + let mut m = metadata(); + m["host"]["default"] = large_baseline(100, 1000); + assert!(p.to_string().len() < hack_config_compiler::MAX_INPUT_BYTES); + assert!(m.to_string().len() < hack_config_compiler::MAX_INPUT_BYTES); + let out = planned(&p, &m); + assert_eq!(out["ok"], false); + assert_eq!(out["diagnostics"][0]["code"], "plan_too_large"); + assert_eq!(out["diagnostics"][0]["document"], "project"); + assert!( + out["diagnostics"][0]["pointer"] + .as_str() + .unwrap() + .starts_with("/host/processes/") + ); + assert!(out.to_string().len() < 1024); +} +#[test] +fn workloads_and_host_invocations_share_one_expansion_budget() { + let mut p = project(); + p["host"] = json!({"processes":many_processes(80)}); + let mut m = metadata(); + m["host"]["default"] = large_baseline(500, 60); + let host_only = planned(&p, &m); + assert_eq!(host_only["ok"], true, "{host_only}"); + assert!(host_only.to_string().len() < hack_config_compiler::environment::MAX_PLAN_OUTPUT_BYTES); + m["workloads"]["web"] = large_baseline(5000, 100); + assert!(m.to_string().len() < hack_config_compiler::MAX_INPUT_BYTES); + let mut guest_metadata = m.clone(); + guest_metadata.as_object_mut().unwrap().remove("host"); + assert_eq!(planned(&project(), &guest_metadata)["ok"], true); + let combined = planned(&p, &m); + assert_eq!(combined["ok"], false); + assert_eq!(combined["diagnostics"][0]["code"], "plan_too_large"); +} diff --git a/packages/config-compiler/tests/protocol.rs b/packages/config-compiler/tests/protocol.rs index ec55d446a..fae1e2068 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}) + 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}) ); let result = run(&["compile"], br#"{"schema_version":1,"name":"example"}"#); assert!(result.status.success()); @@ -130,3 +130,25 @@ fn environment_plan_process_separates_valid_documents_from_complete_bindings() { .contains("private-sentinel") ); } + +#[test] +fn host_planning_does_not_execute_authored_commands() { + let project = r#"{"schema_version":1,"name":"example","host":{"up":{"before":[{"name":"before","command":{"shell":"exit 93"}}]},"processes":{"watch":{"command":{"shell":"exit 94"},"singleton":{"ports":[1],"on_conflict":"adopt"}}}}}"#; + let request = serde_json::json!({"request_version":1,"project":project,"env_metadata":{"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{},"inactive_scopes":[],"host":{"default":{},"workloads":{}}}}); + let output = run(&["plan"], request.to_string().as_bytes()); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["environment_plan"]["complete"], true); + assert_eq!( + result["host_env_targets"], + serde_json::json!({"include_default":true,"workloads":[]}) + ); + assert_eq!( + result["environment_plan"]["host"] + .as_object() + .unwrap() + .len(), + 2 + ); +} diff --git a/scripts/check-native-host-plan-cli.ts b/scripts/check-native-host-plan-cli.ts new file mode 100644 index 000000000..26319bfaf --- /dev/null +++ b/scripts/check-native-host-plan-cli.ts @@ -0,0 +1,404 @@ +#!/usr/bin/env bun +import { copyFile, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; + +/** Actual relocated CLI and sidecar; no Bun/Rust on PATH and no host effects. */ +const root = resolve(import.meta.dir, ".."); +const directory = await mkdtemp(join(tmpdir(), "hack-host-plan-cli-")); +const bundle = join(directory, "bundle"); +const home = join(directory, "home"); +const primary = join(directory, "primary"); +const marker = join(directory, "host-command-must-not-run"); +const canary = "private-synthetic-host-value"; +const secret = { secure: `invalid-ciphertext-${canary}` }; +const command = { exec: ["/usr/bin/touch", marker] }; +const project = { + schema_version: 1, + name: "host-plan-fixture", + profiles: ["optional"], + environment: { default_overlay: "qa" }, + services: { web: { image: "example/web:1" } }, + jobs: { + seed: { image: "example/seed:1", profiles: ["optional"] }, + }, + host: { + up: { + before: [ + { + name: "prepare", + command: { shell: `touch '${marker}'` }, + cwd: "./scripts//.", + environment: { HOST: { env_ref: "HOST" } }, + }, + { + name: "prepare-job", + command, + env_target: { kind: "workload", name: "seed" }, + environment: { JOB: { env_ref: "JOB" } }, + }, + ], + }, + down: { after: [{ name: "cleanup", command }] }, + processes: { + web: { command }, + tunnel: { + command, + env_target: { kind: "workload", name: "web" }, + environment: { + COPY: { env_ref: "SOURCE" }, + SOURCE: { literal: "authored-public-override" }, + EMPTY: { default: "must-not-replace-empty" }, + ERASE: { unset: true }, + ["__proto__"]: { literal: "authored-own-binding" }, + }, + singleton: { ports: [32_002, 32_001], on_conflict: "fail" }, + }, + }, + }, +}; +let checks = 0; +try { + await Promise.all([ + mkdir(bundle), + mkdir(home), + mkdir(join(primary, ".hack"), { recursive: true }), + ]); + for (const name of ["hack", "hack-config-compiler"]) { + await copyFile(join(root, "dist", name), join(bundle, name)); + } + await authored(primary, project); + await mkdir(join(primary, ".hack/.hack.secret.key")); + await layer(primary, "hack.env.default.yaml", { + global: { SOURCE: secret, EMPTY: "", ERASE: secret }, + host: { HOST: secret, HOST_LAYER: secret }, + web: { WEB_ONLY: secret }, + seed: { JOB: secret }, + retired: { UNUSED: secret }, + }); + const first = await planned(primary); + const hosts = report(first); + const sameName = bindings(hosts, "web"); + const tunnel = bindings(hosts, "tunnel"); + assert( + Object.hasOwn(sameName, "HOST") && !Object.hasOwn(sameName, "WEB_ONLY"), + "matching names grant no workload scope" + ); + assert( + record(tunnel.COPY).key === "SOURCE" && + record(tunnel.COPY).secret === true && + record(tunnel.SOURCE).kind === "literal" && + record(tunnel.EMPTY).kind === "managed" && + !Object.hasOwn(tunnel, "ERASE"), + "immutable baseline and directives for explicit target" + ); + assert( + Object.hasOwn(tunnel, "__proto__") && + record(tunnel.__proto__).value === "authored-own-binding", + "own prototype destination survives" + ); + assert( + record(bindings(hosts, "prepare-job").JOB).scope === "seed" && + !Object.hasOwn(record(first.plan).jobs, "seed") && + record(first.declared_workloads).seed === "job", + "inactive job remains an explicit owner target" + ); + assert( + JSON.stringify(first.host_env_targets) === + JSON.stringify({ include_default: true, workloads: ["seed", "web"] }), + "Rust target projection" + ); + const hostPlan = record(record(first.plan).host); + const before = record(hostPlan.up).before; + assert( + Array.isArray(before) && + record(before[0]).name === "prepare" && + record(before[1]).name === "prepare-job" && + record(before[0]).cwd === "scripts", + "hook order and checkout-relative cwd normalization" + ); + const processPlan = record(record(hostPlan.processes).tunnel); + assert( + processPlan.startup === "up" && + processPlan.exit === "stop_on_down" && + JSON.stringify(record(processPlan.singleton).ports) === "[32001,32002]", + "startup/exit and singleton intent preserved" + ); + const defaultPath = join(primary, ".hack/hack.env.default.yaml"); + const original = await Bun.file(defaultPath).text(); + await Bun.write(defaultPath, `invalid: [${canary}`); + const offline = await invoke(["config", "validate", "--json"], primary); + assert( + offline.exit === 0 && + offline.value.ok === true && + offline.value.semantic_hash === first.semantic_hash, + "validation never reads managed documents" + ); + const invalid = await invoke(["config", "plan", "--json"], primary); + assert( + invalid.exit === 1 && + record(invalid.value.error).code === "E_CONFIG_METADATA", + "invalid selected metadata refuses redacted" + ); + await Bun.write( + defaultPath, + original.replaceAll(canary, `${canary}-changed`) + ); + const changed = await planned(primary); + assert( + changed.semantic_hash === first.semantic_hash && + record(changed.local_resolution).resolution_hash === + record(first.local_resolution).resolution_hash, + "managed values do not enter portable identities" + ); + await layer(primary, "hack.env.qa.yaml", { host: { HOST: null } }, "qa"); + const incomplete = await planned(primary, false); + assert( + JSON.stringify(record(incomplete.environment_plan).diagnostics).includes( + "/host/up/before/0/environment/HOST" + ), + "missing host reference has original hook pointer" + ); + await rm(join(primary, ".hack/hack.env.qa.yaml")); + await layer(primary, "hack.env.local.yaml", { host: { COPY: secret } }); + const collision = await planned(primary, false); + assert( + JSON.stringify(record(collision.environment_plan).diagnostics).includes( + "env_reference_collision" + ), + "host remap refuses occupied owner destination" + ); + await rm(join(primary, ".hack/hack.env.local.yaml")); + await authored(primary, { + ...project, + services: { ...project.services, host: { image: "example/host:1" } }, + }); + const namedHost = await planned(primary, false); + assert( + !( + Object.hasOwn(bindings(report(namedHost), "web"), "HOST") || + Object.hasOwn(bindings(report(namedHost), "tunnel"), "HOST") + ) && + Object.hasOwn( + record(record(record(namedHost.environment_plan).workloads).host), + "HOST" + ), + "declared workload named host blocks generic host override" + ); + await authored(primary, { + ...project, + host: { + ...project.host, + processes: { + ...project.host.processes, + tunnel: { command, env_target: { kind: "workload", name: "unknown" } }, + }, + }, + }); + const unknown = await invoke(["config", "validate", "--json"], primary); + assert( + unknown.exit === 1 && unknown.value.ok === false, + "undeclared host target refuses offline" + ); + await authored(primary, project); + await git(["init", "--quiet"], primary); + await git( + ["add", ".hack/hack.project.json", ".hack/hack.env.default.yaml"], + primary + ); + await git( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "fixture", + ], + primary + ); + const checkout = join(directory, "linked"); + await git( + ["worktree", "add", "--quiet", "-b", "host-fixture", checkout], + primary + ); + await layer(primary, "hack.env.local.yaml", { host: { PRIMARY: secret } }); + await layer( + primary, + "hack.env.qa.local.yaml", + { web: { SOURCE: secret } }, + "qa" + ); + await layer(checkout, "hack.env.local.yaml", { host: { SOURCE: "" } }); + await layer( + checkout, + "hack.env.qa.local.yaml", + { host: { CURRENT: secret } }, + "qa" + ); + const linked = await planned(checkout); + const linkedTunnel = bindings(report(linked), "tunnel"); + assert( + Object.hasOwn(linkedTunnel, "PRIMARY") && + Object.hasOwn(linkedTunnel, "CURRENT") && + record(linkedTunnel.COPY).scope === "host" && + record(linkedTunnel.COPY).secret === false, + "six layers preserve host overlay precedence" + ); + for (const extra of [{ CI: "1" }, { HACK_EXECUTION_MODE: "codex" }]) { + const excluded = await planned(checkout, true, extra); + assert( + !Object.hasOwn(bindings(report(excluded), "tunnel"), "PRIMARY"), + "CI/slim excludes primary host metadata" + ); + } + await authored(checkout, { ...project, worktree: { inherit_local: false } }); + const optedOut = await planned(checkout); + assert( + !Object.hasOwn(bindings(report(optedOut), "tunnel"), "PRIMARY"), + "opt-out excludes primary host metadata" + ); + await authored(checkout, project); + await Bun.write( + join(checkout, ".hack/hack.local.json"), + JSON.stringify({ + schema_version: 1, + host: project.host, + }) + ); + const injection = await invoke(["config", "validate", "--json"], checkout); + assert( + injection.exit === 1 && injection.value.ok === false, + "local settings cannot inject host commands" + ); + await rm(join(checkout, ".hack/hack.local.json")); + const runtime = await invoke(["up", "--json"], checkout); + assert( + runtime.exit !== 0 && + (runtime.stdout + runtime.stderr).includes( + "E_NATIVE_PROJECT_UNSUPPORTED" + ), + "native execution remains fenced" + ); + assert( + !(await Bun.file(marker).exists()) && + (await readdir(home)).length === 0 && + !(await readdir(join(checkout, ".hack"))).some( + (name) => name === ".internal" || name === ".branch" + ), + "no hooks, process, registry or runtime effects" + ); + process.stdout.write( + `Relocated native host planning: ${checks} checks passed; no host execution or secret delivery\n` + ); +} finally { + await rm(directory, { recursive: true, force: true }); +} + +async function invoke( + args: readonly string[], + cwd: string, + extra: Readonly> = {} +) { + const child = Bun.spawn([join(bundle, "hack"), ...args], { + cwd, + env: { + PATH: "/usr/bin:/bin", + HOME: home, + HACK_LOGGER: "console", + AWS_SECRET_ACCESS_KEY: canary, + ...extra, + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 30_000); + try { + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + assert( + !(stdout + stderr).includes(canary), + "no managed values or ciphertext in output" + ); + const value = args[0] === "config" ? record(JSON.parse(stdout)) : {}; + return { stdout, stderr, exit, value }; + } finally { + clearTimeout(timer); + } +} +async function planned( + cwd: string, + complete = true, + extra: Readonly> = {} +) { + const result = await invoke(["config", "plan", "--json"], cwd, extra); + assert( + result.value.ok === true && + result.exit === (complete ? 0 : 1) && + record(result.value.environment_plan).complete === complete, + "plan completeness and exit" + ); + // Authored commands intentionally contain the marker path; metadata reports do not. + assert( + !JSON.stringify(result.value.environment_plan).includes(directory), + "metadata report excludes private paths" + ); + return result.value; +} +function report(value: Record) { + return record(record(value.environment_plan).host); +} +function bindings(hosts: Record, name: string) { + return record(record(hosts[name]).bindings); +} +async function authored(cwd: string, value: unknown) { + await Bun.write(join(cwd, ".hack/hack.project.json"), JSON.stringify(value)); +} +async function layer( + cwd: string, + name: string, + values: unknown, + environment = "default" +) { + await Bun.write( + join(cwd, ".hack", name), + JSON.stringify({ + version: 1, + environment, + secretsprovider: "project_key", + values, + }) + ); +} +async function git(args: readonly string[], cwd: string) { + const child = Bun.spawn(["/usr/bin/git", "-C", cwd, ...args], { + env: { PATH: "/usr/bin:/bin", HOME: home, GIT_CONFIG_NOSYSTEM: "1" }, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exit] = await Promise.all([ + new Response(child.stderr).text(), + child.exited, + ]); + assert(exit === 0, `isolated Git fixture: ${stderr}`); +} +function record(value: unknown): Record { + assert(isRecord(value), "expected report object"); + return value; +} +function assert(condition: boolean, label: string): asserts condition { + if (!condition) { + throw new Error(`Native host plan CLI acceptance failed: ${label}`); + } + checks += 1; +} diff --git a/src/lib/native-config-compiler.ts b/src/lib/native-config-compiler.ts index 24db97272..ccd5f73f1 100644 --- a/src/lib/native-config-compiler.ts +++ b/src/lib/native-config-compiler.ts @@ -9,6 +9,11 @@ import { parseNativeEnvironmentPlan, parseNativeEnvMetadata, } from "./native-env-plan-protocol.ts"; +import { + type NativeHostEnvTargets, + nativeHostSelectionMatches, + parseNativeHostTargets, +} from "./native-host-plan-protocol.ts"; export const NATIVE_CONFIG_INPUT_LIMIT = 1024 * 1024; const OUTPUT_LIMIT = 8 * 1024 * 1024; @@ -61,6 +66,7 @@ export type NativeConfigCompileResult = readonly plan: Readonly>; readonly semantic_hash: string; readonly declared_workloads?: NativeDeclaredWorkloads; + readonly host_env_targets?: NativeHostEnvTargets; } | { readonly transport_version: 1; @@ -139,6 +145,7 @@ export async function compileNativeConfig(opts: { readonly signal?: AbortSignal; readonly requireLocalResolution?: boolean; readonly requireEnvPlanning?: boolean; + readonly requireHostPlanning?: boolean; }): Promise { if (opts.input.byteLength > NATIVE_CONFIG_INPUT_LIMIT) { throw failure( @@ -151,6 +158,7 @@ export async function compileNativeConfig(opts: { ...request, requireLocalResolution: opts.requireLocalResolution, requireEnvPlanning: opts.requireEnvPlanning, + requireHostPlanning: opts.requireHostPlanning, }); const response = await invokeCompiler({ ...request, @@ -178,6 +186,7 @@ export async function resolveNativeConfig(opts: { readonly timeoutMs?: number; readonly signal?: AbortSignal; readonly requireEnvPlanning?: boolean; + readonly requireHostPlanning?: boolean; }): Promise { const input = encodeResolveRequest(opts); const request = compilerRequest(opts); @@ -185,6 +194,7 @@ export async function resolveNativeConfig(opts: { ...request, requireLocalResolution: true, requireEnvPlanning: opts.requireEnvPlanning, + requireHostPlanning: opts.requireHostPlanning, }); const response = await invokeCompiler({ ...request, @@ -279,6 +289,7 @@ export async function planNativeConfig( ...request, requireLocalResolution: true, requireEnvPlanning: true, + requireHostPlanning: metadata.host !== undefined, }); const response = await invokeCompiler({ ...request, @@ -306,6 +317,13 @@ export async function planNativeConfig( declared: parsed.declared_workloads, environmentPlan, }) || + !nativeHostSelectionMatches({ + plan: parsed.plan, + declared: parsed.declared_workloads, + targets: parsed.host_env_targets, + report: environmentPlan.host, + requireReport: true, + }) || response.exitCode !== (environmentPlan.complete ? 0 : 1) ) { throw failure( @@ -437,6 +455,7 @@ async function checkProtocol(opts: { readonly signal?: AbortSignal; readonly requireLocalResolution?: boolean; readonly requireEnvPlanning?: boolean; + readonly requireHostPlanning?: boolean; }): Promise { const handshake = await invokeCompiler({ ...opts, args: ["--protocol"] }); const protocol = parseControlJson(handshake.output); @@ -447,6 +466,7 @@ async function checkProtocol(opts: { protocol.authored_version !== 1 || protocol.plan_version !== 1 || (opts.requireEnvPlanning && protocol.env_plan_version !== 1) || + (opts.requireHostPlanning && protocol.host_env_plan_version !== 1) || (opts.requireLocalResolution && (protocol.resolve_version !== 1 || protocol.local_version !== 1)) ) { @@ -715,12 +735,18 @@ function parseCompileValue(opts: { "Native compiler returned an invalid workload namespace." ); } + const hostTargets = parseHostNamespace({ + value, + plan: value.plan, + declared, + }); return { transport_version: 1, ok: true, plan: value.plan, semantic_hash: value.semantic_hash, ...(declared === undefined ? {} : { declared_workloads: declared }), + ...(hostTargets === undefined ? {} : { host_env_targets: hostTargets }), }; } if ( @@ -738,6 +764,31 @@ function parseCompileValue(opts: { ); } +function parseHostNamespace(opts: { + readonly value: Record; + readonly plan: Readonly>; + readonly declared: NativeDeclaredWorkloads | undefined; +}): NativeHostEnvTargets | undefined { + const targets = + opts.value.host_env_targets === undefined + ? undefined + : parseNativeHostTargets(opts.value.host_env_targets); + if ( + targets === null || + !nativeHostSelectionMatches({ + plan: opts.plan, + declared: opts.declared, + targets, + }) + ) { + throw failure( + "E_COMPILER_RESPONSE", + "Native compiler returned an invalid host target namespace." + ); + } + return targets; +} + function parseDiagnostic(value: unknown): NativeConfigDiagnostic { if ( !isRecord(value) || diff --git a/src/lib/native-env-plan-protocol.ts b/src/lib/native-env-plan-protocol.ts index 478f97995..94557bf66 100644 --- a/src/lib/native-env-plan-protocol.ts +++ b/src/lib/native-env-plan-protocol.ts @@ -7,6 +7,12 @@ import type { } from "../../packages/config-compiler/generated/native-config.ts"; import { isRecord } from "./guards.ts"; import type { NativeConfigDiagnostic } from "./native-config-compiler.ts"; +import { + type NativeHostEnvironmentPlan, + type NativeHostMetadata, + parseNativeHostMetadata, + parseNativeHostReports, +} from "./native-host-plan-protocol.ts"; const WORKLOAD_NAME = /^[a-z0-9][a-z0-9._-]{0,62}$/; const MANAGED_KEY = /^[A-Z_][A-Z0-9_]*$/; @@ -17,22 +23,24 @@ 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; }; export type NativeEnvBinding = Readonly; export type NativeEnvironmentPlan = Readonly< - Omit + Omit > & { readonly workloads: Readonly< Record>> >; readonly warnings: readonly NativeConfigDiagnostic[]; readonly diagnostics: readonly NativeConfigDiagnostic[]; + readonly host?: NativeHostEnvironmentPlan; }; function hasOnly( @@ -76,6 +84,7 @@ export function parseNativeEnvMetadata( "overlay_exists", "workloads", "inactive_scopes", + "host", ]) ) || value.metadata_version !== 1 || @@ -86,8 +95,38 @@ export function parseNativeEnvMetadata( ) { return null; } + const workloads = parseWorkloadMetadata(value.workloads); + if (!workloads) { + return null; + } + const inactiveScopes: string[] = []; + for (const scope of value.inactive_scopes) { + if (typeof scope !== "string" || !SCOPE.test(scope)) { + return null; + } + inactiveScopes.push(scope); + } + const host = Object.hasOwn(value, "host") + ? parseNativeHostMetadata(value.host) + : undefined; + if (host === null) { + return null; + } + return { + metadata_version: 1, + overlay: value.overlay, + overlay_exists: value.overlay_exists, + workloads, + inactive_scopes: inactiveScopes, + ...(host === undefined ? {} : { host }), + }; +} + +function parseWorkloadMetadata( + value: Record +): Record> | null { const workloads: Record> = {}; - for (const [name, entries] of Object.entries(value.workloads)) { + for (const [name, entries] of Object.entries(value)) { if (!(WORKLOAD_NAME.test(name) && isRecord(entries))) { return null; } @@ -109,20 +148,7 @@ export function parseNativeEnvMetadata( } workloads[name] = projected; } - const inactiveScopes: string[] = []; - for (const scope of value.inactive_scopes) { - if (typeof scope !== "string" || !SCOPE.test(scope)) { - return null; - } - inactiveScopes.push(scope); - } - return { - metadata_version: 1, - overlay: value.overlay, - overlay_exists: value.overlay_exists, - workloads, - inactive_scopes: inactiveScopes, - }; + return workloads; } function parseBinding(value: unknown): NativeEnvBinding | null { @@ -172,6 +198,7 @@ export function parseNativeEnvironmentPlan(opts: { "workloads", "warnings", "diagnostics", + "host", ]) ) || value.plan_version !== 1 || @@ -207,7 +234,11 @@ 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 }) + : undefined; if ( + host === null || value.complete !== (diagnostics.length === 0) || [...warnings, ...diagnostics].some((entry) => entry.document === undefined) ) { @@ -221,5 +252,6 @@ export function parseNativeEnvironmentPlan(opts: { workloads, warnings, diagnostics, + ...(host === undefined ? {} : { host }), }; } diff --git a/src/lib/native-host-plan-protocol.ts b/src/lib/native-host-plan-protocol.ts new file mode 100644 index 000000000..fa04789bf --- /dev/null +++ b/src/lib/native-host-plan-protocol.ts @@ -0,0 +1,304 @@ +import type { + HostEnvTarget, + HostEnvTargets, + HostMetadata, +} from "../../packages/config-compiler/generated/native-config.ts"; +import { isRecord } from "./guards.ts"; +import type { + NativeDeclaredWorkloads, + NativeEnvBinding, + NativeManagedEntry, +} from "./native-env-plan-protocol.ts"; + +const NAME = /^[a-z0-9][a-z0-9._-]{0,62}$/; +const KEY = /^[A-Z_][A-Z0-9_]*$/; +const DESTINATION = /^[A-Za-z_][A-Za-z0-9_]*$/; +const SCOPE = /^[a-z0-9][a-z0-9._-]*$/; + +export type NativeHostEnvTargets = Readonly< + Omit +> & { readonly workloads: readonly string[] }; +export type NativeHostEnvTarget = Readonly; +export type NativeHostMetadata = Readonly< + Omit +> & { + readonly default?: Readonly>; + readonly workloads: Readonly< + Record>> + >; +}; +export type NativeHostEnvironmentPlan = Readonly< + Record< + string, + { + readonly env_target: NativeHostEnvTarget; + readonly bindings: Readonly>; + } + > +>; + +function only( + value: Record, + keys: readonly string[] +): boolean { + return Object.keys(value).every((key) => keys.includes(key)); +} + +export function parseNativeHostTarget( + value: unknown +): NativeHostEnvTarget | null { + if (!isRecord(value)) { + return null; + } + if (value.kind === "host" && only(value, ["kind"])) { + return { kind: "host" }; + } + if ( + value.kind === "workload" && + only(value, ["kind", "name"]) && + typeof value.name === "string" && + NAME.test(value.name) + ) { + return { kind: "workload", name: value.name }; + } + return null; +} + +/** Rust selects owner targets; this boundary checks only the wire projection. */ +export function parseNativeHostTargets( + value: unknown +): NativeHostEnvTargets | null { + if ( + !(isRecord(value) && only(value, ["include_default", "workloads"])) || + typeof value.include_default !== "boolean" || + !Array.isArray(value.workloads) + ) { + return null; + } + const workloads: string[] = []; + for (const name of value.workloads) { + const previous = workloads.at(-1); + if ( + typeof name !== "string" || + !NAME.test(name) || + (previous !== undefined && name <= previous) + ) { + return null; + } + workloads.push(name); + } + return { include_default: value.include_default, workloads }; +} + +function parseManagedMap( + value: unknown +): Record | null { + if (!isRecord(value)) { + return null; + } + const output: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if ( + !(KEY.test(key) && isRecord(entry) && only(entry, ["scope", "secret"])) || + typeof entry.scope !== "string" || + !SCOPE.test(entry.scope) || + typeof entry.secret !== "boolean" + ) { + return null; + } + output[key] = { scope: entry.scope, secret: entry.secret }; + } + return output; +} + +/** Refuse extra stored values rather than forwarding or silently stripping them. */ +export function parseNativeHostMetadata( + value: unknown +): NativeHostMetadata | null { + if ( + !( + isRecord(value) && + only(value, ["default", "workloads"]) && + isRecord(value.workloads) + ) + ) { + return null; + } + const defaultBindings = Object.hasOwn(value, "default") + ? parseManagedMap(value.default) + : undefined; + if (defaultBindings === null) { + return null; + } + const workloads: Record> = {}; + for (const [name, bindings] of Object.entries(value.workloads)) { + const projected = parseManagedMap(bindings); + if (!(NAME.test(name) && projected)) { + return null; + } + workloads[name] = projected; + } + return { + ...(defaultBindings === undefined ? {} : { default: defaultBindings }), + workloads, + }; +} + +export function parseNativeHostReports(opts: { + readonly value: unknown; + readonly parseBinding: (value: unknown) => NativeEnvBinding | null; +}): NativeHostEnvironmentPlan | null { + if (!isRecord(opts.value)) { + return null; + } + const output: Record = {}; + for (const [name, entry] of Object.entries(opts.value)) { + if ( + !( + NAME.test(name) && + isRecord(entry) && + only(entry, ["env_target", "bindings"]) && + isRecord(entry.bindings) + ) + ) { + return null; + } + const target = parseNativeHostTarget(entry.env_target); + if (!target) { + return null; + } + const bindings: Record = {}; + for (const [key, value] of Object.entries(entry.bindings)) { + const binding = opts.parseBinding(value); + if (!(DESTINATION.test(key) && binding)) { + return null; + } + Object.defineProperty(bindings, key, { + value: binding, + enumerable: true, + configurable: true, + writable: true, + }); + } + output[name] = { env_target: target, bindings }; + } + return output; +} + +/** Compare normalized Rust envelopes, without interpreting authored documents. */ +export function nativeHostSelectionMatches(opts: { + readonly plan: Readonly>; + readonly declared: NativeDeclaredWorkloads | undefined; + readonly targets: NativeHostEnvTargets | undefined; + readonly report?: NativeHostEnvironmentPlan; + readonly requireReport?: boolean; +}): boolean { + if (opts.plan.host === undefined) { + return opts.targets === undefined && opts.report === undefined; + } + const entries = readNormalizedInvocations(opts.plan.host); + if (!(entries && opts.targets && opts.declared) || entries.size === 0) { + return false; + } + let includeDefault = false; + const workloads = new Set(); + for (const target of entries.values()) { + if (target.kind === "host") { + includeDefault = true; + } else if (Object.hasOwn(opts.declared, target.name)) { + workloads.add(target.name); + } else { + return false; + } + } + if ( + includeDefault !== opts.targets.include_default || + workloads.size !== opts.targets.workloads.length || + !opts.targets.workloads.every((name) => workloads.has(name)) + ) { + return false; + } + if (opts.report === undefined) { + return !opts.requireReport; + } + return ( + entries.size === Object.keys(opts.report).length && + Object.entries(opts.report).every(([name, report]) => { + const target = entries.get(name); + return ( + target?.kind === report.env_target.kind && + (target.kind === "host" || + (report.env_target.kind === "workload" && + target.name === report.env_target.name)) + ); + }) + ); +} + +function readNormalizedInvocations( + host: unknown +): Map | null { + if (!(isRecord(host) && only(host, ["up", "down", "processes"]))) { + return null; + } + const output = new Map(); + const add = (name: unknown, value: unknown): boolean => { + if (typeof name !== "string" || !NAME.test(name) || !isRecord(value)) { + return false; + } + const target = parseNativeHostTarget(value.env_target); + if (!target || output.has(name)) { + return false; + } + output.set(name, target); + return true; + }; + for (const action of ["up", "down"]) { + const hooks = normalizedHooks(host[action]); + if (!hooks) { + return null; + } + for (const entry of hooks) { + if (!add(entry.name, entry)) { + return null; + } + } + } + if (host.processes !== undefined) { + if (!isRecord(host.processes)) { + return null; + } + for (const [name, entry] of Object.entries(host.processes)) { + if (!add(name, entry)) { + return null; + } + } + } + return output; +} + +function normalizedHooks(value: unknown): Record[] | null { + if (value === undefined) { + return []; + } + if (!(isRecord(value) && only(value, ["before", "after"]))) { + return null; + } + const output: Record[] = []; + for (const phase of ["before", "after"]) { + const entries = value[phase]; + if (entries === undefined) { + continue; + } + if (!Array.isArray(entries)) { + return null; + } + for (const entry of entries) { + if (!isRecord(entry)) { + return null; + } + output.push(entry); + } + } + return output; +} diff --git a/src/lib/native-project-validation.ts b/src/lib/native-project-validation.ts index 14566e271..e48b3db5e 100644 --- a/src/lib/native-project-validation.ts +++ b/src/lib/native-project-validation.ts @@ -55,6 +55,14 @@ export async function planNativeProject( overlay: resolved.local_resolution.overlay, inheritLocal: resolved.local_resolution.inherit_local, declaredWorkloadNames: Object.keys(declared), + ...(resolved.host_env_targets === undefined + ? {} + : { + hostTargets: { + includeDefault: resolved.host_env_targets.include_default, + workloadNames: resolved.host_env_targets.workloads, + }, + }), signal: opts.signal, }); } catch (error: unknown) { @@ -81,6 +89,9 @@ export async function planNativeProject( ]) ), inactive_scopes: metadata.unknownScopes, + ...(metadata.hostMetadata === undefined + ? {} + : { host: metadata.hostMetadata }), }, }); if ( @@ -142,6 +153,19 @@ async function prepareNativeProject( explicitOverlay: opts.explicitOverlay, signal: opts.signal, requireEnvPlanning: opts.requireEnvPlanning, + requireHostPlanning: + opts.requireEnvPlanning && compiled.host_env_targets !== undefined, }); + if ( + result.ok && + (result.semantic_hash !== compiled.semantic_hash || + JSON.stringify(result.host_env_targets) !== + JSON.stringify(compiled.host_env_targets)) + ) { + throw new NativeConfigCompilerError( + "E_COMPILER_RESPONSE", + "Native local resolution changed the authored identity or host targets." + ); + } return { ...project, result, locals }; } diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 7b2a5146c..cb3a688ec 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -66,6 +66,7 @@ import { const PROJECT_ENV_CONFIG_VERSION = 1 as const; const PROJECT_ENV_SECRETS_PROVIDER = "project_key" as const; +const NATIVE_ENV_WORKLOAD_PATTERN = /^[a-z0-9][a-z0-9._-]{0,62}$/; const NATIVE_ENV_OVERLAY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const PROJECT_ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/; const PROJECT_ENV_SCOPE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; @@ -976,12 +977,27 @@ export async function resolveProjectEnvMetadata( } } +export type NativeProjectEnvHostTargets = { + readonly includeDefault: boolean; + readonly workloadNames: readonly string[]; +}; + +type EnvTargetMetadata = Readonly< + Record +>; + +export type NativeProjectEnvHostMetadata = { + readonly default?: EnvTargetMetadata; + readonly workloads: Readonly>; +}; + /** Native planning carries names and winning scope/secret flags, never stored values. */ export type NativeProjectEnvMetadata = { readonly overlay: string | null; readonly overlayExists: boolean; readonly effectiveMetadata: EffectiveEnvMetadata; readonly unknownScopes: readonly string[]; + readonly hostMetadata?: NativeProjectEnvHostMetadata; }; type NativeEnvSelectionOptions = { @@ -989,6 +1005,7 @@ type NativeEnvSelectionOptions = { readonly overlay: string | null; readonly inheritLocal: boolean; readonly declaredWorkloadNames: readonly string[]; + readonly hostTargets?: NativeProjectEnvHostTargets; readonly signal?: AbortSignal; }; @@ -998,6 +1015,84 @@ function nativeEnvMetadataError(): Error { ); } +function validateNativeHostTargets( + value: unknown, + declaredWorkloadNames: readonly string[] +): NativeProjectEnvHostTargets | undefined { + if (value === undefined) { + return undefined; + } + if ( + !isRecord(value) || + typeof value.includeDefault !== "boolean" || + !Array.isArray(value.workloadNames) + ) { + throw nativeEnvMetadataError(); + } + const declared = new Set(declaredWorkloadNames); + const names: string[] = []; + const seen = new Set(); + for (const name of value.workloadNames) { + if ( + typeof name !== "string" || + !NATIVE_ENV_WORKLOAD_PATTERN.test(name) || + !declared.has(name) || + seen.has(name) + ) { + throw nativeEnvMetadataError(); + } + names.push(name); + seen.add(name); + } + return { includeDefault: value.includeDefault, workloadNames: names }; +} + +/** Resolve only selected host baselines; scope precedence remains owned by the shared projection. */ +function projectNativeHostMetadata(opts: { + readonly targets: NativeProjectEnvHostTargets; + readonly envLayers: readonly (ProjectEnvConfig | null)[]; + readonly projection: ReturnType; + readonly guestResult: NativeProjectEnvMetadata; + readonly signal?: AbortSignal; +}): NativeProjectEnvHostMetadata { + const workloads: Record = {}; + let defaultMetadata: EnvTargetMetadata | undefined; + // Charge the envelope and each returned map, including duplicate default/workload + // baselines. Never build all host maps and then discover that expansion is too large. + let bytes = Buffer.byteLength( + JSON.stringify({ ...opts.guestResult, hostMetadata: { workloads: {} } }) + ); + const acquire = (name: string, scopeNames: readonly string[]) => { + checkNativeEnvCancellation(opts.signal); + const metadata = resolveMetadata({ layers: opts.envLayers, scopeNames }); + bytes += Buffer.byteLength(JSON.stringify({ [name]: metadata })); + if (bytes > NATIVE_CONFIG_INPUT_LIMIT) { + throw nativeEnvMetadataError(); + } + return metadata; + }; + if (opts.targets.includeDefault) { + defaultMetadata = acquire("default", opts.projection.globalHostScopeNames); + } + const scopeNamesByWorkload = new Map( + opts.projection.serviceTargets.map((target) => [ + target.serviceName, + target.hostScopeNames, + ]) + ); + for (const name of opts.targets.workloadNames) { + const scopeNames = scopeNamesByWorkload.get(name); + if (!scopeNames) { + throw nativeEnvMetadataError(); + } + workloads[name] = acquire(name, scopeNames); + } + return { + ...(defaultMetadata === undefined ? {} : { default: defaultMetadata }), + workloads, + }; +} + function checkNativeEnvCancellation(signal?: AbortSignal): void { if (signal?.aborted) { throw new NativeConfigCompilerError( @@ -1071,6 +1166,8 @@ async function readNativeLocalBase(opts: { * Only missing files are optional; selected files are bounded stable regular * files in unredirected native roots. This is not an atomic multi-file snapshot. * Unknown scopes remain names for diagnostics, not authorized workload targets. + * Optional host targets select declared workloads, never host-process names. + * Only requested host baselines are allocated, under a combined output budget. */ export async function resolveProjectEnvMetadataForNativeSelection( opts: NativeEnvSelectionOptions @@ -1084,6 +1181,10 @@ export async function resolveProjectEnvMetadataForNativeSelection( ) { throw nativeEnvMetadataError(); } + const hostTargets = validateNativeHostTargets( + opts.hostTargets, + opts.declaredWorkloadNames + ); const projectRoot = resolve(opts.projectRoot); await assertNativeProjectInputRoot({ projectRoot, signal: opts.signal }); let primaryRoot: string | null = null; @@ -1144,7 +1245,7 @@ export async function resolveProjectEnvMetadataForNativeSelection( metadataByteLimit: NATIVE_CONFIG_INPUT_LIMIT, includeHostMetadata: false, }); - const result: NativeProjectEnvMetadata = { + const guestResult: NativeProjectEnvMetadata = { overlay: opts.overlay, overlayExists: overlay !== null, effectiveMetadata: envLayers.some((layer) => layer !== null) @@ -1152,6 +1253,19 @@ export async function resolveProjectEnvMetadataForNativeSelection( : {}, unknownScopes: projection.metadata.unknownScopes, }; + const result: NativeProjectEnvMetadata = + hostTargets === undefined + ? guestResult + : { + ...guestResult, + hostMetadata: projectNativeHostMetadata({ + targets: hostTargets, + envLayers, + projection, + guestResult, + signal: opts.signal, + }), + }; if (Buffer.byteLength(JSON.stringify(result)) > NATIVE_CONFIG_INPUT_LIMIT) { throw nativeEnvMetadataError(); } diff --git a/tests/native-env-metadata.test.ts b/tests/native-env-metadata.test.ts index e9bae05d5..e66af84ce 100644 --- a/tests/native-env-metadata.test.ts +++ b/tests/native-env-metadata.test.ts @@ -510,3 +510,296 @@ test("host-only many-key input stays within the guest output budget across many result.effectiveMetadata.host ); }); + +function hostMetadata( + projectRoot: string, + opts: { + readonly overlay?: string | null; + readonly inheritLocal?: boolean; + readonly declaredWorkloadNames?: readonly string[]; + readonly includeDefault?: boolean; + readonly workloadNames?: readonly string[]; + } = {} +) { + return resolveProjectEnvMetadataForNativeSelection({ + projectRoot, + overlay: opts.overlay ?? null, + inheritLocal: opts.inheritLocal ?? true, + declaredWorkloadNames: opts.declaredWorkloadNames ?? [ + "web", + "job", + "inactive", + ], + hostTargets: { + includeDefault: opts.includeDefault ?? true, + workloadNames: opts.workloadNames ?? ["web"], + }, + }); +} + +test("host selection is optional and preserves exact empty/default/requested workload presence", async () => { + const p = await project(); + expect(await metadata(p)).not.toHaveProperty("hostMetadata"); + expect((await hostMetadata(p)).hostMetadata).toEqual({ + default: {}, + workloads: { web: {} }, + }); + expect( + (await hostMetadata(p, { includeDefault: false, workloadNames: [] })) + .hostMetadata + ).toEqual({ workloads: {} }); + expect( + ( + await hostMetadata(p, { + includeDefault: false, + workloadNames: ["job", "inactive"], + }) + ).hostMetadata + ).toEqual({ workloads: { job: {}, inactive: {} } }); +}); + +test("host default and workload baselines follow layered specificity, tombstones and reintroduction", async () => { + const p = await project(); + await layer(p, "hack.env.default.yaml", { + global: { WIN: "", DELETE: secure, EMPTY: "" }, + web: { WIN: secure, WEB: secure }, + job: { JOB: secure }, + inactive: { INACTIVE: secure }, + host: { WIN: "", HOST: secure }, + "shell-process": { PRIVATE_SCOPE: secure }, + }); + await layer(p, "hack.env.qa.yaml", { + global: { WIN: secure }, + host: { DELETE: null, EMPTY: null }, + }); + await layer(p, "hack.env.qa.local.yaml", { + global: { EMPTY: "" }, + web: { DELETE: "" }, + }); + const base = await hostMetadata(p); + expect(base.hostMetadata?.default?.WIN).toEqual({ + scope: "host", + secret: false, + }); + expect(base.hostMetadata?.workloads.web?.WIN).toEqual({ + scope: "host", + secret: false, + }); + const result = await hostMetadata(p, { + overlay: "qa", + workloadNames: ["web", "job", "inactive"], + }); + expect(result.hostMetadata?.default?.WIN).toEqual({ + scope: "global", + secret: true, + }); + expect(result.hostMetadata?.default?.DELETE).toBeUndefined(); + expect(result.hostMetadata?.workloads.web?.DELETE).toEqual({ + scope: "web", + secret: false, + }); + expect(result.hostMetadata?.workloads.web?.EMPTY).toEqual({ + scope: "global", + secret: false, + }); + expect(result.hostMetadata?.workloads.job?.JOB).toEqual({ + scope: "job", + secret: true, + }); + expect(result.hostMetadata?.workloads.inactive?.INACTIVE).toEqual({ + scope: "inactive", + secret: true, + }); + expect(result.hostMetadata?.default?.WEB).toBeUndefined(); + expect(result.hostMetadata?.workloads.web?.PRIVATE_SCOPE).toBeUndefined(); + expect(result.unknownScopes).toEqual(["shell-process"]); + expect(JSON.stringify(result)).not.toContain(SENTINEL); +}); + +test("a host-named declared workload disables generic host injection even when not requested", async () => { + const p = await project(); + await layer(p, "hack.env.default.yaml", { + global: { WIN: "" }, + web: { WIN: secure }, + host: { WIN: "", HOST: secure }, + }); + const names = ["web", "host", "inactive-job"]; + const result = await hostMetadata(p, { + declaredWorkloadNames: names, + workloadNames: ["web", "inactive-job"], + }); + expect(result.hostMetadata?.default).toEqual({ + WIN: { scope: "global", secret: false }, + }); + expect(result.hostMetadata?.workloads.web).toEqual({ + WIN: { scope: "web", secret: true }, + }); + expect(result.hostMetadata?.workloads["inactive-job"]).toEqual({ + WIN: { scope: "global", secret: false }, + }); + const selected = await hostMetadata(p, { + declaredWorkloadNames: names, + workloadNames: ["host"], + }); + expect(selected.hostMetadata?.workloads.host?.HOST).toEqual({ + scope: "host", + secret: true, + }); +}); + +test("host metadata observes all six real worktree layers and explicit base selection", async () => { + const { primary, checkout } = await linked(); + await layer(checkout, "hack.env.default.yaml", { + host: { BASE: "", WIN: secure }, + }); + await layer(checkout, "hack.env.qa.yaml", { + global: { OVERLAY: "", WIN: "" }, + }); + await layer(primary, "hack.env.local.yaml", { + host: { PRIMARY: "", WIN: secure }, + }); + await layer(primary, "hack.env.qa.local.yaml", { + global: { PRIMARY_OVERLAY: "", WIN: "" }, + }); + await layer(checkout, "hack.env.local.yaml", { + host: { CURRENT: "", WIN: secure }, + }); + await layer(checkout, "hack.env.qa.local.yaml", { + global: { CURRENT_OVERLAY: "", WIN: "" }, + }); + const result = await hostMetadata(checkout, { overlay: "qa" }); + expect(result.hostMetadata?.default?.WIN).toEqual({ + scope: "global", + secret: false, + }); + expect(result.hostMetadata?.workloads.web).toEqual( + result.hostMetadata?.default + ); + expect(Object.keys(result.hostMetadata?.default ?? {}).sort()).toEqual([ + "BASE", + "CURRENT", + "CURRENT_OVERLAY", + "OVERLAY", + "PRIMARY", + "PRIMARY_OVERLAY", + "WIN", + ]); + const base = await hostMetadata(checkout); + expect(base.hostMetadata?.default?.WIN).toEqual({ + scope: "host", + secret: true, + }); + expect(base.hostMetadata?.default?.PRIMARY_OVERLAY).toBeUndefined(); + expect(base.hostMetadata?.default?.CURRENT_OVERLAY).toBeUndefined(); + for (const exclusion of ["optout", "ci", "slim"]) { + if (exclusion === "ci") { + process.env.CI = "true"; + } + if (exclusion === "slim") { + Reflect.deleteProperty(process.env, "CI"); + process.env.HACK_EXECUTION_MODE = "slim"; + } + const excluded = await hostMetadata(checkout, { + overlay: "qa", + inheritLocal: exclusion !== "optout", + }); + expect(excluded.hostMetadata?.default?.PRIMARY).toBeUndefined(); + expect(excluded.hostMetadata?.default?.PRIMARY_OVERLAY).toBeUndefined(); + expect(excluded.hostMetadata?.default?.CURRENT).toEqual({ + scope: "host", + secret: false, + }); + } +}); + +test("unknown, duplicate, noncanonical and malformed host requests refuse without disclosure", async () => { + const p = await project(); + await layer(p, "hack.env.default.yaml", { + "shell-process": { PRIVATE_SCOPE: secure }, + }); + for (const hostTargets of [ + { includeDefault: true, workloadNames: ["shell-process"] }, + { includeDefault: true, workloadNames: ["web", "web"] }, + { includeDefault: true, workloadNames: ["../secret"] }, + { includeDefault: true, workloadNames: ["Web"] }, + { includeDefault: true, workloadNames: ["a".repeat(64)] }, + { includeDefault: SENTINEL, workloadNames: [] }, + { includeDefault: false, workloadNames: SENTINEL }, + { includeDefault: false, workloadNames: [null] }, + null, + ]) { + await refuses(() => + Reflect.apply(resolveProjectEnvMetadataForNativeSelection, undefined, [ + { + projectRoot: p, + overlay: null, + inheritLocal: false, + declaredWorkloadNames: ["web"], + hostTargets, + }, + ]) + ); + } +}); + +test("host requests retain input redaction and cancellation without decrypting", async () => { + const p = await project(); + await layer(p, "hack.env.default.yaml", { host: { SECRET: secure } }); + expect((await hostMetadata(p)).hostMetadata?.default?.SECRET?.secret).toBe( + true + ); + await writeFile(join(p, ".hack/hack.env.qa.yaml"), `invalid: [${SENTINEL}`); + await refuses(() => hostMetadata(p, { overlay: "qa" })); + const signal = AbortSignal.abort(SENTINEL); + await expect( + resolveProjectEnvMetadataForNativeSelection({ + projectRoot: p, + overlay: null, + inheritLocal: false, + declaredWorkloadNames: ["web"], + hostTargets: { includeDefault: true, workloadNames: ["web"] }, + signal, + }) + ).rejects.toMatchObject({ code: "E_COMPILER_CANCELLED" }); +}); + +test("only requested host baselines expand and combined guest plus host output is bounded", async () => { + const p = await project(); + const names = Array.from({ length: 300 }, (_, index) => `workload-${index}`); + await layer(p, "hack.env.default.yaml", { + host: Object.fromEntries( + Array.from({ length: 4000 }, (_, index) => [`HOST_${index}`, secure]) + ), + }); + const selected = await hostMetadata(p, { + declaredWorkloadNames: names, + workloadNames: [names[0] ?? "workload-0"], + includeDefault: false, + }); + expect(Object.keys(selected.hostMetadata?.workloads ?? {})).toEqual([ + "workload-0", + ]); + expect( + Object.keys(selected.hostMetadata?.workloads["workload-0"] ?? {}) + ).toHaveLength(4000); + expect(Buffer.byteLength(JSON.stringify(selected))).toBeLessThan(1024 * 1024); + await refuses(() => + hostMetadata(p, { declaredWorkloadNames: names, workloadNames: names }) + ); + // Guest metadata alone fits; adding the selected host baselines must share its budget. + await layer(p, "hack.env.default.yaml", { + global: Object.fromEntries( + Array.from({ length: 2500 }, (_, index) => [`KEY_${index}`, ""]) + ), + }); + const fewNames = ["one", "two", "three", "four"]; + expect( + Buffer.byteLength(JSON.stringify(await metadata(p, null, false, fewNames))) + ).toBeLessThan(1024 * 1024); + await refuses(() => + hostMetadata(p, { + declaredWorkloadNames: fewNames, + workloadNames: fewNames, + }) + ); +}); diff --git a/tests/native-host-plan-transport.test.ts b/tests/native-host-plan-transport.test.ts new file mode 100644 index 000000000..36a07a2ed --- /dev/null +++ b/tests/native-host-plan-transport.test.ts @@ -0,0 +1,838 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + compileNativeConfig, + NATIVE_CONFIG_INPUT_LIMIT, + NativeConfigCompilerError, + planNativeConfig, + resolveNativeConfig, +} from "../src/lib/native-config-compiler.ts"; +import { + type NativeEnvMetadata, + parseNativeEnvMetadata, +} from "../src/lib/native-env-plan-protocol.ts"; +import { planNativeProject } from "../src/lib/native-project-validation.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const PROTOCOL = { + transport_version: 1, + authored_version: 1, + plan_version: 1, + resolve_version: 1, + local_version: 1, + env_plan_version: 1, + host_env_plan_version: 1, +}; +const { host_env_plan_version, ...HOSTLESS_PROTOCOL } = PROTOCOL; +void host_env_plan_version; +const INPUT = new TextEncoder().encode('{"schema_version":1,"name":"fixture"}'); +const CANARY = "host-wire-private-canary"; +const HOSTLESS_METADATA = { + metadata_version: 1, + overlay: "qa", + overlay_exists: true, + workloads: { web: { TOKEN: { scope: "web", secret: true } } }, + inactive_scopes: [], +} as const satisfies NativeEnvMetadata; +const HOST_METADATA = { + ...HOSTLESS_METADATA, + host: { + default: { HOST_TOKEN: { scope: "host", secret: true } }, + workloads: { + seed: { SEED_TOKEN: { scope: "seed", secret: true } }, + web: { TOKEN: { scope: "web", secret: true } }, + }, + }, +} as const satisfies NativeEnvMetadata; +const HOST_TARGET = { kind: "host" } as const; +const WEB_TARGET = { kind: "workload", name: "web" } as const; +const SEED_TARGET = { kind: "workload", name: "seed" } as const; +const HOST_TARGETS = { + include_default: true, + workloads: ["seed", "web"], +}; +const INVOCATION = { + command: ["printf", "fixture"], + cwd: ".", + environment: {}, +}; +const HOST_PLAN = { + up: { + before: [ + { ...INVOCATION, name: "web", env_target: HOST_TARGET }, + { ...INVOCATION, name: "prepare", env_target: SEED_TARGET }, + ], + after: [{ ...INVOCATION, name: "after-up", env_target: WEB_TARGET }], + }, + down: { + before: [{ ...INVOCATION, name: "before-down", env_target: HOST_TARGET }], + after: [{ ...INVOCATION, name: "after-down", env_target: WEB_TARGET }], + }, + processes: { + watch: { + ...INVOCATION, + env_target: WEB_TARGET, + startup: "up", + exit: "stop_on_down", + }, + }, +}; +const HOST_BINDING = { + kind: "managed", + key: "HOST_TOKEN", + scope: "host", + secret: true, +} as const; +const HOST_WEB_REPORT = { + env_target: HOST_TARGET, + bindings: { + HOST_TOKEN: HOST_BINDING, + MODE: { kind: "literal", value: "host-web" }, + }, +} as const; +const HOST_REPORT = { + web: HOST_WEB_REPORT, + prepare: { + env_target: SEED_TARGET, + bindings: { + TOKEN: { + kind: "managed", + key: "SEED_TOKEN", + scope: "seed", + secret: true, + }, + }, + }, + "after-up": { env_target: WEB_TARGET, bindings: {} }, + "before-down": { env_target: HOST_TARGET, bindings: {} }, + "after-down": { env_target: WEB_TARGET, bindings: {} }, + watch: { + env_target: WEB_TARGET, + bindings: { retries: { kind: "default", value: "3" } }, + }, +} as const; +const HOSTLESS_SUCCESS = { + transport_version: 1, + ok: true, + plan: { plan_version: 1, services: { web: {} }, jobs: {} }, + semantic_hash: "a".repeat(64), + declared_workloads: { web: "service", seed: "job", unused: "service" }, + local_resolution: { + overlay: "qa", + origin: "checkout_local", + auto_branch: true, + inherit_local: true, + resolution_hash: "b".repeat(64), + }, + environment_plan: { + plan_version: 1, + overlay: "qa", + overlay_exists: true, + complete: true, + workloads: { web: { MODE: { kind: "literal", value: "workload-web" } } }, + warnings: [], + diagnostics: [], + }, +} as const; +const SUCCESS = { + ...HOSTLESS_SUCCESS, + plan: { ...HOSTLESS_SUCCESS.plan, host: HOST_PLAN }, + host_env_targets: HOST_TARGETS, + environment_plan: { ...HOSTLESS_SUCCESS.environment_plan, host: HOST_REPORT }, +}; +let directory = ""; + +beforeEach(async () => { + directory = await realpath( + await mkdtemp(join(tmpdir(), "hack-host-plan-transport-")) + ); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +async function fixture(opts: { + readonly response?: unknown; + readonly protocol?: Readonly>; + readonly body?: string; + readonly onInvocation?: string; + readonly exitCode?: number; +}): Promise { + const path = join(directory, "compiler"); + const body = + opts.body ?? + `process.stdout.write(${JSON.stringify(JSON.stringify(opts.response ?? SUCCESS))}); process.stderr.write(${JSON.stringify(CANARY)}); process.exitCode=${opts.exitCode ?? 0};`; + await Bun.write( + path, + `#!${process.execPath}\n${opts.onInvocation ?? ""}\nif (process.argv[2] === '--protocol') { process.stdout.write(${JSON.stringify(JSON.stringify(opts.protocol ?? PROTOCOL))}); } else { ${body} }\n` + ); + await chmod(path, 0o755); + return path; +} + +async function expectFailure(opts: { + readonly operation: Promise; + readonly code: string; + readonly message?: string; +}): Promise { + const error: unknown = await opts.operation.catch((value: unknown) => value); + expect(error).toBeInstanceOf(NativeConfigCompilerError); + if (!(error instanceof NativeConfigCompilerError)) { + throw new Error("Expected a fixed host planning transport failure"); + } + expect(error.code).toBe(opts.code); + if (opts.message !== undefined) { + expect(error.message).toBe(opts.message); + } + expect(error.message).not.toContain(CANARY); + expect(error.message).not.toContain(directory); +} + +function withHostReport(host: unknown): Record { + return { + ...SUCCESS, + environment_plan: { ...SUCCESS.environment_plan, host }, + }; +} + +test.each([ + { name: "missing", version: undefined }, + { name: "null", version: null }, + { name: "unsupported", version: 2 }, +])("requires host capability before any payload: $name", async ({ + version, +}) => { + const receipt = join(directory, "payload-receipt"); + const protocolReceipt = join(directory, "protocol-input-receipt"); + const binary = await fixture({ + protocol: { ...PROTOCOL, host_env_plan_version: version }, + onInvocation: `if (process.argv[2] === '--protocol') { await Bun.write(${JSON.stringify(protocolReceipt)}, await Bun.stdin.text()); }`, + body: `await Bun.write(${JSON.stringify(receipt)}, await Bun.stdin.text());`, + }); + for (const operation of [ + () => + compileNativeConfig({ input: INPUT, binary, requireHostPlanning: true }), + () => + resolveNativeConfig({ input: INPUT, binary, requireHostPlanning: true }), + () => + planNativeConfig({ input: INPUT, binary, envMetadata: HOST_METADATA }), + () => + planNativeConfig({ + input: INPUT, + binary, + envMetadata: { ...HOSTLESS_METADATA, host: { workloads: {} } }, + }), + ]) { + await expectFailure({ + operation: operation(), + code: "E_COMPILER_VERSION", + message: "Native configuration compiler version mismatch.", + }); + expect(await Bun.file(protocolReceipt).text()).toBe(""); + expect(await Bun.file(receipt).exists()).toBe(false); + } +}); + +test.each([ + { name: "older env v1", protocol: HOSTLESS_PROTOCOL }, + { name: "host-capable", protocol: PROTOCOL }, +])("keeps hostless compile, resolve, and env planning compatible: $name", async ({ + protocol, +}) => { + const binary = await fixture({ protocol, response: HOSTLESS_SUCCESS }); + expect((await compileNativeConfig({ input: INPUT, binary })).ok).toBe(true); + expect((await resolveNativeConfig({ input: INPUT, binary })).ok).toBe(true); + expect( + await planNativeConfig({ + input: INPUT, + binary, + envMetadata: HOSTLESS_METADATA, + }) + ).toEqual(HOSTLESS_SUCCESS); +}); + +test("projects host targets through compile and resolve without a host report", async () => { + const { environment_plan, ...response } = SUCCESS; + void environment_plan; + const binary = await fixture({ response }); + for (const result of [ + await compileNativeConfig({ + input: INPUT, + binary, + requireHostPlanning: true, + }), + await resolveNativeConfig({ + input: INPUT, + binary, + requireHostPlanning: true, + }), + ]) { + expect(result.ok).toBe(true); + expect(result).toHaveProperty("host_env_targets", HOST_TARGETS); + expect(result).not.toHaveProperty("environment_plan"); + } +}); + +test("keeps host and workload names separate across hooks and processes with raw documents and PATH-only env", async () => { + const projectText = '\ufeff{"name":"first","name":"second"}\n'; + const localText = "null\n"; + const input = new TextEncoder().encode(projectText); + const checkoutLocal = new TextEncoder().encode(localText); + const originalInput = input.slice(); + const originalLocal = checkoutLocal.slice(); + const metadata = Object.freeze({ + ...HOST_METADATA, + host: Object.freeze({ + default: Object.freeze(HOST_METADATA.host.default), + workloads: Object.freeze(HOST_METADATA.host.workloads), + }), + }); + const originalMetadata = JSON.stringify(metadata); + const originalEnv = process.env.HACK_TEST_HOST_PLAN_PRIVATE; + process.env.HACK_TEST_HOST_PLAN_PRIVATE = CANARY; + try { + const binary = await fixture({ + body: `const received=JSON.parse(await Bun.stdin.text()); const result=${JSON.stringify(SUCCESS)}; result.plan.received=received; result.plan.arguments=process.argv.slice(2); result.plan.environment=process.env; process.stdout.write(JSON.stringify(result));`, + }); + const result = await planNativeConfig({ + input, + checkoutLocal, + explicitOverlay: null, + envMetadata: metadata, + binary, + profiles: ["with spaces", "--unsafe"], + }); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("Expected host planning success"); + } + expect(result.plan.received).toEqual({ + request_version: 1, + project: projectText, + checkout_local: localText, + explicit_overlay: null, + env_metadata: metadata, + }); + expect(result.plan.arguments).toEqual([ + "plan", + "--profile", + "with spaces", + "--profile", + "--unsafe", + ]); + expect(result.plan.environment).toEqual({ PATH: "/usr/bin:/bin" }); + expect(result.environment_plan).toEqual(SUCCESS.environment_plan); + expect(result).toHaveProperty("host_env_targets", HOST_TARGETS); + expect(input).toEqual(originalInput); + expect(checkoutLocal).toEqual(originalLocal); + expect(JSON.stringify(metadata)).toBe(originalMetadata); + } finally { + restoreEnv("HACK_TEST_HOST_PLAN_PRIVATE", originalEnv); + } +}); + +test("accepts workload-only host targeting without requesting the default scope", async () => { + const response = { + ...SUCCESS, + plan: { + ...HOSTLESS_SUCCESS.plan, + host: { + up: { + before: [{ ...INVOCATION, name: "web", env_target: WEB_TARGET }], + }, + }, + }, + host_env_targets: { include_default: false, workloads: ["web"] }, + environment_plan: { + ...SUCCESS.environment_plan, + host: { web: { env_target: WEB_TARGET, bindings: {} } }, + }, + }; + const binary = await fixture({ response }); + expect( + await planNativeConfig({ + input: INPUT, + binary, + envMetadata: { ...HOSTLESS_METADATA, host: { workloads: { web: {} } } }, + }) + ).toEqual(response); +}); + +test("accepts default-only host targeting with empty metadata maps", async () => { + const response = { + ...SUCCESS, + plan: { + ...HOSTLESS_SUCCESS.plan, + host: { + processes: { + watch: { ...HOST_PLAN.processes.watch, env_target: HOST_TARGET }, + }, + }, + }, + host_env_targets: { include_default: true, workloads: [] }, + environment_plan: { + ...SUCCESS.environment_plan, + host: { watch: { env_target: HOST_TARGET, bindings: {} } }, + }, + }; + const binary = await fixture({ response }); + expect( + await planNativeConfig({ + input: INPUT, + binary, + envMetadata: { + ...HOSTLESS_METADATA, + host: { default: {}, workloads: {} }, + }, + }) + ).toEqual(response); +}); + +test.each([ + "value", + "ciphertext", + "path", +])("refuses sensitive extra host metadata %s before spawning", async (field) => { + const receipt = join(directory, "spawn-receipt"); + const binary = await fixture({ + onInvocation: `await Bun.write(${JSON.stringify(receipt)}, 'spawned');`, + }); + for (const host of [ + { ...HOST_METADATA.host, [field]: CANARY }, + { + ...HOST_METADATA.host, + default: { HOST_TOKEN: { scope: "host", secret: true, [field]: CANARY } }, + }, + { + ...HOST_METADATA.host, + workloads: { + ...HOST_METADATA.host.workloads, + web: { TOKEN: { scope: "web", secret: true, [field]: CANARY } }, + }, + }, + ]) { + await expectFailure({ + operation: planNativeConfig({ + input: INPUT, + binary, + envMetadata: { ...HOSTLESS_METADATA, host }, + }), + code: "E_CONFIG_METADATA", + message: + "Native environment metadata is invalid or exceeds its budget; values omitted.", + }); + expect(await Bun.file(receipt).exists()).toBe(false); + } +}); + +test("bounds host metadata before spawning", async () => { + const receipt = join(directory, "spawn-receipt"); + const binary = await fixture({ + onInvocation: `await Bun.write(${JSON.stringify(receipt)}, 'spawned');`, + }); + await expectFailure({ + operation: planNativeConfig({ + input: INPUT, + binary, + envMetadata: { + ...HOST_METADATA, + host: { + ...HOST_METADATA.host, + default: { + ["X".repeat(NATIVE_CONFIG_INPUT_LIMIT)]: { + scope: "host", + secret: false, + }, + }, + }, + }, + }), + code: "E_CONFIG_METADATA", + message: + "Native environment metadata is invalid or exceeds its budget; values omitted.", + }); + expect(await Bun.file(receipt).exists()).toBe(false); +}); + +test.each([ + { name: "null", host: null }, + { name: "array", host: [] }, + { name: "missing workload maps", host: { default: {} } }, + { name: "default map array", host: { default: [], workloads: {} } }, + { name: "workload maps array", host: { workloads: [] } }, + { name: "noncanonical target", host: { workloads: { Web: {} } } }, + { + name: "invalid binding key", + host: { + default: { lowercase: { scope: "host", secret: true } }, + workloads: {}, + }, + }, + { + name: "invalid scope", + host: { + workloads: { web: { TOKEN: { scope: "../private", secret: true } } }, + }, + }, + { + name: "invalid secret flag", + host: { workloads: { web: { TOKEN: { scope: "web", secret: CANARY } } } }, + }, +])("rejects malformed host metadata shape: $name", ({ host }) => { + expect(parseNativeEnvMetadata({ ...HOSTLESS_METADATA, host })).toBeNull(); +}); + +test("preserves Rust ownership of requested metadata completeness diagnostics", async () => { + const response = { + transport_version: 1, + ok: false, + diagnostics: [ + { + document: "request", + code: "invalid_metadata", + pointer: "/env_metadata/host", + message: "Environment metadata does not match selected targets.", + line: 1, + column: 1, + }, + ], + } as const; + const binary = await fixture({ response, exitCode: 1 }); + expect( + await planNativeConfig({ + input: INPUT, + binary, + envMetadata: { ...HOSTLESS_METADATA, host: { workloads: {} } }, + }) + ).toEqual(response); +}); + +test.each([ + { name: "missing", targets: undefined }, + { name: "null", targets: null }, + { + name: "nonboolean default", + targets: { ...HOST_TARGETS, include_default: 1 }, + }, + { name: "nonarray targets", targets: { ...HOST_TARGETS, workloads: "web" } }, + { + name: "unknown sensitive field", + targets: { ...HOST_TARGETS, path: CANARY }, + }, + { + name: "noncanonical name", + targets: { ...HOST_TARGETS, workloads: ["Web"] }, + }, + { + name: "duplicate names", + targets: { ...HOST_TARGETS, workloads: ["web", "web"] }, + }, + { + name: "unsorted names", + targets: { ...HOST_TARGETS, workloads: ["web", "seed"] }, + }, + { + name: "undeclared target", + targets: { ...HOST_TARGETS, workloads: ["ghost"] }, + }, + { + name: "missing requested target", + targets: { ...HOST_TARGETS, workloads: ["web"] }, + }, + { + name: "extra requested target", + targets: { ...HOST_TARGETS, workloads: ["seed", "unused", "web"] }, + }, + { + name: "default flag mismatch", + targets: { ...HOST_TARGETS, include_default: false }, + }, +])("rejects malformed host target projection in compile and resolve: $name", async ({ + targets, +}) => { + const binary = await fixture({ + response: { ...SUCCESS, host_env_targets: targets }, + }); + for (const operation of [ + () => + compileNativeConfig({ input: INPUT, binary, requireHostPlanning: true }), + () => + resolveNativeConfig({ input: INPUT, binary, requireHostPlanning: true }), + ]) { + await expectFailure({ + operation: operation(), + code: "E_COMPILER_RESPONSE", + }); + } +}); + +test.each([ + { name: "missing host report", response: withHostReport(undefined) }, + { name: "empty host report", response: withHostReport({}) }, + { name: "null host report", response: withHostReport(null) }, + { name: "array host report", response: withHostReport([]) }, + { + name: "extra host name", + response: withHostReport({ ...HOST_REPORT, extra: HOST_WEB_REPORT }), + }, + { + name: "missing host name", + response: withHostReport({ ...HOST_REPORT, watch: undefined }), + }, + { + name: "host report without host plan", + response: { + ...SUCCESS, + plan: HOSTLESS_SUCCESS.plan, + host_env_targets: { include_default: false, workloads: [] }, + }, + }, + { + name: "host report with empty host plan", + response: { + ...SUCCESS, + plan: { ...HOSTLESS_SUCCESS.plan, host: {} }, + host_env_targets: { include_default: false, workloads: [] }, + }, + }, + { + name: "host target mismatch", + response: withHostReport({ + ...HOST_REPORT, + web: { ...HOST_WEB_REPORT, env_target: WEB_TARGET }, + }), + }, + { + name: "workload target mismatch", + response: withHostReport({ + ...HOST_REPORT, + prepare: { ...HOST_REPORT.prepare, env_target: WEB_TARGET }, + }), + }, + { + name: "undeclared workload target", + response: withHostReport({ + ...HOST_REPORT, + watch: { + ...HOST_REPORT.watch, + env_target: { kind: "workload", name: "ghost" }, + }, + }), + }, + { + name: "malformed target kind", + response: withHostReport({ + ...HOST_REPORT, + web: { ...HOST_WEB_REPORT, env_target: { kind: CANARY } }, + }), + }, + { + name: "host target extra name", + response: withHostReport({ + ...HOST_REPORT, + web: { ...HOST_WEB_REPORT, env_target: { kind: "host", name: "web" } }, + }), + }, + { + name: "workload target extra path", + response: withHostReport({ + ...HOST_REPORT, + watch: { + ...HOST_REPORT.watch, + env_target: { ...WEB_TARGET, path: CANARY }, + }, + }), + }, + { + name: "host entry extra ciphertext", + response: withHostReport({ + ...HOST_REPORT, + web: { ...HOST_WEB_REPORT, ciphertext: CANARY }, + }), + }, + { + name: "managed binding extra value", + response: withHostReport({ + ...HOST_REPORT, + web: { + ...HOST_WEB_REPORT, + bindings: { HOST_TOKEN: { ...HOST_BINDING, value: CANARY } }, + }, + }), + }, + { + name: "nonrecord bindings", + response: withHostReport({ + ...HOST_REPORT, + web: { ...HOST_WEB_REPORT, bindings: [] }, + }), + }, + { + name: "invalid destination", + response: withHostReport({ + ...HOST_REPORT, + web: { + ...HOST_WEB_REPORT, + bindings: { "invalid-key": { kind: "literal", value: CANARY } }, + }, + }), + }, + { + name: "declared target omitted", + response: { ...SUCCESS, declared_workloads: { web: "service" } }, + }, + { + name: "duplicate invocation name", + response: { + ...SUCCESS, + plan: { + ...SUCCESS.plan, + host: { + ...HOST_PLAN, + processes: { ...HOST_PLAN.processes, web: HOST_PLAN.processes.watch }, + }, + }, + }, + }, +])("rejects host report and normalized plan inconsistency: $name", async ({ + response, +}) => { + const binary = await fixture({ response }); + await expectFailure({ + operation: planNativeConfig({ + input: INPUT, + binary, + envMetadata: HOST_METADATA, + }), + code: "E_COMPILER_RESPONSE", + }); +}); + +test("retains own __proto__ host bindings through JSON roundtrip and strips raw envelope extras", async () => { + const bindings = Object.fromEntries([ + ["__proto__", { kind: "literal", value: "fixture-proto" } as const], + ]); + const environmentPlan = { + ...SUCCESS.environment_plan, + host: { ...HOST_REPORT, web: { ...HOST_WEB_REPORT, bindings } }, + }; + const binary = await fixture({ + response: { + ...SUCCESS, + environment_plan: environmentPlan, + private_payload: CANARY, + }, + }); + const result = await planNativeConfig({ + input: INPUT, + binary, + envMetadata: HOST_METADATA, + }); + expect(result).toEqual({ ...SUCCESS, environment_plan: environmentPlan }); + expect(JSON.stringify(result)).not.toContain(CANARY); + expect(result).not.toHaveProperty("envelope"); + if (!result.ok) { + throw new Error("Expected host report with an authored own data key"); + } + expect( + Object.hasOwn( + result.environment_plan.host?.web?.bindings ?? {}, + "__proto__" + ) + ).toBe(true); + expect(JSON.parse(JSON.stringify(result.environment_plan))).toEqual( + environmentPlan + ); +}); + +async function projectWithMalformedSelectedMetadata(): Promise { + const projectRoot = join(directory, "project"); + await mkdir(join(projectRoot, ".hack"), { recursive: true }); + await Bun.write(join(projectRoot, ".hack/hack.project.json"), INPUT); + await Bun.write( + join(projectRoot, ".hack/hack.env.qa.yaml"), + `version: [${CANARY}` + ); + return projectRoot; +} + +const PROJECT_SUCCESS = { + ...SUCCESS, + plan: { ...SUCCESS.plan, worktree: { inherit_local: false } }, + local_resolution: { ...SUCCESS.local_resolution, inherit_local: false }, +}; + +test("project planning checks host capability before reading malformed selected YAML", async () => { + const projectRoot = await projectWithMalformedSelectedMetadata(); + const resolveReceipt = join(directory, "resolve-receipt"); + const binary = await fixture({ + protocol: HOSTLESS_PROTOCOL, + body: `await Bun.stdin.text(); if (process.argv[2] === 'resolve') { await Bun.write(${JSON.stringify(resolveReceipt)}, 'received'); } process.stdout.write(${JSON.stringify(JSON.stringify(PROJECT_SUCCESS))});`, + }); + const originalBinary = process.env.HACK_CONFIG_COMPILER_BINARY; + process.env.HACK_CONFIG_COMPILER_BINARY = binary; + try { + await expectFailure({ + operation: planNativeProject({ startDir: projectRoot }), + code: "E_COMPILER_VERSION", + message: "Native configuration compiler version mismatch.", + }); + expect(await Bun.file(resolveReceipt).exists()).toBe(false); + // Confirm the selected YAML is an active failure if the host handshake passes. + await fixture({ response: PROJECT_SUCCESS }); + await expectFailure({ + operation: planNativeProject({ startDir: projectRoot }), + code: "E_CONFIG_METADATA", + message: + "Cannot inspect selected managed environment metadata; values omitted.", + }); + } finally { + restoreEnv("HACK_CONFIG_COMPILER_BINARY", originalBinary); + } +}); + +test.each([ + { + name: "authored hash", + resolved: { ...PROJECT_SUCCESS, semantic_hash: "c".repeat(64) }, + }, + { + name: "host target projection", + resolved: { + ...PROJECT_SUCCESS, + plan: { + ...PROJECT_SUCCESS.plan, + host: { + processes: { + watch: { ...HOST_PLAN.processes.watch, env_target: HOST_TARGET }, + }, + }, + }, + host_env_targets: { include_default: true, workloads: [] }, + }, + }, +])("project planning refuses changed $name before malformed selected YAML", async ({ + resolved, +}) => { + const projectRoot = await projectWithMalformedSelectedMetadata(); + const planReceipt = join(directory, "plan-receipt"); + const binary = await fixture({ + body: `await Bun.stdin.text(); if (process.argv[2] === 'plan') { await Bun.write(${JSON.stringify(planReceipt)}, 'received'); } process.stdout.write(JSON.stringify(process.argv[2] === 'resolve' ? ${JSON.stringify(resolved)} : ${JSON.stringify(PROJECT_SUCCESS)}));`, + }); + const originalBinary = process.env.HACK_CONFIG_COMPILER_BINARY; + process.env.HACK_CONFIG_COMPILER_BINARY = binary; + try { + await expectFailure({ + operation: planNativeProject({ startDir: projectRoot }), + code: "E_COMPILER_RESPONSE", + message: + "Native local resolution changed the authored identity or host targets.", + }); + expect(await Bun.file(planReceipt).exists()).toBe(false); + } finally { + restoreEnv("HACK_CONFIG_COMPILER_BINARY", originalBinary); + } +});