@iseoane/agent-depot 0.2.0 → 0.3.0
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 +47 -0
- package/README.md +117 -3
- package/dist/src/app-cli.js +172 -0
- package/dist/src/app-flow.js +457 -0
- package/dist/src/app-latest.js +63 -0
- package/dist/src/app-owned-skills.js +42 -0
- package/dist/src/app-recipes.js +116 -0
- package/dist/src/app-schema.js +45 -0
- package/dist/src/app-version.js +46 -0
- package/dist/src/atomic-file.js +26 -0
- package/dist/src/cli.js +1256 -0
- package/dist/src/git-source.js +409 -0
- package/dist/src/path-safety.js +31 -0
- package/dist/src/process-runner.js +41 -0
- package/dist/src/project-installation.js +1411 -0
- package/dist/src/project-manifest.js +511 -0
- package/dist/src/skill-adoption.js +77 -0
- package/dist/src/skill-discovery.js +467 -0
- package/dist/src/skill-hosts.js +103 -0
- package/dist/src/skill-install.js +374 -0
- package/dist/src/skill-removal.js +119 -0
- package/dist/src/skill-update.js +244 -0
- package/dist/src/source-state.js +240 -0
- package/dist/src/sources.js +391 -0
- package/dist/src/tui/adoption-actions.js +144 -0
- package/dist/src/tui/app-actions.js +157 -0
- package/dist/src/tui/app.js +63 -0
- package/dist/src/tui/batch.js +17 -0
- package/dist/src/tui/bulk-actions.js +243 -0
- package/dist/src/tui/catalog-actions.js +9 -0
- package/dist/src/tui/catalog-browse.js +59 -0
- package/dist/src/tui/catalog-filter.js +20 -0
- package/dist/src/tui/catalog-flow.js +107 -0
- package/dist/src/tui/catalog-installs.js +193 -0
- package/dist/src/tui/catalog-panel.js +47 -0
- package/dist/src/tui/catalog-state.js +92 -0
- package/dist/src/tui/catalog-tree.js +35 -0
- package/dist/src/tui/catalog-view.js +89 -0
- package/dist/src/tui/environment.js +1 -0
- package/dist/src/tui/installations-browse.js +136 -0
- package/dist/src/tui/installations-mode.js +1 -0
- package/dist/src/tui/installations-panel.js +27 -0
- package/dist/src/tui/installations-rows.js +11 -0
- package/dist/src/tui/installations-tree.js +80 -0
- package/dist/src/tui/installations-view.js +154 -0
- package/dist/src/tui/installations.js +172 -0
- package/dist/src/tui/keys.js +31 -0
- package/dist/src/tui/manage-actions.js +174 -0
- package/dist/src/tui/manage-panel.js +65 -0
- package/dist/src/tui/mode-keys.js +61 -0
- package/dist/src/tui/panel-parts.js +23 -0
- package/dist/src/tui/render.js +8 -0
- package/dist/src/tui/sources-mode.js +17 -0
- package/dist/src/tui/sources-view.js +276 -0
- package/dist/src/tui/theme.js +29 -0
- package/dist/src/tui/tree-navigation.js +68 -0
- package/dist/src/tui/tree.js +53 -0
- package/dist/src/tui/unmanaged-actions.js +178 -0
- package/dist/src/tui/updates-flow.js +89 -0
- package/dist/src/tui/updates-keys.js +104 -0
- package/dist/src/tui/updates-mode.js +1 -0
- package/dist/src/tui/updates-model.js +48 -0
- package/dist/src/tui/updates-panel.js +44 -0
- package/dist/src/tui/updates-view.js +101 -0
- package/dist/src/tui/updates.js +230 -0
- package/dist/src/tui/view-state.js +42 -0
- package/dist/src/tui/window.js +31 -0
- package/dist/src/unmanaged-removal.js +60 -0
- package/dist/src/update-batch.js +376 -0
- package/dist/src/update-flow.js +135 -0
- package/dist/src/usage-error.js +7 -0
- package/dist/src/user-global-skill-inventory.js +565 -0
- package/dist/src/user-method.js +14 -0
- package/package.json +7 -4
- package/skills/agent-depot-apprecipe/SKILL.md +44 -0
- package/skills/agent-depot-apprecipe/assets/example.md +25 -0
- package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
- package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
- package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
- package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
- package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
- package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
- package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
- package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
- package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
- package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, rename, rm, rmdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { assertNoSymlinkPath } from "./path-safety.js";
|
|
6
|
+
import { runProcess } from "./process-runner.js";
|
|
7
|
+
export const SKILL_TREE_LIMITS = Object.freeze({
|
|
8
|
+
maxFiles: 256,
|
|
9
|
+
maxFileBytes: 2 * 1024 * 1024,
|
|
10
|
+
maxTotalBytes: 8 * 1024 * 1024,
|
|
11
|
+
maxPathBytes: 4096,
|
|
12
|
+
maxDepth: 32,
|
|
13
|
+
maxListingBytes: 16 * 1024 * 1024,
|
|
14
|
+
});
|
|
15
|
+
const EXCLUDED_LIFECYCLE_SEGMENTS = new Set([
|
|
16
|
+
"in-progress",
|
|
17
|
+
"beta",
|
|
18
|
+
"deprecated",
|
|
19
|
+
"retired",
|
|
20
|
+
]);
|
|
21
|
+
export class GitSourceAccessError extends Error {
|
|
22
|
+
constructor(source, reason) {
|
|
23
|
+
super(`Unable to refresh Git Source ${JSON.stringify(source.id)} from ${JSON.stringify(source.url)}: ${reason}`);
|
|
24
|
+
this.name = "GitSourceAccessError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function gitFailureDetail(result) {
|
|
28
|
+
return result.stderr.trim()
|
|
29
|
+
|| (result.signal ? `terminated by ${result.signal}` : `exited with code ${result.code ?? "unknown"}`);
|
|
30
|
+
}
|
|
31
|
+
class NodeGitCommandRunner {
|
|
32
|
+
async run(command, args, options = {}) {
|
|
33
|
+
const result = await runProcess(command, args, options.cwd === undefined ? {} : { cwd: options.cwd });
|
|
34
|
+
if (result.code !== 0) {
|
|
35
|
+
throw new Error(`git command failed: ${gitFailureDetail(result)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
class NodeGitSnapshotCommandRunner {
|
|
40
|
+
async run(command, args, options = {}) {
|
|
41
|
+
return Buffer.from(await this.runBinary(command, args, options)).toString("utf8");
|
|
42
|
+
}
|
|
43
|
+
async runBinary(command, args, options = {}) {
|
|
44
|
+
const result = await runProcess(command, args, {
|
|
45
|
+
captureStdout: true,
|
|
46
|
+
...(options.maxOutputBytes === undefined ? {} : { maxOutputBytes: options.maxOutputBytes }),
|
|
47
|
+
});
|
|
48
|
+
if (result.outputTooLarge) {
|
|
49
|
+
throw new Error("git command output exceeds the safe size limit");
|
|
50
|
+
}
|
|
51
|
+
if (result.code !== 0) {
|
|
52
|
+
throw new Error(`git command failed: ${gitFailureDetail(result)}`);
|
|
53
|
+
}
|
|
54
|
+
return result.stdout;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const SOURCE_LOCK_TIMEOUT_MS = 30_000;
|
|
58
|
+
const SOURCE_LOCK_RETRY_MS = 10;
|
|
59
|
+
/**
|
|
60
|
+
* Clones each Source into a local mirror and refreshes it from the registered
|
|
61
|
+
* URL. Mirrors are intentionally not checked out: discovery owns later reads.
|
|
62
|
+
*/
|
|
63
|
+
export class GitSourceAccessAdapter {
|
|
64
|
+
cachePath;
|
|
65
|
+
runner;
|
|
66
|
+
constructor(options = {}) {
|
|
67
|
+
this.cachePath = options.cachePath ?? defaultGitSourceCachePath();
|
|
68
|
+
this.runner = options.runner ?? new NodeGitCommandRunner();
|
|
69
|
+
}
|
|
70
|
+
async refresh(source) {
|
|
71
|
+
const expectedId = sourceIdForUrl(source.url);
|
|
72
|
+
if (source.kind !== "git" || source.id !== expectedId) {
|
|
73
|
+
throw new GitSourceAccessError(source, "the Source identity does not match its registered URL");
|
|
74
|
+
}
|
|
75
|
+
const destination = cachePathForValidatedSource(this.cachePath, source);
|
|
76
|
+
try {
|
|
77
|
+
await assertNoSymlinkPath(this.cachePath, "cache path");
|
|
78
|
+
await mkdir(this.cachePath, { recursive: true });
|
|
79
|
+
await assertNoSymlinkPath(this.cachePath, "cache path");
|
|
80
|
+
const release = await acquireSourceLock(`${destination}.lock`);
|
|
81
|
+
try {
|
|
82
|
+
await this.refreshLocked(source, destination);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await release();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof GitSourceAccessError) {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
const reason = error instanceof Error ? error.message : "the Git command failed";
|
|
93
|
+
throw new GitSourceAccessError(source, reason);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async refreshLocked(source, destination) {
|
|
97
|
+
const existing = await pathType(destination);
|
|
98
|
+
if (existing === "symlink") {
|
|
99
|
+
throw new Error("the Source cache path is a symbolic link");
|
|
100
|
+
}
|
|
101
|
+
if (existing === "file") {
|
|
102
|
+
throw new Error("the Source cache path is not a directory");
|
|
103
|
+
}
|
|
104
|
+
if (existing === "directory") {
|
|
105
|
+
await assertNoSymlinkPath(destination, "cache path");
|
|
106
|
+
// The explicit mirror refspec synchronizes branches, tags, and other
|
|
107
|
+
// refs from the registered URL while --prune removes deleted refs.
|
|
108
|
+
await this.runner.run("git", [
|
|
109
|
+
"-C",
|
|
110
|
+
destination,
|
|
111
|
+
"fetch",
|
|
112
|
+
"--prune",
|
|
113
|
+
"--force",
|
|
114
|
+
source.url,
|
|
115
|
+
"+refs/*:refs/*",
|
|
116
|
+
]);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const temporaryDestination = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
|
120
|
+
try {
|
|
121
|
+
await this.runner.run("git", ["clone", "--mirror", source.url, temporaryDestination]);
|
|
122
|
+
await assertNoSymlinkPath(temporaryDestination, "cache path");
|
|
123
|
+
if ((await pathType(temporaryDestination)) !== "directory") {
|
|
124
|
+
throw new Error("Git clone did not create a directory mirror");
|
|
125
|
+
}
|
|
126
|
+
await rename(temporaryDestination, destination);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await rm(temporaryDestination, { recursive: true, force: true }).catch(() => undefined);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Validates one `git ls-tree -z` record of a selected Skill directory. */
|
|
134
|
+
function parseSkillTreeRecord(record, relativeSkillPath) {
|
|
135
|
+
const separator = record.indexOf("\t");
|
|
136
|
+
if (separator < 0) {
|
|
137
|
+
throw new Error("Git Source returned a malformed Skill tree entry");
|
|
138
|
+
}
|
|
139
|
+
const [mode, objectType, objectId] = record.slice(0, separator).split(" ");
|
|
140
|
+
const relativePath = record.slice(separator + 1);
|
|
141
|
+
if (!relativePath.startsWith(`${relativeSkillPath}/`) || !isSafeSourcePath(relativePath)) {
|
|
142
|
+
throw new Error("Git Source returned a Skill tree path outside the selected directory");
|
|
143
|
+
}
|
|
144
|
+
if (objectType !== "blob" || (mode !== "100644" && mode !== "100755")) {
|
|
145
|
+
throw new Error(`Selected Skill tree contains an unsupported Git entry: ${relativePath}`);
|
|
146
|
+
}
|
|
147
|
+
if (!objectId || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(objectId)) {
|
|
148
|
+
throw new Error("Git Source returned an unsafe object reference");
|
|
149
|
+
}
|
|
150
|
+
return { relativePath, objectId, executable: mode === "100755" };
|
|
151
|
+
}
|
|
152
|
+
export class GitSourceSnapshotAccess {
|
|
153
|
+
cachePath;
|
|
154
|
+
runner;
|
|
155
|
+
constructor(options = {}) {
|
|
156
|
+
this.cachePath = options.cachePath ?? defaultGitSourceCachePath();
|
|
157
|
+
this.runner = options.runner ?? new NodeGitSnapshotCommandRunner();
|
|
158
|
+
}
|
|
159
|
+
async requireMirror(source) {
|
|
160
|
+
const destination = cachePathForSource(this.cachePath, source);
|
|
161
|
+
if ((await pathType(destination)) !== "directory") {
|
|
162
|
+
throw new Error(`Git Source mirror is not available: ${destination}`);
|
|
163
|
+
}
|
|
164
|
+
await assertNoSymlinkPath(destination, "cache path");
|
|
165
|
+
return destination;
|
|
166
|
+
}
|
|
167
|
+
async readSkillTree(source, skillPath) {
|
|
168
|
+
return (await this.readSkillTreeSnapshot(source, skillPath)).files;
|
|
169
|
+
}
|
|
170
|
+
/** Reads Skill bytes and the commit they came from as one immutable snapshot. */
|
|
171
|
+
async readSkillTreeSnapshot(source, skillPath) {
|
|
172
|
+
const relativeSkillPath = validateSkillDirectoryPath(skillPath);
|
|
173
|
+
const destination = await this.requireMirror(source);
|
|
174
|
+
const commit = await this.readResolvedCommit(source);
|
|
175
|
+
const tree = await this.runner.run("git", [
|
|
176
|
+
"--git-dir",
|
|
177
|
+
destination,
|
|
178
|
+
"ls-tree",
|
|
179
|
+
"-r",
|
|
180
|
+
"-z",
|
|
181
|
+
commit,
|
|
182
|
+
"--",
|
|
183
|
+
relativeSkillPath,
|
|
184
|
+
], { maxOutputBytes: SKILL_TREE_LIMITS.maxListingBytes });
|
|
185
|
+
if (Buffer.byteLength(tree, "utf8") > SKILL_TREE_LIMITS.maxListingBytes) {
|
|
186
|
+
throw new Error("Selected Skill tree listing exceeds the safe size limit");
|
|
187
|
+
}
|
|
188
|
+
const files = [];
|
|
189
|
+
let totalBytes = 0;
|
|
190
|
+
for (const record of tree.split("\0")) {
|
|
191
|
+
if (!record) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (files.length >= SKILL_TREE_LIMITS.maxFiles) {
|
|
195
|
+
throw new Error("Selected Skill tree contains too many files");
|
|
196
|
+
}
|
|
197
|
+
const entry = parseSkillTreeRecord(record, relativeSkillPath);
|
|
198
|
+
const content = await this.runner.runBinary("git", ["--git-dir", destination, "cat-file", "blob", entry.objectId], {
|
|
199
|
+
maxOutputBytes: SKILL_TREE_LIMITS.maxFileBytes,
|
|
200
|
+
});
|
|
201
|
+
if (content.byteLength > SKILL_TREE_LIMITS.maxFileBytes) {
|
|
202
|
+
throw new Error(`Selected Skill file exceeds the safe size limit: ${entry.relativePath}`);
|
|
203
|
+
}
|
|
204
|
+
totalBytes += content.byteLength;
|
|
205
|
+
if (totalBytes > SKILL_TREE_LIMITS.maxTotalBytes) {
|
|
206
|
+
throw new Error("Selected Skill tree exceeds the safe total size limit");
|
|
207
|
+
}
|
|
208
|
+
files.push({ path: entry.relativePath, content, executable: entry.executable });
|
|
209
|
+
}
|
|
210
|
+
const hasSkillManifest = files.some((file) => file.path === `${relativeSkillPath}/SKILL.md`);
|
|
211
|
+
if (!hasSkillManifest) {
|
|
212
|
+
throw new Error(`Selected Skill directory does not contain ${relativeSkillPath}/SKILL.md`);
|
|
213
|
+
}
|
|
214
|
+
return Object.freeze({
|
|
215
|
+
resolvedVersion: commit,
|
|
216
|
+
files: Object.freeze(files),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/** Resolves the selected ref to an immutable commit without refreshing the mirror. */
|
|
220
|
+
async readResolvedCommit(source) {
|
|
221
|
+
const destination = await this.requireMirror(source);
|
|
222
|
+
const revision = source.ref === undefined ? "HEAD" : validateGitRef(source.ref);
|
|
223
|
+
const commit = (await this.runner.run("git", [
|
|
224
|
+
"--git-dir",
|
|
225
|
+
destination,
|
|
226
|
+
"rev-parse",
|
|
227
|
+
"--verify",
|
|
228
|
+
`${revision}^{commit}`,
|
|
229
|
+
], { maxOutputBytes: 128 })).trim();
|
|
230
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(commit)) {
|
|
231
|
+
throw new Error(`Git Source mirror has no safe HEAD snapshot: ${source.id}`);
|
|
232
|
+
}
|
|
233
|
+
return commit;
|
|
234
|
+
}
|
|
235
|
+
async readSnapshot(source) {
|
|
236
|
+
const destination = await this.requireMirror(source);
|
|
237
|
+
const commit = await this.readResolvedCommit(source);
|
|
238
|
+
const tree = await this.runner.run("git", ["--git-dir", destination, "ls-tree", "-r", "-z", commit]);
|
|
239
|
+
const files = [];
|
|
240
|
+
for (const record of tree.split("\0")) {
|
|
241
|
+
if (!record) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const separator = record.indexOf("\t");
|
|
245
|
+
if (separator < 0) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const metadata = record.slice(0, separator).split(" ");
|
|
249
|
+
const relativePath = record.slice(separator + 1);
|
|
250
|
+
if (metadata[0] === "120000"
|
|
251
|
+
|| metadata[1] !== "blob"
|
|
252
|
+
|| !relativePath
|
|
253
|
+
|| path.posix.basename(relativePath) !== "SKILL.md"
|
|
254
|
+
|| relativePath.split("/").slice(0, -1).some((segment) => EXCLUDED_LIFECYCLE_SEGMENTS.has(segment))) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
files.push({
|
|
258
|
+
path: relativePath,
|
|
259
|
+
content: await this.runner.run("git", ["--git-dir", destination, "show", `${commit}:${relativePath}`]),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return Object.freeze(files);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
export function defaultGitSourceCachePath(environment = process.env, platform = process.platform, homeDirectory = homedir()) {
|
|
266
|
+
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
|
267
|
+
if (platform === "win32") {
|
|
268
|
+
const localAppData = environment.LOCALAPPDATA || environment.APPDATA;
|
|
269
|
+
return pathApi.join(localAppData || pathApi.join(homeDirectory, "AppData", "Local"), "Agent Depot", "git-sources");
|
|
270
|
+
}
|
|
271
|
+
const cacheHome = environment.XDG_CACHE_HOME;
|
|
272
|
+
return pathApi.join(cacheHome && pathApi.isAbsolute(cacheHome) ? cacheHome : pathApi.join(homeDirectory, ".cache"), "agent-depot", "git-sources");
|
|
273
|
+
}
|
|
274
|
+
export function cachePathForSource(cachePath, source) {
|
|
275
|
+
const expectedId = sourceIdForUrl(source.url);
|
|
276
|
+
if (source.kind !== "git" || source.id !== expectedId) {
|
|
277
|
+
throw new GitSourceAccessError(source, "the Source identity does not match its registered URL");
|
|
278
|
+
}
|
|
279
|
+
return cachePathForValidatedSource(cachePath, source);
|
|
280
|
+
}
|
|
281
|
+
function cachePathForValidatedSource(cachePath, source) {
|
|
282
|
+
return path.join(cachePath, source.id.slice("git:".length));
|
|
283
|
+
}
|
|
284
|
+
async function pathType(candidate) {
|
|
285
|
+
try {
|
|
286
|
+
const information = await lstat(candidate);
|
|
287
|
+
if (information.isSymbolicLink()) {
|
|
288
|
+
return "symlink";
|
|
289
|
+
}
|
|
290
|
+
return information.isDirectory() ? "directory" : "file";
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (isMissing(error)) {
|
|
294
|
+
return "missing";
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function acquireSourceLock(lockPath) {
|
|
300
|
+
const deadline = Date.now() + SOURCE_LOCK_TIMEOUT_MS;
|
|
301
|
+
while (true) {
|
|
302
|
+
try {
|
|
303
|
+
await mkdir(lockPath);
|
|
304
|
+
return async () => {
|
|
305
|
+
await rmdir(lockPath);
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (!isAlreadyExists(error)) {
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
if ((await pathType(lockPath)) === "symlink") {
|
|
313
|
+
throw new Error("the Source cache lock is a symbolic link");
|
|
314
|
+
}
|
|
315
|
+
if (Date.now() >= deadline) {
|
|
316
|
+
throw new Error(`the Source cache lock directory is busy: ${lockPath}; another refresh might be active. Wait for it to finish before retrying`);
|
|
317
|
+
}
|
|
318
|
+
await new Promise((resolve) => setTimeout(resolve, SOURCE_LOCK_RETRY_MS));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
export function validateGitRef(candidate) {
|
|
323
|
+
if (typeof candidate !== "string" || candidate.length === 0 || candidate.trim() !== candidate ||
|
|
324
|
+
Array.from(candidate).some((character) => (character.codePointAt(0) ?? 0) <= 0x20) ||
|
|
325
|
+
/[~^:?*[\\]/u.test(candidate) || candidate.includes("..") || candidate.includes("@{")) {
|
|
326
|
+
throw new Error("Git Source ref contains unsafe characters");
|
|
327
|
+
}
|
|
328
|
+
const segments = candidate.split("/");
|
|
329
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment.endsWith(".") || segment.startsWith("."))) {
|
|
330
|
+
throw new Error("Git Source ref contains an unsafe path segment");
|
|
331
|
+
}
|
|
332
|
+
return candidate;
|
|
333
|
+
}
|
|
334
|
+
export function validateSkillDirectoryPath(candidate) {
|
|
335
|
+
if (typeof candidate !== "string" || candidate.length === 0) {
|
|
336
|
+
throw new Error("Selected Skill path must be a non-empty relative POSIX path");
|
|
337
|
+
}
|
|
338
|
+
if (Buffer.byteLength(candidate, "utf8") > SKILL_TREE_LIMITS.maxPathBytes
|
|
339
|
+
|| candidate.includes("\\")
|
|
340
|
+
|| Array.from(candidate).some((character) => {
|
|
341
|
+
const code = character.codePointAt(0) ?? 0;
|
|
342
|
+
return code <= 0x1f || code === 0x7f;
|
|
343
|
+
})
|
|
344
|
+
|| candidate.startsWith("/")
|
|
345
|
+
|| candidate.endsWith("/")) {
|
|
346
|
+
throw new Error("Selected Skill path contains unsafe characters");
|
|
347
|
+
}
|
|
348
|
+
const segments = candidate.split("/");
|
|
349
|
+
if (segments.length > SKILL_TREE_LIMITS.maxDepth || segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
350
|
+
throw new Error("Selected Skill path must not contain traversal segments");
|
|
351
|
+
}
|
|
352
|
+
return candidate;
|
|
353
|
+
}
|
|
354
|
+
function isSafeSourcePath(candidate) {
|
|
355
|
+
try {
|
|
356
|
+
validateSkillDirectoryPath(candidate);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function isMissing(error) {
|
|
364
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
365
|
+
}
|
|
366
|
+
function isAlreadyExists(error) {
|
|
367
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
368
|
+
}
|
|
369
|
+
const GIT_PROTOCOLS = new Set(["git:", "http:", "https:", "ssh:"]);
|
|
370
|
+
export function canonicalizeGitSourceUrl(input) {
|
|
371
|
+
if (typeof input !== "string" || input.length === 0 || input.trim() !== input) {
|
|
372
|
+
throw new Error("Git Source URL must be a non-empty URL without surrounding whitespace");
|
|
373
|
+
}
|
|
374
|
+
if (/\s/u.test(input) || input.includes("\\") || Array.from(input).some((character) => {
|
|
375
|
+
const code = character.codePointAt(0) ?? 0;
|
|
376
|
+
return code <= 0x1f || code === 0x7f;
|
|
377
|
+
})) {
|
|
378
|
+
throw new Error("Git Source URL contains unsafe characters");
|
|
379
|
+
}
|
|
380
|
+
let parsed;
|
|
381
|
+
try {
|
|
382
|
+
parsed = new URL(input);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
throw new Error("Git Source URL is invalid");
|
|
386
|
+
}
|
|
387
|
+
if (!GIT_PROTOCOLS.has(parsed.protocol)) {
|
|
388
|
+
throw new Error(`Unsupported Git Source protocol: ${parsed.protocol}`);
|
|
389
|
+
}
|
|
390
|
+
if (!parsed.hostname || parsed.password || parsed.search || parsed.hash) {
|
|
391
|
+
throw new Error("Git Source URL contains unsupported or unsafe components");
|
|
392
|
+
}
|
|
393
|
+
if (parsed.username && parsed.username !== "git") {
|
|
394
|
+
throw new Error("Git Source URL credentials are not allowed");
|
|
395
|
+
}
|
|
396
|
+
if ((parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.username) {
|
|
397
|
+
throw new Error("HTTP Git Source URLs cannot contain credentials");
|
|
398
|
+
}
|
|
399
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/u, "");
|
|
400
|
+
if (parsed.pathname === "/" || parsed.pathname.length < 2) {
|
|
401
|
+
throw new Error("Git Source URL must identify a repository path");
|
|
402
|
+
}
|
|
403
|
+
return parsed.toString();
|
|
404
|
+
}
|
|
405
|
+
export function sourceIdForUrl(url) {
|
|
406
|
+
const canonicalUrl = canonicalizeGitSourceUrl(url);
|
|
407
|
+
const digest = createHash("sha256").update(canonicalUrl, "utf8").digest("hex").slice(0, 24);
|
|
408
|
+
return `git:${digest}`;
|
|
409
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** True when the paths are equal or one contains the other. Callers normalize first. */
|
|
4
|
+
export function pathsOverlap(left, right) {
|
|
5
|
+
const relative = path.relative(left, right);
|
|
6
|
+
const reverse = path.relative(right, left);
|
|
7
|
+
return relative === "" || reverse === "" ||
|
|
8
|
+
(!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ||
|
|
9
|
+
(!reverse.startsWith(`..${path.sep}`) && !path.isAbsolute(reverse));
|
|
10
|
+
}
|
|
11
|
+
/** Walks every existing component of a path and rejects the first symbolic link. */
|
|
12
|
+
export async function assertNoSymlinkPath(candidate, label) {
|
|
13
|
+
const absolute = path.resolve(candidate);
|
|
14
|
+
const root = path.parse(absolute).root;
|
|
15
|
+
let current = root;
|
|
16
|
+
const segments = path.relative(root, absolute).split(path.sep).filter(Boolean);
|
|
17
|
+
for (const segment of segments) {
|
|
18
|
+
current = path.join(current, segment);
|
|
19
|
+
try {
|
|
20
|
+
if ((await lstat(current)).isSymbolicLink()) {
|
|
21
|
+
throw new Error(`${label} contains a symbolic link: ${current}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** Runs a command with an argument vector and no shell; rejects only when spawning fails. */
|
|
3
|
+
export function runProcess(command, args, options = {}) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const child = spawn(command, [...args], {
|
|
6
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
7
|
+
shell: false,
|
|
8
|
+
stdio: ["ignore", options.captureStdout ? "pipe" : "ignore", "pipe"],
|
|
9
|
+
});
|
|
10
|
+
const stdout = [];
|
|
11
|
+
let stdoutBytes = 0;
|
|
12
|
+
let stderr = "";
|
|
13
|
+
let stderrBytes = 0;
|
|
14
|
+
let outputTooLarge = false;
|
|
15
|
+
child.stdout?.on("data", (chunk) => {
|
|
16
|
+
stdoutBytes += chunk.length;
|
|
17
|
+
if (options.maxOutputBytes !== undefined && stdoutBytes > options.maxOutputBytes) {
|
|
18
|
+
outputTooLarge = true;
|
|
19
|
+
child.kill();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
stdout.push(chunk);
|
|
23
|
+
});
|
|
24
|
+
child.stderr?.setEncoding("utf8");
|
|
25
|
+
child.stderr?.on("data", (chunk) => {
|
|
26
|
+
const bytes = Buffer.from(chunk);
|
|
27
|
+
const remaining = options.maxStderrBytes === undefined
|
|
28
|
+
? bytes.length : Math.max(0, options.maxStderrBytes - stderrBytes);
|
|
29
|
+
stderrBytes += bytes.length;
|
|
30
|
+
stderr += bytes.subarray(0, remaining).toString("utf8");
|
|
31
|
+
if (options.maxStderrBytes !== undefined && stderrBytes > options.maxStderrBytes) {
|
|
32
|
+
outputTooLarge = true;
|
|
33
|
+
child.kill();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
child.on("error", reject);
|
|
37
|
+
child.on("close", (code, signal) => {
|
|
38
|
+
resolve({ code, signal, stdout: Buffer.concat(stdout), stderr, outputTooLarge });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|