From 7003a8ad40a49896e9e7df46453ce2ff0d7e22d7 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:54:12 -0800 Subject: [PATCH] fix: replace module-level process.env.HOME with homedir() in config paths (#2026) Fixes #2025 Silent credential loss in Docker/CI when HOME is unset. Use node:os homedir() which has OS-level fallbacks and matches history.ts pattern. Prefer process.env.HOME when set to respect test sandboxing overrides. Agent: code-health Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- packages/cli/src/__tests__/aws.test.ts | 36 +++++++++---------- packages/cli/src/aws/aws.ts | 13 ++++--- packages/cli/src/daytona/daytona.ts | 13 ++++--- packages/cli/src/digitalocean/digitalocean.ts | 13 ++++--- packages/cli/src/hetzner/hetzner.ts | 13 ++++--- packages/cli/src/update-check.ts | 3 +- 6 files changed, 56 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/__tests__/aws.test.ts b/packages/cli/src/__tests__/aws.test.ts index 7225ebc4..27aed4eb 100644 --- a/packages/cli/src/__tests__/aws.test.ts +++ b/packages/cli/src/__tests__/aws.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { unlinkSync, existsSync, readFileSync } from "node:fs"; -import { BUNDLES, DEFAULT_BUNDLE, loadCredsFromConfig, saveCredsToConfig, AWS_CONFIG_PATH } from "../aws/aws"; +import { BUNDLES, DEFAULT_BUNDLE, loadCredsFromConfig, saveCredsToConfig, getAwsConfigPath } from "../aws/aws"; // ─── Credential caching tests ──────────────────────────────────────────────── @@ -9,8 +9,8 @@ describe("aws/credential-cache", () => { let originalConfig: string | null = null; beforeEach(() => { - if (existsSync(AWS_CONFIG_PATH)) { - originalConfig = readFileSync(AWS_CONFIG_PATH, "utf-8"); + if (existsSync(getAwsConfigPath())) { + originalConfig = readFileSync(getAwsConfigPath(), "utf-8"); } else { originalConfig = null; } @@ -18,22 +18,22 @@ describe("aws/credential-cache", () => { afterEach(() => { if (originalConfig !== null) { - Bun.write(AWS_CONFIG_PATH, originalConfig); - } else if (existsSync(AWS_CONFIG_PATH)) { - unlinkSync(AWS_CONFIG_PATH); + Bun.write(getAwsConfigPath(), originalConfig); + } else if (existsSync(getAwsConfigPath())) { + unlinkSync(getAwsConfigPath()); } }); describe("loadCredsFromConfig", () => { it("returns null when config file does not exist", () => { - if (existsSync(AWS_CONFIG_PATH)) { - unlinkSync(AWS_CONFIG_PATH); + if (existsSync(getAwsConfigPath())) { + unlinkSync(getAwsConfigPath()); } expect(loadCredsFromConfig()).toBeNull(); }); it("returns null for malformed JSON", async () => { - await Bun.write(AWS_CONFIG_PATH, "not-json", { + await Bun.write(getAwsConfigPath(), "not-json", { mode: 0o600, }); expect(loadCredsFromConfig()).toBeNull(); @@ -41,7 +41,7 @@ describe("aws/credential-cache", () => { it("returns null when accessKeyId is missing", async () => { await Bun.write( - AWS_CONFIG_PATH, + getAwsConfigPath(), JSON.stringify({ secretAccessKey: "secretsecretkey1234", }), @@ -54,7 +54,7 @@ describe("aws/credential-cache", () => { it("returns null when secretAccessKey is too short", async () => { await Bun.write( - AWS_CONFIG_PATH, + getAwsConfigPath(), JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "tooshort", @@ -68,7 +68,7 @@ describe("aws/credential-cache", () => { it("returns null for invalid accessKeyId format", async () => { await Bun.write( - AWS_CONFIG_PATH, + getAwsConfigPath(), JSON.stringify({ accessKeyId: "invalid key!", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", @@ -82,7 +82,7 @@ describe("aws/credential-cache", () => { it("returns credentials for valid data", async () => { await Bun.write( - AWS_CONFIG_PATH, + getAwsConfigPath(), JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", @@ -101,7 +101,7 @@ describe("aws/credential-cache", () => { it("defaults region to us-east-1 when not stored", async () => { await Bun.write( - AWS_CONFIG_PATH, + getAwsConfigPath(), JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", @@ -117,8 +117,8 @@ describe("aws/credential-cache", () => { describe("saveCredsToConfig", () => { it("writes credentials to config file", async () => { - if (existsSync(AWS_CONFIG_PATH)) { - unlinkSync(AWS_CONFIG_PATH); + if (existsSync(getAwsConfigPath())) { + unlinkSync(getAwsConfigPath()); } await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", "us-west-2"); const result = loadCredsFromConfig(); @@ -128,8 +128,8 @@ describe("aws/credential-cache", () => { }); it("round-trips credentials with special characters in secret key", async () => { - if (existsSync(AWS_CONFIG_PATH)) { - unlinkSync(AWS_CONFIG_PATH); + if (existsSync(getAwsConfigPath())) { + unlinkSync(getAwsConfigPath()); } const secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCY=="; await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", secret, "ap-northeast-1"); diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index d7306443..86f6ddf5 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -1,6 +1,8 @@ // aws/aws.ts — Core AWS Lightsail provider: auth, provisioning, SSH execution import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { createHash, createHmac } from "node:crypto"; import { @@ -36,7 +38,9 @@ const DASHBOARD_URL = "https://lightsail.aws.amazon.com/"; // ─── Credential Cache ──────────────────────────────────────────────────────── -export const AWS_CONFIG_PATH = `${process.env.HOME}/.config/spawn/aws.json`; +export function getAwsConfigPath(): string { + return join(process.env.HOME || homedir(), ".config", "spawn", "aws.json"); +} const AwsCredsSchema = v.object({ accessKeyId: v.string(), @@ -45,13 +49,14 @@ const AwsCredsSchema = v.object({ }); export async function saveCredsToConfig(accessKeyId: string, secretAccessKey: string, region: string): Promise { - const dir = AWS_CONFIG_PATH.replace(/\/[^/]+$/, ""); + const configPath = getAwsConfigPath(); + const dir = configPath.replace(/\/[^/]+$/, ""); mkdirSync(dir, { recursive: true, mode: 0o700, }); const payload = `{\n "accessKeyId": ${jsonEscape(accessKeyId)},\n "secretAccessKey": ${jsonEscape(secretAccessKey)},\n "region": ${jsonEscape(region)}\n}\n`; - await Bun.write(AWS_CONFIG_PATH, payload, { + await Bun.write(configPath, payload, { mode: 0o600, }); } @@ -62,7 +67,7 @@ export function loadCredsFromConfig(): { region: string; } | null { try { - const raw = readFileSync(AWS_CONFIG_PATH, "utf-8"); + const raw = readFileSync(getAwsConfigPath(), "utf-8"); const data = parseJsonWith(raw, AwsCredsSchema); if (!data?.accessKeyId || !data?.secretAccessKey) { return null; diff --git a/packages/cli/src/daytona/daytona.ts b/packages/cli/src/daytona/daytona.ts index ffe05393..40cbae10 100644 --- a/packages/cli/src/daytona/daytona.ts +++ b/packages/cli/src/daytona/daytona.ts @@ -1,6 +1,8 @@ // daytona/daytona.ts — Core Daytona provider: API, SSH, provisioning, execution import { mkdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { logInfo, @@ -98,23 +100,26 @@ function extractApiError(text: string, fallback = "Unknown error"): string { // ─── Token Management ──────────────────────────────────────────────────────── -const DAYTONA_CONFIG_PATH = `${process.env.HOME}/.config/spawn/daytona.json`; +function getConfigPath(): string { + return join(process.env.HOME || homedir(), ".config", "spawn", "daytona.json"); +} async function saveTokenToConfig(token: string): Promise { - const dir = DAYTONA_CONFIG_PATH.replace(/\/[^/]+$/, ""); + const configPath = getConfigPath(); + const dir = configPath.replace(/\/[^/]+$/, ""); mkdirSync(dir, { recursive: true, mode: 0o700, }); const escaped = jsonEscape(token); - await Bun.write(DAYTONA_CONFIG_PATH, `{\n "api_key": ${escaped},\n "token": ${escaped}\n}\n`, { + await Bun.write(configPath, `{\n "api_key": ${escaped},\n "token": ${escaped}\n}\n`, { mode: 0o600, }); } function loadTokenFromConfig(): string | null { try { - const data = JSON.parse(readFileSync(DAYTONA_CONFIG_PATH, "utf-8")); + const data = JSON.parse(readFileSync(getConfigPath(), "utf-8")); const token = data.api_key || data.token || ""; if (!token) { return null; diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 95ef1924..2c2ddd32 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -1,6 +1,8 @@ // digitalocean/digitalocean.ts — Core DigitalOcean provider: API, auth, SSH, provisioning import { mkdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { logInfo, @@ -141,7 +143,9 @@ async function doApi( // ─── Token Persistence ─────────────────────────────────────────────────────── -const DO_CONFIG_PATH = `${process.env.HOME}/.config/spawn/digitalocean.json`; +function getConfigPath(): string { + return join(process.env.HOME || homedir(), ".config", "spawn", "digitalocean.json"); +} interface DoConfig { api_key?: string; @@ -153,19 +157,20 @@ interface DoConfig { function loadConfig(): DoConfig | null { try { - return JSON.parse(readFileSync(DO_CONFIG_PATH, "utf-8")); + return JSON.parse(readFileSync(getConfigPath(), "utf-8")); } catch { return null; } } async function saveConfig(config: DoConfig): Promise { - const dir = DO_CONFIG_PATH.replace(/\/[^/]+$/, ""); + const configPath = getConfigPath(); + const dir = configPath.replace(/\/[^/]+$/, ""); mkdirSync(dir, { recursive: true, mode: 0o700, }); - await Bun.write(DO_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", { + await Bun.write(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600, }); } diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index 8cd2512c..08a7dd1d 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -1,6 +1,8 @@ // hetzner/hetzner.ts — Core Hetzner Cloud provider: API, auth, SSH, provisioning import { mkdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { logInfo, @@ -89,23 +91,26 @@ async function hetznerApi(method: string, endpoint: string, body?: string, maxRe // ─── Token Persistence ─────────────────────────────────────────────────────── -const HETZNER_CONFIG_PATH = `${process.env.HOME}/.config/spawn/hetzner.json`; +function getConfigPath(): string { + return join(process.env.HOME || homedir(), ".config", "spawn", "hetzner.json"); +} async function saveTokenToConfig(token: string): Promise { - const dir = HETZNER_CONFIG_PATH.replace(/\/[^/]+$/, ""); + const configPath = getConfigPath(); + const dir = configPath.replace(/\/[^/]+$/, ""); mkdirSync(dir, { recursive: true, mode: 0o700, }); const escaped = jsonEscape(token); - await Bun.write(HETZNER_CONFIG_PATH, `{\n "api_key": ${escaped},\n "token": ${escaped}\n}\n`, { + await Bun.write(configPath, `{\n "api_key": ${escaped},\n "token": ${escaped}\n}\n`, { mode: 0o600, }); } function loadTokenFromConfig(): string | null { try { - const data = JSON.parse(readFileSync(HETZNER_CONFIG_PATH, "utf-8")); + const data = JSON.parse(readFileSync(getConfigPath(), "utf-8")); const token = data.api_key || data.token || ""; if (!token) { return null; diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts index 5ebf30cc..b7ac4d93 100644 --- a/packages/cli/src/update-check.ts +++ b/packages/cli/src/update-check.ts @@ -2,6 +2,7 @@ import "./unicode-detect.js"; // Ensure TERM is set before using symbols import type { ExecSyncOptions, ExecFileSyncOptions } from "node:child_process"; import { execSync as nodeExecSync, execFileSync as nodeExecFileSync } from "node:child_process"; import fs from "node:fs"; +import { homedir } from "node:os"; import path from "node:path"; import pc from "picocolors"; import * as v from "valibot"; @@ -80,7 +81,7 @@ function compareVersions(current: string, latest: string): boolean { // ── Failure Backoff ────────────────────────────────────────────────────────── function getUpdateFailedPath(): string { - return path.join(process.env.HOME || "/tmp", ".config", "spawn", ".update-failed"); + return path.join(process.env.HOME || homedir(), ".config", "spawn", ".update-failed"); } export function isUpdateBackedOff(): boolean {