@ai-sdk/harness 1.0.107 → 1.0.109

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness",
3
- "version": "1.0.107",
3
+ "version": "1.0.109",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -44,9 +44,9 @@
44
44
  }
45
45
  },
46
46
  "dependencies": {
47
- "@ai-sdk/provider": "4.0.13",
48
- "@ai-sdk/provider-utils": "5.0.39",
49
- "ai": "7.0.97"
47
+ "@ai-sdk/provider": "4.0.14",
48
+ "@ai-sdk/provider-utils": "5.0.40",
49
+ "ai": "7.0.99"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "ws": "^8.21.0",
@@ -58,7 +58,7 @@
58
58
  }
59
59
  },
60
60
  "devDependencies": {
61
- "@ai-sdk/otel": "1.0.97",
61
+ "@ai-sdk/otel": "1.0.99",
62
62
  "@opentelemetry/sdk-trace-base": "2.7.1",
63
63
  "@types/node": "22.19.19",
64
64
  "@types/ws": "^8.5.13",
@@ -112,6 +112,7 @@ export class HarnessAgentSession {
112
112
  private turnState: HarnessAgentTurnState;
113
113
  private turnSequence = 0;
114
114
  private activeTurnSequence = 0;
115
+ private activePromptDone: Promise<void> | undefined;
115
116
  private activePromptControl: ActivePromptControl | undefined;
116
117
  private suspendedTurnState:
117
118
  | Promise<HarnessAgentContinueTurnState>
@@ -281,6 +282,7 @@ export class HarnessAgentSession {
281
282
  onStopConditionMet: () =>
282
283
  this.captureStopConditionBoundary({ session, turnId }),
283
284
  });
285
+ this.activePromptDone = turn.done;
284
286
  return {
285
287
  ...turn,
286
288
  ready: this.waitForPromptControl({ turnId }),
@@ -380,6 +382,7 @@ export class HarnessAgentSession {
380
382
  onStopConditionMet: () =>
381
383
  this.captureStopConditionBoundary({ session, turnId }),
382
384
  });
385
+ this.activePromptDone = turn.done;
383
386
  return {
384
387
  ...turn,
385
388
  ready: this.waitForPromptControl({ turnId }),
@@ -463,7 +466,7 @@ export class HarnessAgentSession {
463
466
  try {
464
467
  if (this.turnState !== 'idle') {
465
468
  return this.toResumeStateWithContinuation({
466
- continueFrom: await this.suspendCurrentTurn({ session }),
469
+ continueFrom: await this.finalizeCurrentTurnSuspension({ session }),
467
470
  });
468
471
  }
469
472
  const raw = await session.doDetach();
@@ -495,7 +498,7 @@ export class HarnessAgentSession {
495
498
  try {
496
499
  if (this.turnState !== 'idle') {
497
500
  return this.toResumeStateWithContinuation({
498
- continueFrom: await this.suspendCurrentTurn({ session }),
501
+ continueFrom: await this.finalizeCurrentTurnSuspension({ session }),
499
502
  });
500
503
  }
501
504
  const raw = await session.doStop();
@@ -556,7 +559,7 @@ export class HarnessAgentSession {
556
559
  }
557
560
  const session = this.underlyingSession;
558
561
  try {
559
- return await this.suspendCurrentTurn({ session });
562
+ return await this.finalizeCurrentTurnSuspension({ session });
560
563
  } finally {
561
564
  this.endLocalHandle({ sessionState: 'detached' });
562
565
  }
@@ -611,6 +614,17 @@ export class HarnessAgentSession {
611
614
  return state;
612
615
  }
613
616
 
617
+ private async finalizeCurrentTurnSuspension(options: {
618
+ session: HarnessAgentAdapterSession;
619
+ }): Promise<HarnessAgentContinueTurnState> {
620
+ const state = await this.suspendCurrentTurn(options);
621
+ // Freeze ingress first, then include all dispatched host work in the cursor.
622
+ // Keep this wait outside suspendCurrentTurn because stop-condition handling
623
+ // invokes that helper from inside the active prompt.
624
+ await this.activePromptDone;
625
+ return this.addPendingToolState(state);
626
+ }
627
+
614
628
  private async captureStopConditionBoundary(options: {
615
629
  session: HarnessAgentAdapterSession;
616
630
  turnId: number;
@@ -14,6 +14,7 @@ import type {
14
14
  Experimental_SandboxSession as SandboxSession,
15
15
  FlexibleSchema,
16
16
  MaybePromiseLike,
17
+ SystemModelMessage,
17
18
  ToolSet,
18
19
  } from '@ai-sdk/provider-utils';
19
20
  import type {
@@ -159,9 +160,10 @@ export type HarnessAgentSettings<
159
160
  * Instructions for the underlying agent runtime. Adapters append these to a
160
161
  * native system or developer prompt when supported. Otherwise, they prepend
161
162
  * them to the user message. `prepareCall` can replace them between completed
162
- * turns.
163
+ * turns. When a `SystemModelMessage` is provided, only its `content` is
164
+ * forwarded to the harness adapter.
163
165
  */
164
- readonly instructions?: string;
166
+ readonly instructions?: string | SystemModelMessage;
165
167
 
166
168
  /**
167
169
  * Additional HTTP headers to be sent with every model request.
@@ -15,6 +15,7 @@ import {
15
15
  type Context,
16
16
  type Experimental_SandboxSession as SandboxSession,
17
17
  type ModelMessage,
18
+ type SystemModelMessage,
18
19
  type ToolApprovalResponse,
19
20
  type ToolResultPart,
20
21
  type ToolSet,
@@ -280,6 +281,11 @@ export class HarnessAgent<
280
281
  return this.settings.harness.harnessId;
281
282
  }
282
283
 
284
+ /** Whether this agent parses completed turns with its configured output. */
285
+ get hasOutput(): boolean {
286
+ return this.settings.output != null;
287
+ }
288
+
283
289
  /**
284
290
  * Start a fresh session, or resume from state previously returned by
285
291
  * `session.detach()` or `session.stop()`. The returned
@@ -989,7 +995,7 @@ export class HarnessAgent<
989
995
  private _prepareTurnSettings(options: {
990
996
  model?: string;
991
997
  skills?: ReadonlyArray<HarnessAgentSkill>;
992
- instructions?: string;
998
+ instructions?: string | SystemModelMessage;
993
999
  tools?: TUserTools;
994
1000
  }): PreparedHarnessAgentTurnSettings<THarness, TUserTools> {
995
1001
  const userTools = options.tools ?? ({} as TUserTools);
@@ -1011,7 +1017,10 @@ export class HarnessAgent<
1011
1017
  return {
1012
1018
  model: options.model,
1013
1019
  skills: options.skills ?? [],
1014
- instructions: options.instructions,
1020
+ instructions:
1021
+ typeof options.instructions === 'string'
1022
+ ? options.instructions
1023
+ : options.instructions?.content,
1015
1024
  tools,
1016
1025
  activeTools: toolFiltering.activeUserTools,
1017
1026
  toolSpecs: this._toToolSpecs(toolFiltering.activeUserTools),
@@ -539,28 +539,6 @@ export function runPrompt<
539
539
  onPendingToolResult(pendingResult);
540
540
  return pendingResult;
541
541
  };
542
- const processPendingToolResultContinuation = async (
543
- pendingResult: HarnessV1PendingToolResult,
544
- continuation: ToolResultPart,
545
- ): Promise<void> => {
546
- const result = unwrapToolResultOutput(continuation);
547
- onToolResultSettled(pendingResult.toolCallId);
548
- pendingResultsByToolCallId.delete(pendingResult.toolCallId);
549
- settledHostToolCallIds.add(pendingResult.toolCallId);
550
- await control.submitToolResult({
551
- toolCallId: pendingResult.toolCallId,
552
- output: result.output,
553
- isError: result.isError,
554
- toolResult: {
555
- ...continuation,
556
- toolName: pendingResult.toolName,
557
- ...(continuation.providerOptions == null &&
558
- pendingResult.providerOptions != null
559
- ? { providerOptions: pendingResult.providerOptions }
560
- : {}),
561
- },
562
- });
563
- };
564
542
  const enqueueHostToolOutcome = (options: {
565
543
  toolCall: ToolCallTextStreamPart;
566
544
  outcome: HostToolOutcome;
@@ -596,6 +574,68 @@ export function runPrompt<
596
574
  error: options.outcome.error,
597
575
  } as TextStreamPart<TOOLS>);
598
576
  };
577
+ const submitToolResult: HarnessV1PromptControl['submitToolResult'] =
578
+ async submission => {
579
+ if (!input.isTurnSuspending?.()) {
580
+ return control.submitToolResult(submission);
581
+ }
582
+ const toolCall =
583
+ rawToolCallsByToolCallId.get(submission.toolCallId) ??
584
+ pendingResultsByToolCallId.get(submission.toolCallId) ??
585
+ pendingToolApprovals.find(
586
+ approval => approval.toolCallId === submission.toolCallId,
587
+ );
588
+ if (toolCall == null) {
589
+ throw new Error(
590
+ `Unknown suspended tool call '${submission.toolCallId}'.`,
591
+ );
592
+ }
593
+ const { toolCallId, ...completedResult } = submission;
594
+ onPendingToolResult({
595
+ toolCallId,
596
+ toolName: toolCall.toolName,
597
+ input: toolCall.input,
598
+ completedResult,
599
+ });
600
+ const parsed = toolCallsByToolCallId.get(toolCallId);
601
+ if (parsed != null && !settledHostToolCallIds.has(toolCallId)) {
602
+ bufferedToolOutcomes.push(() =>
603
+ enqueueHostToolOutcome({
604
+ toolCall: parsed,
605
+ outcome: submission.isError
606
+ ? { ok: false, error: submission.output }
607
+ : { ok: true, output: submission.output },
608
+ }),
609
+ );
610
+ }
611
+ };
612
+ const processPendingToolResultContinuation = async (
613
+ pendingResult: HarnessV1PendingToolResult,
614
+ continuation: ToolResultPart | undefined,
615
+ ): Promise<void> => {
616
+ const submission =
617
+ continuation == null
618
+ ? pendingResult.completedResult
619
+ : {
620
+ ...unwrapToolResultOutput(continuation),
621
+ toolResult: {
622
+ ...continuation,
623
+ toolName: pendingResult.toolName,
624
+ ...(continuation.providerOptions == null &&
625
+ pendingResult.providerOptions != null
626
+ ? { providerOptions: pendingResult.providerOptions }
627
+ : {}),
628
+ },
629
+ };
630
+ if (submission == null) return;
631
+ settledHostToolCallIds.add(pendingResult.toolCallId);
632
+ onToolResultSettled(pendingResult.toolCallId);
633
+ await submitToolResult({
634
+ toolCallId: pendingResult.toolCallId,
635
+ ...submission,
636
+ });
637
+ pendingResultsByToolCallId.delete(pendingResult.toolCallId);
638
+ };
599
639
  const processPendingApprovalContinuation = async (
600
640
  approval: HarnessV1PendingToolApproval,
601
641
  continuation: ToolApprovalResponse,
@@ -643,7 +683,7 @@ export function runPrompt<
643
683
 
644
684
  settledHostToolCallIds.add(approval.toolCallId);
645
685
  if (!continuation.approved) {
646
- await control.submitToolResult({
686
+ await submitToolResult({
647
687
  toolCallId: approval.toolCallId,
648
688
  output: {
649
689
  type: 'execution-denied',
@@ -664,7 +704,7 @@ export function runPrompt<
664
704
  wrappedExecuteTool: lifecycle.executeTool,
665
705
  sandboxSession: input.sandboxSession,
666
706
  abortSignal: input.abortSignal,
667
- control,
707
+ submitToolResult,
668
708
  onPreliminaryResult: preliminaryOutput => {
669
709
  const stripped = stripWorkDir(
670
710
  {
@@ -727,12 +767,12 @@ export function runPrompt<
727
767
  const continuation = continuationsByToolCallId.get(
728
768
  pendingResult.toolCallId,
729
769
  );
730
- if (continuation != null) {
770
+ if (continuation != null || pendingResult.completedResult != null) {
731
771
  await processPendingToolResultContinuation(
732
772
  pendingResult,
733
773
  continuation,
734
774
  );
735
- closingResumedStep = true;
775
+ if (pendingResult.completedResult == null) closingResumedStep = true;
736
776
  }
737
777
  }
738
778
 
@@ -1083,7 +1123,7 @@ export function runPrompt<
1083
1123
  toolName: toolCall.toolName,
1084
1124
  }),
1085
1125
  };
1086
- await control.submitToolResult({
1126
+ await submitToolResult({
1087
1127
  toolCallId: toolCall.toolCallId,
1088
1128
  output,
1089
1129
  });
@@ -1130,7 +1170,7 @@ export function runPrompt<
1130
1170
  type: 'execution-denied',
1131
1171
  reason: customToolApprovalDecision.reason,
1132
1172
  };
1133
- await control.submitToolResult({
1173
+ await submitToolResult({
1134
1174
  toolCallId: toolCall.toolCallId,
1135
1175
  output,
1136
1176
  });
@@ -1223,7 +1263,7 @@ export function runPrompt<
1223
1263
  wrappedExecuteTool: lifecycle.executeTool,
1224
1264
  sandboxSession: input.sandboxSession,
1225
1265
  abortSignal: input.abortSignal,
1226
- control,
1266
+ submitToolResult,
1227
1267
  onPreliminaryResult: preliminaryOutput => {
1228
1268
  /*
1229
1269
  * Project a `yield`ed value as a preliminary AI SDK
@@ -1277,6 +1317,7 @@ export function runPrompt<
1277
1317
  await waitForOutstandingHostToolExecutions();
1278
1318
  const isTurnSuspending = input.isTurnSuspending?.() === true;
1279
1319
  if (isTurnSuspending) {
1320
+ await publishToolExecutions();
1280
1321
  if (finalFinish == null) {
1281
1322
  /*
1282
1323
  * A timed slice may stop in the middle of a model step. Its partial
@@ -1388,7 +1429,7 @@ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
1388
1429
  wrappedExecuteTool: TurnLifecycle<ToolSet, Context>['executeTool'];
1389
1430
  sandboxSession: SandboxSession;
1390
1431
  abortSignal: AbortSignal | undefined;
1391
- control: HarnessV1PromptControl;
1432
+ submitToolResult: HarnessV1PromptControl['submitToolResult'];
1392
1433
  /**
1393
1434
  * Called for each value a generator `execute` `yield`s before its last. The
1394
1435
  * caller surfaces these as preliminary `tool-result` parts on the consumer
@@ -1440,13 +1481,13 @@ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
1440
1481
  },
1441
1482
  });
1442
1483
 
1443
- await input.control.submitToolResult({
1484
+ await input.submitToolResult({
1444
1485
  toolCallId: input.event.toolCallId,
1445
1486
  output,
1446
1487
  });
1447
1488
  return { executed: true, outcome: { ok: true, output } };
1448
1489
  } catch (err) {
1449
- await input.control.submitToolResult({
1490
+ await input.submitToolResult({
1450
1491
  toolCallId: input.event.toolCallId,
1451
1492
  output: { error: String(err) },
1452
1493
  isError: true,
@@ -13,6 +13,18 @@ export {
13
13
  export { classifyDiskLog, type DiskLogRecoveryMode } from './classify-disk-log';
14
14
  export { getAiGatewayAuthFromEnv } from './ai-gateway-auth';
15
15
  export { isHarnessAuthenticationEnvironment } from './authentication-environment';
16
+ export { getJwtExpiresAt, parseJwtPayload } from './native-subscription/jwt';
17
+ export { readLinuxSecretServicePassword } from './native-subscription/linux-secret-service';
18
+ export { readMacOSKeychainPassword } from './native-subscription/macos-keychain';
19
+ export { shouldResolveNativeSubscription } from './native-subscription/should-resolve-native';
20
+ export { readWindowsCredentialManagerPassword } from './native-subscription/windows-credential-manager';
21
+ export {
22
+ isAccessTokenExpiringSoon,
23
+ refreshOAuthAccessToken,
24
+ type OAuthCredential,
25
+ type RefreshOAuthAccessTokenResult,
26
+ } from './oauth-access-token';
27
+ export { isLinux, isMacOS, isWindows } from './os';
16
28
  export {
17
29
  applyCredentialForwarding,
18
30
  createSandboxCredentialEnvironment,
@@ -0,0 +1,31 @@
1
+ import { isRecord, safeParseJSON } from '@ai-sdk/provider-utils';
2
+
3
+ export async function parseJwtPayload({
4
+ token,
5
+ }: {
6
+ token: string;
7
+ }): Promise<Readonly<Record<string, unknown>> | undefined> {
8
+ const segments = token.split('.');
9
+ if (segments.length !== 3) return undefined;
10
+
11
+ try {
12
+ const parsed = await safeParseJSON({
13
+ text: Buffer.from(segments[1], 'base64url').toString('utf8'),
14
+ });
15
+ return parsed.success && isRecord(parsed.value) ? parsed.value : undefined;
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+
21
+ export async function getJwtExpiresAt({
22
+ token,
23
+ }: {
24
+ token: string;
25
+ }): Promise<number | undefined> {
26
+ const payload = await parseJwtPayload({ token });
27
+ const expiresAt = payload?.exp;
28
+ return typeof expiresAt === 'number' && Number.isFinite(expiresAt)
29
+ ? expiresAt * 1000
30
+ : undefined;
31
+ }
@@ -0,0 +1,23 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export async function readLinuxSecretServicePassword({
7
+ attributes,
8
+ }: {
9
+ attributes: Readonly<Record<string, string>>;
10
+ }): Promise<string | undefined> {
11
+ try {
12
+ const result = await execFileAsync('secret-tool', [
13
+ 'lookup',
14
+ ...Object.entries(attributes).flatMap(([attribute, value]) => [
15
+ attribute,
16
+ value,
17
+ ]),
18
+ ]);
19
+ return result.stdout || undefined;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
@@ -0,0 +1,26 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export async function readMacOSKeychainPassword({
7
+ service,
8
+ account,
9
+ }: {
10
+ service: string;
11
+ account: string;
12
+ }): Promise<string | undefined> {
13
+ try {
14
+ const result = await execFileAsync('/usr/bin/security', [
15
+ 'find-generic-password',
16
+ '-s',
17
+ service,
18
+ '-a',
19
+ account,
20
+ '-w',
21
+ ]);
22
+ return result.stdout.trim() || undefined;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
@@ -0,0 +1,18 @@
1
+ import type { HarnessV1Authentication } from '../../v1/harness-authentication';
2
+ import { getAiGatewayAuthFromEnv } from '../ai-gateway-auth';
3
+
4
+ export function shouldResolveNativeSubscription({
5
+ auth,
6
+ env,
7
+ hasDirectCredential,
8
+ }: {
9
+ auth: Extract<HarnessV1Authentication, string> | undefined;
10
+ env: Readonly<Record<string, string | undefined>>;
11
+ hasDirectCredential: boolean;
12
+ }): boolean {
13
+ return (
14
+ auth !== 'ai-gateway' &&
15
+ !hasDirectCredential &&
16
+ (auth === 'direct' || getAiGatewayAuthFromEnv({ env }).apiKey == null)
17
+ );
18
+ }
@@ -0,0 +1,61 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ const credentialManagerSource = `
7
+ using System;
8
+ using System.Runtime.InteropServices;
9
+ using System.Text;
10
+ public static class AISDKCredentialManager {
11
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
12
+ public struct Credential {
13
+ public UInt32 Flags; public UInt32 Type; public string TargetName;
14
+ public string Comment; public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
15
+ public UInt32 CredentialBlobSize; public IntPtr CredentialBlob;
16
+ public UInt32 Persist; public UInt32 AttributeCount; public IntPtr Attributes;
17
+ public string TargetAlias; public string UserName;
18
+ }
19
+ [DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
20
+ static extern bool CredRead(string target, UInt32 type, UInt32 flags, out IntPtr credential);
21
+ [DllImport("advapi32.dll", SetLastError = true)] static extern void CredFree(IntPtr credential);
22
+ public static string Read(string target) {
23
+ IntPtr pointer;
24
+ if (!CredRead(target, 1, 0, out pointer)) return null;
25
+ try {
26
+ Credential value = Marshal.PtrToStructure<Credential>(pointer);
27
+ byte[] bytes = new byte[value.CredentialBlobSize];
28
+ Marshal.Copy(value.CredentialBlob, bytes, 0, bytes.Length);
29
+ return Encoding.Unicode.GetString(bytes);
30
+ } finally { CredFree(pointer); }
31
+ }
32
+ }`;
33
+
34
+ export async function readWindowsCredentialManagerPassword({
35
+ targetName,
36
+ }: {
37
+ targetName: string;
38
+ }): Promise<string | undefined> {
39
+ try {
40
+ const result = await execFileAsync(
41
+ 'powershell.exe',
42
+ [
43
+ '-NoProfile',
44
+ '-NonInteractive',
45
+ '-Command',
46
+ `Add-Type -TypeDefinition $env:AI_SDK_WINDOWS_CREDENTIAL_MANAGER_SOURCE; $value = [AISDKCredentialManager]::Read($env:AI_SDK_WINDOWS_CREDENTIAL_MANAGER_TARGET); if ($null -ne $value) { [Console]::Out.Write($value) }`,
47
+ ],
48
+ {
49
+ env: {
50
+ ...process.env,
51
+ AI_SDK_WINDOWS_CREDENTIAL_MANAGER_SOURCE: credentialManagerSource,
52
+ AI_SDK_WINDOWS_CREDENTIAL_MANAGER_TARGET: targetName,
53
+ },
54
+ windowsHide: true,
55
+ },
56
+ );
57
+ return result.stdout || undefined;
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
@@ -0,0 +1,129 @@
1
+ import { isRecord, safeParseJSON } from '@ai-sdk/provider-utils';
2
+ import { getJwtExpiresAt } from './native-subscription/jwt';
3
+
4
+ const DEFAULT_REFRESH_WINDOW_MS = 300_000;
5
+
6
+ export type OAuthCredential = {
7
+ readonly accessToken: string;
8
+ readonly refreshToken: string;
9
+ readonly expiresAt: number;
10
+ };
11
+
12
+ export type RefreshOAuthAccessTokenResult = {
13
+ readonly accessToken: string;
14
+ readonly expiresAt: number;
15
+ readonly refreshToken?: string;
16
+ };
17
+
18
+ export function isAccessTokenExpiringSoon({
19
+ expiresAt,
20
+ now = Date.now(),
21
+ refreshWindowMs = DEFAULT_REFRESH_WINDOW_MS,
22
+ }: {
23
+ readonly expiresAt: number;
24
+ readonly now?: number;
25
+ readonly refreshWindowMs?: number;
26
+ }): boolean {
27
+ return expiresAt <= now + refreshWindowMs;
28
+ }
29
+
30
+ export async function refreshOAuthAccessToken({
31
+ tokenUrl,
32
+ clientId,
33
+ refreshToken,
34
+ requestFormat = 'form',
35
+ headers,
36
+ fetch: fetchImplementation = globalThis.fetch,
37
+ }: {
38
+ readonly tokenUrl: string;
39
+ readonly clientId: string;
40
+ readonly refreshToken: string;
41
+ readonly requestFormat?: 'json' | 'form';
42
+ readonly headers?: Record<string, string>;
43
+ readonly fetch?: typeof globalThis.fetch;
44
+ }): Promise<RefreshOAuthAccessTokenResult> {
45
+ const values = {
46
+ grant_type: 'refresh_token',
47
+ client_id: clientId,
48
+ refresh_token: refreshToken,
49
+ };
50
+ const response = await fetchImplementation(tokenUrl, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'content-type':
54
+ requestFormat === 'json'
55
+ ? 'application/json'
56
+ : 'application/x-www-form-urlencoded',
57
+ ...headers,
58
+ },
59
+ body:
60
+ requestFormat === 'json'
61
+ ? JSON.stringify(values)
62
+ : new URLSearchParams(values).toString(),
63
+ });
64
+
65
+ const responseText = await response.text();
66
+ if (!response.ok) {
67
+ throw new Error(
68
+ `OAuth access token refresh failed with status ${response.status}.`,
69
+ );
70
+ }
71
+
72
+ const parsed = await safeParseJSON({ text: responseText });
73
+ if (!parsed.success || !isRecord(parsed.value)) {
74
+ throw new Error('OAuth access token refresh returned invalid JSON.');
75
+ }
76
+
77
+ const accessToken = parsed.value.access_token;
78
+ if (typeof accessToken !== 'string' || accessToken.length === 0) {
79
+ throw new Error(
80
+ 'OAuth access token refresh response is missing access_token.',
81
+ );
82
+ }
83
+
84
+ const expiresAt = await resolveExpiresAt({
85
+ accessToken,
86
+ expiresIn: parsed.value.expires_in,
87
+ });
88
+ const rotatedRefreshToken = parsed.value.refresh_token;
89
+ if (
90
+ rotatedRefreshToken != null &&
91
+ (typeof rotatedRefreshToken !== 'string' ||
92
+ rotatedRefreshToken.length === 0)
93
+ ) {
94
+ throw new Error(
95
+ 'OAuth access token refresh response contains an invalid refresh_token.',
96
+ );
97
+ }
98
+
99
+ return {
100
+ accessToken,
101
+ expiresAt,
102
+ ...(typeof rotatedRefreshToken === 'string'
103
+ ? { refreshToken: rotatedRefreshToken }
104
+ : {}),
105
+ };
106
+ }
107
+
108
+ async function resolveExpiresAt({
109
+ accessToken,
110
+ expiresIn,
111
+ }: {
112
+ accessToken: string;
113
+ expiresIn: unknown;
114
+ }): Promise<number> {
115
+ if (
116
+ typeof expiresIn === 'number' &&
117
+ Number.isFinite(expiresIn) &&
118
+ expiresIn >= 0
119
+ ) {
120
+ return Date.now() + expiresIn * 1000;
121
+ }
122
+
123
+ const jwtExpiresAt = await getJwtExpiresAt({ token: accessToken });
124
+ if (jwtExpiresAt != null) return jwtExpiresAt;
125
+
126
+ throw new Error(
127
+ 'OAuth access token refresh response does not include a usable expiry.',
128
+ );
129
+ }
@@ -0,0 +1,11 @@
1
+ export function isMacOS(platform: NodeJS.Platform): boolean {
2
+ return platform === 'darwin';
3
+ }
4
+
5
+ export function isLinux(platform: NodeJS.Platform): boolean {
6
+ return platform === 'linux';
7
+ }
8
+
9
+ export function isWindows(platform: NodeJS.Platform): boolean {
10
+ return platform === 'win32';
11
+ }