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.
- package/.env.example +8 -0
- package/README.md +20 -0
- package/agent/agent-custom-tools.mjs +23 -5
- package/agent/agent-event-bus.mjs +248 -6
- package/agent/agent-pool.mjs +131 -30
- package/agent/agent-work-analyzer.mjs +8 -16
- package/agent/primary-agent.mjs +81 -7
- package/agent/retry-queue.mjs +164 -0
- package/bench/swebench/bosun-swebench.mjs +5 -0
- package/bosun.config.example.json +25 -0
- package/bosun.schema.json +825 -183
- package/cli.mjs +267 -8
- package/config/config-doctor.mjs +51 -2
- package/config/config.mjs +232 -5
- package/github/github-auth-manager.mjs +70 -19
- package/infra/library-manager.mjs +894 -60
- package/infra/monitor.mjs +701 -69
- package/infra/runtime-accumulator.mjs +376 -84
- package/infra/session-tracker.mjs +95 -28
- package/infra/test-runtime.mjs +267 -0
- package/lib/codebase-audit.mjs +133 -18
- package/package.json +30 -8
- package/server/setup-web-server.mjs +29 -1
- package/server/ui-server.mjs +1571 -49
- package/setup.mjs +27 -24
- package/shell/codex-shell.mjs +34 -3
- package/shell/copilot-shell.mjs +50 -8
- package/task/msg-hub.mjs +193 -0
- package/task/pipeline.mjs +544 -0
- package/task/task-claims.mjs +6 -10
- package/task/task-cli.mjs +38 -2
- package/task/task-executor-pipeline.mjs +143 -0
- package/task/task-executor.mjs +36 -27
- package/telegram/get-telegram-chat-id.mjs +57 -47
- package/ui/components/chat-view.js +18 -1
- package/ui/components/workspace-switcher.js +321 -9
- package/ui/demo-defaults.js +17830 -10433
- package/ui/demo.html +9 -1
- package/ui/modules/router.js +1 -1
- package/ui/modules/settings-schema.js +2 -0
- package/ui/modules/state.js +54 -57
- package/ui/modules/voice-client-sdk.js +376 -37
- package/ui/modules/voice-client.js +173 -33
- package/ui/setup.html +68 -2
- package/ui/styles/components.css +571 -1
- package/ui/styles.css +201 -1
- package/ui/tabs/dashboard.js +74 -0
- package/ui/tabs/library.js +410 -55
- package/ui/tabs/logs.js +10 -0
- package/ui/tabs/settings.js +178 -99
- package/ui/tabs/tasks.js +1083 -507
- package/ui/tabs/telemetry.js +34 -0
- package/ui/tabs/workflow-canvas-utils.mjs +38 -1
- package/ui/tabs/workflows.js +1275 -402
- package/voice/voice-agents-sdk.mjs +2 -2
- package/voice/voice-relay.mjs +28 -20
- package/workflow/declarative-workflows.mjs +145 -0
- package/workflow/msg-hub.mjs +237 -0
- package/workflow/pipeline-workflows.mjs +287 -0
- package/workflow/pipeline.mjs +828 -315
- package/workflow/project-detection.mjs +559 -0
- package/workflow/workflow-cli.mjs +128 -0
- package/workflow/workflow-contract.mjs +433 -232
- package/workflow/workflow-engine.mjs +510 -47
- package/workflow/workflow-nodes/custom-loader.mjs +251 -0
- package/workflow/workflow-nodes.mjs +2024 -184
- package/workflow/workflow-templates.mjs +118 -24
- package/workflow-templates/agents.mjs +20 -20
- package/workflow-templates/bosun-native.mjs +212 -2
- package/workflow-templates/code-quality.mjs +20 -14
- package/workflow-templates/continuation-loop.mjs +339 -0
- package/workflow-templates/github.mjs +516 -40
- package/workflow-templates/planning.mjs +446 -17
- package/workflow-templates/reliability.mjs +65 -12
- package/workflow-templates/task-batch.mjs +27 -10
- package/workflow-templates/task-execution.mjs +752 -0
- package/workflow-templates/task-lifecycle.mjs +117 -14
- package/workspace/context-cache.mjs +66 -18
- package/workspace/workspace-manager.mjs +153 -1
- 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
|
|
25
|
-
|
|
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
|
-
|
|
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()}-${
|
|
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
|
+
}
|