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:
roboomp
2026-05-17 06:56:07 +00:00
parent cbd37a26ec
commit fa83ebcb45
3 changed files with 349 additions and 0 deletions
+4
View File
@@ -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";
+178
View File
@@ -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();
});
});