loadout-ai 0.1.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/LICENSE +21 -0
- package/MASTER_PLAN.md +1711 -0
- package/README.md +453 -0
- package/SECURITY.md +18 -0
- package/SIMPLE_PLAN.md +44 -0
- package/catalog/discovered.json +10469 -0
- package/catalog/packages.json +1150 -0
- package/dashboard/app.js +607 -0
- package/dashboard/index.html +249 -0
- package/dashboard/styles.css +384 -0
- package/dist/src/cli.js +2735 -0
- package/dist/src/core/active-policy.js +233 -0
- package/dist/src/core/active-set.js +382 -0
- package/dist/src/core/adapters.js +254 -0
- package/dist/src/core/adopt.js +92 -0
- package/dist/src/core/agent-health-score.js +475 -0
- package/dist/src/core/agent-inspection.js +152 -0
- package/dist/src/core/agent-versions.js +131 -0
- package/dist/src/core/api.js +90 -0
- package/dist/src/core/atomic-file.js +22 -0
- package/dist/src/core/audit.js +174 -0
- package/dist/src/core/benchmark-campaign.js +496 -0
- package/dist/src/core/benchmark-evidence.js +458 -0
- package/dist/src/core/benchmark-fixtures.js +1157 -0
- package/dist/src/core/benchmark-runner.js +301 -0
- package/dist/src/core/benchmark-trust.js +795 -0
- package/dist/src/core/canary.js +77 -0
- package/dist/src/core/candidate-intelligence.js +661 -0
- package/dist/src/core/catalog-coverage.js +85 -0
- package/dist/src/core/catalog-install.js +186 -0
- package/dist/src/core/catalog-release.js +286 -0
- package/dist/src/core/catalog.js +327 -0
- package/dist/src/core/codex-mcp.js +122 -0
- package/dist/src/core/community.js +86 -0
- package/dist/src/core/compatibility-intelligence.js +465 -0
- package/dist/src/core/completion.js +160 -0
- package/dist/src/core/components.js +138 -0
- package/dist/src/core/conversion.js +56 -0
- package/dist/src/core/credentials.js +377 -0
- package/dist/src/core/demo.js +136 -0
- package/dist/src/core/diff.js +83 -0
- package/dist/src/core/discovery-connector.js +165 -0
- package/dist/src/core/doctor.js +95 -0
- package/dist/src/core/ecosystem-import.js +1072 -0
- package/dist/src/core/evaluate.js +72 -0
- package/dist/src/core/file-lock.js +140 -0
- package/dist/src/core/freshness-alerts.js +230 -0
- package/dist/src/core/github-discovery.js +97 -0
- package/dist/src/core/github.js +148 -0
- package/dist/src/core/head-to-head.js +437 -0
- package/dist/src/core/health-score-evidence.js +169 -0
- package/dist/src/core/health.js +129 -0
- package/dist/src/core/improve.js +223 -0
- package/dist/src/core/install.js +233 -0
- package/dist/src/core/intelligence-feed-build.js +59 -0
- package/dist/src/core/intelligence-feed.js +353 -0
- package/dist/src/core/loadout-badge.js +55 -0
- package/dist/src/core/loadout-card.js +87 -0
- package/dist/src/core/manifest.js +307 -0
- package/dist/src/core/mcp-recipes.js +419 -0
- package/dist/src/core/mcp-registry-discovery.js +455 -0
- package/dist/src/core/mcp.js +287 -0
- package/dist/src/core/model-config.js +102 -0
- package/dist/src/core/observations.js +87 -0
- package/dist/src/core/outcomes.js +164 -0
- package/dist/src/core/package.js +225 -0
- package/dist/src/core/paths.js +214 -0
- package/dist/src/core/portable.js +130 -0
- package/dist/src/core/private-discovery.js +45 -0
- package/dist/src/core/profiles.js +255 -0
- package/dist/src/core/provenance.js +278 -0
- package/dist/src/core/ranking.js +104 -0
- package/dist/src/core/recommend.js +181 -0
- package/dist/src/core/registry-api.js +97 -0
- package/dist/src/core/registry.js +316 -0
- package/dist/src/core/release-claims.js +175 -0
- package/dist/src/core/remove.js +122 -0
- package/dist/src/core/review-queue.js +200 -0
- package/dist/src/core/runtime-tool-recipe.js +315 -0
- package/dist/src/core/runtime-tools.js +453 -0
- package/dist/src/core/safety.js +234 -0
- package/dist/src/core/sandbox.js +78 -0
- package/dist/src/core/scheduler.js +264 -0
- package/dist/src/core/share-report.js +85 -0
- package/dist/src/core/signing.js +115 -0
- package/dist/src/core/skill-compare.js +239 -0
- package/dist/src/core/skill-inventory.js +193 -0
- package/dist/src/core/skill-security.js +340 -0
- package/dist/src/core/skills-sh-discovery.js +336 -0
- package/dist/src/core/skills.js +208 -0
- package/dist/src/core/snapshot.js +210 -0
- package/dist/src/core/source.js +305 -0
- package/dist/src/core/state.js +300 -0
- package/dist/src/core/sync.js +348 -0
- package/dist/src/core/terminal.js +53 -0
- package/dist/src/core/transaction.js +211 -0
- package/dist/src/core/update-watch.js +39 -0
- package/dist/src/core/update.js +216 -0
- package/dist/src/core/upgrade.js +207 -0
- package/dist/src/dashboard.js +418 -0
- package/dist/src/shared/schemas.js +336 -0
- package/dist/src/shared/types.js +1 -0
- package/docs/ACTIVE_SET.md +53 -0
- package/docs/ACTIVE_SET_POLICY.md +45 -0
- package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
- package/docs/CATALOG.md +78 -0
- package/docs/CATALOG_POLICY.md +41 -0
- package/docs/COMMUNITY_DISCOVERY.md +14 -0
- package/docs/COMPATIBILITY_POLICY.md +22 -0
- package/docs/CONVERSION_AND_SANDBOX.md +27 -0
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
- package/docs/DISCOVERED.md +280 -0
- package/docs/EVALUATION_PROTOCOL.md +40 -0
- package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
- package/docs/FEATURE_TEST_MATRIX.md +802 -0
- package/docs/GITHUB_AUTHORIZATION.md +38 -0
- package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
- package/docs/PROVIDER_CONFIGURATION.md +45 -0
- package/docs/RELEASE_REVIEW.md +117 -0
- package/docs/SAFE_UPDATE_DEMO.md +25 -0
- package/docs/SCHEMA_DECISIONS.md +25 -0
- package/docs/TEAM_POLICY.md +18 -0
- package/docs/TESTING.md +190 -0
- package/package.json +82 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { lstat, mkdtemp, readdir, rename, rm, writeFile, } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { loadoutHome, ensureDirectory } from "./paths.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
async function enforceRepositoryBounds(root, options) {
|
|
10
|
+
if (options.maxBytes === undefined && options.maxFiles === undefined)
|
|
11
|
+
return;
|
|
12
|
+
let bytes = 0;
|
|
13
|
+
let files = 0;
|
|
14
|
+
const pending = [root];
|
|
15
|
+
while (pending.length) {
|
|
16
|
+
const directory = pending.pop();
|
|
17
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
18
|
+
const path = join(directory, entry.name);
|
|
19
|
+
if (entry.isSymbolicLink())
|
|
20
|
+
continue;
|
|
21
|
+
if (entry.isDirectory()) {
|
|
22
|
+
pending.push(path);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!entry.isFile())
|
|
26
|
+
continue;
|
|
27
|
+
const info = await lstat(path);
|
|
28
|
+
bytes += info.size;
|
|
29
|
+
files++;
|
|
30
|
+
if (options.maxBytes !== undefined && bytes > options.maxBytes)
|
|
31
|
+
throw new Error(`Repository exceeds the ${options.maxBytes} byte inspection limit`);
|
|
32
|
+
if (options.maxFiles !== undefined && files > options.maxFiles)
|
|
33
|
+
throw new Error(`Repository exceeds the ${options.maxFiles} file inspection limit`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function validateGitHubTreeBounds(value, options) {
|
|
38
|
+
if (options.maxBytes === undefined && options.maxFiles === undefined)
|
|
39
|
+
return;
|
|
40
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
41
|
+
throw new Error("GitHub tree preflight returned an invalid response");
|
|
42
|
+
const response = value;
|
|
43
|
+
if (response.truncated)
|
|
44
|
+
throw new Error("GitHub tree preflight was truncated; refusing checkout");
|
|
45
|
+
if (!Array.isArray(response.tree))
|
|
46
|
+
throw new Error("GitHub tree preflight has no tree entries");
|
|
47
|
+
let bytes = 0;
|
|
48
|
+
let files = 0;
|
|
49
|
+
for (const entry of response.tree) {
|
|
50
|
+
if (entry.type !== "blob")
|
|
51
|
+
continue;
|
|
52
|
+
if (typeof entry.path !== "string" ||
|
|
53
|
+
typeof entry.size !== "number" ||
|
|
54
|
+
!Number.isSafeInteger(entry.size) ||
|
|
55
|
+
entry.size < 0)
|
|
56
|
+
throw new Error("GitHub tree preflight contains invalid blob evidence");
|
|
57
|
+
files++;
|
|
58
|
+
bytes += entry.size;
|
|
59
|
+
if (options.maxBytes !== undefined && bytes > options.maxBytes)
|
|
60
|
+
throw new Error(`Repository exceeds the ${options.maxBytes} byte inspection limit`);
|
|
61
|
+
if (options.maxFiles !== undefined && files > options.maxFiles)
|
|
62
|
+
throw new Error(`Repository exceeds the ${options.maxFiles} file inspection limit`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function enforceGitHubTreeBounds(repository, commit, options) {
|
|
66
|
+
if (options.maxBytes === undefined && options.maxFiles === undefined)
|
|
67
|
+
return;
|
|
68
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 120_000);
|
|
69
|
+
const token = process.env.GITHUB_TOKEN?.trim();
|
|
70
|
+
const response = await (options.fetcher ?? fetch)(`https://api.github.com/repos/${repository}/git/trees/${commit}?recursive=1`, {
|
|
71
|
+
headers: {
|
|
72
|
+
accept: "application/vnd.github+json",
|
|
73
|
+
"user-agent": "loadout-ai",
|
|
74
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
75
|
+
},
|
|
76
|
+
signal,
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok)
|
|
79
|
+
throw new Error(`GitHub tree preflight failed with HTTP ${response.status}; refusing an unbounded checkout`);
|
|
80
|
+
const length = Number(response.headers.get("content-length") ?? 0);
|
|
81
|
+
if (length > 25 * 1024 * 1024)
|
|
82
|
+
throw new Error("GitHub tree preflight response exceeds 25 MiB");
|
|
83
|
+
if (!response.body)
|
|
84
|
+
throw new Error("GitHub tree preflight response has no body");
|
|
85
|
+
const reader = response.body.getReader();
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let total = 0;
|
|
88
|
+
while (true) {
|
|
89
|
+
const { done, value } = await reader.read();
|
|
90
|
+
if (done)
|
|
91
|
+
break;
|
|
92
|
+
total += value.byteLength;
|
|
93
|
+
if (total > 25 * 1024 * 1024) {
|
|
94
|
+
await reader.cancel();
|
|
95
|
+
throw new Error("GitHub tree preflight response exceeds 25 MiB");
|
|
96
|
+
}
|
|
97
|
+
chunks.push(value);
|
|
98
|
+
}
|
|
99
|
+
const bytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
100
|
+
let value;
|
|
101
|
+
try {
|
|
102
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new Error("GitHub tree preflight returned invalid JSON");
|
|
106
|
+
}
|
|
107
|
+
validateGitHubTreeBounds(value, options);
|
|
108
|
+
}
|
|
109
|
+
async function isolatedGitEnvironment(root) {
|
|
110
|
+
const sandbox = join(root, ".loadout-git-sandbox");
|
|
111
|
+
const hooks = join(sandbox, "hooks");
|
|
112
|
+
const templates = join(sandbox, "templates");
|
|
113
|
+
const config = join(sandbox, "empty.gitconfig");
|
|
114
|
+
await Promise.all([ensureDirectory(hooks), ensureDirectory(templates)]);
|
|
115
|
+
await writeFile(config, "", { flag: "a", mode: 0o600 });
|
|
116
|
+
const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
|
|
117
|
+
return {
|
|
118
|
+
...inherited,
|
|
119
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
120
|
+
GIT_CONFIG_SYSTEM: config,
|
|
121
|
+
GIT_CONFIG_GLOBAL: config,
|
|
122
|
+
GIT_TEMPLATE_DIR: templates,
|
|
123
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
124
|
+
GIT_LFS_SKIP_SMUDGE: "1",
|
|
125
|
+
GIT_CONFIG_COUNT: "2",
|
|
126
|
+
GIT_CONFIG_KEY_0: "core.hooksPath",
|
|
127
|
+
GIT_CONFIG_VALUE_0: hooks,
|
|
128
|
+
GIT_CONFIG_KEY_1: "credential.helper",
|
|
129
|
+
GIT_CONFIG_VALUE_1: "",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Stable on-disk location used to retain fetched repository revisions. */
|
|
133
|
+
export function repositoryCachePath(repository, commit) {
|
|
134
|
+
return join(loadoutHome(), "cache", repository.replace("/", "__"), commit);
|
|
135
|
+
}
|
|
136
|
+
export function normalizeRepository(input) {
|
|
137
|
+
const value = input
|
|
138
|
+
.replace(/^https?:\/\/github\.com\//, "")
|
|
139
|
+
.replace(/^git@github\.com:/, "")
|
|
140
|
+
.replace(/\.git$/, "")
|
|
141
|
+
.replace(/\/$/, "");
|
|
142
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) {
|
|
143
|
+
throw new Error(`Expected a public GitHub repository like owner/repo, received: ${input}`);
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
function normalizeRef(ref) {
|
|
148
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) ||
|
|
149
|
+
ref.includes("..") ||
|
|
150
|
+
ref.endsWith("/"))
|
|
151
|
+
throw new Error(`Invalid Git ref: ${ref}`);
|
|
152
|
+
return ref;
|
|
153
|
+
}
|
|
154
|
+
export async function fetchRepositorySnapshot(input, options = {}) {
|
|
155
|
+
const repository = normalizeRepository(input);
|
|
156
|
+
const gitEnvironment = await isolatedGitEnvironment(loadoutHome());
|
|
157
|
+
const ref = options.ref ? normalizeRef(options.ref) : undefined;
|
|
158
|
+
if (ref && /^[0-9a-f]{40}$/i.test(ref)) {
|
|
159
|
+
const cached = repositoryCachePath(repository, ref);
|
|
160
|
+
let reusable = false;
|
|
161
|
+
try {
|
|
162
|
+
const [{ stdout: head }, { stdout: status }] = await Promise.all([
|
|
163
|
+
execFileAsync("git", ["-C", cached, "rev-parse", "HEAD"], {
|
|
164
|
+
timeout: options.timeoutMs,
|
|
165
|
+
env: gitEnvironment,
|
|
166
|
+
}),
|
|
167
|
+
execFileAsync("git", ["-C", cached, "status", "--porcelain"], {
|
|
168
|
+
timeout: options.timeoutMs,
|
|
169
|
+
env: gitEnvironment,
|
|
170
|
+
}),
|
|
171
|
+
]);
|
|
172
|
+
reusable =
|
|
173
|
+
head.trim().toLowerCase() === ref.toLowerCase() && status.trim() === "";
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
/* missing or modified cache: fetch a clean reviewed snapshot below */
|
|
177
|
+
}
|
|
178
|
+
if (reusable) {
|
|
179
|
+
await enforceRepositoryBounds(cached, options);
|
|
180
|
+
return { repository, commit: ref, path: cached };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const temporary = await mkdtemp(join(tmpdir(), "loadout-repository-"));
|
|
184
|
+
try {
|
|
185
|
+
const url = `https://github.com/${repository}.git`;
|
|
186
|
+
const gitOptions = {
|
|
187
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
188
|
+
timeout: options.timeoutMs,
|
|
189
|
+
env: {
|
|
190
|
+
...gitEnvironment,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
if (ref && /^[0-9a-f]{40}$/i.test(ref)) {
|
|
194
|
+
// `git clone --branch` accepts branch or tag names, not an arbitrary
|
|
195
|
+
// reviewed commit. Fetch the immutable catalog commit directly so a
|
|
196
|
+
// later default-branch change cannot alter what Loadout installs.
|
|
197
|
+
await execFileAsync("git", ["init", "--quiet", temporary], gitOptions);
|
|
198
|
+
await execFileAsync("git", ["-C", temporary, "remote", "add", "origin", url], gitOptions);
|
|
199
|
+
await execFileAsync("git", [
|
|
200
|
+
"-C",
|
|
201
|
+
temporary,
|
|
202
|
+
"fetch",
|
|
203
|
+
"--depth",
|
|
204
|
+
"1",
|
|
205
|
+
...(options.maxBytes !== undefined || options.maxFiles !== undefined
|
|
206
|
+
? ["--filter=blob:none"]
|
|
207
|
+
: []),
|
|
208
|
+
"origin",
|
|
209
|
+
ref,
|
|
210
|
+
], gitOptions);
|
|
211
|
+
const { stdout: fetchedCommit } = await execFileAsync("git", ["-C", temporary, "rev-parse", "FETCH_HEAD"], gitOptions);
|
|
212
|
+
await enforceGitHubTreeBounds(repository, fetchedCommit.trim(), options);
|
|
213
|
+
await execFileAsync("git", ["-C", temporary, "checkout", "--quiet", "--detach", "FETCH_HEAD"], gitOptions);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const refArgs = ref ? ["--branch", ref] : [];
|
|
217
|
+
await execFileAsync("git", [
|
|
218
|
+
"clone",
|
|
219
|
+
"--depth",
|
|
220
|
+
"1",
|
|
221
|
+
...refArgs,
|
|
222
|
+
...(options.maxBytes !== undefined || options.maxFiles !== undefined
|
|
223
|
+
? ["--filter=blob:none", "--no-checkout"]
|
|
224
|
+
: []),
|
|
225
|
+
"--",
|
|
226
|
+
url,
|
|
227
|
+
temporary,
|
|
228
|
+
], gitOptions);
|
|
229
|
+
if (options.maxBytes !== undefined || options.maxFiles !== undefined) {
|
|
230
|
+
const { stdout: headCommit } = await execFileAsync("git", ["-C", temporary, "rev-parse", "HEAD"], gitOptions);
|
|
231
|
+
await enforceGitHubTreeBounds(repository, headCommit.trim(), options);
|
|
232
|
+
await execFileAsync("git", ["-C", temporary, "checkout", "--quiet", "HEAD"], gitOptions);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const { stdout } = await execFileAsync("git", ["-C", temporary, "rev-parse", "HEAD"], { timeout: options.timeoutMs, env: gitEnvironment });
|
|
236
|
+
const commit = stdout.trim();
|
|
237
|
+
if (!/^[0-9a-f]{40}$/i.test(commit))
|
|
238
|
+
throw new Error(`Git returned an invalid commit for ${repository}`);
|
|
239
|
+
await enforceRepositoryBounds(temporary, options);
|
|
240
|
+
const cachePath = repositoryCachePath(repository, commit);
|
|
241
|
+
await ensureDirectory(join(loadoutHome(), "cache", repository.replace("/", "__")));
|
|
242
|
+
await rm(cachePath, { recursive: true, force: true });
|
|
243
|
+
await rename(temporary, cachePath);
|
|
244
|
+
return { repository, commit, path: cachePath };
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
await rm(temporary, { recursive: true, force: true });
|
|
248
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
249
|
+
throw new Error(`Could not fetch ${repository}: ${message}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function normalizeGitUrl(input) {
|
|
253
|
+
const value = input.trim();
|
|
254
|
+
if (value !== input || /[\0\r\n]/.test(value) || value.startsWith("-"))
|
|
255
|
+
throw new Error("Invalid Git URL");
|
|
256
|
+
const httpsOrSsh = /^(?:https|ssh):\/\/[^\s]+$/i.test(value);
|
|
257
|
+
const scp = /^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9._~/-]+(?:\.git)?$/.test(value);
|
|
258
|
+
if (!httpsOrSsh && !scp)
|
|
259
|
+
throw new Error("Generic Git sources require an HTTPS or SSH URL");
|
|
260
|
+
if (httpsOrSsh) {
|
|
261
|
+
const parsed = new URL(value);
|
|
262
|
+
// Git includes failing command arguments in its error text. Reject URLs
|
|
263
|
+
// that can embed a token before invoking Git, rather than leaking one in
|
|
264
|
+
// a clone failure.
|
|
265
|
+
if (parsed.password || (parsed.protocol === "https:" && parsed.username)) {
|
|
266
|
+
throw new Error("Git URLs must not embed credentials; use an SSH agent or credential helper");
|
|
267
|
+
}
|
|
268
|
+
if (parsed.search || parsed.hash)
|
|
269
|
+
throw new Error("Git URLs must not include query strings or fragments");
|
|
270
|
+
}
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
/** Fetch a generic Git source without running repository hooks or lifecycle scripts. */
|
|
274
|
+
export async function fetchGitSnapshot(input, options = {}) {
|
|
275
|
+
if (options.maxBytes !== undefined || options.maxFiles !== undefined)
|
|
276
|
+
throw new Error("Pre-check size/file bounds are supported only for public GitHub owner/repository sources; refusing an unbounded generic Git clone");
|
|
277
|
+
const url = normalizeGitUrl(input);
|
|
278
|
+
const temporary = await mkdtemp(join(tmpdir(), "loadout-git-"));
|
|
279
|
+
try {
|
|
280
|
+
const gitEnvironment = await isolatedGitEnvironment(loadoutHome());
|
|
281
|
+
const refArgs = options.ref ? ["--branch", normalizeRef(options.ref)] : [];
|
|
282
|
+
await execFileAsync("git", ["clone", "--depth", "1", ...refArgs, "--", url, temporary], {
|
|
283
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
284
|
+
timeout: options.timeoutMs,
|
|
285
|
+
env: {
|
|
286
|
+
...gitEnvironment,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const { stdout } = await execFileAsync("git", ["-C", temporary, "rev-parse", "HEAD"], { timeout: options.timeoutMs, env: gitEnvironment });
|
|
290
|
+
const commit = stdout.trim();
|
|
291
|
+
if (!/^[0-9a-f]{40}$/i.test(commit))
|
|
292
|
+
throw new Error("Git returned an invalid commit");
|
|
293
|
+
await enforceRepositoryBounds(temporary, options);
|
|
294
|
+
const key = createHash("sha256").update(url).digest("hex");
|
|
295
|
+
const cachePath = join(loadoutHome(), "cache", "git", key, commit);
|
|
296
|
+
await ensureDirectory(join(loadoutHome(), "cache", "git", key));
|
|
297
|
+
await rm(cachePath, { recursive: true, force: true });
|
|
298
|
+
await rename(temporary, cachePath);
|
|
299
|
+
return { repository: url, commit, path: cachePath };
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
await rm(temporary, { recursive: true, force: true });
|
|
303
|
+
throw new Error(`Could not fetch Git source: ${error instanceof Error ? error.message : String(error)}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, join, relative, sep } from "node:path";
|
|
4
|
+
import { formatSchemaError, installStateSchema } from "../shared/schemas.js";
|
|
5
|
+
import { ensureDirectory, loadoutHome } from "./paths.js";
|
|
6
|
+
import { writeFileAtomically } from "./atomic-file.js";
|
|
7
|
+
const stateFile = () => join(loadoutHome(), "state.json");
|
|
8
|
+
export const installStatePath = () => stateFile();
|
|
9
|
+
export async function writeInstallState(state) {
|
|
10
|
+
await ensureDirectory(loadoutHome());
|
|
11
|
+
await writeFileAtomically(stateFile(), `${JSON.stringify(state, null, 2)}\n`);
|
|
12
|
+
}
|
|
13
|
+
export async function readInstallState() {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(await readFile(stateFile(), "utf8"));
|
|
16
|
+
const result = installStateSchema.safeParse(parsed);
|
|
17
|
+
if (!result.success)
|
|
18
|
+
throw new Error(formatSchemaError(result.error));
|
|
19
|
+
return result.data;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error &&
|
|
23
|
+
typeof error === "object" &&
|
|
24
|
+
"code" in error &&
|
|
25
|
+
error.code === "ENOENT") {
|
|
26
|
+
return { version: 1, installs: [], mcpInstalls: [], activations: [] };
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Loadout state is invalid at ${stateFile()}: ${error instanceof Error ? error.message : String(error)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function activationLibraryPath(packageId, agent, unitId) {
|
|
32
|
+
const safe = packageId.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 80);
|
|
33
|
+
const suffix = createHash("sha256")
|
|
34
|
+
.update(packageId)
|
|
35
|
+
.digest("hex")
|
|
36
|
+
.slice(0, 10);
|
|
37
|
+
const base = join(loadoutHome(), "library", `${safe || "package"}-${suffix}`, agent);
|
|
38
|
+
if (!unitId)
|
|
39
|
+
return base;
|
|
40
|
+
const safeUnit = unitId.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 80);
|
|
41
|
+
const unitSuffix = createHash("sha256")
|
|
42
|
+
.update(unitId)
|
|
43
|
+
.digest("hex")
|
|
44
|
+
.slice(0, 8);
|
|
45
|
+
return join(base, `${safeUnit || "skill"}-${unitSuffix}`);
|
|
46
|
+
}
|
|
47
|
+
function activationRecordsForPlan(plan, metadata = {}) {
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
return plan.targetAgents.flatMap((agent) => {
|
|
50
|
+
const targets = [
|
|
51
|
+
...new Set(plan.files
|
|
52
|
+
.filter((file) => (file.componentType === undefined ||
|
|
53
|
+
file.componentType === "skill") &&
|
|
54
|
+
(file.targetAgent === agent ||
|
|
55
|
+
(!file.targetAgent && plan.targetAgents.length === 1)))
|
|
56
|
+
.map((file) => file.target)),
|
|
57
|
+
];
|
|
58
|
+
return targets.map((activePath) => {
|
|
59
|
+
const unitId = basename(activePath);
|
|
60
|
+
return {
|
|
61
|
+
packageId: plan.packageId,
|
|
62
|
+
unitId,
|
|
63
|
+
agent,
|
|
64
|
+
cacheState: "missing",
|
|
65
|
+
reviewState: metadata.reviewed ? "reviewed" : "unreviewed",
|
|
66
|
+
installationState: "installed",
|
|
67
|
+
activationState: "active",
|
|
68
|
+
libraryPath: activationLibraryPath(plan.packageId, agent, unitId),
|
|
69
|
+
targets: [
|
|
70
|
+
{
|
|
71
|
+
activePath,
|
|
72
|
+
libraryRelativePath: unitId,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
libraryFiles: [],
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function mergeActivationRecords(current, records) {
|
|
82
|
+
const keys = new Set(records.map((item) => `${item.packageId}\0${item.agent}\0${item.unitId ?? ""}`));
|
|
83
|
+
return [
|
|
84
|
+
...(current ?? []).filter((item) => !keys.has(`${item.packageId}\0${item.agent}\0${item.unitId ?? ""}`)),
|
|
85
|
+
...records,
|
|
86
|
+
].sort((left, right) => left.packageId.localeCompare(right.packageId) ||
|
|
87
|
+
left.agent.localeCompare(right.agent) ||
|
|
88
|
+
(left.unitId ?? "").localeCompare(right.unitId ?? ""));
|
|
89
|
+
}
|
|
90
|
+
export async function hashDirectory(root) {
|
|
91
|
+
const files = [];
|
|
92
|
+
async function visit(directory) {
|
|
93
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
94
|
+
const path = join(directory, entry.name);
|
|
95
|
+
if (entry.isSymbolicLink())
|
|
96
|
+
throw new Error(`Refusing symlink while hashing installed files: ${path}`);
|
|
97
|
+
if (entry.isDirectory())
|
|
98
|
+
await visit(path);
|
|
99
|
+
else if (entry.isFile()) {
|
|
100
|
+
const content = await readFile(path);
|
|
101
|
+
files.push({
|
|
102
|
+
path,
|
|
103
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
let rootInfo;
|
|
109
|
+
try {
|
|
110
|
+
rootInfo = await stat(root);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error &&
|
|
114
|
+
typeof error === "object" &&
|
|
115
|
+
"code" in error &&
|
|
116
|
+
error.code === "ENOENT") {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
if (!rootInfo.isDirectory())
|
|
122
|
+
return [];
|
|
123
|
+
await visit(root);
|
|
124
|
+
return files;
|
|
125
|
+
}
|
|
126
|
+
export async function recordInstall(plan, snapshotId, metadata = {}) {
|
|
127
|
+
const record = await createInstallRecord(plan, snapshotId, metadata);
|
|
128
|
+
const state = await readInstallState();
|
|
129
|
+
state.installs = [
|
|
130
|
+
...state.installs.filter((entry) => entry.packageId !== record.packageId),
|
|
131
|
+
record,
|
|
132
|
+
];
|
|
133
|
+
state.activations = mergeActivationRecords(state.activations, activationRecordsForPlan(plan, metadata));
|
|
134
|
+
await writeInstallState(state);
|
|
135
|
+
return record;
|
|
136
|
+
}
|
|
137
|
+
async function createInstallRecord(plan, snapshotId, metadata = {}) {
|
|
138
|
+
const files = (await Promise.all([...new Set(plan.files.map((file) => file.target))].map(hashDirectory))).flat();
|
|
139
|
+
return {
|
|
140
|
+
packageId: plan.packageId,
|
|
141
|
+
...metadata,
|
|
142
|
+
targetAgents: [...plan.targetAgents],
|
|
143
|
+
files,
|
|
144
|
+
snapshotId,
|
|
145
|
+
installedAt: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export async function recordInstallBatch(entries, snapshotId) {
|
|
149
|
+
const records = await Promise.all(entries.map((entry) => createInstallRecord(entry.plan, snapshotId, entry.metadata)));
|
|
150
|
+
const state = await readInstallState();
|
|
151
|
+
const ids = new Set(records.map((record) => record.packageId));
|
|
152
|
+
state.installs = [
|
|
153
|
+
...state.installs.filter((entry) => !ids.has(entry.packageId)),
|
|
154
|
+
...records,
|
|
155
|
+
];
|
|
156
|
+
state.activations = mergeActivationRecords(state.activations, entries.flatMap((entry) => activationRecordsForPlan(entry.plan, entry.metadata)));
|
|
157
|
+
await writeInstallState(state);
|
|
158
|
+
return records;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Record reviewed skills that were copied into Loadout's library but were not
|
|
162
|
+
* activated. Install hashes deliberately use their future active paths so the
|
|
163
|
+
* existing enable/disable verifier can prove the same bytes end to end.
|
|
164
|
+
*/
|
|
165
|
+
export async function recordLibraryInstallBatch(entries, snapshotId) {
|
|
166
|
+
const now = new Date().toISOString();
|
|
167
|
+
const activationRecords = [];
|
|
168
|
+
const records = [];
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
const installFiles = [];
|
|
171
|
+
const targetAgents = [];
|
|
172
|
+
for (const agent of entry.plan.targetAgents) {
|
|
173
|
+
const planned = entry.plan.files.filter((file) => (file.componentType === undefined ||
|
|
174
|
+
file.componentType === "skill") &&
|
|
175
|
+
(file.targetAgent === agent ||
|
|
176
|
+
(!file.targetAgent && entry.plan.targetAgents.length === 1)));
|
|
177
|
+
if (!planned.length)
|
|
178
|
+
continue;
|
|
179
|
+
targetAgents.push(agent);
|
|
180
|
+
const targets = [
|
|
181
|
+
...new Map(planned.map((file) => [
|
|
182
|
+
file.target,
|
|
183
|
+
{
|
|
184
|
+
activePath: file.target,
|
|
185
|
+
libraryRelativePath: basename(file.target),
|
|
186
|
+
},
|
|
187
|
+
])).values(),
|
|
188
|
+
];
|
|
189
|
+
for (const target of targets) {
|
|
190
|
+
const unitId = target.libraryRelativePath;
|
|
191
|
+
const libraryPath = activationLibraryPath(entry.plan.packageId, agent, unitId);
|
|
192
|
+
const libraryFiles = [];
|
|
193
|
+
const libraryTarget = join(libraryPath, target.libraryRelativePath);
|
|
194
|
+
for (const file of await hashDirectory(libraryTarget)) {
|
|
195
|
+
const child = relative(libraryTarget, file.path);
|
|
196
|
+
installFiles.push({
|
|
197
|
+
path: join(target.activePath, child),
|
|
198
|
+
sha256: file.sha256,
|
|
199
|
+
});
|
|
200
|
+
libraryFiles.push({
|
|
201
|
+
path: join(target.libraryRelativePath, child).split(sep).join("/"),
|
|
202
|
+
sha256: file.sha256,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
activationRecords.push({
|
|
206
|
+
packageId: entry.plan.packageId,
|
|
207
|
+
unitId,
|
|
208
|
+
agent,
|
|
209
|
+
cacheState: "downloaded",
|
|
210
|
+
reviewState: entry.metadata?.reviewed ? "reviewed" : "unreviewed",
|
|
211
|
+
installationState: "installed",
|
|
212
|
+
activationState: "disabled",
|
|
213
|
+
libraryPath,
|
|
214
|
+
targets: [target],
|
|
215
|
+
libraryFiles: libraryFiles.sort((left, right) => left.path.localeCompare(right.path)),
|
|
216
|
+
updatedAt: now,
|
|
217
|
+
snapshotId,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
records.push({
|
|
222
|
+
packageId: entry.plan.packageId,
|
|
223
|
+
...(entry.metadata?.repository
|
|
224
|
+
? { repository: entry.metadata.repository }
|
|
225
|
+
: {}),
|
|
226
|
+
...(entry.metadata?.resolvedCommit
|
|
227
|
+
? { resolvedCommit: entry.metadata.resolvedCommit }
|
|
228
|
+
: {}),
|
|
229
|
+
targetAgents,
|
|
230
|
+
files: installFiles.sort((left, right) => left.path.localeCompare(right.path)),
|
|
231
|
+
snapshotId,
|
|
232
|
+
installedAt: now,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const state = await readInstallState();
|
|
236
|
+
const ids = new Set(records.map((record) => record.packageId));
|
|
237
|
+
state.installs = [
|
|
238
|
+
...state.installs.filter((record) => !ids.has(record.packageId)),
|
|
239
|
+
...records,
|
|
240
|
+
];
|
|
241
|
+
state.activations = mergeActivationRecords(state.activations, activationRecords);
|
|
242
|
+
await writeInstallState(state);
|
|
243
|
+
return records;
|
|
244
|
+
}
|
|
245
|
+
function mcpFingerprint(plan) {
|
|
246
|
+
const servers = (plan.proposed.mcpServers ?? {});
|
|
247
|
+
return createHash("sha256")
|
|
248
|
+
.update(JSON.stringify(servers[plan.serverName] ?? null))
|
|
249
|
+
.digest("hex");
|
|
250
|
+
}
|
|
251
|
+
export async function recordInstallTransaction(entries, mcpEntries, snapshotId) {
|
|
252
|
+
const installs = await Promise.all(entries.map((entry) => createInstallRecord(entry.plan, snapshotId, entry.metadata)));
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
const mcpInstalls = mcpEntries.flatMap((entry) => entry.plan.changes.map((change) => ({
|
|
255
|
+
packageId: entry.packageId,
|
|
256
|
+
configPath: entry.plan.path,
|
|
257
|
+
serverName: change.serverName,
|
|
258
|
+
fingerprint: mcpFingerprint({
|
|
259
|
+
...entry.plan,
|
|
260
|
+
serverName: change.serverName,
|
|
261
|
+
}),
|
|
262
|
+
snapshotId,
|
|
263
|
+
installedAt: now,
|
|
264
|
+
})));
|
|
265
|
+
const state = await readInstallState();
|
|
266
|
+
const ids = new Set([
|
|
267
|
+
...installs.map((entry) => entry.packageId),
|
|
268
|
+
...mcpInstalls.map((entry) => entry.packageId),
|
|
269
|
+
]);
|
|
270
|
+
state.installs = [
|
|
271
|
+
...state.installs.filter((entry) => !ids.has(entry.packageId)),
|
|
272
|
+
...installs,
|
|
273
|
+
];
|
|
274
|
+
state.mcpInstalls = [
|
|
275
|
+
...(state.mcpInstalls ?? []).filter((entry) => !ids.has(entry.packageId)),
|
|
276
|
+
...mcpInstalls,
|
|
277
|
+
];
|
|
278
|
+
state.activations = mergeActivationRecords(state.activations, entries.flatMap((entry) => activationRecordsForPlan(entry.plan, entry.metadata)));
|
|
279
|
+
await writeInstallState(state);
|
|
280
|
+
return { installs, mcpInstalls };
|
|
281
|
+
}
|
|
282
|
+
export async function forgetInstall(packageId) {
|
|
283
|
+
const state = await readInstallState();
|
|
284
|
+
const installs = state.installs.filter((entry) => entry.packageId !== packageId);
|
|
285
|
+
if (installs.length === state.installs.length)
|
|
286
|
+
throw new Error(`Package is not managed by Loadout: ${packageId}`);
|
|
287
|
+
await writeInstallState({
|
|
288
|
+
version: 1,
|
|
289
|
+
installs,
|
|
290
|
+
mcpInstalls: (state.mcpInstalls ?? []).filter((entry) => entry.packageId !== packageId),
|
|
291
|
+
activations: (state.activations ?? []).map((entry) => entry.packageId === packageId
|
|
292
|
+
? {
|
|
293
|
+
...entry,
|
|
294
|
+
installationState: "removed",
|
|
295
|
+
activationState: "disabled",
|
|
296
|
+
updatedAt: new Date().toISOString(),
|
|
297
|
+
}
|
|
298
|
+
: entry),
|
|
299
|
+
});
|
|
300
|
+
}
|