From fa83ebcb45fcd5e4e91332a3499582a9080b0866 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 17 May 2026 06:56:07 +0000 Subject: [PATCH] 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 --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/aws-credentials.ts | 167 +++++++++++++++++ packages/ai/test/aws-credentials.test.ts | 178 +++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 packages/ai/test/aws-credentials.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5e28b495c..6c533ac61 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/providers/aws-credentials.ts b/packages/ai/src/providers/aws-credentials.ts index 831bc7fa2..9c10cb8ba 100644 --- a/packages/ai/src/providers/aws-credentials.ts +++ b/packages/ai/src/providers/aws-credentials.ts @@ -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 { 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 { + 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 ?? ""}; 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"; diff --git a/packages/ai/test/aws-credentials.test.ts b/packages/ai/test/aws-credentials.test.ts new file mode 100644 index 000000000..851c4bc58 --- /dev/null +++ b/packages/ai/test/aws-credentials.test.ts @@ -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(); + + 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 { + const p = path.join(tmp, name); + await Bun.write(p, body); + return p; + } + + async function writeConfig(profile: string, line: string): Promise { + 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(); + }); +});