opencode-harness-kit 0.5.4 → 0.5.6

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,49 @@ 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 the GUARD, which reads
40
+ // the tokens and does not care which order they come in. The glob cannot
41
+ // name it - a glob here could only name one spelling of it, and the
42
+ // spelling would still be the one it missed. So the config asks about it,
43
+ // and a human decides, and the code-level block is the guard's.
44
+ "rm -f *",
45
+ "rm -r *",
46
+ "rm -R *",
47
+ // The long GNU names of the same two flags. A delete that is spelled
48
+ // `--recursive --force` is the delete the short forms name.
49
+ "rm --recursive *",
50
+ "rm --force *",
19
51
  "curl * | sh",
20
52
  "curl * | bash",
53
+ // The same pipe into a shell that is named by its path. The shell is the
54
+ // same shell either way, and the config can only match what it can see.
55
+ "curl * | */sh",
56
+ "curl * | */bash",
21
57
  "chmod 777 *",
22
58
  "chmod 777*",
59
+ // The same mode written with a leading zero.
60
+ "chmod 0777 *",
61
+ "chmod 0777*",
23
62
  "gh pr create*",
24
63
  "gh pr merge*",
25
64
  "gh pr close*",
@@ -45,7 +84,7 @@ var BASH_DENYLIST = [
45
84
  ];
46
85
  function matchesPattern(command, pattern) {
47
86
  const re = new RegExp(
48
- "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") + "$"
87
+ "^" + pattern.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") + "$"
49
88
  );
50
89
  return re.test(command);
51
90
  }
@@ -91,7 +130,7 @@ function heredocEnd(command, at) {
91
130
  p = end + 1;
92
131
  }
93
132
  }
94
- function segments(command) {
133
+ function split(command, kind) {
95
134
  const parts = [];
96
135
  let cur = "";
97
136
  let quote = null;
@@ -129,13 +168,26 @@ function segments(command) {
129
168
  continue;
130
169
  }
131
170
  }
132
- if (ch === "\n" || ch === ";") {
171
+ if (ch === "|") {
172
+ if (kind === "pipe") {
173
+ if (command[i + 1] === "|") {
174
+ cur += "||";
175
+ i++;
176
+ continue;
177
+ }
178
+ flush();
179
+ continue;
180
+ }
133
181
  flush();
182
+ if (command[i + 1] === "|") i++;
134
183
  continue;
135
184
  }
136
- if (ch === "|") {
185
+ if (kind === "pipe") {
186
+ cur += ch;
187
+ continue;
188
+ }
189
+ if (ch === "\n" || ch === ";") {
137
190
  flush();
138
- if (command[i + 1] === "|") i++;
139
191
  continue;
140
192
  }
141
193
  if (ch === "&") {
@@ -156,6 +208,12 @@ function segments(command) {
156
208
  flush();
157
209
  return parts;
158
210
  }
211
+ function segments(command) {
212
+ return split(command, "command");
213
+ }
214
+ function pipeStages(command) {
215
+ return split(command, "pipe");
216
+ }
159
217
  function tokenize(segment) {
160
218
  const tokens = [];
161
219
  let cur = "";
@@ -211,6 +269,9 @@ var GIT_WRAPPERS = /* @__PURE__ */ new Set([
211
269
  "xargs",
212
270
  "npx"
213
271
  ]);
272
+ var SHELL_NAMES = /^(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
273
+ var SCRIPT_RUNNERS = /^python[0-9.]*$/i;
274
+ var DOWNLOADERS = /\b(curl|wget)\b/i;
214
275
  var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
215
276
  var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
216
277
  "-C",
@@ -232,6 +293,35 @@ var GIT_GLOBAL_FLAGS = /* @__PURE__ */ new Set([
232
293
  "--glob-pathspecs",
233
294
  "--icase-pathspecs"
234
295
  ]);
296
+ var TOOL_VALUE_FLAGS = {
297
+ gh: /* @__PURE__ */ new Set(["-R", "--repo", "-H", "--hostname"]),
298
+ npm: /* @__PURE__ */ new Set([
299
+ "--prefix",
300
+ "--cache",
301
+ "--registry",
302
+ "--userconfig",
303
+ "--globalconfig",
304
+ "--otp",
305
+ "--tag",
306
+ "--access",
307
+ "--loglevel",
308
+ "--workspace",
309
+ "--script-shell",
310
+ "--init-author-name",
311
+ "--init-author-email",
312
+ "--init-author-url",
313
+ "--init-license",
314
+ "--init-module",
315
+ "--init-version"
316
+ ]),
317
+ yarn: /* @__PURE__ */ new Set(["--cwd", "--registry", "--cache-folder", "--modules-folder"]),
318
+ pnpm: /* @__PURE__ */ new Set(["--dir", "-C", "--filter", "--store-dir", "--registry"]),
319
+ bun: /* @__PURE__ */ new Set(["--cwd", "--config", "--registry"])
320
+ };
321
+ function baseName(word) {
322
+ const slash = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\"));
323
+ return slash === -1 ? word : word.slice(slash + 1);
324
+ }
235
325
  function skipPrefixes(tokens) {
236
326
  let i = 0;
237
327
  for (; ; ) {
@@ -241,7 +331,7 @@ function skipPrefixes(tokens) {
241
331
  i++;
242
332
  continue;
243
333
  }
244
- if (GIT_WRAPPERS.has(word)) {
334
+ if (GIT_WRAPPERS.has(baseName(word))) {
245
335
  i++;
246
336
  while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
247
337
  const flag = tokens[i];
@@ -258,10 +348,25 @@ function stripPrefixes(segment) {
258
348
  const i = skipPrefixes(tokens);
259
349
  if (i >= tokens.length) return "";
260
350
  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);
351
+ rest[0] = baseName(rest[0]);
263
352
  return rest.join(" ");
264
353
  }
354
+ function stripToolFlags(command) {
355
+ const tokens = tokenize(command);
356
+ if (!tokens.length) return command;
357
+ const flags = TOOL_VALUE_FLAGS[baseName(tokens[0]).toLowerCase()];
358
+ if (!flags) return command;
359
+ const out = [];
360
+ for (let i = 0; i < tokens.length; i++) {
361
+ const token = tokens[i];
362
+ if (!flags.has(token.split("=")[0])) {
363
+ out.push(token);
364
+ continue;
365
+ }
366
+ if (!token.includes("=") && i + 1 < tokens.length) i++;
367
+ }
368
+ return out.join(" ");
369
+ }
265
370
  function gitSubcommand(segment) {
266
371
  const tokens = tokenize(segment);
267
372
  let i = skipPrefixes(tokens);
@@ -282,25 +387,124 @@ function gitSubcommand(segment) {
282
387
  }
283
388
  return tokens[i] ?? null;
284
389
  }
285
- var SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
286
390
  var MAX_SHELL_DEPTH = 2;
391
+ function shellCommandString(tokens, from) {
392
+ for (let n = from + 1; n < tokens.length; n++) {
393
+ const token = tokens[n];
394
+ if (!token.startsWith("-") || token === "-") continue;
395
+ const m = /^-([a-zA-Z]*)c/.exec(token);
396
+ if (!m) continue;
397
+ const after = token.slice(m[0].length);
398
+ if (after && !/^[a-zA-Z]+$/.test(after)) return after.trim();
399
+ return tokens.slice(n + 1).join(" ") || null;
400
+ }
401
+ return null;
402
+ }
287
403
  function shellPayload(segment) {
288
404
  const tokens = tokenize(segment);
289
405
  if (tokens.length < 2) return null;
290
406
  const i = skipPrefixes(tokens);
291
407
  if (i >= tokens.length) return null;
292
408
  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;
409
+ if (!SHELL_NAMES.test(baseName(tokens[i]))) return null;
410
+ return shellCommandString(tokens, i);
411
+ }
412
+ var GROUP_OPEN = /^[({[]+/;
413
+ var GROUP_END = /[;)&,}\]]+$/;
414
+ function commandName(word) {
415
+ return baseName(word).replace(GROUP_OPEN, "").replace(GROUP_END, "");
416
+ }
417
+ function runsScript(stage) {
418
+ for (const command of segments(stage)) {
419
+ const tokens = tokenize(command);
420
+ let i = skipPrefixes(tokens);
421
+ if (i >= tokens.length) continue;
422
+ let name = commandName(tokens[i]);
423
+ if (!name) {
424
+ i++;
425
+ if (i >= tokens.length) continue;
426
+ name = commandName(tokens[i]);
427
+ }
428
+ if (SHELL_NAMES.test(name) || SCRIPT_RUNNERS.test(name)) return true;
429
+ }
430
+ return false;
431
+ }
432
+ function pipesScriptIntoShell(command) {
433
+ const stages = pipeStages(command);
434
+ for (let i = 0; i < stages.length; i++) {
435
+ if (!DOWNLOADERS.test(stages[i])) continue;
436
+ for (let j = i + 1; j < stages.length; j++) {
437
+ if (!runsScript(stages[j])) continue;
438
+ return true;
439
+ }
440
+ }
441
+ return false;
442
+ }
443
+ function isDestructiveRmFlag(token) {
444
+ if (/^--(recursive|force)$/i.test(token)) return true;
445
+ return /^-[a-zA-Z]*[rf]/i.test(token);
446
+ }
447
+ function isRecursiveDelete(text) {
448
+ const tokens = tokenize(text);
449
+ if (tokens.length < 2) return false;
450
+ if (baseName(tokens[0]).toLowerCase() !== "rm") return false;
451
+ let destructive = false;
452
+ let hasPath = false;
453
+ for (let i = 1; i < tokens.length; i++) {
454
+ const token = tokens[i];
455
+ if (token === "--") {
456
+ hasPath = tokens.length > i + 1;
457
+ break;
458
+ }
459
+ if (!token.startsWith("-")) {
460
+ hasPath = true;
461
+ continue;
462
+ }
463
+ if (isDestructiveRmFlag(token)) destructive = true;
464
+ }
465
+ return destructive && hasPath;
466
+ }
467
+ var CHMOD_VALUE_OPTIONS = /* @__PURE__ */ new Set(["--reference"]);
468
+ function chmodMode(text) {
469
+ const tokens = tokenize(text);
470
+ if (tokens.length < 2) return null;
471
+ if (baseName(tokens[0]).toLowerCase() !== "chmod") return null;
472
+ for (let i = 1; i < tokens.length; i++) {
473
+ const token = tokens[i];
474
+ if (token === "--") continue;
475
+ if (token.startsWith("--")) {
476
+ if (!token.includes("=") && CHMOD_VALUE_OPTIONS.has(token)) i++;
477
+ continue;
478
+ }
479
+ if (token.startsWith("-")) continue;
480
+ if (/^\d+$/.test(token)) return token;
481
+ return null;
482
+ }
483
+ return null;
484
+ }
485
+ function opensPermissions(text) {
486
+ const mode = chmodMode(text);
487
+ if (mode === null) return false;
488
+ return /^0*777$/.test(mode);
297
489
  }
298
490
  var ASK_GATED = [/^\s*git\s+push\b/];
299
491
  var BASH_RULES = [
300
492
  {
301
493
  name: "secret exfiltration via shell",
302
494
  scope: "command",
303
- test: /\b(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
495
+ // The credential word has to be the whole name or the tail of it: the
496
+ // name of the variable is `GITHUB_TOKEN`, not `GITHUB`. A word boundary
497
+ // never fires after an underscore, so the boundary alone left every
498
+ // prefixed credential name unmatched.
499
+ //
500
+ // The name is matched in whatever case it is written: the rule used to
501
+ // match the upper case spelling only, so `token=$(curl ...)` was
502
+ // allowed, and a lower case name is the same name.
503
+ //
504
+ // What the rule does NOT cover is an assignment whose value is not
505
+ // fetched: `GITHUB_TOKEN=ghp_example` sets a credential, it does not
506
+ // send one.
507
+ test: /(^|[^A-Za-z0-9])(?:[A-Za-z0-9]+_)*(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
304
508
  why: "assigns a fetched value into a credential-named variable"
305
509
  },
306
510
  {
@@ -312,7 +516,7 @@ var BASH_RULES = [
312
516
  {
313
517
  name: "pipe remote script into a shell",
314
518
  scope: "command",
315
- test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
519
+ check: (text) => pipesScriptIntoShell(text),
316
520
  why: "downloads and executes a script in one step"
317
521
  },
318
522
  // `git push` is ask-gated via permissions (`*` ask in the generated
@@ -321,13 +525,17 @@ var BASH_RULES = [
321
525
  {
322
526
  name: "recursive delete",
323
527
  scope: "segment",
324
- test: /^rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*)\s+/i,
528
+ check: (text) => isRecursiveDelete(text),
325
529
  why: "rm with a recursive or force flag"
326
530
  },
327
531
  {
328
532
  name: "chmod 777",
329
533
  scope: "segment",
330
- test: /^chmod\s+(-[a-zA-Z]+\s+)*777\b/,
534
+ // The mode is read as an OPERAND rather than as a literal, because the
535
+ // flags in front of it can be long. `chmod --recursive 777 /` is the
536
+ // same chmod as `chmod -R 777 /`, and a rule that only knew the short
537
+ // flag cluster let the long spelling through.
538
+ check: (text) => opensPermissions(text),
331
539
  why: "opens permissions on everything"
332
540
  },
333
541
  {
@@ -380,7 +588,7 @@ function checkDenied(command, depth = 0) {
380
588
  const parts = segments(command);
381
589
  const normalized = parts.map((seg) => stripPrefixes(seg));
382
590
  for (let i = 0; i < parts.length; i++) {
383
- const pattern = deniedBy(normalized[i]);
591
+ const pattern = deniedBy(stripToolFlags(normalized[i]));
384
592
  if (pattern) return { pattern, segment: parts[i] };
385
593
  }
386
594
  if (depth < MAX_SHELL_DEPTH) {
@@ -433,15 +641,18 @@ function checkWrite(target, rules = WRITE_RULES, root = "") {
433
641
  }
434
642
 
435
643
  // src/telemetry-store.ts
436
- import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs";
644
+ import { appendFileSync, existsSync, mkdirSync, rmSync, statSync, renameSync } from "node:fs";
437
645
  import { join } from "node:path";
438
646
  var MAX_BYTES = 4 * 1024 * 1024;
439
647
  function logPath(dir) {
440
648
  return join(dir, "telemetry.jsonl");
441
649
  }
442
- function rotate(file) {
650
+ function rotate(file, maxBytes = MAX_BYTES) {
443
651
  try {
444
- if (existsSync(file) && statSync(file).size > MAX_BYTES) {
652
+ if (existsSync(file) && statSync(file).size > maxBytes) {
653
+ const oldest = `${file}.2`;
654
+ if (existsSync(oldest)) rmSync(oldest, { force: true });
655
+ if (existsSync(`${file}.1`)) renameSync(`${file}.1`, oldest);
445
656
  renameSync(file, `${file}.1`);
446
657
  }
447
658
  } catch {
@@ -475,19 +686,46 @@ function projectRoot(directory, worktree) {
475
686
  // src/guard.ts
476
687
  var MARKER = "[opencode-harness-kit] blocked";
477
688
  var WRITE_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "apply_patch", "patch"]);
689
+ function hunkFollows(lines, from) {
690
+ for (let i = from; i < lines.length; i++) {
691
+ if (/^@@/.test(lines[i])) return true;
692
+ if (/^(?:---|\+\+\+)\s/.test(lines[i])) return false;
693
+ }
694
+ return false;
695
+ }
478
696
  function pathsInPatch(text) {
479
697
  const out = [];
480
698
  const push = (p) => {
481
- const v = p.trim();
699
+ const v = p.split(" ")[0].trim();
482
700
  if (!v) return;
483
701
  if (v === "/dev/null") return;
484
702
  if (!out.includes(v)) out.push(v);
485
703
  };
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]);
704
+ const lines = text.split("\n");
705
+ let seenHunk = false;
706
+ for (let i = 0; i < lines.length; i++) {
707
+ const line = lines[i];
708
+ const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line);
709
+ if (marker) {
710
+ push(marker[1]);
711
+ continue;
712
+ }
713
+ if (/^@@/.test(line)) {
714
+ seenHunk = true;
715
+ continue;
716
+ }
717
+ const minus = /^---\s+(?:a\/)?(.+)$/.exec(line);
718
+ const next = i + 1 < lines.length ? /^\+\+\+\s+(?:b\/)?(.+)$/.exec(lines[i + 1]) : null;
719
+ if (minus && next) {
720
+ if (hunkFollows(lines, i + 2)) {
721
+ push(minus[1]);
722
+ push(next[1]);
723
+ }
724
+ i++;
725
+ continue;
726
+ }
727
+ const plus = /^\+\+\+\s+(?:b\/)?(.+)$/.exec(line);
728
+ if (plus && !seenHunk) push(plus[1]);
491
729
  }
492
730
  return out;
493
731
  }
@@ -653,6 +891,11 @@ function loadProfile(id) {
653
891
  const msg = err instanceof Error ? err.message : String(err);
654
892
  throw new ProfileError(`cannot read ${path}: ${msg}`);
655
893
  }
894
+ if (raw?.id && raw.id !== id) {
895
+ throw new ProfileError(
896
+ `${path}: profile id is "${raw.id}", which does not match the file name "${id}"`
897
+ );
898
+ }
656
899
  validate(raw, path);
657
900
  return normalize(raw);
658
901
  }
@@ -817,6 +1060,10 @@ function resolveLaneChain(p, role) {
817
1060
  return chain;
818
1061
  }
819
1062
  function resolveLaneModel(p, role, opts = {}) {
1063
+ const model = laneModelId(p, role, opts);
1064
+ return { model, levels: levelsOfModel(model) };
1065
+ }
1066
+ function laneModelId(p, role, opts = {}) {
820
1067
  const unhealthy = opts.unhealthy ?? /* @__PURE__ */ new Set();
821
1068
  const ceiling = (
822
1069
  /** @type {Retention} */
@@ -825,13 +1072,12 @@ function resolveLaneModel(p, role, opts = {}) {
825
1072
  for (const candidate of resolveLaneChain(p, role)) {
826
1073
  if (unhealthy.has(candidate)) continue;
827
1074
  if (!retentionAllowed(candidate, ceiling)) continue;
828
- return { model: candidate, levels: levelsOfModel(candidate) };
1075
+ return candidate;
829
1076
  }
830
1077
  if (p.model && !unhealthy.has(p.model) && retentionAllowed(p.model, ceiling)) {
831
- return { model: p.model, levels: levelsOfModel(p.model) };
1078
+ return p.model;
832
1079
  }
833
- const fallback = resolveLaneChain(p, role)[0] ?? p.model;
834
- return { model: fallback, levels: levelsOfModel(fallback) };
1080
+ return resolveLaneChain(p, role)[0] ?? p.model;
835
1081
  }
836
1082
  function levelsOfModel(model) {
837
1083
  try {
@@ -864,48 +1110,66 @@ function resolveEffort(levels, requested) {
864
1110
  if (!levels || levels.length === 0) return null;
865
1111
  if (levels.includes(requested)) return requested;
866
1112
  if (requested === "none") return null;
1113
+ const rungs = levels.filter(
1114
+ (level) => level !== "none" && CANON.indexOf(level) >= 0
1115
+ );
867
1116
  const want = CANON.indexOf(requested);
868
1117
  if (want < 0) {
869
- return levels[levels.length - 1] ?? null;
1118
+ return rungs[rungs.length - 1] ?? null;
870
1119
  }
871
1120
  let best = null;
872
1121
  let bestDist = Infinity;
873
- for (const level of levels) {
1122
+ for (const level of rungs) {
874
1123
  const have = CANON.indexOf(level);
875
- if (have < 0) continue;
876
1124
  const d = Math.abs(have - want);
877
1125
  if (d < bestDist) {
878
1126
  bestDist = d;
879
1127
  best = level;
880
1128
  }
881
1129
  }
882
- return best ?? levels[levels.length - 1] ?? null;
1130
+ return best ?? rungs[rungs.length - 1] ?? null;
883
1131
  }
884
- var VALID_RETENTION = ["zero-retention", "retained", "may-train", "unknown"];
1132
+ var DECLARABLE_RETENTION = ["zero-retention", "retained", "may-train"];
885
1133
  function validate(p, path = "<memory>") {
886
1134
  const where = p?.id ?? path;
887
1135
  if (!p?.id) throw new ProfileError(`${path}: missing "id"`);
888
1136
  if (!p.provider) throw new ProfileError(`${where}: missing "provider"`);
889
1137
  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
- );
1138
+ if (p.retention === "unknown") {
1139
+ throw new ProfileError(`${where}: retention cannot be unknown`);
894
1140
  }
895
- if (p.maxRetention && !VALID_RETENTION.includes(p.maxRetention)) {
1141
+ if (!DECLARABLE_RETENTION.includes(p.retention)) {
896
1142
  throw new ProfileError(
897
- `${where}: maxRetention must be one of ${VALID_RETENTION.join(", ")}`
1143
+ `${where}: retention must be one of ${DECLARABLE_RETENTION.join(", ")}`
898
1144
  );
899
1145
  }
900
1146
  if (p.maxRetention === "unknown") {
901
1147
  throw new ProfileError(`${where}: maxRetention cannot be unknown`);
902
1148
  }
903
- if (!p.limits?.context || p.limits.context <= 0) {
1149
+ if (p.maxRetention && !DECLARABLE_RETENTION.includes(p.maxRetention)) {
1150
+ throw new ProfileError(
1151
+ `${where}: maxRetention must be one of ${DECLARABLE_RETENTION.join(", ")}`
1152
+ );
1153
+ }
1154
+ if (!Number.isInteger(p.limits?.context) || p.limits.context <= 0) {
904
1155
  throw new ProfileError(`${where}: limits.context must be a positive integer`);
905
1156
  }
906
- if (!p.limits?.output || p.limits.output <= 0) {
1157
+ if (!Number.isInteger(p.limits?.output) || p.limits.output <= 0) {
907
1158
  throw new ProfileError(`${where}: limits.output must be a positive integer`);
908
1159
  }
1160
+ for (const level of p.effort?.levels ?? []) {
1161
+ if (!CANON.includes(level)) {
1162
+ throw new ProfileError(
1163
+ `${where}: effort.levels contains "${level}", which is not one of ${CANON.join(", ")}`
1164
+ );
1165
+ }
1166
+ }
1167
+ const wanted = p.effort?.default;
1168
+ if (wanted !== void 0 && wanted !== null && !CANON.includes(wanted)) {
1169
+ throw new ProfileError(
1170
+ `${where}: effort.default is "${wanted}", which is not one of ${CANON.join(", ")}`
1171
+ );
1172
+ }
909
1173
  const share = p.effort?.reserveShare;
910
1174
  if (share !== void 0 && (share < 0 || share > 0.5)) {
911
1175
  throw new ProfileError(
@@ -1004,6 +1268,11 @@ function validate(p, path = "<memory>") {
1004
1268
  );
1005
1269
  }
1006
1270
  }
1271
+ if (!retentionFor(p.model)) {
1272
+ throw new ProfileError(
1273
+ `${where}: model ${p.model} has no retention.yml entry`
1274
+ );
1275
+ }
1007
1276
  if (p.utility) {
1008
1277
  if (typeof p.utility.model !== "string" || !p.utility.model) {
1009
1278
  throw new ProfileError(`${where}: utility.model must be a model id`);
@@ -1018,6 +1287,11 @@ function validate(p, path = "<memory>") {
1018
1287
  `${where}: utility.model is ${p.utility.model}, which this profile does not resolve to (it has ${[...reachable].join(", ")})`
1019
1288
  );
1020
1289
  }
1290
+ if (!retentionFor(p.utility.model)) {
1291
+ throw new ProfileError(
1292
+ `${where}: utility.model ${p.utility.model} has no retention.yml entry`
1293
+ );
1294
+ }
1021
1295
  }
1022
1296
  for (const id of p.fallback ?? []) {
1023
1297
  if (!profileExists(id)) {
@@ -1026,6 +1300,12 @@ function validate(p, path = "<memory>") {
1026
1300
  );
1027
1301
  }
1028
1302
  }
1303
+ const actual = worstRetention(laneModelsInPlay(p));
1304
+ if (retentionSeverity(p.retention) < retentionSeverity(actual.retention)) {
1305
+ throw new ProfileError(
1306
+ `${where}: retention is ${p.retention}, but ${actual.worst} is ${actual.retention} - a profile cannot be more private than the models it runs on`
1307
+ );
1308
+ }
1029
1309
  }
1030
1310
  function profileExists(id) {
1031
1311
  return profilePath(id) !== null;
@@ -1045,6 +1325,14 @@ function reservedTokens(p, opts = {}) {
1045
1325
  function pruneThreshold(p, opts = {}) {
1046
1326
  return inputCap(p, opts) - reservedTokens(p, opts);
1047
1327
  }
1328
+ function laneModelsInPlay(p, opts = {}) {
1329
+ const out = [p.model];
1330
+ for (const role of Object.keys(p.roleModels ?? {})) {
1331
+ const model = laneModelId(p, role, opts);
1332
+ if (!out.includes(model)) out.push(model);
1333
+ }
1334
+ return out;
1335
+ }
1048
1336
  function roleSplitModels(p, opts = {}) {
1049
1337
  return Object.entries(p.roleModels ?? {}).map(([role]) => {
1050
1338
  const resolved = resolveLaneModel(p, role, opts);
@@ -1057,6 +1345,18 @@ function roleSplitModels(p, opts = {}) {
1057
1345
  return { role, model: resolved.model, levels: resolved.levels, limits };
1058
1346
  });
1059
1347
  }
1348
+ function worstRetention(ids) {
1349
+ let worst = null;
1350
+ let retention = "zero-retention";
1351
+ for (const id of ids) {
1352
+ const declared = modelRetention(id);
1353
+ if (!worst || retentionSeverity(declared) > retentionSeverity(retention)) {
1354
+ worst = id;
1355
+ retention = declared;
1356
+ }
1357
+ }
1358
+ return { retention, worst };
1359
+ }
1060
1360
  function configDir() {
1061
1361
  return process.env.OPENCODE_CONFIG_DIR ?? join4(homedir3(), ".config", "opencode");
1062
1362
  }
@@ -1181,10 +1481,13 @@ function toolSignature(part) {
1181
1481
  }
1182
1482
  return `${tool} ${input}`;
1183
1483
  }
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;
1484
+ function partSize(part) {
1485
+ try {
1486
+ const json = JSON.stringify(part);
1487
+ return typeof json === "string" ? json.length : 0;
1488
+ } catch {
1489
+ return 0;
1490
+ }
1188
1491
  }
1189
1492
  function stub(part) {
1190
1493
  const tool = part?.tool ?? "tool";
@@ -1282,7 +1585,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1282
1585
  if (!msg?.parts) continue;
1283
1586
  const isUser = msg.info?.role === "user";
1284
1587
  for (const part of msg.parts) {
1285
- const size = sizeOf(part);
1588
+ const size = partSize(part);
1286
1589
  stats.bytesBefore += size;
1287
1590
  if (isUser && keepUsers) {
1288
1591
  stats.bytesAfter += size;
@@ -1302,7 +1605,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1302
1605
  break;
1303
1606
  case "reasoning": {
1304
1607
  part.text = REASONING_STUB;
1305
- stats.bytesAfter += sizeOf(part);
1608
+ stats.bytesAfter += partSize(part);
1306
1609
  stats.stubbed++;
1307
1610
  break;
1308
1611
  }
@@ -1321,7 +1624,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1321
1624
  time: state.time,
1322
1625
  metadata: { ...state.metadata ?? {}, pruned: true, prunedAt: (/* @__PURE__ */ new Date()).toISOString() }
1323
1626
  };
1324
- stats.bytesAfter += sizeOf(part);
1627
+ stats.bytesAfter += partSize(part);
1325
1628
  stats.stubbed++;
1326
1629
  break;
1327
1630
  }
@@ -1336,7 +1639,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1336
1639
  }
1337
1640
  }
1338
1641
  if (stats.stubbed > 0) {
1339
- const saved = Math.max(0, stats.bytesBefore - stats.bytesAfter);
1642
+ const saved = stats.bytesBefore - stats.bytesAfter;
1340
1643
  await logPrune(project, profile.id, {
1341
1644
  ...stats,
1342
1645
  saved,
@@ -1394,27 +1697,67 @@ function utilityModel(p) {
1394
1697
  // src/health.js
1395
1698
  import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync } from "node:fs";
1396
1699
  import { join as join6 } from "node:path";
1700
+ var DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1e3;
1701
+ function spentWindowMs() {
1702
+ const raw = process.env.HARNESS_SPENT_WINDOW_HOURS;
1703
+ if (!raw) return DEFAULT_WINDOW_MS;
1704
+ const hours = Number(raw);
1705
+ if (!Number.isFinite(hours) || hours <= 0) return DEFAULT_WINDOW_MS;
1706
+ return hours * 60 * 60 * 1e3;
1707
+ }
1708
+ function isSpent(entry, now = Date.now()) {
1709
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
1710
+ const at = (
1711
+ /** @type {any} */
1712
+ entry.at
1713
+ );
1714
+ if (typeof at !== "string") return true;
1715
+ const when = Date.parse(at);
1716
+ if (Number.isNaN(when)) return true;
1717
+ return now - when <= spentWindowMs();
1718
+ }
1719
+ function liveUnhealthy(health, now = Date.now()) {
1720
+ const out = {};
1721
+ const source = health?.unhealthy;
1722
+ if (!source || typeof source !== "object" || Array.isArray(source)) return out;
1723
+ for (const [key, entry] of Object.entries(source)) {
1724
+ if (isSpent(entry, now)) out[key] = entry;
1725
+ }
1726
+ return out;
1727
+ }
1397
1728
  function healthPath(dir) {
1398
1729
  return join6(dir, ".space", "health.json");
1399
1730
  }
1400
1731
  function readHealth(dir) {
1732
+ let parsed;
1401
1733
  try {
1402
- return JSON.parse(readFileSync3(healthPath(dir), "utf8"));
1734
+ parsed = JSON.parse(readFileSync3(healthPath(dir), "utf8"));
1403
1735
  } catch {
1404
1736
  return { unhealthy: {} };
1405
1737
  }
1738
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1739
+ return { unhealthy: {} };
1740
+ }
1741
+ const unhealthy = parsed.unhealthy;
1742
+ if (!unhealthy || typeof unhealthy !== "object" || Array.isArray(unhealthy)) {
1743
+ return { ...parsed, unhealthy: {} };
1744
+ }
1745
+ return parsed;
1406
1746
  }
1407
1747
  function writeHealth(dir, health) {
1408
1748
  try {
1409
1749
  if (!existsSync5(join6(dir, ".space"))) mkdirSync2(join6(dir, ".space"), { recursive: true });
1410
- writeFileSync(healthPath(dir), JSON.stringify(health, null, 2));
1750
+ const pruned = { ...health ?? {}, unhealthy: liveUnhealthy(health) };
1751
+ writeFileSync(healthPath(dir), JSON.stringify(pruned, null, 2));
1411
1752
  } catch {
1412
1753
  }
1413
1754
  }
1755
+ function unhealthyRoutes(dir) {
1756
+ return liveUnhealthy(readHealth(dir));
1757
+ }
1414
1758
  function unhealthyModels(dir) {
1415
- const health = readHealth(dir);
1416
1759
  const out = /* @__PURE__ */ new Set();
1417
- for (const key of Object.keys(health.unhealthy ?? {})) {
1760
+ for (const key of Object.keys(unhealthyRoutes(dir))) {
1418
1761
  out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key);
1419
1762
  }
1420
1763
  return out;
@@ -1427,24 +1770,94 @@ var SPENT = [
1427
1770
  /\b429\b/,
1428
1771
  /quota (?:exceeded|exhausted)/i,
1429
1772
  /overloaded_error/i,
1430
- /model .*not found/i,
1773
+ // A withdrawn model, named in the error. The words have to sit next to
1774
+ // each other - "model minimax-m3.1-flash-free not found", "model_not_found",
1775
+ // "ModelNotFound" - because the text this runs against is the provider's
1776
+ // own error and that can be a whole response body. The old pattern ran
1777
+ // over the entire event, where a body that mentioned a model in one place
1778
+ // and "not found" in another read as a withdrawn model: a live route
1779
+ // written off by a coincidence.
1780
+ /\bmodel[\W_]*not[\W_]*found\b/i,
1781
+ /\bmodel\b[\w.:/-]*(?:\s+[\w.:/-]+){0,2}\s+not found\b/i,
1431
1782
  /no available provider/i
1432
1783
  ];
1433
- function classify(text) {
1784
+ function errorText(err) {
1785
+ if (err == null) return "";
1786
+ if (typeof err === "string") return err;
1787
+ if (typeof err !== "object") return String(err);
1788
+ const parts = [];
1789
+ try {
1790
+ const push = (v) => {
1791
+ if (typeof v === "string" && v) parts.push(v);
1792
+ else if (typeof v === "number" && Number.isFinite(v)) parts.push(String(v));
1793
+ else if (v && typeof v === "object") parts.push(safeStringify(v));
1794
+ };
1795
+ push(err.name);
1796
+ push(err.message);
1797
+ push(err.code);
1798
+ const data = err.data;
1799
+ if (data && typeof data === "object") {
1800
+ push(data.name);
1801
+ push(data.message);
1802
+ push(data.code);
1803
+ push(data.statusCode);
1804
+ push(data.responseBody);
1805
+ }
1806
+ } catch {
1807
+ }
1808
+ return parts.join("\n");
1809
+ }
1810
+ function classifyError(err) {
1811
+ const text = errorText(err);
1812
+ if (!text) return null;
1434
1813
  for (const p of SPENT) if (p.test(text)) return p.source;
1435
1814
  return null;
1436
1815
  }
1816
+ function errorOf(e) {
1817
+ const props = e?.properties;
1818
+ if (!props || typeof props !== "object") return void 0;
1819
+ return props.info?.error ?? props.error;
1820
+ }
1821
+ function routeKey(provider, model) {
1822
+ const p = typeof provider === "string" && provider ? provider : "opencode";
1823
+ return `${p}/${model}`;
1824
+ }
1825
+ function routeOf(e) {
1826
+ const props = e?.properties;
1827
+ if (!props || typeof props !== "object") return void 0;
1828
+ const info = props.info ?? props;
1829
+ if (!info || typeof info !== "object") return void 0;
1830
+ const model = info.model;
1831
+ const id = model?.modelID ?? info.modelID ?? props.modelID ?? props.model?.modelID;
1832
+ if (typeof id !== "string" || !id) return void 0;
1833
+ const provider = model?.providerID ?? info.providerID ?? props.providerID;
1834
+ return routeKey(typeof provider === "string" ? provider : void 0, id);
1835
+ }
1836
+ function sessionIdOf(e) {
1837
+ const props = e?.properties;
1838
+ const id = props?.info?.sessionID ?? props?.sessionID;
1839
+ return typeof id === "string" && id ? id : void 0;
1840
+ }
1841
+ function routeOfConfig(cfg) {
1842
+ const model = cfg?.model ?? cfg?.harness?.sessionModel;
1843
+ if (typeof model !== "string" || !model) return void 0;
1844
+ return model.includes("/") ? model : `opencode/${model}`;
1845
+ }
1437
1846
  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
1847
  var RouterPlugin = async ({ worktree, directory }) => {
1439
1848
  const project = projectRoot(directory, worktree);
1440
1849
  let failedSessions = 0;
1441
1850
  let warned = false;
1851
+ const sessionRoutes = /* @__PURE__ */ new Map();
1852
+ let lastRoute;
1853
+ let configuredRoute;
1442
1854
  return {
1443
1855
  // Runs once, before the session does any work.
1444
1856
  config: async (config) => {
1445
1857
  try {
1446
1858
  const cfg = config;
1447
1859
  const health = readHealth(project);
1860
+ const routes = unhealthyRoutes(project);
1448
1861
  const unhealthy = unhealthyModels(project);
1449
1862
  const activeId = cfg.harness?.profile ?? profileIdFromConfig();
1450
1863
  if (!activeId) return;
@@ -1455,8 +1868,8 @@ var RouterPlugin = async ({ worktree, directory }) => {
1455
1868
  } catch {
1456
1869
  return;
1457
1870
  }
1458
- const baseKey = `opencode/${profile.model}`;
1459
- if (health.unhealthy?.[baseKey]) {
1871
+ const baseKey = routeKey(profile.provider, profile.model);
1872
+ if (routes[baseKey]) {
1460
1873
  const queue = [id, ...profile.fallback ?? []];
1461
1874
  for (const candidate of queue) {
1462
1875
  let fallback;
@@ -1465,8 +1878,8 @@ var RouterPlugin = async ({ worktree, directory }) => {
1465
1878
  } catch {
1466
1879
  continue;
1467
1880
  }
1468
- const key = `opencode/${fallback.model}`;
1469
- if (health.unhealthy?.[key]) continue;
1881
+ const key = routeKey(fallback.provider, fallback.model);
1882
+ if (routes[key]) continue;
1470
1883
  const fleet = isFleet(profile);
1471
1884
  applySessionModel(cfg, fallback, {
1472
1885
  keepLanes: fleet,
@@ -1481,6 +1894,7 @@ var RouterPlugin = async ({ worktree, directory }) => {
1481
1894
  break;
1482
1895
  }
1483
1896
  }
1897
+ configuredRoute = routeOfConfig(cfg) ?? routeKey(profile.provider, profile.model);
1484
1898
  const roles = resolveRoles(profile, { unhealthy });
1485
1899
  for (const [lane, level] of Object.entries(roles)) {
1486
1900
  if (level === void 0) continue;
@@ -1488,7 +1902,7 @@ var RouterPlugin = async ({ worktree, directory }) => {
1488
1902
  if (!agent) continue;
1489
1903
  const resolved = resolveLaneModel(profile, lane, { unhealthy });
1490
1904
  if (resolved.model === profile.model && !profile.roleModels?.[lane]) continue;
1491
- const wantModel = `opencode/${resolved.model}`;
1905
+ const wantModel = routeKey(profile.provider, resolved.model);
1492
1906
  if (agent.model !== wantModel) {
1493
1907
  agent.model = wantModel;
1494
1908
  const requested = profile.roles?.[lane] ?? null;
@@ -1504,14 +1918,21 @@ var RouterPlugin = async ({ worktree, directory }) => {
1504
1918
  const e = event;
1505
1919
  const type = e?.type;
1506
1920
  if (!type) return;
1507
- const text = safeStringify(e);
1508
- const reason = classify(text);
1921
+ const seen = routeOf(e);
1922
+ if (seen) {
1923
+ lastRoute = seen;
1924
+ const sessionID2 = sessionIdOf(e);
1925
+ if (sessionID2) sessionRoutes.set(sessionID2, seen);
1926
+ }
1927
+ const err = errorOf(e);
1928
+ if (!err) return;
1929
+ const reason = classifyError(err);
1509
1930
  if (!reason) return;
1510
- const modelID = e.properties?.modelID ?? e.properties?.model ?? e.properties?.modelID;
1511
- const key = modelID ? `opencode/${modelID}` : void 0;
1931
+ const sessionID = sessionIdOf(e);
1932
+ const key = seen ?? (sessionID ? sessionRoutes.get(sessionID) : void 0) ?? configuredRoute ?? lastRoute;
1512
1933
  if (!key) return;
1513
1934
  const health = readHealth(project);
1514
- if (health.unhealthy[key]) return;
1935
+ if (unhealthyRoutes(project)[key]) return;
1515
1936
  health.unhealthy[key] = { at: (/* @__PURE__ */ new Date()).toISOString(), reason };
1516
1937
  writeHealth(project, health);
1517
1938
  failedSessions++;
@@ -1574,7 +1995,26 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
1574
1995
  const started = Date.now();
1575
1996
  const opened = /* @__PURE__ */ new Set();
1576
1997
  const closed = /* @__PURE__ */ new Set();
1998
+ const requests = /* @__PURE__ */ new Map();
1999
+ const REQUEST_MEMORY = 500;
2000
+ const rememberRequest = (input) => {
2001
+ const id = input?.message?.id;
2002
+ if (typeof id !== "string") return;
2003
+ const model = input?.model;
2004
+ requests.set(id, {
2005
+ agent: typeof input?.agent === "string" ? input.agent : void 0,
2006
+ model: typeof model?.modelID === "string" ? model.modelID : void 0,
2007
+ provider: typeof model?.providerID === "string" ? model.providerID : void 0
2008
+ });
2009
+ if (requests.size > REQUEST_MEMORY) {
2010
+ const oldest = requests.keys().next();
2011
+ if (!oldest.done) requests.delete(oldest.value);
2012
+ }
2013
+ };
1577
2014
  return {
2015
+ "chat.params": async (input) => {
2016
+ rememberRequest(input);
2017
+ },
1578
2018
  event: async ({ event }) => {
1579
2019
  const type = event?.type;
1580
2020
  if (typeof type !== "string") return;
@@ -1583,7 +2023,7 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
1583
2023
  return;
1584
2024
  }
1585
2025
  if (type === "message.updated") {
1586
- const lane = laneOf(event);
2026
+ const lane = laneOf(event, requests);
1587
2027
  if (!lane || !lane.id) return;
1588
2028
  if (lane.ms !== void 0) {
1589
2029
  if (closed.has(lane.id)) return;
@@ -1607,15 +2047,16 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
1607
2047
  }
1608
2048
  };
1609
2049
  };
1610
- function laneOf(event) {
2050
+ function laneOf(event, requests) {
1611
2051
  const e = event;
1612
2052
  const info = e?.properties?.info ?? e?.properties;
1613
2053
  if (!info || typeof info !== "object") return null;
1614
2054
  if (info.role && info.role !== "assistant") return null;
1615
2055
  const id = typeof info.id === "string" ? info.id : void 0;
1616
- const agent = typeof info.agent === "string" ? info.agent : void 0;
1617
- const modelId = info.model?.modelID ?? info.modelID;
1618
- const providerId = info.model?.providerID ?? info.providerID;
2056
+ const parent = typeof info.parentID === "string" ? requests.get(info.parentID) : void 0;
2057
+ const agent = parent?.agent ?? (typeof info.agent === "string" ? info.agent : void 0);
2058
+ const modelId = parent?.model ?? info.model?.modelID ?? info.modelID;
2059
+ const providerId = parent?.provider ?? info.model?.providerID ?? info.providerID;
1619
2060
  const time = info.time ?? {};
1620
2061
  const created = typeof time.created === "number" ? time.created : void 0;
1621
2062
  const completed = typeof time.completed === "number" ? time.completed : void 0;