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 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-28 12:54:12 -08:00 committed by GitHub
parent 6f6b2a7895
commit 7003a8ad40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 56 additions and 35 deletions

View file

@ -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");

View file

@ -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<void> {
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;

View file

@ -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<void> {
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;

View file

@ -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<void> {
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,
});
}

View file

@ -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<void> {
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;

View file

@ -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 {