@ai-sdk/harness 1.0.91 → 1.0.93
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 +22 -0
- package/README.md +5 -1
- package/dist/agent/index.d.ts +155 -110
- package/dist/agent/index.js +292 -99
- package/dist/agent/index.js.map +1 -1
- package/dist/bridge/index.d.ts +20 -1
- package/dist/bridge/index.js +29 -5
- package/dist/bridge/index.js.map +1 -1
- package/dist/index.d.ts +115 -94
- package/dist/utils/index.d.ts +18 -2
- package/dist/utils/index.js +327 -33
- package/dist/utils/index.js.map +1 -1
- package/package.json +4 -4
- package/src/agent/harness-agent-session.ts +107 -7
- package/src/agent/harness-agent-settings.ts +86 -7
- package/src/agent/harness-agent.ts +322 -112
- package/src/agent/internal/lifecycle-state-validation.ts +3 -0
- package/src/agent/internal/run-prompt.ts +16 -6
- package/src/bridge/index.ts +73 -6
- package/src/utils/authentication-environment.ts +20 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/write-skills.ts +419 -32
- package/src/v1/harness-authentication.ts +24 -0
- package/src/v1/harness-v1-lifecycle-state.ts +38 -0
- package/src/v1/harness-v1-session.ts +9 -40
- package/src/v1/index.ts +5 -0
package/src/bridge/index.ts
CHANGED
|
@@ -293,8 +293,27 @@ export interface RunBridgeOptions<TStart extends { type: 'start' }> {
|
|
|
293
293
|
bridgeType: string;
|
|
294
294
|
/** Directory for `bridge-meta.json` / `start-config.json`. Created if absent. */
|
|
295
295
|
bridgeStateDir: string;
|
|
296
|
-
/**
|
|
296
|
+
/**
|
|
297
|
+
* Drive one prompt turn. Rejections surface to the host as an `error`
|
|
298
|
+
* event.
|
|
299
|
+
*
|
|
300
|
+
* Contract: once `turn.abortSignal` fires, wind down promptly — turns are
|
|
301
|
+
* serialized, and a replacement `start` waits up to
|
|
302
|
+
* {@link turnTeardownGraceMs} for this promise to settle before it
|
|
303
|
+
* proceeds anyway.
|
|
304
|
+
*/
|
|
297
305
|
onStart(start: TStart, turn: BridgeTurn): Promise<void>;
|
|
306
|
+
/**
|
|
307
|
+
* How long a replacement `start` waits for the previous turn's teardown
|
|
308
|
+
* after aborting it, in milliseconds. Turns are serialized so an aborted
|
|
309
|
+
* turn cannot emit into its replacement's event log or overlap its runtime
|
|
310
|
+
* process — but only within this bound: an adapter that does not settle
|
|
311
|
+
* `onStart` after its abort signal fires forfeits the protection for that
|
|
312
|
+
* boundary, and the new turn proceeds anyway rather than blocking forever.
|
|
313
|
+
* The default of ten seconds exceeds the Claude bridge's five-second
|
|
314
|
+
* hard-abort fallback.
|
|
315
|
+
*/
|
|
316
|
+
turnTeardownGraceMs?: number;
|
|
298
317
|
/**
|
|
299
318
|
* Produce the adapter-defined runtime resume data for `stop`. Defaults to
|
|
300
319
|
* `{}`.
|
|
@@ -355,6 +374,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
355
374
|
options: RunBridgeOptions<TStart>,
|
|
356
375
|
): Promise<BridgeHandle> {
|
|
357
376
|
const { bridgeType, bridgeStateDir, onStart, onStop, onDestroy } = options;
|
|
377
|
+
const teardownGraceMs = options.turnTeardownGraceMs ?? 10_000;
|
|
358
378
|
const expectedToken = options.token ?? procEnv.BRIDGE_CHANNEL_TOKEN ?? '';
|
|
359
379
|
const bridgeWsPort =
|
|
360
380
|
options.port ?? parseInt(procEnv.BRIDGE_WS_PORT ?? '0', 10);
|
|
@@ -384,6 +404,12 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
384
404
|
let isFirstTurn = true;
|
|
385
405
|
let turnAbort: AbortController | undefined;
|
|
386
406
|
let currentUserMessages: InternalBridgeUserMessageQueue | undefined;
|
|
407
|
+
/**
|
|
408
|
+
* Settles when the in-flight turn has fully wound down — `onStart`
|
|
409
|
+
* returned or threw AND its completion state was recorded. `undefined`
|
|
410
|
+
* between turns. A new `start` fences on this so turns never overlap.
|
|
411
|
+
*/
|
|
412
|
+
let activeTurn: Promise<void> | undefined;
|
|
387
413
|
|
|
388
414
|
// Diagnostics. Resolved per turn from `start.debug` with a sandbox-side
|
|
389
415
|
// env fallback; gates console capture + structured `debug-event`s.
|
|
@@ -658,10 +684,44 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
658
684
|
): Promise<void> => {
|
|
659
685
|
switch (msg.type) {
|
|
660
686
|
case 'start': {
|
|
687
|
+
/*
|
|
688
|
+
* A new turn replaces the active one — but only after the active one
|
|
689
|
+
* has fully wound down. Inbound frames are dispatched concurrently,
|
|
690
|
+
* and the host settles a caller abort immediately, so a retry's
|
|
691
|
+
* `start` can arrive while the aborted turn is still tearing down
|
|
692
|
+
* (e.g. a graceful interrupt). Without this fence the old turn would
|
|
693
|
+
* keep emitting into the new turn's cleared event log, two runtime
|
|
694
|
+
* processes would run side by side, and the old turn's completion
|
|
695
|
+
* would mark the bridge `waiting` underneath the new turn. Abort the
|
|
696
|
+
* old turn to hasten its teardown; adapters are expected to bound
|
|
697
|
+
* that teardown themselves (e.g. a hard-abort fallback), but the
|
|
698
|
+
* runtime does not rely on it: the wait is capped by the teardown
|
|
699
|
+
* grace period, after which the new turn proceeds anyway — the
|
|
700
|
+
* pre-fence overlapping behavior — rather than hanging behind a
|
|
701
|
+
* teardown that never settles.
|
|
702
|
+
*/
|
|
703
|
+
for (;;) {
|
|
704
|
+
const pendingTurn = activeTurn;
|
|
705
|
+
if (pendingTurn == null) break;
|
|
706
|
+
turnAbort?.abort();
|
|
707
|
+
currentUserMessages?.close(
|
|
708
|
+
new Error('A new bridge turn replaced the active turn.'),
|
|
709
|
+
);
|
|
710
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
711
|
+
const settled = await Promise.race([
|
|
712
|
+
pendingTurn.then(() => true as const),
|
|
713
|
+
new Promise<false>(resolve => {
|
|
714
|
+
graceTimer = setTimeout(() => resolve(false), teardownGraceMs);
|
|
715
|
+
graceTimer.unref?.();
|
|
716
|
+
}),
|
|
717
|
+
]);
|
|
718
|
+
clearTimeout(graceTimer);
|
|
719
|
+
if (!settled) break;
|
|
720
|
+
}
|
|
721
|
+
let turnFinished!: () => void;
|
|
722
|
+
const thisTurn = new Promise<void>(resolve => (turnFinished = resolve));
|
|
723
|
+
activeTurn = thisTurn;
|
|
661
724
|
activeSocket = ws; // asking for a turn claims the event stream
|
|
662
|
-
currentUserMessages?.close(
|
|
663
|
-
new Error('A new bridge turn replaced the active turn.'),
|
|
664
|
-
);
|
|
665
725
|
const firstTurn = isFirstTurn;
|
|
666
726
|
isFirstTurn = false;
|
|
667
727
|
eventLog = []; // clear previous turn; keep seqCounter monotonic
|
|
@@ -727,8 +787,15 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
727
787
|
if (currentUserMessages === userMessages) {
|
|
728
788
|
currentUserMessages = undefined;
|
|
729
789
|
}
|
|
730
|
-
|
|
731
|
-
|
|
790
|
+
// Only the still-active turn records completion: after a fence
|
|
791
|
+
// timeout a replacement turn is already running, and this stale
|
|
792
|
+
// completion must not mark the bridge waiting underneath it.
|
|
793
|
+
if (activeTurn === thisTurn) {
|
|
794
|
+
activeTurn = undefined;
|
|
795
|
+
currentTurnState = 'waiting';
|
|
796
|
+
void writeBridgeMeta('waiting');
|
|
797
|
+
}
|
|
798
|
+
turnFinished();
|
|
732
799
|
}
|
|
733
800
|
return;
|
|
734
801
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HarnessV1AuthenticationEnvironment } from '../v1/harness-authentication';
|
|
2
|
+
|
|
3
|
+
export function isHarnessAuthenticationEnvironment(
|
|
4
|
+
value: unknown,
|
|
5
|
+
): value is HarnessV1AuthenticationEnvironment {
|
|
6
|
+
if (value == null || typeof value !== 'object') {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (
|
|
11
|
+
Array.isArray(value) ||
|
|
12
|
+
Object.values(value).some(entry => typeof entry !== 'string')
|
|
13
|
+
) {
|
|
14
|
+
throw new TypeError(
|
|
15
|
+
'Invalid auth: expected an authentication mode or a flat record with string values.',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return true;
|
|
20
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export {
|
|
|
12
12
|
} from './bridge-user-message-submitter';
|
|
13
13
|
export { classifyDiskLog, type DiskLogRecoveryMode } from './classify-disk-log';
|
|
14
14
|
export { getAiGatewayAuthFromEnv } from './ai-gateway-auth';
|
|
15
|
+
export { isHarnessAuthenticationEnvironment } from './authentication-environment';
|
|
15
16
|
export {
|
|
16
17
|
applyCredentialForwarding,
|
|
17
18
|
createSandboxCredentialEnvironment,
|
|
@@ -29,6 +30,7 @@ export {
|
|
|
29
30
|
writeSkills,
|
|
30
31
|
type SkillFilePathMode,
|
|
31
32
|
type WriteSkillsOptions,
|
|
33
|
+
type WriteSkillsResult,
|
|
32
34
|
} from './write-skills';
|
|
33
35
|
export {
|
|
34
36
|
markBridgeStarting,
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import path from 'node:path';
|
|
2
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
safeParseJSON,
|
|
5
|
+
type Experimental_SandboxSession,
|
|
6
|
+
} from '@ai-sdk/provider-utils';
|
|
3
7
|
import type { HarnessV1Skill } from '../v1';
|
|
4
8
|
import { shellQuote } from './shell-quote';
|
|
5
9
|
|
|
10
|
+
const SKILLS_MANIFEST_FILENAME = '.ai-sdk-harness-skills.json';
|
|
11
|
+
const SKILLS_MANIFEST_VERSION = 1;
|
|
12
|
+
const SAFE_MANIFEST_SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
|
13
|
+
|
|
6
14
|
export type SkillFilePathMode = 'relative' | 'strip-leading-slashes';
|
|
7
15
|
|
|
8
16
|
export type WriteSkillsOptions = {
|
|
@@ -20,6 +28,33 @@ export type WriteSkillsOptions = {
|
|
|
20
28
|
trailingNewline?: boolean;
|
|
21
29
|
};
|
|
22
30
|
|
|
31
|
+
export type WriteSkillsResult = {
|
|
32
|
+
changed: boolean;
|
|
33
|
+
written: string[];
|
|
34
|
+
removed: string[];
|
|
35
|
+
unchanged: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ProjectedSkill = {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly hash: string;
|
|
41
|
+
readonly files: ReadonlyArray<{
|
|
42
|
+
readonly path: string;
|
|
43
|
+
readonly content: string;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type SkillsManifestEntry = {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly hash: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type SkillsManifest = {
|
|
53
|
+
readonly version: typeof SKILLS_MANIFEST_VERSION;
|
|
54
|
+
readonly state: 'complete' | 'pending';
|
|
55
|
+
readonly skills: ReadonlyArray<SkillsManifestEntry>;
|
|
56
|
+
};
|
|
57
|
+
|
|
23
58
|
export async function writeSkills({
|
|
24
59
|
sandbox,
|
|
25
60
|
rootDir,
|
|
@@ -31,50 +66,365 @@ export async function writeSkills({
|
|
|
31
66
|
invalidSkillFilePathMessage = ({ skillName, filePath }) =>
|
|
32
67
|
`Invalid skill file path for ${skillName}: ${filePath}`,
|
|
33
68
|
trailingNewline = false,
|
|
34
|
-
}: WriteSkillsOptions): Promise<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
69
|
+
}: WriteSkillsOptions): Promise<WriteSkillsResult> {
|
|
70
|
+
const projectedSkills = skills
|
|
71
|
+
.map(skill =>
|
|
72
|
+
projectSkill({
|
|
73
|
+
skill,
|
|
74
|
+
skillNamePattern,
|
|
75
|
+
invalidSkillNameMessage,
|
|
76
|
+
filePathMode,
|
|
77
|
+
invalidSkillFilePathMessage,
|
|
78
|
+
trailingNewline,
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
+
assertUniqueSkillNames(projectedSkills);
|
|
83
|
+
|
|
84
|
+
const manifestPath = path.posix.join(rootDir, SKILLS_MANIFEST_FILENAME);
|
|
85
|
+
const existingManifest = await readSkillsManifest({
|
|
86
|
+
sandbox,
|
|
87
|
+
manifestPath,
|
|
88
|
+
abortSignal,
|
|
89
|
+
});
|
|
90
|
+
const nextEntries = projectedSkills.map(({ name, hash }) => ({ name, hash }));
|
|
91
|
+
const nextByName = new Map(nextEntries.map(entry => [entry.name, entry]));
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
* Delete pending-manifest directories before rewriting the requested skills;
|
|
95
|
+
* any listed directory may contain only part of its intended files.
|
|
96
|
+
*/
|
|
97
|
+
if (existingManifest?.state === 'pending') {
|
|
98
|
+
await ensureSkillsDirectory({ sandbox, rootDir, abortSignal });
|
|
99
|
+
const recoveryNames = existingManifest.skills.map(skill => skill.name);
|
|
100
|
+
await removeSkillDirectories({
|
|
101
|
+
sandbox,
|
|
102
|
+
rootDir,
|
|
103
|
+
skillNames: recoveryNames,
|
|
104
|
+
abortSignal,
|
|
105
|
+
});
|
|
106
|
+
const removed = recoveryNames
|
|
107
|
+
.filter(name => !nextByName.has(name))
|
|
108
|
+
.sort((a, b) => a.localeCompare(b));
|
|
109
|
+
await writeProjectedSkills({
|
|
110
|
+
sandbox,
|
|
111
|
+
rootDir,
|
|
112
|
+
skills: projectedSkills,
|
|
113
|
+
abortSignal,
|
|
114
|
+
});
|
|
115
|
+
await writeSkillsManifest({
|
|
116
|
+
sandbox,
|
|
117
|
+
manifestPath,
|
|
118
|
+
manifest: {
|
|
119
|
+
version: SKILLS_MANIFEST_VERSION,
|
|
120
|
+
state: 'complete',
|
|
121
|
+
skills: nextEntries,
|
|
122
|
+
},
|
|
123
|
+
abortSignal,
|
|
40
124
|
});
|
|
41
|
-
|
|
125
|
+
return {
|
|
126
|
+
changed: recoveryNames.length > 0 || projectedSkills.length > 0,
|
|
127
|
+
written: projectedSkills.map(skill => skill.name),
|
|
128
|
+
removed,
|
|
129
|
+
unchanged: [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const previousEntries = existingManifest?.skills ?? [];
|
|
134
|
+
const previousByName = new Map(
|
|
135
|
+
previousEntries.map(entry => [entry.name, entry]),
|
|
136
|
+
);
|
|
137
|
+
const removed = previousEntries
|
|
138
|
+
.filter(entry => !nextByName.has(entry.name))
|
|
139
|
+
.map(entry => entry.name)
|
|
140
|
+
.sort((a, b) => a.localeCompare(b));
|
|
141
|
+
const written = projectedSkills
|
|
142
|
+
.filter(skill => previousByName.get(skill.name)?.hash !== skill.hash)
|
|
143
|
+
.map(skill => skill.name)
|
|
144
|
+
.sort((a, b) => a.localeCompare(b));
|
|
145
|
+
const unchanged = projectedSkills
|
|
146
|
+
.filter(skill => previousByName.get(skill.name)?.hash === skill.hash)
|
|
147
|
+
.map(skill => skill.name)
|
|
148
|
+
.sort((a, b) => a.localeCompare(b));
|
|
149
|
+
const changed = removed.length > 0 || written.length > 0;
|
|
150
|
+
|
|
151
|
+
if (!changed && existingManifest != null) {
|
|
152
|
+
return { changed: false, written, removed, unchanged };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const previouslyOwned = new Set(previousEntries.map(entry => entry.name));
|
|
156
|
+
for (const skillName of written) {
|
|
157
|
+
if (previouslyOwned.has(skillName)) continue;
|
|
158
|
+
await assertSkillDirectoryAvailable({
|
|
159
|
+
sandbox,
|
|
160
|
+
rootDir,
|
|
161
|
+
skillName,
|
|
162
|
+
abortSignal,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await ensureSkillsDirectory({ sandbox, rootDir, abortSignal });
|
|
167
|
+
|
|
168
|
+
const pendingNames = Array.from(
|
|
169
|
+
new Set([
|
|
170
|
+
...previousEntries.map(entry => entry.name),
|
|
171
|
+
...nextEntries.map(entry => entry.name),
|
|
172
|
+
]),
|
|
173
|
+
).sort((a, b) => a.localeCompare(b));
|
|
174
|
+
await writeSkillsManifest({
|
|
175
|
+
sandbox,
|
|
176
|
+
manifestPath,
|
|
177
|
+
manifest: {
|
|
178
|
+
version: SKILLS_MANIFEST_VERSION,
|
|
179
|
+
state: 'pending',
|
|
180
|
+
skills: pendingNames.map(name => ({
|
|
181
|
+
name,
|
|
182
|
+
hash: nextByName.get(name)?.hash ?? previousByName.get(name)!.hash,
|
|
183
|
+
})),
|
|
184
|
+
},
|
|
185
|
+
abortSignal,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await removeSkillDirectories({
|
|
189
|
+
sandbox,
|
|
190
|
+
rootDir,
|
|
191
|
+
skillNames: [
|
|
192
|
+
...removed,
|
|
193
|
+
...written.filter(name => previouslyOwned.has(name)),
|
|
194
|
+
],
|
|
195
|
+
abortSignal,
|
|
196
|
+
});
|
|
197
|
+
const writtenSet = new Set(written);
|
|
198
|
+
await writeProjectedSkills({
|
|
199
|
+
sandbox,
|
|
200
|
+
rootDir,
|
|
201
|
+
skills: projectedSkills.filter(skill => writtenSet.has(skill.name)),
|
|
202
|
+
abortSignal,
|
|
203
|
+
});
|
|
204
|
+
await writeSkillsManifest({
|
|
205
|
+
sandbox,
|
|
206
|
+
manifestPath,
|
|
207
|
+
manifest: {
|
|
208
|
+
version: SKILLS_MANIFEST_VERSION,
|
|
209
|
+
state: 'complete',
|
|
210
|
+
skills: nextEntries,
|
|
211
|
+
},
|
|
212
|
+
abortSignal,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return { changed, written, removed, unchanged };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function ensureSkillsDirectory({
|
|
219
|
+
sandbox,
|
|
220
|
+
rootDir,
|
|
221
|
+
abortSignal,
|
|
222
|
+
}: {
|
|
223
|
+
sandbox: Experimental_SandboxSession;
|
|
224
|
+
rootDir: string;
|
|
225
|
+
abortSignal?: AbortSignal;
|
|
226
|
+
}): Promise<void> {
|
|
227
|
+
await runSandboxCommand({
|
|
228
|
+
sandbox,
|
|
229
|
+
command: `mkdir -p ${shellQuote(rootDir)}`,
|
|
230
|
+
abortSignal,
|
|
231
|
+
errorMessage: `Failed to create skills directory: ${rootDir}`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function projectSkill({
|
|
236
|
+
skill,
|
|
237
|
+
skillNamePattern,
|
|
238
|
+
invalidSkillNameMessage,
|
|
239
|
+
filePathMode,
|
|
240
|
+
invalidSkillFilePathMessage,
|
|
241
|
+
trailingNewline,
|
|
242
|
+
}: {
|
|
243
|
+
skill: HarnessV1Skill;
|
|
244
|
+
skillNamePattern: RegExp;
|
|
245
|
+
invalidSkillNameMessage: (input: { name: string }) => string;
|
|
246
|
+
filePathMode: SkillFilePathMode;
|
|
247
|
+
invalidSkillFilePathMessage: (input: {
|
|
248
|
+
skillName: string;
|
|
249
|
+
filePath: string;
|
|
250
|
+
}) => string;
|
|
251
|
+
trailingNewline: boolean;
|
|
252
|
+
}): ProjectedSkill {
|
|
253
|
+
const name = validateSkillName({
|
|
254
|
+
name: skill.name,
|
|
255
|
+
pattern: skillNamePattern,
|
|
256
|
+
message: invalidSkillNameMessage,
|
|
257
|
+
});
|
|
258
|
+
const files = new Map<string, string>();
|
|
259
|
+
files.set('SKILL.md', renderSkillFile({ skill, trailingNewline }));
|
|
260
|
+
for (const file of skill.files ?? []) {
|
|
261
|
+
files.set(
|
|
42
262
|
normalizeSkillFilePath({
|
|
43
263
|
skillName: skill.name,
|
|
44
264
|
filePath: file.path,
|
|
45
265
|
mode: filePathMode,
|
|
46
266
|
message: invalidSkillFilePathMessage,
|
|
47
|
-
})
|
|
267
|
+
}),
|
|
268
|
+
file.content,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const projectedFiles = Array.from(files, ([filePath, content]) => ({
|
|
272
|
+
path: filePath,
|
|
273
|
+
content,
|
|
274
|
+
})).sort((a, b) => a.path.localeCompare(b.path));
|
|
275
|
+
const hash = createHash('sha256');
|
|
276
|
+
for (const file of projectedFiles) {
|
|
277
|
+
hash.update(String(Buffer.byteLength(file.path)));
|
|
278
|
+
hash.update(':');
|
|
279
|
+
hash.update(file.path);
|
|
280
|
+
hash.update(String(Buffer.byteLength(file.content)));
|
|
281
|
+
hash.update(':');
|
|
282
|
+
hash.update(file.content);
|
|
283
|
+
}
|
|
284
|
+
return { name, hash: hash.digest('hex'), files: projectedFiles };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function readSkillsManifest({
|
|
288
|
+
sandbox,
|
|
289
|
+
manifestPath,
|
|
290
|
+
abortSignal,
|
|
291
|
+
}: {
|
|
292
|
+
sandbox: Experimental_SandboxSession;
|
|
293
|
+
manifestPath: string;
|
|
294
|
+
abortSignal?: AbortSignal;
|
|
295
|
+
}): Promise<SkillsManifest | undefined> {
|
|
296
|
+
const content = await sandbox.readTextFile({
|
|
297
|
+
path: manifestPath,
|
|
298
|
+
abortSignal,
|
|
299
|
+
});
|
|
300
|
+
if (content == null) return undefined;
|
|
301
|
+
const parsed = await safeParseJSON({ text: content });
|
|
302
|
+
if (!parsed.success || !isSkillsManifest(parsed.value)) {
|
|
303
|
+
throw new Error(`Invalid AI SDK harness skills manifest: ${manifestPath}`);
|
|
304
|
+
}
|
|
305
|
+
return parsed.value;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isSkillsManifest(value: unknown): value is SkillsManifest {
|
|
309
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
const manifest = value as Record<string, unknown>;
|
|
313
|
+
if (
|
|
314
|
+
manifest.version !== SKILLS_MANIFEST_VERSION ||
|
|
315
|
+
(manifest.state !== 'complete' && manifest.state !== 'pending') ||
|
|
316
|
+
!Array.isArray(manifest.skills)
|
|
317
|
+
) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
const names = new Set<string>();
|
|
321
|
+
for (const entry of manifest.skills) {
|
|
322
|
+
if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
323
|
+
return false;
|
|
48
324
|
}
|
|
325
|
+
const skill = entry as Record<string, unknown>;
|
|
326
|
+
if (
|
|
327
|
+
typeof skill.name !== 'string' ||
|
|
328
|
+
!isSafeManifestSkillName(skill.name) ||
|
|
329
|
+
typeof skill.hash !== 'string' ||
|
|
330
|
+
!/^[a-f0-9]{64}$/.test(skill.hash) ||
|
|
331
|
+
names.has(skill.name)
|
|
332
|
+
) {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
names.add(skill.name);
|
|
49
336
|
}
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
50
339
|
|
|
51
|
-
|
|
52
|
-
|
|
340
|
+
async function writeSkillsManifest({
|
|
341
|
+
sandbox,
|
|
342
|
+
manifestPath,
|
|
343
|
+
manifest,
|
|
344
|
+
abortSignal,
|
|
345
|
+
}: {
|
|
346
|
+
sandbox: Experimental_SandboxSession;
|
|
347
|
+
manifestPath: string;
|
|
348
|
+
manifest: SkillsManifest;
|
|
349
|
+
abortSignal?: AbortSignal;
|
|
350
|
+
}): Promise<void> {
|
|
351
|
+
const temporaryPath = `${manifestPath}.tmp`;
|
|
352
|
+
await sandbox.writeTextFile({
|
|
353
|
+
path: temporaryPath,
|
|
354
|
+
content: `${JSON.stringify(manifest, null, 2)}\n`,
|
|
355
|
+
abortSignal,
|
|
356
|
+
});
|
|
357
|
+
await runSandboxCommand({
|
|
358
|
+
sandbox,
|
|
359
|
+
command: `mv -f ${shellQuote(temporaryPath)} ${shellQuote(manifestPath)}`,
|
|
53
360
|
abortSignal,
|
|
361
|
+
errorMessage: `Failed to update skills manifest: ${manifestPath}`,
|
|
54
362
|
});
|
|
363
|
+
}
|
|
55
364
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
365
|
+
async function assertSkillDirectoryAvailable({
|
|
366
|
+
sandbox,
|
|
367
|
+
rootDir,
|
|
368
|
+
skillName,
|
|
369
|
+
abortSignal,
|
|
370
|
+
}: {
|
|
371
|
+
sandbox: Experimental_SandboxSession;
|
|
372
|
+
rootDir: string;
|
|
373
|
+
skillName: string;
|
|
374
|
+
abortSignal?: AbortSignal;
|
|
375
|
+
}): Promise<void> {
|
|
376
|
+
const skillDir = path.posix.join(rootDir, skillName);
|
|
377
|
+
const result = await sandbox.run({
|
|
378
|
+
command: `test ! -e ${shellQuote(skillDir)}`,
|
|
379
|
+
abortSignal,
|
|
380
|
+
});
|
|
381
|
+
if (result.exitCode !== 0) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`Cannot write harness skill '${skillName}': ${skillDir} already exists and is not owned by the AI SDK harness.`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
68
387
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
388
|
+
async function removeSkillDirectories({
|
|
389
|
+
sandbox,
|
|
390
|
+
rootDir,
|
|
391
|
+
skillNames,
|
|
392
|
+
abortSignal,
|
|
393
|
+
}: {
|
|
394
|
+
sandbox: Experimental_SandboxSession;
|
|
395
|
+
rootDir: string;
|
|
396
|
+
skillNames: ReadonlyArray<string>;
|
|
397
|
+
abortSignal?: AbortSignal;
|
|
398
|
+
}): Promise<void> {
|
|
399
|
+
if (skillNames.length === 0) return;
|
|
400
|
+
const directories = Array.from(new Set(skillNames))
|
|
401
|
+
.sort((a, b) => a.localeCompare(b))
|
|
402
|
+
.map(name => shellQuote(path.posix.join(rootDir, name)))
|
|
403
|
+
.join(' ');
|
|
404
|
+
await runSandboxCommand({
|
|
405
|
+
sandbox,
|
|
406
|
+
command: `rm -rf -- ${directories}`,
|
|
407
|
+
abortSignal,
|
|
408
|
+
errorMessage: `Failed to replace harness skills in: ${rootDir}`,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function writeProjectedSkills({
|
|
413
|
+
sandbox,
|
|
414
|
+
rootDir,
|
|
415
|
+
skills,
|
|
416
|
+
abortSignal,
|
|
417
|
+
}: {
|
|
418
|
+
sandbox: Experimental_SandboxSession;
|
|
419
|
+
rootDir: string;
|
|
420
|
+
skills: ReadonlyArray<ProjectedSkill>;
|
|
421
|
+
abortSignal?: AbortSignal;
|
|
422
|
+
}): Promise<void> {
|
|
423
|
+
for (const skill of skills) {
|
|
424
|
+
const skillDir = path.posix.join(rootDir, skill.name);
|
|
425
|
+
for (const file of skill.files) {
|
|
76
426
|
await sandbox.writeTextFile({
|
|
77
|
-
path: path.posix.join(skillDir,
|
|
427
|
+
path: path.posix.join(skillDir, file.path),
|
|
78
428
|
content: file.content,
|
|
79
429
|
abortSignal,
|
|
80
430
|
});
|
|
@@ -82,6 +432,40 @@ export async function writeSkills({
|
|
|
82
432
|
}
|
|
83
433
|
}
|
|
84
434
|
|
|
435
|
+
async function runSandboxCommand({
|
|
436
|
+
sandbox,
|
|
437
|
+
command,
|
|
438
|
+
abortSignal,
|
|
439
|
+
errorMessage,
|
|
440
|
+
}: {
|
|
441
|
+
sandbox: Experimental_SandboxSession;
|
|
442
|
+
command: string;
|
|
443
|
+
abortSignal?: AbortSignal;
|
|
444
|
+
errorMessage: string;
|
|
445
|
+
}): Promise<void> {
|
|
446
|
+
const result = await sandbox.run({ command, abortSignal });
|
|
447
|
+
if (result.exitCode !== 0) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`${errorMessage} (exit ${result.exitCode})${result.stderr ? `: ${result.stderr}` : ''}`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function assertUniqueSkillNames(skills: ReadonlyArray<ProjectedSkill>): void {
|
|
455
|
+
for (let index = 1; index < skills.length; index++) {
|
|
456
|
+
if (skills[index - 1]!.name === skills[index]!.name) {
|
|
457
|
+
throw new Error(`Duplicate skill name: ${skills[index]!.name}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function isSafeManifestSkillName(name: string): boolean {
|
|
463
|
+
SAFE_MANIFEST_SKILL_NAME.lastIndex = 0;
|
|
464
|
+
const matches = SAFE_MANIFEST_SKILL_NAME.test(name);
|
|
465
|
+
SAFE_MANIFEST_SKILL_NAME.lastIndex = 0;
|
|
466
|
+
return matches && name !== '.' && name !== '..' && !name.includes('/');
|
|
467
|
+
}
|
|
468
|
+
|
|
85
469
|
function validateSkillName({
|
|
86
470
|
name,
|
|
87
471
|
pattern,
|
|
@@ -91,7 +475,10 @@ function validateSkillName({
|
|
|
91
475
|
pattern: RegExp;
|
|
92
476
|
message?: (input: { name: string }) => string;
|
|
93
477
|
}): string {
|
|
94
|
-
|
|
478
|
+
pattern.lastIndex = 0;
|
|
479
|
+
const matches = pattern.test(name);
|
|
480
|
+
pattern.lastIndex = 0;
|
|
481
|
+
if (!matches || name === '.' || name === '..') {
|
|
95
482
|
throw new Error(message?.({ name }) ?? `Invalid skill name: ${name}`);
|
|
96
483
|
}
|
|
97
484
|
return name;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication-related environment variables supplied directly to a harness.
|
|
3
|
+
*
|
|
4
|
+
* When used as an adapter's `auth` option, this environment replaces the host
|
|
5
|
+
* process environment for authentication discovery.
|
|
6
|
+
*/
|
|
7
|
+
export type HarnessV1AuthenticationEnvironment = Readonly<
|
|
8
|
+
Record<string, string>
|
|
9
|
+
>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Authentication options shared by harness adapters. Adapter choices must be
|
|
13
|
+
* a non-empty union of concrete string values.
|
|
14
|
+
*/
|
|
15
|
+
export type HarnessV1Authentication<ADAPTER_CHOICES extends string = 'direct'> =
|
|
16
|
+
[ADAPTER_CHOICES] extends [never]
|
|
17
|
+
? never
|
|
18
|
+
: string extends ADAPTER_CHOICES
|
|
19
|
+
? never
|
|
20
|
+
:
|
|
21
|
+
| 'auto'
|
|
22
|
+
| 'ai-gateway'
|
|
23
|
+
| ADAPTER_CHOICES
|
|
24
|
+
| HarnessV1AuthenticationEnvironment;
|