mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-13 15:40:55 +00:00
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:
parent
a96a396e79
commit
ac7fa14c61
5 changed files with 209 additions and 4 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue