mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 16:39:50 +00:00
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:
parent
6f6b2a7895
commit
7003a8ad40
6 changed files with 56 additions and 35 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue