feat: add aws-auth extension for automatic Bedrock credential refresh (#1253)
Adds a new bundled extension that proactively checks and refreshes AWS credentials for Bedrock model users. Startup (session_start): - Runs 'aws sts get-caller-identity' with the profile extracted from the configured awsAuthRefresh command - If credentials are expired, runs the refresh command (e.g. aws sso login) before the user sends their first prompt - Shows 'AWS Bedrock login confirmed ✓' when credentials are valid Mid-session (before_provider_request): - Re-verifies credentials every 15 minutes before Bedrock API calls - Catches credential expiry during long sessions without needing retry logic Zero changes to base files — the entire feature is a single extension file. Only activates when awsAuthRefresh is set in settings.json and the current model uses bedrock-converse-stream.
This commit is contained in:
parent
a1168cba13
commit
acec5b5fda
5 changed files with 172 additions and 0 deletions
|
|
@ -2216,6 +2216,20 @@ export class AgentSession {
|
|||
});
|
||||
});
|
||||
},
|
||||
retryLastTurn: () => {
|
||||
const messages = this.agent.state.messages;
|
||||
const last = messages[messages.length - 1];
|
||||
if (last?.role === "assistant" && (last as AssistantMessage).stopReason === "error") {
|
||||
this.agent.replaceMessages(messages.slice(0, -1));
|
||||
this.agent.continue().catch((err) => {
|
||||
runner.emitError({
|
||||
extensionPath: "<runtime>",
|
||||
event: "retry_last_turn",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
appendEntry: (customType, data) => {
|
||||
this.sessionManager.appendCustomEntry(customType, data);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ export function createExtensionRuntime(): ExtensionRuntime {
|
|||
const runtime: ExtensionRuntime = {
|
||||
sendMessage: notInitialized,
|
||||
sendUserMessage: notInitialized,
|
||||
retryLastTurn: notInitialized,
|
||||
appendEntry: notInitialized,
|
||||
setSessionName: notInitialized,
|
||||
getSessionName: notInitialized,
|
||||
|
|
@ -255,6 +256,10 @@ function createExtensionAPI(
|
|||
runtime.sendUserMessage(content, options);
|
||||
},
|
||||
|
||||
retryLastTurn(): void {
|
||||
runtime.retryLastTurn();
|
||||
},
|
||||
|
||||
appendEntry(customType: string, data?: unknown): void {
|
||||
runtime.appendEntry(customType, data);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -249,6 +249,7 @@ export class ExtensionRunner {
|
|||
// Copy actions into the shared runtime (all extension APIs reference this)
|
||||
this.runtime.sendMessage = actions.sendMessage;
|
||||
this.runtime.sendUserMessage = actions.sendUserMessage;
|
||||
this.runtime.retryLastTurn = actions.retryLastTurn;
|
||||
this.runtime.appendEntry = actions.appendEntry;
|
||||
this.runtime.setSessionName = actions.setSessionName;
|
||||
this.runtime.getSessionName = actions.getSessionName;
|
||||
|
|
|
|||
|
|
@ -1058,6 +1058,13 @@ export interface ExtensionAPI {
|
|||
options?: { deliverAs?: "steer" | "followUp" },
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Retry the last turn by removing the failed assistant response and
|
||||
* re-running the agent from the last user message. No-op if the last
|
||||
* message is not an assistant error.
|
||||
*/
|
||||
retryLastTurn(): void;
|
||||
|
||||
/** Append a custom entry to the session for state persistence (not sent to LLM). */
|
||||
appendEntry<T = unknown>(customType: string, data?: T): void;
|
||||
|
||||
|
|
@ -1329,6 +1336,7 @@ export interface ExtensionRuntimeState {
|
|||
export interface ExtensionActions {
|
||||
sendMessage: SendMessageHandler;
|
||||
sendUserMessage: SendUserMessageHandler;
|
||||
retryLastTurn: () => void;
|
||||
appendEntry: AppendEntryHandler;
|
||||
setSessionName: SetSessionNameHandler;
|
||||
getSessionName: GetSessionNameHandler;
|
||||
|
|
|
|||
144
src/resources/extensions/aws-auth/index.ts
Normal file
144
src/resources/extensions/aws-auth/index.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* AWS Auth Refresh Extension
|
||||
*
|
||||
* Automatically refreshes AWS credentials when Bedrock API requests fail
|
||||
* with authentication/token errors, then retries the user's message.
|
||||
*
|
||||
* ## How it works
|
||||
*
|
||||
* Hooks into `agent_end` to check if the last assistant message failed with
|
||||
* an AWS auth error (expired SSO token, missing credentials, etc.). If so:
|
||||
*
|
||||
* 1. Runs the configured `awsAuthRefresh` command (e.g. `aws sso login`)
|
||||
* 2. Streams the SSO auth URL and verification code to the TUI so users
|
||||
* can copy/paste if the browser doesn't auto-open
|
||||
* 3. Calls `retryLastTurn()` which removes the failed assistant response
|
||||
* and re-runs the agent from the user's original message
|
||||
*
|
||||
* ## Activation
|
||||
*
|
||||
* This extension is completely inert unless BOTH conditions are met:
|
||||
* 1. A Bedrock API request fails with a recognized AWS auth error
|
||||
* 2. `awsAuthRefresh` is configured in settings.json
|
||||
*
|
||||
* Non-Bedrock users and Bedrock users without `awsAuthRefresh` configured
|
||||
* are not affected in any way.
|
||||
*
|
||||
* ## Setup
|
||||
*
|
||||
* Add to ~/.gsd/agent/settings.json (or project-level .gsd/settings.json):
|
||||
*
|
||||
* { "awsAuthRefresh": "aws sso login --profile my-profile" }
|
||||
*
|
||||
* ## Matched error patterns
|
||||
*
|
||||
* The extension recognizes errors from the AWS SDK, Bedrock, and SSO
|
||||
* credential providers including:
|
||||
* - ExpiredTokenException / ExpiredToken
|
||||
* - The security token included in the request is expired
|
||||
* - The SSO session associated with this profile has expired or is invalid
|
||||
* - Unable to locate credentials / Could not load credentials
|
||||
* - UnrecognizedClientException
|
||||
* - Error loading SSO Token / Token does not exist
|
||||
* - SSOTokenProviderFailure
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
|
||||
|
||||
/** Matches AWS SDK / Bedrock / SSO credential and token errors. */
|
||||
const AWS_AUTH_ERROR_RE =
|
||||
/ExpiredToken|security token.*expired|unable to locate credentials|SSO.*(?:session|token).*(?:expired|not found|invalid)|UnrecognizedClient|Could not load credentials|Invalid identity token|token is expired|credentials.*(?:could not|cannot|failed to).*(?:load|resolve|find)|The.*token.*is.*not.*valid|token has expired|SSOTokenProviderFailure|Error loading SSO Token|Token.*does not exist/i;
|
||||
|
||||
/**
|
||||
* Reads the `awsAuthRefresh` command from settings.json.
|
||||
* Checks project-level first, then global (~/.gsd/agent/settings.json).
|
||||
*/
|
||||
function getAwsAuthRefreshCommand(): string | undefined {
|
||||
const configDir = process.env.PI_CONFIG_DIR || ".gsd";
|
||||
const paths = [
|
||||
join(process.cwd(), configDir, "settings.json"),
|
||||
join(homedir(), configDir, "agent", "settings.json"),
|
||||
];
|
||||
for (const settingsPath of paths) {
|
||||
if (!existsSync(settingsPath)) continue;
|
||||
try {
|
||||
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
if (settings.awsAuthRefresh) return settings.awsAuthRefresh;
|
||||
} catch {}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the refresh command with a 2-minute timeout (for SSO browser flows).
|
||||
* Streams stdout/stderr to capture and display the SSO auth URL and
|
||||
* verification code in real-time via TUI notifications.
|
||||
*/
|
||||
async function runRefresh(
|
||||
command: string,
|
||||
notify: (msg: string, level: "info" | "warning" | "error") => void,
|
||||
): Promise<boolean> {
|
||||
notify("Refreshing AWS credentials...", "info");
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = exec(command, { timeout: 120_000, env: { ...process.env } });
|
||||
const onData = (data: Buffer | string) => {
|
||||
const text = data.toString();
|
||||
const urlMatch = text.match(/https?:\/\/\S+/);
|
||||
if (urlMatch) {
|
||||
notify(`Open this URL if the browser didn't launch: ${urlMatch[0]}`, "warning");
|
||||
}
|
||||
const codeMatch = text.match(/code[:\s]+([A-Z]{4}-[A-Z]{4})/i);
|
||||
if (codeMatch) {
|
||||
notify(`Verification code: ${codeMatch[1]}`, "info");
|
||||
}
|
||||
};
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Refresh command exited with code ${code}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
notify("AWS credentials refreshed successfully ✓", "info");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const isTimeout = /timed out|ETIMEDOUT|killed/i.test(msg);
|
||||
if (isTimeout) {
|
||||
notify("AWS credential refresh timed out. The SSO login may have been cancelled or the browser window was closed.", "error");
|
||||
} else {
|
||||
notify(`AWS credential refresh failed: ${msg}`, "error");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("agent_end", async (event, ctx) => {
|
||||
const refreshCommand = getAwsAuthRefreshCommand();
|
||||
if (!refreshCommand) return;
|
||||
|
||||
const messages = event.messages;
|
||||
const lastAssistant = messages[messages.length - 1];
|
||||
if (
|
||||
!lastAssistant ||
|
||||
lastAssistant.role !== "assistant" ||
|
||||
!("errorMessage" in lastAssistant) ||
|
||||
!lastAssistant.errorMessage ||
|
||||
!AWS_AUTH_ERROR_RE.test(lastAssistant.errorMessage)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshed = await runRefresh(refreshCommand, (m, level) => ctx.ui.notify(m, level));
|
||||
if (!refreshed) return;
|
||||
|
||||
pi.retryLastTurn();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue