From bdab2bdd33582f3a9c264e8d83e335485216974b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 3 Apr 2026 11:16:48 -0400 Subject: [PATCH 1/9] fix(runtime): trust local hack CA for host workflows --- src/commands/env.ts | 11 +- src/commands/global.ts | 518 ++++++++++++++++------ src/commands/project.ts | 9 +- src/commands/session.ts | 21 +- src/lib/local-ca.ts | 123 +++++ tests/env-exec-command.test.ts | 77 ++++ tests/global-command.macos.test.ts | 151 +++++++ tests/project-lifecycle-processes.test.ts | 32 ++ tests/session-env-command.test.ts | 75 ++++ 9 files changed, 853 insertions(+), 164 deletions(-) create mode 100644 src/lib/local-ca.ts diff --git a/src/commands/env.ts b/src/commands/env.ts index f4308e76..46635d25 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -36,6 +36,7 @@ import { serializeEnvClassificationForJson, serializeEnvStorageForJson as serializeEnvStorageForJsonShape, } from "../lib/hack-env-status.ts"; +import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts"; import type { ProjectContext } from "../lib/project.ts"; import { defaultProjectSlugFromPath, @@ -605,7 +606,7 @@ async function resolveEnvInjection(input: { target: input.target, }); return { - env: adaptEnvForHostExecution({ + env: await adaptEnvForHostExecution({ env, target: input.target, serviceNames, @@ -627,7 +628,7 @@ async function resolveEnvInjection(input: { composeFile: input.project.composeFile, }); return { - env: adaptEnvForHostExecution({ + env: await adaptEnvForHostExecution({ env: selectHackEnvValues({ resolved, serviceName: input.serviceName, @@ -659,9 +660,9 @@ function adaptEnvForHostExecution(input: { readonly env: Readonly>; readonly target: (typeof HOST_ENV_TARGET_VALUES)[number]; readonly serviceNames: readonly string[]; -}): Record { +}): Promise> { if (input.target !== "host") { - return { ...input.env }; + return Promise.resolve({ ...input.env }); } const composeServiceNames = new Set(input.serviceNames); @@ -673,7 +674,7 @@ function adaptEnvForHostExecution(input: { composeServiceNames, }); } - return out; + return appendHackHostTrustEnvironment(out); } function rewriteEnvValueForHostExecution(input: { diff --git a/src/commands/global.ts b/src/commands/global.ts index fbebee16..727dddac 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -57,11 +57,19 @@ import { ensureDir, pathExists, readTextFile, + writeTextFile, writeTextFileIfChanged, } from "../lib/fs.ts"; import { getString, isRecord } from "../lib/guards.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; import { parseJsonLines } from "../lib/json-lines.ts"; +import { + buildHackHostTrustEnvironment, + renderHackHostTrustShellExports, + resolveHackHostTrustBundlePath, + resolveHackHostTrustEnvScriptPath, + resolveHackLocalCaCertPath, +} from "../lib/local-ca.ts"; import { ensureBundledMutagenInstalled, getMutagenPath, @@ -492,172 +500,233 @@ async function globalInstall(): Promise { } const s = spinner(); - s.start("Ensuring gum…"); + await ensureOptionalInstallDependencies({ spinner: s }); + await warnIfSessionsMuxUnavailable(); + const useStaticIps = await ensureGlobalDockerNetworks({ spinner: s }); + const paths = getGlobalPaths(); + await materializeGlobalInstallFiles({ paths, useStaticIps }); + logger.success({ message: "Global files ready in ~/.hack/" }); + await globalUp(); + await completeGlobalInstallHostBootstrap(); + note( + [ + "Next:", + "- Open https://logs.hack", + "- Start a repo with: hack init && hack up", + ].join("\n"), + "Global install" + ); + + return 0; +} + +async function ensureOptionalInstallDependencies(opts: { + readonly spinner: ReturnType; +}): Promise { + await ensureManagedGum({ spinner: opts.spinner }); + await ensureManagedMutagen({ spinner: opts.spinner }); + await ensureMacInstallDependencies(); +} + +async function ensureManagedGum(opts: { + readonly spinner: ReturnType; +}): Promise { + opts.spinner.start("Ensuring gum…"); const gum = await ensureBundledGumInstalled(); if (gum.ok) { - s.stop(gum.installed ? "Installed bundled gum" : "gum already installed"); - } else { - const systemGum = Bun.which("gum"); - s.stop( - systemGum ? "gum available on PATH" : "gum not installed (optional)" + opts.spinner.stop( + gum.installed ? "Installed bundled gum" : "gum already installed" ); - if (gum.reason === "failed") { - logger.warn({ - message: `gum install failed: ${gum.message ?? "unknown error"}`, - }); - } + return; + } + + const systemGum = Bun.which("gum"); + opts.spinner.stop( + systemGum ? "gum available on PATH" : "gum not installed (optional)" + ); + if (gum.reason === "failed") { + logger.warn({ + message: `gum install failed: ${gum.message ?? "unknown error"}`, + }); } +} - s.start("Ensuring mutagen…"); +async function ensureManagedMutagen(opts: { + readonly spinner: ReturnType; +}): Promise { + opts.spinner.start("Ensuring mutagen…"); const mutagen = await ensureBundledMutagenInstalled(); if (mutagen.ok) { - s.stop( + opts.spinner.stop( mutagen.installed ? "Installed managed mutagen" : "mutagen already installed" ); - } else { - const systemMutagen = getMutagenPath(); - s.stop( - systemMutagen ? "mutagen available on PATH" : "mutagen not installed" - ); - if (!systemMutagen) { - const detail = mutagen.message ? `: ${mutagen.message}` : ""; - logger.warn({ - message: `mutagen install skipped (${mutagen.reason}${detail})`, - }); - logger.warn({ - message: - "Remote sync may fail without mutagen. Repair with: hack doctor --fix", - }); - } + return; } + const systemMutagen = getMutagenPath(); + opts.spinner.stop( + systemMutagen ? "mutagen available on PATH" : "mutagen not installed" + ); + if (!systemMutagen) { + const detail = mutagen.message ? `: ${mutagen.message}` : ""; + logger.warn({ + message: `mutagen install skipped (${mutagen.reason}${detail})`, + }); + logger.warn({ + message: + "Remote sync may fail without mutagen. Repair with: hack doctor --fix", + }); + } +} + +async function ensureMacInstallDependencies(): Promise { if (isMac()) { await ensureMacChafa(); await ensureMacMkcert(); - } else { - logger.warn({ - message: "Skipping chafa install (only automated on macOS for now).", - }); + return; } - await warnIfSessionsMuxUnavailable(); + logger.warn({ + message: "Skipping chafa install (only automated on macOS for now).", + }); +} - s.start("Checking Docker…"); +async function ensureGlobalDockerNetworks(opts: { + readonly spinner: ReturnType; +}): Promise { + opts.spinner.start("Checking Docker…"); await ensureDockerRunning(); - s.stop("Docker is running"); + opts.spinner.stop("Docker is running"); - s.start("Ensuring shared networks…"); + opts.spinner.start("Ensuring shared networks…"); const ingressNetwork = await ensureNetwork(DEFAULT_INGRESS_NETWORK, { subnet: DEFAULT_INGRESS_SUBNET, gateway: DEFAULT_INGRESS_GATEWAY, }); await ensureNetwork(DEFAULT_LOGGING_NETWORK); - s.stop( + opts.spinner.stop( `Networks ready (${DEFAULT_INGRESS_NETWORK}, ${DEFAULT_LOGGING_NETWORK})` ); - const useStaticIps = ingressNetwork.hasSubnet; - if (!useStaticIps) { + + if (!ingressNetwork.hasSubnet) { logger.warn({ message: "hack-dev network has no subnet; CoreDNS will resolve via dynamic IP.", }); } + return ingressNetwork.hasSubnet; +} - const paths = getGlobalPaths(); - await ensureDir(paths.caddyDir); - await ensureDir(paths.loggingDir); - await ensureDir(paths.schemasDir); - await ensureDir(dirname(paths.grafanaDatasource)); - await ensureDir(dirname(paths.grafanaDashboardsProvisioning)); - await ensureDir(dirname(paths.grafanaDashboard)); - await ensureDir(dirname(paths.alloyConfig)); - await ensureDir(dirname(paths.lokiConfig)); +async function materializeGlobalInstallFiles(opts: { + readonly paths: ReturnType; + readonly useStaticIps: boolean; +}): Promise { + await ensureGlobalInstallDirectories({ paths: opts.paths }); + await writeGlobalInstallAssets({ + paths: opts.paths, + useStaticIps: opts.useStaticIps, + }); +} +async function ensureGlobalInstallDirectories(opts: { + readonly paths: ReturnType; +}): Promise { + await ensureDir(opts.paths.caddyDir); + await ensureDir(opts.paths.loggingDir); + await ensureDir(opts.paths.schemasDir); + await ensureDir(dirname(opts.paths.grafanaDatasource)); + await ensureDir(dirname(opts.paths.grafanaDashboardsProvisioning)); + await ensureDir(dirname(opts.paths.grafanaDashboard)); + await ensureDir(dirname(opts.paths.alloyConfig)); + await ensureDir(dirname(opts.paths.lokiConfig)); +} + +async function writeGlobalInstallAssets(opts: { + readonly paths: ReturnType; + readonly useStaticIps: boolean; +}): Promise { await writeWithPromptIfDifferent( - paths.caddyCompose, + opts.paths.caddyCompose, renderGlobalCaddyCompose({ - useStaticCoreDnsIp: useStaticIps, - useStaticCaddyIp: useStaticIps, + useStaticCoreDnsIp: opts.useStaticIps, + useStaticCaddyIp: opts.useStaticIps, }) ); await writeWithPromptIfDifferent( - paths.coreDnsConfig, - renderGlobalCoreDnsConfig({ useStaticCaddyIp: useStaticIps }) + opts.paths.coreDnsConfig, + renderGlobalCoreDnsConfig({ useStaticCaddyIp: opts.useStaticIps }) ); await writeWithPromptIfDifferent( - paths.loggingCompose, + opts.paths.loggingCompose, renderGlobalLoggingCompose() ); await writeWithPromptIfDifferent( - paths.alloyConfig, + opts.paths.alloyConfig, renderGlobalAlloyConfig() ); await writeWithPromptIfDifferent( - paths.lokiConfig, + opts.paths.lokiConfig, renderGlobalLokiConfigYaml() ); await writeWithPromptIfDifferent( - paths.grafanaDatasource, + opts.paths.grafanaDatasource, renderGlobalGrafanaDatasourceYaml() ); await writeWithPromptIfDifferent( - paths.grafanaDashboardsProvisioning, + opts.paths.grafanaDashboardsProvisioning, renderGlobalGrafanaDashboardsProvisioningYaml() ); await writeWithPromptIfDifferent( - paths.grafanaDashboard, + opts.paths.grafanaDashboard, renderGlobalGrafanaLogsDashboardJson() ); await writeWithPromptIfDifferent( - paths.configSchema, + opts.paths.configSchema, renderProjectConfigSchemaJson() ); await writeWithPromptIfDifferent( - paths.envSchema, + opts.paths.envSchema, renderProjectEnvSchemaJson() ); await writeWithPromptIfDifferent( - paths.managedEnvSchema, + opts.paths.managedEnvSchema, renderProjectManagedEnvSchemaJson() ); await writeWithPromptIfDifferent( - paths.branchesSchema, + opts.paths.branchesSchema, renderProjectBranchesSchemaJson() ); +} - logger.success({ message: "Global files ready in ~/.hack/" }); - await globalUp(); - +async function completeGlobalInstallHostBootstrap(): Promise { if (isMac()) { - const hostDnsTarget = await resolvePreferredMacHostDnsTarget(); - await ensureMacHackDns({ targetIp: hostDnsTarget }); - await ensureMacTrustCaddyLocalCa(); - await maybeOfferMacRecoverySetup(); - } else { - logger.warn({ - message: "Skipping DNS bootstrap (only implemented for macOS for now).", - }); - note( - [ - `You need wildcard DNS for *.hack pointing to ${DEFAULT_HOST_DNS_IP}.`, - "Recommended: dnsmasq + OS resolver config for the 'hack' TLD.", - ].join("\n"), - "DNS setup" - ); + await bootstrapMacGlobalInstall(); + return; } + logger.warn({ + message: "Skipping DNS bootstrap (only implemented for macOS for now).", + }); note( [ - "Next:", - "- Open https://logs.hack", - "- Start a repo with: hack init && hack up", + `You need wildcard DNS for *.hack pointing to ${DEFAULT_HOST_DNS_IP}.`, + "Recommended: dnsmasq + OS resolver config for the 'hack' TLD.", ].join("\n"), - "Global install" + "DNS setup" ); +} - return 0; +async function bootstrapMacGlobalInstall(): Promise { + const hostDnsTarget = await resolvePreferredMacHostDnsTarget(); + await ensureMacHackDns({ targetIp: hostDnsTarget }); + const certPath = await exportCaddyLocalCaCert(); + if (certPath) { + await ensureMacTrustCaddyLocalCa({ certPath }); + await configureMacHostTlsTrust({ certPath }); + } + await maybeOfferMacRecoverySetup(); } async function globalLogsReset(): Promise { @@ -1136,13 +1205,8 @@ async function inspectIngressNetworkSnapshot(): Promise(); const containerIpByName = new Map(); for (const entry of parsed) { - if (!entry || typeof entry !== "object") { - continue; - } if (subnet === null) { - const ipamConfig = ( - entry as { - IPAM?: { - Config?: Array<{ Subnet?: unknown; Gateway?: unknown }>; - }; - } - ).IPAM?.Config; - if (Array.isArray(ipamConfig)) { - for (const config of ipamConfig) { - if (subnet === null && typeof config?.Subnet === "string") { - subnet = config.Subnet; - } - if (gateway === null && typeof config?.Gateway === "string") { - gateway = config.Gateway; - } - if (subnet !== null && gateway !== null) { - break; - } - } - } + const ipamConfig = readIngressIpamConfig({ entry }); + subnet = ipamConfig.subnet; + gateway = ipamConfig.gateway; } - const containers = (entry as { Containers?: Record }) - .Containers; - if (!containers || typeof containers !== "object") { - continue; - } - - for (const info of Object.values(containers)) { - if (!info || typeof info !== "object") { - continue; - } - const record = info as { Name?: unknown; IPv4Address?: unknown }; - const name = typeof record.Name === "string" ? record.Name : ""; - const ipRaw = - typeof record.IPv4Address === "string" ? record.IPv4Address : ""; - if (!(name && ipRaw)) { - continue; - } - const ip = extractIpv4Address({ raw: ipRaw }); - if (ip.length === 0) { - continue; - } - usedIps.add(ip); - containerIpByName.set(name, ip); - } + collectIngressContainerIps({ + entry, + usedIps, + containerIpByName, + }); } return { @@ -1211,6 +1236,85 @@ async function inspectIngressNetworkSnapshot(): Promise[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(opts.stdout); + } catch { + return null; + } + if (!Array.isArray(parsed)) { + return null; + } + return parsed.filter(isRecord); +} + +function readIngressIpamConfig(opts: { + readonly entry: Record; +}): { + readonly subnet: string | null; + readonly gateway: string | null; +} { + const ipam = opts.entry.IPAM; + if (!isRecord(ipam)) { + return { subnet: null, gateway: null }; + } + + const rawConfig = ipam.Config; + if (!Array.isArray(rawConfig)) { + return { subnet: null, gateway: null }; + } + + let subnet: string | null = null; + let gateway: string | null = null; + for (const config of rawConfig) { + if (!isRecord(config)) { + continue; + } + if (subnet === null && typeof config.Subnet === "string") { + subnet = config.Subnet; + } + if (gateway === null && typeof config.Gateway === "string") { + gateway = config.Gateway; + } + if (subnet !== null && gateway !== null) { + break; + } + } + + return { subnet, gateway }; +} + +function collectIngressContainerIps(opts: { + readonly entry: Record; + readonly usedIps: Set; + readonly containerIpByName: Map; +}): void { + const containers = opts.entry.Containers; + if (!isRecord(containers)) { + return; + } + + for (const info of Object.values(containers)) { + if (!isRecord(info)) { + continue; + } + const name = typeof info.Name === "string" ? info.Name : ""; + const ipRaw = typeof info.IPv4Address === "string" ? info.IPv4Address : ""; + if (!(name && ipRaw)) { + continue; + } + const ip = extractIpv4Address({ raw: ipRaw }); + if (ip.length === 0) { + continue; + } + opts.usedIps.add(ip); + opts.containerIpByName.set(name, ip); + } +} + async function reassignIngressIpConflicts(opts: { readonly conflicts: readonly IngressIpConflict[]; readonly reservedIps: readonly string[]; @@ -2336,7 +2440,17 @@ async function globalTrust(): Promise { } await ensureDockerRunning(); - await ensureMacTrustCaddyLocalCa(); + const certPath = await exportCaddyLocalCaCert(); + if (!certPath) { + return 1; + } + + await ensureMacTrustCaddyLocalCa({ + certPath, + }); + await configureMacHostTlsTrust({ + certPath, + }); return 0; } @@ -2934,7 +3048,9 @@ async function ensureMacMkcert(): Promise { } } -async function ensureMacTrustCaddyLocalCa(): Promise { +async function ensureMacTrustCaddyLocalCa(input: { + readonly certPath: string; +}): Promise { const ok = await confirm({ message: "Trust Caddy Local CA in macOS System keychain? (enables trusted https://*.hack; requires sudo)", @@ -2964,10 +3080,6 @@ async function ensureMacTrustCaddyLocalCa(): Promise { }); return; } - const certPath = await exportCaddyLocalCaCert(); - if (!certPath) { - return; - } logger.step({ message: "Installing Caddy Local CA to System keychain (requires sudo)…", @@ -2982,7 +3094,7 @@ async function ensureMacTrustCaddyLocalCa(): Promise { "trustRoot", "-k", "/Library/Keychains/System.keychain", - certPath, + input.certPath, ], { stdin: "inherit" } ); @@ -3004,6 +3116,118 @@ async function ensureMacTrustCaddyLocalCa(): Promise { ); } +async function configureMacHostTlsTrust(input: { + readonly certPath: string; +}): Promise { + const bundlePath = await writeMacHostTrustBundle({ + certPath: input.certPath, + }); + const env = buildHackHostTrustEnvironment({ + certPath: input.certPath, + bundlePath, + }); + const scriptPath = resolveHackHostTrustEnvScriptPath(); + await ensureDir(dirname(scriptPath)); + await writeTextFile( + scriptPath, + renderHackHostTrustShellExports({ + certPath: input.certPath, + bundlePath, + }) + ); + + for (const [key, value] of Object.entries(env)) { + const exitCode = await run(["launchctl", "setenv", key, value], { + stdin: "ignore", + }); + if (exitCode !== 0) { + logger.warn({ + message: `Failed to set ${key} for future macOS shells via launchctl (exit ${exitCode}).`, + }); + } + } + + logger.success({ + message: + "Prepared host trust env for Bun/Node/curl/git and registered it for future macOS shells.", + }); + note( + [ + `Current shell: source ${scriptPath}`, + "New Terminal/iTerm windows should inherit the trust env automatically.", + ].join("\n"), + "Host TLS" + ); +} + +async function writeMacHostTrustBundle(input: { + readonly certPath: string; +}): Promise { + const bundlePath = resolveHackHostTrustBundlePath(); + const home = getHomeDir(); + const loginKeychainPath = resolve( + home, + "Library", + "Keychains", + "login.keychain-db" + ); + const keychainPaths = [ + "/System/Library/Keychains/SystemRootCertificates.keychain", + "/Library/Keychains/System.keychain", + loginKeychainPath, + ]; + + const pemChunks: string[] = []; + for (const keychainPath of keychainPaths) { + if (!(await pathExists(keychainPath))) { + continue; + } + const result = await exec( + ["security", "find-certificate", "-a", "-p", keychainPath], + { + stdin: "ignore", + } + ); + if (result.exitCode !== 0) { + logger.warn({ + message: `Failed to export trust roots from ${keychainPath}; host bundle will skip it.`, + }); + continue; + } + const pemText = result.stdout.trim(); + if (pemText.length > 0) { + pemChunks.push(pemText); + } + } + + const localCaPem = (await readTextFile(input.certPath))?.trim() ?? ""; + if (localCaPem.length === 0) { + logger.warn({ + message: `Unable to read exported Caddy Local CA at ${input.certPath}.`, + }); + return null; + } + + if (pemChunks.length === 0) { + logger.warn({ + message: + "No macOS keychain roots were exported for the host trust bundle; falling back to NODE_EXTRA_CA_CERTS only.", + }); + return null; + } + + const bundleText = `${[...pemChunks, localCaPem].join("\n")}\n`; + const expectedPath = resolveHackLocalCaCertPath(); + if (input.certPath !== expectedPath) { + logger.info({ + message: `Using exported Caddy Local CA at ${input.certPath} instead of ${expectedPath}.`, + }); + } + await ensureDir(dirname(bundlePath)); + await writeTextFileIfChanged(bundlePath, bundleText); + return bundlePath; +} + async function exportCaddyLocalCaCert(): Promise { const paths = getGlobalPaths(); diff --git a/src/commands/project.ts b/src/commands/project.ts index 60416536..e4aad931 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -92,6 +92,7 @@ import { resolveLifecycleLogPath, upsertLifecycleStateEntry, } from "../lib/lifecycle-runtime.ts"; +import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts"; import { buildLogSelector, resolveShouldTryLoki, @@ -1289,7 +1290,7 @@ async function runLifecycleCommands(opts: { const proc = Bun.spawn(["sh", "-c", cmd.command], { cwd, - env: mergeLifecycleCommandEnv(opts.env), + env: await mergeLifecycleCommandEnv(opts.env), stdin: "inherit", stdout: "pipe", stderr: "pipe", @@ -2160,16 +2161,16 @@ function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } -function mergeLifecycleCommandEnv( +export async function mergeLifecycleCommandEnv( override: Readonly> -): Record { +): Promise> { const base: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (typeof value === "string") { base[key] = value; } } - return { ...base, ...override }; + return await appendHackHostTrustEnvironment({ ...base, ...override }); } async function resolveBranchComposeFiles(opts: { diff --git a/src/commands/session.ts b/src/commands/session.ts index 52fa29d7..d260b5a8 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -17,6 +17,7 @@ import { resolveHackEnv, selectHackEnvValues, } from "../lib/hack-env.ts"; +import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts"; import { defaultProjectSlugFromPath, findProjectContext, @@ -1081,10 +1082,12 @@ async function resolveSessionInjectedEnv(opts: { serviceNames, }); if (modern) { - return selectProjectEnvValues({ - resolved: modern, - scopeName: opts.serviceName, - }); + return await appendHackHostTrustEnvironment( + selectProjectEnvValues({ + resolved: modern, + scopeName: opts.serviceName, + }) + ); } const resolved = await resolveHackEnv({ @@ -1092,10 +1095,12 @@ async function resolveSessionInjectedEnv(opts: { projectName: opts.projectName, envName: opts.envName, }); - return selectHackEnvValues({ - resolved, - serviceName: opts.serviceName, - }); + return await appendHackHostTrustEnvironment( + selectHackEnvValues({ + resolved, + serviceName: opts.serviceName, + }) + ); } async function resolveProjectContextForWorkspace(opts: { diff --git a/src/lib/local-ca.ts b/src/lib/local-ca.ts new file mode 100644 index 00000000..8c14ab93 --- /dev/null +++ b/src/lib/local-ca.ts @@ -0,0 +1,123 @@ +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +import { GLOBAL_CADDY_DIR_NAME, GLOBAL_HACK_DIR_NAME } from "../constants.ts"; +import { pathExists } from "./fs.ts"; + +export const HACK_HOST_TRUST_BUNDLE_FILENAME = + "caddy-host-trust-bundle.pem" as const; +export const HACK_HOST_TRUST_ENV_FILENAME = "caddy-host-trust-env.sh" as const; + +export function resolveHackLocalCaCertPath(input?: { + readonly home?: string | null; +}): string { + const home = input?.home?.trim() || process.env.HOME?.trim() || homedir(); + return resolve( + home, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-local-authority.crt" + ); +} + +export async function findHackLocalCaCertPath(input?: { + readonly home?: string | null; +}): Promise { + const certPath = resolveHackLocalCaCertPath(input); + return (await pathExists(certPath)) ? certPath : null; +} + +export function resolveHackHostTrustBundlePath(input?: { + readonly home?: string | null; +}): string { + const home = input?.home?.trim() || process.env.HOME?.trim() || homedir(); + return resolve( + home, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + HACK_HOST_TRUST_BUNDLE_FILENAME + ); +} + +export async function findHackHostTrustBundlePath(input?: { + readonly home?: string | null; +}): Promise { + const bundlePath = resolveHackHostTrustBundlePath(input); + return (await pathExists(bundlePath)) ? bundlePath : null; +} + +export function resolveHackHostTrustEnvScriptPath(input?: { + readonly home?: string | null; +}): string { + const home = input?.home?.trim() || process.env.HOME?.trim() || homedir(); + return resolve( + home, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + HACK_HOST_TRUST_ENV_FILENAME + ); +} + +export function buildHackHostTrustEnvironment(input: { + readonly certPath: string; + readonly bundlePath?: string | null; +}): Record { + const env: Record = { + NODE_EXTRA_CA_CERTS: input.certPath, + HACK_LOCAL_CA_CERT: input.certPath, + }; + + const bundlePath = input.bundlePath?.trim(); + if (!bundlePath) { + return env; + } + + return { + ...env, + SSL_CERT_FILE: bundlePath, + CURL_CA_BUNDLE: bundlePath, + REQUESTS_CA_BUNDLE: bundlePath, + GIT_SSL_CAINFO: bundlePath, + HACK_HOST_TRUST_BUNDLE: bundlePath, + }; +} + +export async function appendHackHostTrustEnvironment( + env: Readonly> +): Promise> { + const certPath = await findHackLocalCaCertPath(); + if (!certPath) { + return { ...env }; + } + + const bundlePath = await findHackHostTrustBundlePath(); + return { + ...env, + ...buildHackHostTrustEnvironment({ + certPath, + bundlePath, + }), + }; +} + +export function renderHackHostTrustShellExports(input: { + readonly certPath: string; + readonly bundlePath?: string | null; +}): string { + const exports = buildHackHostTrustEnvironment(input); + return [ + "#!/usr/bin/env sh", + "# Auto-generated by `hack global trust`.", + ...Object.entries(exports).map( + ([key, value]) => `export ${key}=${shellQuote(value)}` + ), + "", + ].join("\n"); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/tests/env-exec-command.test.ts b/tests/env-exec-command.test.ts index 9db79da1..03211b37 100644 --- a/tests/env-exec-command.test.ts +++ b/tests/env-exec-command.test.ts @@ -17,6 +17,7 @@ const runCalls: Array<{ readonly env: Record | undefined; }> = []; const tempDirs = new Set(); +const originalHome = process.env.HOME; const originalShell = process.env.SHELL; mock.module("../src/lib/shell.ts", () => ({ @@ -44,6 +45,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); + process.env.HOME = originalHome; process.env.SHELL = originalShell; }); @@ -333,6 +335,71 @@ test("env exec can preserve the compose view when requested", async () => { }); }); +test("env exec adds local Hack CA trust for host runtimes when available", async () => { + const projectRoot = await createProject(); + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const execCommand = findSubcommand("exec"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + service: "api", + target: undefined, + }, + positionals: { + command: ["bun", "db:migrate"], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--service", + "api", + "bun", + "db:migrate", + ], + positionals: ["bun", "db:migrate"], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]?.env).toEqual({ + API_BASE_URL: "https://qa.example.com", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + GLOBAL_FLAG: "base", + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: resolve(caDir, "caddy-local-authority.crt"), + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SERVICE_TOKEN: "overlay-secret", + SSL_CERT_FILE: resolve(caDir, "caddy-host-trust-bundle.pem"), + }); +}); + type EnvSubcommandName = (typeof envCommand.subcommands)[number]["name"]; type EnvSubcommand = Extract< (typeof envCommand.subcommands)[number], @@ -376,6 +443,7 @@ async function createProject(input?: { readonly services?: readonly string[]; readonly defaultYaml?: string; }): Promise { + await ensureIsolatedHome(); const root = await mkdtemp(join(tmpdir(), "hack-env-exec-")); tempDirs.add(root); @@ -437,3 +505,12 @@ async function createProject(input?: { return projectRoot; } + +async function ensureIsolatedHome(): Promise { + if (process.env.HOME && process.env.HOME !== originalHome) { + return; + } + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; +} diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index c3e6faeb..36c7f254 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -15,6 +15,11 @@ import { const runCalls: string[][] = []; let runResponder: ((cmd: readonly string[]) => number | null) | null = null; +let execMockResponder: + | (( + cmd: readonly string[] + ) => { exitCode: number; stdout: string; stderr: string } | null) + | null = null; let tempDir: string | null = null; let originalHome: string | undefined; @@ -22,6 +27,7 @@ let originalLogger: string | undefined; let originalUser: string | undefined; let reachabilityByHost: Record = {}; let idUser = "mock-user"; +let pathExistsOverrides = new Map(); mock.module("@clack/prompts", () => ({ access: async () => true, @@ -65,10 +71,58 @@ mock.module("node:net", () => ({ }, }; }, + createServer: () => ({ + close: () => {}, + listen: () => {}, + on: () => {}, + }), +})); + +mock.module("../src/lib/fs.ts", () => ({ + ensureDir: async (absoluteDir: string) => { + await mkdir(absoluteDir, { recursive: true }); + }, + ensureGitignoreEntry: async () => ({ changed: false }), + pathExists: async (absolutePath: string) => { + const override = pathExistsOverrides.get(absolutePath); + if (override !== undefined) { + return override; + } + try { + await Bun.file(absolutePath).stat(); + return true; + } catch { + return false; + } + }, + readTextFile: async (absolutePath: string) => { + try { + return await Bun.file(absolutePath).text(); + } catch { + return null; + } + }, + writeTextFile: async (absolutePath: string, content: string) => { + await Bun.write(absolutePath, content); + }, + writeTextFileIfChanged: async (absolutePath: string, content: string) => { + const existing = await Bun.file(absolutePath) + .text() + .catch(() => null); + if (existing === content) { + return { changed: false }; + } + await Bun.write(absolutePath, content); + return { changed: true }; + }, })); mock.module("../src/lib/shell.ts", () => ({ exec: async (cmd: readonly string[]) => { + const custom = execMockResponder?.(cmd) ?? null; + if (custom) { + return custom; + } if (cmd[0] === "docker" && cmd[1] === "info") { return { exitCode: 0, stdout: "", stderr: "" }; } @@ -123,6 +177,10 @@ beforeEach(async () => { process.env.HACK_LOGGER = "console"; runCalls.length = 0; runResponder = null; + execMockResponder = null; + pathExistsOverrides = new Map([ + ["/etc/sudoers.d/dance.hack-dns-recovery", false], + ]); reachabilityByHost = {}; idUser = "mock-user"; }); @@ -362,3 +420,96 @@ test("global up falls back to interactive sudo when stdin is tty but stdout is r } } }); + +test("global trust prepares host runtime trust env for future shells", async () => { + const caddyCompose = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + GLOBAL_CADDY_COMPOSE_FILENAME + ); + await writeComposeFile(caddyCompose); + + const localCaPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-local-authority.crt" + ); + await mkdir(dirname(localCaPath), { recursive: true }); + await writeFile( + localCaPath, + "-----BEGIN CERTIFICATE-----\nLOCAL\n-----END CERTIFICATE-----\n" + ); + + execMockResponder = (cmd) => { + if ( + cmd[0] === "docker" && + cmd[1] === "compose" && + cmd[2] === "-f" && + cmd[4] === "ps" + ) { + return { exitCode: 0, stdout: "caddy-123\n", stderr: "" }; + } + if ( + cmd[0] === "security" && + cmd[1] === "find-certificate" && + cmd[2] === "-c" + ) { + return { exitCode: 0, stdout: "already trusted", stderr: "" }; + } + if ( + cmd[0] === "security" && + cmd[1] === "find-certificate" && + cmd[2] === "-a" && + cmd[3] === "-p" + ) { + return { + exitCode: 0, + stdout: + "-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n", + stderr: "", + }; + } + return null; + }; + + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", "trust"]); + + const bundlePath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-bundle.pem" + ); + const envScriptPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-env.sh" + ); + + expect(code).toBe(0); + expect(await Bun.file(bundlePath).text()).toContain("LOCAL"); + expect(await Bun.file(bundlePath).text()).toContain("SYSTEM"); + expect(await Bun.file(envScriptPath).text()).toContain("NODE_EXTRA_CA_CERTS"); + expect(runCalls).toEqual( + expect.arrayContaining([ + [ + "docker", + "cp", + "caddy-123:/data/caddy/pki/authorities/local/root.crt", + localCaPath, + ], + ["launchctl", "setenv", "NODE_EXTRA_CA_CERTS", localCaPath], + ["launchctl", "setenv", "SSL_CERT_FILE", bundlePath], + ["launchctl", "setenv", "CURL_CA_BUNDLE", bundlePath], + ["launchctl", "setenv", "REQUESTS_CA_BUNDLE", bundlePath], + ["launchctl", "setenv", "GIT_SSL_CAINFO", bundlePath], + ]) + ); +}); diff --git a/tests/project-lifecycle-processes.test.ts b/tests/project-lifecycle-processes.test.ts index 809e9520..112479d2 100644 --- a/tests/project-lifecycle-processes.test.ts +++ b/tests/project-lifecycle-processes.test.ts @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import { collectDescendantProcessGroupIds, + mergeLifecycleCommandEnv, parseProcessSnapshotOutput, resolveLifecycleProcessGroupIdsForTmuxState, wrapLifecyclePersistentCommand, @@ -12,12 +13,14 @@ import { import { readLifecycleState } from "../src/lib/lifecycle-runtime.ts"; const tempDirs = new Set(); +const originalHome = process.env.HOME; afterEach(async () => { for (const tempDir of tempDirs) { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); + process.env.HOME = originalHome; }); test("readLifecycleState preserves lifecycle pane and process group metadata", async () => { @@ -152,6 +155,35 @@ test("wrapLifecyclePersistentCommand avoids login-shell execution", () => { ); }); +test("mergeLifecycleCommandEnv appends local Hack CA trust for host processes", async () => { + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const merged = await mergeLifecycleCommandEnv({ + APP_ENV: "dev", + }); + + expect(merged).toMatchObject({ + APP_ENV: "dev", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: resolve(caDir, "caddy-local-authority.crt"), + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SSL_CERT_FILE: resolve(caDir, "caddy-host-trust-bundle.pem"), + }); +}); + async function createLifecycleProjectDir(): Promise { const root = await mkdtemp(join(tmpdir(), "hack-lifecycle-processes-")); tempDirs.add(root); diff --git a/tests/session-env-command.test.ts b/tests/session-env-command.test.ts index 1374426e..a1437583 100644 --- a/tests/session-env-command.test.ts +++ b/tests/session-env-command.test.ts @@ -23,6 +23,7 @@ const execInSessionCalls: Array<{ readonly env: Record | undefined; }> = []; const tempDirs = new Set(); +const originalHome = process.env.HOME; let registeredProject: { readonly id: string; readonly name: string; @@ -130,6 +131,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); + process.env.HOME = originalHome; }); afterAll(() => { @@ -337,6 +339,69 @@ test("session exec treats explicit --service global as an env injection request" expect(execInSessionCalls).toHaveLength(0); }); +test("session start includes local Hack CA trust when available", async () => { + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const projectRoot = await createProject(); + const startCommand = findSubcommand("start"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + up: false, + new: false, + name: undefined, + detach: true, + env: "qa", + service: "api", + }, + positionals: { + project: "session-env-test", + }, + raw: { + argv: [ + "--detach", + "--env", + "qa", + "--service", + "api", + "session-env-test", + ], + positionals: ["session-env-test"], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await startCommand.handler(input); + + expect(exitCode).toBe(0); + expect(createSessionCalls).toHaveLength(1); + expect(createSessionCalls[0]?.env).toEqual({ + API_BASE_URL: "https://qa.example.com", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + GLOBAL_FLAG: "base", + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: resolve(caDir, "caddy-local-authority.crt"), + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SERVICE_TOKEN: "overlay-secret", + SSL_CERT_FILE: resolve(caDir, "caddy-host-trust-bundle.pem"), + }); +}); + test("session start accepts compose-compatible service scope names", async () => { const projectRoot = await createProject(); const startCommand = findSubcommand("start"); @@ -411,6 +476,7 @@ function findSubcommand( } async function createProject(): Promise { + await ensureIsolatedHome(); const root = await mkdtemp(join(tmpdir(), "hack-session-env-")); tempDirs.add(root); @@ -482,3 +548,12 @@ async function createProject(): Promise { return projectRoot; } + +async function ensureIsolatedHome(): Promise { + if (process.env.HOME && process.env.HOME !== originalHome) { + return; + } + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; +} From 3c10f3b354409938dd8ccc923853b7b1760bb4e6 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 3 Apr 2026 11:20:42 -0400 Subject: [PATCH 2/9] fix(env): clarify host command env inspection --- docs/cli.md | 8 ++ docs/env.md | 13 +++ docs/guides/codex-managed-environments.md | 5 +- docs/integrations.md | 4 +- src/commands/env.ts | 4 +- tests/env-exec-command.test.ts | 111 ++++++++++++++++++++++ 6 files changed, 141 insertions(+), 4 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 8a5c53b8..675a17f2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1638,6 +1638,10 @@ Runs a host command with injected env. Default target is `host`, which applies t view and any `host` scope overrides. Use `--target compose` to preserve the container-oriented compose view. +If you are checking a variable value, prefer `printenv KEY` or +`sh -lc 'printf "%s\n" "$KEY"'`. `hack env exec -- echo $KEY` expands `$KEY` in your current shell +before Hack injects the env. + Options: | Flag | Type | Default | Description | @@ -1711,6 +1715,10 @@ Runs a host command with injected env. Default target is `host`, which applies t view and any `host` scope overrides. Use `--target compose` to preserve the container-oriented compose view while still running on the host. +If you are checking a variable value, prefer `printenv KEY` or +`sh -lc 'printf "%s\n" "$KEY"'`. `hack host exec -- echo $KEY` expands `$KEY` in your current shell +before Hack injects the env. + Options: | Flag | Type | Default | Description | diff --git a/docs/env.md b/docs/env.md index ec04386e..03112969 100644 --- a/docs/env.md +++ b/docs/env.md @@ -169,6 +169,19 @@ hack host exec --env qa --scope api -- bun db:migrate hack host exec --env qa --scope api --target compose -- bun test ``` +When you want to inspect an injected value, avoid `hack env exec -- echo $VAR` or +`hack host exec -- echo $VAR`. Your current shell expands `$VAR` before Hack starts the child +process, so the command often sees an empty string. + +Use one of these instead: + +```bash +hack env exec -- printenv APPLE_TEAM_ID +hack env exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"' +hack host exec -- printenv APPLE_TEAM_ID +hack host exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"' +``` + Open a host shell with injected env: ```bash diff --git a/docs/guides/codex-managed-environments.md b/docs/guides/codex-managed-environments.md index 44e9d17d..6678049d 100644 --- a/docs/guides/codex-managed-environments.md +++ b/docs/guides/codex-managed-environments.md @@ -94,7 +94,10 @@ hack host exec --env qa --scope api --target compose -- bun test `hack host exec` and `hack host shell` default to a host-local env view for host commands. Use `--scope` when you want service-scoped values without running inside that service container. Use -`--target compose` when you explicitly want the container-oriented compose view instead. +`--target compose` when you explicitly want the container-oriented compose view instead. If you are +checking a value, prefer `hack host exec -- printenv KEY` or +`hack host exec -- sh -lc 'printf "%s\n" "$KEY"'`; plain `echo $KEY` expands in the parent shell +before Hack injects env. ## Not available in slim mode diff --git a/docs/integrations.md b/docs/integrations.md index 1c46f3e5..a90564f0 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -132,7 +132,9 @@ Main surface: Prefer `hack host exec` and `hack host shell` when a command should run on your host machine but still needs Hack-resolved env. They default to a host-local env view and accept `--scope` when you want service-scoped values without running inside that container. Use `--target compose` when you -explicitly want the raw compose/container-oriented values. +explicitly want the raw compose/container-oriented values. When checking a variable, use +`hack host exec -- printenv KEY` or `hack host exec -- sh -lc 'printf "%s\n" "$KEY"'`; plain +`echo $KEY` expands before Hack injects env. Reference: diff --git a/src/commands/env.ts b/src/commands/env.ts index 46635d25..ee2839b1 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -188,7 +188,7 @@ const execSpec = defineCommand({ summary: "Run a host command with project env injected", group: "Project", description: - "Inject the selected Hack env overlay directly into a one-off host command without materializing .hack/.env.", + 'Inject the selected Hack env overlay directly into a one-off host command without materializing .hack/.env. To inspect a value, prefer `printenv KEY` or `sh -lc \'printf "%s\\n" "$KEY"\'`; `echo $KEY` expands in your current shell before Hack injects env.', options: [optPath, optProject, optEnv, optService, optTarget], positionals: [{ name: "command", required: true, multiple: true }], subcommands: [], @@ -210,7 +210,7 @@ const hostExecSpec = defineCommand({ summary: "Run a host command with project env injected", group: "Project", description: - "Run a one-off command on the host with the selected Hack env overlay injected. Use --scope when you want service-scoped values without running inside that service container.", + 'Run a one-off command on the host with the selected Hack env overlay injected. Use --scope when you want service-scoped values without running inside that service container. To inspect a value, prefer `printenv KEY` or `sh -lc \'printf "%s\\n" "$KEY"\'`; `echo $KEY` expands in your current shell before Hack injects env.', options: [optPath, optProject, optEnv, optScope, optTarget], positionals: [{ name: "command", required: true, multiple: true }], subcommands: [], diff --git a/tests/env-exec-command.test.ts b/tests/env-exec-command.test.ts index 03211b37..fcb51ba2 100644 --- a/tests/env-exec-command.test.ts +++ b/tests/env-exec-command.test.ts @@ -155,6 +155,58 @@ test("host exec injects scoped env into one-off host commands", async () => { }); }); +test("host exec preserves child-shell env expansion patterns", async () => { + const projectRoot = await createProject(); + const execCommand = findHostSubcommand("exec"); + + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + scope: "api", + target: undefined, + }, + positionals: { + command: ["sh", "-lc", 'printf "%s\\n" "$SERVICE_TOKEN"'], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--scope", + "api", + "sh", + "-lc", + 'printf "%s\\n" "$SERVICE_TOKEN"', + ], + positionals: ["sh", "-lc", 'printf "%s\\n" "$SERVICE_TOKEN"'], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]).toEqual({ + cmd: ["sh", "-lc", 'printf "%s\\n" "$SERVICE_TOKEN"'], + cwd: projectRoot, + env: { + API_BASE_URL: "https://qa.example.com", + GLOBAL_FLAG: "base", + SERVICE_TOKEN: "overlay-secret", + }, + }); +}); + test("env shell opens the current shell with injected project env", async () => { const projectRoot = await createProject(); process.env.SHELL = "/bin/zsh"; @@ -235,6 +287,65 @@ test("host shell opens the current shell with injected project env", async () => }); }); +test("host shell can inject scoped env and local Hack CA trust", async () => { + const projectRoot = await createProject(); + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + process.env.SHELL = "/bin/zsh"; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const shellCommand = findHostSubcommand("shell"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + scope: "api", + target: undefined, + }, + positionals: {}, + raw: { + argv: ["--path", projectRoot, "--env", "qa", "--scope", "api"], + positionals: [], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await shellCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]).toEqual({ + cmd: ["/bin/zsh", "-l"], + cwd: projectRoot, + env: { + API_BASE_URL: "https://qa.example.com", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + GLOBAL_FLAG: "base", + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: resolve(caDir, "caddy-local-authority.crt"), + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SERVICE_TOKEN: "overlay-secret", + SSL_CERT_FILE: resolve(caDir, "caddy-host-trust-bundle.pem"), + }, + }); +}); + test("env exec defaults to a host-local view for host-like env values", async () => { const projectRoot = await createProject({ services: ["api", "redis"], From 7337d5982e83ddf944f3d0464c541557027d8ae2 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 3 Apr 2026 11:32:53 -0400 Subject: [PATCH 3/9] feat(env): add shell mode for host env commands --- docs/cli.md | 8 +- docs/env.md | 2 + docs/guides/codex-managed-environments.md | 3 +- docs/integrations.md | 3 +- src/commands/env.ts | 55 ++++++-- tests/env-exec-command.test.ts | 149 ++++++++++++++++++++++ 6 files changed, 206 insertions(+), 14 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 675a17f2..1cc38961 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1640,7 +1640,8 @@ compose view. If you are checking a variable value, prefer `printenv KEY` or `sh -lc 'printf "%s\n" "$KEY"'`. `hack env exec -- echo $KEY` expands `$KEY` in your current shell -before Hack injects the env. +before Hack injects the env. Use `--shell 'echo $KEY'` when you want Hack to launch the child shell +after env injection. Options: @@ -1651,6 +1652,7 @@ Options: | `--env ` | string | - | Apply an optional env overlay by name | | `--service ` | string | - | Resolve values for one service scope | | `--target ` | string | `host` | Host-local env view for host commands, or raw compose view | +| `--shell ` | string | - | Run a shell command string via `/bin/sh -lc` after env injection | #### hack env shell @@ -1717,7 +1719,8 @@ compose view while still running on the host. If you are checking a variable value, prefer `printenv KEY` or `sh -lc 'printf "%s\n" "$KEY"'`. `hack host exec -- echo $KEY` expands `$KEY` in your current shell -before Hack injects the env. +before Hack injects the env. Use `--shell 'echo $KEY'` when you want Hack to launch the child shell +after env injection. Options: @@ -1728,6 +1731,7 @@ Options: | `--env ` | string | - | Apply an optional env overlay by name | | `--scope ` | string | - | Resolve values for one env scope while still running on the host | | `--target ` | string | `host` | Host-local env view for host commands, or raw compose view | +| `--shell ` | string | - | Run a shell command string via `/bin/sh -lc` after env injection | #### hack host shell diff --git a/docs/env.md b/docs/env.md index 03112969..1dbe83ac 100644 --- a/docs/env.md +++ b/docs/env.md @@ -178,8 +178,10 @@ Use one of these instead: ```bash hack env exec -- printenv APPLE_TEAM_ID hack env exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"' +hack env exec --shell 'echo $APPLE_TEAM_ID' hack host exec -- printenv APPLE_TEAM_ID hack host exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"' +hack host exec --shell 'echo $APPLE_TEAM_ID' ``` Open a host shell with injected env: diff --git a/docs/guides/codex-managed-environments.md b/docs/guides/codex-managed-environments.md index 6678049d..8ad75b57 100644 --- a/docs/guides/codex-managed-environments.md +++ b/docs/guides/codex-managed-environments.md @@ -97,7 +97,8 @@ hack host exec --env qa --scope api --target compose -- bun test `--target compose` when you explicitly want the container-oriented compose view instead. If you are checking a value, prefer `hack host exec -- printenv KEY` or `hack host exec -- sh -lc 'printf "%s\n" "$KEY"'`; plain `echo $KEY` expands in the parent shell -before Hack injects env. +before Hack injects env. Use `hack host exec --shell 'echo $KEY'` if you want Hack to start the +child shell after env injection. ## Not available in slim mode diff --git a/docs/integrations.md b/docs/integrations.md index a90564f0..ecb95040 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -134,7 +134,8 @@ still needs Hack-resolved env. They default to a host-local env view and accept want service-scoped values without running inside that container. Use `--target compose` when you explicitly want the raw compose/container-oriented values. When checking a variable, use `hack host exec -- printenv KEY` or `hack host exec -- sh -lc 'printf "%s\n" "$KEY"'`; plain -`echo $KEY` expands before Hack injects env. +`echo $KEY` expands before Hack injects env. Use `hack host exec --shell 'echo $KEY'` if you want +Hack to launch the child shell after env injection. Reference: diff --git a/src/commands/env.ts b/src/commands/env.ts index ee2839b1..86e9e69c 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -121,6 +121,15 @@ const optTarget = defineOption({ "Env view for host commands (default: host rewrites container-oriented addresses for local host execution)", } as const); +const optShellCommand = defineOption({ + name: "shellCommand", + type: "string", + long: "--shell", + valueHint: "", + description: + "Run a shell command string via /bin/sh -lc after env injection so `$VAR` expansion happens inside the child shell", +} as const); + const SECRET_MASK = "***"; const MODERN_ENV_STATUS_CLASSIFICATION = { trust_model: "repo_managed_env_config", @@ -189,8 +198,15 @@ const execSpec = defineCommand({ group: "Project", description: 'Inject the selected Hack env overlay directly into a one-off host command without materializing .hack/.env. To inspect a value, prefer `printenv KEY` or `sh -lc \'printf "%s\\n" "$KEY"\'`; `echo $KEY` expands in your current shell before Hack injects env.', - options: [optPath, optProject, optEnv, optService, optTarget], - positionals: [{ name: "command", required: true, multiple: true }], + options: [ + optPath, + optProject, + optEnv, + optService, + optTarget, + optShellCommand, + ], + positionals: [{ name: "command", required: false, multiple: true }], subcommands: [], } as const); @@ -211,8 +227,8 @@ const hostExecSpec = defineCommand({ group: "Project", description: 'Run a one-off command on the host with the selected Hack env overlay injected. Use --scope when you want service-scoped values without running inside that service container. To inspect a value, prefer `printenv KEY` or `sh -lc \'printf "%s\\n" "$KEY"\'`; `echo $KEY` expands in your current shell before Hack injects env.', - options: [optPath, optProject, optEnv, optScope, optTarget], - positionals: [{ name: "command", required: true, multiple: true }], + options: [optPath, optProject, optEnv, optScope, optTarget, optShellCommand], + positionals: [{ name: "command", required: false, multiple: true }], subcommands: [], } as const); @@ -1565,6 +1581,12 @@ function resolveInteractiveShellCommand(): readonly string[] { return [shellPath, "-l"]; } +function resolveShellCommandCommand(input: { + readonly command: string; +}): readonly string[] { + return ["/bin/sh", "-lc", input.command]; +} + function resolveExecutionScopeName(input: { readonly scopeName?: string; readonly serviceName?: string; @@ -1586,6 +1608,7 @@ async function runHostCommandWithInjectedEnv(input: { readonly serviceName?: string; readonly targetOpt: string | undefined; readonly command: readonly string[]; + readonly shellCommandOpt?: string; }): Promise { const project = await resolveProjectForEnv({ ctx: input.ctx, @@ -1599,7 +1622,12 @@ async function runHostCommandWithInjectedEnv(input: { const target = resolveHostEnvTarget({ targetOption: input.targetOpt, }); - if (input.command.length === 0) { + const shellCommand = input.shellCommandOpt?.trim(); + const positionalCommand = input.command; + if (shellCommand && positionalCommand.length > 0) { + throw new CliUsageError("Use either or --shell, not both."); + } + if (!shellCommand && positionalCommand.length === 0) { throw new CliUsageError("Command is required."); } @@ -1613,11 +1641,16 @@ async function runHostCommandWithInjectedEnv(input: { }), target, }); - return await run(input.command, { - cwd: project.projectRoot, - env: envState.env, - stdin: "inherit", - }); + return await run( + shellCommand + ? resolveShellCommandCommand({ command: shellCommand }) + : positionalCommand, + { + cwd: project.projectRoot, + env: envState.env, + stdin: "inherit", + } + ); } async function openHostShellWithInjectedEnv(input: { @@ -1671,6 +1704,7 @@ const handleEnvExec: CommandHandlerFor = async ({ serviceName: args.options.service, targetOpt: args.options.target, command: args.positionals.command, + shellCommandOpt: args.options.shellCommand, }); }; @@ -1700,6 +1734,7 @@ const handleHostExec: CommandHandlerFor = async ({ scopeName: args.options.scope, targetOpt: args.options.target, command: args.positionals.command, + shellCommandOpt: args.options.shellCommand, }); }; diff --git a/tests/env-exec-command.test.ts b/tests/env-exec-command.test.ts index fcb51ba2..15e75092 100644 --- a/tests/env-exec-command.test.ts +++ b/tests/env-exec-command.test.ts @@ -207,6 +207,155 @@ test("host exec preserves child-shell env expansion patterns", async () => { }); }); +test("env exec can run a shell command string after env injection", async () => { + const projectRoot = await createProject(); + const execCommand = findSubcommand("exec"); + + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + service: "api", + target: undefined, + shellCommand: 'printf "%s\\n" "$SERVICE_TOKEN"', + }, + positionals: { + command: [], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--service", + "api", + "--shell", + 'printf "%s\\n" "$SERVICE_TOKEN"', + ], + positionals: [], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]).toEqual({ + cmd: ["/bin/sh", "-lc", 'printf "%s\\n" "$SERVICE_TOKEN"'], + cwd: projectRoot, + env: { + API_BASE_URL: "https://qa.example.com", + GLOBAL_FLAG: "base", + SERVICE_TOKEN: "overlay-secret", + }, + }); +}); + +test("host exec can run a shell command string after env injection", async () => { + const projectRoot = await createProject(); + const execCommand = findHostSubcommand("exec"); + + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + scope: "api", + target: undefined, + shellCommand: 'printf "%s\\n" "$SERVICE_TOKEN"', + }, + positionals: { + command: [], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--scope", + "api", + "--shell", + 'printf "%s\\n" "$SERVICE_TOKEN"', + ], + positionals: [], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]).toEqual({ + cmd: ["/bin/sh", "-lc", 'printf "%s\\n" "$SERVICE_TOKEN"'], + cwd: projectRoot, + env: { + API_BASE_URL: "https://qa.example.com", + GLOBAL_FLAG: "base", + SERVICE_TOKEN: "overlay-secret", + }, + }); +}); + +test("host exec rejects mixing positional commands with --shell", async () => { + const projectRoot = await createProject(); + const execCommand = findHostSubcommand("exec"); + + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + scope: "api", + target: undefined, + shellCommand: 'printf "%s\\n" "$SERVICE_TOKEN"', + }, + positionals: { + command: ["printenv", "SERVICE_TOKEN"], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--scope", + "api", + "--shell", + 'printf "%s\\n" "$SERVICE_TOKEN"', + "printenv", + "SERVICE_TOKEN", + ], + positionals: ["printenv", "SERVICE_TOKEN"], + }, + }, + } as unknown as Parameters[0]; + + await expect(execCommand.handler(input)).rejects.toThrow( + "Use either or --shell, not both." + ); + expect(runCalls).toHaveLength(0); +}); + test("env shell opens the current shell with injected project env", async () => { const projectRoot = await createProject(); process.env.SHELL = "/bin/zsh"; From 3f1c9283527933d56258b09dba5bacbb38cb3136 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 3 Apr 2026 11:37:40 -0400 Subject: [PATCH 4/9] fix(runtime): preserve explicit host TLS env --- src/lib/local-ca.ts | 16 +++- tests/env-exec-command.test.ts | 78 ++++++++++++++++++ tests/project-lifecycle-processes.test.ts | 31 ++++++++ tests/session-env-command.test.ts | 97 ++++++++++++++++++++--- 4 files changed, 208 insertions(+), 14 deletions(-) diff --git a/src/lib/local-ca.ts b/src/lib/local-ca.ts index 8c14ab93..e833ba5a 100644 --- a/src/lib/local-ca.ts +++ b/src/lib/local-ca.ts @@ -94,12 +94,20 @@ export async function appendHackHostTrustEnvironment( } const bundlePath = await findHackHostTrustBundlePath(); + const trustEnvironment = buildHackHostTrustEnvironment({ + certPath, + bundlePath, + }); + return { + ...trustEnvironment, ...env, - ...buildHackHostTrustEnvironment({ - certPath, - bundlePath, - }), + HACK_LOCAL_CA_CERT: trustEnvironment.HACK_LOCAL_CA_CERT, + ...(trustEnvironment.HACK_HOST_TRUST_BUNDLE + ? { + HACK_HOST_TRUST_BUNDLE: trustEnvironment.HACK_HOST_TRUST_BUNDLE, + } + : {}), }; } diff --git a/tests/env-exec-command.test.ts b/tests/env-exec-command.test.ts index 15e75092..cf6c8777 100644 --- a/tests/env-exec-command.test.ts +++ b/tests/env-exec-command.test.ts @@ -660,6 +660,84 @@ test("env exec adds local Hack CA trust for host runtimes when available", async }); }); +test("env exec preserves explicit TLS env values while adding Hack trust metadata", async () => { + const projectRoot = await createProject({ + defaultYaml: [ + "version: 1", + "environment: default", + "secretsprovider: project_key", + "values:", + " global:", + ' GLOBAL_FLAG: "base"', + ' NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem"', + ' SSL_CERT_FILE: "/tmp/custom-bundle.pem"', + "", + ].join("\n"), + }); + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const execCommand = findSubcommand("exec"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + service: "api", + target: undefined, + shellCommand: undefined, + }, + positionals: { + command: ["bun", "db:migrate"], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--service", + "api", + "bun", + "db:migrate", + ], + positionals: ["bun", "db:migrate"], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]?.env).toEqual({ + API_BASE_URL: "https://qa.example.com", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + GLOBAL_FLAG: "base", + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem", + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SERVICE_TOKEN: "overlay-secret", + SSL_CERT_FILE: "/tmp/custom-bundle.pem", + }); +}); + type EnvSubcommandName = (typeof envCommand.subcommands)[number]["name"]; type EnvSubcommand = Extract< (typeof envCommand.subcommands)[number], diff --git a/tests/project-lifecycle-processes.test.ts b/tests/project-lifecycle-processes.test.ts index 112479d2..df7894e7 100644 --- a/tests/project-lifecycle-processes.test.ts +++ b/tests/project-lifecycle-processes.test.ts @@ -184,6 +184,37 @@ test("mergeLifecycleCommandEnv appends local Hack CA trust for host processes", }); }); +test("mergeLifecycleCommandEnv preserves explicit TLS env values", async () => { + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const merged = await mergeLifecycleCommandEnv({ + APP_ENV: "dev", + NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem", + SSL_CERT_FILE: "/tmp/custom-bundle.pem", + }); + + expect(merged).toMatchObject({ + APP_ENV: "dev", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem", + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SSL_CERT_FILE: "/tmp/custom-bundle.pem", + }); +}); + async function createLifecycleProjectDir(): Promise { const root = await mkdtemp(join(tmpdir(), "hack-lifecycle-processes-")); tempDirs.add(root); diff --git a/tests/session-env-command.test.ts b/tests/session-env-command.test.ts index a1437583..b1abfd5d 100644 --- a/tests/session-env-command.test.ts +++ b/tests/session-env-command.test.ts @@ -452,6 +452,76 @@ test("session start accepts compose-compatible service scope names", async () => }); }); +test("session start preserves explicit TLS env values while adding Hack trust metadata", async () => { + const projectRoot = await createProject({ + globalValues: { + GLOBAL_FLAG: "base", + NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem", + SSL_CERT_FILE: "/tmp/custom-bundle.pem", + }, + }); + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const startCommand = findSubcommand("start"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + up: false, + new: false, + name: undefined, + detach: true, + env: "qa", + service: "api", + }, + positionals: { + project: "session-env-test", + }, + raw: { + argv: [ + "--detach", + "--env", + "qa", + "--service", + "api", + "session-env-test", + ], + positionals: ["session-env-test"], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await startCommand.handler(input); + + expect(exitCode).toBe(0); + expect(createSessionCalls).toHaveLength(1); + expect(createSessionCalls[0]?.env).toEqual({ + API_BASE_URL: "https://qa.example.com", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + GLOBAL_FLAG: "base", + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem", + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SERVICE_TOKEN: "overlay-secret", + SSL_CERT_FILE: "/tmp/custom-bundle.pem", + }); +}); + type SessionSubcommandName = (typeof sessionCommand.subcommands)[number]["name"]; type SessionSubcommand = Extract< @@ -475,7 +545,9 @@ function findSubcommand( }; } -async function createProject(): Promise { +async function createProject(input?: { + readonly globalValues?: Readonly>; +}): Promise { await ensureIsolatedHome(); const root = await mkdtemp(join(tmpdir(), "hack-session-env-")); tempDirs.add(root); @@ -500,15 +572,20 @@ async function createProject(): Promise { )}\n` ); - await setProjectEnvValue({ - projectRoot, - projectDir, - envName: null, - scope: "global", - key: "GLOBAL_FLAG", - value: "base", - secret: false, - }); + const globalValues = input?.globalValues ?? { + GLOBAL_FLAG: "base", + }; + for (const [key, value] of Object.entries(globalValues)) { + await setProjectEnvValue({ + projectRoot, + projectDir, + envName: null, + scope: "global", + key, + value, + secret: false, + }); + } await setProjectEnvValue({ projectRoot, projectDir, From 2ac7b51f6b6121ec6402ec545a7dfdc41fcde045 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 3 Apr 2026 11:42:33 -0400 Subject: [PATCH 5/9] fix(runtime): tighten host trust env typing --- src/lib/local-ca.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lib/local-ca.ts b/src/lib/local-ca.ts index e833ba5a..0ae79a6c 100644 --- a/src/lib/local-ca.ts +++ b/src/lib/local-ca.ts @@ -8,6 +8,16 @@ export const HACK_HOST_TRUST_BUNDLE_FILENAME = "caddy-host-trust-bundle.pem" as const; export const HACK_HOST_TRUST_ENV_FILENAME = "caddy-host-trust-env.sh" as const; +interface HackHostTrustEnvironment { + readonly NODE_EXTRA_CA_CERTS: string; + readonly HACK_LOCAL_CA_CERT: string; + readonly SSL_CERT_FILE?: string; + readonly CURL_CA_BUNDLE?: string; + readonly REQUESTS_CA_BUNDLE?: string; + readonly GIT_SSL_CAINFO?: string; + readonly HACK_HOST_TRUST_BUNDLE?: string; +} + export function resolveHackLocalCaCertPath(input?: { readonly home?: string | null; }): string { @@ -64,8 +74,8 @@ export function resolveHackHostTrustEnvScriptPath(input?: { export function buildHackHostTrustEnvironment(input: { readonly certPath: string; readonly bundlePath?: string | null; -}): Record { - const env: Record = { +}): HackHostTrustEnvironment { + const env: HackHostTrustEnvironment = { NODE_EXTRA_CA_CERTS: input.certPath, HACK_LOCAL_CA_CERT: input.certPath, }; From 7cbd139fbd9cd4074213ee71c4064dd625aad286 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 3 Apr 2026 11:52:51 -0400 Subject: [PATCH 6/9] fix(global): skip host tls env when trust is declined --- src/commands/global.ts | 29 ++++++---- tests/global-command.macos.test.ts | 86 +++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/commands/global.ts b/src/commands/global.ts index 727dddac..b5850818 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -723,8 +723,10 @@ async function bootstrapMacGlobalInstall(): Promise { await ensureMacHackDns({ targetIp: hostDnsTarget }); const certPath = await exportCaddyLocalCaCert(); if (certPath) { - await ensureMacTrustCaddyLocalCa({ certPath }); - await configureMacHostTlsTrust({ certPath }); + const trustReady = await ensureMacTrustCaddyLocalCa({ certPath }); + if (trustReady) { + await configureMacHostTlsTrust({ certPath }); + } } await maybeOfferMacRecoverySetup(); } @@ -2445,12 +2447,14 @@ async function globalTrust(): Promise { return 1; } - await ensureMacTrustCaddyLocalCa({ - certPath, - }); - await configureMacHostTlsTrust({ + const trustReady = await ensureMacTrustCaddyLocalCa({ certPath, }); + if (trustReady) { + await configureMacHostTlsTrust({ + certPath, + }); + } return 0; } @@ -3050,7 +3054,7 @@ async function ensureMacMkcert(): Promise { async function ensureMacTrustCaddyLocalCa(input: { readonly certPath: string; -}): Promise { +}): Promise { const ok = await confirm({ message: "Trust Caddy Local CA in macOS System keychain? (enables trusted https://*.hack; requires sudo)", @@ -3060,7 +3064,11 @@ async function ensureMacTrustCaddyLocalCa(input: { throw new Error("Canceled"); } if (!ok) { - return; + logger.info({ + message: + "Skipped macOS System keychain trust; leaving host TLS env unchanged.", + }); + return false; } // Fast-path: already trusted. @@ -3078,7 +3086,7 @@ async function ensureMacTrustCaddyLocalCa(input: { logger.info({ message: "Caddy Local CA already present in System keychain", }); - return; + return true; } logger.step({ @@ -3103,7 +3111,7 @@ async function ensureMacTrustCaddyLocalCa(input: { logger.warn({ message: `Failed to trust Caddy Local CA (exit ${installExit}). You may see HTTPS warnings in the browser.`, }); - return; + return false; } logger.success({ message: "Trusted Caddy Local CA (macOS System keychain)" }); @@ -3114,6 +3122,7 @@ async function ensureMacTrustCaddyLocalCa(input: { ].join("\n"), "TLS" ); + return true; } async function configureMacHostTlsTrust(input: { diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index 36c7f254..71ebde95 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -28,12 +28,13 @@ let originalUser: string | undefined; let reachabilityByHost: Record = {}; let idUser = "mock-user"; let pathExistsOverrides = new Map(); +let confirmResponder: (() => boolean) | null = null; mock.module("@clack/prompts", () => ({ access: async () => true, autocompleteMultiselect: async () => [], cancel: () => {}, - confirm: async () => true, + confirm: async () => confirmResponder?.() ?? true, multiselect: async () => [], isCancel: () => false, log: { @@ -183,6 +184,7 @@ beforeEach(async () => { ]); reachabilityByHost = {}; idUser = "mock-user"; + confirmResponder = null; }); afterEach(async () => { @@ -220,6 +222,15 @@ async function readDnsmasqConf(root: string): Promise { return await Bun.file(dnsmasqConf).text(); } +async function fileExists(path: string): Promise { + try { + await Bun.file(path).stat(); + return true; + } catch { + return false; + } +} + async function writeComposeFile(path: string): Promise { await mkdir(dirname(path), { recursive: true }); await writeFile(path, "services: {}\n"); @@ -513,3 +524,76 @@ test("global trust prepares host runtime trust env for future shells", async () ]) ); }); + +test("global trust leaves host TLS env unchanged when keychain trust is declined", async () => { + const caddyCompose = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + GLOBAL_CADDY_COMPOSE_FILENAME + ); + await writeComposeFile(caddyCompose); + confirmResponder = () => false; + + const localCaPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-local-authority.crt" + ); + await mkdir(dirname(localCaPath), { recursive: true }); + await writeFile( + localCaPath, + "-----BEGIN CERTIFICATE-----\nLOCAL\n-----END CERTIFICATE-----\n" + ); + + execMockResponder = (cmd) => { + if ( + cmd[0] === "docker" && + cmd[1] === "compose" && + cmd[2] === "-f" && + cmd[4] === "ps" + ) { + return { exitCode: 0, stdout: "caddy-123\n", stderr: "" }; + } + return null; + }; + + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", "trust"]); + + const bundlePath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-bundle.pem" + ); + const envScriptPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-env.sh" + ); + + expect(code).toBe(0); + expect(await fileExists(bundlePath)).toBe(false); + expect(await fileExists(envScriptPath)).toBe(false); + expect(runCalls).toEqual( + expect.arrayContaining([ + [ + "docker", + "cp", + "caddy-123:/data/caddy/pki/authorities/local/root.crt", + localCaPath, + ], + ]) + ); + expect(runCalls).not.toEqual( + expect.arrayContaining([ + ["launchctl", "setenv", "NODE_EXTRA_CA_CERTS", localCaPath], + ]) + ); +}); From 7da28f50a62fd9555c15b4eec978a1c90035b502 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 3 Apr 2026 12:06:55 -0400 Subject: [PATCH 7/9] feat(doctor): repair host tls trust drift --- docs/cli.md | 2 +- src/commands/doctor.ts | 37 ++++++++++++++++++- src/lib/doctor-host-tls.ts | 65 +++++++++++++++++++++++++++++++++ tests/doctor-command.test.ts | 4 +- tests/doctor-host-trust.test.ts | 45 +++++++++++++++++++++++ 5 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 src/lib/doctor-host-tls.ts create mode 100644 tests/doctor-host-trust.test.ts diff --git a/docs/cli.md b/docs/cli.md index 1cc38961..c06b7da7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2527,7 +2527,7 @@ Options: | Flag | Type | Default | Description | | --- | --- | --- | --- | | `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | -| `--fix` | boolean | false | Attempt safe auto-remediations (network + CoreDNS + CA) | +| `--fix` | boolean | false | Attempt safe auto-remediations (network + CoreDNS + CA + host TLS trust) | ### hack crash-capture diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c1ff5160..edc170c7 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -35,6 +35,7 @@ import { resolveGlobalCaddyIp, } from "../lib/caddy-hosts.ts"; import { resolveGlobalConfigPath } from "../lib/config-paths.ts"; +import { checkMacHostTlsTrust } from "../lib/doctor-host-tls.ts"; import { parseDotEnv } from "../lib/env.ts"; import { ensureDir, @@ -170,6 +171,7 @@ const DOCTOR_SUMMARY_GROUPS = [ "grafana", "proxy ports", "caddy local ca", + "host tls trust", ]), }, { @@ -414,6 +416,13 @@ const handleDoctor: CommandHandlerFor = async ({ timeoutMs: 1500, }) ); + if (isMac()) { + results.push( + await runCheck(s, "host tls trust", () => checkMacHostTlsTrust(), { + timeoutMs: 1500, + }) + ); + } // Project (if in a repo or --path) const startDir = args.options.path @@ -1851,6 +1860,7 @@ async function runDoctorFix(opts: { await maybeStartGlobalCaddyCompose({ paths }); await maybeExportCaddyCaCert({ paths }); + await maybeRepairMacHostTlsTrust(); await maybeMigrateDnsmasq(); await maybeRepairProjectTicketsGitHealth({ startDir: opts.startDir }); if (opts.migrateEnvConfig) { @@ -2078,7 +2088,7 @@ export async function buildDoctorRemediationPlanLines(opts: { readonly migrateEnvConfig: boolean; }): Promise { const lines = [ - "1. Review and repair local network, CoreDNS, CA, and daemon drift where needed.", + "1. Review and repair local network, CoreDNS, CA, host TLS env, and daemon drift where needed.", "2. Repair tickets refs if the project repo needs it.", ]; if (!opts.migrateEnvConfig) { @@ -2430,6 +2440,31 @@ async function maybeExportCaddyCaCert(opts: { await exportCaddyLocalCaCert({ paths: opts.paths }); } +async function maybeRepairMacHostTlsTrust(): Promise { + if (!isMac()) { + return; + } + + const hostTlsTrust = await checkMacHostTlsTrust(); + if (hostTlsTrust.status === "ok") { + return; + } + + note(hostTlsTrust.message, "doctor"); + const okRepair = await confirmOrThrow({ + message: + "Repair macOS host TLS trust now? (Bun/Node/curl/git trust for https://*.hack)", + initialValue: true, + }); + if (!okRepair) { + return; + } + + await runHackSubcommand({ + args: ["global", "trust"], + }); +} + async function maybeMigrateDnsmasq(): Promise { if (!isMac()) { return; diff --git a/src/lib/doctor-host-tls.ts b/src/lib/doctor-host-tls.ts new file mode 100644 index 00000000..5ed6211b --- /dev/null +++ b/src/lib/doctor-host-tls.ts @@ -0,0 +1,65 @@ +import { pathExists } from "./fs.ts"; +import { + findHackLocalCaCertPath, + resolveHackHostTrustBundlePath, + resolveHackHostTrustEnvScriptPath, +} from "./local-ca.ts"; +import { exec } from "./shell.ts"; + +export async function checkMacHostTlsTrust(input?: { + readonly certPath?: string | null; + readonly bundlePath?: string; + readonly envScriptPath?: string; + readonly pathExists?: typeof pathExists; + readonly exec?: typeof exec; +}): Promise<{ + readonly name: string; + readonly status: "ok" | "warn"; + readonly message: string; +}> { + const pathExistsFn = input?.pathExists ?? pathExists; + const execFn = input?.exec ?? exec; + const certPath = input?.certPath ?? (await findHackLocalCaCertPath()); + if (!certPath) { + return { + name: "host tls trust", + status: "warn", + message: "Missing Caddy Local CA (run: hack doctor --fix)", + }; + } + + const issues: string[] = []; + const keychainTrust = await execFn( + [ + "security", + "find-certificate", + "-c", + "Caddy Local Authority", + "/Library/Keychains/System.keychain", + ], + { stdin: "ignore" } + ); + if (keychainTrust.exitCode !== 0) { + issues.push("macOS System keychain trust missing"); + } + + const bundlePath = input?.bundlePath ?? resolveHackHostTrustBundlePath(); + if (!(await pathExistsFn(bundlePath))) { + issues.push(`missing ${bundlePath}`); + } + + const envScriptPath = + input?.envScriptPath ?? resolveHackHostTrustEnvScriptPath(); + if (!(await pathExistsFn(envScriptPath))) { + issues.push(`missing ${envScriptPath}`); + } + + return { + name: "host tls trust", + status: issues.length > 0 ? "warn" : "ok", + message: + issues.length > 0 + ? `${issues.join("; ")} (run: hack doctor --fix)` + : `Ready (${envScriptPath})`, + }; +} diff --git a/tests/doctor-command.test.ts b/tests/doctor-command.test.ts index 26d86793..df8d923e 100644 --- a/tests/doctor-command.test.ts +++ b/tests/doctor-command.test.ts @@ -78,7 +78,7 @@ test("doctor remediation plan mentions env migration when requested for a legacy }); expect(lines).toEqual([ - "1. Review and repair local network, CoreDNS, CA, and daemon drift where needed.", + "1. Review and repair local network, CoreDNS, CA, host TLS env, and daemon drift where needed.", "2. Repair tickets refs if the project repo needs it.", "3. Prompt to migrate legacy env config (.hack/hack.env.json) to hack.env.*.yaml.", ]); @@ -96,7 +96,7 @@ test("doctor remediation plan skips env migration when modern env files already }); expect(lines).toEqual([ - "1. Review and repair local network, CoreDNS, CA, and daemon drift where needed.", + "1. Review and repair local network, CoreDNS, CA, host TLS env, and daemon drift where needed.", "2. Repair tickets refs if the project repo needs it.", "3. Skip env migration because this project already uses hack.env.*.yaml.", ]); diff --git a/tests/doctor-host-trust.test.ts b/tests/doctor-host-trust.test.ts new file mode 100644 index 00000000..3dce0da6 --- /dev/null +++ b/tests/doctor-host-trust.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; + +import { checkMacHostTlsTrust } from "../src/lib/doctor-host-tls.ts"; + +test("checkMacHostTlsTrust reports ready when keychain trust and host env artifacts exist", async () => { + const caDir = "/tmp/hack-doctor-host-trust"; + const bundlePath = resolve(caDir, "caddy-host-trust-bundle.pem"); + const envScriptPath = resolve(caDir, "caddy-host-trust-env.sh"); + + const result = await checkMacHostTlsTrust({ + certPath: resolve(caDir, "caddy-local-authority.crt"), + bundlePath, + envScriptPath, + pathExists: async () => true, + exec: async () => ({ exitCode: 0, stdout: "trusted", stderr: "" }), + }); + + expect(result).toEqual({ + name: "host tls trust", + status: "ok", + message: `Ready (${envScriptPath})`, + }); +}); + +test("checkMacHostTlsTrust flags missing keychain trust and host env artifacts", async () => { + const caDir = "/tmp/hack-doctor-host-trust"; + const bundlePath = resolve(caDir, "caddy-host-trust-bundle.pem"); + const envScriptPath = resolve(caDir, "caddy-host-trust-env.sh"); + + const result = await checkMacHostTlsTrust({ + certPath: resolve(caDir, "caddy-local-authority.crt"), + bundlePath, + envScriptPath, + pathExists: async () => false, + exec: async () => ({ exitCode: 1, stdout: "", stderr: "missing" }), + }); + + expect(result.name).toBe("host tls trust"); + expect(result.status).toBe("warn"); + expect(result.message).toContain("macOS System keychain trust missing"); + expect(result.message).toContain(`missing ${bundlePath}`); + expect(result.message).toContain(`missing ${envScriptPath}`); + expect(result.message).toContain("(run: hack doctor --fix)"); +}); From a8044ef43946f1c72dfd7c78291ed16a3f8e3fcd Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 3 Apr 2026 13:54:05 -0400 Subject: [PATCH 8/9] fix(runtime): cover compose-target host trust fallback --- src/commands/env.ts | 24 ++++----- src/commands/global.ts | 18 ++++++- tests/env-exec-command.test.ts | 68 +++++++++++++++++++++++++ tests/global-command.macos.test.ts | 82 ++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 14 deletions(-) diff --git a/src/commands/env.ts b/src/commands/env.ts index 86e9e69c..76b35098 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -677,20 +677,20 @@ function adaptEnvForHostExecution(input: { readonly target: (typeof HOST_ENV_TARGET_VALUES)[number]; readonly serviceNames: readonly string[]; }): Promise> { - if (input.target !== "host") { - return Promise.resolve({ ...input.env }); + if (input.target === "host") { + const composeServiceNames = new Set(input.serviceNames); + const out: Record = {}; + for (const [key, value] of Object.entries(input.env)) { + out[key] = rewriteEnvValueForHostExecution({ + key, + value, + composeServiceNames, + }); + } + return appendHackHostTrustEnvironment(out); } - const composeServiceNames = new Set(input.serviceNames); - const out: Record = {}; - for (const [key, value] of Object.entries(input.env)) { - out[key] = rewriteEnvValueForHostExecution({ - key, - value, - composeServiceNames, - }); - } - return appendHackHostTrustEnvironment(out); + return appendHackHostTrustEnvironment(input.env); } function rewriteEnvValueForHostExecution(input: { diff --git a/src/commands/global.ts b/src/commands/global.ts index b5850818..0b148caf 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -2441,8 +2441,22 @@ async function globalTrust(): Promise { return 0; } - await ensureDockerRunning(); - const certPath = await exportCaddyLocalCaCert(); + const existingCertPath = (await pathExists(resolveHackLocalCaCertPath())) + ? resolveHackLocalCaCertPath() + : null; + let certPath = existingCertPath; + const dockerStatus = await exec(["docker", "info"], { stdin: "ignore" }); + if (dockerStatus.exitCode === 0) { + certPath = (await exportCaddyLocalCaCert()) ?? certPath; + } else if (certPath) { + logger.info({ + message: + "Docker is not running; using the previously exported Caddy Local CA for trust setup.", + }); + } else { + await ensureDockerRunning(); + certPath = (await exportCaddyLocalCaCert()) ?? certPath; + } if (!certPath) { return 1; } diff --git a/tests/env-exec-command.test.ts b/tests/env-exec-command.test.ts index cf6c8777..77533629 100644 --- a/tests/env-exec-command.test.ts +++ b/tests/env-exec-command.test.ts @@ -595,6 +595,74 @@ test("env exec can preserve the compose view when requested", async () => { }); }); +test("env exec compose target still adds local Hack CA trust for host runtimes", async () => { + const projectRoot = await createProject({ + defaultYaml: [ + "version: 1", + "environment: default", + "secretsprovider: project_key", + "values:", + " global:", + ' DATABASE_URL: "mysql://appuser:secret@host.docker.internal:3306/app?ssl={\\"rejectUnauthorized\\":false}"', + ' REDISHOST: "redis"', + "", + ].join("\n"), + }); + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const execCommand = findSubcommand("exec"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: undefined, + service: undefined, + target: "compose", + shellCommand: undefined, + }, + positionals: { + command: ["env"], + }, + raw: { + argv: ["--path", projectRoot, "--target", "compose", "env"], + positionals: ["env"], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]?.env).toEqual({ + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + DATABASE_URL: + 'mysql://appuser:secret@host.docker.internal:3306/app?ssl={"rejectUnauthorized":false}', + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: resolve(caDir, "caddy-local-authority.crt"), + REDISHOST: "redis", + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SSL_CERT_FILE: resolve(caDir, "caddy-host-trust-bundle.pem"), + }); +}); + test("env exec adds local Hack CA trust for host runtimes when available", async () => { const projectRoot = await createProject(); const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index 71ebde95..1042cfeb 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -597,3 +597,85 @@ test("global trust leaves host TLS env unchanged when keychain trust is declined ]) ); }); + +test("global trust falls back to an existing exported CA when Caddy is unavailable", async () => { + const caddyCompose = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + GLOBAL_CADDY_COMPOSE_FILENAME + ); + await writeComposeFile(caddyCompose); + + const localCaPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-local-authority.crt" + ); + await mkdir(dirname(localCaPath), { recursive: true }); + await writeFile( + localCaPath, + "-----BEGIN CERTIFICATE-----\nLOCAL\n-----END CERTIFICATE-----\n" + ); + + execMockResponder = (cmd) => { + if (cmd[0] === "docker" && cmd[1] === "info") { + return { exitCode: 1, stdout: "", stderr: "down" }; + } + if ( + cmd[0] === "security" && + cmd[1] === "find-certificate" && + cmd[2] === "-c" + ) { + return { exitCode: 0, stdout: "already trusted", stderr: "" }; + } + if ( + cmd[0] === "security" && + cmd[1] === "find-certificate" && + cmd[2] === "-a" && + cmd[3] === "-p" + ) { + return { + exitCode: 0, + stdout: + "-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n", + stderr: "", + }; + } + return null; + }; + + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", "trust"]); + + const bundlePath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-bundle.pem" + ); + const envScriptPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-env.sh" + ); + + expect(code).toBe(0); + expect(await Bun.file(bundlePath).text()).toContain("LOCAL"); + expect(await Bun.file(bundlePath).text()).toContain("SYSTEM"); + expect(await Bun.file(envScriptPath).text()).toContain("NODE_EXTRA_CA_CERTS"); + expect(runCalls).toEqual( + expect.arrayContaining([ + ["launchctl", "setenv", "NODE_EXTRA_CA_CERTS", localCaPath], + ["launchctl", "setenv", "SSL_CERT_FILE", bundlePath], + ]) + ); + expect(runCalls).not.toEqual( + expect.arrayContaining([expect.arrayContaining(["docker", "cp"])]) + ); +}); From bd2c07456a8e8d3a6a39d70a20753a481d0db76e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 3 Apr 2026 14:02:29 -0400 Subject: [PATCH 9/9] fix(global): limit host bundle to macos roots --- src/commands/global.ts | 35 ++++++++++-------------------- tests/global-command.macos.test.ts | 32 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/commands/global.ts b/src/commands/global.ts index 0b148caf..d757f786 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -3187,39 +3187,26 @@ async function writeMacHostTrustBundle(input: { readonly certPath: string; }): Promise { const bundlePath = resolveHackHostTrustBundlePath(); - const home = getHomeDir(); - const loginKeychainPath = resolve( - home, - "Library", - "Keychains", - "login.keychain-db" - ); - const keychainPaths = [ - "/System/Library/Keychains/SystemRootCertificates.keychain", - "/Library/Keychains/System.keychain", - loginKeychainPath, - ]; + const systemRootsKeychain = + "/System/Library/Keychains/SystemRootCertificates.keychain"; const pemChunks: string[] = []; - for (const keychainPath of keychainPaths) { - if (!(await pathExists(keychainPath))) { - continue; - } + if (await pathExists(systemRootsKeychain)) { const result = await exec( - ["security", "find-certificate", "-a", "-p", keychainPath], + ["security", "find-certificate", "-a", "-p", systemRootsKeychain], { stdin: "ignore", } ); if (result.exitCode !== 0) { logger.warn({ - message: `Failed to export trust roots from ${keychainPath}; host bundle will skip it.`, + message: `Failed to export trust roots from ${systemRootsKeychain}; host bundle will skip it.`, }); - continue; - } - const pemText = result.stdout.trim(); - if (pemText.length > 0) { - pemChunks.push(pemText); + } else { + const pemText = result.stdout.trim(); + if (pemText.length > 0) { + pemChunks.push(pemText); + } } } @@ -3234,7 +3221,7 @@ async function writeMacHostTrustBundle(input: { if (pemChunks.length === 0) { logger.warn({ message: - "No macOS keychain roots were exported for the host trust bundle; falling back to NODE_EXTRA_CA_CERTS only.", + "No macOS system trust roots were exported for the host trust bundle; falling back to NODE_EXTRA_CA_CERTS only.", }); return null; } diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index 1042cfeb..4caf15cb 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -14,6 +14,7 @@ import { } from "../src/constants.ts"; const runCalls: string[][] = []; +const execCalls: string[][] = []; let runResponder: ((cmd: readonly string[]) => number | null) | null = null; let execMockResponder: | (( @@ -120,6 +121,7 @@ mock.module("../src/lib/fs.ts", () => ({ mock.module("../src/lib/shell.ts", () => ({ exec: async (cmd: readonly string[]) => { + execCalls.push([...cmd]); const custom = execMockResponder?.(cmd) ?? null; if (custom) { return custom; @@ -177,6 +179,7 @@ beforeEach(async () => { process.env.USER = "env-user"; process.env.HACK_LOGGER = "console"; runCalls.length = 0; + execCalls.length = 0; runResponder = null; execMockResponder = null; pathExistsOverrides = new Map([ @@ -508,6 +511,35 @@ test("global trust prepares host runtime trust env for future shells", async () expect(await Bun.file(bundlePath).text()).toContain("LOCAL"); expect(await Bun.file(bundlePath).text()).toContain("SYSTEM"); expect(await Bun.file(envScriptPath).text()).toContain("NODE_EXTRA_CA_CERTS"); + expect(execCalls).toEqual( + expect.arrayContaining([ + [ + "security", + "find-certificate", + "-a", + "-p", + "/System/Library/Keychains/SystemRootCertificates.keychain", + ], + ]) + ); + expect(execCalls).not.toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + "security", + "find-certificate", + "-a", + "-p", + "/Library/Keychains/System.keychain", + ]), + expect.arrayContaining([ + "security", + "find-certificate", + "-a", + "-p", + resolve(tempDir!, "Library", "Keychains", "login.keychain-db"), + ]), + ]) + ); expect(runCalls).toEqual( expect.arrayContaining([ [