fix(ai): support credential_process profiles in AWS credential resolver
readProfileCredentials now checks for a credential_process key after the static-keys and SSO branches. The helper is spawned via Bun.spawn with stdin ignored; stdout is parsed as the AWS SDK Version 1 JSON envelope; Expiration feeds the existing per-profile cache so re-invocations honour the broker's TTL. Additional coverage: - POSIX-shell tokenizer preserves backslashes inside double quotes so Windows paths like "C:\\Program Files\\auth.exe" tokenize correctly. - .cmd/.bat helpers are routed through cmd.exe /c on Windows. - AbortSignal is forwarded to the child process. - 13 new tests covering tokenizer edge cases, envelope validation, cache hit-count, non-zero exit stderr surfacing, and abort propagation. Fixes #1142
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added `credential_process` support to the Bedrock provider's AWS credential resolver so profiles delegating to external brokers (`aws-vault`, `granted`, in-house tools) resolve instead of falling through to `Unable to resolve AWS credentials`. Parses the AWS SDK `Version: 1` JSON envelope, honors `Expiration` in the per-profile cache, propagates `AbortSignal` to the spawned helper, routes Windows `.cmd`/`.bat` helpers through `cmd.exe /c`, and ships a POSIX-shell-style tokenizer that preserves backslashes inside double quotes so Windows paths survive ([#1142](https://github.com/can1357/oh-my-pi/issues/1142))
|
||||
|
||||
## [15.1.3] - 2026-05-17
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
* - SSO profile referencing a cached token in `~/.aws/sso/cache/*.json`,
|
||||
* which we exchange for short-lived role credentials via
|
||||
* `https://portal.sso.{region}.amazonaws.com/federation/credentials`.
|
||||
* - `credential_process` — an external command emitting the AWS SDK
|
||||
* `Version: 1` JSON envelope on stdout. Used by `aws-vault`, `granted`,
|
||||
* in-house brokers, etc.
|
||||
* 3. EC2 IMDSv2 (only when `AWS_EC2_METADATA_DISABLED` is unset / falsey and
|
||||
* `169.254.169.254` is reachable within a 1 s timeout).
|
||||
*
|
||||
@@ -162,6 +165,10 @@ async function readProfileCredentials(
|
||||
return readSsoCredentials(merged, configIni, region, signal);
|
||||
}
|
||||
|
||||
if (merged.credential_process) {
|
||||
return readCredentialProcess(profile, merged.credential_process, signal);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -276,6 +283,166 @@ async function sha1Hex(input: string): Promise<string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- credential_process ----------
|
||||
|
||||
/** JSON envelope emitted by an external credential process. Matches the
|
||||
* AWS CLI / SDK contract documented at
|
||||
* https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html */
|
||||
interface CredentialProcessEnvelope {
|
||||
Version?: number;
|
||||
AccessKeyId?: string;
|
||||
SecretAccessKey?: string;
|
||||
SessionToken?: string;
|
||||
Expiration?: string;
|
||||
}
|
||||
|
||||
async function readCredentialProcess(
|
||||
profile: string,
|
||||
command: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<ResolvedCredentials> {
|
||||
const argv = buildCredentialProcessArgv(profile, command);
|
||||
|
||||
const child = Bun.spawn(argv, {
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
signal,
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
const tail = stderr.trim().slice(-512) || stdout.trim().slice(-512) || "(no output)";
|
||||
throw new Error(`AWS credential_process for profile '${profile}' exited ${exitCode}: ${tail}`);
|
||||
}
|
||||
|
||||
let parsed: CredentialProcessEnvelope;
|
||||
try {
|
||||
parsed = JSON.parse(stdout) as CredentialProcessEnvelope;
|
||||
} catch (err) {
|
||||
throw new Error(`AWS credential_process for profile '${profile}' did not emit valid JSON: ${String(err)}`);
|
||||
}
|
||||
if (parsed.Version !== 1) {
|
||||
throw new Error(
|
||||
`AWS credential_process for profile '${profile}' returned unsupported Version ${parsed.Version ?? "<missing>"}; expected 1.`,
|
||||
);
|
||||
}
|
||||
if (!parsed.AccessKeyId || !parsed.SecretAccessKey) {
|
||||
throw new Error(
|
||||
`AWS credential_process for profile '${profile}' returned envelope without AccessKeyId/SecretAccessKey.`,
|
||||
);
|
||||
}
|
||||
|
||||
const out: ResolvedCredentials = {
|
||||
accessKeyId: parsed.AccessKeyId,
|
||||
secretAccessKey: parsed.SecretAccessKey,
|
||||
};
|
||||
if (parsed.SessionToken) out.sessionToken = parsed.SessionToken;
|
||||
if (parsed.Expiration) {
|
||||
const exp = Date.parse(parsed.Expiration);
|
||||
if (!Number.isNaN(exp)) out.expiresAt = exp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Resolve the argv for `Bun.spawn`. On Windows we route `.cmd`/`.bat` helpers
|
||||
* through `cmd.exe /c` because direct execution refuses batch files (mirrors
|
||||
* Node's `execFile` policy and avoids surprise no-ops). */
|
||||
function buildCredentialProcessArgv(profile: string, command: string): string[] {
|
||||
const tokens = tokenizeCredentialProcessCommand(command);
|
||||
if (tokens.length === 0) {
|
||||
throw new Error(`AWS credential_process for profile '${profile}' is empty.`);
|
||||
}
|
||||
if (process.platform === "win32" && isBatchScript(tokens[0])) {
|
||||
return ["cmd.exe", "/d", "/s", "/c", command];
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isBatchScript(executable: string): boolean {
|
||||
const lower = executable.toLowerCase();
|
||||
return lower.endsWith(".cmd") || lower.endsWith(".bat");
|
||||
}
|
||||
|
||||
/** POSIX-shell-style tokenizer used by the AWS CLI for `credential_process`.
|
||||
*
|
||||
* Outside quotes a backslash escapes the next character. Inside single quotes
|
||||
* everything is literal (no escapes, cannot contain `'`). Inside double quotes
|
||||
* a backslash only escapes `$`, `` ` ``, `"`, and `\` — every other backslash
|
||||
* is preserved verbatim, which is what makes Windows paths like
|
||||
* `"C:\Program Files\tool\auth.exe"` survive tokenization. */
|
||||
export function tokenizeCredentialProcessCommand(cmd: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let hasToken = false;
|
||||
let mode: "normal" | "single" | "double" = "normal";
|
||||
for (let i = 0; i < cmd.length; i++) {
|
||||
const ch = cmd[i];
|
||||
if (mode === "normal") {
|
||||
if (ch === "'") {
|
||||
mode = "single";
|
||||
hasToken = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
mode = "double";
|
||||
hasToken = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\" && i + 1 < cmd.length) {
|
||||
current += cmd[++i];
|
||||
hasToken = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
||||
if (hasToken) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
hasToken = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
hasToken = true;
|
||||
continue;
|
||||
}
|
||||
if (mode === "single") {
|
||||
if (ch === "'") {
|
||||
mode = "normal";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
// double-quote
|
||||
if (ch === '"') {
|
||||
mode = "normal";
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\" && i + 1 < cmd.length) {
|
||||
const next = cmd[i + 1];
|
||||
if (next === "$" || next === "`" || next === '"' || next === "\\") {
|
||||
current += next;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Preserve literal backslash for Windows paths.
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (mode !== "normal") {
|
||||
throw new Error("AWS credential_process command has an unterminated quote.");
|
||||
}
|
||||
if (hasToken) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ---------- IMDSv2 ----------
|
||||
|
||||
const IMDS_HOST = "169.254.169.254";
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
clearAwsCredentialCache,
|
||||
resolveAwsCredentials,
|
||||
tokenizeCredentialProcessCommand,
|
||||
} from "../src/providers/aws-credentials";
|
||||
|
||||
// `credential_process` integration coverage. Drives a real `Bun.spawn`
|
||||
// against a fixture script so the JSON envelope contract, exit-code
|
||||
// handling, abort propagation, cache behavior, and the POSIX-style
|
||||
// tokenizer are all exercised end-to-end.
|
||||
|
||||
const ENV_KEYS = [
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AWS_PROFILE",
|
||||
"AWS_REGION",
|
||||
"AWS_DEFAULT_REGION",
|
||||
"AWS_CONFIG_FILE",
|
||||
"AWS_SHARED_CREDENTIALS_FILE",
|
||||
"AWS_EC2_METADATA_DISABLED",
|
||||
] as const;
|
||||
|
||||
function quoteForConfig(p: string): string {
|
||||
if (!/[\s"]/.test(p)) return p;
|
||||
// Wrap in double quotes; our tokenizer preserves backslashes so Windows
|
||||
// paths survive without further escaping.
|
||||
return `"${p.replace(/(["])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
describe("tokenizeCredentialProcessCommand", () => {
|
||||
test("splits on whitespace", () => {
|
||||
expect(tokenizeCredentialProcessCommand("/bin/auth --json")).toEqual(["/bin/auth", "--json"]);
|
||||
});
|
||||
|
||||
test("collapses runs of whitespace", () => {
|
||||
expect(tokenizeCredentialProcessCommand(" a\tb \n c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("double quotes preserve Windows backslashes", () => {
|
||||
expect(tokenizeCredentialProcessCommand(`"C:\\Program Files\\auth\\tool.exe" --json`)).toEqual([
|
||||
"C:\\Program Files\\auth\\tool.exe",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
test('double quotes still escape $ ` " and \\', () => {
|
||||
expect(tokenizeCredentialProcessCommand(`"a\\"b" "\\$x" "\\\\n"`)).toEqual([`a"b`, "$x", "\\n"]);
|
||||
});
|
||||
|
||||
test("single quotes are fully literal", () => {
|
||||
expect(tokenizeCredentialProcessCommand(`'C:\\path with spaces\\bin' --x`)).toEqual([
|
||||
"C:\\path with spaces\\bin",
|
||||
"--x",
|
||||
]);
|
||||
});
|
||||
|
||||
test("backslash outside quotes escapes the next character", () => {
|
||||
expect(tokenizeCredentialProcessCommand(`a\\ b c`)).toEqual(["a b", "c"]);
|
||||
});
|
||||
|
||||
test("rejects unterminated quotes", () => {
|
||||
expect(() => tokenizeCredentialProcessCommand(`"unterminated`)).toThrow(/unterminated/);
|
||||
expect(() => tokenizeCredentialProcessCommand(`'half`)).toThrow(/unterminated/);
|
||||
});
|
||||
|
||||
test("empty input yields no tokens", () => {
|
||||
expect(tokenizeCredentialProcessCommand("")).toEqual([]);
|
||||
expect(tokenizeCredentialProcessCommand(" \t ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAwsCredentials credential_process", () => {
|
||||
let tmp: string;
|
||||
const saved = new Map<string, string | undefined>();
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const k of ENV_KEYS) {
|
||||
saved.set(k, Bun.env[k]);
|
||||
delete Bun.env[k];
|
||||
}
|
||||
Bun.env.AWS_EC2_METADATA_DISABLED = "true";
|
||||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), "aws-credproc-"));
|
||||
clearAwsCredentialCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [k, v] of saved) {
|
||||
if (v === undefined) delete Bun.env[k];
|
||||
else Bun.env[k] = v;
|
||||
}
|
||||
saved.clear();
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
clearAwsCredentialCache();
|
||||
});
|
||||
|
||||
async function writeFixture(name: string, body: string): Promise<string> {
|
||||
const p = path.join(tmp, name);
|
||||
await Bun.write(p, body);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function writeConfig(profile: string, line: string): Promise<void> {
|
||||
const cfg = path.join(tmp, "config");
|
||||
await Bun.write(cfg, `[profile ${profile}]\n${line}\n`);
|
||||
Bun.env.AWS_CONFIG_FILE = cfg;
|
||||
// Point shared credentials at a known-empty file so static-creds resolution
|
||||
// definitely misses.
|
||||
const sharedPath = path.join(tmp, "credentials");
|
||||
await Bun.write(sharedPath, "");
|
||||
Bun.env.AWS_SHARED_CREDENTIALS_FILE = sharedPath;
|
||||
}
|
||||
|
||||
test("parses a Version 1 envelope and honors Expiration", async () => {
|
||||
const script = await writeFixture(
|
||||
"good.js",
|
||||
`console.log(JSON.stringify({Version:1,AccessKeyId:"AKIATEST",SecretAccessKey:"sek",SessionToken:"tok",Expiration:"2099-01-01T00:00:00Z"}));`,
|
||||
);
|
||||
await writeConfig("good", `credential_process = ${quoteForConfig(process.execPath)} ${quoteForConfig(script)}`);
|
||||
|
||||
const creds = await resolveAwsCredentials({ profile: "good", region: "us-east-1" });
|
||||
expect(creds.accessKeyId).toBe("AKIATEST");
|
||||
expect(creds.secretAccessKey).toBe("sek");
|
||||
expect(creds.sessionToken).toBe("tok");
|
||||
expect(creds.expiresAt).toBe(Date.parse("2099-01-01T00:00:00Z"));
|
||||
});
|
||||
|
||||
test("caches by profile so the helper is only invoked once", async () => {
|
||||
const counterPath = path.join(tmp, "calls.txt");
|
||||
const script = await writeFixture(
|
||||
"counted.js",
|
||||
`const fs=require("node:fs");
|
||||
const prev=fs.existsSync(${JSON.stringify(counterPath)})?Number(fs.readFileSync(${JSON.stringify(counterPath)},"utf8")):0;
|
||||
fs.writeFileSync(${JSON.stringify(counterPath)},String(prev+1));
|
||||
console.log(JSON.stringify({Version:1,AccessKeyId:"AKIA",SecretAccessKey:"s",Expiration:"2099-01-01T00:00:00Z"}));`,
|
||||
);
|
||||
await writeConfig(
|
||||
"counted",
|
||||
`credential_process = ${quoteForConfig(process.execPath)} ${quoteForConfig(script)}`,
|
||||
);
|
||||
|
||||
await resolveAwsCredentials({ profile: "counted" });
|
||||
await resolveAwsCredentials({ profile: "counted" });
|
||||
const calls = Number(await Bun.file(counterPath).text());
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("rejects unsupported envelope versions", async () => {
|
||||
const script = await writeFixture(
|
||||
"badversion.js",
|
||||
`console.log(JSON.stringify({Version:2,AccessKeyId:"a",SecretAccessKey:"b"}));`,
|
||||
);
|
||||
await writeConfig("badv", `credential_process = ${quoteForConfig(process.execPath)} ${quoteForConfig(script)}`);
|
||||
await expect(resolveAwsCredentials({ profile: "badv" })).rejects.toThrow(/unsupported Version 2/);
|
||||
});
|
||||
|
||||
test("surfaces stderr on non-zero exit", async () => {
|
||||
const script = await writeFixture("fail.js", `process.stderr.write("auth helper broke");process.exit(7);`);
|
||||
await writeConfig(
|
||||
"failing",
|
||||
`credential_process = ${quoteForConfig(process.execPath)} ${quoteForConfig(script)}`,
|
||||
);
|
||||
await expect(resolveAwsCredentials({ profile: "failing" })).rejects.toThrow(/exited 7.*auth helper broke/);
|
||||
});
|
||||
|
||||
test("aborts a long-running helper when the caller's signal fires", async () => {
|
||||
const script = await writeFixture("hang.js", `setTimeout(()=>{},60_000);`);
|
||||
await writeConfig("hangs", `credential_process = ${quoteForConfig(process.execPath)} ${quoteForConfig(script)}`);
|
||||
const ctrl = new AbortController();
|
||||
const promise = resolveAwsCredentials({ profile: "hangs", signal: ctrl.signal });
|
||||
setTimeout(() => ctrl.abort(new Error("test abort")), 50);
|
||||
await expect(promise).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user