@kaydash9999/banker-plugins 0.24.1 → 0.25.1

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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +50 -0
  4. package/README.md +5 -4
  5. package/bin/lib/codex-hooks.mjs +5 -1
  6. package/codex/hooks.json +4 -4
  7. package/codex/manifest.json +1 -0
  8. package/codex/skills/teamwork/scripts/teamwork.mjs +18 -8
  9. package/codex/transform-matrix.md +1 -0
  10. package/hooks/telemetry-count-codex.mjs +3 -2
  11. package/package.json +1 -1
  12. package/skills/3d-intro-build/SKILL.md +1 -0
  13. package/skills/3d-intro-build/references/azure-adapter.mjs +54 -27
  14. package/skills/3d-intro-build/references/video-pool.mjs +11 -6
  15. package/skills/3d-intro-setup/references/azure-adapter.mjs +54 -27
  16. package/skills/3d-intro-setup/references/video-pool.mjs +11 -6
  17. package/skills/anti-drm/SKILL.md +2 -2
  18. package/skills/anti-drm/scripts/anti-drm.mjs +8 -5
  19. package/skills/anti-drm/scripts/instruction-toggle.mjs +6 -3
  20. package/skills/build-testbox/scripts/browser-cell.mjs +10 -2
  21. package/skills/build-testbox/scripts/containers.mjs +8 -3
  22. package/skills/build-testbox/scripts/proc.mjs +32 -8
  23. package/skills/build-testbox/scripts/testbox.mjs +10 -2
  24. package/skills/git-init/scripts/git-init.mjs +53 -5
  25. package/skills/git-init/scripts/marker-block.mjs +131 -19
  26. package/skills/init-dir/scripts/init-dir.mjs +29 -2
  27. package/skills/init-dir/scripts/marker-block.mjs +131 -19
  28. package/skills/init-dir/templates/scripts/lib/service.mjs +57 -6
  29. package/skills/lineage/SKILL.md +1 -1
  30. package/skills/lineage/lineage.py +11 -11
  31. package/skills/motion-sites/SKILL.md +87 -0
  32. package/skills/motion-sites/scripts/motion-sites.mjs +348 -0
  33. package/skills/omc-patch/scripts/omc-patch.mjs +76 -17
  34. package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
  35. package/skills/payload-mon/scripts/payload-size.mjs +2 -2
  36. package/skills/progress-mon/scripts/pm-adr.mjs +6 -3
  37. package/skills/progress-mon/scripts/pm-config.mjs +133 -7
  38. package/skills/progress-mon/scripts/pm-data.mjs +35 -29
  39. package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
  40. package/skills/progress-mon/scripts/pm-server.mjs +3 -3
  41. package/skills/progress-mon/scripts/pm-sources.mjs +2 -1
  42. package/skills/ralph-qa/SKILL.md +1 -0
  43. package/skills/ralph-qa/references/seat-run.mjs +21 -4
  44. package/skills/ralph-qa/references/verifier-probe.mjs +35 -13
  45. package/skills/remains/SKILL.md +31 -4
  46. package/skills/remains/scripts/boxes.mjs +44 -15
  47. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
  48. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
  49. package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
  50. package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
  51. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
  52. package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
  53. package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
  54. package/skills/tune-tmux/scripts/marker-block.mjs +131 -19
  55. package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
  56. package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
  57. package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
  58. package/skills/vertical-pptx/verify_a4p.mjs +11 -4
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: remains
3
- description: "남은 버그, 하자, 미검증 항목을 찾아 표로 보여 줌. 테스트박스가 등록돼 있으면 각 OS 에서 시험을 돌려 확인함. 읽기와 시험 실행만 하고 고치지 않음. 'remains'/'남은 버그'/'남은 하자'/'미검증 항목'/'뭐가 남았어' 시 사용."
3
+ description: "남은 버그, 하자, 미검증 항목을 찾아 표로 보여 줌. 찾은 결함과 원인이 같은 자리는 저장소 전체에서 찾음. 테스트박스가 등록돼 있으면 각 OS 에서 시험을 돌려 확인함. 읽기와 시험 실행만 하고 고치지 않음. 'remains'/'남은 버그'/'남은 하자'/'미검증 항목'/'뭐가 남았어' 시 사용."
4
4
  argument-hint: "[기준 커밋] [--no-boxes]"
5
5
  ---
6
6
 
@@ -28,10 +28,34 @@ argument-hint: "[기준 커밋] [--no-boxes]"
28
28
  |---|---|
29
29
  | 이번 대화 | 미검증이나 보류로 남긴 것, 검토의 비차단 지적, 사용자가 미룬 결정, 보고했지만 확인하지 않은 주장 |
30
30
  | 저장소 기록 | 인계 문서(예: `.omc/handoffs/`), CHANGELOG `[Unreleased]` 의 `UNVALIDATED` 나 미검증 표기, 문서의 미검증 절 |
31
- | 코드 표시 | 범위 안에서 바뀐 파일의 `TODO`, `FIXME`, `XXX`, `HACK`, 시험의 `skip`, `only`, `todo` |
31
+ | 코드 표시 | 범위 안에서 바뀐 파일의 `TODO`, `FIXME`, `XXX`, `HACK`, 시험의 `skip`, `only`, `todo`. 같은 종류는 2-1 에서 저장소 전체로 넓힌다 |
32
32
  | git 상태 | 커밋하지 않은 변경, push 하지 않은 커밋, 추적하지 않는 새 파일 |
33
33
  | 메모리와 노트 | 있으면 그 안의 남은 지적 |
34
34
 
35
+ ## 2-1. 같은 종류 전체 찾기 (읽기만)
36
+
37
+ 지적을 그 자리에만 적으면 같은 원인의 결함이 다른 파일에 남는다. 그러면 고칠 때마다 다음 검토에서 같은 종류가 또 나온다. 그래서 모은 결함 항목(버그와 하자)마다 같은 종류를 저장소 전체에서 찾는다.
38
+
39
+ 1. 항목마다 결함 종류를 원인으로 한 문장에 쓴다. 자리 이름으로 쓰지 않는다.
40
+ - 예: "Windows 의 wx 는 끊어진 링크를 따라가므로, 추측 가능한 이름에 새 파일을 만들면 링크 너머에 파일이 생긴다"
41
+ - 예: "경로 문자열로 같은 파일인지 판정하면 대소문자, 8.3 짧은 이름, 링크인 부모 폴더만 다른 같은 파일을 놓친다"
42
+ 2. 같은 원인을 내는 코드 꼴을 모두 적는다. API 하나만 보지 않는다.
43
+ - 예: `wx` 만이 아니라 `O_EXCL`, `COPYFILE_EXCL`, `wx` 없이 추측 가능한 임시 이름에 쓰는 것까지
44
+ - 예: `===` 비교만이 아니라 `Map`, `Set` 키, `startsWith`, `path.relative` 의 `..` 판정, `includes` 까지
45
+ 3. 범위는 바뀐 파일이 아니라 배포되는 파일 전부다.
46
+ - 같은 파일의 사본(다른 스킬 폴더, 런타임별 폴더)도 각각 센다.
47
+ - 시험과 개발 도구에서 찾은 것은 따로 표시한다.
48
+ 4. 찾은 명령과 건수를 확인 범위에 적는다.
49
+ - 찾기가 0건이면 패턴을 의심한다. 더 단순한 패턴이나 다른 도구(`rg`, `git grep`)로 다시 찾는다. 셸의 `grep` 이 다른 도구로 바뀌어 있을 수 있다.
50
+ 5. 찾은 자리마다 판정한다.
51
+ - 고칠 것: 결과(거짓 성공, 예외, 경계 우회, 밖 파일 생성 등)를 적는다.
52
+ - 해당 없음: 까닭(방어가 이미 있음, 닿는 길이 없음, 이름을 미리 알 수 없음 등)을 적는다.
53
+ 6. 프로젝트에 기록된, 이미 고친 결함도 종류로 삼는다.
54
+ - 출처: CHANGELOG 의 고친 항목, 위키와 메모리의 함정 기록
55
+ - 비슷한 항목은 종류 하나로 묶어 중복을 줄인다.
56
+ - 예전에 한 곳만 고친 종류가 다른 곳에 남았는지 1~5 와 같은 방법으로 본다.
57
+ 7. 자리가 많으면 런타임이 지원하는 읽기 전용 하위 에이전트에게 종류별로 나눠 맡겨도 된다. 맡길 때는 결함 종류, 찾을 꼴, 이미 고친 본보기를 함께 준다. 하위 에이전트도 고치지 않는다.
58
+
35
59
  ## 3. 이 머신에서 시험
36
60
 
37
61
  - 프로젝트의 시험 명령을 찾는다. `package.json` 의 `scripts.test`, CI 워크플로의 시험 단계, `Makefile`, pytest 설정, `*.test.mjs`(node:test) 순으로 본다. 찾은 명령과 그 근거를 확인 범위에 적는다.
@@ -68,7 +92,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
68
92
  - `noTests` 는 시험이 하나도 돌지 않았다는 뜻이라 통과가 아니라 미검증이다.
69
93
  - 한 OS 에서만 실패하면, 제품의 결함은 버그로, 시험이 실행 환경을 잘못 가정한 것은 하자로 적는다.
70
94
 
71
- 박스에 둔 것은 `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` 이고, 그 박스를 하자로 적는다.
72
96
  알려진 한계: 이중 fork 로 프로세스 트리를 벗어난 프로세스는 끝나지 않는다. `pgrep` 이 없는 박스에서는 기록된 프로세스 하나만 끝난다.
73
97
 
74
98
  ### 등록 파일
@@ -90,7 +114,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
90
114
  | `name` | 박스 이름(영문이나 숫자로 시작, 영문, 숫자, `.`, `_`, `-`) |
91
115
  | `os` | `linux`, `darwin`, `win32` 중 하나 |
92
116
  | `ssh` | `user@host`. 암호 없이 SSH 키로 접속돼야 한다 |
93
- | `port`, `identity` | 선택. SSH 포트와 키 파일 경로 |
117
+ | `port`, `identity` | 선택. SSH 포트와 키 파일 경로(상대 경로는 boxes.mjs 를 실행한 폴더 기준) |
94
118
  | `path` | 선택. Linux, macOS 박스에서 PATH 앞에 붙일 절대 경로(비대화형 SSH 의 PATH 에 node 가 없을 때) |
95
119
  | `runAs` | 선택. root 로 접속하는 Linux 박스에서 시험을 대신 돌릴 계정(예: `nobody`) |
96
120
 
@@ -123,5 +147,8 @@ argument-hint: "[기준 커밋] [--no-boxes]"
123
147
 
124
148
  - 근거는 다시 확인할 수 있게 쓴다(파일과 줄, 명령, 시험 이름, 로그 경로).
125
149
  - 심각도가 높은 것부터 놓는다.
150
+ - 2-1 의 결함 종류는 종류마다 한 행으로 쓴다. 항목 칸에 종류를, 근거 칸에 고칠 자리 수와 해당 없음 수를 적는다.
151
+ - 고칠 자리가 20곳 이하면 표 아래에 `파일:줄 | 판정 | 까닭` 목록을 붙인다.
152
+ - 20곳을 넘으면 자리 목록을 시스템 임시 폴더의 파일에 쓰고 그 경로를 근거에 적는다. 저장소 안에는 쓰지 않는다.
126
153
  - 남은 항목이 없으면 표 대신 "남은 항목 없음" 과 확인 범위를 쓴다.
127
154
  - 마지막 줄에는 사용자가 고를 다음 행동을 쓴다(예: 고칠 항목 고르기). 고치는 일은 요청을 받은 뒤에 한다.
@@ -15,15 +15,23 @@ import { spawn, spawnSync } from "node:child_process";
15
15
  import { randomBytes } from "node:crypto";
16
16
  import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
17
17
  import { homedir, tmpdir } from "node:os";
18
- import { join } from "node:path";
18
+ import { isAbsolute, join, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
 
21
+ // The folder programs start in: the home folder (Windows looks for a program in the working folder first, so a
22
+ // program planted in the folder worked on never runs), or the temp folder when there is no home folder.
23
+ const runDir = () => (existsSync(homedir()) ? homedir() : tmpdir());
24
+
21
25
  const OSES = new Set(["linux", "darwin", "win32"]);
22
26
  const FIELDS = new Set(["name", "os", "ssh", "port", "identity", "path", "runAs"]);
23
27
  const SECRETISH = /pass|secret|token|credential/i;
24
28
  const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
25
29
  const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*$/;
26
- 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]+$`);
27
35
  // What ssh and scp print about the connection itself, as opposed to what the remote command prints.
28
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;
29
37
  const UPLOAD_MS = 10 * 60 * 1000;
@@ -102,7 +110,10 @@ export function loadBoxes(file = configFile()) {
102
110
 
103
111
  // ── commands for a box ───────────────────────────────────────────────────────
104
112
 
105
- const extras = (b, portFlag) => [...(b.port ? [portFlag, String(b.port)] : []), ...(b.identity ? ["-i", b.identity] : [])];
113
+ // ssh and scp start in the home folder, so a relative key path is made absolute against this run's folder first,
114
+ // where it was meant. A path from ~ stays as it is: OpenSSH expands it itself.
115
+ const keyPath = (p) => (/^~([\\/]|$)/.test(p) || isAbsolute(p) ? p : resolve(p));
116
+ const extras = (b, portFlag) => [...(b.port ? [portFlag, String(b.port)] : []), ...(b.identity ? ["-i", keyPath(b.identity)] : [])];
106
117
  export const sshBase = (b) => [...BASE, ...extras(b, "-p")];
107
118
  export const scpBase = (b) => [...BASE, ...extras(b, "-P")];
108
119
 
@@ -146,8 +157,8 @@ function winSteps(b, name) {
146
157
  };
147
158
  }
148
159
 
149
- // Linux and macOS work in the private folder `mktemp -d` made. A root login hands the run to runAs, with a
150
- // 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.
151
162
  function posixSteps(b, name, dir) {
152
163
  const go = b.runAs
153
164
  ? `mkdir -p "$D/home" && chown -R ${b.runAs} "$D" && runuser -u ${b.runAs} -- env HOME="$D/home" sh "$D/run.sh"`
@@ -220,10 +231,10 @@ const sshFailed = (r) => (r.status === null && !r.timedOut) || (r.status === 255
220
231
 
221
232
  async function makeDir(b, exec) {
222
233
  if (b.os === "win32") return { dir: null };
223
- 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 });
224
235
  const dir = String(r.stdout || "").trim();
225
236
  if (r.status !== 0) return { error: lastLine(r.stderr) || "ssh 실패" };
226
- 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)}` };
227
238
  }
228
239
 
229
240
  async function attempt(b, plan, exec, files, steps) {
@@ -256,22 +267,39 @@ export async function runOnBox(b, plan, exec) {
256
267
  return { ...rest, ms: Date.now() - t0 };
257
268
  }
258
269
 
259
- function git(cwd, args) {
260
- const r = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
261
- if (r.status !== 0) throw new Error(`git ${args.filter((a) => !a.includes("=")).join(" ")} 실패: ${lastLine(r.stderr) || r.error?.message || ""}`);
270
+ // Started from the home folder with git -C: Windows looks for a program in the working folder first, so a git.exe
271
+ // inside the folder worked on never runs.
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 || ""}`);
262
275
  return r.stdout;
263
276
  }
264
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
+
265
281
  // The commit to send. `worktree` is a commit object made from the tracked files as they are now
266
282
  // (git stash create: no stash entry, no change to the work tree). Untracked files are never sent.
267
- function resolveTree(repo, ref) {
268
- 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);
269
287
  const untracked = status.filter((l) => l.startsWith("??")).length;
270
288
  if (ref !== "worktree") {
271
289
  return { sha: git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]).trim(), untracked, modified: status.length - untracked };
272
290
  }
273
- const made = git(repo, ["-c", "user.name=banker-remains", "-c", "user.email=banker-remains@localhost", "stash", "create"]).trim();
274
- 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
+ }
275
303
  }
276
304
 
277
305
  function testFiles(repo, sha) {
@@ -423,7 +451,8 @@ export async function main(argv, deps = {}) {
423
451
  // even if a grandchild still holds its pipes. Output past MAX_OUTPUT is dropped.
424
452
  export function spawnExec(file, args, { timeoutMs = UPLOAD_MS } = {}) {
425
453
  return new Promise((resolve) => {
426
- const child = spawn(file, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
454
+ // from the home folder: Windows looks for ssh.exe and scp.exe in the working folder (the repository) first
455
+ const child = spawn(file, args, { cwd: runDir(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
427
456
  children.add(child);
428
457
  const buf = { stdout: "", stderr: "" };
429
458
  for (const k of ["stdout", "stderr"]) {
@@ -38,6 +38,7 @@
38
38
  * folder (a sandbox, a read-only mount, a file another account owns): the settings are unchanged.
39
39
  * Every output ends with the line `설정 파일: <path>`.
40
40
  */
41
+ import { randomBytes } from "node:crypto";
41
42
  import { accessSync, chmodSync, constants, existsSync, lstatSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
42
43
  import { homedir } from "node:os";
43
44
  import { dirname, join } from "node:path";
@@ -187,7 +188,7 @@ function writeFresh(file, data, mode) {
187
188
  if (lstatSync(file, { throwIfNoEntry: false })?.isDirectory()) {
188
189
  throw Object.assign(new Error(`쓸 자리에 폴더가 있습니다: ${file}`), { code: "EISDIR" });
189
190
  }
190
- const tmp = `${file}.${process.pid}.tmp`;
191
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
191
192
  rmSync(tmp, { force: true });
192
193
  try {
193
194
  writeFileSync(tmp, data, { flag: "wx", mode });
@@ -9,6 +9,7 @@
9
9
  * - 파일이 링크이거나 일반 파일이 아니면 고치지 않는다. 쓰기 전 원본을 백업(이 계정만 읽음)하고, 새 파일에 쓴 뒤
10
10
  * 바꿔 넣으며 파일 모드를 지킨다.
11
11
  */
12
+ import crypto from 'node:crypto';
12
13
  import fs from 'node:fs';
13
14
  import os from 'node:os';
14
15
  import path from 'node:path';
@@ -34,15 +35,19 @@ export function readToml(file) {
34
35
  return { text: fs.readFileSync(file, 'utf8'), mode: st.mode & 0o7777, exists: true };
35
36
  }
36
37
 
37
- // 원본을 backup 에 복사한 뒤(원본이 있을 때) 새 파일에 써서 바꿔 넣는다.
38
+ // 원본을 backup 에 복사한 뒤(원본이 있을 때) 새 파일에 써서 바꿔 넣는다. 백업도 같은 방식이라 백업 자리의 링크는
39
+ // 따라가지 않고 바뀐다.
38
40
  export function writeToml(file, text, { mode = null, backup = null } = {}) {
39
- if (backup && fs.existsSync(file)) {
40
- fs.copyFileSync(file, backup);
41
- fs.chmodSync(backup, 0o600); // 원본에 비밀 값이 있을 수 있다
42
- }
43
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
41
+ if (backup && fs.existsSync(file)) replaceFile(backup, fs.readFileSync(file), 0o600); // 원본에 비밀 값이 있을 수 있다
42
+ replaceFile(file, text, mode);
43
+ }
44
+
45
+ // 난수 이름의 새 파일에 쓴 뒤 file 자리로 바꿔 넣는다. 이름을 미리 맞힐 수 없어, 그 자리에 심어 둔 링크를 wx 가
46
+ // 따라가는 일(Windows 는 끊어진 링크를 따라가 대상 파일을 만든다)이 없다. 실패하면 새 파일을 지운다.
47
+ function replaceFile(file, data, mode) {
48
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
44
49
  try {
45
- fs.writeFileSync(tmp, text, { flag: 'wx', mode: mode ?? 0o600 });
50
+ fs.writeFileSync(tmp, data, { flag: 'wx', mode: mode ?? 0o600 });
46
51
  if (mode !== null) fs.chmodSync(tmp, mode);
47
52
  fs.renameSync(tmp, file);
48
53
  } catch (e) {
@@ -34,6 +34,7 @@
34
34
  * changes the exit code. APPLY_HUD_CHECK_TIMEOUT_MS (tests) shortens its 25s limit.
35
35
  * Exit 1 means nothing changed; exit 2 means an unexpected error stopped it partway.
36
36
  */
37
+ import { randomBytes } from "node:crypto";
37
38
  import { spawnSync } from "node:child_process";
38
39
  import {
39
40
  accessSync,
@@ -405,7 +406,7 @@ function writeSettings(file, text) {
405
406
  } catch {
406
407
  if (isLink(file)) return writeFileSync(file, text);
407
408
  }
408
- const tmp = `${target}.${process.pid}.tmp`;
409
+ const tmp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
409
410
  try {
410
411
  rmSync(tmp, { force: true });
411
412
  writeFileSync(tmp, text, { flag: "wx", mode });
@@ -9,6 +9,7 @@
9
9
  * - 파일이 링크이거나 일반 파일이 아니면 고치지 않는다. 쓰기 전 원본을 백업(이 계정만 읽음)하고, 새 파일에 쓴 뒤
10
10
  * 바꿔 넣으며 파일 모드를 지킨다.
11
11
  */
12
+ import crypto from 'node:crypto';
12
13
  import fs from 'node:fs';
13
14
  import os from 'node:os';
14
15
  import path from 'node:path';
@@ -34,15 +35,19 @@ export function readToml(file) {
34
35
  return { text: fs.readFileSync(file, 'utf8'), mode: st.mode & 0o7777, exists: true };
35
36
  }
36
37
 
37
- // 원본을 backup 에 복사한 뒤(원본이 있을 때) 새 파일에 써서 바꿔 넣는다.
38
+ // 원본을 backup 에 복사한 뒤(원본이 있을 때) 새 파일에 써서 바꿔 넣는다. 백업도 같은 방식이라 백업 자리의 링크는
39
+ // 따라가지 않고 바뀐다.
38
40
  export function writeToml(file, text, { mode = null, backup = null } = {}) {
39
- if (backup && fs.existsSync(file)) {
40
- fs.copyFileSync(file, backup);
41
- fs.chmodSync(backup, 0o600); // 원본에 비밀 값이 있을 수 있다
42
- }
43
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
41
+ if (backup && fs.existsSync(file)) replaceFile(backup, fs.readFileSync(file), 0o600); // 원본에 비밀 값이 있을 수 있다
42
+ replaceFile(file, text, mode);
43
+ }
44
+
45
+ // 난수 이름의 새 파일에 쓴 뒤 file 자리로 바꿔 넣는다. 이름을 미리 맞힐 수 없어, 그 자리에 심어 둔 링크를 wx 가
46
+ // 따라가는 일(Windows 는 끊어진 링크를 따라가 대상 파일을 만든다)이 없다. 실패하면 새 파일을 지운다.
47
+ function replaceFile(file, data, mode) {
48
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
44
49
  try {
45
- fs.writeFileSync(tmp, text, { flag: 'wx', mode: mode ?? 0o600 });
50
+ fs.writeFileSync(tmp, data, { flag: 'wx', mode: mode ?? 0o600 });
46
51
  if (mode !== null) fs.chmodSync(tmp, mode);
47
52
  fs.renameSync(tmp, file);
48
53
  } catch (e) {
@@ -25,6 +25,7 @@
25
25
  * `node --check`. Exit status 1 means nothing was changed; 2 means an unexpected
26
26
  * error stopped the command partway, so check `status`.
27
27
  */
28
+ import { randomBytes } from "node:crypto";
28
29
  import { spawnSync } from "node:child_process";
29
30
  import { accessSync, constants, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
30
31
  import { homedir, tmpdir } from "node:os";
@@ -139,7 +140,7 @@ function insertBlock(base) {
139
140
  // either name is replaced rather than written through, and a write that fails
140
141
  // leaves the old file as it was.
141
142
  function writeFresh(file, data) {
142
- const tmp = `${file}.${process.pid}.tmp`;
143
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
143
144
  rmSync(tmp, { force: true });
144
145
  try {
145
146
  writeFileSync(tmp, data, { flag: "wx" });
@@ -27,6 +27,7 @@ import {
27
27
  chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync,
28
28
  renameSync, rmSync, statSync, writeFileSync,
29
29
  } from "node:fs";
30
+ import { randomBytes } from "node:crypto";
30
31
  import { homedir, tmpdir } from "node:os";
31
32
  import { join, posix, win32 } from "node:path";
32
33
  import { fileURLToPath } from "node:url";
@@ -60,10 +61,11 @@ function readJson(file) {
60
61
  }
61
62
  }
62
63
 
63
- // A new file beside the target, given the target's mode when there is one, then renamed over it. The temp name is
64
- // created exclusively and a link in the target's place is replaced, not followed. The temp file goes on failure.
64
+ // A new file beside the target, given the target's mode when there is one, then renamed over it. The temp name ends
65
+ // in random bytes, so no one can plant a link at it first (Windows opens "wx" through a link to nothing and makes the
66
+ // file it leads to), and a link in the target's place is replaced, not followed. The temp file goes on failure.
65
67
  function writeAtomic(file, data) {
66
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
68
+ const tmp = `${file}.${randomBytes(8).toString("hex")}.tmp`;
67
69
  try {
68
70
  writeFileSync(tmp, data, { flag: "wx" });
69
71
  const mode = modeOf(file);
@@ -214,10 +216,29 @@ export function hookCommand(node, script, opts = {}) {
214
216
  const r = P.relative(dir, f);
215
217
  return r === "" || (!r.startsWith("..") && !P.isAbsolute(r));
216
218
  };
217
- if (under(node, tmpdir()) || (codex && [node, SELF].some((f) => under(f, cwd)))) return null;
219
+ // Each path as given and as the file system spells it: a link, macOS /var against /private/var, a Windows 8.3 name
220
+ // or another case would otherwise slip a node in the temp folder past the check.
221
+ const both = (p) => [p, realOf(p)];
222
+ const temps = [tmpdir(), ...(win ? [] : ["/tmp"])].flatMap(both);
223
+ const inTemp = both(node).some((f) => temps.some((d) => under(f, d)));
224
+ const inWork = codex && [node, SELF].flatMap(both).some((f) => both(cwd).some((d) => under(f, d)));
225
+ if (inTemp || inWork) return null;
218
226
  return formatCommand(node, script, win);
219
227
  }
220
228
 
229
+ // The path as the file system spells it (native first: it gives the long name and the case on disk), or as given.
230
+ function realOf(p) {
231
+ try {
232
+ return realpathSync.native(p);
233
+ } catch {
234
+ try {
235
+ return realpathSync(p);
236
+ } catch {
237
+ return p;
238
+ }
239
+ }
240
+ }
241
+
221
242
  const COPY_RE = new RegExp(`[/\\\\]${COPY_NAME.replace(/\./g, "\\.")}["']? hook$`);
222
243
  const isOurs = (h) => isObject(h) && typeof h.command === "string" && COPY_RE.test(h.command);
223
244
 
@@ -198,7 +198,8 @@ export function writeFresh(file, data, mode = 0o644) {
198
198
  // any name that is there, a link to nothing among them (EEXIST). Windows follows a link to nothing and makes the file
199
199
  // it leads to: then that file is removed again when it is still the empty one made, and the open is refused with
200
200
  // EEXIST as elsewhere. A look before the open catches such a link; this catches one planted between the look and the
201
- // open. A look that fails after the open leaves the file to the caller, as a plain "wx" would.
201
+ // open. A name gone after the open is EEXIST too. A look that cannot be made (ESTALE, EPERM) leaves the file to the
202
+ // caller, as a plain "wx" would.
202
203
  export function openNew(path, mode) {
203
204
  const fd = openSync(path, "wx", mode);
204
205
  let made = null;
@@ -206,8 +207,10 @@ export function openNew(path, mode) {
206
207
  try {
207
208
  made = fstatSync(fd, { bigint: true });
208
209
  st = lstatSync(path, { bigint: true });
209
- } catch {
210
- return fd;
210
+ } catch (e) {
211
+ if (e?.code !== "ENOENT") return fd; // cannot look (ESTALE, EPERM): the file is the caller's, as a plain "wx" leaves it
212
+ closeSync(fd); // the name is gone: nothing is written through a name not proved
213
+ throw Object.assign(new Error(`EEXIST: file already exists, open '${path}'`), { code: "EEXIST", syscall: "open", path });
211
214
  }
212
215
  if (!st.isSymbolicLink() && (made.ino === 0n || sameInode(made, st))) return fd;
213
216
  closeSync(fd);