@oh-my-pi/pi-ai 17.2.11 → 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 (79) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/types/auth-retry.d.ts +3 -3
  3. package/dist/types/auth-storage.d.ts +2 -0
  4. package/dist/types/error/auth-classify.d.ts +5 -5
  5. package/dist/types/error/aws.d.ts +5 -1
  6. package/dist/types/error/flags.d.ts +4 -0
  7. package/dist/types/providers/aws-credentials.d.ts +4 -3
  8. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  9. package/dist/types/providers/cursor-pi-args.d.ts +14 -0
  10. package/dist/types/providers/openai-shared.d.ts +9 -1
  11. package/dist/types/registry/api-key-login.d.ts +6 -4
  12. package/dist/types/registry/cloudflare-ai-gateway.d.ts +2 -2
  13. package/dist/types/registry/kagi.d.ts +2 -2
  14. package/dist/types/registry/litellm.d.ts +2 -2
  15. package/dist/types/registry/llama-cpp.d.ts +2 -2
  16. package/dist/types/registry/lm-studio.d.ts +2 -2
  17. package/dist/types/registry/parallel.d.ts +2 -2
  18. package/dist/types/registry/tavily.d.ts +1 -1
  19. package/dist/types/registry/vercel-ai-gateway.d.ts +2 -2
  20. package/dist/types/registry/vllm.d.ts +2 -2
  21. package/dist/types/types.d.ts +7 -0
  22. package/dist/types/usage/cursor.d.ts +11 -0
  23. package/dist/types/usage/openai-codex-reset.d.ts +0 -20
  24. package/dist/types/usage/shared.d.ts +13 -1
  25. package/dist/types/utils/block-symbols.d.ts +12 -0
  26. package/package.json +5 -5
  27. package/src/auth-retry.ts +5 -4
  28. package/src/auth-storage.ts +77 -33
  29. package/src/dialect/owned-stream.ts +3 -0
  30. package/src/error/auth-classify.ts +7 -6
  31. package/src/error/aws.ts +5 -1
  32. package/src/error/flags.ts +14 -0
  33. package/src/providers/amazon-bedrock.ts +38 -0
  34. package/src/providers/aws-credentials.ts +222 -29
  35. package/src/providers/cursor/exec-modern.ts +1 -0
  36. package/src/providers/cursor-pi-args.ts +22 -0
  37. package/src/providers/cursor.ts +81 -1
  38. package/src/providers/google-gemini-cli.ts +49 -15
  39. package/src/providers/google-shared.ts +7 -1
  40. package/src/providers/openai-codex/request-transformer.ts +38 -17
  41. package/src/providers/openai-codex-responses.ts +2 -3
  42. package/src/providers/openai-responses.ts +4 -0
  43. package/src/providers/openai-shared.ts +55 -1
  44. package/src/providers/pi-native-server.ts +1 -0
  45. package/src/providers/register-builtins.ts +18 -14
  46. package/src/registry/api-key-login.ts +26 -12
  47. package/src/registry/aws.ts +13 -6
  48. package/src/registry/cloudflare-ai-gateway.ts +10 -29
  49. package/src/registry/kagi.ts +11 -29
  50. package/src/registry/litellm.ts +11 -29
  51. package/src/registry/llama-cpp.ts +11 -21
  52. package/src/registry/lm-studio.ts +9 -20
  53. package/src/registry/oauth/callback-server.ts +93 -5
  54. package/src/registry/parallel.ts +10 -28
  55. package/src/registry/tavily.ts +9 -27
  56. package/src/registry/vercel-ai-gateway.ts +10 -28
  57. package/src/registry/vllm.ts +11 -21
  58. package/src/stream.ts +10 -7
  59. package/src/types.ts +7 -0
  60. package/src/usage/alibaba-token-plan.ts +8 -15
  61. package/src/usage/claude.ts +12 -19
  62. package/src/usage/cursor.ts +179 -59
  63. package/src/usage/gemini.ts +3 -2
  64. package/src/usage/github-copilot.ts +3 -2
  65. package/src/usage/google-antigravity.ts +10 -17
  66. package/src/usage/kimi.ts +35 -17
  67. package/src/usage/minimax-code.ts +7 -27
  68. package/src/usage/openai-codex-reset.ts +3 -2
  69. package/src/usage/openai-codex.ts +5 -5
  70. package/src/usage/opencode-go.ts +1 -2
  71. package/src/usage/shared.ts +33 -10
  72. package/src/usage/synthetic.ts +4 -11
  73. package/src/usage/umans.ts +2 -2
  74. package/src/usage/xai-oauth.ts +13 -26
  75. package/src/usage/zai.ts +4 -6
  76. package/src/utils/aws-profile.ts +39 -1
  77. package/src/utils/block-symbols.ts +18 -0
  78. package/src/utils/leaked-thinking-stream.ts +3 -0
  79. package/src/utils/openrouter-headers.ts +3 -3
@@ -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 {
@@ -1,5 +1,5 @@
1
1
  import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
- import { isOAuthExpiry, isUsageLimit } from "./flags";
2
+ import { isAccountPolicyError, isOAuthExpiry, isUsageLimit } from "./flags";
3
3
  import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./rate-limit";
4
4
 
5
5
  /**
@@ -27,15 +27,16 @@ export function isInvalidatedOAuthTokenError(error: unknown): boolean {
27
27
  /**
28
28
  * Whether an upstream failure should rotate to a sibling credential: a hard
29
29
  * `401`, a `403` (token valid but access denied — plan, model policy, or org
30
- * restriction a sibling account may not share), a body-classified usage limit
31
- * (Codex `usage_limit_reached`, Anthropic account rate-limit, Google
30
+ * restriction a sibling account may not share), an account-scoped policy
31
+ * denial such as Codex `cyber_policy`, a body-classified usage limit (Codex
32
+ * `usage_limit_reached`, Anthropic account rate-limit, Google
32
33
  * `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
33
- * whose payload did not preserve a richer quota code.
34
- * Transient 429s (`Too many requests`, per-minute caps) stay in the
35
- * upstream-backoff lane.
34
+ * whose payload did not preserve a richer quota code. Transient 429s
35
+ * (`Too many requests`, per-minute caps) stay in the upstream-backoff lane.
36
36
  */
37
37
  export function isAuthRetryableError(error: unknown): boolean {
38
38
  if (isUsageLimit(error)) return true;
39
+ if (isAccountPolicyError(error)) return true;
39
40
  if (isInvalidatedOAuthTokenError(error)) return true;
40
41
  const httpStatus = extractHttpStatusFromError(error);
41
42
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
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 {
@@ -25,6 +25,8 @@ export const Flag = {
25
25
  MalformedFunctionCall: 0x0020_0000,
26
26
  ProviderFinishError: 0x0040_0000,
27
27
  ContentBlocked: 0x0000_8000,
28
+ /** Account-scoped provider policy denial that may succeed with another credential. */
29
+ AccountPolicy: 0x0000_4000,
28
30
  ContextOverflow: 0x0080_0000,
29
31
  AuthFailed: 0x0100_0000,
30
32
  SilentAbort: 0x0200_0000,
@@ -49,6 +51,7 @@ const KIND_MASK =
49
51
  Flag.MalformedFunctionCall |
50
52
  Flag.ProviderFinishError |
51
53
  Flag.ContentBlocked |
54
+ Flag.AccountPolicy |
52
55
  Flag.ContextOverflow |
53
56
  Flag.AuthFailed |
54
57
  Flag.SilentAbort |
@@ -102,6 +105,7 @@ const AUTH_FAILURE_PATTERN =
102
105
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
103
106
  const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
104
107
  const CONTENT_FILTER_PATTERN = /\b(?:incomplete:\s*)?content_filter\b/i;
108
+ const ACCOUNT_POLICY_PATTERN = /\bcyber_policy\b|trusted access for cyber/i;
105
109
  const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
106
110
  const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i;
107
111
  /**
@@ -194,6 +198,7 @@ const ERROR_KIND_LABELS: readonly [Flag, string][] = [
194
198
  [Flag.MalformedFunctionCall, "malformed-function-call"],
195
199
  [Flag.ProviderFinishError, "provider-finish-error"],
196
200
  [Flag.ContentBlocked, "content-blocked"],
201
+ [Flag.AccountPolicy, "account-policy"],
197
202
  [Flag.ContextOverflow, "context-overflow"],
198
203
  [Flag.AuthFailed, "auth-failed"],
199
204
  [Flag.SilentAbort, "silent-abort"],
@@ -336,6 +341,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
336
341
  if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall;
337
342
  if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
338
343
  if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
344
+ if (ACCOUNT_POLICY_PATTERN.test(errorMessage)) kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
339
345
  if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
340
346
 
341
347
  const statusClean = errorStatus ? errorStatus : (status({ message: errorMessage }) ?? undefined);
@@ -398,6 +404,9 @@ export function classify(error: unknown, api?: Api): number {
398
404
  if ("errorId" in link && typeof (link as { errorId: unknown }).errorId === "number") {
399
405
  kinds |= (link as { errorId: number }).errorId & KIND_MASK;
400
406
  }
407
+ if ("code" in link && typeof link.code === "string" && ACCOUNT_POLICY_PATTERN.test(link.code)) {
408
+ kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
409
+ }
401
410
  }
402
411
 
403
412
  if (link instanceof AwsCredentialsError) {
@@ -475,6 +484,11 @@ export function isUsageLimit(error: unknown, api?: Api): boolean {
475
484
  return is(classify(error, api), Flag.UsageLimit);
476
485
  }
477
486
 
487
+ /** Whether an upstream rejection is an account-scoped policy denial worth retrying with a sibling credential. */
488
+ export function isAccountPolicyError(error: unknown, api?: Api): boolean {
489
+ return is(classify(error, api), Flag.AccountPolicy);
490
+ }
491
+
478
492
  /**
479
493
  * Strict-tool rejection: grammar too large, schema too complex, or structured
480
494
  * outputs unsupported by the model/endpoint.
@@ -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
+ }
@@ -210,6 +210,7 @@ import {
210
210
  buildPiWriteError,
211
211
  buildPiWriteRejected,
212
212
  buildPiWriteResult,
213
+ omitUndefinedArgs,
213
214
  piEscapeRegexLiteral,
214
215
  piGrepSkip,
215
216
  piJoinPath,
@@ -223,6 +224,67 @@ import {
223
224
  export const CURSOR_API_URL = "https://api2.cursor.sh";
224
225
  export const CURSOR_CLIENT_VERSION = "cli-2026.07.23-e383d2b";
225
226
 
227
+ /**
228
+ * HTTP/1 connection-specific headers that HTTP/2 forbids. Node's `http2.request()`
229
+ * throws `ERR_HTTP2_INVALID_CONNECTION_HEADERS` on these rather than dropping
230
+ * them, so a caller sending one would kill the request outright.
231
+ */
232
+ const HTTP2_FORBIDDEN_HEADERS = new Set([
233
+ "connection",
234
+ "keep-alive",
235
+ "proxy-connection",
236
+ "transfer-encoding",
237
+ "upgrade",
238
+ "http2-settings",
239
+ ]);
240
+
241
+ /**
242
+ * Header names the Cursor request sets for itself. A caller copy in ANY casing
243
+ * has to go: the spread below adds the fixed lower-case name regardless, and two
244
+ * spellings of one field are a duplicate rather than an override.
245
+ */
246
+ const CURSOR_RESERVED_HEADERS = new Set([
247
+ "content-type",
248
+ "connect-protocol-version",
249
+ "te",
250
+ "authorization",
251
+ "x-ghost-mode",
252
+ "x-cursor-client-version",
253
+ "x-cursor-client-type",
254
+ "x-request-id",
255
+ // Transport-owned even though this request never sets it: node's http2 client
256
+ // suppresses the `:authority` it derives from the URL when a plain `host`
257
+ // header is present, so a caller value here silently retargets the request at
258
+ // a different virtual host.
259
+ "host",
260
+ // The Connect body is streamed after the headers (initial frame, heartbeats,
261
+ // tool responses), so no caller-supplied length can describe it and an HTTP/2
262
+ // peer resets the stream once the body diverges.
263
+ "content-length",
264
+ ]);
265
+
266
+ /**
267
+ * Reduce caller-supplied headers to what this HTTP/2 request can legally carry.
268
+ *
269
+ * Everything is lower-cased, because HTTP/2 field names are lower-case and node
270
+ * compares them that way. A caller `Authorization` next to the fixed
271
+ * `authorization` does not lose to it, it DUPLICATES it, and node throws
272
+ * `ERR_HTTP2_HEADER_SINGLE_VALUE` before the request goes out. Same for a `TE`
273
+ * that is not `trailers`. Node throws on all three classes here rather than
274
+ * ignoring them, so a miss turns a harmless header into a dead request.
275
+ */
276
+ function sanitizeCursorCallerHeaders(headers: Record<string, string> | undefined): Record<string, string> {
277
+ const sanitized: Record<string, string> = {};
278
+ for (const [name, value] of Object.entries(headers ?? {})) {
279
+ const field = name.toLowerCase();
280
+ if (field.startsWith(":")) continue;
281
+ if (HTTP2_FORBIDDEN_HEADERS.has(field)) continue;
282
+ if (CURSOR_RESERVED_HEADERS.has(field)) continue;
283
+ sanitized[field] = value;
284
+ }
285
+ return sanitized;
286
+ }
287
+
226
288
  const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000;
227
289
 
228
290
  /**
@@ -545,7 +607,22 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
545
607
 
546
608
  const baseUrl = model.baseUrl || CURSOR_API_URL;
547
609
  const requestPath = "/agent.v1.AgentService/Run";
610
+ // Caller headers are additive, and are spread FIRST so the protocol
611
+ // framing, auth, and request id below always win. Cursor built this map
612
+ // from scratch and never read `options.headers`, so tracing/attribution
613
+ // headers set by a caller (or a `before_provider_headers` extension) were
614
+ // silently dropped here while working on other providers.
615
+ //
616
+ // Two classes are stripped because node's http2 client THROWS on them
617
+ // rather than ignoring them, which would turn a harmless header into a
618
+ // dead request: pseudo-headers, which belong to the transport, and the
619
+ // HTTP/1 connection-specific headers HTTP/2 forbids outright
620
+ // (ERR_HTTP2_INVALID_CONNECTION_HEADERS). `te` needs no filtering here —
621
+ // HTTP/2 allows it only as `trailers`, which is exactly what the fixed
622
+ // set below re-applies over anything a caller sent.
623
+ const callerHeaders = sanitizeCursorCallerHeaders(options?.headers);
548
624
  const requestHeaders = {
625
+ ...callerHeaders,
549
626
  ":method": "POST",
550
627
  ":path": requestPath,
551
628
  "content-type": "application/connect+proto",
@@ -3592,11 +3669,14 @@ export function synthesizeCursorExecToolCall(
3592
3669
  ): void {
3593
3670
  endCurrentTextBlock(output, stream, state);
3594
3671
  endCurrentThinkingBlock(output, stream, state);
3672
+ // Exec-frame translators often write `optional: value || undefined`. A
3673
+ // present `undefined` fails ArkType optional-field validation; drop those
3674
+ // keys so the transcript block matches what a model-native call would omit.
3595
3675
  const block: ToolCallState = {
3596
3676
  type: "toolCall",
3597
3677
  id: toolCallId,
3598
3678
  name: toolName,
3599
- arguments: args,
3679
+ arguments: omitUndefinedArgs(args),
3600
3680
  [kStreamingBlockIndex]: output.content.length,
3601
3681
  [kStreamingBlockKind]: "cursor-exec",
3602
3682
  [kCursorExecResolved]: true,