fix: cache AWS credentials to avoid re-prompting on retry (#1841) (#1852)

After a successful interactive credential entry, credentials are now
saved to ~/.config/spawn/aws.json (chmod 600). On the next run, cached
credentials are loaded and validated before prompting again.

Supports --reauth flag / SPAWN_REAUTH=1 to force fresh credential entry.

Fixes #1841

Agent: issue-fixer

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-23 21:53:34 -08:00 committed by GitHub
parent a96a396e79
commit ac7fa14c61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 209 additions and 4 deletions

View file

@ -1,10 +1,118 @@
import { describe, it, expect } from "bun:test";
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { unlinkSync, existsSync, readFileSync } from "node:fs";
import { BUNDLES, DEFAULT_BUNDLE } from "../aws/aws";
import { BUNDLES, DEFAULT_BUNDLE, loadCredsFromConfig, saveCredsToConfig, AWS_CONFIG_PATH } from "../aws/aws";
import { resolveAgent, agents } from "../aws/agents";
import { generateEnvConfig } from "../shared/agents";
// ─── Credential caching tests ────────────────────────────────────────────────
describe("aws/credential-cache", () => {
let originalConfig: string | null = null;
beforeEach(() => {
if (existsSync(AWS_CONFIG_PATH)) {
originalConfig = readFileSync(AWS_CONFIG_PATH, "utf-8");
} else {
originalConfig = null;
}
});
afterEach(() => {
if (originalConfig !== null) {
Bun.write(AWS_CONFIG_PATH, originalConfig);
} else if (existsSync(AWS_CONFIG_PATH)) {
unlinkSync(AWS_CONFIG_PATH);
}
});
describe("loadCredsFromConfig", () => {
it("returns null when config file does not exist", () => {
if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); }
expect(loadCredsFromConfig()).toBeNull();
});
it("returns null for malformed JSON", async () => {
await Bun.write(AWS_CONFIG_PATH, "not-json", { mode: 0o600 });
expect(loadCredsFromConfig()).toBeNull();
});
it("returns null when accessKeyId is missing", async () => {
await Bun.write(AWS_CONFIG_PATH, JSON.stringify({ secretAccessKey: "secretsecretkey1234" }), { mode: 0o600 });
expect(loadCredsFromConfig()).toBeNull();
});
it("returns null when secretAccessKey is too short", async () => {
await Bun.write(
AWS_CONFIG_PATH,
JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "tooshort" }),
{ mode: 0o600 },
);
expect(loadCredsFromConfig()).toBeNull();
});
it("returns null for invalid accessKeyId format", async () => {
await Bun.write(
AWS_CONFIG_PATH,
JSON.stringify({ accessKeyId: "invalid key!", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY" }),
{ mode: 0o600 },
);
expect(loadCredsFromConfig()).toBeNull();
});
it("returns credentials for valid data", async () => {
await Bun.write(
AWS_CONFIG_PATH,
JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", region: "eu-west-1" }),
{ mode: 0o600 },
);
const result = loadCredsFromConfig();
expect(result).not.toBeNull();
expect(result?.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
expect(result?.secretAccessKey).toBe("wJalrXUtnFEMI/K7MDENG/bPxRfiCY");
expect(result?.region).toBe("eu-west-1");
});
it("defaults region to us-east-1 when not stored", async () => {
await Bun.write(
AWS_CONFIG_PATH,
JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY" }),
{ mode: 0o600 },
);
const result = loadCredsFromConfig();
expect(result?.region).toBe("us-east-1");
});
});
describe("saveCredsToConfig", () => {
it("writes credentials to config file", async () => {
if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); }
await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", "us-west-2");
const result = loadCredsFromConfig();
expect(result?.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
expect(result?.secretAccessKey).toBe("wJalrXUtnFEMI/K7MDENG/bPxRfiCY");
expect(result?.region).toBe("us-west-2");
});
it("round-trips credentials with special characters in secret key", async () => {
if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); }
const secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCY==";
await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", secret, "ap-northeast-1");
const result = loadCredsFromConfig();
expect(result?.secretAccessKey).toBe(secret);
});
it("overwrites existing config file", async () => {
await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", "us-east-1");
await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE2", "newSecretKeyNewSecretKey1234567", "eu-central-1");
const result = loadCredsFromConfig();
expect(result?.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE2");
expect(result?.region).toBe("eu-central-1");
});
});
});
// ─── aws.ts tests ────────────────────────────────────────────────────────────
describe("aws/aws", () => {

View file

@ -222,6 +222,16 @@ describe("Unknown Flag Detection", () => {
]),
).toBeNull();
});
it("should allow --reauth", () => {
expect(
findUnknownFlag([
"claude",
"aws",
"--reauth",
]),
).toBeNull();
});
});
describe("ignores positional arguments", () => {
@ -339,6 +349,7 @@ describe("KNOWN_FLAGS completeness", () => {
"--cloud",
"--clear",
"--custom",
"--reauth",
];
for (const flag of expected) {
expect(KNOWN_FLAGS.has(flag)).toBe(true);

View file

@ -15,6 +15,7 @@ import {
toKebabCase,
defaultSpawnName,
sanitizeTermValue,
jsonEscape,
} from "../shared/ui";
import type { CloudInitTier } from "../shared/agents";
import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init";
@ -26,6 +27,40 @@ import { saveVmConnection } from "../history.js";
const DASHBOARD_URL = "https://lightsail.aws.amazon.com/";
// ─── Credential Cache ────────────────────────────────────────────────────────
export const AWS_CONFIG_PATH = `${process.env.HOME}/.config/spawn/aws.json`;
const AwsCredsSchema = v.object({
accessKeyId: v.string(),
secretAccessKey: v.string(),
region: v.optional(v.string()),
});
export async function saveCredsToConfig(accessKeyId: string, secretAccessKey: string, region: string): Promise<void> {
const dir = AWS_CONFIG_PATH.replace(/\/[^/]+$/, "");
await Bun.spawn(["mkdir", "-p", dir]).exited;
const payload = `{\n "accessKeyId": ${jsonEscape(accessKeyId)},\n "secretAccessKey": ${jsonEscape(secretAccessKey)},\n "region": ${jsonEscape(region)}\n}\n`;
await Bun.write(AWS_CONFIG_PATH, payload, { mode: 0o600 });
}
export function loadCredsFromConfig(): { accessKeyId: string; secretAccessKey: string; region: string } | null {
try {
const raw = readFileSync(AWS_CONFIG_PATH, "utf-8");
const data = parseJsonWith(raw, AwsCredsSchema);
if (!data?.accessKeyId || !data?.secretAccessKey) { return null; }
if (!/^[A-Za-z0-9/+]{16,128}$/.test(data.accessKeyId)) { return null; }
if (data.secretAccessKey.length < 16) { return null; }
return {
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
region: data.region || "us-east-1",
};
} catch {
return null;
}
}
// ─── Lightsail Bundles ────────────────────────────────────────────────────────
export interface Bundle {
@ -435,6 +470,7 @@ export async function ensureAwsCli(): Promise<void> {
export async function authenticate(): Promise<void> {
const region = process.env.AWS_DEFAULT_REGION || process.env.LIGHTSAIL_REGION || "us-east-1";
awsRegion = region;
const skipCache = process.env.SPAWN_REAUTH === "1";
// 1. Try existing CLI with valid credentials
if (hasAwsCli()) {
@ -460,23 +496,62 @@ export async function authenticate(): Promise<void> {
if (hasAwsCli()) {
lightsailMode = "cli";
process.env.AWS_DEFAULT_REGION = region;
await saveCredsToConfig(awsAccessKeyId, awsSecretAccessKey, region);
logInfo(`AWS CLI ready with env credentials, using region: ${region}`);
return;
}
lightsailMode = "rest";
await saveCredsToConfig(awsAccessKeyId, awsSecretAccessKey, region);
logInfo("AWS CLI not available \u2014 using Lightsail REST API directly");
logInfo(`Using region: ${region}`);
return;
}
// 3. Interactive credential entry
// 3. Try cached credentials from ~/.config/spawn/aws.json
if (!skipCache) {
const cached = loadCredsFromConfig();
if (cached) {
const cachedRegion = process.env.AWS_DEFAULT_REGION || process.env.LIGHTSAIL_REGION || cached.region;
process.env.AWS_ACCESS_KEY_ID = cached.accessKeyId;
process.env.AWS_SECRET_ACCESS_KEY = cached.secretAccessKey;
process.env.AWS_DEFAULT_REGION = cachedRegion;
awsRegion = cachedRegion;
awsAccessKeyId = cached.accessKeyId;
awsSecretAccessKey = cached.secretAccessKey;
if (hasAwsCli()) {
const result = awsCliSync(["sts", "get-caller-identity"]);
if (result.exitCode === 0) {
lightsailMode = "cli";
logInfo(`AWS CLI ready with cached credentials, using region: ${cachedRegion}`);
return;
}
logWarn("Cached AWS credentials invalid or expired");
awsAccessKeyId = "";
awsSecretAccessKey = "";
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
lightsailMode = "rest";
logInfo("Using cached AWS credentials with Lightsail REST API");
logInfo(`Using region: ${cachedRegion}`);
return;
}
}
}
// 4. Interactive credential entry
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
logError("AWS credentials not found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.");
throw new Error("No AWS credentials");
}
logStep("Enter your AWS credentials:");
if (skipCache) {
logStep("Re-entering AWS credentials (--reauth):");
} else {
logStep("Enter your AWS credentials:");
}
const accessKey = await prompt("AWS Access Key ID: ");
if (!accessKey) {
throw new Error("No access key provided");
@ -499,12 +574,14 @@ export async function authenticate(): Promise<void> {
]);
if (result.exitCode === 0) {
lightsailMode = "cli";
await saveCredsToConfig(accessKey, secretKey, region);
logInfo(`AWS CLI configured, using region: ${region}`);
return;
}
}
lightsailMode = "rest";
await saveCredsToConfig(accessKey, secretKey, region);
logInfo("Using Lightsail REST API directly");
logInfo(`Using region: ${region}`);
}

View file

@ -23,6 +23,7 @@ export const KNOWN_FLAGS = new Set([
"--cloud",
"--clear",
"--custom",
"--reauth",
]);
/** Return the first unknown flag in args, or null if all are known/positional */

View file

@ -94,6 +94,7 @@ function checkUnknownFlags(args: string[]): void {
console.error(` ${pc.cyan("--output json")} Output structured JSON to stdout`);
console.error(` ${pc.cyan("--custom")} Show interactive size/region pickers`);
console.error(` ${pc.cyan("--name")} Set the spawn/resource name`);
console.error(` ${pc.cyan("--reauth")} Force re-prompting for cloud credentials`);
console.error(` ${pc.cyan("--help, -h")} Show help information`);
console.error(` ${pc.cyan("--version, -v")} Show version`);
console.error();
@ -726,6 +727,13 @@ async function main(): Promise<void> {
process.env.SPAWN_CUSTOM = "1";
}
// Extract --reauth boolean flag
const reauthIdx = filteredArgs.indexOf("--reauth");
if (reauthIdx !== -1) {
filteredArgs.splice(reauthIdx, 1);
process.env.SPAWN_REAUTH = "1";
}
// Extract --output <format> flag
const [outputFormat, outputFilteredArgs] = extractFlagValue(
filteredArgs,