skilld-harness 3.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/_chunks/skills.mjs +87 -0
- package/dist/_chunks/skills.mjs.map +1 -0
- package/dist/index.d.mts +122 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1209 -0
- package/dist/index.mjs.map +1 -0
- package/dist/skills/generate-package-skill/SKILL.md +64 -0
- package/dist/skills/generate-package-skill/assets/harness-request.md +11 -0
- package/dist/skills/generate-project-skill/SKILL.md +62 -0
- package/dist/skills/generate-project-skill/assets/harness-request.md +11 -0
- package/dist/skills/harness-skills.json +5 -0
- package/dist/skills/review-skill/SKILL.md +28 -0
- package/dist/skills/review-skill/assets/harness-request.md +24 -0
- package/dist/skills/skilld/SKILL.md +67 -0
- package/dist/skills/skilld-maintained-skills.json +6 -0
- package/dist/skills.d.mts +14 -0
- package/dist/skills.d.mts.map +1 -0
- package/dist/skills.mjs +2 -0
- package/package.json +76 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
import { DEFAULT_OUTPUT_POLICY, isSkillName, loadSkilldMaintainedSkill, normalizeOutputPath, resolveWithin } from "./_chunks/skills.mjs";
|
|
2
|
+
import { lstat, mkdir, open, opendir, realpath, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { HarnessAgent } from "@ai-sdk/harness/agent";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { parseDocument } from "yaml";
|
|
7
|
+
import { constants } from "node:fs";
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
import { createGunzip } from "node:zlib";
|
|
10
|
+
const ok = (value) => ({
|
|
11
|
+
_tag: "Ok",
|
|
12
|
+
value
|
|
13
|
+
});
|
|
14
|
+
const err = (error) => ({
|
|
15
|
+
_tag: "Err",
|
|
16
|
+
error
|
|
17
|
+
});
|
|
18
|
+
function invalid$2(message) {
|
|
19
|
+
return err({
|
|
20
|
+
_tag: "InvalidInput",
|
|
21
|
+
message
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function isNonEmptyString(value) {
|
|
25
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
26
|
+
}
|
|
27
|
+
function parseSkillRun(input) {
|
|
28
|
+
if (!input || typeof input !== "object" || !("_tag" in input)) return invalid$2("Skill run input must be an object with a tag.");
|
|
29
|
+
if (input._tag === "PackageSkill") {
|
|
30
|
+
if (!input.source || !input.destination) return invalid$2("Package Skill input is incomplete.");
|
|
31
|
+
if (!isNonEmptyString(input.destination.rootDir) || !isSkillName(input.destination.name)) return invalid$2("Package Skill destination is invalid.");
|
|
32
|
+
if (input.source._tag === "NpmPackage") {
|
|
33
|
+
if (!isNonEmptyString(input.source.spec)) return invalid$2("npm package spec is required.");
|
|
34
|
+
return ok({
|
|
35
|
+
_tag: "PackageSkill",
|
|
36
|
+
source: {
|
|
37
|
+
_tag: "NpmPackage",
|
|
38
|
+
spec: input.source.spec
|
|
39
|
+
},
|
|
40
|
+
destination: {
|
|
41
|
+
rootDir: resolve(input.destination.rootDir),
|
|
42
|
+
name: input.destination.name
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (input.source._tag === "LocalPackage") {
|
|
47
|
+
if (!isNonEmptyString(input.source.rootDir) || !isNonEmptyString(input.source.packageDir)) return invalid$2("Local package paths are required.");
|
|
48
|
+
if (resolveWithin(input.source.rootDir, input.source.packageDir) === null) return invalid$2("Local package directory must stay inside its root directory.");
|
|
49
|
+
return ok({
|
|
50
|
+
_tag: "PackageSkill",
|
|
51
|
+
source: {
|
|
52
|
+
_tag: "LocalPackage",
|
|
53
|
+
rootDir: resolve(input.source.rootDir),
|
|
54
|
+
packageDir: input.source.packageDir
|
|
55
|
+
},
|
|
56
|
+
destination: {
|
|
57
|
+
rootDir: resolve(input.destination.rootDir),
|
|
58
|
+
name: input.destination.name
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return invalid$2("Package source tag is invalid.");
|
|
63
|
+
}
|
|
64
|
+
if (input._tag === "ProjectSkill") {
|
|
65
|
+
if (!isNonEmptyString(input.projectDir) || !input.destination) return invalid$2("Project Skill input is incomplete.");
|
|
66
|
+
if (!isNonEmptyString(input.destination.rootDir) || !isSkillName(input.destination.name)) return invalid$2("Project Skill destination is invalid.");
|
|
67
|
+
return ok({
|
|
68
|
+
_tag: "ProjectSkill",
|
|
69
|
+
projectDir: resolve(input.projectDir),
|
|
70
|
+
destination: {
|
|
71
|
+
rootDir: resolve(input.destination.rootDir),
|
|
72
|
+
name: input.destination.name
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (input._tag === "ReviewSkill") {
|
|
77
|
+
if (!isNonEmptyString(input.skillDir)) return invalid$2("Skill review directory is required.");
|
|
78
|
+
return ok({
|
|
79
|
+
_tag: "ReviewSkill",
|
|
80
|
+
skillDir: resolve(input.skillDir)
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return invalid$2("Skill run tag is invalid.");
|
|
84
|
+
}
|
|
85
|
+
function parseOutputPolicy(policy) {
|
|
86
|
+
for (const [name, value] of Object.entries(policy)) if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive integer.`);
|
|
87
|
+
return Object.freeze(policy);
|
|
88
|
+
}
|
|
89
|
+
const invalid$1 = (message, issues) => err({
|
|
90
|
+
_tag: "InvalidSkill",
|
|
91
|
+
message,
|
|
92
|
+
issues
|
|
93
|
+
});
|
|
94
|
+
const inventory = async (sandbox, outputDir, signal) => {
|
|
95
|
+
if (signal?.aborted) return err({
|
|
96
|
+
_tag: "Cancelled",
|
|
97
|
+
message: "Skill run was cancelled."
|
|
98
|
+
});
|
|
99
|
+
const command = "find -P \"$SKILLD_OUTPUT\" -mindepth 1 -printf '%y\\0%P\\0%s\\0'";
|
|
100
|
+
const result = await Promise.resolve().then(() => sandbox.run({
|
|
101
|
+
command,
|
|
102
|
+
env: {
|
|
103
|
+
LC_ALL: "C",
|
|
104
|
+
SKILLD_OUTPUT: outputDir
|
|
105
|
+
},
|
|
106
|
+
abortSignal: signal
|
|
107
|
+
})).catch((error) => error);
|
|
108
|
+
if (result instanceof Error) return invalid$1("Harness output cannot be inspected.", [result.message]);
|
|
109
|
+
if (result.exitCode !== 0) return invalid$1("Harness output directory is missing.", [result.stderr.trim() || "No output directory."]);
|
|
110
|
+
const fields = result.stdout.split("\0");
|
|
111
|
+
if (fields.at(-1) === "") fields.pop();
|
|
112
|
+
if (fields.length % 3 !== 0) return invalid$1("Harness output inventory is invalid.", ["File inventory has an incomplete entry."]);
|
|
113
|
+
const entries = [];
|
|
114
|
+
for (let index = 0; index < fields.length; index += 3) {
|
|
115
|
+
const type = fields[index];
|
|
116
|
+
const path = fields[index + 1];
|
|
117
|
+
const size = Number(fields[index + 2]);
|
|
118
|
+
if (!type || !path || !Number.isSafeInteger(size) || size < 0) return invalid$1("Harness output inventory is invalid.", ["File inventory has invalid fields."]);
|
|
119
|
+
entries.push({
|
|
120
|
+
type,
|
|
121
|
+
path,
|
|
122
|
+
size
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return ok(entries);
|
|
126
|
+
};
|
|
127
|
+
const collectSandboxOutput = async (sandbox, outputDir, policy, signal) => {
|
|
128
|
+
const before = await inventory(sandbox, outputDir, signal);
|
|
129
|
+
if (before._tag === "Err") return before;
|
|
130
|
+
const files = [];
|
|
131
|
+
const seen = /* @__PURE__ */ new Set();
|
|
132
|
+
let totalBytes = 0;
|
|
133
|
+
for (const entry of before.value) {
|
|
134
|
+
if (signal?.aborted) return err({
|
|
135
|
+
_tag: "Cancelled",
|
|
136
|
+
message: "Skill run was cancelled."
|
|
137
|
+
});
|
|
138
|
+
const path = normalizeOutputPath(entry.path);
|
|
139
|
+
if (path === null) return invalid$1("Harness output contains an invalid path.", [entry.path]);
|
|
140
|
+
if (entry.type === "d") continue;
|
|
141
|
+
if (entry.type !== "f") return invalid$1("Harness output contains a linked or special entry.", [path]);
|
|
142
|
+
if (seen.has(path)) return invalid$1("Harness output contains duplicate paths.", [path]);
|
|
143
|
+
if (files.length >= policy.maxOutputFiles) return invalid$1("Harness output contains too many files.", [`Limit: ${policy.maxOutputFiles}`]);
|
|
144
|
+
if (entry.size > policy.maxOutputFileBytes) return invalid$1("Harness output contains an oversized file.", [path]);
|
|
145
|
+
if (totalBytes + entry.size > policy.maxOutputBytes) return invalid$1("Harness output exceeds the total byte limit.", [`Limit: ${policy.maxOutputBytes}`]);
|
|
146
|
+
const content = await Promise.resolve().then(() => sandbox.readBinaryFile({
|
|
147
|
+
path: `${outputDir}/${path}`,
|
|
148
|
+
abortSignal: signal
|
|
149
|
+
})).catch((error) => error);
|
|
150
|
+
if (content instanceof Error) {
|
|
151
|
+
if (signal?.aborted) return err({
|
|
152
|
+
_tag: "Cancelled",
|
|
153
|
+
message: "Skill run was cancelled."
|
|
154
|
+
});
|
|
155
|
+
return invalid$1("Harness output cannot be read.", [path, content.message]);
|
|
156
|
+
}
|
|
157
|
+
if (content === null || content.byteLength !== entry.size) return invalid$1("Harness output changed during collection.", [path]);
|
|
158
|
+
seen.add(path);
|
|
159
|
+
files.push({
|
|
160
|
+
path,
|
|
161
|
+
content: Uint8Array.from(content)
|
|
162
|
+
});
|
|
163
|
+
totalBytes += content.byteLength;
|
|
164
|
+
}
|
|
165
|
+
const after = await inventory(sandbox, outputDir, signal);
|
|
166
|
+
if (after._tag === "Err") return after;
|
|
167
|
+
if (JSON.stringify(before.value) !== JSON.stringify(after.value)) return invalid$1("Harness output changed during collection.", ["File inventory changed."]);
|
|
168
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
169
|
+
return ok(files);
|
|
170
|
+
};
|
|
171
|
+
function pathError(message, path) {
|
|
172
|
+
return err({
|
|
173
|
+
_tag: "UnsafeOutputPath",
|
|
174
|
+
message,
|
|
175
|
+
path
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const missing = Symbol("missing");
|
|
179
|
+
async function statOrMissing(path) {
|
|
180
|
+
return lstat(path).catch((error) => error.code === "ENOENT" ? missing : Promise.reject(error));
|
|
181
|
+
}
|
|
182
|
+
const promoteSkill = async (rootDir, name, files, attempts) => {
|
|
183
|
+
const root = resolve(rootDir);
|
|
184
|
+
let rootStat = await statOrMissing(root).catch((error) => error);
|
|
185
|
+
if (rootStat instanceof Error) return pathError("Output root cannot be inspected.", root);
|
|
186
|
+
let createdRoot = false;
|
|
187
|
+
if (rootStat === missing) {
|
|
188
|
+
const parent = dirname(root);
|
|
189
|
+
const parentStat = await statOrMissing(parent).catch((error) => error);
|
|
190
|
+
if (parentStat instanceof Error || parentStat === missing || !parentStat.isDirectory() || parentStat.isSymbolicLink()) return pathError("Output root parent must be an existing directory.", parent);
|
|
191
|
+
const canonicalParent = await realpath(parent).catch((error) => error);
|
|
192
|
+
if (canonicalParent instanceof Error || canonicalParent !== parent) return pathError("Output root parent must not pass through a symbolic link.", parent);
|
|
193
|
+
const created = await mkdir(root, { mode: 448 }).then(() => true, (error) => error);
|
|
194
|
+
if (created instanceof Error && created.code !== "EEXIST") return pathError("Output root cannot be created.", root);
|
|
195
|
+
createdRoot = created === true;
|
|
196
|
+
rootStat = await statOrMissing(root).catch((error) => error);
|
|
197
|
+
}
|
|
198
|
+
if (rootStat instanceof Error || rootStat === missing || !rootStat.isDirectory() || rootStat.isSymbolicLink()) return pathError("Output root must be a directory, not a symbolic link.", root);
|
|
199
|
+
const canonicalRoot = await realpath(root).catch((error) => error);
|
|
200
|
+
if (canonicalRoot instanceof Error) return pathError("Output root cannot be resolved.", root);
|
|
201
|
+
if (canonicalRoot !== root) return pathError("Output root must not pass through a symbolic link.", root);
|
|
202
|
+
const target = join(root, name);
|
|
203
|
+
const targetStat = await statOrMissing(target).catch((error) => error);
|
|
204
|
+
if (targetStat instanceof Error) return pathError("Output path cannot be inspected.", target);
|
|
205
|
+
if (targetStat !== missing && (!targetStat.isDirectory() || targetStat.isSymbolicLink())) return pathError("Output path must be a directory, not a symbolic link.", target);
|
|
206
|
+
const lockPath = join(root, `.skilld-${name}.lock`);
|
|
207
|
+
const lock = await open(lockPath, "wx").catch((error) => error);
|
|
208
|
+
if (lock instanceof Error) {
|
|
209
|
+
if (lock.code === "EEXIST") return err({
|
|
210
|
+
_tag: "OutputBusy",
|
|
211
|
+
message: "Another Skill run owns this output path.",
|
|
212
|
+
path: target
|
|
213
|
+
});
|
|
214
|
+
return err({
|
|
215
|
+
_tag: "PromotionFailed",
|
|
216
|
+
message: "Output lock could not be created.",
|
|
217
|
+
path: target,
|
|
218
|
+
cause: lock
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const nonce = randomUUID();
|
|
222
|
+
const staging = join(root, `.skilld-${name}-${nonce}.next`);
|
|
223
|
+
const backup = join(root, `.skilld-${name}-${nonce}.previous`);
|
|
224
|
+
let movedCurrent = false;
|
|
225
|
+
let promoted = false;
|
|
226
|
+
let failureMessage = "Skill output could not be promoted.";
|
|
227
|
+
let failureCause;
|
|
228
|
+
try {
|
|
229
|
+
await mkdir(staging, { mode: 448 });
|
|
230
|
+
for (const file of files) {
|
|
231
|
+
const destination = join(staging, file.path);
|
|
232
|
+
await mkdir(dirname(destination), {
|
|
233
|
+
recursive: true,
|
|
234
|
+
mode: 448
|
|
235
|
+
});
|
|
236
|
+
await writeFile(destination, file.content, {
|
|
237
|
+
flag: "wx",
|
|
238
|
+
mode: 384
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (targetStat !== missing) {
|
|
242
|
+
await rename(target, backup);
|
|
243
|
+
movedCurrent = true;
|
|
244
|
+
}
|
|
245
|
+
await rename(staging, target);
|
|
246
|
+
promoted = true;
|
|
247
|
+
} catch (cause) {
|
|
248
|
+
failureCause = cause;
|
|
249
|
+
if (movedCurrent && !promoted) {
|
|
250
|
+
const current = await statOrMissing(target).catch((error) => error);
|
|
251
|
+
if (current instanceof Error) {
|
|
252
|
+
failureMessage = "Skill output rollback could not inspect its destination.";
|
|
253
|
+
failureCause = new AggregateError([cause, current]);
|
|
254
|
+
}
|
|
255
|
+
if (current === missing) {
|
|
256
|
+
const rollback = await rename(backup, target).catch((error) => error);
|
|
257
|
+
if (rollback instanceof Error) {
|
|
258
|
+
failureMessage = "Skill output rollback failed.";
|
|
259
|
+
failureCause = new AggregateError([cause, rollback]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const lockCleanupErrors = [];
|
|
265
|
+
await lock.close().catch((error) => lockCleanupErrors.push(error));
|
|
266
|
+
await unlink(lockPath).catch((error) => lockCleanupErrors.push(error));
|
|
267
|
+
if (promoted) {
|
|
268
|
+
const warnings = [];
|
|
269
|
+
if (lockCleanupErrors.length > 0) {
|
|
270
|
+
const detail = lockCleanupErrors.map((error) => error instanceof Error ? error.message : String(error)).join("; ");
|
|
271
|
+
warnings.push(`Output lock cleanup failed at ${lockPath}: ${detail}`);
|
|
272
|
+
}
|
|
273
|
+
if (movedCurrent) {
|
|
274
|
+
const backupCleanup = await rm(backup, { recursive: true }).catch((error) => error);
|
|
275
|
+
if (backupCleanup instanceof Error) warnings.push(`Previous Skill cleanup failed at ${backup}: ${backupCleanup.message}`);
|
|
276
|
+
}
|
|
277
|
+
return ok({
|
|
278
|
+
_tag: "GeneratedSkill",
|
|
279
|
+
name,
|
|
280
|
+
outputDir: target,
|
|
281
|
+
files: files.map((file) => ({
|
|
282
|
+
path: file.path,
|
|
283
|
+
bytes: file.content.byteLength
|
|
284
|
+
})),
|
|
285
|
+
sourceAttempts: attempts,
|
|
286
|
+
warnings
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
const cleanupErrors = [...lockCleanupErrors];
|
|
290
|
+
await rm(staging, {
|
|
291
|
+
recursive: true,
|
|
292
|
+
force: true
|
|
293
|
+
}).catch((error) => cleanupErrors.push(error));
|
|
294
|
+
if (createdRoot) await rmdir(root).catch((error) => cleanupErrors.push(error));
|
|
295
|
+
const causes = failureCause === void 0 ? cleanupErrors : [failureCause, ...cleanupErrors];
|
|
296
|
+
const cause = causes.length <= 1 ? causes[0] : new AggregateError(causes, "Skill output cleanup failed.");
|
|
297
|
+
return err({
|
|
298
|
+
_tag: "PromotionFailed",
|
|
299
|
+
message: failureMessage,
|
|
300
|
+
path: target,
|
|
301
|
+
cause
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
const allowedFrontmatter = /* @__PURE__ */ new Set([
|
|
305
|
+
"name",
|
|
306
|
+
"description",
|
|
307
|
+
"license",
|
|
308
|
+
"compatibility",
|
|
309
|
+
"metadata",
|
|
310
|
+
"allowed-tools"
|
|
311
|
+
]);
|
|
312
|
+
const invalid = (issues) => err({
|
|
313
|
+
_tag: "InvalidSkill",
|
|
314
|
+
message: "Skill output failed deterministic checks.",
|
|
315
|
+
issues
|
|
316
|
+
});
|
|
317
|
+
function decodeText(content) {
|
|
318
|
+
try {
|
|
319
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
320
|
+
} catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const isStringMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
|
|
325
|
+
const validateGeneratedSkill = (name, files) => {
|
|
326
|
+
const issues = [];
|
|
327
|
+
const skillFiles = files.filter((file) => file.path === "SKILL.md");
|
|
328
|
+
if (skillFiles.length !== 1) return invalid(["Output must contain exactly one SKILL.md file."]);
|
|
329
|
+
const source = decodeText(skillFiles[0].content);
|
|
330
|
+
if (source === null) return invalid(["SKILL.md must contain valid UTF-8 text."]);
|
|
331
|
+
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
332
|
+
if (!match) return invalid(["SKILL.md must start with YAML frontmatter."]);
|
|
333
|
+
if (match[2]?.trim().length === 0) issues.push("SKILL.md instructions must not be empty.");
|
|
334
|
+
const document = parseDocument(match[1], { uniqueKeys: true });
|
|
335
|
+
for (const error of document.errors) issues.push(`Frontmatter: ${error.message}`);
|
|
336
|
+
for (const warning of document.warnings) issues.push(`Frontmatter: ${warning.message}`);
|
|
337
|
+
const value = document.errors.length === 0 ? document.toJS() : null;
|
|
338
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
339
|
+
issues.push("Frontmatter must be a mapping.");
|
|
340
|
+
return invalid(issues);
|
|
341
|
+
}
|
|
342
|
+
const frontmatter = value;
|
|
343
|
+
for (const key of Object.keys(frontmatter)) if (!allowedFrontmatter.has(key)) issues.push(`Frontmatter field is not supported: ${key}`);
|
|
344
|
+
if (frontmatter.name !== name) issues.push("Frontmatter name must match the Skill directory name.");
|
|
345
|
+
if (typeof frontmatter.name !== "string" || !isSkillName(frontmatter.name)) issues.push("Frontmatter name is invalid.");
|
|
346
|
+
if (typeof frontmatter.description !== "string" || frontmatter.description.trim().length === 0 || frontmatter.description.length > 1024) issues.push("Frontmatter description must contain 1 to 1024 characters.");
|
|
347
|
+
if (frontmatter.license !== void 0 && typeof frontmatter.license !== "string") issues.push("Frontmatter license must be a string.");
|
|
348
|
+
if (frontmatter.compatibility !== void 0 && (typeof frontmatter.compatibility !== "string" || frontmatter.compatibility.length > 500)) issues.push("Frontmatter compatibility must be a string of at most 500 characters.");
|
|
349
|
+
if (frontmatter.metadata !== void 0 && !isStringMap(frontmatter.metadata)) issues.push("Frontmatter metadata must map strings to strings.");
|
|
350
|
+
if (frontmatter["allowed-tools"] !== void 0 && typeof frontmatter["allowed-tools"] !== "string") issues.push("Frontmatter allowed-tools must be a string.");
|
|
351
|
+
return issues.length === 0 ? ok(void 0) : invalid(issues);
|
|
352
|
+
};
|
|
353
|
+
const findingLevels = /* @__PURE__ */ new Set([
|
|
354
|
+
"error",
|
|
355
|
+
"warning",
|
|
356
|
+
"note"
|
|
357
|
+
]);
|
|
358
|
+
const validateSkillReview = (files) => {
|
|
359
|
+
if (files.length !== 1 || files[0]?.path !== "review.json") return invalid(["Review output must contain only review.json."]);
|
|
360
|
+
let value;
|
|
361
|
+
try {
|
|
362
|
+
const source = decodeText(files[0].content);
|
|
363
|
+
if (source === null) return invalid(["review.json must contain valid UTF-8 text."]);
|
|
364
|
+
value = JSON.parse(source);
|
|
365
|
+
} catch {
|
|
366
|
+
return invalid(["review.json must contain valid JSON."]);
|
|
367
|
+
}
|
|
368
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(["review.json must contain an object."]);
|
|
369
|
+
const review = value;
|
|
370
|
+
if (Object.keys(review).some((key) => key !== "summary" && key !== "findings")) return invalid(["review.json contains unsupported fields."]);
|
|
371
|
+
if (typeof review.summary !== "string" || review.summary.trim().length === 0) return invalid(["Review summary is required."]);
|
|
372
|
+
if (!Array.isArray(review.findings)) return invalid(["Review findings must be an array."]);
|
|
373
|
+
const findings = [];
|
|
374
|
+
for (const item of review.findings) {
|
|
375
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return invalid(["Each review finding must be an object."]);
|
|
376
|
+
const finding = item;
|
|
377
|
+
if (Object.keys(finding).some((key) => ![
|
|
378
|
+
"level",
|
|
379
|
+
"path",
|
|
380
|
+
"message",
|
|
381
|
+
"fix"
|
|
382
|
+
].includes(key))) return invalid(["A review finding contains unsupported fields."]);
|
|
383
|
+
if (typeof finding.level !== "string" || !findingLevels.has(finding.level)) return invalid(["A review finding level is invalid."]);
|
|
384
|
+
if (typeof finding.path !== "string" || normalizeOutputPath(finding.path) === null) return invalid(["A review finding path is invalid."]);
|
|
385
|
+
if (typeof finding.message !== "string" || finding.message.trim().length === 0 || typeof finding.fix !== "string" || finding.fix.trim().length === 0) return invalid(["A review finding is incomplete."]);
|
|
386
|
+
findings.push(finding);
|
|
387
|
+
}
|
|
388
|
+
return ok({
|
|
389
|
+
_tag: "SkillReview",
|
|
390
|
+
summary: review.summary,
|
|
391
|
+
findings
|
|
392
|
+
});
|
|
393
|
+
};
|
|
394
|
+
const ignoredNames = /* @__PURE__ */ new Set([
|
|
395
|
+
".git",
|
|
396
|
+
".hg",
|
|
397
|
+
".next",
|
|
398
|
+
".nuxt",
|
|
399
|
+
".output",
|
|
400
|
+
".skilld",
|
|
401
|
+
".turbo",
|
|
402
|
+
"coverage",
|
|
403
|
+
"dist",
|
|
404
|
+
"node_modules",
|
|
405
|
+
"target"
|
|
406
|
+
]);
|
|
407
|
+
async function collectHostDirectory(directory, policy, sourceLabel = directory, signal) {
|
|
408
|
+
const root = resolve(directory);
|
|
409
|
+
const unavailable = (message, cause) => err({
|
|
410
|
+
_tag: "SourceUnavailable",
|
|
411
|
+
message,
|
|
412
|
+
attempts: [{
|
|
413
|
+
source: sourceLabel,
|
|
414
|
+
status: "skipped",
|
|
415
|
+
reason: message
|
|
416
|
+
}],
|
|
417
|
+
cause
|
|
418
|
+
});
|
|
419
|
+
const cancelled = () => err({
|
|
420
|
+
_tag: "Cancelled",
|
|
421
|
+
message: "Skill run was cancelled."
|
|
422
|
+
});
|
|
423
|
+
if (signal?.aborted) return cancelled();
|
|
424
|
+
const rootStat = await lstat(root).catch((error) => error);
|
|
425
|
+
if (rootStat instanceof Error) return unavailable("Source directory is unavailable.", rootStat);
|
|
426
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return unavailable("Source path must be a directory, not a symbolic link.");
|
|
427
|
+
const canonicalRoot = await realpath(root).catch((error) => error);
|
|
428
|
+
if (canonicalRoot instanceof Error) return unavailable("Source directory cannot be resolved.", canonicalRoot);
|
|
429
|
+
if (canonicalRoot !== root) return unavailable("Source path must not pass through a symbolic link.");
|
|
430
|
+
const files = [];
|
|
431
|
+
let totalBytes = 0;
|
|
432
|
+
const walk = async (current) => {
|
|
433
|
+
if (signal?.aborted) return cancelled();
|
|
434
|
+
const canonicalCurrent = await realpath(current).catch((error) => error);
|
|
435
|
+
if (canonicalCurrent instanceof Error || canonicalCurrent !== current) return unavailable("Source directory changed during collection.", canonicalCurrent instanceof Error ? canonicalCurrent : void 0);
|
|
436
|
+
const directoryHandle = await opendir(current).catch((error) => error);
|
|
437
|
+
if (directoryHandle instanceof Error) return unavailable("Source directory cannot be read.", directoryHandle);
|
|
438
|
+
for await (const entry of directoryHandle) {
|
|
439
|
+
if (signal?.aborted) return cancelled();
|
|
440
|
+
if (ignoredNames.has(entry.name)) continue;
|
|
441
|
+
const absolute = join(current, entry.name);
|
|
442
|
+
const stat = await lstat(absolute).catch((error) => error);
|
|
443
|
+
if (stat instanceof Error) return unavailable("Source entry cannot be read.", stat);
|
|
444
|
+
if (stat.isSymbolicLink()) return unavailable("Source contains a symbolic link.");
|
|
445
|
+
if (stat.isDirectory()) {
|
|
446
|
+
const nested = await walk(absolute);
|
|
447
|
+
if (nested._tag === "Err") return nested;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (!stat.isFile()) return unavailable("Source contains a special file.");
|
|
451
|
+
const path = relative(root, absolute).split(sep).join("/");
|
|
452
|
+
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW).catch((error) => error);
|
|
453
|
+
if (handle instanceof Error) return unavailable("Source file cannot be opened without following links.", handle);
|
|
454
|
+
const openedStat = await handle.stat().catch((error) => error);
|
|
455
|
+
if (openedStat instanceof Error) {
|
|
456
|
+
await handle.close();
|
|
457
|
+
return unavailable("Source file cannot be inspected.", openedStat);
|
|
458
|
+
}
|
|
459
|
+
if (!openedStat.isFile() || openedStat.dev !== stat.dev || openedStat.ino !== stat.ino) {
|
|
460
|
+
await handle.close();
|
|
461
|
+
return unavailable("Source file changed during collection.");
|
|
462
|
+
}
|
|
463
|
+
if (openedStat.size > policy.maxSourceFileBytes) {
|
|
464
|
+
await handle.close();
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (files.length >= policy.maxSourceFiles) {
|
|
468
|
+
await handle.close();
|
|
469
|
+
return unavailable("Source contains too many files.");
|
|
470
|
+
}
|
|
471
|
+
if (totalBytes + openedStat.size > policy.maxSourceBytes) {
|
|
472
|
+
await handle.close();
|
|
473
|
+
return unavailable("Source exceeds the total byte limit.");
|
|
474
|
+
}
|
|
475
|
+
const content = await handle.readFile().catch((error) => error);
|
|
476
|
+
await handle.close();
|
|
477
|
+
if (content instanceof Error) return unavailable("Source file cannot be read.", content);
|
|
478
|
+
if (content.byteLength !== openedStat.size) return unavailable("Source file changed during collection.");
|
|
479
|
+
if (signal?.aborted) return cancelled();
|
|
480
|
+
files.push({
|
|
481
|
+
path,
|
|
482
|
+
content
|
|
483
|
+
});
|
|
484
|
+
totalBytes += content.byteLength;
|
|
485
|
+
}
|
|
486
|
+
return ok(void 0);
|
|
487
|
+
};
|
|
488
|
+
const walked = await walk(root);
|
|
489
|
+
if (walked._tag === "Err") return walked;
|
|
490
|
+
if (files.length === 0) return unavailable("Source directory has no usable files.");
|
|
491
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
492
|
+
return ok({
|
|
493
|
+
files,
|
|
494
|
+
attempts: [{
|
|
495
|
+
source: sourceLabel,
|
|
496
|
+
status: "used"
|
|
497
|
+
}]
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
const blockBytes = 512;
|
|
501
|
+
function unavailable$1(message, cause) {
|
|
502
|
+
return err({
|
|
503
|
+
_tag: "SourceUnavailable",
|
|
504
|
+
message,
|
|
505
|
+
attempts: [],
|
|
506
|
+
cause
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
function readString(buffer, start, length) {
|
|
510
|
+
const bytes = buffer.subarray(start, start + length);
|
|
511
|
+
const zero = bytes.indexOf(0);
|
|
512
|
+
return Buffer.from(zero === -1 ? bytes : bytes.subarray(0, zero)).toString("utf8").trim();
|
|
513
|
+
}
|
|
514
|
+
function readOctal(buffer, start, length) {
|
|
515
|
+
const value = readString(buffer, start, length).replace(/^\s+|\s+$/g, "");
|
|
516
|
+
if (!/^[0-7]+$/.test(value)) return null;
|
|
517
|
+
const parsed = Number.parseInt(value, 8);
|
|
518
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
519
|
+
}
|
|
520
|
+
function hasValidChecksum(header) {
|
|
521
|
+
const expected = readOctal(header, 148, 8);
|
|
522
|
+
if (expected === null) return false;
|
|
523
|
+
let actual = 0;
|
|
524
|
+
for (let index = 0; index < header.byteLength; index += 1) actual += index >= 148 && index < 156 ? 32 : header[index];
|
|
525
|
+
return actual === expected;
|
|
526
|
+
}
|
|
527
|
+
function parsePaxPath(content) {
|
|
528
|
+
const text = Buffer.from(content).toString("utf8");
|
|
529
|
+
let offset = 0;
|
|
530
|
+
let path = null;
|
|
531
|
+
while (offset < text.length) {
|
|
532
|
+
const space = text.indexOf(" ", offset);
|
|
533
|
+
if (space === -1) return null;
|
|
534
|
+
const length = Number.parseInt(text.slice(offset, space), 10);
|
|
535
|
+
if (!Number.isSafeInteger(length) || length <= 0 || offset + length > text.length) return null;
|
|
536
|
+
const record = text.slice(space + 1, offset + length - 1);
|
|
537
|
+
const equals = record.indexOf("=");
|
|
538
|
+
if (equals !== -1 && record.slice(0, equals) === "path") path = record.slice(equals + 1);
|
|
539
|
+
offset += length;
|
|
540
|
+
}
|
|
541
|
+
return path;
|
|
542
|
+
}
|
|
543
|
+
function archivePath(value, layout) {
|
|
544
|
+
const normalized = normalizeOutputPath(value);
|
|
545
|
+
if (normalized === null) return null;
|
|
546
|
+
const segments = normalized.split("/");
|
|
547
|
+
if (layout === "npm") {
|
|
548
|
+
if (segments[0] !== "package" || segments.length < 2) return null;
|
|
549
|
+
} else if (segments.length < 2) return null;
|
|
550
|
+
return normalizeOutputPath(segments.slice(1).join("/"));
|
|
551
|
+
}
|
|
552
|
+
function entryContent(entry) {
|
|
553
|
+
return entry.chunks.length === 1 ? entry.chunks[0] : Buffer.concat(entry.chunks, entry.size);
|
|
554
|
+
}
|
|
555
|
+
async function extractArchive(compressed, policy, layout) {
|
|
556
|
+
const source = Readable.from(compressed);
|
|
557
|
+
const gunzip = createGunzip();
|
|
558
|
+
source.on("error", (error) => gunzip.destroy(error));
|
|
559
|
+
source.pipe(gunzip);
|
|
560
|
+
const files = [];
|
|
561
|
+
const seen = /* @__PURE__ */ new Set();
|
|
562
|
+
const maxArchiveBytes = policy.maxSourceBytes + (policy.maxSourceFiles + 2) * blockBytes * 2;
|
|
563
|
+
let archiveBytes = 0;
|
|
564
|
+
let fileEntries = 0;
|
|
565
|
+
let nextPath = null;
|
|
566
|
+
let pending = Buffer.alloc(0);
|
|
567
|
+
let entry = null;
|
|
568
|
+
let totalBytes = 0;
|
|
569
|
+
let ended = false;
|
|
570
|
+
try {
|
|
571
|
+
for await (const value of gunzip) {
|
|
572
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
573
|
+
archiveBytes += chunk.byteLength;
|
|
574
|
+
if (archiveBytes > maxArchiveBytes) return unavailable$1("Source archive exceeds the uncompressed byte limit.");
|
|
575
|
+
pending = pending.byteLength === 0 ? chunk : Buffer.concat([pending, chunk]);
|
|
576
|
+
while (pending.byteLength > 0) {
|
|
577
|
+
if (ended) {
|
|
578
|
+
if (pending.some((byte) => byte !== 0)) return unavailable$1("Source archive has data after its end marker.");
|
|
579
|
+
pending = Buffer.alloc(0);
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
if (entry !== null) {
|
|
583
|
+
if (entry.remaining > 0) {
|
|
584
|
+
const consumed = Math.min(entry.remaining, pending.byteLength);
|
|
585
|
+
if (entry.kind === "file" || entry.kind === "metadata") entry.chunks.push(pending.subarray(0, consumed));
|
|
586
|
+
pending = pending.subarray(consumed);
|
|
587
|
+
entry.remaining -= consumed;
|
|
588
|
+
if (entry.remaining > 0) break;
|
|
589
|
+
}
|
|
590
|
+
if (entry.padding > 0) {
|
|
591
|
+
const consumed = Math.min(entry.padding, pending.byteLength);
|
|
592
|
+
pending = pending.subarray(consumed);
|
|
593
|
+
entry.padding -= consumed;
|
|
594
|
+
if (entry.padding > 0) break;
|
|
595
|
+
}
|
|
596
|
+
if (entry.kind === "metadata") {
|
|
597
|
+
const content = entryContent(entry);
|
|
598
|
+
nextPath = entry.metadataType === "pax" ? parsePaxPath(content) : content.toString("utf8").replace(/\0.*$/s, "").trim();
|
|
599
|
+
if (nextPath === null || nextPath.length === 0) return unavailable$1("Source archive has invalid path metadata.");
|
|
600
|
+
} else if (entry.kind === "file" && entry.path) {
|
|
601
|
+
files.push({
|
|
602
|
+
path: entry.path,
|
|
603
|
+
content: Uint8Array.from(entryContent(entry))
|
|
604
|
+
});
|
|
605
|
+
totalBytes += entry.size;
|
|
606
|
+
}
|
|
607
|
+
entry = null;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (pending.byteLength < blockBytes) break;
|
|
611
|
+
const header = pending.subarray(0, blockBytes);
|
|
612
|
+
pending = pending.subarray(blockBytes);
|
|
613
|
+
if (header.every((byte) => byte === 0)) {
|
|
614
|
+
ended = true;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (!hasValidChecksum(header)) return unavailable$1("Source archive has an invalid header checksum.");
|
|
618
|
+
const size = readOctal(header, 124, 12);
|
|
619
|
+
if (size === null) return unavailable$1("Source archive has an invalid entry size.");
|
|
620
|
+
const name = readString(header, 0, 100);
|
|
621
|
+
const prefix = readString(header, 345, 155);
|
|
622
|
+
const headerPath = prefix.length > 0 ? `${prefix}/${name}` : name;
|
|
623
|
+
const type = String.fromCharCode(header[156] ?? 0);
|
|
624
|
+
const padding = (blockBytes - size % blockBytes) % blockBytes;
|
|
625
|
+
if (type === "x" || type === "L") {
|
|
626
|
+
if (size > policy.maxSourceFileBytes) return unavailable$1("Source archive path metadata exceeds the file byte limit.");
|
|
627
|
+
entry = {
|
|
628
|
+
kind: "metadata",
|
|
629
|
+
metadataType: type === "x" ? "pax" : "long-path",
|
|
630
|
+
size,
|
|
631
|
+
remaining: size,
|
|
632
|
+
padding,
|
|
633
|
+
chunks: []
|
|
634
|
+
};
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (type === "5") {
|
|
638
|
+
nextPath = null;
|
|
639
|
+
entry = {
|
|
640
|
+
kind: "directory",
|
|
641
|
+
size,
|
|
642
|
+
remaining: size,
|
|
643
|
+
padding,
|
|
644
|
+
chunks: []
|
|
645
|
+
};
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (type !== "0" && type !== "\0") return unavailable$1("Source archive contains a linked or special entry.");
|
|
649
|
+
const path = archivePath(nextPath ?? headerPath, layout);
|
|
650
|
+
nextPath = null;
|
|
651
|
+
if (path === null) return unavailable$1("Source archive contains an invalid path.");
|
|
652
|
+
if (seen.has(path)) return unavailable$1("Source archive contains duplicate paths.");
|
|
653
|
+
seen.add(path);
|
|
654
|
+
fileEntries += 1;
|
|
655
|
+
if (fileEntries > policy.maxSourceFiles) return unavailable$1("Source archive contains too many files.");
|
|
656
|
+
if (size <= policy.maxSourceFileBytes && totalBytes + size > policy.maxSourceBytes) return unavailable$1("Source archive exceeds the total byte limit.");
|
|
657
|
+
entry = {
|
|
658
|
+
kind: size > policy.maxSourceFileBytes ? "skip" : "file",
|
|
659
|
+
path,
|
|
660
|
+
size,
|
|
661
|
+
remaining: size,
|
|
662
|
+
padding,
|
|
663
|
+
chunks: []
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
} catch (cause) {
|
|
668
|
+
const message = cause instanceof Error ? cause.message : "";
|
|
669
|
+
return unavailable$1(message.startsWith("Source archive ") ? message : "Source archive is not valid gzip data.", cause);
|
|
670
|
+
}
|
|
671
|
+
if (entry !== null || pending.some((byte) => byte !== 0) || !ended) return unavailable$1("Source archive ended before its final entry.");
|
|
672
|
+
if (files.length === 0) return unavailable$1("Source archive has no usable files.");
|
|
673
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
674
|
+
return ok(files);
|
|
675
|
+
}
|
|
676
|
+
const packageSpecPattern = /^(@[^/@]+\/[^/@]+|[^/@]+)(?:@(\S+))?$/;
|
|
677
|
+
const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?$/i;
|
|
678
|
+
const gitCommitPattern = /^[0-9a-f]{40}$/i;
|
|
679
|
+
const npmRequestTimeoutMs = 3e4;
|
|
680
|
+
const npmRedirectLimit = 3;
|
|
681
|
+
const packumentByteLimit = 10485760;
|
|
682
|
+
function unavailable(message, attempts, cause) {
|
|
683
|
+
return err({
|
|
684
|
+
_tag: "SourceUnavailable",
|
|
685
|
+
message,
|
|
686
|
+
attempts,
|
|
687
|
+
cause
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
function parsePackageSpec(spec) {
|
|
691
|
+
const match = spec.match(packageSpecPattern);
|
|
692
|
+
return match?.[1] ? {
|
|
693
|
+
name: match[1],
|
|
694
|
+
version: match[2] ?? "latest"
|
|
695
|
+
} : null;
|
|
696
|
+
}
|
|
697
|
+
function isRecord(value) {
|
|
698
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
699
|
+
}
|
|
700
|
+
function parseRepository(value) {
|
|
701
|
+
if (typeof value === "string") return value;
|
|
702
|
+
if (!isRecord(value)) return void 0;
|
|
703
|
+
return {
|
|
704
|
+
type: typeof value.type === "string" ? value.type : void 0,
|
|
705
|
+
url: typeof value.url === "string" ? value.url : void 0
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function parseVersion(packument, requested) {
|
|
709
|
+
if (!isRecord(packument) || !isRecord(packument["dist-tags"]) || !isRecord(packument.versions)) return null;
|
|
710
|
+
const tagged = packument["dist-tags"][requested];
|
|
711
|
+
const resolvedVersion = typeof tagged === "string" ? tagged : requested;
|
|
712
|
+
const value = packument.versions[resolvedVersion];
|
|
713
|
+
if (!isRecord(value) || typeof value.version !== "string" || !isRecord(value.dist) || typeof value.dist.tarball !== "string") return null;
|
|
714
|
+
return {
|
|
715
|
+
resolvedVersion,
|
|
716
|
+
repository: parseRepository(packument.repository),
|
|
717
|
+
version: {
|
|
718
|
+
version: value.version,
|
|
719
|
+
gitHead: typeof value.gitHead === "string" ? value.gitHead : void 0,
|
|
720
|
+
repository: parseRepository(value.repository),
|
|
721
|
+
dist: {
|
|
722
|
+
tarball: value.dist.tarball,
|
|
723
|
+
integrity: typeof value.dist.integrity === "string" ? value.dist.integrity : void 0,
|
|
724
|
+
shasum: typeof value.dist.shasum === "string" ? value.dist.shasum : void 0
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
async function cancelBody(response) {
|
|
730
|
+
if (!response.body) return;
|
|
731
|
+
await response.body.cancel().catch(() => void 0);
|
|
732
|
+
}
|
|
733
|
+
function declaredBytes(response) {
|
|
734
|
+
const value = response.headers.get("content-length");
|
|
735
|
+
if (value === null || !/^\d+$/.test(value)) return null;
|
|
736
|
+
const bytes = Number(value);
|
|
737
|
+
return Number.isSafeInteger(bytes) ? bytes : null;
|
|
738
|
+
}
|
|
739
|
+
async function* responseChunks(response, maxBytes, limitMessage, onChunk = () => {}) {
|
|
740
|
+
const declared = declaredBytes(response);
|
|
741
|
+
if (declared !== null && declared > maxBytes) {
|
|
742
|
+
await cancelBody(response);
|
|
743
|
+
throw new Error(limitMessage);
|
|
744
|
+
}
|
|
745
|
+
if (!response.body) return;
|
|
746
|
+
const reader = response.body.getReader();
|
|
747
|
+
let complete = false;
|
|
748
|
+
let totalBytes = 0;
|
|
749
|
+
try {
|
|
750
|
+
while (true) {
|
|
751
|
+
const result = await reader.read();
|
|
752
|
+
if (result.done) {
|
|
753
|
+
complete = true;
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
totalBytes += result.value.byteLength;
|
|
757
|
+
if (totalBytes > maxBytes) throw new Error(limitMessage);
|
|
758
|
+
onChunk(result.value);
|
|
759
|
+
yield result.value;
|
|
760
|
+
}
|
|
761
|
+
} finally {
|
|
762
|
+
if (!complete) await reader.cancel().catch(() => void 0);
|
|
763
|
+
reader.releaseLock();
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async function readBoundedResponse(response, maxBytes) {
|
|
767
|
+
const chunks = [];
|
|
768
|
+
let totalBytes = 0;
|
|
769
|
+
try {
|
|
770
|
+
for await (const chunk of responseChunks(response, maxBytes, "npm response exceeds the byte limit.")) {
|
|
771
|
+
chunks.push(chunk);
|
|
772
|
+
totalBytes += chunk.byteLength;
|
|
773
|
+
}
|
|
774
|
+
} catch (cause) {
|
|
775
|
+
return unavailable(cause instanceof Error ? cause.message : "npm response cannot be read.", [], cause);
|
|
776
|
+
}
|
|
777
|
+
return ok(Uint8Array.from(Buffer.concat(chunks, totalBytes)));
|
|
778
|
+
}
|
|
779
|
+
async function fetchWithRedirects(fetchClient, url, init = {}, signal) {
|
|
780
|
+
const timeout = AbortSignal.timeout(npmRequestTimeoutMs);
|
|
781
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
782
|
+
let current = url;
|
|
783
|
+
for (let redirects = 0; redirects <= npmRedirectLimit; redirects += 1) {
|
|
784
|
+
const response = await fetchClient(current, {
|
|
785
|
+
...init,
|
|
786
|
+
redirect: "manual",
|
|
787
|
+
signal: requestSignal
|
|
788
|
+
}).catch((error) => error);
|
|
789
|
+
if (response instanceof Error) return unavailable(timeout.aborted ? "npm request timed out." : "npm request failed.", [], response);
|
|
790
|
+
if (![
|
|
791
|
+
301,
|
|
792
|
+
302,
|
|
793
|
+
303,
|
|
794
|
+
307,
|
|
795
|
+
308
|
|
796
|
+
].includes(response.status)) return ok(response);
|
|
797
|
+
if (redirects === npmRedirectLimit) {
|
|
798
|
+
await cancelBody(response);
|
|
799
|
+
return unavailable("npm request exceeded the redirect limit.", []);
|
|
800
|
+
}
|
|
801
|
+
const location = response.headers.get("location");
|
|
802
|
+
if (!location) {
|
|
803
|
+
await cancelBody(response);
|
|
804
|
+
return unavailable("npm redirect is missing its location.", []);
|
|
805
|
+
}
|
|
806
|
+
const redirected = await Promise.resolve().then(() => new URL(location, current)).catch(() => null);
|
|
807
|
+
if (redirected === null) {
|
|
808
|
+
await cancelBody(response);
|
|
809
|
+
return unavailable("npm redirect location is invalid.", []);
|
|
810
|
+
}
|
|
811
|
+
if (redirected.protocol !== "https:") {
|
|
812
|
+
await cancelBody(response);
|
|
813
|
+
return unavailable("npm redirect URL must use HTTPS.", []);
|
|
814
|
+
}
|
|
815
|
+
await cancelBody(response);
|
|
816
|
+
current = redirected.href;
|
|
817
|
+
}
|
|
818
|
+
return unavailable("npm request exceeded the redirect limit.", []);
|
|
819
|
+
}
|
|
820
|
+
function createIntegrityVerifier(dist) {
|
|
821
|
+
const values = dist.integrity?.split(/\s+/).filter(Boolean) ?? [];
|
|
822
|
+
let expected = values.flatMap((value) => {
|
|
823
|
+
const match = value.match(/^(sha(?:1|256|384|512))-([A-Za-z0-9+/=]+)$/);
|
|
824
|
+
return match?.[1] && match[2] ? [{
|
|
825
|
+
algorithm: match[1],
|
|
826
|
+
digest: match[2]
|
|
827
|
+
}] : [];
|
|
828
|
+
});
|
|
829
|
+
if (expected.length > 0) {
|
|
830
|
+
const strength = {
|
|
831
|
+
sha1: 1,
|
|
832
|
+
sha256: 2,
|
|
833
|
+
sha384: 3,
|
|
834
|
+
sha512: 4
|
|
835
|
+
};
|
|
836
|
+
const strongest = Math.max(...expected.map((value) => strength[value.algorithm]));
|
|
837
|
+
expected = expected.filter((value) => strength[value.algorithm] === strongest);
|
|
838
|
+
}
|
|
839
|
+
if (values.length === 0 && dist.shasum && /^[0-9a-f]{40}$/i.test(dist.shasum)) expected.push({
|
|
840
|
+
algorithm: "sha1",
|
|
841
|
+
digest: Buffer.from(dist.shasum, "hex").toString("base64")
|
|
842
|
+
});
|
|
843
|
+
if (expected.length === 0) return null;
|
|
844
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
845
|
+
for (const value of expected) hashes.set(value.algorithm, createHash(value.algorithm));
|
|
846
|
+
return {
|
|
847
|
+
update(content) {
|
|
848
|
+
for (const hash of hashes.values()) hash.update(content);
|
|
849
|
+
},
|
|
850
|
+
valid() {
|
|
851
|
+
const actual = new Map([...hashes].map(([algorithm, hash]) => [algorithm, hash.digest("base64")]));
|
|
852
|
+
return expected.some((value) => actual.get(value.algorithm) === value.digest);
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
function repositoryUrl(repository) {
|
|
857
|
+
if (typeof repository === "string") return repository;
|
|
858
|
+
return typeof repository?.url === "string" ? repository.url : null;
|
|
859
|
+
}
|
|
860
|
+
function githubArchiveUrl(repository, gitHead) {
|
|
861
|
+
if (!gitHead || !gitCommitPattern.test(gitHead)) return null;
|
|
862
|
+
const source = repositoryUrl(repository);
|
|
863
|
+
if (source === null) return null;
|
|
864
|
+
const match = source.match(/^(?:(?:git\+)?https?:\/\/(?:git@)?github\.com\/|git:\/\/github\.com\/|git@github\.com:)([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/);
|
|
865
|
+
if (!match?.[1] || !match[2]) return null;
|
|
866
|
+
return `https://codeload.github.com/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}/tar.gz/${gitHead}`;
|
|
867
|
+
}
|
|
868
|
+
function skipped(source, reason) {
|
|
869
|
+
return {
|
|
870
|
+
source,
|
|
871
|
+
status: "skipped",
|
|
872
|
+
reason
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
function used(source) {
|
|
876
|
+
return {
|
|
877
|
+
source,
|
|
878
|
+
status: "used"
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function withAttempts(error, attempts) {
|
|
882
|
+
return error._tag === "SourceUnavailable" ? unavailable(error.message, attempts, error.cause) : err(error);
|
|
883
|
+
}
|
|
884
|
+
function remainingPolicy(policy, files) {
|
|
885
|
+
const usedBytes = files.reduce((total, file) => total + file.content.byteLength, 0);
|
|
886
|
+
const maxSourceFiles = policy.maxSourceFiles - files.length;
|
|
887
|
+
const maxSourceBytes = policy.maxSourceBytes - usedBytes;
|
|
888
|
+
if (maxSourceFiles <= 0 || maxSourceBytes <= 0) return null;
|
|
889
|
+
return {
|
|
890
|
+
...policy,
|
|
891
|
+
maxSourceFiles,
|
|
892
|
+
maxSourceBytes
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
async function extractResponse(response, policy, layout, verifier) {
|
|
896
|
+
return extractArchive(responseChunks(response, policy.maxSourceBytes, "Source archive exceeds the compressed byte limit.", (content) => verifier?.update(content)), policy, layout);
|
|
897
|
+
}
|
|
898
|
+
async function prepareNpmPackage(spec, policy, fetchClient, signal) {
|
|
899
|
+
const parsed = parsePackageSpec(spec);
|
|
900
|
+
if (parsed === null) return unavailable("npm package spec is invalid.", []);
|
|
901
|
+
const attempts = [];
|
|
902
|
+
const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(parsed.name)}`;
|
|
903
|
+
const registryResponse = await fetchWithRedirects(fetchClient, registryUrl, { headers: { accept: "application/vnd.npm.install-v1+json" } }, signal);
|
|
904
|
+
if (registryResponse._tag === "Err") return withAttempts(registryResponse.error, [skipped(registryUrl, registryResponse.error.message)]);
|
|
905
|
+
const response = registryResponse.value;
|
|
906
|
+
if (!response.ok) {
|
|
907
|
+
await cancelBody(response);
|
|
908
|
+
const message = `npm registry returned HTTP ${response.status}.`;
|
|
909
|
+
return unavailable(message, [skipped(registryUrl, message)]);
|
|
910
|
+
}
|
|
911
|
+
const packumentBytes = await readBoundedResponse(response, packumentByteLimit);
|
|
912
|
+
if (packumentBytes._tag === "Err") return withAttempts(packumentBytes.error, [skipped(registryUrl, packumentBytes.error.message)]);
|
|
913
|
+
let packument;
|
|
914
|
+
try {
|
|
915
|
+
packument = JSON.parse(Buffer.from(packumentBytes.value).toString("utf8"));
|
|
916
|
+
} catch (cause) {
|
|
917
|
+
const message = "npm registry returned invalid package data.";
|
|
918
|
+
return unavailable(message, [skipped(registryUrl, message)], cause);
|
|
919
|
+
}
|
|
920
|
+
const resolution = parseVersion(packument, parsed.version);
|
|
921
|
+
if (resolution === null || !exactVersionPattern.test(resolution.resolvedVersion) || resolution.version.version !== resolution.resolvedVersion) {
|
|
922
|
+
const message = "npm package did not resolve to an exact version.";
|
|
923
|
+
return unavailable(message, [skipped(registryUrl, message)]);
|
|
924
|
+
}
|
|
925
|
+
const { repository, resolvedVersion, version } = resolution;
|
|
926
|
+
if (!version.dist.tarball.startsWith("https://")) {
|
|
927
|
+
const message = "npm package archive URL must use HTTPS.";
|
|
928
|
+
return unavailable(message, [skipped(registryUrl, message)]);
|
|
929
|
+
}
|
|
930
|
+
attempts.push(used(registryUrl));
|
|
931
|
+
const fetchedArchive = await fetchWithRedirects(fetchClient, version.dist.tarball, {}, signal);
|
|
932
|
+
if (fetchedArchive._tag === "Err") return withAttempts(fetchedArchive.error, [...attempts, skipped(version.dist.tarball, fetchedArchive.error.message)]);
|
|
933
|
+
const archiveResponse = fetchedArchive.value;
|
|
934
|
+
if (!archiveResponse.ok) {
|
|
935
|
+
await cancelBody(archiveResponse);
|
|
936
|
+
const message = `npm package archive returned HTTP ${archiveResponse.status}.`;
|
|
937
|
+
return unavailable(message, [...attempts, skipped(version.dist.tarball, message)]);
|
|
938
|
+
}
|
|
939
|
+
const verifier = createIntegrityVerifier(version.dist);
|
|
940
|
+
if (verifier === null) {
|
|
941
|
+
await cancelBody(archiveResponse);
|
|
942
|
+
const message = "npm package archive has no supported integrity value.";
|
|
943
|
+
return unavailable(message, [...attempts, skipped(version.dist.tarball, message)]);
|
|
944
|
+
}
|
|
945
|
+
const extracted = await extractResponse(archiveResponse, policy, "npm", verifier);
|
|
946
|
+
if (extracted._tag === "Err") return withAttempts(extracted.error, [...attempts, skipped(version.dist.tarball, extracted.error.message)]);
|
|
947
|
+
if (!verifier.valid()) {
|
|
948
|
+
const message = "npm package archive integrity check failed.";
|
|
949
|
+
return unavailable(message, [...attempts, skipped(version.dist.tarball, message)]);
|
|
950
|
+
}
|
|
951
|
+
attempts.push(used(version.dist.tarball));
|
|
952
|
+
let files = [...extracted.value];
|
|
953
|
+
const repositoryArchive = githubArchiveUrl(version.repository ?? repository, version.gitHead);
|
|
954
|
+
const available = remainingPolicy(policy, files);
|
|
955
|
+
if (repositoryArchive !== null && available === null) attempts.push(skipped(repositoryArchive, "Prepared package files reached the source limit."));
|
|
956
|
+
else if (repositoryArchive !== null && available !== null) {
|
|
957
|
+
const fetchedRepository = await fetchWithRedirects(fetchClient, repositoryArchive, {}, signal);
|
|
958
|
+
if (fetchedRepository._tag === "Err") attempts.push(skipped(repositoryArchive, fetchedRepository.error.message));
|
|
959
|
+
else if (!fetchedRepository.value.ok) {
|
|
960
|
+
const message = `GitHub source archive returned HTTP ${fetchedRepository.value.status}.`;
|
|
961
|
+
await cancelBody(fetchedRepository.value);
|
|
962
|
+
attempts.push(skipped(repositoryArchive, message));
|
|
963
|
+
} else {
|
|
964
|
+
const repositoryFiles = await extractResponse(fetchedRepository.value, available, "github");
|
|
965
|
+
if (repositoryFiles._tag === "Err") attempts.push(skipped(repositoryArchive, repositoryFiles.error.message));
|
|
966
|
+
else {
|
|
967
|
+
files = [...files, ...repositoryFiles.value.map((file) => ({
|
|
968
|
+
...file,
|
|
969
|
+
path: posix.join("repository", file.path)
|
|
970
|
+
}))];
|
|
971
|
+
attempts.push(used(repositoryArchive));
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
976
|
+
return ok({
|
|
977
|
+
files,
|
|
978
|
+
attempts,
|
|
979
|
+
npmResolution: {
|
|
980
|
+
package: parsed.name,
|
|
981
|
+
version: resolvedVersion
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
function cancelled() {
|
|
986
|
+
return err({
|
|
987
|
+
_tag: "Cancelled",
|
|
988
|
+
message: "Skill run was cancelled."
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
async function prepareCurrentSkill(destination, policy, signal) {
|
|
992
|
+
const root = resolve(destination.rootDir);
|
|
993
|
+
const rootStat = await lstat(root).catch((error) => error);
|
|
994
|
+
if (rootStat instanceof Error) {
|
|
995
|
+
if (rootStat.code === "ENOENT") return ok(void 0);
|
|
996
|
+
return err({
|
|
997
|
+
_tag: "UnsafeOutputPath",
|
|
998
|
+
message: "Output root cannot be inspected.",
|
|
999
|
+
path: root
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return err({
|
|
1003
|
+
_tag: "UnsafeOutputPath",
|
|
1004
|
+
message: "Output root must be a directory, not a symbolic link.",
|
|
1005
|
+
path: root
|
|
1006
|
+
});
|
|
1007
|
+
const canonicalRoot = await realpath(root).catch((error) => error);
|
|
1008
|
+
if (canonicalRoot instanceof Error || canonicalRoot !== root) return err({
|
|
1009
|
+
_tag: "UnsafeOutputPath",
|
|
1010
|
+
message: "Output root must not pass through a symbolic link.",
|
|
1011
|
+
path: root
|
|
1012
|
+
});
|
|
1013
|
+
const target = join(root, destination.name);
|
|
1014
|
+
const targetStat = await lstat(target).catch((error) => error);
|
|
1015
|
+
if (targetStat instanceof Error) {
|
|
1016
|
+
if (targetStat.code === "ENOENT") return ok(void 0);
|
|
1017
|
+
return err({
|
|
1018
|
+
_tag: "UnsafeOutputPath",
|
|
1019
|
+
message: "Output path cannot be inspected.",
|
|
1020
|
+
path: target
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
if (!targetStat.isDirectory() || targetStat.isSymbolicLink()) return err({
|
|
1024
|
+
_tag: "UnsafeOutputPath",
|
|
1025
|
+
message: "Output path must be a directory, not a symbolic link.",
|
|
1026
|
+
path: target
|
|
1027
|
+
});
|
|
1028
|
+
return collectHostDirectory(target, policy, "current Skill", signal);
|
|
1029
|
+
}
|
|
1030
|
+
async function prepareRun(input, policy, fetchClient, signal) {
|
|
1031
|
+
if (signal?.aborted) return err({
|
|
1032
|
+
_tag: "Cancelled",
|
|
1033
|
+
message: "Skill run was cancelled."
|
|
1034
|
+
});
|
|
1035
|
+
const current = input._tag === "ReviewSkill" ? ok(void 0) : await prepareCurrentSkill(input.destination, policy, signal);
|
|
1036
|
+
if (current._tag === "Err") return current;
|
|
1037
|
+
if (input._tag === "ProjectSkill") {
|
|
1038
|
+
const source = await collectHostDirectory(input.projectDir, policy, input.projectDir, signal);
|
|
1039
|
+
return source._tag === "Err" ? source : ok({
|
|
1040
|
+
skillName: "generate-project-skill",
|
|
1041
|
+
outputName: input.destination.name,
|
|
1042
|
+
source: source.value,
|
|
1043
|
+
current: current.value,
|
|
1044
|
+
destination: input.destination
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
if (input._tag === "ReviewSkill") {
|
|
1048
|
+
const source = await collectHostDirectory(input.skillDir, policy, input.skillDir, signal);
|
|
1049
|
+
return source._tag === "Err" ? source : ok({
|
|
1050
|
+
skillName: "review-skill",
|
|
1051
|
+
outputName: "review",
|
|
1052
|
+
source: source.value
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
if (input.source._tag === "NpmPackage") {
|
|
1056
|
+
const source = await prepareNpmPackage(input.source.spec, policy, fetchClient, signal);
|
|
1057
|
+
if (signal?.aborted) return err({
|
|
1058
|
+
_tag: "Cancelled",
|
|
1059
|
+
message: "Skill run was cancelled."
|
|
1060
|
+
});
|
|
1061
|
+
return source._tag === "Err" ? source : ok({
|
|
1062
|
+
skillName: "generate-package-skill",
|
|
1063
|
+
outputName: input.destination.name,
|
|
1064
|
+
source: source.value,
|
|
1065
|
+
current: current.value,
|
|
1066
|
+
destination: input.destination
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
const packageDir = resolveWithin(input.source.rootDir, input.source.packageDir);
|
|
1070
|
+
if (packageDir === null) return err({
|
|
1071
|
+
_tag: "InvalidInput",
|
|
1072
|
+
message: "Local package directory must stay inside its root directory."
|
|
1073
|
+
});
|
|
1074
|
+
const manifest = await lstat(join(packageDir, "package.json")).catch((error) => error);
|
|
1075
|
+
if (manifest instanceof Error || !manifest.isFile() || manifest.isSymbolicLink()) return err({
|
|
1076
|
+
_tag: "SourceUnavailable",
|
|
1077
|
+
message: "Local package directory must contain package.json.",
|
|
1078
|
+
attempts: [{
|
|
1079
|
+
source: packageDir,
|
|
1080
|
+
status: "skipped",
|
|
1081
|
+
reason: "package.json is unavailable."
|
|
1082
|
+
}]
|
|
1083
|
+
});
|
|
1084
|
+
const source = await collectHostDirectory(packageDir, policy, packageDir, signal);
|
|
1085
|
+
return source._tag === "Err" ? source : ok({
|
|
1086
|
+
skillName: "generate-package-skill",
|
|
1087
|
+
outputName: input.destination.name,
|
|
1088
|
+
source: source.value,
|
|
1089
|
+
current: current.value,
|
|
1090
|
+
destination: input.destination
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
function requestContent(skill) {
|
|
1094
|
+
const request = skill.files?.find((file) => file.path === "assets/harness-request.md");
|
|
1095
|
+
if (!request) throw new Error(`Harness request asset is missing for ${skill.name}.`);
|
|
1096
|
+
return request.content;
|
|
1097
|
+
}
|
|
1098
|
+
function renderRequest(template, sourcePath, currentSkillPath, outputPath, skillName) {
|
|
1099
|
+
return template.replaceAll("{{SOURCE_PATH}}", sourcePath).replaceAll("{{CURRENT_SKILL_PATH}}", currentSkillPath).replaceAll("{{OUTPUT_PATH}}", outputPath).replaceAll("{{SKILL_NAME}}", skillName);
|
|
1100
|
+
}
|
|
1101
|
+
async function writePreparedSource(active, prepared, signal) {
|
|
1102
|
+
const sourcePath = posix.join(active.workDir, "input/source");
|
|
1103
|
+
const reset = await active.sandbox.run({
|
|
1104
|
+
command: "rm -rf -- \"$SKILLD_INPUT\" \"$SKILLD_OUTPUT\" && mkdir -p -- \"$SKILLD_INPUT\"",
|
|
1105
|
+
env: {
|
|
1106
|
+
SKILLD_INPUT: posix.join(active.workDir, "input"),
|
|
1107
|
+
SKILLD_OUTPUT: posix.join(active.workDir, "skilld-output")
|
|
1108
|
+
},
|
|
1109
|
+
abortSignal: signal
|
|
1110
|
+
});
|
|
1111
|
+
if (reset.exitCode !== 0) throw new Error(reset.stderr.trim() || "Harness work directory cannot be prepared.");
|
|
1112
|
+
for (const file of prepared.source.files) await active.sandbox.writeBinaryFile({
|
|
1113
|
+
path: posix.join(sourcePath, file.path),
|
|
1114
|
+
content: file.content,
|
|
1115
|
+
abortSignal: signal
|
|
1116
|
+
});
|
|
1117
|
+
for (const file of prepared.current?.files ?? []) await active.sandbox.writeBinaryFile({
|
|
1118
|
+
path: posix.join(active.workDir, "input/current-skill", file.path),
|
|
1119
|
+
content: file.content,
|
|
1120
|
+
abortSignal: signal
|
|
1121
|
+
});
|
|
1122
|
+
await active.sandbox.writeTextFile({
|
|
1123
|
+
path: posix.join(active.workDir, "input/source-manifest.json"),
|
|
1124
|
+
content: `${JSON.stringify({
|
|
1125
|
+
sourceAttempts: prepared.source.attempts,
|
|
1126
|
+
npmResolution: prepared.source.npmResolution,
|
|
1127
|
+
hasCurrentSkill: prepared.current !== void 0
|
|
1128
|
+
}, null, 2)}\n`,
|
|
1129
|
+
abortSignal: signal
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
function toAgentError(cause, signal) {
|
|
1133
|
+
return signal?.aborted ? cancelled() : err({
|
|
1134
|
+
_tag: "AgentFailed",
|
|
1135
|
+
message: "Harness Agent failed during the Skill run.",
|
|
1136
|
+
cause
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
function createSkillHarness(options) {
|
|
1140
|
+
const policy = parseOutputPolicy({
|
|
1141
|
+
...DEFAULT_OUTPUT_POLICY,
|
|
1142
|
+
...options.outputPolicy
|
|
1143
|
+
});
|
|
1144
|
+
const fetchClient = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
1145
|
+
const sandboxConfig = { ...options.sandboxConfig };
|
|
1146
|
+
const harness = options.harness;
|
|
1147
|
+
const sandbox = options.sandbox;
|
|
1148
|
+
return { run: async (input, runOptions = {}) => {
|
|
1149
|
+
const parsed = parseSkillRun(input);
|
|
1150
|
+
if (parsed._tag === "Err") return parsed;
|
|
1151
|
+
const prepared = await prepareRun(parsed.value, policy, fetchClient, runOptions.signal);
|
|
1152
|
+
if (prepared._tag === "Err") return prepared;
|
|
1153
|
+
const skill = await loadSkilldMaintainedSkill(prepared.value.skillName);
|
|
1154
|
+
let active;
|
|
1155
|
+
const userOnSession = sandboxConfig.onSession;
|
|
1156
|
+
const agent = new HarnessAgent({
|
|
1157
|
+
harness,
|
|
1158
|
+
sandbox,
|
|
1159
|
+
skills: [skill],
|
|
1160
|
+
permissionMode: "allow-all",
|
|
1161
|
+
sandboxConfig: {
|
|
1162
|
+
...sandboxConfig,
|
|
1163
|
+
onSession: async (sessionOptions) => {
|
|
1164
|
+
active = {
|
|
1165
|
+
sandbox: sessionOptions.session,
|
|
1166
|
+
workDir: sessionOptions.sessionWorkDir
|
|
1167
|
+
};
|
|
1168
|
+
await writePreparedSource(active, prepared.value, runOptions.signal);
|
|
1169
|
+
await userOnSession?.(sessionOptions);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
const sessionResult = await agent.createSession({ abortSignal: runOptions.signal }).then(ok, (cause) => err(cause));
|
|
1174
|
+
if (sessionResult._tag === "Err") return toAgentError(sessionResult.error, runOptions.signal);
|
|
1175
|
+
const session = sessionResult.value;
|
|
1176
|
+
try {
|
|
1177
|
+
if (!active) return err({
|
|
1178
|
+
_tag: "AgentFailed",
|
|
1179
|
+
message: "Harness did not provide its sandbox session."
|
|
1180
|
+
});
|
|
1181
|
+
const sourcePath = posix.join(active.workDir, "input/source");
|
|
1182
|
+
const currentSkillPath = posix.join(active.workDir, "input/current-skill");
|
|
1183
|
+
const outputPath = posix.join(active.workDir, "skilld-output", prepared.value.outputName);
|
|
1184
|
+
const prompt = renderRequest(requestContent(skill), sourcePath, currentSkillPath, outputPath, prepared.value.outputName);
|
|
1185
|
+
const generated = await agent.generate({
|
|
1186
|
+
session,
|
|
1187
|
+
prompt,
|
|
1188
|
+
abortSignal: runOptions.signal
|
|
1189
|
+
}).then(ok, (cause) => err(cause));
|
|
1190
|
+
if (generated._tag === "Err") return toAgentError(generated.error, runOptions.signal);
|
|
1191
|
+
if (runOptions.signal?.aborted) return cancelled();
|
|
1192
|
+
const collected = await collectSandboxOutput(active.sandbox, outputPath, policy, runOptions.signal);
|
|
1193
|
+
if (collected._tag === "Err") return collected;
|
|
1194
|
+
if (prepared.value.skillName === "review-skill") return validateSkillReview(collected.value);
|
|
1195
|
+
const validated = validateGeneratedSkill(prepared.value.outputName, collected.value);
|
|
1196
|
+
if (validated._tag === "Err") return validated;
|
|
1197
|
+
if (!prepared.value.destination) return err({
|
|
1198
|
+
_tag: "InvalidInput",
|
|
1199
|
+
message: "Skill destination is required."
|
|
1200
|
+
});
|
|
1201
|
+
return promoteSkill(prepared.value.destination.rootDir, prepared.value.destination.name, collected.value, prepared.value.source.attempts);
|
|
1202
|
+
} finally {
|
|
1203
|
+
await session.destroy();
|
|
1204
|
+
}
|
|
1205
|
+
} };
|
|
1206
|
+
}
|
|
1207
|
+
export { createSkillHarness };
|
|
1208
|
+
|
|
1209
|
+
//# sourceMappingURL=index.mjs.map
|