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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ into a host command, and `--target` on env and host commands chooses the `host`
| `hack down` | `--env`, `--target` | At teardown, for lifecycle down hooks only; omitted reuses a named overlay recorded for the running stack, else the configured default. | None; nothing is delivered to containers. | The native runtime backend refuses `--env`. |
| `hack restart` | `--env`, `--target` | When services are recreated; omitted reuses a named overlay recorded for the running stack, else the configured default, and an explicit `--env` (including `base`) wins. | This is the refresh path for the whole stack or the named services. | Authored native projects receive the same selection before dispatch. |
| `hack run` | `--env` | Per invocation, for the new one-off container. | Each run resolves again. | The native runtime backend refuses `--env`; restart with the selection first. |
| `hack exec` | `--env` | Checked at invocation against the env recorded for the running stack; the command runs with the env the container was created with. Compose warns on stderr when the service's resolved env changed since it started. | `hack restart <service>` (or `hack restart --env <name>`) | Compose refuses when the requested env differs from the env recorded for the running stack; the native runtime backend refuses `--env`. |
| `hack exec` | `--env` | Checked at invocation against the env recorded for the running stack; the command runs with the env the container was created with. Compose exec and native-runtime exec (Compose-file and authored native projects) warn on stderr when the service's resolved env changed since it started. | `hack restart <service>` (or `hack restart --env <name>`); native projects restart as a whole with `hack restart` | Compose refuses when the requested env differs from the env recorded for the running stack; the native runtime backend refuses `--env`. |
| `hack env list` | `--env`, `--service` | Per invocation, read-only; secret values are masked unless `--show-secrets`. | Not needed. | Reads files; no runtime required. |
| `hack env get` | `--env`, `--service` | Per invocation; prints exactly one plaintext value to stdout. | Not needed. | Reads files; refuses native project inputs. |
| `hack env explain` | `--env`, `--service`, `--target` | Per invocation; reports source, precedence and delivery without the value. | Not needed. | Reads files; no runtime required. |
Expand Down Expand Up @@ -278,7 +278,30 @@ command's output. The warning never prints a key or value.
The check is advisory. Identical env, state written before this check existed, and a
service whose last targeted restart failed produce no warning. `hack up <service>`,
`hack restart <service>` and `hack env apply --service <service>` refresh only the
named services' digests. The native runtime backend does not run this check.
named services' digests.

Projects on the native runtime backend run the same check. The foreground `hack up` or `hack restart` records
value-free digests of the env selection it delivered once the run is ready, in
`.hack/.internal/native-env-fingerprints/` (one file per instance, mode 0600). The
record is bound to that run and removed when the foreground ends. `hack exec` resolves
the same selection again: for a Compose-file (image) project, the env for the run's
recorded env name; for an authored project, the managed values for the recorded
`--env` and `--profile` selection. When they differ, it prints one fixed warning to
stderr that names the service and suggests `hack restart`, since native restart is
whole-project. An image-project exec receives freshly resolved values itself, so its
warning says the running service still uses the env it started with. A restart drops
the record once the previous run stops, so a failed restart produces no warning. A
record from another run, a missing record, or any resolution failure produces no
warning. An authored-project check is bounded: after five seconds without a result
it is skipped and the command's output and exit code follow as usual. Ctrl-C during
the check ends the command with exit code 130 without waiting for it.

Two exec paths do not run this check yet: an authored `.hack/hack.project.json`
project on the Compose backend (see
[native configuration with Compose](reference/native-compose-commands.md)), and an
existing Compose project switched with
[`hack config adopt`](reference/native-compose-adoption.md), whose exec runs in the
retained containers.

MCP tools accept no env selector. They run the CLI without `--env`, so each row's
omitted-`--env` behavior applies.
Expand Down Expand Up @@ -545,7 +568,13 @@ no key file exists, the check classifies `HACK_ENV_SECRET_KEY` instead. The chec
read-only. It never prints key bytes or absolute paths, and `--fix` does not act on it.
Each finding names a project-relative subject and the exact command to run from the
project root. `--json` adds an `envKey` object to the check with `status` and
`findings` (`code`, `status`, `subject`, `remedy`).
`findings` (`code`, `status`, `subject`, `remedy`). The native runtime backend's
`hack doctor` runs the same check inside a project.

Nested public artifact directories are found with a bounded walk from the project
root: at most six levels deep, 4096 directories and 100,000 entries. It skips
`node_modules` and `.git`, does not follow symlinked directories, and reports a path
it cannot spell portably as `<unprintable-path>/.hack.secret.key`.

| Code | Status | Meaning |
| --- | --- | --- |
Expand All @@ -559,7 +588,7 @@ project root. `--json` adds an `envKey` object to the check with `status` and
| `env-key-not-regular` | error | the key path is a directory or another non-file entry |
| `env-key-unreadable` | error | unreadable, oversized or not UTF-8 |
| `env-key-tracked` | error | a file named `.hack.secret.key` is tracked by git at any depth |
| `env-key-public-path` | error | a key copy sits in `public/`, `static/`, `dist/`, `build/`, `out/`, `www/`, `_site/`, `.next/` or `.output/` |
| `env-key-public-path` | error | a key copy sits directly in `public/`, `static/`, `dist/`, `build/`, `out/`, `www/`, `_site/`, `.next/` or `.output/`, at the project root or in a nested package such as `apps/web/public/` |

Hack derives the AES-256-GCM key by hashing the key text with SHA-256, so key
strength is the entropy of that text. Generated keys are 32 random bytes in base64url.
Expand Down
2 changes: 2 additions & 0 deletions src/backends/native-authored-project-restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export type NativeAuthoredProjectRestartOptions = {
readonly onReady?: ServeOptions["onReady"];
readonly onExitDiagnostic?: ServeOptions["onExitDiagnostic"];
readonly onHookDiagnostic?: ServeOptions["onHookDiagnostic"];
readonly onEnvironmentSelected?: ServeOptions["onEnvironmentSelected"];
/** Execution seam for sequencing controls; the CLI always uses the real owners. */
readonly dependencies?: Partial<Dependencies>;
};
Expand Down Expand Up @@ -170,6 +171,7 @@ export async function restartNativeAuthoredProject(
onReady: opts.onReady,
onExitDiagnostic: opts.onExitDiagnostic,
onHookDiagnostic: opts.onHookDiagnostic,
onEnvironmentSelected: opts.onEnvironmentSelected,
});
} catch (error: unknown) {
const startup =
Expand Down
127 changes: 101 additions & 26 deletions src/backends/native-authored-project-start.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { createHash } from "node:crypto";
import { isAbsolute } from "node:path";
import {
type ComposeEnvFingerprintRecord,
withComposeServiceEnvFingerprints,
} from "../lib/compose-env-fingerprint.ts";
import { deepFreeze } from "../lib/guards.ts";
import { NativeComposeHostHookError } from "../lib/native-compose-host-contract.ts";
import { NativeConfigCompilerError } from "../lib/native-config-compiler.ts";
Expand Down Expand Up @@ -167,8 +171,41 @@ type Options = {
) => undefined;
readonly onExitDiagnostic?: (diagnostic: NativeExitDiagnostic) => void;
readonly onHookDiagnostic?: (event: NativeHookDiagnostic) => void;
/**
* Advisory stale-env bookkeeping (HACK-1140): value-free digests of the
* managed selection delivered to every selected workload, observed
* synchronously before the graph starts. Grants no authority; a failing
* observer never fails startup.
*/
readonly onEnvironmentSelected?: (
fingerprint: ComposeEnvFingerprintRecord
) => undefined;
};

type SelectedEnvironment = Readonly<
Record<string, Readonly<Record<string, string>>>
>;

function observeSelectedEnvironment(
opts: Options,
selected: SelectedEnvironment
): void {
if (!opts.onEnvironmentSelected) {
return;
}
try {
opts.onEnvironmentSelected(
withComposeServiceEnvFingerprints({
existing: undefined,
services: Object.keys(selected),
envByService: selected,
})
);
} catch {
// Advisory only; startup proceeds without a stale-env record.
}
}

function observedReady(
opts: Options,
ready: NativeAuthoredProjectRunSelection
Expand Down Expand Up @@ -216,31 +253,38 @@ function matchingReview(
return review;
}

/** Project only compiler-selected managed source keys. Rust owns destination remapping. */
async function privateDelivery(
inputs: Inputs,
review: NativeAuthoredReview,
lifetimeMs: () => number
): Promise<Buffer | undefined> {
const sources = Object.entries(inputs.result.environment_plan.workloads)
.map(([name, bindings]) => ({
name,
keys: [
...new Set(
Object.values(bindings).flatMap((binding) =>
binding.kind === "managed" ? [binding.key] : []
)
),
],
}))
.filter(({ keys }) => keys.length > 0);
if (sources.length === 0) {
return undefined;
}
const values = await inputs.resolveManagedValues();
const services = Object.fromEntries(
sources.map(({ name, keys }) => {
const selected = values[name];
type EnvironmentPlan = Inputs["result"]["environment_plan"];

/** Compiler-selected managed source keys per workload; Rust owns destination remapping. */
function managedSourceKeys(
environmentPlan: EnvironmentPlan
): readonly { readonly name: string; readonly keys: readonly string[] }[] {
return Object.entries(environmentPlan.workloads).map(([name, bindings]) => ({
name,
keys: [
...new Set(
Object.values(bindings).flatMap((binding) =>
binding.kind === "managed" ? [binding.key] : []
)
),
],
}));
}

/**
* The managed values startup delivers, per selected workload: only
* compiler-selected source keys. Workloads without managed keys select an
* empty set. Values stay in memory; callers digest or deliver them privately.
*/
export function selectNativeAuthoredManagedEnvironment(opts: {
readonly environmentPlan: EnvironmentPlan;
readonly values: Readonly<
Record<string, Readonly<Record<string, string>> | undefined>
>;
}): Readonly<Record<string, Readonly<Record<string, string>>>> {
return Object.fromEntries(
managedSourceKeys(opts.environmentPlan).map(({ name, keys }) => {
const selected = opts.values[name];
return [
name,
Object.fromEntries(
Expand All @@ -259,6 +303,32 @@ async function privateDelivery(
];
})
);
}

/** Private delivery of the selected managed values, or none when nothing is selected. */
async function privateDelivery(
inputs: Inputs,
review: NativeAuthoredReview,
lifetimeMs: () => number,
observe: (environment: SelectedEnvironment) => void
): Promise<Buffer | undefined> {
const environmentPlan = inputs.result.environment_plan;
const managed = managedSourceKeys(environmentPlan).some(
({ keys }) => keys.length > 0
);
const selected = selectNativeAuthoredManagedEnvironment({
environmentPlan,
values: managed ? await inputs.resolveManagedValues() : {},
});
observe(selected);
if (!managed) {
return undefined;
}
const services = Object.fromEntries(
Object.entries(selected).filter(
([, values]) => Object.keys(values).length > 0
)
);
const lifetime = Math.floor(lifetimeMs() / 1000);
if (lifetime < 1 || lifetime > 300) {
throw new Error("Native managed lifetime expired; values omitted.");
Expand Down Expand Up @@ -949,7 +1019,12 @@ export async function serveNativeAuthoredProject(
attempt = { source, start: await admission.reserve({ review }) };
outcome = "retained";
stage = "runtime";
payload = await privateDelivery(inputs, review, remaining);
payload = await privateDelivery(
inputs,
review,
remaining,
(selected) => observeSelectedEnvironment(opts, selected)
);
await inputs.assertFresh();
await storageTool?.assertFresh();
await source.assertFresh();
Expand Down
Loading
Loading