bosun 0.40.21 → 0.41.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 (80) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-custom-tools.mjs +23 -5
  4. package/agent/agent-event-bus.mjs +248 -6
  5. package/agent/agent-pool.mjs +131 -30
  6. package/agent/agent-work-analyzer.mjs +8 -16
  7. package/agent/primary-agent.mjs +81 -7
  8. package/agent/retry-queue.mjs +164 -0
  9. package/bench/swebench/bosun-swebench.mjs +5 -0
  10. package/bosun.config.example.json +25 -0
  11. package/bosun.schema.json +825 -183
  12. package/cli.mjs +267 -8
  13. package/config/config-doctor.mjs +51 -2
  14. package/config/config.mjs +232 -5
  15. package/github/github-auth-manager.mjs +70 -19
  16. package/infra/library-manager.mjs +894 -60
  17. package/infra/monitor.mjs +701 -69
  18. package/infra/runtime-accumulator.mjs +376 -84
  19. package/infra/session-tracker.mjs +95 -28
  20. package/infra/test-runtime.mjs +267 -0
  21. package/lib/codebase-audit.mjs +133 -18
  22. package/package.json +30 -8
  23. package/server/setup-web-server.mjs +29 -1
  24. package/server/ui-server.mjs +1571 -49
  25. package/setup.mjs +27 -24
  26. package/shell/codex-shell.mjs +34 -3
  27. package/shell/copilot-shell.mjs +50 -8
  28. package/task/msg-hub.mjs +193 -0
  29. package/task/pipeline.mjs +544 -0
  30. package/task/task-claims.mjs +6 -10
  31. package/task/task-cli.mjs +38 -2
  32. package/task/task-executor-pipeline.mjs +143 -0
  33. package/task/task-executor.mjs +36 -27
  34. package/telegram/get-telegram-chat-id.mjs +57 -47
  35. package/ui/components/chat-view.js +18 -1
  36. package/ui/components/workspace-switcher.js +321 -9
  37. package/ui/demo-defaults.js +17830 -10433
  38. package/ui/demo.html +9 -1
  39. package/ui/modules/router.js +1 -1
  40. package/ui/modules/settings-schema.js +2 -0
  41. package/ui/modules/state.js +54 -57
  42. package/ui/modules/voice-client-sdk.js +376 -37
  43. package/ui/modules/voice-client.js +173 -33
  44. package/ui/setup.html +68 -2
  45. package/ui/styles/components.css +571 -1
  46. package/ui/styles.css +201 -1
  47. package/ui/tabs/dashboard.js +74 -0
  48. package/ui/tabs/library.js +410 -55
  49. package/ui/tabs/logs.js +10 -0
  50. package/ui/tabs/settings.js +178 -99
  51. package/ui/tabs/tasks.js +1083 -507
  52. package/ui/tabs/telemetry.js +34 -0
  53. package/ui/tabs/workflow-canvas-utils.mjs +38 -1
  54. package/ui/tabs/workflows.js +1275 -402
  55. package/voice/voice-agents-sdk.mjs +2 -2
  56. package/voice/voice-relay.mjs +28 -20
  57. package/workflow/declarative-workflows.mjs +145 -0
  58. package/workflow/msg-hub.mjs +237 -0
  59. package/workflow/pipeline-workflows.mjs +287 -0
  60. package/workflow/pipeline.mjs +828 -315
  61. package/workflow/project-detection.mjs +559 -0
  62. package/workflow/workflow-cli.mjs +128 -0
  63. package/workflow/workflow-contract.mjs +433 -232
  64. package/workflow/workflow-engine.mjs +510 -47
  65. package/workflow/workflow-nodes/custom-loader.mjs +251 -0
  66. package/workflow/workflow-nodes.mjs +2024 -184
  67. package/workflow/workflow-templates.mjs +118 -24
  68. package/workflow-templates/agents.mjs +20 -20
  69. package/workflow-templates/bosun-native.mjs +212 -2
  70. package/workflow-templates/code-quality.mjs +20 -14
  71. package/workflow-templates/continuation-loop.mjs +339 -0
  72. package/workflow-templates/github.mjs +516 -40
  73. package/workflow-templates/planning.mjs +446 -17
  74. package/workflow-templates/reliability.mjs +65 -12
  75. package/workflow-templates/task-batch.mjs +27 -10
  76. package/workflow-templates/task-execution.mjs +752 -0
  77. package/workflow-templates/task-lifecycle.mjs +117 -14
  78. package/workspace/context-cache.mjs +66 -18
  79. package/workspace/workspace-manager.mjs +153 -1
  80. package/workflow-templates/issue-continuation.mjs +0 -243
@@ -13,16 +13,20 @@
13
13
 
14
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from "node:fs";
15
15
  import { resolve, dirname } from "node:path";
16
+ import { randomBytes } from "node:crypto";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import { buildSessionInsights } from "../lib/session-insights.mjs";
19
+ import { isTestRuntime } from "./test-runtime.mjs";
20
+ import { addCompletedSession } from "./runtime-accumulator.mjs";
18
21
 
19
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
23
  const SESSIONS_DIR = resolve(__dirname, "..", "logs", "sessions");
21
24
 
22
25
  const TAG = "[session-tracker]";
23
26
 
24
- /** Default: keep last 10 messages per task session. */
25
- const DEFAULT_MAX_MESSAGES = 10;
27
+ /** Default: keep last 300 messages per task session.
28
+ * Previously 10 — far too few for historic session review. */
29
+ const DEFAULT_MAX_MESSAGES = 300;
26
30
 
27
31
  /** Default: keep a larger history for manual/primary chat sessions. */
28
32
  const DEFAULT_CHAT_MAX_MESSAGES = 2000;
@@ -35,6 +39,22 @@ const MAX_MESSAGE_CHARS = 100_000;
35
39
 
36
40
  /** Maximum total sessions to keep in memory. */
37
41
  const MAX_SESSIONS = 100;
42
+ const TERMINAL_SESSION_STATUSES = new Set(["completed", "failed", "idle", "archived"]);
43
+
44
+ function isTerminalSessionStatus(status) {
45
+ return TERMINAL_SESSION_STATUSES.has(String(status || "").trim().toLowerCase());
46
+ }
47
+
48
+ function randomToken(length = 8) {
49
+ return randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
50
+ }
51
+
52
+ function resolveSessionTrackerPersistDir(options = {}) {
53
+ if (options.persistDir !== undefined) {
54
+ return options.persistDir;
55
+ }
56
+ return isTestRuntime() ? null : SESSIONS_DIR;
57
+ }
38
58
 
39
59
  function resolveSessionMaxMessages(type, metadata, explicitMax, fallbackMax) {
40
60
  if (Number.isFinite(explicitMax)) {
@@ -173,6 +193,7 @@ export class SessionTracker {
173
193
  taskId,
174
194
  taskTitle,
175
195
  id: taskId,
196
+ sessionKey: `${taskId}:${Date.now()}:${randomToken(8)}`,
176
197
  type: "task",
177
198
  maxMessages: this.#maxMessages,
178
199
  startedAt: Date.now(),
@@ -183,6 +204,7 @@ export class SessionTracker {
183
204
  totalEvents: 0,
184
205
  turnCount: 0,
185
206
  status: "active",
207
+ accumulatedAt: null,
186
208
  lastActivityAt: Date.now(),
187
209
  metadata: {},
188
210
  insights: buildSessionInsights({ messages: [] }),
@@ -246,7 +268,7 @@ export class SessionTracker {
246
268
  // Direct message format (role/content)
247
269
  if (event && event.role && event.content !== undefined) {
248
270
  const msg = {
249
- id: event.id || `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
271
+ id: event.id || `msg-${Date.now()}-${randomToken(6)}`,
250
272
  type: event.type || undefined,
251
273
  role: event.role,
252
274
  content: String(event.content).slice(0, MAX_MESSAGE_CHARS),
@@ -303,25 +325,8 @@ export class SessionTracker {
303
325
  session.endedAt = Date.now();
304
326
  session.status = status;
305
327
  this.#refreshDerivedState(session);
328
+ this.#accumulateCompletedSession(session, taskId);
306
329
  this.#markDirty(taskId);
307
-
308
- // Lazy import to avoid top-level await issues
309
- import("./runtime-accumulator.mjs")
310
- .then((module) => {
311
- if (module.addCompletedSession) {
312
- module.addCompletedSession({
313
- id: taskId,
314
- taskId: taskId,
315
- taskTitle: session.taskTitle,
316
- executor: session.executor,
317
- model: session.model,
318
- startedAt: session.startedAt,
319
- endedAt: session.endedAt,
320
- status: status,
321
- });
322
- }
323
- })
324
- .catch(() => {});
325
330
  }
326
331
 
327
332
  /**
@@ -485,6 +490,8 @@ export class SessionTracker {
485
490
  * @param {string} taskId
486
491
  */
487
492
  removeSession(taskId) {
493
+ const session = this.#sessions.get(taskId);
494
+ this.#accumulateCompletedSession(session, taskId);
488
495
  this.#sessions.delete(taskId);
489
496
  this.#dirty.delete(taskId);
490
497
  // Remove persisted session file if it exists
@@ -516,7 +523,7 @@ export class SessionTracker {
516
523
  * Create a new session with explicit options.
517
524
  * @param {{ id: string, type?: string, taskId?: string, metadata?: Object }} opts
518
525
  */
519
- createSession({ id, type = "manual", taskId, metadata = {}, maxMessages }) {
526
+ createSession({ id, type = "manual", taskId, metadata = {}, maxMessages, sessionKey }) {
520
527
  // Evict oldest non-active sessions if at capacity
521
528
  if (this.#sessions.size >= MAX_SESSIONS && !this.#sessions.has(id)) {
522
529
  this.#evictOldest();
@@ -533,6 +540,9 @@ export class SessionTracker {
533
540
  id,
534
541
  taskId: taskId || id,
535
542
  taskTitle: metadata.title || id,
543
+ sessionKey:
544
+ String(sessionKey || "").trim() ||
545
+ `${taskId || id}:${Date.now()}:${randomToken(8)}`,
536
546
  type,
537
547
  status: "active",
538
548
  createdAt: now,
@@ -543,6 +553,7 @@ export class SessionTracker {
543
553
  totalEvents: 0,
544
554
  turnCount: 0,
545
555
  lastActivityAt: Date.now(),
556
+ accumulatedAt: null,
546
557
  metadata,
547
558
  maxMessages: resolvedMax,
548
559
  insights: buildSessionInsights({ messages: [] }),
@@ -611,10 +622,11 @@ export class SessionTracker {
611
622
  const session = this.#sessions.get(sessionId);
612
623
  if (!session) return;
613
624
  session.status = status;
614
- if (status === "completed" || status === "archived") {
625
+ if (status === "completed" || status === "archived" || status === "failed" || status === "idle") {
615
626
  session.endedAt = Date.now();
616
627
  }
617
628
  this.#refreshDerivedState(session);
629
+ this.#accumulateCompletedSession(session, sessionId);
618
630
  this.#markDirty(sessionId);
619
631
  }
620
632
 
@@ -687,7 +699,7 @@ export class SessionTracker {
687
699
  return { ok: false, error: "Only user messages can be edited" };
688
700
  }
689
701
 
690
- target.id = target.id || `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
702
+ target.id = target.id || `msg-${Date.now()}-${randomToken(6)}`;
691
703
  target.content = nextContent.slice(0, MAX_MESSAGE_CHARS);
692
704
  target.edited = true;
693
705
  target.editedAt = new Date().toISOString();
@@ -807,6 +819,7 @@ export class SessionTracker {
807
819
  const type = event._sessionType || "task";
808
820
  this.createSession({
809
821
  id: taskId,
822
+ sessionKey: `${taskId}:${Date.now()}:${randomToken(8)}`,
810
823
  type,
811
824
  taskId,
812
825
  metadata: { autoCreated: true },
@@ -828,7 +841,8 @@ export class SessionTracker {
828
841
  return (a[1].lastActivityAt || a[1].startedAt) - (b[1].lastActivityAt || b[1].startedAt);
829
842
  });
830
843
  const toEvict = sorted.slice(0, evictCount);
831
- for (const [id] of toEvict) {
844
+ for (const [id, session] of toEvict) {
845
+ this.#accumulateCompletedSession(session, id);
832
846
  this.#sessions.delete(id);
833
847
  }
834
848
  }
@@ -848,6 +862,7 @@ export class SessionTracker {
848
862
  session.status = "completed";
849
863
  session.endedAt = now;
850
864
  this.#refreshDerivedState(session);
865
+ this.#accumulateCompletedSession(session, id);
851
866
  this.#markDirty(id);
852
867
  reaped++;
853
868
  }
@@ -871,6 +886,43 @@ export class SessionTracker {
871
886
  }
872
887
  }
873
888
 
889
+ #accumulateCompletedSession(session, fallbackTaskId = "") {
890
+ if (!session || session.accumulatedAt) return false;
891
+ if (!isTerminalSessionStatus(session.status)) return false;
892
+ const taskId = String(session.taskId || session.id || fallbackTaskId || "").trim();
893
+ if (!taskId) return false;
894
+
895
+ const now = Date.now();
896
+ const endedAt = Number.isFinite(Number(session.endedAt)) && Number(session.endedAt) > 0
897
+ ? Number(session.endedAt)
898
+ : now;
899
+ const startedAt = Number.isFinite(Number(session.startedAt))
900
+ ? Number(session.startedAt)
901
+ : endedAt;
902
+ const tokenUsage = session.insights?.tokenUsage || null;
903
+
904
+ addCompletedSession({
905
+ id: session.id || taskId,
906
+ sessionId: session.id || taskId,
907
+ sessionKey: session.sessionKey || `${taskId}:${startedAt}:${endedAt}`,
908
+ taskId,
909
+ taskTitle: session.taskTitle,
910
+ executor: session.executor,
911
+ model: session.model,
912
+ startedAt,
913
+ endedAt,
914
+ durationMs: Math.max(0, endedAt - startedAt),
915
+ tokenCount: tokenUsage?.totalTokens || 0,
916
+ inputTokens: tokenUsage?.inputTokens || 0,
917
+ outputTokens: tokenUsage?.outputTokens || 0,
918
+ tokenUsage,
919
+ insights: session.insights || null,
920
+ status: String(session.status || "completed"),
921
+ });
922
+ session.accumulatedAt = new Date().toISOString();
923
+ return true;
924
+ }
925
+
874
926
  #refreshDerivedState(session) {
875
927
  if (!session) return;
876
928
  try {
@@ -908,10 +960,14 @@ export class SessionTracker {
908
960
  taskId: session.taskId,
909
961
  title: session.taskTitle || session.title || null,
910
962
  taskTitle: session.taskTitle || null,
963
+ sessionKey: session.sessionKey || null,
911
964
  type: session.type || "task",
912
965
  status: session.status,
913
966
  createdAt: session.createdAt || new Date(session.startedAt).toISOString(),
914
967
  lastActiveAt: session.lastActiveAt || new Date(session.lastActivityAt).toISOString(),
968
+ startedAt: session.startedAt || null,
969
+ endedAt: session.endedAt || null,
970
+ accumulatedAt: session.accumulatedAt || null,
915
971
  turnCount: session.turnCount || 0,
916
972
  messages: session.messages || [],
917
973
  metadata: session.metadata || {},
@@ -978,20 +1034,30 @@ export class SessionTracker {
978
1034
  this.#sessions.set(id, {
979
1035
  id,
980
1036
  taskId: data.taskId || id,
981
- taskTitle: data.metadata?.title || id,
1037
+ taskTitle: data.taskTitle || data.title || data.metadata?.title || id,
1038
+ sessionKey:
1039
+ String(data.sessionKey || "").trim() ||
1040
+ `${data.taskId || id}:${data.startedAt || Date.now()}:${endedAt || data.startedAt || Date.now()}`,
982
1041
  type: data.type || "task",
983
1042
  status,
984
1043
  createdAt: data.createdAt || new Date().toISOString(),
985
1044
  lastActiveAt: data.lastActiveAt || new Date().toISOString(),
986
- startedAt: data.createdAt ? new Date(data.createdAt).getTime() : Date.now(),
1045
+ startedAt: data.startedAt || (data.createdAt ? new Date(data.createdAt).getTime() : Date.now()),
987
1046
  endedAt,
988
1047
  messages: data.messages || [],
989
1048
  totalEvents: (data.messages || []).length,
990
1049
  turnCount: data.turnCount || 0,
1050
+ accumulatedAt: data.accumulatedAt || null,
991
1051
  lastActivityAt: lastActive || Date.now(),
992
1052
  metadata: data.metadata || {},
993
1053
  insights: data.insights || buildSessionInsights({ messages: data.messages || [] }),
994
1054
  });
1055
+ const restored = this.#sessions.get(id);
1056
+ if (restored && isTerminalSessionStatus(restored.status) && !restored.accumulatedAt) {
1057
+ if (this.#accumulateCompletedSession(restored, id)) {
1058
+ this.#markDirty(id);
1059
+ }
1060
+ }
995
1061
  }
996
1062
  } catch {
997
1063
  // Directory read failed — proceed without disk data
@@ -1524,9 +1590,10 @@ let _instance = null;
1524
1590
  */
1525
1591
  export function getSessionTracker(options) {
1526
1592
  if (!_instance) {
1593
+ const persistDir = resolveSessionTrackerPersistDir(options || {});
1527
1594
  _instance = new SessionTracker({
1528
- persistDir: SESSIONS_DIR,
1529
1595
  ...options,
1596
+ persistDir,
1530
1597
  });
1531
1598
  console.log(`${TAG} initialized (maxMessages=${_instance.getStats ? DEFAULT_MAX_MESSAGES : "?"})`);
1532
1599
  }
@@ -0,0 +1,267 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, relative, resolve } from "node:path";
4
+
5
+ const TEST_RUNTIME_ENV_KEYS = [
6
+ "VITEST",
7
+ "VITEST_POOL_ID",
8
+ "VITEST_WORKER_ID",
9
+ "JEST_WORKER_ID",
10
+ ];
11
+
12
+ const TEST_GIT_IDENTITY_KEYS = [
13
+ "GIT_AUTHOR_NAME",
14
+ "GIT_AUTHOR_EMAIL",
15
+ "GIT_COMMITTER_NAME",
16
+ "GIT_COMMITTER_EMAIL",
17
+ "VE_GIT_AUTHOR_NAME",
18
+ "VE_GIT_AUTHOR_EMAIL",
19
+ ];
20
+
21
+ let cachedSandboxContext = null;
22
+
23
+ function sanitizeToken(value, fallback) {
24
+ const normalized = String(value || "")
25
+ .trim()
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9._-]+/g, "-")
28
+ .replace(/^-+|-+$/g, "");
29
+ return normalized || fallback;
30
+ }
31
+
32
+ function pathsEqual(left, right) {
33
+ const a = resolve(String(left || ""));
34
+ const b = resolve(String(right || ""));
35
+ if (process.platform === "win32") {
36
+ return a.toLowerCase() === b.toLowerCase();
37
+ }
38
+ return a === b;
39
+ }
40
+
41
+ function getWorkerToken() {
42
+ return sanitizeToken(
43
+ process.env.VITEST_POOL_ID ||
44
+ process.env.VITEST_WORKER_ID ||
45
+ process.env.JEST_WORKER_ID ||
46
+ process.pid,
47
+ "default",
48
+ );
49
+ }
50
+
51
+ function buildDefaultSandboxRoot() {
52
+ return resolve(
53
+ tmpdir(),
54
+ "bosun-test-sandbox",
55
+ getWorkerToken(),
56
+ );
57
+ }
58
+
59
+ function buildSandboxContext(rootDir) {
60
+ const sandboxRoot = resolve(rootDir);
61
+ const configDir = resolve(sandboxRoot, "bosun-home");
62
+ const homeDir = resolve(sandboxRoot, "home");
63
+ const appDataDir = resolve(homeDir, "AppData", "Roaming");
64
+ const localAppDataDir = resolve(homeDir, "AppData", "Local");
65
+ const xdgConfigDir = resolve(homeDir, ".config");
66
+ const bosunDataDir = resolve(configDir, ".bosun");
67
+ const workflowDir = resolve(bosunDataDir, "workflows");
68
+ const runsDir = resolve(bosunDataDir, "workflow-runs");
69
+ const cacheDir = resolve(bosunDataDir, ".cache");
70
+ const gitGlobalConfigPath = resolve(homeDir, ".gitconfig");
71
+
72
+ return {
73
+ sandboxRoot,
74
+ configDir,
75
+ homeDir,
76
+ appDataDir,
77
+ localAppDataDir,
78
+ xdgConfigDir,
79
+ workflowDir,
80
+ runsDir,
81
+ cacheDir,
82
+ gitGlobalConfigPath,
83
+ };
84
+ }
85
+
86
+ function ensureDir(path) {
87
+ mkdirSync(path, { recursive: true });
88
+ }
89
+
90
+ function ensureSandboxFiles(context) {
91
+ ensureDir(context.sandboxRoot);
92
+ ensureDir(context.configDir);
93
+ ensureDir(context.homeDir);
94
+ ensureDir(context.appDataDir);
95
+ ensureDir(context.localAppDataDir);
96
+ ensureDir(context.xdgConfigDir);
97
+ ensureDir(context.workflowDir);
98
+ ensureDir(context.runsDir);
99
+ ensureDir(context.cacheDir);
100
+ if (!existsSync(context.gitGlobalConfigPath)) {
101
+ ensureDir(dirname(context.gitGlobalConfigPath));
102
+ writeFileSync(context.gitGlobalConfigPath, "", "utf8");
103
+ }
104
+ }
105
+
106
+ function setEnvValue(key, value, force) {
107
+ if (!force && process.env[key]) return;
108
+ process.env[key] = value;
109
+ }
110
+
111
+ export function isTestRuntime() {
112
+ if (process.env.BOSUN_TEST_SANDBOX === "1") return true;
113
+ for (const key of TEST_RUNTIME_ENV_KEYS) {
114
+ if (process.env[key]) return true;
115
+ }
116
+ if (process.env.NODE_ENV === "test") return true;
117
+ const argv = Array.isArray(process.argv)
118
+ ? process.argv.join(" ").toLowerCase()
119
+ : "";
120
+ return argv.includes("vitest") || argv.includes("--test");
121
+ }
122
+
123
+ function isPathInside(parentPath, childPath) {
124
+ const parent = resolve(String(parentPath || ""));
125
+ const child = resolve(String(childPath || ""));
126
+ const rel = relative(parent, child);
127
+ if (!rel) return true;
128
+ return !rel.startsWith("..") && rel !== "..";
129
+ }
130
+
131
+ export function ensureTestRuntimeSandbox(options = {}) {
132
+ if (!isTestRuntime()) return null;
133
+ const force = options.force === true;
134
+ const requestedRoot =
135
+ options.rootDir ||
136
+ process.env.BOSUN_TEST_SANDBOX_ROOT ||
137
+ buildDefaultSandboxRoot();
138
+ const context =
139
+ cachedSandboxContext && pathsEqual(cachedSandboxContext.sandboxRoot, requestedRoot)
140
+ ? cachedSandboxContext
141
+ : buildSandboxContext(requestedRoot);
142
+ cachedSandboxContext = context;
143
+
144
+ ensureSandboxFiles(context);
145
+
146
+ setEnvValue("BOSUN_TEST_SANDBOX", "1", force);
147
+ setEnvValue("BOSUN_TEST_SANDBOX_ROOT", context.sandboxRoot, force);
148
+ setEnvValue("GIT_CONFIG_GLOBAL", context.gitGlobalConfigPath, force);
149
+ setEnvValue("GIT_CONFIG_NOSYSTEM", "1", force);
150
+
151
+ if (force) {
152
+ for (const key of TEST_GIT_IDENTITY_KEYS) {
153
+ delete process.env[key];
154
+ }
155
+ }
156
+
157
+ return context;
158
+ }
159
+
160
+ export function getTestRuntimeSandbox() {
161
+ if (cachedSandboxContext) return cachedSandboxContext;
162
+ return ensureTestRuntimeSandbox();
163
+ }
164
+
165
+ export function isSafeTestFilesystemPath(candidatePath) {
166
+ if (!candidatePath) return false;
167
+ const sandbox = getTestRuntimeSandbox();
168
+ const safeRoots = [tmpdir(), sandbox?.sandboxRoot].filter(Boolean);
169
+ return safeRoots.some((root) => isPathInside(root, candidatePath));
170
+ }
171
+
172
+ function getGitSubcommand(args = []) {
173
+ const values = Array.isArray(args) ? [...args] : [];
174
+ for (let i = 0; i < values.length; i++) {
175
+ const token = String(values[i] || "").trim();
176
+ if (!token) continue;
177
+ if (token === "-C" || token === "-c" || token === "--git-dir" || token === "--work-tree") {
178
+ i += 1;
179
+ continue;
180
+ }
181
+ if (token.startsWith("-")) continue;
182
+ return {
183
+ command: token,
184
+ rest: values.slice(i + 1).map((value) => String(value || "").trim()),
185
+ };
186
+ }
187
+ return { command: "", rest: [] };
188
+ }
189
+
190
+ function isDestructiveGitArgs(args = []) {
191
+ const { command, rest } = getGitSubcommand(args);
192
+ switch (command) {
193
+ case "add":
194
+ case "am":
195
+ case "apply":
196
+ return true;
197
+ case "branch":
198
+ if (rest.some((value) => value === "--show-current" || value === "--list" || value === "-l")) {
199
+ return false;
200
+ }
201
+ return true;
202
+ case "checkout":
203
+ case "cherry-pick":
204
+ case "clean":
205
+ case "commit":
206
+ case "fetch":
207
+ case "merge":
208
+ case "pull":
209
+ case "push":
210
+ case "rebase":
211
+ case "reset":
212
+ case "restore":
213
+ case "stash":
214
+ case "switch":
215
+ case "tag":
216
+ return true;
217
+ case "config":
218
+ return !rest.includes("--get");
219
+ case "remote":
220
+ return rest.some((value) => ["add", "remove", "rm", "rename", "set-head", "set-branches", "set-url"].includes(value));
221
+ case "worktree":
222
+ return rest.some((value) => ["add", "move", "lock", "remove", "prune", "repair", "unlock"].includes(value));
223
+ default:
224
+ return false;
225
+ }
226
+ }
227
+
228
+ const DESTRUCTIVE_GIT_SHELL_RE =
229
+ /(^|[;&|]\s*)git\s+(?:-[^\s]+\s+)*(?:add|am|apply|branch(?!\s+(?:--show-current|--list|-l)\b)|checkout|cherry-pick|clean|commit|config(?!\s+--get\b)|fetch|merge|pull|push|rebase|reset|remote\s+(?:add|remove|rm|rename|set-head|set-branches|set-url)\b|restore|stash|switch|tag|worktree\s+(?:add|move|lock|remove|prune|repair|unlock)\b)/i;
230
+
231
+ function isDestructiveGitShell(command) {
232
+ return DESTRUCTIVE_GIT_SHELL_RE.test(String(command || ""));
233
+ }
234
+
235
+ function describeGitInvocation(command, args = []) {
236
+ if (Array.isArray(args) && args.length > 0) {
237
+ return [String(command || ""), ...args.map((value) => String(value || ""))]
238
+ .filter(Boolean)
239
+ .join(" ");
240
+ }
241
+ return String(command || "").trim();
242
+ }
243
+
244
+ export function assertSafeGitMutationInTests({ command = "", args = [], cwd = process.cwd() } = {}) {
245
+ if (!isTestRuntime()) return;
246
+ const isGitBinary = /(?:^|[/\\])git(?:\.exe)?$/i.test(String(command || "").trim());
247
+ const destructive =
248
+ Array.isArray(args) && args.length > 0
249
+ ? isGitBinary && isDestructiveGitArgs(args)
250
+ : isDestructiveGitShell(command);
251
+ if (!destructive) return;
252
+ const resolvedCwd = resolve(String(cwd || process.cwd()));
253
+ if (isSafeTestFilesystemPath(resolvedCwd)) return;
254
+ throw new Error(
255
+ `[test-runtime] blocked destructive git command outside sandbox/temp path: ${describeGitInvocation(command, args)} (cwd: ${resolvedCwd})`,
256
+ );
257
+ }
258
+
259
+ export function resolvePathForTestRuntime(candidatePath, persistentPath, sandboxPath) {
260
+ const resolvedCandidate = resolve(String(candidatePath || ""));
261
+ if (!isTestRuntime()) return resolvedCandidate;
262
+ if (!persistentPath || !sandboxPath) return resolvedCandidate;
263
+ const resolvedPersistent = resolve(String(persistentPath));
264
+ if (!pathsEqual(resolvedCandidate, resolvedPersistent)) return resolvedCandidate;
265
+ ensureTestRuntimeSandbox();
266
+ return resolve(String(sandboxPath));
267
+ }