opencode-harness-kit 0.5.4 → 0.5.5

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/dist/index.js CHANGED
@@ -16,10 +16,50 @@ var BASH_DENYLIST = [
16
16
  // so opencode prompts for human approval before publishing.
17
17
  "rm -rf *",
18
18
  "rm -fr *",
19
+ // The short forms of the same flags, one at a time.
20
+ //
21
+ // The guard's rule is a TOKEN scanner: it blocks an rm whose short flag
22
+ // cluster contains r or f in any case, and an rm whose long flag is
23
+ // --recursive or --force. So `rm -f file` and `rm -r dir` are blocked
24
+ // there - and the permission map has to deny what the guard blocks, or
25
+ // the two layers contradict each other: the map would ask about a
26
+ // command the guard refuses.
27
+ //
28
+ // `-R` is named too because the guard's rule is case-insensitive and
29
+ // GNU rm reads `-R` as `--recursive`.
30
+ //
31
+ // The two layers are not the same matcher. The guard reads the tokens
32
+ // and can see a destructive flag in any position; this list is a flat
33
+ // glob over the command, so it can only name the spellings.
34
+ //
35
+ // A cluster a glob cannot express (`-rfv`) is caught by the guard and
36
+ // asked about by the config - the config asks about everything it does
37
+ // not name, and asking is the safe direction.
38
+ //
39
+ // A PERMUTED delete (`rm /tmp/x -rf`) is caught by neither layer today:
40
+ // the guard's rule needs the flag before the path, and the glob needs
41
+ // the flag in front. The config asks, so a human decides. Closing that
42
+ // hole is a change to the guard's rule in src/rules.js, which reads the
43
+ // tokens - a glob here could only name one spelling of it, and the
44
+ // spelling would still be the one it missed.
45
+ "rm -f *",
46
+ "rm -r *",
47
+ "rm -R *",
48
+ // The long GNU names of the same two flags. A delete that is spelled
49
+ // `--recursive --force` is the delete the short forms name.
50
+ "rm --recursive *",
51
+ "rm --force *",
19
52
  "curl * | sh",
20
53
  "curl * | bash",
54
+ // The same pipe into a shell that is named by its path. The shell is the
55
+ // same shell either way, and the config can only match what it can see.
56
+ "curl * | */sh",
57
+ "curl * | */bash",
21
58
  "chmod 777 *",
22
59
  "chmod 777*",
60
+ // The same mode written with a leading zero.
61
+ "chmod 0777 *",
62
+ "chmod 0777*",
23
63
  "gh pr create*",
24
64
  "gh pr merge*",
25
65
  "gh pr close*",
@@ -45,7 +85,7 @@ var BASH_DENYLIST = [
45
85
  ];
46
86
  function matchesPattern(command, pattern) {
47
87
  const re = new RegExp(
48
- "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") + "$"
88
+ "^" + pattern.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") + "$"
49
89
  );
50
90
  return re.test(command);
51
91
  }
@@ -91,7 +131,7 @@ function heredocEnd(command, at) {
91
131
  p = end + 1;
92
132
  }
93
133
  }
94
- function segments(command) {
134
+ function split(command, kind) {
95
135
  const parts = [];
96
136
  let cur = "";
97
137
  let quote = null;
@@ -129,13 +169,26 @@ function segments(command) {
129
169
  continue;
130
170
  }
131
171
  }
132
- if (ch === "\n" || ch === ";") {
172
+ if (ch === "|") {
173
+ if (kind === "pipe") {
174
+ if (command[i + 1] === "|") {
175
+ cur += "||";
176
+ i++;
177
+ continue;
178
+ }
179
+ flush();
180
+ continue;
181
+ }
133
182
  flush();
183
+ if (command[i + 1] === "|") i++;
134
184
  continue;
135
185
  }
136
- if (ch === "|") {
186
+ if (kind === "pipe") {
187
+ cur += ch;
188
+ continue;
189
+ }
190
+ if (ch === "\n" || ch === ";") {
137
191
  flush();
138
- if (command[i + 1] === "|") i++;
139
192
  continue;
140
193
  }
141
194
  if (ch === "&") {
@@ -156,6 +209,12 @@ function segments(command) {
156
209
  flush();
157
210
  return parts;
158
211
  }
212
+ function segments(command) {
213
+ return split(command, "command");
214
+ }
215
+ function pipeStages(command) {
216
+ return split(command, "pipe");
217
+ }
159
218
  function tokenize(segment) {
160
219
  const tokens = [];
161
220
  let cur = "";
@@ -211,6 +270,9 @@ var GIT_WRAPPERS = /* @__PURE__ */ new Set([
211
270
  "xargs",
212
271
  "npx"
213
272
  ]);
273
+ var SHELL_NAMES = /^(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
274
+ var SCRIPT_RUNNERS = /^python[0-9.]*$/i;
275
+ var DOWNLOADERS = /\b(curl|wget)\b/i;
214
276
  var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
215
277
  var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
216
278
  "-C",
@@ -232,6 +294,35 @@ var GIT_GLOBAL_FLAGS = /* @__PURE__ */ new Set([
232
294
  "--glob-pathspecs",
233
295
  "--icase-pathspecs"
234
296
  ]);
297
+ var TOOL_VALUE_FLAGS = {
298
+ gh: /* @__PURE__ */ new Set(["-R", "--repo", "-H", "--hostname"]),
299
+ npm: /* @__PURE__ */ new Set([
300
+ "--prefix",
301
+ "--cache",
302
+ "--registry",
303
+ "--userconfig",
304
+ "--globalconfig",
305
+ "--otp",
306
+ "--tag",
307
+ "--access",
308
+ "--loglevel",
309
+ "--workspace",
310
+ "--script-shell",
311
+ "--init-author-name",
312
+ "--init-author-email",
313
+ "--init-author-url",
314
+ "--init-license",
315
+ "--init-module",
316
+ "--init-version"
317
+ ]),
318
+ yarn: /* @__PURE__ */ new Set(["--cwd", "--registry", "--cache-folder", "--modules-folder"]),
319
+ pnpm: /* @__PURE__ */ new Set(["--dir", "-C", "--filter", "--store-dir", "--registry"]),
320
+ bun: /* @__PURE__ */ new Set(["--cwd", "--config", "--registry"])
321
+ };
322
+ function baseName(word) {
323
+ const slash = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\"));
324
+ return slash === -1 ? word : word.slice(slash + 1);
325
+ }
235
326
  function skipPrefixes(tokens) {
236
327
  let i = 0;
237
328
  for (; ; ) {
@@ -241,7 +332,7 @@ function skipPrefixes(tokens) {
241
332
  i++;
242
333
  continue;
243
334
  }
244
- if (GIT_WRAPPERS.has(word)) {
335
+ if (GIT_WRAPPERS.has(baseName(word))) {
245
336
  i++;
246
337
  while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
247
338
  const flag = tokens[i];
@@ -258,10 +349,25 @@ function stripPrefixes(segment) {
258
349
  const i = skipPrefixes(tokens);
259
350
  if (i >= tokens.length) return "";
260
351
  const rest = tokens.slice(i);
261
- const slash = Math.max(rest[0].lastIndexOf("/"), rest[0].lastIndexOf("\\"));
262
- if (slash !== -1) rest[0] = rest[0].slice(slash + 1);
352
+ rest[0] = baseName(rest[0]);
263
353
  return rest.join(" ");
264
354
  }
355
+ function stripToolFlags(command) {
356
+ const tokens = tokenize(command);
357
+ if (!tokens.length) return command;
358
+ const flags = TOOL_VALUE_FLAGS[baseName(tokens[0]).toLowerCase()];
359
+ if (!flags) return command;
360
+ const out = [];
361
+ for (let i = 0; i < tokens.length; i++) {
362
+ const token = tokens[i];
363
+ if (!flags.has(token.split("=")[0])) {
364
+ out.push(token);
365
+ continue;
366
+ }
367
+ if (!token.includes("=") && i + 1 < tokens.length) i++;
368
+ }
369
+ return out.join(" ");
370
+ }
265
371
  function gitSubcommand(segment) {
266
372
  const tokens = tokenize(segment);
267
373
  let i = skipPrefixes(tokens);
@@ -282,25 +388,87 @@ function gitSubcommand(segment) {
282
388
  }
283
389
  return tokens[i] ?? null;
284
390
  }
285
- var SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
286
391
  var MAX_SHELL_DEPTH = 2;
392
+ function shellCommandString(tokens, from) {
393
+ for (let n = from + 1; n < tokens.length; n++) {
394
+ const token = tokens[n];
395
+ if (!token.startsWith("-") || token === "-") continue;
396
+ const m = /^-([a-zA-Z]*)c/.exec(token);
397
+ if (!m) continue;
398
+ const after = token.slice(m[0].length);
399
+ if (after && !/^[a-zA-Z]+$/.test(after)) return after.trim();
400
+ return tokens.slice(n + 1).join(" ") || null;
401
+ }
402
+ return null;
403
+ }
287
404
  function shellPayload(segment) {
288
405
  const tokens = tokenize(segment);
289
406
  if (tokens.length < 2) return null;
290
407
  const i = skipPrefixes(tokens);
291
408
  if (i >= tokens.length) return null;
292
409
  if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null;
293
- if (!SHELL_BIN.test(tokens[i])) return null;
294
- const c = tokens.findIndex((t, n) => n > i && /^-[a-zA-Z]*c$/.test(t));
295
- if (c === -1 || c === tokens.length - 1) return null;
296
- return tokens.slice(c + 1).join(" ") || null;
410
+ if (!SHELL_NAMES.test(baseName(tokens[i]))) return null;
411
+ return shellCommandString(tokens, i);
412
+ }
413
+ var GROUP_OPEN = /^[({[]+/;
414
+ var GROUP_END = /[;)&,}\]]+$/;
415
+ function commandName(word) {
416
+ return baseName(word).replace(GROUP_OPEN, "").replace(GROUP_END, "");
417
+ }
418
+ function runsScript(stage) {
419
+ for (const command of segments(stage)) {
420
+ const tokens = tokenize(command);
421
+ let i = skipPrefixes(tokens);
422
+ if (i >= tokens.length) continue;
423
+ let name = commandName(tokens[i]);
424
+ if (!name) {
425
+ i++;
426
+ if (i >= tokens.length) continue;
427
+ name = commandName(tokens[i]);
428
+ }
429
+ if (SHELL_NAMES.test(name) || SCRIPT_RUNNERS.test(name)) return true;
430
+ }
431
+ return false;
432
+ }
433
+ function pipesScriptIntoShell(command) {
434
+ const stages = pipeStages(command);
435
+ for (let i = 1; i < stages.length; i++) {
436
+ if (!DOWNLOADERS.test(stages[i - 1])) continue;
437
+ if (!runsScript(stages[i])) continue;
438
+ return true;
439
+ }
440
+ return false;
441
+ }
442
+ function isRecursiveDelete(text) {
443
+ const tokens = tokenize(text);
444
+ if (tokens.length < 2) return false;
445
+ if (baseName(tokens[0]).toLowerCase() !== "rm") return false;
446
+ for (let i = 1; i < tokens.length; i++) {
447
+ const token = tokens[i];
448
+ if (!token.startsWith("-")) continue;
449
+ if (!/^-[a-zA-Z]*[rf]/i.test(token) && !/^--(recursive|force)$/i.test(token)) continue;
450
+ return tokens.slice(i + 1).some((t) => !t.startsWith("-"));
451
+ }
452
+ return false;
297
453
  }
298
454
  var ASK_GATED = [/^\s*git\s+push\b/];
299
455
  var BASH_RULES = [
300
456
  {
301
457
  name: "secret exfiltration via shell",
302
458
  scope: "command",
303
- test: /\b(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
459
+ // The credential word has to be the whole name or the tail of it: the
460
+ // name of the variable is `GITHUB_TOKEN`, not `GITHUB`. A word boundary
461
+ // never fires after an underscore, so the boundary alone left every
462
+ // prefixed credential name unmatched.
463
+ //
464
+ // The name is matched in whatever case it is written: the rule used to
465
+ // match the upper case spelling only, so `token=$(curl ...)` was
466
+ // allowed, and a lower case name is the same name.
467
+ //
468
+ // What the rule does NOT cover is an assignment whose value is not
469
+ // fetched: `GITHUB_TOKEN=ghp_example` sets a credential, it does not
470
+ // send one.
471
+ test: /(^|[^A-Za-z0-9])(?:[A-Za-z0-9]+_)*(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
304
472
  why: "assigns a fetched value into a credential-named variable"
305
473
  },
306
474
  {
@@ -312,7 +480,7 @@ var BASH_RULES = [
312
480
  {
313
481
  name: "pipe remote script into a shell",
314
482
  scope: "command",
315
- test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
483
+ check: (text) => pipesScriptIntoShell(text),
316
484
  why: "downloads and executes a script in one step"
317
485
  },
318
486
  // `git push` is ask-gated via permissions (`*` ask in the generated
@@ -321,13 +489,16 @@ var BASH_RULES = [
321
489
  {
322
490
  name: "recursive delete",
323
491
  scope: "segment",
324
- test: /^rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*)\s+/i,
492
+ check: (text) => isRecursiveDelete(text),
325
493
  why: "rm with a recursive or force flag"
326
494
  },
327
495
  {
328
496
  name: "chmod 777",
329
497
  scope: "segment",
330
- test: /^chmod\s+(-[a-zA-Z]+\s+)*777\b/,
498
+ // The mode may be written with leading zeros: `0777` and `777` are the
499
+ // same mode, and a rule that required the bare digits let one spelling
500
+ // of it through.
501
+ test: /^chmod\s+(-[a-zA-Z]+\s+)*0*777\b/,
331
502
  why: "opens permissions on everything"
332
503
  },
333
504
  {
@@ -380,7 +551,7 @@ function checkDenied(command, depth = 0) {
380
551
  const parts = segments(command);
381
552
  const normalized = parts.map((seg) => stripPrefixes(seg));
382
553
  for (let i = 0; i < parts.length; i++) {
383
- const pattern = deniedBy(normalized[i]);
554
+ const pattern = deniedBy(stripToolFlags(normalized[i]));
384
555
  if (pattern) return { pattern, segment: parts[i] };
385
556
  }
386
557
  if (depth < MAX_SHELL_DEPTH) {
@@ -433,15 +604,18 @@ function checkWrite(target, rules = WRITE_RULES, root = "") {
433
604
  }
434
605
 
435
606
  // src/telemetry-store.ts
436
- import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs";
607
+ import { appendFileSync, existsSync, mkdirSync, rmSync, statSync, renameSync } from "node:fs";
437
608
  import { join } from "node:path";
438
609
  var MAX_BYTES = 4 * 1024 * 1024;
439
610
  function logPath(dir) {
440
611
  return join(dir, "telemetry.jsonl");
441
612
  }
442
- function rotate(file) {
613
+ function rotate(file, maxBytes = MAX_BYTES) {
443
614
  try {
444
- if (existsSync(file) && statSync(file).size > MAX_BYTES) {
615
+ if (existsSync(file) && statSync(file).size > maxBytes) {
616
+ const oldest = `${file}.2`;
617
+ if (existsSync(oldest)) rmSync(oldest, { force: true });
618
+ if (existsSync(`${file}.1`)) renameSync(`${file}.1`, oldest);
445
619
  renameSync(file, `${file}.1`);
446
620
  }
447
621
  } catch {
@@ -475,19 +649,46 @@ function projectRoot(directory, worktree) {
475
649
  // src/guard.ts
476
650
  var MARKER = "[opencode-harness-kit] blocked";
477
651
  var WRITE_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "apply_patch", "patch"]);
652
+ function hunkFollows(lines, from) {
653
+ for (let i = from; i < lines.length; i++) {
654
+ if (/^@@/.test(lines[i])) return true;
655
+ if (/^(?:---|\+\+\+)\s/.test(lines[i])) return false;
656
+ }
657
+ return false;
658
+ }
478
659
  function pathsInPatch(text) {
479
660
  const out = [];
480
661
  const push = (p) => {
481
- const v = p.trim();
662
+ const v = p.split(" ")[0].trim();
482
663
  if (!v) return;
483
664
  if (v === "/dev/null") return;
484
665
  if (!out.includes(v)) out.push(v);
485
666
  };
486
- for (const m of text.matchAll(/^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/gm)) {
487
- push(m[1]);
488
- }
489
- for (const m of text.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)) {
490
- push(m[1]);
667
+ const lines = text.split("\n");
668
+ let seenHunk = false;
669
+ for (let i = 0; i < lines.length; i++) {
670
+ const line = lines[i];
671
+ const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line);
672
+ if (marker) {
673
+ push(marker[1]);
674
+ continue;
675
+ }
676
+ if (/^@@/.test(line)) {
677
+ seenHunk = true;
678
+ continue;
679
+ }
680
+ const minus = /^---\s+(?:a\/)?(.+)$/.exec(line);
681
+ const next = i + 1 < lines.length ? /^\+\+\+\s+(?:b\/)?(.+)$/.exec(lines[i + 1]) : null;
682
+ if (minus && next) {
683
+ if (hunkFollows(lines, i + 2)) {
684
+ push(minus[1]);
685
+ push(next[1]);
686
+ }
687
+ i++;
688
+ continue;
689
+ }
690
+ const plus = /^\+\+\+\s+(?:b\/)?(.+)$/.exec(line);
691
+ if (plus && !seenHunk) push(plus[1]);
491
692
  }
492
693
  return out;
493
694
  }
@@ -653,6 +854,11 @@ function loadProfile(id) {
653
854
  const msg = err instanceof Error ? err.message : String(err);
654
855
  throw new ProfileError(`cannot read ${path}: ${msg}`);
655
856
  }
857
+ if (raw?.id && raw.id !== id) {
858
+ throw new ProfileError(
859
+ `${path}: profile id is "${raw.id}", which does not match the file name "${id}"`
860
+ );
861
+ }
656
862
  validate(raw, path);
657
863
  return normalize(raw);
658
864
  }
@@ -817,6 +1023,10 @@ function resolveLaneChain(p, role) {
817
1023
  return chain;
818
1024
  }
819
1025
  function resolveLaneModel(p, role, opts = {}) {
1026
+ const model = laneModelId(p, role, opts);
1027
+ return { model, levels: levelsOfModel(model) };
1028
+ }
1029
+ function laneModelId(p, role, opts = {}) {
820
1030
  const unhealthy = opts.unhealthy ?? /* @__PURE__ */ new Set();
821
1031
  const ceiling = (
822
1032
  /** @type {Retention} */
@@ -825,13 +1035,12 @@ function resolveLaneModel(p, role, opts = {}) {
825
1035
  for (const candidate of resolveLaneChain(p, role)) {
826
1036
  if (unhealthy.has(candidate)) continue;
827
1037
  if (!retentionAllowed(candidate, ceiling)) continue;
828
- return { model: candidate, levels: levelsOfModel(candidate) };
1038
+ return candidate;
829
1039
  }
830
1040
  if (p.model && !unhealthy.has(p.model) && retentionAllowed(p.model, ceiling)) {
831
- return { model: p.model, levels: levelsOfModel(p.model) };
1041
+ return p.model;
832
1042
  }
833
- const fallback = resolveLaneChain(p, role)[0] ?? p.model;
834
- return { model: fallback, levels: levelsOfModel(fallback) };
1043
+ return resolveLaneChain(p, role)[0] ?? p.model;
835
1044
  }
836
1045
  function levelsOfModel(model) {
837
1046
  try {
@@ -864,48 +1073,66 @@ function resolveEffort(levels, requested) {
864
1073
  if (!levels || levels.length === 0) return null;
865
1074
  if (levels.includes(requested)) return requested;
866
1075
  if (requested === "none") return null;
1076
+ const rungs = levels.filter(
1077
+ (level) => level !== "none" && CANON.indexOf(level) >= 0
1078
+ );
867
1079
  const want = CANON.indexOf(requested);
868
1080
  if (want < 0) {
869
- return levels[levels.length - 1] ?? null;
1081
+ return rungs[rungs.length - 1] ?? null;
870
1082
  }
871
1083
  let best = null;
872
1084
  let bestDist = Infinity;
873
- for (const level of levels) {
1085
+ for (const level of rungs) {
874
1086
  const have = CANON.indexOf(level);
875
- if (have < 0) continue;
876
1087
  const d = Math.abs(have - want);
877
1088
  if (d < bestDist) {
878
1089
  bestDist = d;
879
1090
  best = level;
880
1091
  }
881
1092
  }
882
- return best ?? levels[levels.length - 1] ?? null;
1093
+ return best ?? rungs[rungs.length - 1] ?? null;
883
1094
  }
884
- var VALID_RETENTION = ["zero-retention", "retained", "may-train", "unknown"];
1095
+ var DECLARABLE_RETENTION = ["zero-retention", "retained", "may-train"];
885
1096
  function validate(p, path = "<memory>") {
886
1097
  const where = p?.id ?? path;
887
1098
  if (!p?.id) throw new ProfileError(`${path}: missing "id"`);
888
1099
  if (!p.provider) throw new ProfileError(`${where}: missing "provider"`);
889
1100
  if (!p.model) throw new ProfileError(`${where}: missing "model"`);
890
- if (!VALID_RETENTION.includes(p.retention)) {
891
- throw new ProfileError(
892
- `${where}: retention must be one of ${VALID_RETENTION.join(", ")}`
893
- );
1101
+ if (p.retention === "unknown") {
1102
+ throw new ProfileError(`${where}: retention cannot be unknown`);
894
1103
  }
895
- if (p.maxRetention && !VALID_RETENTION.includes(p.maxRetention)) {
1104
+ if (!DECLARABLE_RETENTION.includes(p.retention)) {
896
1105
  throw new ProfileError(
897
- `${where}: maxRetention must be one of ${VALID_RETENTION.join(", ")}`
1106
+ `${where}: retention must be one of ${DECLARABLE_RETENTION.join(", ")}`
898
1107
  );
899
1108
  }
900
1109
  if (p.maxRetention === "unknown") {
901
1110
  throw new ProfileError(`${where}: maxRetention cannot be unknown`);
902
1111
  }
903
- if (!p.limits?.context || p.limits.context <= 0) {
1112
+ if (p.maxRetention && !DECLARABLE_RETENTION.includes(p.maxRetention)) {
1113
+ throw new ProfileError(
1114
+ `${where}: maxRetention must be one of ${DECLARABLE_RETENTION.join(", ")}`
1115
+ );
1116
+ }
1117
+ if (!Number.isInteger(p.limits?.context) || p.limits.context <= 0) {
904
1118
  throw new ProfileError(`${where}: limits.context must be a positive integer`);
905
1119
  }
906
- if (!p.limits?.output || p.limits.output <= 0) {
1120
+ if (!Number.isInteger(p.limits?.output) || p.limits.output <= 0) {
907
1121
  throw new ProfileError(`${where}: limits.output must be a positive integer`);
908
1122
  }
1123
+ for (const level of p.effort?.levels ?? []) {
1124
+ if (!CANON.includes(level)) {
1125
+ throw new ProfileError(
1126
+ `${where}: effort.levels contains "${level}", which is not one of ${CANON.join(", ")}`
1127
+ );
1128
+ }
1129
+ }
1130
+ const wanted = p.effort?.default;
1131
+ if (wanted !== void 0 && wanted !== null && !CANON.includes(wanted)) {
1132
+ throw new ProfileError(
1133
+ `${where}: effort.default is "${wanted}", which is not one of ${CANON.join(", ")}`
1134
+ );
1135
+ }
909
1136
  const share = p.effort?.reserveShare;
910
1137
  if (share !== void 0 && (share < 0 || share > 0.5)) {
911
1138
  throw new ProfileError(
@@ -1004,6 +1231,11 @@ function validate(p, path = "<memory>") {
1004
1231
  );
1005
1232
  }
1006
1233
  }
1234
+ if (!retentionFor(p.model)) {
1235
+ throw new ProfileError(
1236
+ `${where}: model ${p.model} has no retention.yml entry`
1237
+ );
1238
+ }
1007
1239
  if (p.utility) {
1008
1240
  if (typeof p.utility.model !== "string" || !p.utility.model) {
1009
1241
  throw new ProfileError(`${where}: utility.model must be a model id`);
@@ -1018,6 +1250,11 @@ function validate(p, path = "<memory>") {
1018
1250
  `${where}: utility.model is ${p.utility.model}, which this profile does not resolve to (it has ${[...reachable].join(", ")})`
1019
1251
  );
1020
1252
  }
1253
+ if (!retentionFor(p.utility.model)) {
1254
+ throw new ProfileError(
1255
+ `${where}: utility.model ${p.utility.model} has no retention.yml entry`
1256
+ );
1257
+ }
1021
1258
  }
1022
1259
  for (const id of p.fallback ?? []) {
1023
1260
  if (!profileExists(id)) {
@@ -1026,6 +1263,12 @@ function validate(p, path = "<memory>") {
1026
1263
  );
1027
1264
  }
1028
1265
  }
1266
+ const actual = worstRetention(laneModelsInPlay(p));
1267
+ if (retentionSeverity(p.retention) < retentionSeverity(actual.retention)) {
1268
+ throw new ProfileError(
1269
+ `${where}: retention is ${p.retention}, but ${actual.worst} is ${actual.retention} - a profile cannot be more private than the models it runs on`
1270
+ );
1271
+ }
1029
1272
  }
1030
1273
  function profileExists(id) {
1031
1274
  return profilePath(id) !== null;
@@ -1045,6 +1288,14 @@ function reservedTokens(p, opts = {}) {
1045
1288
  function pruneThreshold(p, opts = {}) {
1046
1289
  return inputCap(p, opts) - reservedTokens(p, opts);
1047
1290
  }
1291
+ function laneModelsInPlay(p, opts = {}) {
1292
+ const out = [p.model];
1293
+ for (const role of Object.keys(p.roleModels ?? {})) {
1294
+ const model = laneModelId(p, role, opts);
1295
+ if (!out.includes(model)) out.push(model);
1296
+ }
1297
+ return out;
1298
+ }
1048
1299
  function roleSplitModels(p, opts = {}) {
1049
1300
  return Object.entries(p.roleModels ?? {}).map(([role]) => {
1050
1301
  const resolved = resolveLaneModel(p, role, opts);
@@ -1057,6 +1308,18 @@ function roleSplitModels(p, opts = {}) {
1057
1308
  return { role, model: resolved.model, levels: resolved.levels, limits };
1058
1309
  });
1059
1310
  }
1311
+ function worstRetention(ids) {
1312
+ let worst = null;
1313
+ let retention = "zero-retention";
1314
+ for (const id of ids) {
1315
+ const declared = modelRetention(id);
1316
+ if (!worst || retentionSeverity(declared) > retentionSeverity(retention)) {
1317
+ worst = id;
1318
+ retention = declared;
1319
+ }
1320
+ }
1321
+ return { retention, worst };
1322
+ }
1060
1323
  function configDir() {
1061
1324
  return process.env.OPENCODE_CONFIG_DIR ?? join4(homedir3(), ".config", "opencode");
1062
1325
  }
@@ -1181,10 +1444,13 @@ function toolSignature(part) {
1181
1444
  }
1182
1445
  return `${tool} ${input}`;
1183
1446
  }
1184
- function sizeOf(part) {
1185
- if (typeof part?.text === "string") return part.text.length;
1186
- if (typeof part?.state?.output === "string") return part.state.output.length;
1187
- return 0;
1447
+ function partSize(part) {
1448
+ try {
1449
+ const json = JSON.stringify(part);
1450
+ return typeof json === "string" ? json.length : 0;
1451
+ } catch {
1452
+ return 0;
1453
+ }
1188
1454
  }
1189
1455
  function stub(part) {
1190
1456
  const tool = part?.tool ?? "tool";
@@ -1282,7 +1548,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1282
1548
  if (!msg?.parts) continue;
1283
1549
  const isUser = msg.info?.role === "user";
1284
1550
  for (const part of msg.parts) {
1285
- const size = sizeOf(part);
1551
+ const size = partSize(part);
1286
1552
  stats.bytesBefore += size;
1287
1553
  if (isUser && keepUsers) {
1288
1554
  stats.bytesAfter += size;
@@ -1302,7 +1568,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1302
1568
  break;
1303
1569
  case "reasoning": {
1304
1570
  part.text = REASONING_STUB;
1305
- stats.bytesAfter += sizeOf(part);
1571
+ stats.bytesAfter += partSize(part);
1306
1572
  stats.stubbed++;
1307
1573
  break;
1308
1574
  }
@@ -1321,7 +1587,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1321
1587
  time: state.time,
1322
1588
  metadata: { ...state.metadata ?? {}, pruned: true, prunedAt: (/* @__PURE__ */ new Date()).toISOString() }
1323
1589
  };
1324
- stats.bytesAfter += sizeOf(part);
1590
+ stats.bytesAfter += partSize(part);
1325
1591
  stats.stubbed++;
1326
1592
  break;
1327
1593
  }
@@ -1336,7 +1602,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1336
1602
  }
1337
1603
  }
1338
1604
  if (stats.stubbed > 0) {
1339
- const saved = Math.max(0, stats.bytesBefore - stats.bytesAfter);
1605
+ const saved = stats.bytesBefore - stats.bytesAfter;
1340
1606
  await logPrune(project, profile.id, {
1341
1607
  ...stats,
1342
1608
  saved,
@@ -1394,27 +1660,67 @@ function utilityModel(p) {
1394
1660
  // src/health.js
1395
1661
  import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync } from "node:fs";
1396
1662
  import { join as join6 } from "node:path";
1663
+ var DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1e3;
1664
+ function spentWindowMs() {
1665
+ const raw = process.env.HARNESS_SPENT_WINDOW_HOURS;
1666
+ if (!raw) return DEFAULT_WINDOW_MS;
1667
+ const hours = Number(raw);
1668
+ if (!Number.isFinite(hours) || hours <= 0) return DEFAULT_WINDOW_MS;
1669
+ return hours * 60 * 60 * 1e3;
1670
+ }
1671
+ function isSpent(entry, now = Date.now()) {
1672
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
1673
+ const at = (
1674
+ /** @type {any} */
1675
+ entry.at
1676
+ );
1677
+ if (typeof at !== "string") return true;
1678
+ const when = Date.parse(at);
1679
+ if (Number.isNaN(when)) return true;
1680
+ return now - when <= spentWindowMs();
1681
+ }
1682
+ function liveUnhealthy(health, now = Date.now()) {
1683
+ const out = {};
1684
+ const source = health?.unhealthy;
1685
+ if (!source || typeof source !== "object" || Array.isArray(source)) return out;
1686
+ for (const [key, entry] of Object.entries(source)) {
1687
+ if (isSpent(entry, now)) out[key] = entry;
1688
+ }
1689
+ return out;
1690
+ }
1397
1691
  function healthPath(dir) {
1398
1692
  return join6(dir, ".space", "health.json");
1399
1693
  }
1400
1694
  function readHealth(dir) {
1695
+ let parsed;
1401
1696
  try {
1402
- return JSON.parse(readFileSync3(healthPath(dir), "utf8"));
1697
+ parsed = JSON.parse(readFileSync3(healthPath(dir), "utf8"));
1403
1698
  } catch {
1404
1699
  return { unhealthy: {} };
1405
1700
  }
1701
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1702
+ return { unhealthy: {} };
1703
+ }
1704
+ const unhealthy = parsed.unhealthy;
1705
+ if (!unhealthy || typeof unhealthy !== "object" || Array.isArray(unhealthy)) {
1706
+ return { ...parsed, unhealthy: {} };
1707
+ }
1708
+ return parsed;
1406
1709
  }
1407
1710
  function writeHealth(dir, health) {
1408
1711
  try {
1409
1712
  if (!existsSync5(join6(dir, ".space"))) mkdirSync2(join6(dir, ".space"), { recursive: true });
1410
- writeFileSync(healthPath(dir), JSON.stringify(health, null, 2));
1713
+ const pruned = { ...health ?? {}, unhealthy: liveUnhealthy(health) };
1714
+ writeFileSync(healthPath(dir), JSON.stringify(pruned, null, 2));
1411
1715
  } catch {
1412
1716
  }
1413
1717
  }
1718
+ function unhealthyRoutes(dir) {
1719
+ return liveUnhealthy(readHealth(dir));
1720
+ }
1414
1721
  function unhealthyModels(dir) {
1415
- const health = readHealth(dir);
1416
1722
  const out = /* @__PURE__ */ new Set();
1417
- for (const key of Object.keys(health.unhealthy ?? {})) {
1723
+ for (const key of Object.keys(unhealthyRoutes(dir))) {
1418
1724
  out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key);
1419
1725
  }
1420
1726
  return out;
@@ -1427,24 +1733,94 @@ var SPENT = [
1427
1733
  /\b429\b/,
1428
1734
  /quota (?:exceeded|exhausted)/i,
1429
1735
  /overloaded_error/i,
1430
- /model .*not found/i,
1736
+ // A withdrawn model, named in the error. The words have to sit next to
1737
+ // each other - "model minimax-m3.1-flash-free not found", "model_not_found",
1738
+ // "ModelNotFound" - because the text this runs against is the provider's
1739
+ // own error and that can be a whole response body. The old pattern ran
1740
+ // over the entire event, where a body that mentioned a model in one place
1741
+ // and "not found" in another read as a withdrawn model: a live route
1742
+ // written off by a coincidence.
1743
+ /\bmodel[\W_]*not[\W_]*found\b/i,
1744
+ /\bmodel\b[\w.:/-]*(?:\s+[\w.:/-]+){0,2}\s+not found\b/i,
1431
1745
  /no available provider/i
1432
1746
  ];
1433
- function classify(text) {
1747
+ function errorText(err) {
1748
+ if (err == null) return "";
1749
+ if (typeof err === "string") return err;
1750
+ if (typeof err !== "object") return String(err);
1751
+ const parts = [];
1752
+ try {
1753
+ const push = (v) => {
1754
+ if (typeof v === "string" && v) parts.push(v);
1755
+ else if (typeof v === "number" && Number.isFinite(v)) parts.push(String(v));
1756
+ else if (v && typeof v === "object") parts.push(safeStringify(v));
1757
+ };
1758
+ push(err.name);
1759
+ push(err.message);
1760
+ push(err.code);
1761
+ const data = err.data;
1762
+ if (data && typeof data === "object") {
1763
+ push(data.name);
1764
+ push(data.message);
1765
+ push(data.code);
1766
+ push(data.statusCode);
1767
+ push(data.responseBody);
1768
+ }
1769
+ } catch {
1770
+ }
1771
+ return parts.join("\n");
1772
+ }
1773
+ function classifyError(err) {
1774
+ const text = errorText(err);
1775
+ if (!text) return null;
1434
1776
  for (const p of SPENT) if (p.test(text)) return p.source;
1435
1777
  return null;
1436
1778
  }
1779
+ function errorOf(e) {
1780
+ const props = e?.properties;
1781
+ if (!props || typeof props !== "object") return void 0;
1782
+ return props.info?.error ?? props.error;
1783
+ }
1784
+ function routeKey(provider, model) {
1785
+ const p = typeof provider === "string" && provider ? provider : "opencode";
1786
+ return `${p}/${model}`;
1787
+ }
1788
+ function routeOf(e) {
1789
+ const props = e?.properties;
1790
+ if (!props || typeof props !== "object") return void 0;
1791
+ const info = props.info ?? props;
1792
+ if (!info || typeof info !== "object") return void 0;
1793
+ const model = info.model;
1794
+ const id = model?.modelID ?? info.modelID ?? props.modelID ?? props.model?.modelID;
1795
+ if (typeof id !== "string" || !id) return void 0;
1796
+ const provider = model?.providerID ?? info.providerID ?? props.providerID;
1797
+ return routeKey(typeof provider === "string" ? provider : void 0, id);
1798
+ }
1799
+ function sessionIdOf(e) {
1800
+ const props = e?.properties;
1801
+ const id = props?.info?.sessionID ?? props?.sessionID;
1802
+ return typeof id === "string" && id ? id : void 0;
1803
+ }
1804
+ function routeOfConfig(cfg) {
1805
+ const model = cfg?.model ?? cfg?.harness?.sessionModel;
1806
+ if (typeof model !== "string" || !model) return void 0;
1807
+ return model.includes("/") ? model : `opencode/${model}`;
1808
+ }
1437
1809
  var WARN_TEXT = "[opencode-harness-kit] This session's model route is rate limited or withdrawn. No plugin can swap a live session's model, so stop retrying and ask the human to start a new session. Run `oc-harness list` to see the lanes and their chains, or `oc-harness doctor` to check whether the profile still matches live metadata.";
1438
1810
  var RouterPlugin = async ({ worktree, directory }) => {
1439
1811
  const project = projectRoot(directory, worktree);
1440
1812
  let failedSessions = 0;
1441
1813
  let warned = false;
1814
+ const sessionRoutes = /* @__PURE__ */ new Map();
1815
+ let lastRoute;
1816
+ let configuredRoute;
1442
1817
  return {
1443
1818
  // Runs once, before the session does any work.
1444
1819
  config: async (config) => {
1445
1820
  try {
1446
1821
  const cfg = config;
1447
1822
  const health = readHealth(project);
1823
+ const routes = unhealthyRoutes(project);
1448
1824
  const unhealthy = unhealthyModels(project);
1449
1825
  const activeId = cfg.harness?.profile ?? profileIdFromConfig();
1450
1826
  if (!activeId) return;
@@ -1455,8 +1831,8 @@ var RouterPlugin = async ({ worktree, directory }) => {
1455
1831
  } catch {
1456
1832
  return;
1457
1833
  }
1458
- const baseKey = `opencode/${profile.model}`;
1459
- if (health.unhealthy?.[baseKey]) {
1834
+ const baseKey = routeKey(profile.provider, profile.model);
1835
+ if (routes[baseKey]) {
1460
1836
  const queue = [id, ...profile.fallback ?? []];
1461
1837
  for (const candidate of queue) {
1462
1838
  let fallback;
@@ -1465,8 +1841,8 @@ var RouterPlugin = async ({ worktree, directory }) => {
1465
1841
  } catch {
1466
1842
  continue;
1467
1843
  }
1468
- const key = `opencode/${fallback.model}`;
1469
- if (health.unhealthy?.[key]) continue;
1844
+ const key = routeKey(fallback.provider, fallback.model);
1845
+ if (routes[key]) continue;
1470
1846
  const fleet = isFleet(profile);
1471
1847
  applySessionModel(cfg, fallback, {
1472
1848
  keepLanes: fleet,
@@ -1481,6 +1857,7 @@ var RouterPlugin = async ({ worktree, directory }) => {
1481
1857
  break;
1482
1858
  }
1483
1859
  }
1860
+ configuredRoute = routeOfConfig(cfg) ?? routeKey(profile.provider, profile.model);
1484
1861
  const roles = resolveRoles(profile, { unhealthy });
1485
1862
  for (const [lane, level] of Object.entries(roles)) {
1486
1863
  if (level === void 0) continue;
@@ -1488,7 +1865,7 @@ var RouterPlugin = async ({ worktree, directory }) => {
1488
1865
  if (!agent) continue;
1489
1866
  const resolved = resolveLaneModel(profile, lane, { unhealthy });
1490
1867
  if (resolved.model === profile.model && !profile.roleModels?.[lane]) continue;
1491
- const wantModel = `opencode/${resolved.model}`;
1868
+ const wantModel = routeKey(profile.provider, resolved.model);
1492
1869
  if (agent.model !== wantModel) {
1493
1870
  agent.model = wantModel;
1494
1871
  const requested = profile.roles?.[lane] ?? null;
@@ -1504,14 +1881,21 @@ var RouterPlugin = async ({ worktree, directory }) => {
1504
1881
  const e = event;
1505
1882
  const type = e?.type;
1506
1883
  if (!type) return;
1507
- const text = safeStringify(e);
1508
- const reason = classify(text);
1884
+ const seen = routeOf(e);
1885
+ if (seen) {
1886
+ lastRoute = seen;
1887
+ const sessionID2 = sessionIdOf(e);
1888
+ if (sessionID2) sessionRoutes.set(sessionID2, seen);
1889
+ }
1890
+ const err = errorOf(e);
1891
+ if (!err) return;
1892
+ const reason = classifyError(err);
1509
1893
  if (!reason) return;
1510
- const modelID = e.properties?.modelID ?? e.properties?.model ?? e.properties?.modelID;
1511
- const key = modelID ? `opencode/${modelID}` : void 0;
1894
+ const sessionID = sessionIdOf(e);
1895
+ const key = seen ?? (sessionID ? sessionRoutes.get(sessionID) : void 0) ?? configuredRoute ?? lastRoute;
1512
1896
  if (!key) return;
1513
1897
  const health = readHealth(project);
1514
- if (health.unhealthy[key]) return;
1898
+ if (unhealthyRoutes(project)[key]) return;
1515
1899
  health.unhealthy[key] = { at: (/* @__PURE__ */ new Date()).toISOString(), reason };
1516
1900
  writeHealth(project, health);
1517
1901
  failedSessions++;