@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/CHANGELOG.md +27 -0
- package/dist/agent/index.d.ts +14 -3
- package/dist/agent/index.js +74 -30
- package/dist/agent/index.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/utils/index.d.ts +62 -1
- package/dist/utils/index.js +241 -7
- package/dist/utils/index.js.map +1 -1
- package/package.json +5 -5
- package/src/agent/harness-agent-session.ts +17 -3
- package/src/agent/harness-agent-settings.ts +4 -2
- package/src/agent/harness-agent.ts +11 -2
- package/src/agent/internal/run-prompt.ts +73 -32
- package/src/utils/index.ts +12 -0
- package/src/utils/native-subscription/jwt.ts +31 -0
- package/src/utils/native-subscription/linux-secret-service.ts +23 -0
- package/src/utils/native-subscription/macos-keychain.ts +26 -0
- package/src/utils/native-subscription/should-resolve-native.ts +18 -0
- package/src/utils/native-subscription/windows-credential-manager.ts +61 -0
- package/src/utils/oauth-access-token.ts +129 -0
- package/src/utils/os.ts +11 -0
- package/src/v1/harness-v1-lifecycle-state.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/harness",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
48
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
49
|
-
"ai": "7.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1490
|
+
await input.submitToolResult({
|
|
1450
1491
|
toolCallId: input.event.toolCallId,
|
|
1451
1492
|
output: { error: String(err) },
|
|
1452
1493
|
isError: true,
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/os.ts
ADDED
|
@@ -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
|
+
}
|