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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.9.0",
3
+ "version": "4.9.2",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- const skipFirstSync =
609
- Array.isArray(vendors) && vendors.length === 0 && !flags.global;
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 + syncSummary.updated + syncSummary.removed === 0;
698
+ syncSummary.added +
699
+ syncSummary.updated +
700
+ syncSummary.removed +
701
+ (syncSummary.skipped ?? 0) ===
702
+ 0;
686
703
 
687
- if (syncFailedReason) {
688
- // The warning already printed; the step-summary success line
689
- // would be misleading, so we skip it. Any helpful "next steps"
690
- // is in the final `SkillRepo is ready` block.
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
@@ -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
- const total = summary.added + summary.updated + summary.removed;
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.\n`,
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
- const total = s.added + s.updated + s.removed;
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
 
@@ -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 { diskError, validationError } from "./errors.mjs";
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
- writeSkillDir(skill, { vendors, global });
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
+ });