@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.
- package/CHANGELOG.md +25 -0
- package/dist/types/auth-retry.d.ts +3 -3
- package/dist/types/auth-storage.d.ts +2 -0
- package/dist/types/error/auth-classify.d.ts +5 -5
- package/dist/types/error/aws.d.ts +5 -1
- package/dist/types/error/flags.d.ts +4 -0
- package/dist/types/providers/aws-credentials.d.ts +4 -3
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +14 -0
- package/dist/types/providers/openai-shared.d.ts +9 -1
- package/dist/types/registry/api-key-login.d.ts +6 -4
- package/dist/types/registry/cloudflare-ai-gateway.d.ts +2 -2
- package/dist/types/registry/kagi.d.ts +2 -2
- package/dist/types/registry/litellm.d.ts +2 -2
- package/dist/types/registry/llama-cpp.d.ts +2 -2
- package/dist/types/registry/lm-studio.d.ts +2 -2
- package/dist/types/registry/parallel.d.ts +2 -2
- package/dist/types/registry/tavily.d.ts +1 -1
- package/dist/types/registry/vercel-ai-gateway.d.ts +2 -2
- package/dist/types/registry/vllm.d.ts +2 -2
- package/dist/types/types.d.ts +7 -0
- package/dist/types/usage/cursor.d.ts +11 -0
- package/dist/types/usage/openai-codex-reset.d.ts +0 -20
- package/dist/types/usage/shared.d.ts +13 -1
- package/dist/types/utils/block-symbols.d.ts +12 -0
- package/package.json +5 -5
- package/src/auth-retry.ts +5 -4
- package/src/auth-storage.ts +77 -33
- package/src/dialect/owned-stream.ts +3 -0
- package/src/error/auth-classify.ts +7 -6
- package/src/error/aws.ts +5 -1
- package/src/error/flags.ts +14 -0
- package/src/providers/amazon-bedrock.ts +38 -0
- package/src/providers/aws-credentials.ts +222 -29
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor-pi-args.ts +22 -0
- package/src/providers/cursor.ts +81 -1
- package/src/providers/google-gemini-cli.ts +49 -15
- package/src/providers/google-shared.ts +7 -1
- package/src/providers/openai-codex/request-transformer.ts +38 -17
- package/src/providers/openai-codex-responses.ts +2 -3
- package/src/providers/openai-responses.ts +4 -0
- package/src/providers/openai-shared.ts +55 -1
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/register-builtins.ts +18 -14
- package/src/registry/api-key-login.ts +26 -12
- package/src/registry/aws.ts +13 -6
- package/src/registry/cloudflare-ai-gateway.ts +10 -29
- package/src/registry/kagi.ts +11 -29
- package/src/registry/litellm.ts +11 -29
- package/src/registry/llama-cpp.ts +11 -21
- package/src/registry/lm-studio.ts +9 -20
- package/src/registry/oauth/callback-server.ts +93 -5
- package/src/registry/parallel.ts +10 -28
- package/src/registry/tavily.ts +9 -27
- package/src/registry/vercel-ai-gateway.ts +10 -28
- package/src/registry/vllm.ts +11 -21
- package/src/stream.ts +10 -7
- package/src/types.ts +7 -0
- package/src/usage/alibaba-token-plan.ts +8 -15
- package/src/usage/claude.ts +12 -19
- package/src/usage/cursor.ts +179 -59
- package/src/usage/gemini.ts +3 -2
- package/src/usage/github-copilot.ts +3 -2
- package/src/usage/google-antigravity.ts +10 -17
- package/src/usage/kimi.ts +35 -17
- package/src/usage/minimax-code.ts +7 -27
- package/src/usage/openai-codex-reset.ts +3 -2
- package/src/usage/openai-codex.ts +5 -5
- package/src/usage/opencode-go.ts +1 -2
- package/src/usage/shared.ts +33 -10
- package/src/usage/synthetic.ts +4 -11
- package/src/usage/umans.ts +2 -2
- package/src/usage/xai-oauth.ts +13 -26
- package/src/usage/zai.ts +4 -6
- package/src/utils/aws-profile.ts +39 -1
- package/src/utils/block-symbols.ts +18 -0
- package/src/utils/leaked-thinking-stream.ts +3 -0
- 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),
|
|
31
|
-
*
|
|
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
|
-
*
|
|
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 {
|
package/src/error/flags.ts
CHANGED
|
@@ -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 `
|
|
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
|
|
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:
|
|
64
|
-
kind:
|
|
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
|
-
|
|
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> = {
|
|
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:
|
|
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
|
-
|
|
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 ----------
|
|
@@ -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
|
+
}
|
package/src/providers/cursor.ts
CHANGED
|
@@ -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,
|