skillrepo 4.9.0 → 4.9.2
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/package.json +1 -1
- package/src/commands/init.mjs +32 -7
- package/src/commands/update.mjs +16 -3
- package/src/lib/file-write.mjs +39 -0
- package/src/lib/sync.mjs +59 -2
- package/src/test/commands/init.test.mjs +90 -0
- package/src/test/lib/file-write.test.mjs +35 -0
- package/src/test/lib/http.test.mjs +60 -0
- package/src/test/lib/sync.test.mjs +168 -2
package/package.json
CHANGED
package/src/commands/init.mjs
CHANGED
|
@@ -605,8 +605,17 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
605
605
|
// placementTargetsFor (no vendors specified), and even if it
|
|
606
606
|
// didn't, fetching skill files we have nowhere to write is
|
|
607
607
|
// pure waste.
|
|
608
|
-
|
|
609
|
-
|
|
608
|
+
//
|
|
609
|
+
// `--global` does NOT change that. `effectiveVendors` returns the
|
|
610
|
+
// `--agent none` sentinel verbatim under --global precisely because
|
|
611
|
+
// "--global --agent none" still means "no placement writes" (see its
|
|
612
|
+
// test in cli-config.test.mjs), and this guard has to honour the same
|
|
613
|
+
// contract. It previously carried `&& !flags.global`, which defeated
|
|
614
|
+
// the skip for exactly the combination the comment above says would
|
|
615
|
+
// throw: every skill in the library was quarantined one-by-one with a
|
|
616
|
+
// misleading "will be retried next session" warning, and init then
|
|
617
|
+
// printed "No skills in library yet" and "SkillRepo is ready" (#2433).
|
|
618
|
+
const skipFirstSync = Array.isArray(vendors) && vendors.length === 0;
|
|
610
619
|
if (skipFirstSync) {
|
|
611
620
|
p.success("Skipped first sync (--agent none).");
|
|
612
621
|
syncSummary = {
|
|
@@ -681,13 +690,29 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
681
690
|
}
|
|
682
691
|
}
|
|
683
692
|
|
|
693
|
+
// `skipped` counts here: a run that dropped a skill is NOT a
|
|
694
|
+
// zero-delta run, and reporting "No skills in library yet" or
|
|
695
|
+
// "up to date" for it is a false statement to the user. Same defect
|
|
696
|
+
// class as the printSummary/hook fix in update.mjs (#2433).
|
|
684
697
|
const zeroDeltas =
|
|
685
|
-
syncSummary.added +
|
|
698
|
+
syncSummary.added +
|
|
699
|
+
syncSummary.updated +
|
|
700
|
+
syncSummary.removed +
|
|
701
|
+
(syncSummary.skipped ?? 0) ===
|
|
702
|
+
0;
|
|
686
703
|
|
|
687
|
-
if (syncFailedReason) {
|
|
688
|
-
// The warning
|
|
689
|
-
// would be misleading, so we skip
|
|
690
|
-
// is in the final `SkillRepo is
|
|
704
|
+
if (syncFailedReason || skipFirstSync) {
|
|
705
|
+
// The warning (or the "Skipped first sync" line) already printed;
|
|
706
|
+
// the step-summary success line would be misleading, so we skip
|
|
707
|
+
// it. Any helpful "next steps" is in the final `SkillRepo is
|
|
708
|
+
// ready` block.
|
|
709
|
+
//
|
|
710
|
+
// `skipFirstSync` belongs here for the same reason the synthesized
|
|
711
|
+
// summary above uses `fullSync: null`: the network call never ran,
|
|
712
|
+
// so we do not know the library's state. Falling through printed
|
|
713
|
+
// "Library is up to date (no changes since last sync)" — a
|
|
714
|
+
// confident claim about a server we never contacted, on the same
|
|
715
|
+
// screen as "Skipped first sync" (#2433).
|
|
691
716
|
} else if (syncSummary.notModified) {
|
|
692
717
|
// 304 Not Modified — the client had the current ETag already.
|
|
693
718
|
// Definitively "up to date" regardless of whether the library
|
package/src/commands/update.mjs
CHANGED
|
@@ -155,7 +155,13 @@ export async function runUpdate(argv, io = {}) {
|
|
|
155
155
|
throttle: true,
|
|
156
156
|
io: { stdout: BLACK_HOLE_STREAM, stderr: BLACK_HOLE_STREAM },
|
|
157
157
|
});
|
|
158
|
-
|
|
158
|
+
// `skipped` is included so the hook cannot go silent on a dropped
|
|
159
|
+
// skill: this path black-holes BOTH streams, so if the count that
|
|
160
|
+
// gates output ignores skips, a quarantined skill produces no output
|
|
161
|
+
// anywhere and exits 0 (#2413 adversarial review).
|
|
162
|
+
const skipped = summary.skipped ?? 0;
|
|
163
|
+
const total =
|
|
164
|
+
summary.added + summary.updated + summary.removed + skipped;
|
|
159
165
|
if (summary.notModified || total === 0) {
|
|
160
166
|
// 304 Not Modified OR 200 with zero deltas — silent by
|
|
161
167
|
// contract. Users should not see "Syncing..." on every
|
|
@@ -163,7 +169,9 @@ export async function runUpdate(argv, io = {}) {
|
|
|
163
169
|
return;
|
|
164
170
|
}
|
|
165
171
|
stdout.write(
|
|
166
|
-
`[SkillRepo] Library synced: ${summary.added} added, ${summary.updated} updated, ${summary.removed} removed
|
|
172
|
+
`[SkillRepo] Library synced: ${summary.added} added, ${summary.updated} updated, ${summary.removed} removed` +
|
|
173
|
+
(skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
|
|
174
|
+
`.\n`,
|
|
167
175
|
);
|
|
168
176
|
} catch (err) {
|
|
169
177
|
// The one-line failure message is the user's primary signal
|
|
@@ -266,7 +274,11 @@ export async function runUpdate(argv, io = {}) {
|
|
|
266
274
|
}
|
|
267
275
|
|
|
268
276
|
function printSummary(s, out) {
|
|
269
|
-
|
|
277
|
+
// `skipped` counts here: a run that dropped a skill is NOT "up to date",
|
|
278
|
+
// and saying so was a false statement to the user (#2413 adversarial
|
|
279
|
+
// review — the counter was incremented and never read).
|
|
280
|
+
const skipped = s.skipped ?? 0;
|
|
281
|
+
const total = s.added + s.updated + s.removed + skipped;
|
|
270
282
|
if (s.notModified || total === 0) {
|
|
271
283
|
out.write(" ✓ Library is up to date.\n");
|
|
272
284
|
return;
|
|
@@ -275,6 +287,7 @@ function printSummary(s, out) {
|
|
|
275
287
|
if (s.added > 0) out.write(` + ${s.added} added\n`);
|
|
276
288
|
if (s.updated > 0) out.write(` ↻ ${s.updated} updated\n`);
|
|
277
289
|
if (s.removed > 0) out.write(` − ${s.removed} removed\n`);
|
|
290
|
+
if (skipped > 0) out.write(` ! ${skipped} skipped (see warnings above)\n`);
|
|
278
291
|
out.write("\n");
|
|
279
292
|
}
|
|
280
293
|
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* next `update` run fully overwrites it.
|
|
28
28
|
*
|
|
29
29
|
* • Safety checks (NOT layout enforcement):
|
|
30
|
+
* - Control characters (C0/DEL/C1, raw or URL-encoded) — rejected
|
|
31
|
+
* (matches server CONTROL_CHARS, #2402)
|
|
30
32
|
* - Path traversal (..) — rejected
|
|
31
33
|
* - Absolute paths — rejected
|
|
32
34
|
* - Depth > 5 — rejected (matches server MAX_PATH_DEPTH)
|
|
@@ -131,6 +133,31 @@ const GLOBAL_TARGETS = Object.freeze([
|
|
|
131
133
|
|
|
132
134
|
// ── Public API ──────────────────────────────────────────────────────────
|
|
133
135
|
|
|
136
|
+
// Unicode Cc: C0 controls, DEL, and C1 controls. Same rule as the
|
|
137
|
+
// server-side validator (#2402): a control character in a path can inject
|
|
138
|
+
// line breaks or terminal escapes into prompts, logs, and rendered file
|
|
139
|
+
// trees — and the CLI writes these paths to the user's disk on sync.
|
|
140
|
+
// Mirrors CONTROL_CHAR_CLASS in src/lib/skills/file-validation.ts (the
|
|
141
|
+
// CLI cannot import server TS). Always extend via \uXXXX escapes — NEVER
|
|
142
|
+
// paste a raw control byte; a raw byte silently corrupts the class.
|
|
143
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/;
|
|
144
|
+
const CONTROL_CHARS_GLOBAL = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render a path for an error message with control characters
|
|
148
|
+
* \uXXXX-escaped, so the error string can never re-inject the characters
|
|
149
|
+
* it rejects into terminal output or sync logs.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} value
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
function escapeControlChars(value) {
|
|
155
|
+
return value.replace(
|
|
156
|
+
CONTROL_CHARS_GLOBAL,
|
|
157
|
+
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
134
161
|
/**
|
|
135
162
|
* Validate a single file path inside a skill directory.
|
|
136
163
|
*
|
|
@@ -142,6 +169,12 @@ const GLOBAL_TARGETS = Object.freeze([
|
|
|
142
169
|
* @returns {string | null}
|
|
143
170
|
*/
|
|
144
171
|
export function validateFilePath(rawPath) {
|
|
172
|
+
// Checked before decoding so no error message below (they interpolate
|
|
173
|
+
// the raw path verbatim) can ever echo a raw control character.
|
|
174
|
+
if (CONTROL_CHARS.test(rawPath)) {
|
|
175
|
+
return `Blocked control character in path "${escapeControlChars(rawPath)}".`;
|
|
176
|
+
}
|
|
177
|
+
|
|
145
178
|
// Decode URL-encoded characters before validation
|
|
146
179
|
let path;
|
|
147
180
|
try {
|
|
@@ -150,6 +183,12 @@ export function validateFilePath(rawPath) {
|
|
|
150
183
|
return `Invalid URL encoding in path "${rawPath}".`;
|
|
151
184
|
}
|
|
152
185
|
|
|
186
|
+
// Re-check after the single decode: %0A-style encodings become real
|
|
187
|
+
// control characters here.
|
|
188
|
+
if (CONTROL_CHARS.test(path)) {
|
|
189
|
+
return `Blocked control character in path "${escapeControlChars(path)}".`;
|
|
190
|
+
}
|
|
191
|
+
|
|
153
192
|
// Path traversal
|
|
154
193
|
if (path.includes("..")) {
|
|
155
194
|
return `Blocked path traversal in "${rawPath}".`;
|
package/src/lib/sync.mjs
CHANGED
|
@@ -145,7 +145,12 @@ import {
|
|
|
145
145
|
} from "./file-write.mjs";
|
|
146
146
|
import { writeFileAtomic } from "./fs-utils.mjs";
|
|
147
147
|
import { globalLastSyncPath } from "./paths.mjs";
|
|
148
|
-
import {
|
|
148
|
+
import {
|
|
149
|
+
diskError,
|
|
150
|
+
validationError,
|
|
151
|
+
CliError,
|
|
152
|
+
EXIT_VALIDATION,
|
|
153
|
+
} from "./errors.mjs";
|
|
149
154
|
import { computeSkillShas } from "./crypto-shas.mjs";
|
|
150
155
|
|
|
151
156
|
/**
|
|
@@ -923,6 +928,10 @@ export async function runSync(options) {
|
|
|
923
928
|
added: 0,
|
|
924
929
|
updated: 0,
|
|
925
930
|
removed: 0,
|
|
931
|
+
// Skills the server sent that could not be written locally (e.g. a
|
|
932
|
+
// stored path that fails client-side validation). Counted rather than
|
|
933
|
+
// fatal — see the quarantine-and-continue block in the apply loop.
|
|
934
|
+
skipped: 0,
|
|
926
935
|
notModified: false,
|
|
927
936
|
fullSync,
|
|
928
937
|
syncedAt: result.syncedAt,
|
|
@@ -1011,13 +1020,61 @@ export async function runSync(options) {
|
|
|
1011
1020
|
delete skillsMap[key];
|
|
1012
1021
|
}
|
|
1013
1022
|
|
|
1023
|
+
// Placement configuration is validated ONCE, before any skill is
|
|
1024
|
+
// written. `placementTargetsFor` depends only on `vendors` and
|
|
1025
|
+
// `global` — both constant for the whole sync — so everything it
|
|
1026
|
+
// throws is a GLOBAL misconfiguration, never a per-skill problem.
|
|
1027
|
+
// Reaching the per-skill quarantine below, it was swallowed once per
|
|
1028
|
+
// skill: N copies of a misleading "this one will be retried next
|
|
1029
|
+
// session" warning for a condition no retry can fix, and the caller
|
|
1030
|
+
// still reported success (#2433).
|
|
1031
|
+
//
|
|
1032
|
+
// Guarded on there being something to write, so a no-op sync (empty
|
|
1033
|
+
// response, tombstones only) is unaffected.
|
|
1034
|
+
if (result.skills.length > 0) {
|
|
1035
|
+
placementTargetsFor({ vendors, global: global === true });
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1014
1038
|
for (const skill of result.skills) {
|
|
1015
1039
|
if (skill.filesIncomplete) {
|
|
1016
1040
|
anyIncomplete = true;
|
|
1017
1041
|
continue;
|
|
1018
1042
|
}
|
|
1019
1043
|
const wasAlreadyOnDisk = isAnyTargetPresent(skill.name, { vendors, global });
|
|
1020
|
-
|
|
1044
|
+
// Quarantine-and-continue, NOT abort (#2413 audit). `writeSkillDir` ->
|
|
1045
|
+
// `validateSkill` throws on the first unusable skill, and this loop used
|
|
1046
|
+
// to have no catch — so ONE bad skill anywhere in a subscriber's library
|
|
1047
|
+
// aborted the whole sync, delivered none of the others, and failed
|
|
1048
|
+
// identically on every retry. #2402's control-character rejection made
|
|
1049
|
+
// that reachable from stored data: a path accepted by an older server
|
|
1050
|
+
// now fails validation on the client. A single publisher could therefore
|
|
1051
|
+
// break sync for every member of every account holding that skill.
|
|
1052
|
+
// Degrade per skill exactly as `filesIncomplete` above does: warn, mark
|
|
1053
|
+
// the payload partial so local state does not advance (the run is
|
|
1054
|
+
// retried next session rather than throttled away), and move on.
|
|
1055
|
+
try {
|
|
1056
|
+
writeSkillDir(skill, { vendors, global });
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
// ONLY a validation failure is quarantinable. `writeSkillDir` also
|
|
1059
|
+
// throws EXIT_DISK for ENOSPC, EACCES, a read-only filesystem, and the
|
|
1060
|
+
// Windows rename failure whose `hint` tells the user their only copy is
|
|
1061
|
+
// in a `.tmp` directory. Those are not per-skill problems — every
|
|
1062
|
+
// remaining skill will hit them too — and swallowing them turned a
|
|
1063
|
+
// loud, actionable failure into a silent one, because the SessionStart
|
|
1064
|
+
// hook passes a black-hole stderr and gates its output on
|
|
1065
|
+
// added+updated+removed. Rethrow so the command's own handler reports
|
|
1066
|
+
// them with the hint intact. (#2413 adversarial review.)
|
|
1067
|
+
if (!(err instanceof CliError) || err.exitCode !== EXIT_VALIDATION) {
|
|
1068
|
+
throw err;
|
|
1069
|
+
}
|
|
1070
|
+
anyIncomplete = true;
|
|
1071
|
+
summary.skipped++;
|
|
1072
|
+
stderr.write(
|
|
1073
|
+
` warning: skipped ${skill.owner}/${skill.name} (${err.message}). ` +
|
|
1074
|
+
`Other skills were still synced; this one will be retried next session.\n`,
|
|
1075
|
+
);
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1021
1078
|
if (wasAlreadyOnDisk) {
|
|
1022
1079
|
summary.updated++;
|
|
1023
1080
|
} else {
|
|
@@ -516,6 +516,96 @@ describe("runInit — --agent none", () => {
|
|
|
516
516
|
assert.equal(cfg.apiKey, VALID_KEY);
|
|
517
517
|
assert.equal(cfg.serverUrl, serverUrl);
|
|
518
518
|
});
|
|
519
|
+
|
|
520
|
+
it("--agent none --global skips the sync too, and never claims success over dropped skills (#2433)", async () => {
|
|
521
|
+
// REGRESSION. `skipFirstSync` used to carry `&& !flags.global`, so
|
|
522
|
+
// this combination reached `runSync` with an empty vendor list —
|
|
523
|
+
// the exact case the skip exists to avoid. Every skill in the
|
|
524
|
+
// library was then quarantined one-by-one by the per-skill catch
|
|
525
|
+
// with "Other skills were still synced; this one will be retried
|
|
526
|
+
// next session" (both halves false), and init went on to print
|
|
527
|
+
// "No skills in library yet" AND "SkillRepo is ready".
|
|
528
|
+
//
|
|
529
|
+
// A non-empty library is essential: with zero skills the old code
|
|
530
|
+
// produced no warnings and the bug was invisible. This is why the
|
|
531
|
+
// pre-existing `--agent none` tests above never caught it.
|
|
532
|
+
server.setLibraryResponse({
|
|
533
|
+
skills: [
|
|
534
|
+
{
|
|
535
|
+
owner: "alice",
|
|
536
|
+
name: "pdf-helper",
|
|
537
|
+
version: "1.0.0",
|
|
538
|
+
files: [{ path: "SKILL.md", content: "# PDF Helper\n" }],
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
removals: [],
|
|
542
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
await runInit(
|
|
546
|
+
[
|
|
547
|
+
"--key", VALID_KEY,
|
|
548
|
+
"--url", serverUrl,
|
|
549
|
+
"--yes",
|
|
550
|
+
"--agent", "none",
|
|
551
|
+
"--global",
|
|
552
|
+
],
|
|
553
|
+
{ stdout, stderr },
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
const out = stdout.text();
|
|
557
|
+
const err = stderr.text();
|
|
558
|
+
|
|
559
|
+
assert.match(out, /Skipped first sync/, "the sync must be skipped under --agent none --global");
|
|
560
|
+
assert.doesNotMatch(
|
|
561
|
+
err,
|
|
562
|
+
/retried next session/,
|
|
563
|
+
"a global misconfiguration must never be reported as a per-skill quarantine",
|
|
564
|
+
);
|
|
565
|
+
assert.doesNotMatch(
|
|
566
|
+
out,
|
|
567
|
+
/No skills in library yet/,
|
|
568
|
+
"must not claim the library is empty when it holds a skill",
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("a skipped sync makes no claim about the library's state (#2433)", async () => {
|
|
573
|
+
// "Skipped first sync" used to be followed by "Library is up to
|
|
574
|
+
// date (no changes since last sync)" — a confident claim about a
|
|
575
|
+
// server that was never contacted, printed on the same screen as
|
|
576
|
+
// the line saying we didn't contact it. Same rationale as the
|
|
577
|
+
// synthesized summary's `fullSync: null`: the state is UNKNOWN,
|
|
578
|
+
// so the honest output is no claim at all.
|
|
579
|
+
//
|
|
580
|
+
// The library deliberately holds a skill, so "up to date" would
|
|
581
|
+
// additionally be false on the merits.
|
|
582
|
+
server.setLibraryResponse({
|
|
583
|
+
skills: [
|
|
584
|
+
{
|
|
585
|
+
owner: "alice",
|
|
586
|
+
name: "pdf-helper",
|
|
587
|
+
version: "1.0.0",
|
|
588
|
+
files: [{ path: "SKILL.md", content: "# PDF Helper\n" }],
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
removals: [],
|
|
592
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
await runInit(
|
|
596
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "none"],
|
|
597
|
+
{ stdout, stderr },
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
const out = stdout.text();
|
|
601
|
+
assert.match(out, /Skipped first sync/);
|
|
602
|
+
assert.doesNotMatch(
|
|
603
|
+
out,
|
|
604
|
+
/Library is up to date/,
|
|
605
|
+
"a sync that never ran cannot report the library as up to date",
|
|
606
|
+
);
|
|
607
|
+
assert.doesNotMatch(out, /No skills in library yet/);
|
|
608
|
+
});
|
|
519
609
|
});
|
|
520
610
|
|
|
521
611
|
// ── Idempotency ────────────────────────────────────────────────────────
|
|
@@ -150,6 +150,41 @@ describe("validateFilePath", () => {
|
|
|
150
150
|
assert.match(err, /URL encoding/);
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
it("rejects control characters (newline prompt-injection vector, #2402)", () => {
|
|
154
|
+
const err = validateFilePath("references/a\n## heading\ntext.md");
|
|
155
|
+
assert.match(err ?? "", /control character/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("rejects NUL, DEL, and C1 controls", () => {
|
|
159
|
+
for (const code of [0x00, 0x1b, 0x1f, 0x7f, 0x85, 0x9f]) {
|
|
160
|
+
const err = validateFilePath(`a${String.fromCharCode(code)}b.md`);
|
|
161
|
+
assert.match(
|
|
162
|
+
err ?? "",
|
|
163
|
+
/control character/,
|
|
164
|
+
`U+${code.toString(16).padStart(4, "0")} should be blocked`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("rejects URL-encoded control characters", () => {
|
|
170
|
+
for (const enc of ["%0A", "%0a", "%00", "%1B", "%7F", "%C2%9B"]) {
|
|
171
|
+
const err = validateFilePath(`a${enc}b.md`);
|
|
172
|
+
assert.match(err ?? "", /control character/, `${enc} should be blocked`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("escapes control characters in the error message instead of echoing them", () => {
|
|
177
|
+
// The message ends up in terminal output and sync logs — echoing the
|
|
178
|
+
// raw character would recreate the injection there.
|
|
179
|
+
const err = validateFilePath("a\nb.md");
|
|
180
|
+
assert.ok(err != null && !err.includes("\n"), "error must not contain a raw newline");
|
|
181
|
+
assert.ok(err.includes("\\u000a"), "error should render the escaped codepoint");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("accepts double-encoded %250A (single-decode policy, matches %252E%252E precedent)", () => {
|
|
185
|
+
assert.equal(validateFilePath("a%250Ab.md"), null);
|
|
186
|
+
});
|
|
187
|
+
|
|
153
188
|
// QA cross-PR review (#1252): lock the current behavior of the
|
|
154
189
|
// safety pre-check against backslash + Unicode-encoded traversal
|
|
155
190
|
// patterns. validateFilePath uses an ASCII `..` substring check —
|
|
@@ -551,6 +551,66 @@ describe("getLibrary", () => {
|
|
|
551
551
|
}
|
|
552
552
|
});
|
|
553
553
|
|
|
554
|
+
// ── Wire identity (#2358 — skillset epic no-file contract) ──────────
|
|
555
|
+
//
|
|
556
|
+
// The skillset epic's D2 contract: a repo with no skillset declaration
|
|
557
|
+
// must produce request/receipt payloads byte-identical to the
|
|
558
|
+
// pre-skillset CLI, forever. These tests capture today's wire shape so
|
|
559
|
+
// any future change to it — a new query param, a pin/skillset header —
|
|
560
|
+
// fails here first and has to be made deliberately.
|
|
561
|
+
|
|
562
|
+
it("wire identity: a plain sync sends the exact pre-skillset request shape (#2358)", async () => {
|
|
563
|
+
let captured;
|
|
564
|
+
const srv = await startServer((req, res) => {
|
|
565
|
+
captured = { url: req.url, method: req.method, headers: { ...req.headers } };
|
|
566
|
+
jsonRes(res, 200, { skills: [], removals: [], syncedAt: "x" });
|
|
567
|
+
});
|
|
568
|
+
try {
|
|
569
|
+
await getLibrary(srv.url, VALID_KEY);
|
|
570
|
+
|
|
571
|
+
// Exact path — no query string at all on a plain sync.
|
|
572
|
+
assert.equal(captured.url, "/api/v1/library");
|
|
573
|
+
assert.equal(captured.method, "GET");
|
|
574
|
+
// Auth rides the Authorization header, and no pin/skillset-shaped
|
|
575
|
+
// vocabulary appears in any header name or value.
|
|
576
|
+
assert.match(captured.headers.authorization, /^Bearer /);
|
|
577
|
+
const headerBlob = JSON.stringify(captured.headers);
|
|
578
|
+
assert.doesNotMatch(headerBlob, /skillset|approved|pin/i);
|
|
579
|
+
} finally {
|
|
580
|
+
await srv.close();
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("wire identity: a pin-suffixed ETag round-trips verbatim as an opaque string (#2358)", async () => {
|
|
585
|
+
// Servers emit `"{ts}-{count}-{removals}-p{ts}-{n}"` for accounts
|
|
586
|
+
// with pin history. The CLI must treat every ETag as opaque: store
|
|
587
|
+
// it exactly, send it back exactly. A client that parsed the legacy
|
|
588
|
+
// three-segment shape would corrupt the conditional-GET cycle for
|
|
589
|
+
// pinned accounts.
|
|
590
|
+
const SUFFIXED = '"1754680000000-12-3-p1754690000000-2"';
|
|
591
|
+
let conditional;
|
|
592
|
+
const srv = await startServer((req, res) => {
|
|
593
|
+
if (req.headers["if-none-match"]) {
|
|
594
|
+
conditional = req.headers["if-none-match"];
|
|
595
|
+
res.statusCode = 304;
|
|
596
|
+
res.end();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
jsonRes(res, 200, { skills: [], removals: [], syncedAt: "x" }, { ETag: SUFFIXED });
|
|
600
|
+
});
|
|
601
|
+
try {
|
|
602
|
+
const first = await getLibrary(srv.url, VALID_KEY);
|
|
603
|
+
assert.equal(first.etag, SUFFIXED);
|
|
604
|
+
|
|
605
|
+
const second = await getLibrary(srv.url, VALID_KEY, { ifNoneMatch: first.etag });
|
|
606
|
+
assert.equal(conditional, SUFFIXED);
|
|
607
|
+
assert.equal(second.notModified, true);
|
|
608
|
+
assert.equal(second.etag, SUFFIXED);
|
|
609
|
+
} finally {
|
|
610
|
+
await srv.close();
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
554
614
|
it("throws authError on 401", async () => {
|
|
555
615
|
const srv = await startServer((req, res) => jsonRes(res, 401, { error: "nope" }));
|
|
556
616
|
try {
|
|
@@ -27,11 +27,12 @@ import {
|
|
|
27
27
|
mkdtempSync,
|
|
28
28
|
rmSync,
|
|
29
29
|
mkdirSync,
|
|
30
|
+
chmodSync,
|
|
30
31
|
writeFileSync,
|
|
31
32
|
existsSync,
|
|
32
33
|
readFileSync,
|
|
33
34
|
} from "node:fs";
|
|
34
|
-
import { join } from "node:path";
|
|
35
|
+
import { join, dirname } from "node:path";
|
|
35
36
|
import { tmpdir } from "node:os";
|
|
36
37
|
|
|
37
38
|
import {
|
|
@@ -48,7 +49,7 @@ import {
|
|
|
48
49
|
} from "../../lib/sync.mjs";
|
|
49
50
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
50
51
|
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
51
|
-
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
52
|
+
import { CliError, EXIT_VALIDATION, EXIT_DISK } from "../../lib/errors.mjs";
|
|
52
53
|
import { computeSkillShas } from "../../lib/crypto-shas.mjs";
|
|
53
54
|
import { walkDetectedPlacements } from "../../lib/placement-walk.mjs";
|
|
54
55
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
@@ -2534,3 +2535,168 @@ describe("runSync — SessionStart throttle (#2174)", () => {
|
|
|
2534
2535
|
assert.equal(server.getLibraryRequestCount(), 0, "verbose throttle still makes no GET");
|
|
2535
2536
|
});
|
|
2536
2537
|
});
|
|
2538
|
+
|
|
2539
|
+
// A skill whose stored path fails client-side validation is the failure mode
|
|
2540
|
+
// #2402 made reachable: a path an older server accepted now throws inside
|
|
2541
|
+
// validateSkill. Before the #2413 audit fix the apply loop had no catch, so
|
|
2542
|
+
// ONE such skill aborted the entire sync — every other skill in the library
|
|
2543
|
+
// went undelivered and every retry failed identically. A single publisher
|
|
2544
|
+
// could break sync for every member of every account holding that skill.
|
|
2545
|
+
describe("runSync — a poisoned skill is quarantined, not fatal (#2413 audit)", () => {
|
|
2546
|
+
beforeEach(setupServer);
|
|
2547
|
+
afterEach(teardownServer);
|
|
2548
|
+
|
|
2549
|
+
function poisonedSkill(name) {
|
|
2550
|
+
const skill = makeSkill(name);
|
|
2551
|
+
// Control character in a stored path — rejected by validateFilePath.
|
|
2552
|
+
skill.files.push({
|
|
2553
|
+
path: "references/a\nb.md",
|
|
2554
|
+
content: "x",
|
|
2555
|
+
sha256: "y",
|
|
2556
|
+
size: 1,
|
|
2557
|
+
contentType: "text/plain",
|
|
2558
|
+
});
|
|
2559
|
+
return skill;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
it("a GLOBAL placement misconfiguration throws once, before any skill is written (#2433)", async () => {
|
|
2563
|
+
// The quarantine is for per-skill data problems only. An empty
|
|
2564
|
+
// vendor list is a global misconfiguration — `placementTargetsFor`
|
|
2565
|
+
// reads only `vendors`/`global`, both constant for the whole sync,
|
|
2566
|
+
// so it fails identically for every skill. Left inside the loop it
|
|
2567
|
+
// was caught by the per-skill handler and emitted one misleading
|
|
2568
|
+
// "will be retried next session" warning PER SKILL, while runSync
|
|
2569
|
+
// returned normally and the caller reported success.
|
|
2570
|
+
//
|
|
2571
|
+
// Two skills, so a per-skill quarantine would produce two warnings
|
|
2572
|
+
// and a resolved promise — a single throw is the discriminator.
|
|
2573
|
+
const { createCaptureStream } = await import(
|
|
2574
|
+
"../helpers/capture-stream.mjs"
|
|
2575
|
+
);
|
|
2576
|
+
const stderr = createCaptureStream();
|
|
2577
|
+
server.setLibraryResponse({
|
|
2578
|
+
skills: [makeSkill("alpha"), makeSkill("beta")],
|
|
2579
|
+
removals: [],
|
|
2580
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2581
|
+
});
|
|
2582
|
+
|
|
2583
|
+
await assert.rejects(
|
|
2584
|
+
() =>
|
|
2585
|
+
runSync({
|
|
2586
|
+
serverUrl,
|
|
2587
|
+
apiKey: VALID_KEY,
|
|
2588
|
+
vendors: [],
|
|
2589
|
+
global: true,
|
|
2590
|
+
io: { stderr },
|
|
2591
|
+
}),
|
|
2592
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
2593
|
+
"an empty vendor list must propagate, not be quarantined per skill",
|
|
2594
|
+
);
|
|
2595
|
+
|
|
2596
|
+
assert.doesNotMatch(
|
|
2597
|
+
stderr.text(),
|
|
2598
|
+
/retried next session/,
|
|
2599
|
+
"a permanent misconfiguration must not be described as retryable",
|
|
2600
|
+
);
|
|
2601
|
+
});
|
|
2602
|
+
|
|
2603
|
+
it("delivers the healthy skills and reports the bad one instead of throwing", async () => {
|
|
2604
|
+
const { createCaptureStream } = await import(
|
|
2605
|
+
"../helpers/capture-stream.mjs"
|
|
2606
|
+
);
|
|
2607
|
+
const stderr = createCaptureStream();
|
|
2608
|
+
server.setLibraryResponse({
|
|
2609
|
+
skills: [poisonedSkill("poisoned"), makeSkill("healthy")],
|
|
2610
|
+
removals: [],
|
|
2611
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2612
|
+
});
|
|
2613
|
+
|
|
2614
|
+
const result = await runSync({
|
|
2615
|
+
serverUrl,
|
|
2616
|
+
apiKey: VALID_KEY,
|
|
2617
|
+
vendors: ["claudeCode"],
|
|
2618
|
+
io: { stderr },
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
// Did not throw, and the healthy skill still landed.
|
|
2622
|
+
assert.equal(result.added, 1, "the healthy skill must still be delivered");
|
|
2623
|
+
assert.equal(result.skipped, 1, "the poisoned skill must be counted as skipped");
|
|
2624
|
+
assert.match(stderr.text(), /skipped alice\/poisoned/);
|
|
2625
|
+
assert.match(stderr.text(), /retried next session/);
|
|
2626
|
+
});
|
|
2627
|
+
|
|
2628
|
+
it("does NOT quarantine a disk error — it propagates loudly", { skip: process.platform === "win32" ? "POSIX perms" : false }, async () => {
|
|
2629
|
+
// The quarantine is for validation failures only. A disk condition
|
|
2630
|
+
// (ENOSPC, EACCES, a read-only FS, the Windows rename failure whose
|
|
2631
|
+
// `hint` names the user's only surviving copy) is not a per-skill
|
|
2632
|
+
// problem — every remaining skill hits it too. Swallowing it turned an
|
|
2633
|
+
// actionable failure into silence, because the SessionStart hook
|
|
2634
|
+
// black-holes stderr and gates its output on added+updated+removed.
|
|
2635
|
+
// Simulated with a read-only working directory. (#2413 adversarial
|
|
2636
|
+
// review.)
|
|
2637
|
+
const { createCaptureStream } = await import(
|
|
2638
|
+
"../helpers/capture-stream.mjs"
|
|
2639
|
+
);
|
|
2640
|
+
const stderr = createCaptureStream();
|
|
2641
|
+
server.setLibraryResponse({
|
|
2642
|
+
skills: [makeSkill("blocked")],
|
|
2643
|
+
removals: [],
|
|
2644
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
const projectDir = process.cwd();
|
|
2648
|
+
chmodSync(projectDir, 0o500);
|
|
2649
|
+
try {
|
|
2650
|
+
await assert.rejects(
|
|
2651
|
+
() =>
|
|
2652
|
+
runSync({
|
|
2653
|
+
serverUrl,
|
|
2654
|
+
apiKey: VALID_KEY,
|
|
2655
|
+
vendors: ["claudeCode"],
|
|
2656
|
+
io: { stderr },
|
|
2657
|
+
}),
|
|
2658
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_DISK,
|
|
2659
|
+
"a disk error must propagate, not be quarantined as a skip",
|
|
2660
|
+
);
|
|
2661
|
+
} finally {
|
|
2662
|
+
chmodSync(projectDir, 0o700);
|
|
2663
|
+
}
|
|
2664
|
+
});
|
|
2665
|
+
|
|
2666
|
+
it("does not advance local sync state when a skill was skipped", async () => {
|
|
2667
|
+
// Same guard as `filesIncomplete`: a partial apply must not persist the
|
|
2668
|
+
// ETag, or the next run 304s and the skipped skill is never retried.
|
|
2669
|
+
const { createCaptureStream } = await import(
|
|
2670
|
+
"../helpers/capture-stream.mjs"
|
|
2671
|
+
);
|
|
2672
|
+
const stderr = createCaptureStream();
|
|
2673
|
+
// The ETag must be set on the SERVER (a response-header concern) — passing
|
|
2674
|
+
// `etag` inside setLibraryResponse does nothing, which is what made an
|
|
2675
|
+
// earlier version of this test vacuous: it passed even with
|
|
2676
|
+
// `anyIncomplete` deleted, because `result.etag` was null and the state
|
|
2677
|
+
// write was skipped for an unrelated reason (#2413 adversarial review).
|
|
2678
|
+
server.setEtag('"poisoned-v1"');
|
|
2679
|
+
server.setLibraryResponse({
|
|
2680
|
+
skills: [poisonedSkill("poisoned"), makeSkill("healthy")],
|
|
2681
|
+
removals: [],
|
|
2682
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2683
|
+
});
|
|
2684
|
+
|
|
2685
|
+
await runSync({
|
|
2686
|
+
serverUrl,
|
|
2687
|
+
apiKey: VALID_KEY,
|
|
2688
|
+
vendors: ["claudeCode"],
|
|
2689
|
+
io: { stderr },
|
|
2690
|
+
});
|
|
2691
|
+
|
|
2692
|
+
// Assert the INVARIANT directly rather than inferring it from request
|
|
2693
|
+
// counts: no `.last-sync` ETag may be persisted while a skill was
|
|
2694
|
+
// skipped, or the next run 304s and the skipped skill is never retried.
|
|
2695
|
+
const state = readLastSync();
|
|
2696
|
+
assert.equal(
|
|
2697
|
+
state?.etag ?? null,
|
|
2698
|
+
null,
|
|
2699
|
+
"a skipped skill must leave the ETag unpersisted",
|
|
2700
|
+
);
|
|
2701
|
+
});
|
|
2702
|
+
});
|