@oh-my-pi/pi-ai 17.2.12 → 17.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/types/error/aws.d.ts +5 -1
  3. package/dist/types/providers/aws-credentials.d.ts +4 -3
  4. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  5. package/dist/types/providers/cursor-pi-args.d.ts +14 -0
  6. package/dist/types/providers/openai-shared.d.ts +9 -1
  7. package/dist/types/types.d.ts +7 -0
  8. package/dist/types/usage/cursor.d.ts +11 -0
  9. package/dist/types/utils/block-symbols.d.ts +12 -0
  10. package/package.json +5 -5
  11. package/src/dialect/owned-stream.ts +3 -0
  12. package/src/error/aws.ts +5 -1
  13. package/src/providers/amazon-bedrock.ts +38 -0
  14. package/src/providers/aws-credentials.ts +222 -29
  15. package/src/providers/cursor/exec-modern.ts +1 -0
  16. package/src/providers/cursor-pi-args.ts +22 -0
  17. package/src/providers/cursor.ts +81 -1
  18. package/src/providers/google-gemini-cli.ts +49 -15
  19. package/src/providers/google-shared.ts +7 -1
  20. package/src/providers/openai-codex/request-transformer.ts +38 -17
  21. package/src/providers/openai-codex-responses.ts +2 -3
  22. package/src/providers/openai-responses.ts +4 -0
  23. package/src/providers/openai-shared.ts +55 -1
  24. package/src/providers/pi-native-server.ts +1 -0
  25. package/src/providers/register-builtins.ts +18 -14
  26. package/src/registry/aws.ts +13 -6
  27. package/src/registry/oauth/callback-server.ts +93 -5
  28. package/src/stream.ts +1 -0
  29. package/src/types.ts +7 -0
  30. package/src/usage/cursor.ts +174 -42
  31. package/src/usage/kimi.ts +29 -5
  32. package/src/usage/openai-codex-reset.ts +2 -1
  33. package/src/usage/openai-codex.ts +2 -1
  34. package/src/usage/zai.ts +2 -1
  35. package/src/utils/aws-profile.ts +39 -1
  36. package/src/utils/block-symbols.ts +18 -0
  37. package/src/utils/leaked-thinking-stream.ts +3 -0
  38. package/src/utils/openrouter-headers.ts +3 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.13] - 2026-08-11
6
+
7
+ ### Changed
8
+
9
+ - Standardized first-party outbound User-Agent headers on `omp/<version>` via the shared `USER_AGENT` utility.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed the Amazon Bedrock and Cursor transports ignoring `StreamOptions.headers`; both built their request headers from scratch, so caller-supplied tracing or attribution headers were silently dropped while working on every other provider ([#8107](https://github.com/can1357/oh-my-pi/pull/8107) by [@svperfecta](https://github.com/svperfecta)).
14
+ - Fixed Antigravity Flash turns hanging after successful response headers when the endpoint never emitted an SSE event; the provider now cancels the stalled body and fails over after 60 seconds while retaining the longer allowance for Pro reasoning starts.
15
+ - Fixed Cursor exec-bridge bash/grep calls failing ArkType validation when the server omitted optional frame fields: synthesized and executed tool args now drop `undefined` keys (`cwd`, `case`, `skip`, `timeout`) instead of writing `optional: value || undefined`.
16
+ - Fixed Cursor sessions double-executing settled tools when `tools.format` is an owned dialect (e.g. `gemini`): `wrapInbandToolStream` rebuilt toolCall blocks without copying `kCursorExecResolved`, so agent-loop re-ran bash/grep/todo and appended a second result for the same call id.
17
+ - Fixed Codex Responses Lite requests for opaque model codenames such as Daybreak omitting the required `reasoning.context: "all_turns"` value and failing with HTTP 400.
18
+ - Fixed Cursor personal usage reporting for current Pro / Pro+ / Ultra `/api/usage-summary` payloads that expose `individualUsage.plan` (and optional `onDemand`) instead of the older `individualUsage.overall` bucket ([#7998](https://github.com/can1357/oh-my-pi/pull/7998) by [@dnth](https://github.com/dnth)).
19
+ - Allowed passive Google callers to accept empty or thinking-only `STOP` responses as successful silence instead of exhausting the provider's empty-response retry budget. ([#8223](https://github.com/can1357/oh-my-pi/issues/8223))
20
+ - Fixed the AWS credential resolver ignoring `role_arn` profiles: shared-config role chaining (`source_profile` recursion, `web_identity_token_file`, `credential_source`) now resolves via STS `AssumeRole`/`AssumeRoleWithWebIdentity`, honoring `role_session_name`/`duration_seconds`/`external_id`, so Bedrock is detected on EKS/IRSA and multi-account setups instead of reporting "No models available" ([#8209](https://github.com/can1357/oh-my-pi/issues/8209)).
21
+ - Fixed Bedrock availability being under-detected on Nitro/EKS hosts: the EC2 metadata probe now recognizes Nitro DMI markers (`board_asset_tag` instance ids, `Amazon EC2` vendor fields) in addition to the Xen `ec2` UUID prefix ([#8209](https://github.com/can1357/oh-my-pi/issues/8209)).
22
+ - Fixed DeepSeek Responses targets (opencode-go) rejecting a thinking-mode continuation with `400 The reasoning_text in the thinking mode must be passed back to the API` after a prewalk hand-off plus mid-run compaction: the Responses input builder re-encoded replayed assistant turns without a reasoning item, so the request enabled reasoning but shipped no `reasoning_text`. The encoder now synthesizes a `reasoning_text` reasoning item for every replayed assistant turn when the target requires reasoning replay in thinking mode (`requiresReasoningContentForAllAssistantTurns` / `requiresReasoningContentForToolCalls`), mirroring the chat-completions `reasoning_content` safety net ([#8248](https://github.com/can1357/oh-my-pi/issues/8248)).
23
+
5
24
  ## [17.2.12] - 2026-08-08
6
25
 
7
26
  ### Fixed
@@ -13,7 +13,11 @@ export type AwsCredentialsErrorKind =
13
13
  /** STS web-identity exchange failed or returned malformed credentials. */
14
14
  | "web-identity"
15
15
  /** ECS/container credential endpoint failed or returned malformed credentials. */
16
- | "container";
16
+ | "container"
17
+ /** Shared-config role chain is misconfigured (cycle, missing source_profile, unsupported credential_source). */
18
+ | "profile"
19
+ /** STS `AssumeRole` call failed or returned malformed credentials. */
20
+ | "assume-role";
17
21
  /** A failure resolving AWS credentials for the Bedrock provider. */
18
22
  export declare class AwsCredentialsError extends Error {
19
23
  readonly kind: AwsCredentialsErrorKind;
@@ -5,8 +5,9 @@
5
5
  * 1. Static credentials from the environment
6
6
  * (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` [+ `AWS_SESSION_TOKEN`]).
7
7
  * 2. Web identity (`AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN`).
8
- * 3. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO):
9
- * - static keys, SSO, or `credential_process`.
8
+ * 3. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO/roles):
9
+ * - static keys, SSO, `credential_process`, or `role_arn` role chaining
10
+ * (`source_profile` recursion, `web_identity_token_file`, `credential_source`).
10
11
  * 4. ECS/container credentials from `AWS_CONTAINER_CREDENTIALS_*`.
11
12
  * 5. EC2 IMDSv2 when metadata is enabled.
12
13
  *
@@ -14,7 +15,7 @@
14
15
  * 60 s before `Expiration` to absorb clock skew.
15
16
  */
16
17
  import type { FetchImpl } from "../types.js";
17
- import type { AwsCredentials } from "./aws-sigv4.js";
18
+ import { type AwsCredentials } from "./aws-sigv4.js";
18
19
  export interface ResolvedCredentials extends AwsCredentials {
19
20
  /** Absolute expiration timestamp in ms. `undefined` for non-expiring static creds. */
20
21
  expiresAt?: number;
@@ -17,7 +17,7 @@ import type { ToolResultMessage } from "../../types.js";
17
17
  * virtual registry. Re-exported here because this is where the frame builders
18
18
  * and their translation are consumed together.
19
19
  */
20
- export { piEscapeRegexLiteral, piGrepSkip, piJoinPath, piLimit, piLsPath, piReadDisplayPath, piReadPath, piReadPathHasRange, piTimeout, } from "../cursor-pi-args.js";
20
+ export { omitUndefinedArgs, piEscapeRegexLiteral, piGrepSkip, piJoinPath, piLimit, piLsPath, piReadDisplayPath, piReadPath, piReadPathHasRange, piTimeout, } from "../cursor-pi-args.js";
21
21
  /** Flatten a tool result's content into the single `output` string the Pi frames carry. */
22
22
  export declare function piOutputText(toolResult: ToolResultMessage): string;
23
23
  /**
@@ -103,3 +103,17 @@ export declare function piLimit(limit: number | undefined): number | undefined;
103
103
  * Negative values have no local meaning and fall back to the default.
104
104
  */
105
105
  export declare function piTimeout(timeout: number | undefined): number | undefined;
106
+ /**
107
+ * Drop keys whose value is `undefined` so optional local-tool kwargs stay
108
+ * absent rather than present-as-undefined.
109
+ *
110
+ * The Cursor exec bridge historically wrote forms like
111
+ * `cwd: workingDirectory || undefined` and
112
+ * `case: caseInsensitive === true ? false : undefined`. ArkType rejects a
113
+ * present `undefined` on an optional field (`was undefined`) even though
114
+ * omitting the key is valid — which flooded Cursor sessions with bash/grep
115
+ * validation errors for otherwise fine frames.
116
+ */
117
+ export declare function omitUndefinedArgs<T extends Record<string, unknown>>(args: T): {
118
+ [K in keyof T]?: Exclude<T[K], undefined>;
119
+ };
@@ -419,6 +419,14 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
419
419
  repairOrphanOutputs?: boolean;
420
420
  /** Preserve assistant message item IDs from text signatures during fallback replay. */
421
421
  preserveAssistantMessageIds?: boolean;
422
+ /**
423
+ * Synthesize a reasoning item for every replayed assistant turn that carries
424
+ * content but no reasoning item. Set for DeepSeek-family Responses targets
425
+ * that reject a thinking-mode continuation lacking `reasoning_text`.
426
+ */
427
+ requiresReasoningReplayForAllTurns?: boolean;
428
+ /** As {@link requiresReasoningReplayForAllTurns}, but only for turns that contain a tool call. */
429
+ requiresReasoningReplayForToolCalls?: boolean;
422
430
  }
423
431
  /**
424
432
  * Escape reserved Harmony control tokens in the free-text fields of replayed
@@ -443,7 +451,7 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
443
451
  */
444
452
  export declare function escapeReplayedControlTokens(items: ResponseInput): ResponseInput;
445
453
  export declare function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput;
446
- export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>, preserveMessageIds?: boolean, supportsCustomToolCalls?: boolean, customToolWireNameMap?: ReadonlyMap<string, string>, computerCallIds?: Set<string>): ResponseInput;
454
+ export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>, preserveMessageIds?: boolean, supportsCustomToolCalls?: boolean, customToolWireNameMap?: ReadonlyMap<string, string>, computerCallIds?: Set<string>, requiresReasoningReplayForAllTurns?: boolean, requiresReasoningReplayForToolCalls?: boolean): ResponseInput;
447
455
  /** Appends one tool result while keeping consecutive outputs ahead of its synthetic image messages. */
448
456
  export declare function appendResponsesToolResultMessages<TApi extends Api>(messages: ResponseInput, toolResult: ToolResultMessage, model: Model<TApi>, strictResponsesPairing: boolean, supportsImageDetailOriginal: boolean, knownCallIds: ReadonlySet<string>, customCallIds?: ReadonlySet<string>, supportsCustomToolCalls?: boolean, computerCallIds?: ReadonlySet<string>): void;
449
457
  /**
@@ -368,6 +368,13 @@ export interface StreamOptions {
368
368
  * Optional retry delay hook for tests and transports that need custom scheduling.
369
369
  */
370
370
  providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
371
+ /**
372
+ * Accept a Google `STOP` response with no visible text or tool call as a
373
+ * successful completion. Passive callers such as advisors use this because
374
+ * silence is a valid result; interactive agent turns retain empty-response
375
+ * retries by default. Ignored by non-Google providers.
376
+ */
377
+ acceptEmptyResponse?: boolean;
371
378
  /**
372
379
  * Optional `fetch` implementation override. Providers route every HTTP
373
380
  * request — direct calls, SDK clients, and retry helpers — through this
@@ -1,4 +1,15 @@
1
1
  import type { UsageProvider, UsageReport } from "../usage.js";
2
+ /**
3
+ * Cursor's `/api/usage-summary` has shipped two personal-bucket shapes:
4
+ * - Enterprise/team dashboards historically exposed `individualUsage.overall`
5
+ * - Current Pro / Pro+ / Ultra dashboards expose `individualUsage.plan`
6
+ * (plus optional `onDemand`)
7
+ *
8
+ * Prefer a *usable* overall bucket; if overall is absent/disabled/malformed,
9
+ * fall through to plan rails (`autoPercentUsed` / `apiPercentUsed`). Always
10
+ * consider on-demand afterward so a valid on-demand meter is not dropped when
11
+ * the included plan bucket is empty.
12
+ */
2
13
  export declare function parseCursorIndividualUsage(payload: unknown, fetchedAt?: number): UsageReport | null;
3
14
  export declare function parseCursorUsage(payload: unknown, fetchedAt?: number): UsageReport | null;
4
15
  export declare const cursorUsageProvider: UsageProvider;
@@ -45,6 +45,18 @@ export declare const kCursorExecResolved: unique symbol;
45
45
  export type CursorExecResolvedCarrier = object & {
46
46
  [kCursorExecResolved]?: true;
47
47
  };
48
+ /** True when a toolCall block was already executed by Cursor's exec channel. */
49
+ export declare function isCursorExecResolved(block: CursorExecResolvedCarrier | null | undefined): boolean;
50
+ /**
51
+ * Copy {@link kCursorExecResolved} onto a cloned/projected toolCall block.
52
+ *
53
+ * Stream projectors (owned/in-band dialect, leaked-thinking heal) rebuild
54
+ * toolCall objects field-by-field. Dropping this marker lets `agent-loop.ts`
55
+ * re-execute a call Cursor already settled — duplicate toolResults and a
56
+ * second bash/write/delete. Partial-JSON is already copied explicitly; this
57
+ * marker is the other load-bearing symbol that must survive the same way.
58
+ */
59
+ export declare function copyCursorExecResolved(target: CursorExecResolvedCarrier, source: CursorExecResolvedCarrier): void;
48
60
  /**
49
61
  * Marks a text block synthesized by cross-model thinking demotion in
50
62
  * `transformMessages`. Converters that flatten adjacent text blocks into one
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.2.12",
4
+ "version": "17.2.13",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,10 +38,10 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/omptype": "17.2.12",
42
- "@oh-my-pi/pi-catalog": "17.2.12",
43
- "@oh-my-pi/pi-utils": "17.2.12",
44
- "@oh-my-pi/pi-wire": "17.2.12"
41
+ "@oh-my-pi/omptype": "17.2.13",
42
+ "@oh-my-pi/pi-catalog": "17.2.13",
43
+ "@oh-my-pi/pi-utils": "17.2.13",
44
+ "@oh-my-pi/pi-wire": "17.2.13"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@bufbuild/protoc-gen-es": "^2.12.1",
@@ -7,6 +7,7 @@ import type {
7
7
  } from "../types";
8
8
  import {
9
9
  clearStreamingPartialJson,
10
+ copyCursorExecResolved,
10
11
  getStreamingPartialJson,
11
12
  type StreamingPartialJsonCarrier,
12
13
  setStreamingPartialJson,
@@ -54,6 +55,7 @@ function cloneToolCall(source: StreamingToolCall): StreamingToolCall {
54
55
  };
55
56
  const partialJson = getStreamingPartialJson(source);
56
57
  if (partialJson !== undefined) setStreamingPartialJson(block, partialJson);
58
+ copyCursorExecResolved(block, source);
57
59
  return block;
58
60
  }
59
61
 
@@ -65,6 +67,7 @@ function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): voi
65
67
  const partialJson = getStreamingPartialJson(source);
66
68
  if (partialJson === undefined) clearStreamingPartialJson(target);
67
69
  else setStreamingPartialJson(target, partialJson);
70
+ copyCursorExecResolved(target, source);
68
71
  }
69
72
 
70
73
  function hasNamedNativeToolCall(source: StreamingToolCall | undefined): source is StreamingToolCall {
package/src/error/aws.ts CHANGED
@@ -13,7 +13,11 @@ export type AwsCredentialsErrorKind =
13
13
  /** STS web-identity exchange failed or returned malformed credentials. */
14
14
  | "web-identity"
15
15
  /** ECS/container credential endpoint failed or returned malformed credentials. */
16
- | "container";
16
+ | "container"
17
+ /** Shared-config role chain is misconfigured (cycle, missing source_profile, unsupported credential_source). */
18
+ | "profile"
19
+ /** STS `AssumeRole` call failed or returned malformed credentials. */
20
+ | "assume-role";
17
21
 
18
22
  /** A failure resolving AWS credentials for the Bedrock provider. */
19
23
  export class AwsCredentialsError extends Error {
@@ -47,6 +47,19 @@ import { decodeEventStream } from "./aws-eventstream";
47
47
  import { signRequest } from "./aws-sigv4";
48
48
  import { transformMessages } from "./transform-messages";
49
49
 
50
+ /**
51
+ * Headers SigV4 generates for itself. A caller cannot be allowed to supply these:
52
+ * `signRequest` would sign the caller's value but return its own, so the signature
53
+ * would not match what goes on the wire.
54
+ */
55
+ const SIGNER_OWNED_HEADERS = new Set(["host", "x-amz-date", "x-amz-content-sha256", "x-amz-security-token"]);
56
+
57
+ /** Headers the Bedrock request sets itself; a caller copy in any casing duplicates them. */
58
+ // `content-length` included: the fetch layer recomputes it from the serialized
59
+ // body, so a caller value would be signed but not sent, and AWS rejects the
60
+ // mismatch.
61
+ const BEDROCK_RESERVED_HEADERS = new Set(["content-type", "accept", "authorization", "content-length"]);
62
+
50
63
  export type BedrockThinkingDisplay = "summarized" | "omitted";
51
64
 
52
65
  export interface BedrockOptions extends StreamOptions {
@@ -356,7 +369,32 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
356
369
 
357
370
  const bodyText = JSON.stringify(commandInput);
358
371
  const body = new TextEncoder().encode(bodyText);
372
+ // Caller headers are merged BEFORE signing, so SigV4 covers them and they
373
+ // reach the wire. Bedrock built its header map from scratch and ignored
374
+ // `options.headers` entirely, so tracing/attribution headers set by a
375
+ // caller (or by a `before_provider_headers` extension) were silently
376
+ // dropped here while working on every other provider. Content-type and
377
+ // accept stay last: the eventstream framing is not the caller's to change.
378
+ //
379
+ // The signer's OWN headers are dropped first, and that is load-bearing:
380
+ // `signRequest` lets a caller value overwrite `host`/`x-amz-*` in the map
381
+ // it signs, but always RETURNS the generated ones, which `requestHeaders`
382
+ // below then puts on the wire. A caller supplying any of them would sign
383
+ // one set of values and send another, and Bedrock would reject every
384
+ // request with a signature mismatch.
385
+ // Lower-cased, and names the request sets itself are dropped. Keeping a
386
+ // caller `Content-Type` beside the fixed `content-type` leaves TWO object
387
+ // keys: SigV4 signs one value while fetch canonicalizes both into a single
388
+ // comma-joined wire header, so AWS validates different bytes than were
389
+ // signed and rejects the request.
390
+ const callerHeaders: Record<string, string> = {};
391
+ for (const [name, value] of Object.entries(options?.headers ?? {})) {
392
+ const field = name.toLowerCase();
393
+ if (SIGNER_OWNED_HEADERS.has(field) || BEDROCK_RESERVED_HEADERS.has(field)) continue;
394
+ callerHeaders[field] = value;
395
+ }
359
396
  const baseHeaders: Record<string, string> = {
397
+ ...callerHeaders,
360
398
  "content-type": "application/json",
361
399
  accept: "application/vnd.amazon.eventstream",
362
400
  };
@@ -5,8 +5,9 @@
5
5
  * 1. Static credentials from the environment
6
6
  * (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` [+ `AWS_SESSION_TOKEN`]).
7
7
  * 2. Web identity (`AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN`).
8
- * 3. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO):
9
- * - static keys, SSO, or `credential_process`.
8
+ * 3. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO/roles):
9
+ * - static keys, SSO, `credential_process`, or `role_arn` role chaining
10
+ * (`source_profile` recursion, `web_identity_token_file`, `credential_source`).
10
11
  * 4. ECS/container credentials from `AWS_CONTAINER_CREDENTIALS_*`.
11
12
  * 5. EC2 IMDSv2 when metadata is enabled.
12
13
  *
@@ -29,7 +30,7 @@ import {
29
30
  shouldLoadAwsSharedConfig,
30
31
  } from "../utils/aws-profile";
31
32
  import { isLocalOrMetadataHost } from "../utils/proxy";
32
- import type { AwsCredentials } from "./aws-sigv4";
33
+ import { type AwsCredentials, signRequest } from "./aws-sigv4";
33
34
 
34
35
  export interface ResolvedCredentials extends AwsCredentials {
35
36
  /** Absolute expiration timestamp in ms. `undefined` for non-expiring static creds. */
@@ -60,8 +61,8 @@ const SHARED_RESOLVE_TIMEOUT_MS = 30_000;
60
61
 
61
62
  function requireDynamicCredentialExpiration(
62
63
  value: string | undefined,
63
- source: "AWS web identity" | "AWS container credential",
64
- kind: "web-identity" | "container",
64
+ source: string,
65
+ kind: AIError.AwsCredentialsErrorKind,
65
66
  ): number {
66
67
  const expiresAt = value ? Date.parse(value) : Number.NaN;
67
68
  if (Number.isFinite(expiresAt)) return expiresAt;
@@ -179,7 +180,16 @@ async function readIniFile(p: string): Promise<AwsIniFile | undefined> {
179
180
  }
180
181
  }
181
182
 
182
- // ---------- Profile / SSO ----------
183
+ // ---------- Profile / SSO / role chaining ----------
184
+
185
+ /** Shared-config view and resolution context threaded through role-chain recursion. */
186
+ interface ProfileResolveContext {
187
+ credentialsIni: AwsIniFile | undefined;
188
+ configIni: AwsIniFile | undefined;
189
+ region: string;
190
+ signal: AbortSignal | undefined;
191
+ fetchImpl: FetchImpl;
192
+ }
183
193
 
184
194
  async function readProfileCredentials(
185
195
  profile: string,
@@ -195,11 +205,36 @@ async function readProfileCredentials(
195
205
  const credentialsIni = await readIniFile(credentialsPath);
196
206
  const configIni = loadSharedConfig ? await readIniFile(configPath) : undefined;
197
207
 
198
- // Static credentials live in ~/.aws/credentials; SSO config lives in
208
+ return resolveProfileChain(profile, { credentialsIni, configIni, region, signal, fetchImpl }, new Set());
209
+ }
210
+
211
+ /**
212
+ * Resolve one profile, following `role_arn` chains. A `role_arn` profile derives
213
+ * base credentials from `source_profile` (recursive), `web_identity_token_file`,
214
+ * or `credential_source`, then exchanges them via STS. Non-role profiles resolve
215
+ * directly from static keys, SSO, or `credential_process`. `seen` guards against
216
+ * `source_profile` cycles.
217
+ */
218
+ async function resolveProfileChain(
219
+ profile: string,
220
+ ctx: ProfileResolveContext,
221
+ seen: Set<string>,
222
+ ): Promise<ResolvedCredentials | undefined> {
223
+ if (seen.has(profile)) {
224
+ throw new AIError.AwsCredentialsError(`AWS profile role chain contains a cycle at '${profile}'.`, "profile");
225
+ }
226
+ seen.add(profile);
227
+
228
+ // Static credentials live in ~/.aws/credentials; SSO/role config lives in
199
229
  // ~/.aws/config under `[profile foo]`. Merge into a single view.
200
- const merged: Record<string, string> = { ...(configIni?.[profile] ?? {}), ...(credentialsIni?.[profile] ?? {}) };
230
+ const merged: Record<string, string> = {
231
+ ...(ctx.configIni?.[profile] ?? {}),
232
+ ...(ctx.credentialsIni?.[profile] ?? {}),
233
+ };
201
234
  if (Object.keys(merged).length === 0) return undefined;
202
235
 
236
+ if (merged.role_arn) return assumeRoleFromProfile(profile, merged, ctx, seen);
237
+
203
238
  if (merged.aws_access_key_id && merged.aws_secret_access_key) {
204
239
  const out: ResolvedCredentials = {
205
240
  accessKeyId: merged.aws_access_key_id,
@@ -215,16 +250,158 @@ async function readProfileCredentials(
215
250
  }
216
251
 
217
252
  if (merged.sso_account_id && merged.sso_role_name) {
218
- return readSsoCredentials(merged, configIni, region, signal, fetchImpl);
253
+ return readSsoCredentials(merged, ctx.configIni, ctx.region, ctx.signal, ctx.fetchImpl);
219
254
  }
220
255
 
221
256
  if (merged.credential_process) {
222
- return readCredentialProcess(profile, merged.credential_process, signal);
257
+ return readCredentialProcess(profile, merged.credential_process, ctx.signal);
223
258
  }
224
259
 
225
260
  return undefined;
226
261
  }
227
262
 
263
+ /**
264
+ * Resolve base credentials for a `role_arn` profile and exchange them for the
265
+ * target role. `web_identity_token_file` is a self-contained
266
+ * AssumeRoleWithWebIdentity; otherwise the base comes from `source_profile`
267
+ * (recursive) or `credential_source`, followed by an STS `AssumeRole`.
268
+ */
269
+ async function assumeRoleFromProfile(
270
+ profile: string,
271
+ merged: Record<string, string>,
272
+ ctx: ProfileResolveContext,
273
+ seen: Set<string>,
274
+ ): Promise<ResolvedCredentials> {
275
+ const roleArn = merged.role_arn;
276
+ const region = ctx.region;
277
+
278
+ if (merged.web_identity_token_file) {
279
+ return assumeRoleWithWebIdentity(
280
+ { roleArn, tokenFile: merged.web_identity_token_file, sessionName: merged.role_session_name },
281
+ region,
282
+ ctx.signal,
283
+ ctx.fetchImpl,
284
+ );
285
+ }
286
+
287
+ if (merged.mfa_serial) {
288
+ // MFA-gated roles need an interactive token code, which a non-interactive
289
+ // resolver cannot supply. Fail with a clear message instead of a confusing
290
+ // STS AccessDenied.
291
+ throw new AIError.AwsCredentialsError(
292
+ `AWS profile '${profile}' requires MFA (mfa_serial), which is not supported for non-interactive credential resolution.`,
293
+ "profile",
294
+ );
295
+ }
296
+
297
+ let base: ResolvedCredentials | undefined;
298
+ if (merged.source_profile) {
299
+ base = await resolveProfileChain(merged.source_profile, ctx, seen);
300
+ if (!base) {
301
+ throw new AIError.AwsCredentialsError(
302
+ `AWS profile '${profile}' references source_profile '${merged.source_profile}', which has no usable credentials.`,
303
+ "profile",
304
+ );
305
+ }
306
+ } else if (merged.credential_source) {
307
+ base = await resolveCredentialSource(merged.credential_source, region, ctx.signal, ctx.fetchImpl);
308
+ if (!base) {
309
+ throw new AIError.AwsCredentialsError(
310
+ `AWS profile '${profile}' credential_source '${merged.credential_source}' produced no credentials.`,
311
+ "profile",
312
+ );
313
+ }
314
+ } else {
315
+ throw new AIError.AwsCredentialsError(
316
+ `AWS profile '${profile}' sets role_arn without source_profile, credential_source, or web_identity_token_file.`,
317
+ "profile",
318
+ );
319
+ }
320
+
321
+ return stsAssumeRole(
322
+ base,
323
+ roleArn,
324
+ region,
325
+ {
326
+ sessionName: merged.role_session_name,
327
+ durationSeconds: merged.duration_seconds,
328
+ externalId: merged.external_id,
329
+ },
330
+ ctx.signal,
331
+ ctx.fetchImpl,
332
+ );
333
+ }
334
+
335
+ /** Resolve the base credentials named by a profile `credential_source` directive. */
336
+ async function resolveCredentialSource(
337
+ source: string,
338
+ _region: string,
339
+ signal: AbortSignal | undefined,
340
+ fetchImpl: FetchImpl,
341
+ ): Promise<ResolvedCredentials | undefined> {
342
+ switch (source) {
343
+ case "Environment":
344
+ return readEnvCredentials();
345
+ case "Ec2InstanceMetadata":
346
+ return $env.AWS_EC2_METADATA_DISABLED?.toLowerCase() === "true"
347
+ ? undefined
348
+ : readImdsCredentials(signal, fetchImpl);
349
+ case "EcsContainer":
350
+ return readContainerCredentials(signal, fetchImpl);
351
+ default:
352
+ throw new AIError.AwsCredentialsError(`Unsupported AWS credential_source '${source}'.`, "profile");
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Exchange base credentials for a target role via STS `AssumeRole`. The request
358
+ * is SigV4-signed with the base credentials.
359
+ */
360
+ async function stsAssumeRole(
361
+ base: ResolvedCredentials,
362
+ roleArn: string,
363
+ region: string,
364
+ opts: { sessionName?: string; durationSeconds?: string; externalId?: string },
365
+ signal: AbortSignal | undefined,
366
+ fetchImpl: FetchImpl,
367
+ ): Promise<ResolvedCredentials> {
368
+ const body = new URLSearchParams({
369
+ Action: "AssumeRole",
370
+ Version: "2011-06-15",
371
+ RoleArn: roleArn,
372
+ RoleSessionName: opts.sessionName || `omp-${process.pid}`,
373
+ });
374
+ if (opts.durationSeconds) body.set("DurationSeconds", opts.durationSeconds);
375
+ if (opts.externalId) body.set("ExternalId", opts.externalId);
376
+ const payload = new TextEncoder().encode(body.toString());
377
+ const endpoint = new URL(stsEndpoint(region));
378
+ const contentType = "application/x-www-form-urlencoded";
379
+ const signed = await signRequest({
380
+ method: "POST",
381
+ host: endpoint.host,
382
+ path: endpoint.pathname,
383
+ body: payload,
384
+ region,
385
+ service: "sts",
386
+ credentials: base,
387
+ headers: { "content-type": contentType },
388
+ });
389
+ const response = await fetchImpl(endpoint, {
390
+ method: "POST",
391
+ headers: { ...signed, "content-type": contentType },
392
+ body: payload,
393
+ signal,
394
+ });
395
+ const xml = await response.text();
396
+ if (!response.ok) {
397
+ throw new AIError.AwsCredentialsError(
398
+ `AWS AssumeRole failed: ${response.status} ${xmlTag(xml, "Message") ?? xml.slice(0, 200)}`,
399
+ "assume-role",
400
+ );
401
+ }
402
+ return parseStsCredentials(xml, "AWS AssumeRole", "assume-role");
403
+ }
404
+
228
405
  interface SsoCachedToken {
229
406
  accessToken?: string;
230
407
  expiresAt?: string;
@@ -543,6 +720,18 @@ function stsEndpoint(region: string): string {
543
720
  return `https://sts.${region}.${dnsSuffix}/`;
544
721
  }
545
722
 
723
+ /** Parse `<Credentials>` from an STS AssumeRole/WithWebIdentity XML response. */
724
+ function parseStsCredentials(xml: string, source: string, kind: AIError.AwsCredentialsErrorKind): ResolvedCredentials {
725
+ const accessKeyId = xmlTag(xml, "AccessKeyId");
726
+ const secretAccessKey = xmlTag(xml, "SecretAccessKey");
727
+ const sessionToken = xmlTag(xml, "SessionToken");
728
+ if (!accessKeyId || !secretAccessKey || !sessionToken) {
729
+ throw new AIError.AwsCredentialsError(`${source} response is missing credentials.`, kind);
730
+ }
731
+ const expiresAt = requireDynamicCredentialExpiration(xmlTag(xml, "Expiration"), source, kind);
732
+ return { accessKeyId, secretAccessKey, sessionToken, expiresAt };
733
+ }
734
+
546
735
  async function readWebIdentityCredentials(
547
736
  region: string,
548
737
  signal: AbortSignal | undefined,
@@ -551,9 +740,28 @@ async function readWebIdentityCredentials(
551
740
  const tokenFile = $env.AWS_WEB_IDENTITY_TOKEN_FILE;
552
741
  const roleArn = $env.AWS_ROLE_ARN;
553
742
  if (!tokenFile || !roleArn) return undefined;
743
+ return assumeRoleWithWebIdentity(
744
+ { roleArn, tokenFile, sessionName: $env.AWS_ROLE_SESSION_NAME },
745
+ region,
746
+ signal,
747
+ fetchImpl,
748
+ );
749
+ }
750
+
751
+ /**
752
+ * Exchange a web-identity token file for role credentials via STS
753
+ * `AssumeRoleWithWebIdentity`. Used by the env chain (`AWS_WEB_IDENTITY_TOKEN_FILE`)
754
+ * and by `role_arn` + `web_identity_token_file` profiles.
755
+ */
756
+ async function assumeRoleWithWebIdentity(
757
+ params: { roleArn: string; tokenFile: string; sessionName?: string },
758
+ region: string,
759
+ signal: AbortSignal | undefined,
760
+ fetchImpl: FetchImpl,
761
+ ): Promise<ResolvedCredentials> {
554
762
  let token: string;
555
763
  try {
556
- token = (await Bun.file(tokenFile).text()).trim();
764
+ token = (await Bun.file(params.tokenFile).text()).trim();
557
765
  } catch (err) {
558
766
  throw new AIError.AwsCredentialsError(
559
767
  `Unable to read AWS web identity token file: ${String(err)}`,
@@ -569,8 +777,8 @@ async function readWebIdentityCredentials(
569
777
  const body = new URLSearchParams({
570
778
  Action: "AssumeRoleWithWebIdentity",
571
779
  Version: "2011-06-15",
572
- RoleArn: roleArn,
573
- RoleSessionName: $env.AWS_ROLE_SESSION_NAME || `omp-${process.pid}`,
780
+ RoleArn: params.roleArn,
781
+ RoleSessionName: params.sessionName || `omp-${process.pid}`,
574
782
  WebIdentityToken: token,
575
783
  });
576
784
  const response = await fetchImpl(stsEndpoint(region), {
@@ -586,22 +794,7 @@ async function readWebIdentityCredentials(
586
794
  "web-identity",
587
795
  );
588
796
  }
589
- const accessKeyId = xmlTag(xml, "AccessKeyId");
590
- const secretAccessKey = xmlTag(xml, "SecretAccessKey");
591
- const sessionToken = xmlTag(xml, "SessionToken");
592
- if (!accessKeyId || !secretAccessKey || !sessionToken) {
593
- throw new AIError.AwsCredentialsError(
594
- "AWS AssumeRoleWithWebIdentity response is missing credentials.",
595
- "web-identity",
596
- );
597
- }
598
- const expiresAt = requireDynamicCredentialExpiration(xmlTag(xml, "Expiration"), "AWS web identity", "web-identity");
599
- return {
600
- accessKeyId,
601
- secretAccessKey,
602
- sessionToken,
603
- expiresAt,
604
- };
797
+ return parseStsCredentials(xml, "AWS web identity", "web-identity");
605
798
  }
606
799
 
607
800
  // ---------- ECS/container credentials ----------
@@ -74,6 +74,7 @@ import type { ToolResultMessage } from "../../types";
74
74
  * and their translation are consumed together.
75
75
  */
76
76
  export {
77
+ omitUndefinedArgs,
77
78
  piEscapeRegexLiteral,
78
79
  piGrepSkip,
79
80
  piJoinPath,
@@ -163,3 +163,25 @@ export function piLimit(limit: number | undefined): number | undefined {
163
163
  export function piTimeout(timeout: number | undefined): number | undefined {
164
164
  return timeout !== undefined && timeout >= 0 ? timeout : undefined;
165
165
  }
166
+
167
+ /**
168
+ * Drop keys whose value is `undefined` so optional local-tool kwargs stay
169
+ * absent rather than present-as-undefined.
170
+ *
171
+ * The Cursor exec bridge historically wrote forms like
172
+ * `cwd: workingDirectory || undefined` and
173
+ * `case: caseInsensitive === true ? false : undefined`. ArkType rejects a
174
+ * present `undefined` on an optional field (`was undefined`) even though
175
+ * omitting the key is valid — which flooded Cursor sessions with bash/grep
176
+ * validation errors for otherwise fine frames.
177
+ */
178
+ export function omitUndefinedArgs<T extends Record<string, unknown>>(
179
+ args: T,
180
+ ): { [K in keyof T]?: Exclude<T[K], undefined> } {
181
+ const out: Record<string, unknown> = {};
182
+ for (const key of Object.keys(args)) {
183
+ const value = args[key];
184
+ if (value !== undefined) out[key] = value;
185
+ }
186
+ return out as { [K in keyof T]?: Exclude<T[K], undefined> };
187
+ }