pi-crew 0.9.41 → 0.9.44
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/CHANGELOG.md +253 -0
- package/README.md +14 -0
- package/dist/build-meta.json +6450 -12419
- package/dist/index.mjs +57998 -68417
- package/dist/index.mjs.map +4 -4
- package/docs/migration/atomic-write-v2-migration.md +2 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
- package/package.json +4 -2
- package/scripts/README.md +6 -0
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/check-all-skills.ts +3 -3
- package/scripts/dev-runner.mjs +106 -0
- package/scripts/postinstall.mjs +6 -2
- package/scripts/verify-skill.ts +2 -2
- package/src/agents/agent-config.ts +1 -1
- package/src/config/config.ts +20 -1
- package/src/config/drift-detector.ts +97 -15
- package/src/config/resilient-parser.ts +4 -4
- package/src/config/types.ts +0 -2
- package/src/extension/crew-cleanup.ts +17 -3
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/font-detect.ts +16 -3
- package/src/extension/crew-vibes/index.ts +1 -1
- package/src/extension/cross-extension-rpc.ts +26 -9
- package/src/extension/management.ts +6 -5
- package/src/extension/register.ts +58 -1573
- package/src/extension/registration/command-registration.ts +57 -0
- package/src/extension/registration/commands.ts +7 -2
- package/src/extension/registration/context-builder.ts +131 -0
- package/src/extension/registration/crash-recovery-cache.ts +54 -0
- package/src/extension/registration/foreground-run-controller.ts +238 -0
- package/src/extension/registration/hook-registration.ts +119 -0
- package/src/extension/registration/lazy-configurers.ts +124 -0
- package/src/extension/registration/lifecycle-handlers.ts +731 -0
- package/src/extension/registration/observability.ts +33 -4
- package/src/extension/registration/registration-types.ts +149 -0
- package/src/extension/registration/runtime-cleanup.ts +230 -0
- package/src/extension/registration/subagent-manager-setup.ts +211 -0
- package/src/extension/registration/tool-registration.ts +50 -0
- package/src/extension/registration/wire-cross-extension.ts +37 -0
- package/src/extension/rpc-hmac.ts +21 -1
- package/src/extension/run-import.ts +3 -3
- package/src/extension/team-tool/auto-summarize.ts +4 -4
- package/src/extension/team-tool/cancel.ts +5 -5
- package/src/extension/team-tool/chain-executor.ts +5 -4
- package/src/extension/team-tool/context.ts +11 -1
- package/src/extension/team-tool/doctor.ts +2 -1
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +25 -36
- package/src/extension/team-tool/orchestrate.ts +0 -1
- package/src/extension/team-tool/parallel-dispatch.ts +5 -0
- package/src/extension/team-tool/run.ts +220 -214
- package/src/extension/team-tool/workflow-manage.ts +1 -1
- package/src/extension/team-tool.ts +69 -16
- package/src/hooks/registry.ts +7 -2
- package/src/observability/correlation.ts +4 -3
- package/src/observability/event-bus.ts +10 -1
- package/src/observability/event-to-metric.ts +8 -7
- package/src/observability/exporters/adapter.ts +6 -9
- package/src/observability/exporters/otlp-exporter.ts +81 -0
- package/src/observability/metric-retention.ts +4 -0
- package/src/observability/metric-sink.ts +14 -4
- package/src/prompt/prompt-runtime.ts +15 -0
- package/src/runtime/adaptive-plan.ts +1 -1
- package/src/runtime/async-runner.ts +13 -4
- package/src/runtime/background-runner.ts +32 -23
- package/src/runtime/capability-inventory.ts +1 -1
- package/src/runtime/chain-runner.ts +5 -5
- package/src/runtime/checkpoint.ts +6 -16
- package/src/runtime/child-pi-constants.ts +42 -0
- package/src/runtime/child-pi-kill.ts +180 -0
- package/src/runtime/child-pi-spawn.ts +234 -0
- package/src/runtime/child-pi-steering.ts +128 -0
- package/src/runtime/child-pi-streams.ts +296 -0
- package/src/runtime/child-pi-transcript.ts +169 -0
- package/src/runtime/child-pi.ts +80 -822
- package/src/runtime/coalesce-tasks.ts +1 -1
- package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
- package/src/runtime/crash-recovery.ts +63 -47
- package/src/runtime/delivery-coordinator.ts +1 -1
- package/src/runtime/deterministic-ast.ts +2 -2
- package/src/runtime/diagnostic-export.ts +2 -1
- package/src/runtime/dwf-state-store.ts +5 -0
- package/src/runtime/dynamic-workflow-context.ts +78 -18
- package/src/runtime/dynamic-workflow-runner.ts +18 -2
- package/src/runtime/foreground-control.ts +3 -2
- package/src/runtime/goal-evaluator.ts +59 -27
- package/src/runtime/goal-loop-runner.ts +46 -13
- package/src/runtime/goal-state-store.ts +1 -1
- package/src/runtime/handoff-manager.ts +0 -2
- package/src/runtime/intercom-bridge.ts +0 -2
- package/src/runtime/live-agent-manager.ts +30 -5
- package/src/runtime/live-extension-bridge.ts +0 -2
- package/src/runtime/live-session-runtime.ts +65 -57
- package/src/runtime/manifest-cache.ts +59 -0
- package/src/runtime/mcp-proxy.ts +1 -1
- package/src/runtime/orphan-worker-registry.ts +3 -3
- package/src/runtime/output-validator.ts +1 -1
- package/src/runtime/path-overlap.ts +1 -1
- package/src/runtime/pi-args.ts +3 -3
- package/src/runtime/pi-json-output.ts +4 -3
- package/src/runtime/pi-spawn.ts +7 -1
- package/src/runtime/pipeline-runner.ts +1 -4
- package/src/runtime/post-checks.ts +0 -1
- package/src/runtime/retry-runner.ts +1 -1
- package/src/runtime/run-coalesced-task-group.ts +18 -23
- package/src/runtime/scheduler.ts +2 -0
- package/src/runtime/session-resources.ts +1 -1
- package/src/runtime/settings-store.ts +31 -1
- package/src/runtime/skill-effectiveness.ts +2 -2
- package/src/runtime/stale-reconciler.ts +23 -53
- package/src/runtime/subagent-manager.ts +19 -3
- package/src/runtime/task-output-context.ts +2 -1
- package/src/runtime/task-runner/prompt-builder.ts +3 -3
- package/src/runtime/task-runner/run-projection.ts +3 -1
- package/src/runtime/task-runner/state-helpers.ts +13 -1
- package/src/runtime/task-runner.ts +31 -6
- package/src/runtime/team-runner.ts +8 -2
- package/src/runtime/verification-gates.ts +54 -5
- package/src/schema/config-schema.ts +19 -1
- package/src/schema/team-tool-schema.ts +2 -0
- package/src/skills/discover-skills.ts +25 -15
- package/src/state/active-run-registry.ts +0 -1
- package/src/state/atomic-write.ts +37 -11
- package/src/state/blob-store.ts +3 -9
- package/src/state/contracts.ts +5 -1
- package/src/state/crew-init.ts +3 -2
- package/src/state/decision-ledger.ts +2 -2
- package/src/state/event-log-rotation.ts +1 -1
- package/src/state/event-log.ts +4 -3
- package/src/state/gitignore-manager.ts +2 -1
- package/src/state/health-store.ts +5 -2
- package/src/state/instinct-store.ts +11 -2
- package/src/state/locks.ts +132 -49
- package/src/state/mailbox.ts +3 -4
- package/src/state/observation-store.ts +1 -13
- package/src/state/run-cache.ts +12 -12
- package/src/state/run-graph.ts +2 -1
- package/src/state/run-metrics.ts +1 -1
- package/src/state/schedule.ts +7 -1
- package/src/state/state-store.ts +9 -3
- package/src/state/tiered-eval.ts +1 -1
- package/src/state/worker-atomic-writer.ts +1 -0
- package/src/ui/dynamic-border.ts +13 -3
- package/src/ui/live-conversation-overlay.ts +7 -27
- package/src/ui/live-duration.ts +2 -2
- package/src/ui/live-run-sidebar.ts +56 -32
- package/src/ui/mascot.ts +19 -13
- package/src/ui/powerbar-publisher.ts +244 -178
- package/src/ui/render-coalescer.ts +44 -3
- package/src/ui/render-diff.ts +2 -2
- package/src/ui/run-dashboard.ts +122 -21
- package/src/ui/run-event-bus.ts +66 -0
- package/src/ui/run-snapshot-cache.ts +84 -81
- package/src/ui/settings-overlay.ts +3 -3
- package/src/ui/status-colors.ts +24 -0
- package/src/ui/tool-progress-formatter.ts +1 -1
- package/src/ui/tool-render.ts +3 -3
- package/src/ui/transcript-cache.ts +5 -10
- package/src/ui/transcript-entries.ts +68 -72
- package/src/ui/transcript-viewer.ts +54 -14
- package/src/ui/widget/index.ts +26 -15
- package/src/ui/widget/widget-model.ts +18 -9
- package/src/ui/widget/widget-renderer.ts +2 -5
- package/src/utils/fingerprint.ts +1 -1
- package/src/utils/paths.ts +1 -1
- package/src/utils/project-detector.ts +1 -1
- package/src/worktree/worktree-manager.ts +47 -19
- package/src/state/atomic-write-v2.ts +0 -108
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* run-coalesced-task-group.ts — M6 real-dispatch MVP worker spawn.
|
|
3
|
-
*
|
|
4
|
-
* Spawns ONE child Pi process for an entire coalesced group of N tasks
|
|
5
|
-
* (sharing role + cwd per `planCoalescedGroups`). The combined prompt
|
|
6
|
-
* instructs the worker to wrap each task's result in `<<<TASK_RESULT:id>>>`
|
|
7
|
-
* ... `<<<END_TASK_RESULT>>>` delimiters. Output is split back into N
|
|
8
|
-
* per-task results via `splitCoalescedOutput`, each written to its own
|
|
9
|
-
* result artifact + state update.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { writeFile } from "node:fs/promises";
|
|
13
|
-
import path from "node:path";
|
|
14
1
|
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
15
2
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
16
3
|
import { appendEventAsync } from "../state/event-log.ts";
|
|
@@ -18,8 +5,10 @@ import { saveRunTasksAsync, updateRunStatus } from "../state/state-store.ts";
|
|
|
18
5
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
19
6
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
20
7
|
import { runChildPi } from "./child-pi.ts";
|
|
8
|
+
import { DEFAULT_RETRY_POLICY, executeWithRetry } from "./retry-executor.ts";
|
|
21
9
|
import { permissionForRole } from "./role-permission.ts";
|
|
22
10
|
import type { CrewRuntimeMode } from "./runtime-resolver.ts";
|
|
11
|
+
import { sanitizeTaskText } from "./task-packet.ts";
|
|
23
12
|
import { splitCoalescedOutput } from "./task-runner/output-splitter.ts";
|
|
24
13
|
import { mergeArtifacts } from "./team-runner-artifacts.ts";
|
|
25
14
|
import { createWorkerHeartbeat, touchWorkerHeartbeat } from "./worker-heartbeat.ts";
|
|
@@ -114,15 +103,21 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
114
103
|
}
|
|
115
104
|
}, 15_000);
|
|
116
105
|
try {
|
|
117
|
-
const result = await
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
106
|
+
const result = await executeWithRetry(
|
|
107
|
+
async () => {
|
|
108
|
+
return await runChildPi({
|
|
109
|
+
cwd: firstTask.cwd,
|
|
110
|
+
task: combinedPrompt,
|
|
111
|
+
agent,
|
|
112
|
+
signal,
|
|
113
|
+
excludeContextBash: true,
|
|
114
|
+
maxTurns: 5,
|
|
115
|
+
onJsonEvent: (e) => input.onJsonEvent?.(firstTask.id, manifest.runId, e),
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
DEFAULT_RETRY_POLICY,
|
|
119
|
+
{ signal },
|
|
120
|
+
);
|
|
126
121
|
rawOutput = result.rawFinalText ?? result.stdout ?? "";
|
|
127
122
|
success = result.exitStatus?.exitCode === 0;
|
|
128
123
|
} catch (err) {
|
|
@@ -206,7 +201,7 @@ async function buildCoalescedPrompt(
|
|
|
206
201
|
`### Task ${idx + 1} of ${groupTasks.length} (id: ${task.id})`,
|
|
207
202
|
`Step: ${step.id}`,
|
|
208
203
|
`Role: ${step.role}`,
|
|
209
|
-
`Task: ${step.task.replaceAll("{goal}", manifest.goal)}`,
|
|
204
|
+
`Task: ${sanitizeTaskText(step.task.replaceAll("{goal}", manifest.goal))}`,
|
|
210
205
|
].join("\n");
|
|
211
206
|
})
|
|
212
207
|
.join("\n\n---\n\n");
|
package/src/runtime/scheduler.ts
CHANGED
|
@@ -111,6 +111,7 @@ export class CrewScheduler {
|
|
|
111
111
|
if (this.timers.has(job.id)) return;
|
|
112
112
|
if (job.scheduleType === "interval" && job.intervalMs) {
|
|
113
113
|
const t = setInterval(() => this.fire(job.id), job.intervalMs);
|
|
114
|
+
t.unref();
|
|
114
115
|
this.timers.set(job.id, t);
|
|
115
116
|
} else if (job.scheduleType === "once") {
|
|
116
117
|
const target = new Date(job.schedule).getTime();
|
|
@@ -120,6 +121,7 @@ export class CrewScheduler {
|
|
|
120
121
|
this.fire(job.id);
|
|
121
122
|
this.update(job.id, { enabled: false });
|
|
122
123
|
}, delay);
|
|
124
|
+
t.unref();
|
|
123
125
|
this.timers.set(job.id, t);
|
|
124
126
|
} else {
|
|
125
127
|
this.update(job.id, { enabled: false, lastStatus: "error" });
|
|
@@ -9,7 +9,7 @@ import { logInternalError } from "../utils/internal-error.ts";
|
|
|
9
9
|
*/
|
|
10
10
|
export function tryRegisterSessionCleanup(pi: ExtensionAPI, cleanup: () => void): (() => void) | undefined {
|
|
11
11
|
const api = pi as unknown as Record<string, unknown>;
|
|
12
|
-
const registerFn = api
|
|
12
|
+
const registerFn = api.registerSessionResourceCleanup;
|
|
13
13
|
if (typeof registerFn === "function") {
|
|
14
14
|
try {
|
|
15
15
|
const unregister = (registerFn as (fn: () => void) => (() => void) | void)(cleanup);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { atomicWriteJson } from "../state/atomic-write.ts";
|
|
5
|
+
import { withFileLockSync } from "../state/locks.ts";
|
|
4
6
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
5
7
|
import type { JoinMode } from "./group-join.ts";
|
|
6
8
|
|
|
@@ -98,13 +100,41 @@ export function saveCrewSettings(s: CrewSettings, cwd: string = process.cwd()):
|
|
|
98
100
|
const p = projectPath(cwd);
|
|
99
101
|
try {
|
|
100
102
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
101
|
-
|
|
103
|
+
// Atomic (temp + rename + fsync) instead of raw writeFileSync: a crash/SIGKILL
|
|
104
|
+
// mid-write would otherwise truncate <cwd>/.pi/crew-settings.json, and the
|
|
105
|
+
// reader silently resets to {} on parse failure — losing ALL crew settings +
|
|
106
|
+
// scheduledJobs.
|
|
107
|
+
atomicWriteJson(p, s);
|
|
102
108
|
return true;
|
|
103
109
|
} catch {
|
|
104
110
|
return false;
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Atomically read-modify-write crew settings under a file lock.
|
|
116
|
+
*
|
|
117
|
+
* Fixes two issues vs a plain loadCrewSettings → saveCrewSettings sequence:
|
|
118
|
+
* 1. Cross-session lost-update race: <cwd>/.pi/crew-settings.json is project-scoped,
|
|
119
|
+
* so two Pi sessions on the same project concurrently scheduling jobs would each
|
|
120
|
+
* load-modify-save and the second save clobbers the first (lost job). The lock
|
|
121
|
+
* serializes the whole transaction.
|
|
122
|
+
* 2. Crash truncation: the write uses atomicWriteJson (temp + rename + fsync).
|
|
123
|
+
*
|
|
124
|
+
* `mutator` receives the freshly-loaded (merged) settings and returns the new value
|
|
125
|
+
* to persist. Returns the persisted settings.
|
|
126
|
+
*/
|
|
127
|
+
export function updateCrewSettings(cwd: string, mutator: (settings: CrewSettings) => CrewSettings): CrewSettings {
|
|
128
|
+
const p = projectPath(cwd);
|
|
129
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
130
|
+
return withFileLockSync(p, () => {
|
|
131
|
+
const fresh = loadCrewSettings(cwd); // re-read inside the lock (TOCTOU-safe vs other updaters)
|
|
132
|
+
const next = mutator(fresh);
|
|
133
|
+
atomicWriteJson(p, next);
|
|
134
|
+
return next;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
108
138
|
export function applyCrewSettingsToConfig(
|
|
109
139
|
config: {
|
|
110
140
|
limits?: { maxConcurrentWorkers?: number };
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* @module skill-effectiveness
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
14
|
-
import { dirname, join } from "path";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
15
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
16
16
|
import { crewHooks } from "./crew-hooks.ts";
|
|
17
17
|
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { errors } from "../errors.ts";
|
|
5
|
-
import { atomicWriteJson } from "../state/atomic-write.ts";
|
|
5
|
+
import { atomicWriteFile, atomicWriteJson } from "../state/atomic-write.ts";
|
|
6
6
|
import { saveRunManifest } from "../state/state-store.ts";
|
|
7
7
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
8
8
|
import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
|
|
@@ -152,29 +152,32 @@ function checkPidLiveness(
|
|
|
152
152
|
}
|
|
153
153
|
// If process is alive per kill(0), we're done.
|
|
154
154
|
if (liveness.alive) return { alive: true, detail: liveness.detail };
|
|
155
|
-
// Process is dead per kill(0).
|
|
155
|
+
// Process is dead per kill(0). ESRCH is AUTHORITATIVE — the PID does not exist.
|
|
156
|
+
// A frozen/zombie process still has a PID (kill(0) returns 0, or EPERM if we lack
|
|
157
|
+
// permission to signal it), so reaching here with alive=false means the process
|
|
158
|
+
// is genuinely gone. PID recycling is already handled above via the startTime
|
|
159
|
+
// TOCTOU check, so the heartbeat file must NOT override the dead verdict.
|
|
160
|
+
//
|
|
161
|
+
// HISTORY: a <5min-old heartbeat.json previously OVERRRODE this verdict (returned
|
|
162
|
+
// alive:true "process dead but heartbeat Xs old"), delaying repair by ~5min.
|
|
163
|
+
// Combined with the 60s reconcile cadence this produced a ~300-360s detection lag
|
|
164
|
+
// for dead workers (observed 353s in production). The heartbeat is now used only
|
|
165
|
+
// to enrich the detail string, never to override the authoritative kill(0) verdict.
|
|
166
|
+
let heartbeatNote = "";
|
|
156
167
|
if (stateRoot) {
|
|
157
|
-
const heartbeatPath = path.join(stateRoot, "heartbeat.json");
|
|
158
168
|
try {
|
|
169
|
+
const heartbeatPath = path.join(stateRoot, "heartbeat.json");
|
|
159
170
|
if (fs.existsSync(heartbeatPath)) {
|
|
160
171
|
const hb = JSON.parse(fs.readFileSync(heartbeatPath, "utf-8")) as { pid?: number; at?: number };
|
|
161
172
|
if (hb?.pid === pid && hb?.at) {
|
|
162
|
-
|
|
163
|
-
// Heartbeat written < 5 min ago → process was alive recently.
|
|
164
|
-
// Don't repair yet; let the next reconciliation cycle catch it.
|
|
165
|
-
if (ageMs < 5 * 60_000) {
|
|
166
|
-
return {
|
|
167
|
-
alive: true,
|
|
168
|
-
detail: `process dead but heartbeat ${Math.round(ageMs / 1000)}s old`,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
173
|
+
heartbeatNote = ` (heartbeat was ${Math.round((Date.now() - hb.at) / 1000)}s old)`;
|
|
171
174
|
}
|
|
172
175
|
}
|
|
173
176
|
} catch {
|
|
174
177
|
/* ignore — best-effort */
|
|
175
178
|
}
|
|
176
179
|
}
|
|
177
|
-
return { alive:
|
|
180
|
+
return { alive: false, detail: `${liveness.detail}${heartbeatNote}` };
|
|
178
181
|
}
|
|
179
182
|
|
|
180
183
|
/**
|
|
@@ -267,11 +270,6 @@ function buildStaleReconcileError(task: TeamTaskState, reason: string): Error {
|
|
|
267
270
|
return errors.runStale(reason, heartbeatAgeSeconds);
|
|
268
271
|
}
|
|
269
272
|
|
|
270
|
-
/** @deprecated use buildStaleReconcileError (returns a structured CrewError). Kept for any external callers. */
|
|
271
|
-
function formatStaleReconcileError(task: TeamTaskState, reason: string): string {
|
|
272
|
-
return buildStaleReconcileError(task, reason).message;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
273
|
function repairStaleRun(manifest: TeamRunManifest, tasks: TeamTaskState[], reason: string): TeamTaskState[] {
|
|
276
274
|
const now = new Date().toISOString();
|
|
277
275
|
const repairedTasks = tasks.map((task) => {
|
|
@@ -592,9 +590,13 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
592
590
|
if (!fs.existsSync(workspaceDir)) {
|
|
593
591
|
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
594
592
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
593
|
+
// Atomic exclusive-create: reserve the sentinel (fails with EEXIST if
|
|
594
|
+
// another cleanup is in progress), then fill it atomically. The brief
|
|
595
|
+
// empty-file window is safe — the sentinel "exists" (correctly
|
|
596
|
+
// signalling in-progress cleanup).
|
|
597
|
+
const sentinelFd = fs.openSync(sentinelPath, "wx");
|
|
598
|
+
fs.closeSync(sentinelFd);
|
|
599
|
+
atomicWriteFile(sentinelPath, JSON.stringify({ startedAt: now }));
|
|
598
600
|
} catch {
|
|
599
601
|
// Sentinel already exists (another cleanup in progress) — skip
|
|
600
602
|
canCleanup = false;
|
|
@@ -661,38 +663,6 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
661
663
|
} catch {
|
|
662
664
|
/* ignore if already gone */
|
|
663
665
|
}
|
|
664
|
-
} else if (canCleanup) {
|
|
665
|
-
// Sentinel already existed — another cleanup attempt is in progress.
|
|
666
|
-
// Perform a simplified TOCTOU check: if any manifest is running,
|
|
667
|
-
// do NOT remove the sentinel (let the first attempt finish its scan).
|
|
668
|
-
let hasRunningManifest = false;
|
|
669
|
-
if (fs.existsSync(stateRunsDir)) {
|
|
670
|
-
try {
|
|
671
|
-
for (const runDir of fs.readdirSync(stateRunsDir)) {
|
|
672
|
-
const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
|
|
673
|
-
if (!fs.existsSync(manifestPath)) continue;
|
|
674
|
-
try {
|
|
675
|
-
const m = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as TeamRunManifest;
|
|
676
|
-
if (m.status === "running") {
|
|
677
|
-
hasRunningManifest = true;
|
|
678
|
-
break;
|
|
679
|
-
}
|
|
680
|
-
} catch {
|
|
681
|
-
/* skip on parse error */
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
} catch {
|
|
685
|
-
/* skip if runs dir unreadable */
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
// Only clean up sentinel if no running manifests exist
|
|
689
|
-
if (!hasRunningManifest) {
|
|
690
|
-
try {
|
|
691
|
-
fs.unlinkSync(sentinelPath);
|
|
692
|
-
} catch {
|
|
693
|
-
/* ignore if already gone */
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
666
|
}
|
|
697
667
|
}
|
|
698
668
|
} catch {
|
|
@@ -439,13 +439,29 @@ export class SubagentManager {
|
|
|
439
439
|
private drainQueue(): void {
|
|
440
440
|
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
|
|
441
441
|
const next = this.queue.shift();
|
|
442
|
-
if (
|
|
442
|
+
if (next?.record.status !== "queued") continue;
|
|
443
443
|
this.start(next.record, next.options, next.runner, next.signal);
|
|
444
444
|
}
|
|
445
445
|
}
|
|
446
446
|
|
|
447
447
|
private async pollRunToTerminal(cwd: string, record: SubagentRecord): Promise<void> {
|
|
448
|
+
// Safety: max 30 minutes (1800 polls at 1s interval) to prevent infinite
|
|
449
|
+
// polling if manifest file is deleted or run state becomes unrecoverable.
|
|
450
|
+
const MAX_POLL_COUNT = 1800;
|
|
451
|
+
let pollCount = 0;
|
|
448
452
|
while (record.runId && (record.status === "running" || record.status === "blocked")) {
|
|
453
|
+
if (++pollCount > MAX_POLL_COUNT) {
|
|
454
|
+
logInternalError(
|
|
455
|
+
"subagent-manager.poll-timeout",
|
|
456
|
+
new Error(`pollRunToTerminal exceeded ${MAX_POLL_COUNT} polls for runId=${record.runId}`),
|
|
457
|
+
`id=${record.id}`,
|
|
458
|
+
);
|
|
459
|
+
record.status = "error";
|
|
460
|
+
record.error = `Poll timeout: run did not reach terminal state after ${MAX_POLL_COUNT} seconds`;
|
|
461
|
+
record.completedAt = Date.now();
|
|
462
|
+
savePersistedSubagentRecord(cwd, record);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
449
465
|
const loaded = loadRunManifestById(cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
450
466
|
if (!loaded) {
|
|
451
467
|
await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
|
|
@@ -488,7 +504,7 @@ export class SubagentManager {
|
|
|
488
504
|
private scheduleBlockedTerminalPoll(cwd: string, record: SubagentRecord): void {
|
|
489
505
|
const poll = (): void => {
|
|
490
506
|
const current = this.records.get(record.id);
|
|
491
|
-
if (
|
|
507
|
+
if (current?.status !== "blocked" || !current.runId) return;
|
|
492
508
|
const loaded = loadRunManifestById(cwd, current.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
493
509
|
if (
|
|
494
510
|
!loaded ||
|
|
@@ -524,7 +540,7 @@ export class SubagentManager {
|
|
|
524
540
|
const threshold = DEFAULT_SUBAGENT.stuckBlockedNotifyMs;
|
|
525
541
|
const fire = (): void => {
|
|
526
542
|
const current = this.records.get(record.id);
|
|
527
|
-
if (
|
|
543
|
+
if (current?.status !== "blocked" || !current.blockedAt || current.stuckNotified) return;
|
|
528
544
|
current.stuckNotified = true;
|
|
529
545
|
this.onEvent?.("subagent.stuck-blocked", {
|
|
530
546
|
event: "subagent.stuck-blocked",
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { DEFAULT_OUTPUT_CONTEXT } from "../config/defaults.ts";
|
|
4
4
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
5
|
+
import { atomicWriteFile } from "../state/atomic-write.ts";
|
|
5
6
|
import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
6
7
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
7
8
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
@@ -155,7 +156,7 @@ export function teePathForArtifact(artifactsRoot: string, taskId: string, artifa
|
|
|
155
156
|
function writeTeeFile(fullOutputPath: string, content: string): boolean {
|
|
156
157
|
try {
|
|
157
158
|
fs.mkdirSync(path.dirname(fullOutputPath), { recursive: true });
|
|
158
|
-
|
|
159
|
+
atomicWriteFile(fullOutputPath, content);
|
|
159
160
|
return true;
|
|
160
161
|
} catch {
|
|
161
162
|
return false;
|
|
@@ -4,7 +4,7 @@ import type { TaskOutputSchema, TeamRunManifest, TeamTaskState } from "../../sta
|
|
|
4
4
|
import type { WorkflowStep } from "../../workflows/workflow-config.ts";
|
|
5
5
|
import { buildMemoryBlock } from "../agent-memory.ts";
|
|
6
6
|
import { permissionForRole } from "../role-permission.ts";
|
|
7
|
-
import { HANDOFF_TEMPLATE, renderTaskPacket } from "../task-packet.ts";
|
|
7
|
+
import { HANDOFF_TEMPLATE, renderTaskPacket, sanitizeTaskText } from "../task-packet.ts";
|
|
8
8
|
import { buildWorkspaceTree } from "../workspace-tree.ts";
|
|
9
9
|
import { renderSuggestedFilesSection, runRetrievalCycle } from "./retrieval-orchestrator.ts";
|
|
10
10
|
|
|
@@ -14,7 +14,7 @@ import { renderSuggestedFilesSection, runRetrievalCycle } from "./retrieval-orch
|
|
|
14
14
|
* filtering at the Pi level is a future optimisation (Phase 3.2+).
|
|
15
15
|
*/
|
|
16
16
|
export function toolGuidanceBlock(agent?: AgentConfig): string {
|
|
17
|
-
if (
|
|
17
|
+
if (agent?.loadMode !== "lean" || !agent.defaultTools?.length) return "";
|
|
18
18
|
return [
|
|
19
19
|
"# Tool Guidance",
|
|
20
20
|
`This role uses a focused tool set. Preferred tools: ${agent.defaultTools.join(", ")}.`,
|
|
@@ -267,7 +267,7 @@ export async function renderTaskPrompt(
|
|
|
267
267
|
memoryBlock,
|
|
268
268
|
task.taskPacket?.outputSchema ? renderOutputSchemaBlock(task.taskPacket.outputSchema) : "",
|
|
269
269
|
"Task:",
|
|
270
|
-
step.task.replaceAll("{goal}", manifest.goal),
|
|
270
|
+
sanitizeTaskText(step.task.replaceAll("{goal}", manifest.goal)),
|
|
271
271
|
"",
|
|
272
272
|
"When your task is complete, structure your final output using this handoff template:",
|
|
273
273
|
HANDOFF_TEMPLATE,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { MailboxMessage } from "../../state/mailbox.ts";
|
|
2
2
|
import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
3
|
+
import { sanitizeTaskText } from "../task-packet.ts";
|
|
3
4
|
|
|
4
5
|
export interface RunProjectionSource {
|
|
5
6
|
kind: "events" | "mailbox" | "artifacts" | "ui_metadata" | "runtime_metadata";
|
|
@@ -64,7 +65,8 @@ export function transformRunContextBeforeWorkerStart(input: {
|
|
|
64
65
|
});
|
|
65
66
|
lines.push(`Pending messages (${messages.length}/${input.pendingMailbox.length}):`);
|
|
66
67
|
for (const msg of messages) {
|
|
67
|
-
|
|
68
|
+
const safeBody = sanitizeTaskText(msg.body.slice(0, 100));
|
|
69
|
+
lines.push(`- ${msg.kind ?? "message"}: ${safeBody}`);
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
@@ -24,6 +24,14 @@ export function updateTask(tasks: TeamTaskState[], updated: TeamTaskState): Team
|
|
|
24
24
|
* committed first; retry with the fresh state. This is O(retry) under contention but
|
|
25
25
|
* converges in the normal single-writer case.
|
|
26
26
|
*
|
|
27
|
+
* ST-7: pass `skipCoalesce: true` when persisting a TERMINAL task status
|
|
28
|
+
* (completed/failed/cancelled/needs_attention/skipped). Without this the
|
|
29
|
+
* underlying `saveRunTasksCoalesced` would buffer the write for 50ms; a
|
|
30
|
+
* SIGKILL in that window loses the terminal update and crash recovery
|
|
31
|
+
* would see "running" in tasks.json while events.jsonl already shows
|
|
32
|
+
* "completed". For non-terminal transitions (heartbeat, progress) the
|
|
33
|
+
* default buffered write is fine and matches prior behavior.
|
|
34
|
+
*
|
|
27
35
|
* @param checkpointPhase - Optional checkpoint phase to include in the task state alongside the update.
|
|
28
36
|
*/
|
|
29
37
|
export function persistSingleTaskUpdate(
|
|
@@ -31,6 +39,7 @@ export function persistSingleTaskUpdate(
|
|
|
31
39
|
fallbackTasks: TeamTaskState[],
|
|
32
40
|
updated: TeamTaskState,
|
|
33
41
|
checkpointPhase?: TaskCheckpointState["phase"],
|
|
42
|
+
skipCoalesce: boolean = false,
|
|
34
43
|
): TeamTaskState[] {
|
|
35
44
|
const MAX_CAS_ATTEMPTS = 100;
|
|
36
45
|
let baseMtime = 0;
|
|
@@ -126,7 +135,10 @@ export function persistSingleTaskUpdate(
|
|
|
126
135
|
// non-coalesced writers; the flushPendingAtomicWrites() guard at
|
|
127
136
|
// the top of the retry loop ensures reads see any other coalesced
|
|
128
137
|
// writer's flushed-before-this-call state.
|
|
129
|
-
|
|
138
|
+
// ST-7: terminal transitions (skipCoalesce=true) bypass the 50ms
|
|
139
|
+
// coalesce window so a SIGKILL after the persist completes cannot
|
|
140
|
+
// leave tasks.json stale with a non-terminal status.
|
|
141
|
+
saveRunTasksCoalesced(manifest, merged, skipCoalesce);
|
|
130
142
|
} catch (err) {
|
|
131
143
|
logInternalError("persistSingleTaskUpdate", err, undefined, "error");
|
|
132
144
|
throw err;
|
|
@@ -15,11 +15,10 @@ import type {
|
|
|
15
15
|
OperationTerminalEvidence,
|
|
16
16
|
TeamRunManifest,
|
|
17
17
|
TeamTaskState,
|
|
18
|
-
UsageState,
|
|
19
18
|
VerificationEvidence,
|
|
20
19
|
} from "../state/types.ts";
|
|
21
20
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
22
|
-
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
21
|
+
import { resolveContainedPath, resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
23
22
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
24
23
|
import { captureWorktreeDiffAsync, captureWorktreeDiffStatAsync, prepareTaskWorkspaceAsync } from "../worktree/worktree-manager.ts";
|
|
25
24
|
import { reserveControlChannel } from "./agent-control.ts";
|
|
@@ -35,7 +34,7 @@ import {
|
|
|
35
34
|
recordFromTask,
|
|
36
35
|
upsertCrewAgent,
|
|
37
36
|
} from "./crew-agent-records.ts";
|
|
38
|
-
import type {
|
|
37
|
+
import type { CrewRuntimeKind } from "./crew-agent-runtime.ts";
|
|
39
38
|
import { crewHooks } from "./crew-hooks.ts";
|
|
40
39
|
import { bridgeEventFromJsonEvent, registerStreamBridge } from "./event-stream-bridge.ts";
|
|
41
40
|
import { createVerificationEvidence } from "./green-contract.ts";
|
|
@@ -87,7 +86,7 @@ registerYieldTool();
|
|
|
87
86
|
async function appendSteeringAsync(steeringDir: string, taskId: string, steers: string[]): Promise<void> {
|
|
88
87
|
try {
|
|
89
88
|
await fs.promises.mkdir(steeringDir, { recursive: true });
|
|
90
|
-
const steeringPath = `${
|
|
89
|
+
const steeringPath = resolveContainedPath(steeringDir, `${taskId}.jsonl`);
|
|
91
90
|
const lines = steers
|
|
92
91
|
.map(
|
|
93
92
|
(msg) =>
|
|
@@ -254,10 +253,18 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
254
253
|
if (input.step.preStepScript) {
|
|
255
254
|
const scriptTimeout = input.step.preStepTimeout ?? 30_000;
|
|
256
255
|
const scriptArgs = input.step.preStepArgs ?? [];
|
|
256
|
+
appendEventFireAndForget(manifest.eventsPath, {
|
|
257
|
+
type: "hook.pre_step_started",
|
|
258
|
+
runId: manifest.runId,
|
|
259
|
+
taskId: task.id,
|
|
260
|
+
data: { script: input.step.preStepScript, argCount: scriptArgs.length, timeoutMs: scriptTimeout },
|
|
261
|
+
});
|
|
257
262
|
// SECURITY (M-1 fix, code-review 2026-06-23): use the project's safe-path
|
|
258
263
|
// primitive instead of a hand-rolled path.resolve + startsWith check.
|
|
259
264
|
// The lexical check passed a symlinked ancestor, letting execFileSync
|
|
260
265
|
// follow it and execute a script outside cwd. Throws on escape.
|
|
266
|
+
// Keep validation outside the optional-execution catch: preStepOptional
|
|
267
|
+
// must never bypass a path-containment failure.
|
|
261
268
|
resolveRealContainedPath(manifest.cwd, input.step.preStepScript);
|
|
262
269
|
try {
|
|
263
270
|
// LAZY: defer dynamic import of node:child_process to its call site.
|
|
@@ -268,9 +275,22 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
268
275
|
cwd: manifest.cwd,
|
|
269
276
|
maxBuffer: 1024 * 1024, // 1MB cap
|
|
270
277
|
});
|
|
278
|
+
appendEventFireAndForget(manifest.eventsPath, {
|
|
279
|
+
type: "hook.pre_step_completed",
|
|
280
|
+
runId: manifest.runId,
|
|
281
|
+
taskId: task.id,
|
|
282
|
+
data: { script: input.step.preStepScript, outputBytes: Buffer.byteLength(preStepOutput, "utf8") },
|
|
283
|
+
});
|
|
271
284
|
} catch (err) {
|
|
272
285
|
const msg = err instanceof Error ? err.message : String(err);
|
|
273
286
|
const exitCode = (err as NodeJS.ErrnoException & { status?: number }).status;
|
|
287
|
+
appendEventFireAndForget(manifest.eventsPath, {
|
|
288
|
+
type: "hook.pre_step_failed",
|
|
289
|
+
runId: manifest.runId,
|
|
290
|
+
taskId: task.id,
|
|
291
|
+
message: `pre-step hook failed (exit ${exitCode ?? "?"})`,
|
|
292
|
+
data: { script: input.step.preStepScript, exitCode: exitCode ?? null, optional: input.step.preStepOptional === true },
|
|
293
|
+
});
|
|
274
294
|
// E1 (Round 15): structured CrewError with code E009 + help hint,
|
|
275
295
|
// instead of a raw Error. Surfaces the script path, exit code, and stderr.
|
|
276
296
|
// Round 21 (E4): if preStepOptional is set, a failing hook is NON-FATAL.
|
|
@@ -484,7 +504,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
484
504
|
runId: manifest.runId,
|
|
485
505
|
agentId: task.id,
|
|
486
506
|
artifactsRoot: manifest.artifactsRoot,
|
|
487
|
-
steeringFile: `${manifest.artifactsRoot}/steering
|
|
507
|
+
steeringFile: resolveContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
|
|
488
508
|
onSpawn: (pid) => {
|
|
489
509
|
try {
|
|
490
510
|
({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
|
|
@@ -1222,9 +1242,14 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
1222
1242
|
// stale manifest and overwrite this task's freshly-written artifacts, silently
|
|
1223
1243
|
// losing them. persistSingleTaskUpdate is re-entrance-safe (runLockHeldByUs guard),
|
|
1224
1244
|
// so nesting it inside this lock is a no-op re-acquire, not a deadlock.
|
|
1245
|
+
// ST-7: this is a terminal transition (task.status is completed/failed/needs_attention),
|
|
1246
|
+
// pass skipCoalesce=true so the terminal update is durable — a SIGKILL in the
|
|
1247
|
+
// 50ms coalesce window must NOT leave tasks.json showing the prior non-terminal
|
|
1248
|
+
// status, otherwise crash recovery would see inconsistency between tasks.json
|
|
1249
|
+
// (running) and events.jsonl (terminal).
|
|
1225
1250
|
tasks = await withRunLock(manifest, async () => {
|
|
1226
1251
|
await saveRunManifestAsync(manifest);
|
|
1227
|
-
return persistSingleTaskUpdate(manifest, tasks, task);
|
|
1252
|
+
return persistSingleTaskUpdate(manifest, tasks, task, undefined, true);
|
|
1228
1253
|
});
|
|
1229
1254
|
upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
|
|
1230
1255
|
// Execute task_result hook before emitting terminal event
|
|
@@ -9,6 +9,7 @@ import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
|
9
9
|
import { PluginRegistry } from "../plugins/plugin-registry.ts";
|
|
10
10
|
import { NextJsPlugin, VitePlugin, VitestPlugin } from "../plugins/plugins/index.ts";
|
|
11
11
|
import { hashArtifactContent as hashContent, writeArtifact } from "../state/artifact-store.ts";
|
|
12
|
+
import { atomicWriteFile, flushPendingAtomicWrites } from "../state/atomic-write.ts";
|
|
12
13
|
import { appendEvent, appendEventAsync, appendEventBuffered, appendEventFireAndForget, flushEventLogBuffer } from "../state/event-log.ts";
|
|
13
14
|
import { HealthStore } from "../state/health-store.ts";
|
|
14
15
|
import { withRunLock } from "../state/locks.ts";
|
|
@@ -84,7 +85,7 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
|
|
|
84
85
|
// captured manifest.updatedAt once at startup, making the value
|
|
85
86
|
// permanently stale throughout the run.
|
|
86
87
|
const now = new Date().toISOString();
|
|
87
|
-
|
|
88
|
+
atomicWriteFile(
|
|
88
89
|
heartbeatPath,
|
|
89
90
|
JSON.stringify({
|
|
90
91
|
pid: process.pid,
|
|
@@ -93,7 +94,7 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
|
|
|
93
94
|
kind: "team-runner",
|
|
94
95
|
lastTaskUpdateAt: now,
|
|
95
96
|
}),
|
|
96
|
-
{
|
|
97
|
+
{ mode: 0o600 },
|
|
97
98
|
);
|
|
98
99
|
} catch {
|
|
99
100
|
// best-effort
|
|
@@ -1614,6 +1615,11 @@ async function executeTeamRunCore(
|
|
|
1614
1615
|
// Read committed manifest from disk inside the lock so artifact merge is based
|
|
1615
1616
|
// on committed state, not in-memory state that may differ from disk.
|
|
1616
1617
|
const mergeResult = await withRunLock(manifest, async () => {
|
|
1618
|
+
// NEW-D1: flush any pending coalesced atomic writes before reading from
|
|
1619
|
+
// disk. Without this, a worker's async manifest save (coalesced by
|
|
1620
|
+
// atomic-write) may not be committed yet, causing a lost-update on the
|
|
1621
|
+
// merge read. flushPendingAtomicWrites forces all queued writes to disk.
|
|
1622
|
+
flushPendingAtomicWrites();
|
|
1617
1623
|
const disk = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
1618
1624
|
const diskManifest = disk?.manifest ?? manifest;
|
|
1619
1625
|
const diskArtifacts = diskManifest.artifacts;
|
|
@@ -13,7 +13,7 @@ import { spawn } from "node:child_process";
|
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
14
|
import * as path from "node:path";
|
|
15
15
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
16
|
-
import type {
|
|
16
|
+
import type { GreenLevel, VerificationCommandResult, VerificationContract } from "../state/types.ts";
|
|
17
17
|
import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
|
|
18
18
|
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
19
19
|
import { redactSecretString } from "../utils/redaction.ts";
|
|
@@ -154,13 +154,61 @@ export const CARGO_RUST_GATES: Array<{
|
|
|
154
154
|
// `npm test > ~/.ssh/authorized_keys`) and used char-class semantics instead of
|
|
155
155
|
// a lookahead. Now `[<>](?![&\d])` blocks BOTH `<` and `>` file redirects while
|
|
156
156
|
// still permitting fd-duplication forms (`2>&1`, `1>&2`, `>&2`, `<&3`) via the
|
|
157
|
-
// negative lookahead (a `>`/`<` followed by `&` or a digit is allowed).
|
|
158
|
-
|
|
157
|
+
// negative lookahead (a `>`/`<` followed by `&` or a digit is allowed). A bare
|
|
158
|
+
// `&` is also blocked so an allowed gate cannot background itself and start a
|
|
159
|
+
// second command.
|
|
160
|
+
const DANGEROUS_SHELL_PATTERNS = /(?:;|&&|\|\||\$\(|`|\$\{|\$\w|\b(eval|exec)\b|[<>](?![&\d])|(?<![<>])&|[\r\n])/;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Verification gates intentionally execute project-controlled code, but they
|
|
164
|
+
* must enter through a known build/test runner. Interpreter eval modes and
|
|
165
|
+
* arbitrary utilities are excluded: `node -e`, `python -c`, `curl`, etc. can
|
|
166
|
+
* otherwise execute attacker-provided code without any shell metacharacters.
|
|
167
|
+
*
|
|
168
|
+
* Prefixes are token-bound and each side of a pipeline is checked separately,
|
|
169
|
+
* preventing an allowed prefix such as `npm test | node -e ...` from bypassing
|
|
170
|
+
* the restriction. Keep this list focused on common verification commands.
|
|
171
|
+
*/
|
|
172
|
+
const ALLOWED_GATE_COMMAND_PATTERNS: readonly RegExp[] = [
|
|
173
|
+
/^npm\s+(?:test(?:\s|$)|run(?:-script)?\s+[A-Za-z0-9_.:-]+(?:\s|$))/,
|
|
174
|
+
/^pnpm\s+(?:test(?:\s|$)|run\s+[A-Za-z0-9_.:-]+(?:\s|$))/,
|
|
175
|
+
/^yarn\s+(?:test(?:\s|$)|run\s+[A-Za-z0-9_.:-]+(?:\s|$))/,
|
|
176
|
+
/^npx\s+(?:--no-install\s+)?(?:biome|eslint|tsc)(?:\s|$)/,
|
|
177
|
+
/^(?:biome|eslint|tsc)(?:\s|$)/,
|
|
178
|
+
/^node\s+(?:(?:--experimental-strip-types|--no-warnings)\s+)*--test(?:\s|$)/,
|
|
179
|
+
/^python(?:3(?:\.\d+)*)?\s+-m\s+(?:pytest|unittest)(?:\s|$)/,
|
|
180
|
+
/^pytest(?:\s|$)/,
|
|
181
|
+
/^cargo\s+(?:build|check|clippy|fmt|test)(?:\s|$)/,
|
|
182
|
+
/^go\s+(?:build|test|vet)(?:\s|$)/,
|
|
183
|
+
/^bun\s+test(?:\s|$)/,
|
|
184
|
+
/^deno\s+(?:check|lint|test)(?:\s|$)/,
|
|
185
|
+
/^dotnet\s+(?:build|test)(?:\s|$)/,
|
|
186
|
+
/^(?:gradle|\.\/gradlew)\s+(?:build|check|test)(?:\s|$)/,
|
|
187
|
+
/^(?:mvn|\.\/mvnw)\s+(?:package|test|verify)(?:\s|$)/,
|
|
188
|
+
/^make\s+(?:build|check|lint|test)(?:\s|$)/,
|
|
189
|
+
/^(?:echo|false|printenv|printf|true)(?:\s|$)/,
|
|
190
|
+
/^(?:grep|head|rg|tail)(?:\s|$)/,
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
const NODE_EVAL_MODE_PATTERN = /(?:^|\s)["']?(?:(?:-e|-p)(?!-)|--(?:eval|print)(?:=|\s|["']|$))/;
|
|
194
|
+
|
|
195
|
+
function validateAllowedGateCommands(normalized: string, original: string): void {
|
|
196
|
+
const pipelineCommands = normalized.split("|").map((part) => part.trim());
|
|
197
|
+
for (const pipelineCommand of pipelineCommands) {
|
|
198
|
+
if (/^node(?:\s|$)/.test(pipelineCommand) && NODE_EVAL_MODE_PATTERN.test(pipelineCommand)) {
|
|
199
|
+
throw new Error(`Security: verification gate command rejected (Node eval/print mode is not allowed): ${original}`);
|
|
200
|
+
}
|
|
201
|
+
if (!pipelineCommand || !ALLOWED_GATE_COMMAND_PATTERNS.some((pattern) => pattern.test(pipelineCommand))) {
|
|
202
|
+
throw new Error(`Security: verification gate command rejected (command is not allowlisted): ${original}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
159
206
|
|
|
160
207
|
/**
|
|
161
208
|
* Validate a verification gate command is safe to execute.
|
|
162
|
-
* Rejects commands with shell metacharacters that could enable injection
|
|
163
|
-
*
|
|
209
|
+
* Rejects commands with shell metacharacters that could enable injection and
|
|
210
|
+
* commands outside the verification-runner allowlist. Allows safe pipelines
|
|
211
|
+
* and stderr fd duplication (`2>&1`).
|
|
164
212
|
*/
|
|
165
213
|
/** @internal — exported for injection-guard unit testing (Round 25). */
|
|
166
214
|
export function __test__validateGateCommand(command: string): void {
|
|
@@ -187,6 +235,7 @@ function validateGateCommand(command: string): void {
|
|
|
187
235
|
if (DANGEROUS_SHELL_PATTERNS.test(normalized)) {
|
|
188
236
|
throw new Error(`Security: verification gate command rejected (dangerous shell pattern): ${command}`);
|
|
189
237
|
}
|
|
238
|
+
validateAllowedGateCommands(normalized, command);
|
|
190
239
|
}
|
|
191
240
|
|
|
192
241
|
async function executeCommand(
|