skillgauntlet 1.1.0 → 1.2.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/README.md +30 -1
- package/bin/skillgauntlet.js +328 -132
- package/lib/browse.js +58 -0
- package/lib/chart.js +157 -0
- package/lib/depth.js +4 -1
- package/lib/discover.js +27 -2
- package/lib/findhost.js +83 -0
- package/lib/gain.js +10 -6
- package/lib/i18n.js +395 -34
- package/lib/models.js +38 -0
- package/lib/report.js +1 -1
- package/lib/schema.js +90 -0
- package/lib/scrub.js +186 -0
- package/lib/static.js +22 -11
- package/lib/submission.js +325 -0
- package/lib/submit.js +208 -0
- package/lib/ticker.js +39 -0
- package/lib/trial.js +69 -0
- package/lib/ui.js +40 -2
- package/package.json +4 -2
- package/schemas/submission-1.json +210 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// A submission is a finished trigger run plus the facts a stranger needs to check it: which skill
|
|
2
|
+
// (repo, pinned commit, SKILL.md hash), which host and model, which questions, which runner.
|
|
3
|
+
//
|
|
4
|
+
// The same module builds a package on the contributor's machine and checks one in CI, so the two cannot
|
|
5
|
+
// disagree about what a valid submission is. Checking is only ever about format, hashes and arithmetic:
|
|
6
|
+
// nothing here can show that the trials were really run, and nothing in the docs says it can.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { loadSchema, maxDepth, validate } from './schema.js';
|
|
13
|
+
import { GENERIC_PATHS, emptyContext, findSecrets, localContext, scrubPackage } from './scrub.js';
|
|
14
|
+
import { summarize, wilson } from './trial.js';
|
|
15
|
+
import { querySetHash, validRecords } from './runs.js';
|
|
16
|
+
|
|
17
|
+
export const SUBMISSION_SCHEMA = 'skillgauntlet/submission/1';
|
|
18
|
+
export const FILES = ['meta.json', 'raw.jsonl', 'summary.json', 'queries.json'];
|
|
19
|
+
export const LIMITS = { fileBytes: 4 * 1024 * 1024, lineBytes: 20 * 1024, depth: 12 };
|
|
20
|
+
const MIN_TRIALS = 10; // the same floor as step0/trigger_test.py: fewer trials on either side gets no grade
|
|
21
|
+
|
|
22
|
+
export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
23
|
+
|
|
24
|
+
// The SKILL.md hash a submission states is taken over the text with LF line endings. A Windows checkout
|
|
25
|
+
// with autocrlf writes CRLF, and its raw bytes hash differently from the blob in the repository: without
|
|
26
|
+
// this, every honest Windows submission would fail the hash check against the pinned commit.
|
|
27
|
+
export const skillMdHash = (buf) => sha256(Buffer.from(String(buf).replace(/\r\n/g, '\n'), 'utf8'));
|
|
28
|
+
export const slug = (s) => String(s || 'unknown').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'unknown';
|
|
29
|
+
|
|
30
|
+
// Same coarse rule as step0/trigger_test.py grade(): no precise scores, and no grade on thin data.
|
|
31
|
+
export function gradeTrigger({ recall, fpr, nPos, nNeg }) {
|
|
32
|
+
if (nPos < MIN_TRIALS || nNeg < MIN_TRIALS || recall == null || fpr == null) return 'insufficient';
|
|
33
|
+
const rarely = recall < 0.5;
|
|
34
|
+
const over = fpr > 0.25;
|
|
35
|
+
if (recall >= 0.8 && fpr <= 0.1) return 'well';
|
|
36
|
+
return rarely || over ? 'poorly' : 'fair';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function gradeOfSummary(summary) {
|
|
40
|
+
const nPos = summary.questions.filter((q) => q.shouldTrigger).reduce((a, q) => a + q.n, 0);
|
|
41
|
+
const nNeg = summary.questions.filter((q) => !q.shouldTrigger).reduce((a, q) => a + q.n, 0);
|
|
42
|
+
return gradeTrigger({ recall: summary.recall, fpr: summary.fpr, nPos, nNeg });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The same numbers execute.js finishRun() writes into summary.json, recomputed from the raw records.
|
|
46
|
+
export function computeSummary(rawRecords, { runs, expected }) {
|
|
47
|
+
const records = validRecords(rawRecords);
|
|
48
|
+
const s = summarize(records);
|
|
49
|
+
const posQ = s.questions.filter((q) => q.shouldTrigger);
|
|
50
|
+
const negQ = s.questions.filter((q) => !q.shouldTrigger);
|
|
51
|
+
return {
|
|
52
|
+
...s,
|
|
53
|
+
failedTrials: rawRecords.length - records.length,
|
|
54
|
+
incomplete: expected ? Math.max(0, expected - records.length) : 0,
|
|
55
|
+
runs,
|
|
56
|
+
recallCi: wilson(posQ.filter((q) => q.hits * 2 > q.n).length, posQ.length),
|
|
57
|
+
fprCi: wilson(negQ.filter((q) => q.hits > 0).length, negQ.length),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const compactTime = (iso) => iso.replace(/[-:]/g, '').replace(/\.\d+/, '');
|
|
62
|
+
|
|
63
|
+
// submissions/<skill-id>/<host>-<model>/<timestamp>-<github-user>/
|
|
64
|
+
export function submissionPath(meta) {
|
|
65
|
+
const sub = meta.submission;
|
|
66
|
+
const model = sub.model.resolved || sub.model.requested || 'unknown';
|
|
67
|
+
return [slug(sub.skill.name), `${sub.host.id}-${slug(model)}`, `${compactTime(meta.startedAt)}-${sub.contributor}`];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizeRepo(url) {
|
|
71
|
+
const m = /^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(String(url).trim());
|
|
72
|
+
return m ? `https://github.com/${m[1]}/${m[2]}` : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const git = (cwd, args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
76
|
+
|
|
77
|
+
// Where the skill came from, read from the checkout the run was made on. Flags can fill in or override
|
|
78
|
+
// (a skill copied in without its .git, or a fork's remote that is not the canonical repo).
|
|
79
|
+
export function gitFacts(skillDir, overrides = {}) {
|
|
80
|
+
const facts = { repo: null, commit: null, subdir: null, dirty: false };
|
|
81
|
+
try {
|
|
82
|
+
facts.commit = git(skillDir, ['rev-parse', 'HEAD']);
|
|
83
|
+
facts.repo = normalizeRepo(git(skillDir, ['remote', 'get-url', 'origin']));
|
|
84
|
+
facts.subdir = git(skillDir, ['rev-parse', '--show-prefix']).replace(/\/$/, '');
|
|
85
|
+
facts.dirty = git(skillDir, ['status', '--porcelain', '--untracked-files=no', '--', '.']) !== '';
|
|
86
|
+
} catch { /* not a git checkout, or no origin: the overrides must supply it */ }
|
|
87
|
+
if (overrides.repo) facts.repo = normalizeRepo(overrides.repo);
|
|
88
|
+
if (overrides.commit) { facts.commit = overrides.commit; facts.dirty = false; }
|
|
89
|
+
if (overrides.subdir !== undefined) facts.subdir = overrides.subdir.replace(/^\/+|\/+$/g, '');
|
|
90
|
+
return facts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The question "does SKILL.md at that commit hash to this?" asked of the public repo. Used by the CLI
|
|
94
|
+
// before it opens a pull request and by CI after one is opened.
|
|
95
|
+
export function skillShaAtCommit({ repo, commit, subdir }, { exec = execFileSync } = {}) {
|
|
96
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-fetch-'));
|
|
97
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' };
|
|
98
|
+
const run = (args) => exec('git', ['-C', tmp, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
99
|
+
try {
|
|
100
|
+
run(['init', '-q']);
|
|
101
|
+
run(['fetch', '-q', '--depth', '1', repo, commit]);
|
|
102
|
+
const file = subdir ? `${subdir}/SKILL.md` : 'SKILL.md';
|
|
103
|
+
return skillMdHash(run(['show', `FETCH_HEAD:${file}`]));
|
|
104
|
+
} finally {
|
|
105
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// apiKeySource is "none", an environment variable name, or (CodeBuddy) the host name of the account endpoint the
|
|
110
|
+
// contributor logged in to. The last says which service they use and adds nothing to a result, so it is generalised.
|
|
111
|
+
export const keySource = (v) => (typeof v === 'string' && /^(none|[A-Z][A-Z0-9_]*)$/.test(v) ? v : v == null ? v : 'other');
|
|
112
|
+
|
|
113
|
+
const META_KEYS = [
|
|
114
|
+
'schema', 'runId', 'runnerVersion', 'skill', 'host', 'backend', 'model', 'runs', 'maxExplore', 'depth', 'querySets',
|
|
115
|
+
'skillFingerprint', 'platform', 'startedAt', 'finishedAt', 'trials', 'interrupted', 'resumedAt', 'modelResolved',
|
|
116
|
+
'apiKeySource', 'claudeCodeVersion', 'loadedSkills', 'tools', 'incompleteTrials',
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
function environmentOf(meta) {
|
|
120
|
+
const loaded = [...(meta.loadedSkills || [])].sort();
|
|
121
|
+
const tools = [...(meta.tools || [])].sort();
|
|
122
|
+
const osName = String(meta.platform || '').split(' ')[0];
|
|
123
|
+
const id = sha256(JSON.stringify({ host: meta.host, v: meta.claudeCodeVersion || null, os: osName, loaded, tools })).slice(0, 12);
|
|
124
|
+
return { id, platform: meta.platform, hostVersion: meta.claudeCodeVersion || null, backend: meta.backend, loadedSkills: loaded, toolCount: tools.length };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readJsonl(text) {
|
|
128
|
+
return text.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class SubmitError extends Error {
|
|
132
|
+
constructor(key, vars = {}, message) {
|
|
133
|
+
super(message || key);
|
|
134
|
+
this.messageKey = key;
|
|
135
|
+
this.vars = vars;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Build, scrub and validate a package from a run directory. Nothing is written. Throws SubmitError for
|
|
140
|
+
// anything the contributor can fix; returns { path, files, counts } otherwise.
|
|
141
|
+
export function buildPackage({ runDir, skillDir, facts, contributor, ctx: given, now = new Date() }) {
|
|
142
|
+
// The GitHub login is public by design and goes in the file, so it must not be scrubbed as "your username".
|
|
143
|
+
const base = given || localContext({ extraPaths: [runDir, skillDir] });
|
|
144
|
+
const ctx = { ...base, names: base.names.filter((n) => n.toLowerCase() !== String(contributor).toLowerCase()) };
|
|
145
|
+
const read = (f) => fs.readFileSync(path.join(runDir, f), 'utf8');
|
|
146
|
+
let meta;
|
|
147
|
+
let rawRecords;
|
|
148
|
+
let summary;
|
|
149
|
+
let queries;
|
|
150
|
+
try { meta = JSON.parse(read('meta.json')); } catch { throw new SubmitError('submitNoRun', { dir: runDir }); }
|
|
151
|
+
try { rawRecords = readJsonl(read('raw.jsonl')); } catch { throw new SubmitError('submitNoRun', { dir: runDir }); }
|
|
152
|
+
try { summary = JSON.parse(read('summary.json')); } catch { throw new SubmitError('submitNoSummary', { dir: runDir }); }
|
|
153
|
+
try { queries = JSON.parse(read('queries.json')); } catch { throw new SubmitError('submitNoQueries', { dir: runDir }); }
|
|
154
|
+
|
|
155
|
+
if (meta.schema !== 'skillgauntlet/run/1' || !['claude-code', 'codebuddy', 'codewhale'].includes(meta.host) || summary.probe) {
|
|
156
|
+
throw new SubmitError('submitNotTrigger', { host: meta.host });
|
|
157
|
+
}
|
|
158
|
+
if (!meta.finishedAt || meta.interrupted || meta.stoppedAt) throw new SubmitError('submitUnfinished', { dir: runDir });
|
|
159
|
+
if (meta.querySets?.[0]?.sha256 !== querySetHash(queries.queries)) throw new SubmitError('submitQueriesChanged');
|
|
160
|
+
if (!facts.repo || !facts.commit) throw new SubmitError('submitNoSource');
|
|
161
|
+
if (!/^[0-9a-f]{40}$/.test(facts.commit)) throw new SubmitError('submitBadCommit', { commit: facts.commit });
|
|
162
|
+
if (facts.dirty) throw new SubmitError('submitDirty');
|
|
163
|
+
|
|
164
|
+
const skillMd = path.join(skillDir, 'SKILL.md');
|
|
165
|
+
let skillBytes;
|
|
166
|
+
try { skillBytes = fs.readFileSync(skillMd); } catch { throw new SubmitError('submitNoSkillMd', { dir: skillDir }); }
|
|
167
|
+
// the run's fingerprint is over the raw bytes (queries.js); the submitted hash is the line-ending-neutral one
|
|
168
|
+
if (meta.skillFingerprint && sha256(skillBytes).slice(0, 16) !== meta.skillFingerprint) throw new SubmitError('submitSkillChanged');
|
|
169
|
+
const skillSha = skillMdHash(skillBytes);
|
|
170
|
+
|
|
171
|
+
const submission = {
|
|
172
|
+
schema: SUBMISSION_SCHEMA,
|
|
173
|
+
contributor,
|
|
174
|
+
skill: { name: meta.skill, repo: facts.repo, commit: facts.commit, subdir: facts.subdir || '', skillMdSha256: skillSha },
|
|
175
|
+
host: { id: meta.host, version: meta.claudeCodeVersion || null },
|
|
176
|
+
model: { requested: meta.model ?? null, resolved: meta.modelResolved ?? null },
|
|
177
|
+
queryPack: { id: meta.querySets[0].id, sha256: sha256(JSON.stringify(queries.queries)), queries: queries.queries.length },
|
|
178
|
+
environment: environmentOf(meta),
|
|
179
|
+
submittedAt: now.toISOString().replace(/\.\d+Z$/, 'Z'),
|
|
180
|
+
};
|
|
181
|
+
const kept = Object.fromEntries(META_KEYS.filter((k) => meta[k] !== undefined).map((k) => [k, k === 'apiKeySource' ? keySource(meta[k]) : meta[k]]));
|
|
182
|
+
const rows = rawRecords.map((r) => {
|
|
183
|
+
const row = typeof r.error === 'string' ? { ...r, error: r.error.slice(0, 380) } : { ...r };
|
|
184
|
+
if ('apiKeySource' in row) row.apiKeySource = keySource(row.apiKeySource);
|
|
185
|
+
return row;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const scrubbed = scrubPackage({
|
|
189
|
+
'meta.json': { ...kept, submission },
|
|
190
|
+
'raw.jsonl': rows,
|
|
191
|
+
'summary.json': summary,
|
|
192
|
+
'queries.json': { schema: 'skillgauntlet/queries/1', queries: queries.queries },
|
|
193
|
+
}, ctx, { verbatim: ['queries.json'] });
|
|
194
|
+
if (scrubbed.problems.length) throw Object.assign(new SubmitError('submitSecrets'), { problems: scrubbed.problems });
|
|
195
|
+
|
|
196
|
+
// The scrubbed copy is what gets submitted, so it is what is validated.
|
|
197
|
+
const out = { meta: scrubbed.files['meta.json'], raw: scrubbed.files['raw.jsonl'], summary: scrubbed.files['summary.json'], queries: scrubbed.files['queries.json'] };
|
|
198
|
+
const errors = checkParsed(out);
|
|
199
|
+
if (errors.length) throw Object.assign(new SubmitError('submitInvalid', { n: errors.length }), { errors });
|
|
200
|
+
return { path: submissionPath(out.meta), files: out, counts: scrubbed.counts };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function serialize(pkg) {
|
|
204
|
+
return {
|
|
205
|
+
'meta.json': `${JSON.stringify(pkg.files.meta, null, 2)}\n`,
|
|
206
|
+
'raw.jsonl': pkg.files.raw.map((r) => JSON.stringify(r)).join('\n') + '\n',
|
|
207
|
+
'summary.json': `${JSON.stringify(pkg.files.summary, null, 2)}\n`,
|
|
208
|
+
'queries.json': `${JSON.stringify(pkg.files.queries, null, 2)}\n`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function writePackage(outDir, pkg) {
|
|
213
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
214
|
+
const text = serialize(pkg);
|
|
215
|
+
for (const [name, body] of Object.entries(text)) fs.writeFileSync(path.join(outDir, name), body);
|
|
216
|
+
return Object.fromEntries(Object.entries(text).map(([n, b]) => [n, Buffer.byteLength(b)]));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const near = (a, b) => (a == null || b == null ? a === b : Math.abs(a - b) < 1e-9);
|
|
220
|
+
|
|
221
|
+
// Schema plus the cross-file facts, on parsed content. Returns a list of strings, empty when it is fine.
|
|
222
|
+
export function checkParsed({ meta, raw, summary, queries }) {
|
|
223
|
+
const root = loadSchema();
|
|
224
|
+
const errors = [];
|
|
225
|
+
const add = (file, list) => list.forEach((e) => errors.push(`${file}: ${e.path} ${e.message}`));
|
|
226
|
+
add('meta.json', validate(root, meta, 'meta'));
|
|
227
|
+
add('summary.json', validate(root, summary, 'summary'));
|
|
228
|
+
add('queries.json', validate(root, queries, 'queries'));
|
|
229
|
+
raw.forEach((row, i) => add(`raw.jsonl line ${i + 1}`, validate(root, row, 'rawLine')));
|
|
230
|
+
if (errors.length) return errors;
|
|
231
|
+
|
|
232
|
+
const sub = meta.submission;
|
|
233
|
+
if (sub.skill.name !== meta.skill) errors.push('meta.json: submission.skill.name differs from skill');
|
|
234
|
+
if (sub.host.id !== meta.host) errors.push('meta.json: submission.host.id differs from host');
|
|
235
|
+
if (meta.querySets[0].sha256 !== querySetHash(queries.queries)) errors.push('queries.json: hash differs from meta.querySets[0].sha256');
|
|
236
|
+
if (sub.queryPack.sha256 !== sha256(JSON.stringify(queries.queries))) errors.push('queries.json: hash differs from submission.queryPack.sha256');
|
|
237
|
+
if (sub.queryPack.queries !== queries.queries.length) errors.push('submission.queryPack.queries differs from the number of questions');
|
|
238
|
+
if (meta.trials !== raw.length) errors.push(`meta.json: trials is ${meta.trials} but raw.jsonl has ${raw.length} lines`);
|
|
239
|
+
|
|
240
|
+
// every record must be one of the pack's questions, with the pack's label
|
|
241
|
+
const byId = new Map(queries.queries.map((q) => [q.id, q]));
|
|
242
|
+
let bad = 0;
|
|
243
|
+
for (const r of raw) {
|
|
244
|
+
const q = byId.get(r.queryId);
|
|
245
|
+
if (!q || q.query !== r.query || q.shouldTrigger !== r.shouldTrigger) bad += 1;
|
|
246
|
+
if (r.host !== meta.host) bad += 1;
|
|
247
|
+
}
|
|
248
|
+
if (bad) errors.push(`raw.jsonl: ${bad} record(s) do not match queries.json or the run's host`);
|
|
249
|
+
|
|
250
|
+
const again = computeSummary(raw, { runs: meta.runs, expected: queries.queries.length * meta.runs });
|
|
251
|
+
const same = summary.trials === again.trials && near(summary.recall, again.recall) && near(summary.fpr, again.fpr)
|
|
252
|
+
&& near(summary.noToolRate, again.noToolRate) && summary.failedTrials === again.failedTrials
|
|
253
|
+
&& near(summary.recallCi[0], again.recallCi[0]) && near(summary.recallCi[1], again.recallCi[1])
|
|
254
|
+
&& near(summary.fprCi[0], again.fprCi[0]) && near(summary.fprCi[1], again.fprCi[1])
|
|
255
|
+
&& JSON.stringify(summary.questions) === JSON.stringify(again.questions);
|
|
256
|
+
if (!same) {
|
|
257
|
+
errors.push(`summary.json: the numbers do not recompute from raw.jsonl (summary says recall ${summary.recall}, `
|
|
258
|
+
+ `false positives ${summary.fpr}, ${summary.trials} trials; raw.jsonl gives ${again.recall}, ${again.fpr}, ${again.trials})`);
|
|
259
|
+
}
|
|
260
|
+
if (summary.incomplete) errors.push(`summary.json: the run is incomplete (${summary.incomplete} trials owed); finish it with resume first`);
|
|
261
|
+
return errors;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// CI-side check of a directory on disk. layoutPath is the path below submissions/ ('a/b/c'), author the
|
|
265
|
+
// pull request's GitHub login. Fetching the skill repo is injected so tests need no network.
|
|
266
|
+
export function checkPackageDir(dir, { layoutPath = null, author = null, fetchSha = skillShaAtCommit } = {}) {
|
|
267
|
+
const errors = [];
|
|
268
|
+
const names = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
|
|
269
|
+
for (const f of FILES) if (!names.includes(f)) errors.push(`${f}: missing`);
|
|
270
|
+
for (const f of names) if (!FILES.includes(f)) errors.push(`${f}: not allowed in a submission (only ${FILES.join(', ')})`);
|
|
271
|
+
if (errors.length) return { ok: false, errors };
|
|
272
|
+
|
|
273
|
+
const parsed = {};
|
|
274
|
+
for (const f of FILES) {
|
|
275
|
+
const file = path.join(dir, f);
|
|
276
|
+
if (fs.statSync(file).size > LIMITS.fileBytes) { errors.push(`${f}: larger than ${LIMITS.fileBytes / 1024 / 1024} MB`); continue; }
|
|
277
|
+
}
|
|
278
|
+
if (errors.length) return { ok: false, errors };
|
|
279
|
+
try {
|
|
280
|
+
parsed.meta = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf8'));
|
|
281
|
+
parsed.summary = JSON.parse(fs.readFileSync(path.join(dir, 'summary.json'), 'utf8'));
|
|
282
|
+
parsed.queries = JSON.parse(fs.readFileSync(path.join(dir, 'queries.json'), 'utf8'));
|
|
283
|
+
} catch (e) { return { ok: false, errors: [`not valid JSON: ${e.message}`] }; }
|
|
284
|
+
const lines = fs.readFileSync(path.join(dir, 'raw.jsonl'), 'utf8').split(/\r?\n/).filter(Boolean);
|
|
285
|
+
if (lines.some((l) => Buffer.byteLength(l) > LIMITS.lineBytes)) return { ok: false, errors: [`raw.jsonl: a line is longer than ${LIMITS.lineBytes} bytes`] };
|
|
286
|
+
try { parsed.raw = lines.map((l) => JSON.parse(l)); } catch (e) { return { ok: false, errors: [`raw.jsonl: not valid JSON lines (${e.message})`] }; }
|
|
287
|
+
if (Object.values(parsed).some((v) => maxDepth(v) > LIMITS.depth)) return { ok: false, errors: [`JSON nested deeper than ${LIMITS.depth} levels`] };
|
|
288
|
+
|
|
289
|
+
errors.push(...checkParsed(parsed));
|
|
290
|
+
if (errors.length) return { ok: false, errors };
|
|
291
|
+
|
|
292
|
+
const sub = parsed.meta.submission;
|
|
293
|
+
if (layoutPath) {
|
|
294
|
+
const want = submissionPath(parsed.meta).join('/');
|
|
295
|
+
if (layoutPath !== want) errors.push(`path: this submission belongs at submissions/${want}/ but is at submissions/${layoutPath}/`);
|
|
296
|
+
}
|
|
297
|
+
if (author && author.toLowerCase() !== sub.contributor.toLowerCase()) {
|
|
298
|
+
errors.push(`contributor: the file says "${sub.contributor}" but the pull request was opened by "${author}"`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// secrets and paths, on everything except the question text (scanned for secrets only)
|
|
302
|
+
const found = findSecrets({ meta: parsed.meta, raw: parsed.raw, summary: parsed.summary, queries: parsed.queries }, emptyContext());
|
|
303
|
+
for (const h of found) errors.push(`secret-shaped text at ${h.where} (${h.kind}: ${h.sample})`);
|
|
304
|
+
const leaks = [];
|
|
305
|
+
const walk = (v, key, at) => {
|
|
306
|
+
if (typeof v === 'string') {
|
|
307
|
+
if (key === 'query' || key === 'note' || key === 'clause') return;
|
|
308
|
+
for (const [re] of GENERIC_PATHS) { re.lastIndex = 0; if (re.test(v)) { leaks.push(at); break; } }
|
|
309
|
+
} else if (Array.isArray(v)) v.forEach((x, i) => walk(x, key, `${at}[${i}]`));
|
|
310
|
+
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, k, at ? `${at}.${k}` : k);
|
|
311
|
+
};
|
|
312
|
+
walk({ meta: parsed.meta, raw: parsed.raw, summary: parsed.summary }, '', '');
|
|
313
|
+
for (const at of leaks.slice(0, 5)) errors.push(`local path at ${at}`);
|
|
314
|
+
|
|
315
|
+
// the hash check: fetch the skill at the pinned commit and hash SKILL.md ourselves
|
|
316
|
+
try {
|
|
317
|
+
const got = fetchSha(sub.skill);
|
|
318
|
+
if (got !== sub.skill.skillMdSha256) {
|
|
319
|
+
errors.push(`SKILL.md: at ${sub.skill.repo}@${sub.skill.commit.slice(0, 7)} the hash is ${got.slice(0, 12)}…, but the submission says ${sub.skill.skillMdSha256.slice(0, 12)}…`);
|
|
320
|
+
}
|
|
321
|
+
} catch (e) {
|
|
322
|
+
errors.push(`SKILL.md: could not fetch ${sub.skill.repo} at commit ${sub.skill.commit.slice(0, 7)}${sub.skill.subdir ? ` (${sub.skill.subdir}/SKILL.md)` : ''}; is the commit pushed to that public repo?`);
|
|
323
|
+
}
|
|
324
|
+
return { ok: errors.length === 0, errors, meta: parsed.meta, summary: parsed.summary };
|
|
325
|
+
}
|
package/lib/submit.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// `skillgauntlet submit <run-dir>`: scrub, show, ask, then open the pull request.
|
|
2
|
+
//
|
|
3
|
+
// Nothing is uploaded before the contributor has seen what would go and said yes, and the pull request
|
|
4
|
+
// is opened as them, from their own fork: a bot-authored commit would not count as their contribution.
|
|
5
|
+
// When the GitHub CLI is missing or not logged in the same package is left on disk with manual steps.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { say, confirm } from './ui.js';
|
|
11
|
+
import { errorText, t } from './i18n.js';
|
|
12
|
+
import {
|
|
13
|
+
SubmitError, buildPackage, gitFacts, gradeOfSummary, normalizeRepo, skillShaAtCommit, slug, writePackage,
|
|
14
|
+
} from './submission.js';
|
|
15
|
+
|
|
16
|
+
export const UPSTREAM = 'SkillGauntlet/SkillGauntlet';
|
|
17
|
+
|
|
18
|
+
const WITH_VALUE = ['--skill-dir', '--repo', '--commit', '--subdir', '--github-user', '--out'];
|
|
19
|
+
const FLAGS = ['--dry-run', '--yes', '--skip-remote-check'];
|
|
20
|
+
|
|
21
|
+
export function parseSubmitArgs(args) {
|
|
22
|
+
const opts = { dir: null };
|
|
23
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
24
|
+
const a = args[i];
|
|
25
|
+
if (WITH_VALUE.includes(a)) { opts[a.slice(2)] = args[i + 1]; i += 1; } else if (FLAGS.includes(a)) opts[a.slice(2)] = true;
|
|
26
|
+
else if (!a.startsWith('--') && !opts.dir) opts.dir = a;
|
|
27
|
+
else throw new Error(t('submitUsage'));
|
|
28
|
+
}
|
|
29
|
+
if (!opts.dir) throw new Error(t('submitUsage'));
|
|
30
|
+
return opts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const defaultExec = (cmd, args, opts = {}) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
|
|
34
|
+
|
|
35
|
+
// Who is logged in to the GitHub CLI, or why nothing can be done with it. Never throws.
|
|
36
|
+
export function ghState(exec = defaultExec) {
|
|
37
|
+
try {
|
|
38
|
+
exec('gh', ['auth', 'status']);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return { usable: false, installed: err.code !== 'ENOENT', user: null };
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const user = exec('gh', ['api', 'user', '--jq', '.login + " " + (.id|tostring)']).trim().split(' ');
|
|
44
|
+
return { usable: true, installed: true, user: user[0], id: user[1] };
|
|
45
|
+
} catch {
|
|
46
|
+
return { usable: false, installed: true, user: null };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pct = (x) => (x == null ? '–' : `${Math.round(x * 1000) / 10}%`);
|
|
51
|
+
const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
|
|
52
|
+
|
|
53
|
+
function printPreview({ pkg, sizes, dir }) {
|
|
54
|
+
const m = pkg.files.meta;
|
|
55
|
+
const sub = m.submission;
|
|
56
|
+
const s = pkg.files.summary;
|
|
57
|
+
say(`\n${t('submitPreviewTitle')}\n`);
|
|
58
|
+
say(t('submitPreviewSkill', { skill: m.skill, repo: sub.skill.repo, commit: sub.skill.commit.slice(0, 7), subdir: sub.skill.subdir ? `, ${sub.skill.subdir}` : '' }));
|
|
59
|
+
say(t('submitPreviewSha', { sha: sub.skill.skillMdSha256.slice(0, 16) }));
|
|
60
|
+
say(t('submitPreviewRun', {
|
|
61
|
+
host: sub.host.id, hostVersion: sub.host.version || '?', model: sub.model.resolved || sub.model.requested || '?', trials: m.trials, questions: sub.queryPack.queries, runner: m.runnerVersion,
|
|
62
|
+
}));
|
|
63
|
+
say(t('submitPreviewResult', { grade: gradeOfSummary(s), recall: pct(s.recall), fpr: pct(s.fpr) }));
|
|
64
|
+
say(t('submitPreviewFiles', { files: Object.entries(sizes).map(([n, b]) => `${n} ${kb(b)}`).join(', ') }));
|
|
65
|
+
const counts = Object.entries(pkg.counts).map(([k, n]) => `${k} ×${n}`).join(', ');
|
|
66
|
+
say(t('submitPreviewScrub', { what: counts || t('submitScrubNone') }));
|
|
67
|
+
say(t('submitPreviewPath', { path: pkg.path.join('/'), upstream: UPSTREAM }));
|
|
68
|
+
say(t('submitPreviewWhere', { dir }));
|
|
69
|
+
say(`\n${t('submitPublic')}\n`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function prBody(pkg) {
|
|
73
|
+
const m = pkg.files.meta;
|
|
74
|
+
const sub = m.submission;
|
|
75
|
+
const s = pkg.files.summary;
|
|
76
|
+
return [
|
|
77
|
+
`Community submission: \`${m.skill}\` on ${sub.host.id} / ${sub.model.resolved || sub.model.requested}.`,
|
|
78
|
+
'',
|
|
79
|
+
'| | |',
|
|
80
|
+
'|---|---|',
|
|
81
|
+
`| Skill | ${sub.skill.repo} @ \`${sub.skill.commit.slice(0, 12)}\`${sub.skill.subdir ? ` (\`${sub.skill.subdir}\`)` : ''} |`,
|
|
82
|
+
`| SKILL.md sha256 | \`${sub.skill.skillMdSha256}\` |`,
|
|
83
|
+
`| Host | ${sub.host.id} ${sub.host.version || ''} |`,
|
|
84
|
+
`| Trials / questions | ${m.trials} / ${sub.queryPack.queries} |`,
|
|
85
|
+
`| Result | grade \`${gradeOfSummary(s)}\`, recall ${pct(s.recall)}, false positives ${pct(s.fpr)} |`,
|
|
86
|
+
`| Runner | skillgauntlet ${m.runnerVersion} |`,
|
|
87
|
+
'',
|
|
88
|
+
'Opened by `skillgauntlet submit`: the files were scrubbed of local paths and secrets and shown to the contributor before this pull request was opened.',
|
|
89
|
+
'I understand that the runner cannot prove the trials were really run, that the data is published under CC BY-NC-SA 4.0, and that I sign the CLA when the bot asks.',
|
|
90
|
+
'',
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fork, branch, commit, push, open. Every GitHub call goes through `exec` so tests can fake them.
|
|
95
|
+
export function openPullRequest({ pkgDir, pkg, gh, exec = defaultExec, say: out = say }) {
|
|
96
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-pr-'));
|
|
97
|
+
try {
|
|
98
|
+
out(t('submitForking', { upstream: UPSTREAM }));
|
|
99
|
+
exec('gh', ['repo', 'fork', UPSTREAM, '--clone=false']);
|
|
100
|
+
out(t('submitPreparing'));
|
|
101
|
+
const repoDir = path.join(work, 'repo');
|
|
102
|
+
exec('git', ['clone', '--depth', '1', `https://github.com/${UPSTREAM}.git`, repoDir]);
|
|
103
|
+
const g = (...a) => exec('git', ['-C', repoDir, ...a]);
|
|
104
|
+
const branch = `submit/${slug(pkg.files.meta.skill)}-${pkg.files.meta.submission.host.id}-${pkg.path[2].split('-')[0].toLowerCase()}`;
|
|
105
|
+
g('checkout', '-b', branch);
|
|
106
|
+
const target = path.join(repoDir, 'submissions', ...pkg.path);
|
|
107
|
+
fs.mkdirSync(target, { recursive: true });
|
|
108
|
+
for (const f of fs.readdirSync(pkgDir)) fs.copyFileSync(path.join(pkgDir, f), path.join(target, f));
|
|
109
|
+
g('add', 'submissions');
|
|
110
|
+
const msg = path.join(work, 'commit-message.txt');
|
|
111
|
+
const m = pkg.files.meta;
|
|
112
|
+
fs.writeFileSync(msg, `Add community submission: ${m.skill} on ${m.submission.host.id} / ${m.submission.model.resolved || m.submission.model.requested}\n\nSubmitted with skillgauntlet submit ${m.runnerVersion}.\n`);
|
|
113
|
+
// the noreply address keeps a private email out of a public commit, and still links it to the account
|
|
114
|
+
const ident = ['-c', `user.name=${gh.user}`, '-c', `user.email=${gh.id}+${gh.user}@users.noreply.github.com`];
|
|
115
|
+
g(...ident, 'commit', '-q', '-F', msg);
|
|
116
|
+
out(t('submitPushing'));
|
|
117
|
+
g('remote', 'add', 'fork', `https://github.com/${gh.user}/${UPSTREAM.split('/')[1]}.git`);
|
|
118
|
+
g('-c', 'credential.helper=', '-c', 'credential.https://github.com.helper=!gh auth git-credential', 'push', '-q', 'fork', branch);
|
|
119
|
+
out(t('submitOpening'));
|
|
120
|
+
const body = path.join(work, 'body.md');
|
|
121
|
+
fs.writeFileSync(body, prBody(pkg));
|
|
122
|
+
const url = exec('gh', [
|
|
123
|
+
'pr', 'create', '--repo', UPSTREAM, '--head', `${gh.user}:${branch}`, '--base', 'main',
|
|
124
|
+
'--title', `Submission: ${m.skill} on ${m.submission.host.id} / ${m.submission.model.resolved || m.submission.model.requested}`,
|
|
125
|
+
'--body-file', body,
|
|
126
|
+
]).trim().split(/\s+/).pop();
|
|
127
|
+
return url;
|
|
128
|
+
} finally {
|
|
129
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function printProblems(err) {
|
|
134
|
+
for (const p of err.problems || []) say(` - ${p.where}: ${p.kind} ${p.sample}`);
|
|
135
|
+
for (const e of (err.errors || []).slice(0, 20)) say(` - ${e}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function cmdSubmit(args, { exec = defaultExec, verifyRemote = skillShaAtCommit } = {}) {
|
|
139
|
+
const opts = parseSubmitArgs(args);
|
|
140
|
+
const runDir = path.resolve(opts.dir);
|
|
141
|
+
const skillDir = opts['skill-dir'] ? path.resolve(opts['skill-dir']) : path.resolve(runDir, '..', '..', '..');
|
|
142
|
+
const gh = ghState(exec);
|
|
143
|
+
const contributor = opts['github-user'] || gh.user;
|
|
144
|
+
if (!contributor) throw new SubmitError('submitNoUser');
|
|
145
|
+
|
|
146
|
+
let pkg;
|
|
147
|
+
try {
|
|
148
|
+
pkg = buildPackage({
|
|
149
|
+
runDir, skillDir, contributor, facts: gitFacts(skillDir, { repo: opts.repo, commit: opts.commit, subdir: opts.subdir }),
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (err instanceof SubmitError) {
|
|
153
|
+
say(`\n${errorText(err)}`);
|
|
154
|
+
printProblems(err);
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sk = pkg.files.meta.submission.skill;
|
|
162
|
+
if (!opts['skip-remote-check']) {
|
|
163
|
+
let got;
|
|
164
|
+
try { got = verifyRemote(sk); } catch { got = null; }
|
|
165
|
+
if (!got) {
|
|
166
|
+
say(`\n${t('submitRemoteFail', { repo: sk.repo, commit: sk.commit.slice(0, 7) })}`);
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if (got !== sk.skillMdSha256) {
|
|
171
|
+
say(`\n${t('submitRemoteMismatch', { repo: sk.repo, commit: sk.commit.slice(0, 7), got: got.slice(0, 12), want: sk.skillMdSha256.slice(0, 12) })}`);
|
|
172
|
+
process.exitCode = 1;
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const outRoot = opts.out ? path.resolve(opts.out) : fs.mkdtempSync(path.join(os.tmpdir(), 'sg-submit-'));
|
|
178
|
+
const pkgDir = path.join(outRoot, ...pkg.path);
|
|
179
|
+
const sizes = writePackage(pkgDir, pkg);
|
|
180
|
+
printPreview({ pkg, sizes, dir: pkgDir });
|
|
181
|
+
|
|
182
|
+
if (opts['dry-run']) {
|
|
183
|
+
say(t('submitDry', { dir: pkgDir }));
|
|
184
|
+
return { pkgDir, pkg };
|
|
185
|
+
}
|
|
186
|
+
if (!opts.yes && !(await confirm(t('submitAsk', { upstream: UPSTREAM }), false))) {
|
|
187
|
+
say(t('submitCancelled', { dir: pkgDir }));
|
|
188
|
+
return { pkgDir, pkg };
|
|
189
|
+
}
|
|
190
|
+
if (!gh.usable) {
|
|
191
|
+
say(`\n${t('submitGhMissing')}`);
|
|
192
|
+
say(t('submitManual', { upstream: UPSTREAM, dir: pkgDir, path: pkg.path.join('/') }));
|
|
193
|
+
return { pkgDir, pkg };
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const url = openPullRequest({ pkgDir, pkg, gh, exec });
|
|
197
|
+
say(`\n${t('submitPrOpened', { url })}`);
|
|
198
|
+
say(t('submitNext'));
|
|
199
|
+
return { pkgDir, pkg, url };
|
|
200
|
+
} catch (err) {
|
|
201
|
+
say(`\n${t('submitGhFail', { msg: String(err.stderr || err.message).trim().split(/\r?\n/).slice(-3).join(' ') })}`);
|
|
202
|
+
say(t('submitManual', { upstream: UPSTREAM, dir: pkgDir, path: pkg.path.join('/') }));
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
return { pkgDir, pkg };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export { normalizeRepo };
|
package/lib/ticker.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A moving bar with the elapsed seconds while one blocking call runs. The calls it wraps are synchronous
|
|
2
|
+
// (execFileSync), so a timer in this process cannot fire; a small child process draws on the same terminal
|
|
3
|
+
// instead. A single model call has no real percentage, so the bar is a bouncing marker, and the seconds are the
|
|
4
|
+
// honest part: they show the program is alive and how long it has been.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { stdout } from 'node:process';
|
|
7
|
+
|
|
8
|
+
export const TICKER_SCRIPT = `
|
|
9
|
+
const [label, elapsedTpl, abortHint, slowAfter] = process.argv.slice(1);
|
|
10
|
+
const W = 16;
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
let i = 0;
|
|
13
|
+
const draw = () => {
|
|
14
|
+
const s = Math.floor((Date.now() - start) / 1000);
|
|
15
|
+
const pos = Math.abs(((i % (2 * (W - 3))) - (W - 3)));
|
|
16
|
+
i += 1;
|
|
17
|
+
let bar = '';
|
|
18
|
+
for (let k = 0; k < W; k += 1) bar += k >= pos && k < pos + 3 ? '█' : '░';
|
|
19
|
+
const tail = s >= Number(slowAfter) ? ' ' + abortHint : '';
|
|
20
|
+
process.stdout.write('\\r\\x1b[2K ' + label + ' ' + bar + ' ' + elapsedTpl.replace('{s}', s) + tail);
|
|
21
|
+
};
|
|
22
|
+
// Short waits stay silent: a bar that flashes for half a second is worse than none.
|
|
23
|
+
setTimeout(() => { draw(); setInterval(draw, 250); }, 700);
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
// Runs fn() with the bar showing; returns what fn returns. Without a terminal there is nothing to draw on.
|
|
27
|
+
export function withTicker({ label, elapsed, abortHint, slowAfter = 90 }, fn) {
|
|
28
|
+
if (!stdout.isTTY) return fn();
|
|
29
|
+
const child = spawn(process.execPath, ['-e', TICKER_SCRIPT, label, elapsed, abortHint, String(slowAfter)], {
|
|
30
|
+
stdio: ['ignore', 'inherit', 'inherit'], windowsHide: true,
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
return fn();
|
|
34
|
+
} finally {
|
|
35
|
+
child.kill();
|
|
36
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 80); // let a last frame land before it is wiped
|
|
37
|
+
stdout.write('\r\x1b[2K');
|
|
38
|
+
}
|
|
39
|
+
}
|
package/lib/trial.js
CHANGED
|
@@ -66,7 +66,12 @@ const LINE_BREAK = /\r?\n/;
|
|
|
66
66
|
|
|
67
67
|
// On Windows `where` returns the extension-less shim first (a shell script Node cannot exec), then the
|
|
68
68
|
// .cmd that it can -- with shell: true. Prefer a real executable, then a cmd shim, then whatever is left.
|
|
69
|
+
// A host CLI found somewhere off PATH (or just installed) and accepted by the user, keyed by its command name.
|
|
70
|
+
const binOverride = new Map();
|
|
71
|
+
export const setHostBin = (name, file) => binOverride.set(name, file);
|
|
72
|
+
|
|
69
73
|
export function resolveBin(name) {
|
|
74
|
+
if (binOverride.has(name)) return binOverride.get(name);
|
|
70
75
|
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
71
76
|
let found = [];
|
|
72
77
|
try {
|
|
@@ -80,6 +85,51 @@ export function resolveBin(name) {
|
|
|
80
85
|
|| name;
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
// Whether a host CLI can be found on PATH at all. resolveBin falls back to the bare name, which on Windows
|
|
89
|
+
// ends in cmd.exe's "not recognized" (or a spawn ENOENT) deep inside a trial; this asks up front.
|
|
90
|
+
export function binInstalled(name) {
|
|
91
|
+
if (binOverride.has(name)) return true;
|
|
92
|
+
try {
|
|
93
|
+
execFileSync(process.platform === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' });
|
|
94
|
+
return true;
|
|
95
|
+
} catch { return false; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const HOST_NAME = { 'claude-code': 'Claude Code', codebuddy: 'CodeBuddy', codewhale: 'Codewhale' };
|
|
99
|
+
const INSTALL_HINT = { 'claude-code': 'installClaude', codebuddy: 'installCodebuddy', codewhale: 'installCodewhale' };
|
|
100
|
+
|
|
101
|
+
// Throws an error with a translatable message when the host's CLI is not installed.
|
|
102
|
+
export function requireHost(host, probe = binInstalled) {
|
|
103
|
+
const spec = HOSTS[host];
|
|
104
|
+
if (!spec || probe(spec.bin)) return;
|
|
105
|
+
const err = new Error(`host ${host} not found: ${spec.bin} is not on PATH`);
|
|
106
|
+
err.messageKey = 'hostMissing';
|
|
107
|
+
err.vars = { host: HOST_NAME[host] || host, bin: spec.bin };
|
|
108
|
+
err.howKey = INSTALL_HINT[host];
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// One tiny call before the real work: a host that is not signed in retries silently for minutes, so the user
|
|
113
|
+
// would stare at a frozen screen. A short timeout turns that into an immediate, actionable message.
|
|
114
|
+
export function pingHost(host, { bin, model = null, timeout = 90, run = execFileSync } = {}) {
|
|
115
|
+
const spec = HOSTS[host];
|
|
116
|
+
if (!spec || host === 'codewhale') return;
|
|
117
|
+
const args = ['-p', 'Reply with the single word OK.', '--output-format', 'json', '--tools', '', '--no-session-persistence'];
|
|
118
|
+
if (model && !isDeepseekModel(model)) args.push('--model', model);
|
|
119
|
+
const launch = launchSpec(bin, args, spec.bin);
|
|
120
|
+
try {
|
|
121
|
+
run(launch.cmd, launch.args, { encoding: 'utf8', timeout: timeout * 1000, shell: launch.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const why = err.code === 'ETIMEDOUT' || err.killed
|
|
124
|
+
? `no answer within ${timeout}s`
|
|
125
|
+
: String(err.stderr || err.message).split(LINE_BREAK).map((l) => l.trim()).find(Boolean) || `exit status ${err.status}`;
|
|
126
|
+
const e = new Error(`host ${host} did not answer: ${why}`);
|
|
127
|
+
e.messageKey = 'hostNotReady';
|
|
128
|
+
e.vars = { host: HOST_NAME[host] || host, why: why.slice(0, 200) };
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
83
133
|
// Variables a host (the Claude desktop app, the Agent SDK, an outer Claude Code session) sets for its own
|
|
84
134
|
// child processes. A trial inherits them otherwise, and they change what the model under test sees: under
|
|
85
135
|
// the desktop app the CLI adds Artifact, ArtifactComments and ArtifactData to the tool list, and the model
|
|
@@ -100,6 +150,25 @@ export function trialEnv(env = process.env) {
|
|
|
100
150
|
return out;
|
|
101
151
|
}
|
|
102
152
|
|
|
153
|
+
// An allow-list environment for runs where the model executes code (the gain layer): the host CLI gets what
|
|
154
|
+
// it needs to start and authenticate and nothing else, so an unrelated token in the user's shell is not
|
|
155
|
+
// reachable from a task. Names match case-insensitively (Windows spells it Path).
|
|
156
|
+
const ENV_ALLOW = new Set(['PATH', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA',
|
|
157
|
+
'PROGRAMDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR', 'COMSPEC', 'PATHEXT',
|
|
158
|
+
'TEMP', 'TMP', 'TMPDIR', 'USER', 'USERNAME', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'TZ', 'OS',
|
|
159
|
+
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'ALL_PROXY',
|
|
160
|
+
'SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS', 'CLOUD_ML_REGION', 'DEEPSEEK_API_KEY']);
|
|
161
|
+
const ENV_ALLOW_PREFIX = ['ANTHROPIC_', 'AWS_', 'GOOGLE_', 'CODEBUDDY_'];
|
|
162
|
+
|
|
163
|
+
export function narrowEnv(env = process.env) {
|
|
164
|
+
const out = {};
|
|
165
|
+
for (const [k, v] of Object.entries(trialEnv(env))) {
|
|
166
|
+
const u = k.toUpperCase();
|
|
167
|
+
if (ENV_ALLOW.has(u) || ENV_ALLOW_PREFIX.some((p) => u.startsWith(p)) || HOST_ENV_KEEP.has(k)) out[k] = v;
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
103
172
|
function backendHost() {
|
|
104
173
|
const url = process.env.ANTHROPIC_BASE_URL;
|
|
105
174
|
if (!url) return 'anthropic';
|