@kaydash9999/banker-plugins 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -188,6 +188,8 @@ export const DECISION_REASONS = [
188
188
 
189
189
  export const CLI_DEFAULT = "@cli-default";
190
190
 
191
+ // How a CLI call ended, for a note.
192
+ const callWhy = (res) => (res?.why ? res.why : res?.status === 0 ? "exit 0 with output the probe could not use" : `exit ${res?.status ?? "?"}`);
191
193
  const firstLine = (res) => (res?.status === 0 ? String(res.stdout).trim().split("\n")[0] || null : null);
192
194
  const absent = (cli) => ({ cli, present: false, decision: "unseated", reason: "cli-absent" });
193
195
  const adopt = (seat, model, reason) => ({ ...seat, decision: "adopt", model, reason });
@@ -382,6 +384,8 @@ const rawMcpNames = (lines) => lines.filter((l) => !/^\s*#/.test(l))
382
384
  // or the line that opened what is still open), and the strings of each top-level array
383
385
  // (`arrays`). Enough for those keys; not a TOML parser.
384
386
  export function readToml(text) {
387
+ // a file there that could not be read (permissions, a folder): nothing it says is known, so it reads as broken
388
+ if (text?.unreadable) return { values: {}, roots: [], arrays: {}, mcpServers: [], mcpUnread: true, modelKeys: false, broken: true, brokenAt: 0, unreadable: text.unreadable };
385
389
  const state = tomlState();
386
390
  const lines = String(text ?? "").split(/\r?\n/);
387
391
  const isOpen = () => state.open !== null || state.depth > 0;
@@ -457,6 +461,7 @@ export function stripJsonc(text) {
457
461
  // { value } for a readable JSONC text, { unreadable: true } for a broken one, {} when absent.
458
462
  function readJsonc(text) {
459
463
  if (text == null) return {};
464
+ if (text.unreadable) return { unreadable: true };
460
465
  try {
461
466
  const value = JSON.parse(stripJsonc(String(text)));
462
467
  return value && typeof value === "object" && !Array.isArray(value) ? { value } : { unreadable: true };
@@ -530,7 +535,7 @@ function codexConfig(world) {
530
535
  mcpUnread: layers.some((l) => l.mcpUnread),
531
536
  modelKeys: layers.some((l) => l.modelKeys),
532
537
  broken: layers.some((l) => l.broken),
533
- brokenAt: layers.map((l, k) => (l.broken ? `${files[k]} line ${l.brokenAt}` : null)).filter(Boolean),
538
+ brokenAt: layers.map((l, k) => (!l.broken ? null : l.unreadable ? `${files[k]} (not readable: ${l.unreadable})` : `${files[k]} line ${l.brokenAt}`)).filter(Boolean),
534
539
  };
535
540
  }
536
541
 
@@ -555,6 +560,8 @@ function mcpBlockedSeat(seat, conf, notes) {
555
560
  const SEAT_KEYS = ["notify", "otel", "mcp_servers", "web_search", "tools", "analytics", "features", "hooks",
556
561
  "sandbox_mode", "sandbox_workspace_write", "approval_policy"];
557
562
 
563
+ const unreadAt = (read) => (read.unreadable ? `could not be read (${read.unreadable})` : `could not be read to its end (line ${read.brokenAt})`);
564
+
558
565
  // The unseated seat when the managed file sets one of those keys, or could not be read to its
559
566
  // end (a line read short may set one); null otherwise.
560
567
  function managedBlockedSeat(seat, world, notes) {
@@ -562,7 +569,7 @@ function managedBlockedSeat(seat, world, notes) {
562
569
  const read = readToml(world.readFile(CODEX_MANAGED));
563
570
  const keys = read.roots.filter((k) => SEAT_KEYS.includes(k));
564
571
  if (!keys.length && !read.broken) return null;
565
- notes.push(`${CODEX_MANAGED} ${keys.length ? `sets ${keys.join(", ")}` : `could not be read to its end (line ${read.brokenAt})`}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
572
+ notes.push(`${CODEX_MANAGED} ${keys.length ? `sets ${keys.join(", ")}` : unreadAt(read)}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
566
573
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
567
574
  }
568
575
 
@@ -583,7 +590,7 @@ function requirementsBlockedSeat(seat, world, notes) {
583
590
  const allows = (k) => SEAT_ALLOWS[k] !== undefined && (read.arrays[k] ?? []).includes(SEAT_ALLOWS[k]);
584
591
  const keys = read.roots.filter((k) => !GUARD_FREE.includes(k) && !allows(k));
585
592
  if (!keys.length && !read.broken) return null;
586
- notes.push(`${CODEX_REQUIREMENTS} ${keys.length ? `sets ${keys.join(", ")}` : `could not be read to its end (line ${read.brokenAt})`}, which codex holds over the seat's -c, --disable and -s flags: the seat cannot hold its guards`);
593
+ notes.push(`${CODEX_REQUIREMENTS} ${keys.length ? `sets ${keys.join(", ")}` : unreadAt(read)}, which codex holds over the seat's -c, --disable and -s flags: the seat cannot hold its guards`);
587
594
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
588
595
  }
589
596
 
@@ -653,10 +660,18 @@ function codexSeat(flag, { world, runtime, authorFamilies }) {
653
660
  const notes = [];
654
661
  const conf = codexConfig(world);
655
662
  const configured = vetConfigured(safeOnly([conf.cfg.model], "config.toml", notes)[0], authorFamilies, notes);
656
- const catalog = readCatalog(world.run("codex", ["debug", "models", "--bundled"]), notes) ?? [];
663
+ const listed = world.run("codex", ["debug", "models", "--bundled"]);
664
+ const read = readCatalog(listed, notes);
665
+ const catalog = read ?? [];
657
666
  const sorted = sortModels([...catalog, configured], authorFamilies);
658
- const version = firstLine(world.run("codex", ["--version"]));
667
+ const ran = world.run("codex", ["--version"]);
668
+ // a version line has a version number in it; an error text on stdout with exit 0 is not one
669
+ const version = /\d+\.\d+/.test(firstLine(ran) ?? "") ? firstLine(ran) : null;
670
+ if (!read) notes.push(`codex debug models --bundled gave no catalog (${callWhy(listed)})`);
671
+ if (!version) notes.push(`codex --version gave no version (${callWhy(ran)})`);
659
672
  const seat = { cli: "codex", present: true, version, configured, candidates: sorted.independent, unclassified: sorted.unclassified, mcpServers: conf.mcpServers };
673
+ // both calls failed: this codex does not run here (not logged in, broken install, killed), so it is not a seat
674
+ if (!read && !version) return withNotes({ ...seat, decision: "unseated", reason: "cli-call-failed" }, notes);
660
675
  const refused = legacyProfileSeat(seat, conf.cfg, notes) ?? managedBlockedSeat(seat, world, notes)
661
676
  ?? requirementsBlockedSeat(seat, world, notes) ?? mcpBlockedSeat(seat, conf, notes);
662
677
  if (refused) return withNotes(refused, notes);
@@ -710,8 +725,10 @@ function geminiPoliciesDir(world) {
710
725
  // seat's read-only tools rest on that flag: such a seat is not seated.
711
726
  function geminiPolicySeat(seat, world, notes) {
712
727
  const dir = geminiPoliciesDir(world);
713
- if (!world.listDir(dir).some((f) => f.endsWith(".toml"))) return null;
714
- notes.push(`gemini ignores --admin-policy while ${dir} holds policies: the seat cannot be held to its read-only tools`);
728
+ const list = world.listDir(dir);
729
+ if (list && !list.some((f) => f.endsWith(".toml"))) return null;
730
+ notes.push(list ? `gemini ignores --admin-policy while ${dir} holds policies: the seat cannot be held to its read-only tools`
731
+ : `the probe could not list ${dir}: gemini ignores --admin-policy while it holds policies, so the seat cannot be shown to keep to its read-only tools`);
715
732
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
716
733
  }
717
734
 
@@ -835,7 +852,8 @@ const opencodeCache = (world) => join(world.env.XDG_CACHE_HOME || join(world.hom
835
852
  function catalogProviders(world) {
836
853
  const file = join(opencodeCache(world), "models.json");
837
854
  const read = readJsonc(world.readFile(file));
838
- return read.value ? new Set(Object.keys(read.value)) : null;
855
+ // an emptied copy ({}) lists no provider: it says nothing about which ones models.dev knows
856
+ return read.value && Object.keys(read.value).length ? new Set(Object.keys(read.value)) : null;
839
857
  }
840
858
 
841
859
  // The models the config lists are all opencode can run only when enabled_providers names
@@ -953,18 +971,20 @@ function realWorld() {
953
971
  platform: process.platform,
954
972
  env: process.env,
955
973
  which: onPath,
974
+ // null when the file is not there; { unreadable } when it is but cannot be read, which is not the same as absent
956
975
  readFile: (path) => {
957
976
  try {
958
977
  return readFileSync(path, "utf8");
959
- } catch {
960
- return null;
978
+ } catch (e) {
979
+ return e.code === "ENOENT" || e.code === "ENOTDIR" ? null : { unreadable: e.code || "error" };
961
980
  }
962
981
  },
982
+ // [] when the folder is not there; null when it is but cannot be listed
963
983
  listDir: (path) => {
964
984
  try {
965
985
  return readdirSync(path);
966
- } catch {
967
- return [];
986
+ } catch (e) {
987
+ return e.code === "ENOENT" || e.code === "ENOTDIR" ? [] : null;
968
988
  }
969
989
  },
970
990
  run: (cmd, args, env = {}) => {
@@ -978,11 +998,13 @@ function realWorld() {
978
998
  const r = spawnSync(viaCmd ? `"${exe}"` : exe, args, {
979
999
  encoding: "utf8",
980
1000
  timeout: 15000,
1001
+ // a CLI that ignores SIGTERM would hold spawnSync past its time limit
1002
+ killSignal: "SIGKILL",
981
1003
  maxBuffer: 64 * 1024 * 1024,
982
1004
  shell,
983
1005
  env: { ...process.env, ...(viaCmd ? { NoDefaultCurrentDirectoryInExePath: "1" } : {}), ...env },
984
1006
  });
985
- return { status: r.status, stdout: r.stdout ?? "" };
1007
+ return { status: r.status, stdout: r.stdout ?? "", why: r.error ? (r.error.code === "ETIMEDOUT" ? "timed out" : r.error.code) : r.signal ? `killed by ${r.signal}` : null };
986
1008
  },
987
1009
  };
988
1010
  }
@@ -92,7 +92,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
92
92
  - `noTests` 는 시험이 하나도 돌지 않았다는 뜻이라 통과가 아니라 미검증이다.
93
93
  - 한 OS 에서만 실패하면, 제품의 결함은 버그로, 시험이 실행 환경을 잘못 가정한 것은 하자로 적는다.
94
94
 
95
- 박스에 둔 것은 `run` 이 끝날 때 지운다. 시간 초과, 연결 끊김, 중단(SIGINT, SIGTERM) 때는 박스에서 시작한 프로세스를 먼저 끝낸다. Linux, macOS 는 `mktemp -d` 로 만든 개인 폴더를 쓴다. 지우지 못하면 결과의 `cleaned` 가 `false` 이고, 그 박스를 하자로 적는다.
95
+ 박스에 둔 것은 `run` 이 끝날 때 지운다. 시간 초과, 연결 끊김, 중단(SIGINT, SIGTERM) 때는 박스에서 시작한 프로세스를 먼저 끝낸다. Linux, macOS 는 `mktemp -d` 로 만든 개인 폴더를 쓴다. 보통은 `/tmp` 아래이고, `runAs` 박스는 `/var/lib` 아래다. `runAs` 계정의 HOME 도 그 안에 있어서, 임시 폴더를 거부하는 도구(teamwork 등)의 시험도 그대로 돈다. 지우지 못하면 결과의 `cleaned` 가 `false` 이고, 그 박스를 하자로 적는다.
96
96
  알려진 한계: 이중 fork 로 프로세스 트리를 벗어난 프로세스는 끝나지 않는다. `pgrep` 이 없는 박스에서는 기록된 프로세스 하나만 끝난다.
97
97
 
98
98
  ### 등록 파일
@@ -27,7 +27,11 @@ const FIELDS = new Set(["name", "os", "ssh", "port", "identity", "path", "runAs"
27
27
  const SECRETISH = /pass|secret|token|credential/i;
28
28
  const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
29
29
  const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*$/;
30
- const REMOTE_DIR = /^\/tmp\/banker-remains\.[A-Za-z0-9]+$/;
30
+ // Where a box's private folder goes. A runAs box gets one outside the temporary folders, so that the copy and the
31
+ // run account's HOME inside it are not under /tmp: tools that refuse temporary folders (teamwork's Codex home check)
32
+ // would refuse the tests' fixtures there. /var/lib is root's, so only a root login (runAs) makes one there.
33
+ const dirBase = (b) => (b.runAs ? "/var/lib" : "/tmp");
34
+ const remoteDir = (b) => new RegExp(`^${dirBase(b).replace(/\//g, "\\/")}\\/banker-remains\\.[A-Za-z0-9]+$`);
31
35
  // What ssh and scp print about the connection itself, as opposed to what the remote command prints.
32
36
  const SSH_ERR = /^(ssh|scp): |Connection (closed|refused|reset|timed out)|Host key verification failed|Permission denied \(|Could not resolve hostname|No route to host|Broken pipe/m;
33
37
  const UPLOAD_MS = 10 * 60 * 1000;
@@ -153,8 +157,8 @@ function winSteps(b, name) {
153
157
  };
154
158
  }
155
159
 
156
- // Linux and macOS work in the private folder `mktemp -d` made. A root login hands the run to runAs, with a
157
- // HOME of its own. Ending a run takes the process tree under the recorded id first, then the folder.
160
+ // Linux and macOS work in the private folder `mktemp -d` made (under /tmp, or /var/lib for runAs). A root login
161
+ // hands the run to runAs, with a HOME of its own inside that folder. Ending a run takes the process tree under the recorded id first, then the folder.
158
162
  function posixSteps(b, name, dir) {
159
163
  const go = b.runAs
160
164
  ? `mkdir -p "$D/home" && chown -R ${b.runAs} "$D" && runuser -u ${b.runAs} -- env HOME="$D/home" sh "$D/run.sh"`
@@ -227,10 +231,10 @@ const sshFailed = (r) => (r.status === null && !r.timedOut) || (r.status === 255
227
231
 
228
232
  async function makeDir(b, exec) {
229
233
  if (b.os === "win32") return { dir: null };
230
- const r = await exec("ssh", [...sshBase(b), b.ssh, "mktemp -d /tmp/banker-remains.XXXXXXXX"], { timeoutMs: PROBE_MS });
234
+ const r = await exec("ssh", [...sshBase(b), b.ssh, `mktemp -d ${dirBase(b)}/banker-remains.XXXXXXXX`], { timeoutMs: PROBE_MS });
231
235
  const dir = String(r.stdout || "").trim();
232
236
  if (r.status !== 0) return { error: lastLine(r.stderr) || "ssh 실패" };
233
- return REMOTE_DIR.test(dir) ? { dir } : { error: `mktemp -d 가 예상과 다른 경로를 냈습니다: ${dir.slice(0, 80)}` };
237
+ return remoteDir(b).test(dir) ? { dir } : { error: `mktemp -d 가 예상과 다른 경로를 냈습니다: ${dir.slice(0, 80)}` };
234
238
  }
235
239
 
236
240
  async function attempt(b, plan, exec, files, steps) {
@@ -265,22 +269,37 @@ export async function runOnBox(b, plan, exec) {
265
269
 
266
270
  // Started from the home folder with git -C: Windows looks for a program in the working folder first, so a git.exe
267
271
  // inside the folder worked on never runs.
268
- function git(cwd, args) {
269
- const r = spawnSync("git", ["-C", resolve(cwd), ...args], { cwd: runDir(), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
270
- if (r.status !== 0) throw new Error(`git ${args.filter((a) => !a.includes("=")).join(" ")} 실패: ${lastLine(r.stderr) || r.error?.message || ""}`);
272
+ function git(cwd, args, env = process.env) {
273
+ const r = spawnSync("git", ["-C", resolve(cwd), ...args], { cwd: runDir(), env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
274
+ if (r.status !== 0) throw new Error(`git ${gitWords(args)} 실패: ${lastLine(r.stderr) || r.error?.message || ""}`);
271
275
  return r.stdout;
272
276
  }
273
277
 
278
+ // The command words for a message: global options (-c key=value, --no-optional-locks) are left out.
279
+ export const gitWords = (args) => args.filter((a, i) => a !== "-c" && args[i - 1] !== "-c" && !a.startsWith("--no-optional-locks")).join(" ");
280
+
274
281
  // The commit to send. `worktree` is a commit object made from the tracked files as they are now
275
282
  // (git stash create: no stash entry, no change to the work tree). Untracked files are never sent.
276
- function resolveTree(repo, ref) {
277
- const status = git(repo, ["status", "--porcelain", "--untracked-files=all"]).split("\n").filter(Boolean);
283
+ // Neither step writes the repository's index: status takes no optional lock, and stash create works on a copy of
284
+ // the index (GIT_INDEX_FILE), so two runs at once, or a git command of the user's, do not meet on index.lock.
285
+ export function resolveTree(repo, ref) {
286
+ const status = git(repo, ["--no-optional-locks", "status", "--porcelain", "--untracked-files=all"]).split("\n").filter(Boolean);
278
287
  const untracked = status.filter((l) => l.startsWith("??")).length;
279
288
  if (ref !== "worktree") {
280
289
  return { sha: git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]).trim(), untracked, modified: status.length - untracked };
281
290
  }
282
- const made = git(repo, ["-c", "user.name=banker-remains", "-c", "user.email=banker-remains@localhost", "stash", "create"]).trim();
283
- return { sha: made || git(repo, ["rev-parse", "HEAD"]).trim(), untracked, modified: 0 };
291
+ const tmp = join(tmpdir(), `banker-remains-index-${randomBytes(8).toString("hex")}`);
292
+ mkdirSync(tmp, { mode: 0o700 });
293
+ try {
294
+ const index = resolve(repo, git(repo, ["rev-parse", "--git-path", "index"]).trim());
295
+ const copy = join(tmp, "index");
296
+ if (existsSync(index)) copyFileSync(index, copy);
297
+ const env = { ...process.env, GIT_INDEX_FILE: copy };
298
+ const made = git(repo, ["-c", "user.name=banker-remains", "-c", "user.email=banker-remains@localhost", "stash", "create"], env).trim();
299
+ return { sha: made || git(repo, ["rev-parse", "HEAD"]).trim(), untracked, modified: 0 };
300
+ } finally {
301
+ rmSync(tmp, { recursive: true, force: true });
302
+ }
284
303
  }
285
304
 
286
305
  function testFiles(repo, sha) {