pi-crew 0.9.19 → 0.9.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.19",
3
+ "version": "0.9.21",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
3
3
  import { resolveContainedPath } from "../utils/safe-paths.ts";
4
+ import { withHmacVerification } from "./rpc-hmac.ts";
4
5
  // Lazy-loaded to avoid pulling team-tool.ts (and its entire runtime chain) into module load.
5
6
  import type { handleTeamTool as HandleTeamToolFn } from "./team-tool.ts";
6
7
 
@@ -143,9 +144,8 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => vo
143
144
 
144
145
  // SECURITY TRUST BOUNDARY: RPC channels (pi-crew:rpc:run, pi-crew:rpc:status,
145
146
  // pi-crew:rpc:live-control) are accessible to any extension on the shared event
146
- // bus. Mitigations applied: rate limiting (RPC_RATE_LIMIT_MAX), explicit intent
147
- // requirement for runs, operation allowlist for live-control reads, and cwd
148
- // containment validation. A full fix requires event-bus-level origin signing.
147
+ // bus. Mitigations: rate limiting, explicit intent, operation allowlist, cwd
148
+ // containment validation, and HMAC origin signing (see rpc-hmac.ts).
149
149
 
150
150
  export function registerPiCrewRpc(
151
151
  events: EventBusLike | undefined,
@@ -153,150 +153,181 @@ export function registerPiCrewRpc(
153
153
  ): PiCrewRpcHandle | undefined {
154
154
  if (!events) return undefined;
155
155
  const unsubs = [
156
- on(events, "pi-crew:rpc:ping", (raw) =>
157
- reply(events, "pi-crew:rpc:ping", requestId(raw), {
158
- success: true,
159
- data: { version: PI_CREW_RPC_VERSION },
160
- }),
156
+ on(
157
+ events,
158
+ "pi-crew:rpc:ping",
159
+ withHmacVerification(
160
+ (raw) =>
161
+ reply(events, "pi-crew:rpc:ping", requestId(raw), {
162
+ success: true,
163
+ data: { version: PI_CREW_RPC_VERSION },
164
+ }),
165
+ "pi-crew:rpc:ping",
166
+ ),
161
167
  ),
162
- on(events, "pi-crew:rpc:run", async (raw) => {
163
- const id = requestId(raw);
164
- try {
165
- // SECURITY (HIGH #4 fix): Rate limit RPC run requests
166
- const rateLimit = checkRpcRateLimit();
167
- if (!rateLimit.allowed) {
168
+ on(
169
+ events,
170
+ "pi-crew:rpc:run",
171
+ withHmacVerification(async (raw) => {
172
+ const id = requestId(raw);
173
+ try {
174
+ // SECURITY (HIGH #4 fix): Rate limit RPC run requests
175
+ const rateLimit = checkRpcRateLimit();
176
+ if (!rateLimit.allowed) {
177
+ reply(events, "pi-crew:rpc:run", id, {
178
+ success: false,
179
+ error: `RPC run rate limit exceeded. Max ${RPC_RATE_LIMIT_MAX} requests per ${RPC_RATE_LIMIT_WINDOW_MS / 1000}s. Retry after ${Math.ceil((rateLimit.retryAfterMs ?? 60000) / 1000)}s.`,
180
+ });
181
+ return;
182
+ }
183
+ recordRpcRun();
184
+ const ctx = getCtx();
185
+ if (!ctx) throw new Error("No active pi-crew session context.");
186
+ // Validate payload: only allow known fields from TeamToolParamsValue
187
+ const ALLOWED_RPC_RUN_KEYS = new Set([
188
+ "goal",
189
+ "team",
190
+ "workflow",
191
+ "async",
192
+ "cwd",
193
+ "config",
194
+ "skill",
195
+ "model",
196
+ "budgetTotal",
197
+ "budgetWarning",
198
+ "budgetAbort",
199
+ "budgetUnlimited",
200
+ ]);
201
+ let params: TeamToolParamsValue;
202
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
203
+ const filtered: Record<string, unknown> = {
204
+ ...(raw as object),
205
+ };
206
+ // Strip any keys not in the allowlist to prevent injection of unexpected fields
207
+ for (const key of Object.keys(filtered)) {
208
+ if (!ALLOWED_RPC_RUN_KEYS.has(key)) delete filtered[key];
209
+ }
210
+ params = {
211
+ ...filtered,
212
+ action: "run",
213
+ } as TeamToolParamsValue;
214
+ } else {
215
+ params = { action: "run" };
216
+ }
217
+ const permission = isAllowedRpcRunParams(params);
218
+ if (!permission.ok) {
219
+ reply(events, "pi-crew:rpc:run", id, {
220
+ success: false,
221
+ error: permission.error ?? "permission denied",
222
+ });
223
+ return;
224
+ }
225
+ const result = await handleTeamTool(params, ctx);
226
+ reply(
227
+ events,
228
+ "pi-crew:rpc:run",
229
+ id,
230
+ result.isError ? { success: false, error: textOf(result) } : { success: true, data: result.details },
231
+ );
232
+ } catch (error) {
168
233
  reply(events, "pi-crew:rpc:run", id, {
169
234
  success: false,
170
- error: `RPC run rate limit exceeded. Max ${RPC_RATE_LIMIT_MAX} requests per ${RPC_RATE_LIMIT_WINDOW_MS / 1000}s. Retry after ${Math.ceil((rateLimit.retryAfterMs ?? 60000) / 1000)}s.`,
235
+ error: error instanceof Error ? error.message : String(error),
171
236
  });
172
- return;
173
237
  }
174
- recordRpcRun();
175
- const ctx = getCtx();
176
- if (!ctx) throw new Error("No active pi-crew session context.");
177
- // Validate payload: only allow known fields from TeamToolParamsValue
178
- const ALLOWED_RPC_RUN_KEYS = new Set(["goal", "team", "workflow", "async", "cwd", "config", "skill", "model"]);
179
- let params: TeamToolParamsValue;
180
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
181
- const filtered: Record<string, unknown> = {
182
- ...(raw as object),
183
- };
184
- // Strip any keys not in the allowlist to prevent injection of unexpected fields
185
- for (const key of Object.keys(filtered)) {
186
- if (!ALLOWED_RPC_RUN_KEYS.has(key)) delete filtered[key];
187
- }
188
- params = {
189
- ...filtered,
190
- action: "run",
191
- } as TeamToolParamsValue;
192
- } else {
193
- params = { action: "run" };
194
- }
195
- const permission = isAllowedRpcRunParams(params);
196
- if (!permission.ok) {
197
- reply(events, "pi-crew:rpc:run", id, {
238
+ }, "pi-crew:rpc:run"),
239
+ ),
240
+ on(
241
+ events,
242
+ "pi-crew:rpc:status",
243
+ withHmacVerification(async (raw) => {
244
+ const id = requestId(raw);
245
+ try {
246
+ const ctx = getCtx();
247
+ if (!ctx) throw new Error("No active pi-crew session context.");
248
+ const runId = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as { runId?: string }).runId : undefined;
249
+ const result = await handleTeamTool({ action: "status", runId }, ctx);
250
+ reply(
251
+ events,
252
+ "pi-crew:rpc:status",
253
+ id,
254
+ result.isError
255
+ ? { success: false, error: textOf(result) }
256
+ : {
257
+ success: true,
258
+ data: {
259
+ text: textOf(result),
260
+ details: result.details,
261
+ },
262
+ },
263
+ );
264
+ } catch (error) {
265
+ reply(events, "pi-crew:rpc:status", id, {
198
266
  success: false,
199
- error: permission.error ?? "permission denied",
267
+ error: error instanceof Error ? error.message : String(error),
200
268
  });
201
- return;
202
269
  }
203
- const result = await handleTeamTool(params, ctx);
204
- reply(
205
- events,
206
- "pi-crew:rpc:run",
207
- id,
208
- result.isError ? { success: false, error: textOf(result) } : { success: true, data: result.details },
209
- );
210
- } catch (error) {
211
- reply(events, "pi-crew:rpc:run", id, {
212
- success: false,
213
- error: error instanceof Error ? error.message : String(error),
214
- });
215
- }
216
- }),
217
- on(events, "pi-crew:rpc:status", async (raw) => {
218
- const id = requestId(raw);
219
- try {
220
- const ctx = getCtx();
221
- if (!ctx) throw new Error("No active pi-crew session context.");
222
- const runId = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as { runId?: string }).runId : undefined;
223
- const result = await handleTeamTool({ action: "status", runId }, ctx);
224
- reply(
225
- events,
226
- "pi-crew:rpc:status",
227
- id,
228
- result.isError
229
- ? { success: false, error: textOf(result) }
230
- : {
231
- success: true,
232
- data: {
233
- text: textOf(result),
234
- details: result.details,
235
- },
236
- },
237
- );
238
- } catch (error) {
239
- reply(events, "pi-crew:rpc:status", id, {
240
- success: false,
241
- error: error instanceof Error ? error.message : String(error),
242
- });
243
- }
244
- }),
270
+ }, "pi-crew:rpc:status"),
271
+ ),
245
272
  on(events, "pi-crew:live-control", (raw) => {
246
273
  const request = parseLiveControlRealtimeMessage(raw);
247
274
  if (request) publishLiveControlRealtime(request);
248
275
  }),
249
- on(events, "pi-crew:rpc:live-control", async (raw) => {
250
- const id = requestId(raw);
251
- try {
252
- const ctx = getCtx();
253
- if (!ctx) throw new Error("No active pi-crew session context.");
254
- const obj = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
255
- const rawOp = typeof obj.operation === "string" ? obj.operation : "steer-agent";
256
- // SECURITY: Reject any operation not in the explicit allowlist.
257
- // Mutating ops (approve-plan, cancel-plan, steer-agent, stop-agent, etc.)
258
- // require user consent and are blocked here.
259
- if (!isAllowedRpcOperation(rawOp)) {
276
+ on(
277
+ events,
278
+ "pi-crew:rpc:live-control",
279
+ withHmacVerification(async (raw) => {
280
+ const id = requestId(raw);
281
+ try {
282
+ const ctx = getCtx();
283
+ if (!ctx) throw new Error("No active pi-crew session context.");
284
+ const obj = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
285
+ const rawOp = typeof obj.operation === "string" ? obj.operation : "steer-agent";
286
+ // SECURITY: Reject any operation not in the explicit allowlist.
287
+ // Mutating ops (approve-plan, cancel-plan, steer-agent, stop-agent, etc.)
288
+ // require user consent and are blocked here.
289
+ if (!isAllowedRpcOperation(rawOp)) {
290
+ reply(events, "pi-crew:rpc:live-control", id, {
291
+ success: false,
292
+ error: `RPC operation '${rawOp}' is not allowed. Allowed: ${[...RPC_ALLOWED_OPERATIONS].join(", ")}`,
293
+ });
294
+ return;
295
+ }
296
+ const result = await handleTeamTool(
297
+ {
298
+ action: "api",
299
+ runId: typeof obj.runId === "string" ? obj.runId : undefined,
300
+ config: {
301
+ operation: rawOp,
302
+ agentId: obj.agentId,
303
+ message: obj.message,
304
+ prompt: obj.prompt,
305
+ },
306
+ },
307
+ ctx,
308
+ );
309
+ reply(
310
+ events,
311
+ "pi-crew:rpc:live-control",
312
+ id,
313
+ result.isError
314
+ ? { success: false, error: textOf(result) }
315
+ : {
316
+ success: true,
317
+ data: {
318
+ text: textOf(result),
319
+ details: result.details,
320
+ },
321
+ },
322
+ );
323
+ } catch (error) {
260
324
  reply(events, "pi-crew:rpc:live-control", id, {
261
325
  success: false,
262
- error: `RPC operation '${rawOp}' is not allowed. Allowed: ${[...RPC_ALLOWED_OPERATIONS].join(", ")}`,
326
+ error: error instanceof Error ? error.message : String(error),
263
327
  });
264
- return;
265
328
  }
266
- const result = await handleTeamTool(
267
- {
268
- action: "api",
269
- runId: typeof obj.runId === "string" ? obj.runId : undefined,
270
- config: {
271
- operation: rawOp,
272
- agentId: obj.agentId,
273
- message: obj.message,
274
- prompt: obj.prompt,
275
- },
276
- },
277
- ctx,
278
- );
279
- reply(
280
- events,
281
- "pi-crew:rpc:live-control",
282
- id,
283
- result.isError
284
- ? { success: false, error: textOf(result) }
285
- : {
286
- success: true,
287
- data: {
288
- text: textOf(result),
289
- details: result.details,
290
- },
291
- },
292
- );
293
- } catch (error) {
294
- reply(events, "pi-crew:rpc:live-control", id, {
295
- success: false,
296
- error: error instanceof Error ? error.message : String(error),
297
- });
298
- }
299
- }),
329
+ }, "pi-crew:rpc:live-control"),
330
+ ),
300
331
  ];
301
332
  return { unsubscribe: () => unsubs.forEach((unsub) => unsub()) };
302
333
  }
@@ -0,0 +1,236 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * HMAC-based origin signing for cross-extension RPC.
5
+ *
6
+ * Shared secret is distributed via environment variable PI_CREW_RPC_SECRET.
7
+ * When the secret is configured, all RPC requests must include a valid HMAC
8
+ * signature. When the secret is NOT configured, HMAC is disabled (backward
9
+ * compat for setups that don't need it).
10
+ *
11
+ * Usage:
12
+ * Sender: signRpcRequest(params, "my-extension", "pi-crew:rpc:ping")
13
+ * Receiver: wrap handler with withHmacVerification(handler, channel)
14
+ */
15
+
16
+ export const RPC_HMAC_VERSION = 1;
17
+
18
+ const SECRET_ENV_VAR = "PI_CREW_RPC_SECRET";
19
+ const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000; // 5 min
20
+ const SIGNATURE_VALIDITY_MS = 10 * 60 * 1000; // 10 min
21
+
22
+ // --- Secret management -------------------------------------------------------
23
+
24
+ /** Get the shared secret. Returns undefined if not configured. */
25
+ export function getRpcSecret(): string | undefined {
26
+ return process.env[SECRET_ENV_VAR];
27
+ }
28
+
29
+ /** Set the shared secret (for testing or programmatic setup). */
30
+ export function setRpcSecret(secret: string): void {
31
+ process.env[SECRET_ENV_VAR] = secret;
32
+ }
33
+
34
+ /** Clear the shared secret. */
35
+ export function clearRpcSecret(): void {
36
+ delete process.env[SECRET_ENV_VAR];
37
+ }
38
+
39
+ /** Whether HMAC authentication is currently enabled. */
40
+ export function isHmacEnabled(): boolean {
41
+ return typeof process.env[SECRET_ENV_VAR] === "string" && process.env[SECRET_ENV_VAR].length > 0;
42
+ }
43
+
44
+ // --- Types -------------------------------------------------------------------
45
+
46
+ export interface RpcSignaturePayload {
47
+ version: number;
48
+ origin: string;
49
+ timestamp: number;
50
+ channel: string;
51
+ nonce: string;
52
+ }
53
+
54
+ export type RpcSignedPayload = RpcSignaturePayload & { signature: string };
55
+
56
+ // --- Signing -----------------------------------------------------------------
57
+
58
+ /**
59
+ * Create an HMAC signature for an RPC request.
60
+ *
61
+ * @throws if no secret is configured
62
+ */
63
+ export function createRpcSignature(origin: string, channel: string, body: unknown): RpcSignedPayload {
64
+ const secret = getRpcSecret();
65
+ if (!secret) {
66
+ throw new Error(`[pi-crew HMAC] Cannot create signature: ${SECRET_ENV_VAR} not set.`);
67
+ }
68
+
69
+ const payload: RpcSignaturePayload = {
70
+ version: RPC_HMAC_VERSION,
71
+ origin,
72
+ timestamp: Date.now(),
73
+ channel,
74
+ nonce: randomBytes(16).toString("hex"),
75
+ };
76
+
77
+ const signature = computeHmac(secret, payload, body);
78
+ return { ...payload, signature };
79
+ }
80
+
81
+ // --- Verification ------------------------------------------------------------
82
+
83
+ /**
84
+ * Verify an HMAC signature.
85
+ *
86
+ * @returns `{ valid: true }` or `{ valid: false, reason: string }`
87
+ */
88
+ export function verifyRpcSignature(payload: RpcSignedPayload, body: unknown): { valid: true } | { valid: false; reason: string } {
89
+ const secret = getRpcSecret();
90
+ if (!secret) {
91
+ return { valid: false, reason: `[pi-crew HMAC] ${SECRET_ENV_VAR} not configured.` };
92
+ }
93
+
94
+ if (payload.version !== RPC_HMAC_VERSION) {
95
+ return { valid: false, reason: `HMAC version mismatch: expected ${RPC_HMAC_VERSION}, got ${payload.version}` };
96
+ }
97
+
98
+ const now = Date.now();
99
+ const age = now - payload.timestamp;
100
+ if (age < -CLOCK_SKEW_TOLERANCE_MS) {
101
+ return { valid: false, reason: `HMAC timestamp in the future by ${Math.abs(age)}ms` };
102
+ }
103
+ if (age > SIGNATURE_VALIDITY_MS) {
104
+ return { valid: false, reason: `HMAC signature expired (${age}ms old)` };
105
+ }
106
+
107
+ const expected = computeHmac(secret, payload, body);
108
+ return timingSafeCompare(payload.signature, expected) ? { valid: true } : { valid: false, reason: "HMAC signature mismatch" };
109
+ }
110
+
111
+ // --- Middleware --------------------------------------------------------------
112
+
113
+ /**
114
+ * Wrap an RPC handler with HMAC verification.
115
+ *
116
+ * When HMAC is NOT configured (getRpcSecret() returns undefined), requests
117
+ * without signatures are passed through (backward compat). When HMAC IS
118
+ * configured, unsigned or invalid signatures are rejected.
119
+ */
120
+ export function withHmacVerification<P extends { requestId: string }>(
121
+ handler: (params: P) => unknown | Promise<unknown>,
122
+ _channel: string,
123
+ ): (params: P) => unknown | Promise<unknown> {
124
+ return (params: P) => {
125
+ if (!isHmacEnabled()) {
126
+ // No secret configured → backward compat: allow unsigned
127
+ return handler(params);
128
+ }
129
+
130
+ const sigPayload = extractSignaturePayload(params);
131
+ if (!sigPayload) {
132
+ throw new Error("[pi-crew HMAC] Missing HMAC signature in RPC request.");
133
+ }
134
+
135
+ // Strip HMAC fields from body before verification (HMAC was signed over original params)
136
+ const originalBody = stripHmacFields(params);
137
+ const verification = verifyRpcSignature(sigPayload, originalBody);
138
+ if (!verification.valid) {
139
+ throw new Error(`[pi-crew HMAC] ${verification.reason}`);
140
+ }
141
+
142
+ // Pass original params (with HMAC fields stripped) to handler
143
+ return handler(originalBody as P);
144
+ };
145
+ }
146
+
147
+ // --- Helpers -----------------------------------------------------------------
148
+
149
+ /**
150
+ * Attach HMAC signature fields to RPC request params.
151
+ */
152
+ export function signRpcRequest<P extends Record<string, unknown>>(
153
+ params: P,
154
+ origin: string,
155
+ channel: string,
156
+ ): P & {
157
+ hmacVersion: number;
158
+ hmacOrigin: string;
159
+ hmacTimestamp: number;
160
+ hmacChannel: string;
161
+ hmacNonce: string;
162
+ hmacSignature: string;
163
+ } {
164
+ const signed = createRpcSignature(origin, channel, params);
165
+ return {
166
+ ...params,
167
+ hmacVersion: signed.version,
168
+ hmacOrigin: signed.origin,
169
+ hmacTimestamp: signed.timestamp,
170
+ hmacChannel: signed.channel,
171
+ hmacNonce: signed.nonce,
172
+ hmacSignature: signed.signature,
173
+ };
174
+ }
175
+
176
+ // --- Internal ----------------------------------------------------------------
177
+
178
+ /** Strip HMAC signature fields from params to get the original body. */
179
+ function stripHmacFields<P extends Record<string, unknown>>(params: P): Partial<P> {
180
+ const result = { ...params };
181
+ delete result.hmacVersion;
182
+ delete result.hmacOrigin;
183
+ delete result.hmacTimestamp;
184
+ delete result.hmacChannel;
185
+ delete result.hmacNonce;
186
+ delete result.hmacSignature;
187
+ return result;
188
+ }
189
+
190
+ function computeHmac(secret: string, payload: RpcSignaturePayload, body: unknown): string {
191
+ const payloadForHmac = {
192
+ version: payload.version,
193
+ origin: payload.origin,
194
+ timestamp: payload.timestamp,
195
+ channel: payload.channel,
196
+ nonce: payload.nonce,
197
+ };
198
+ const message = `${JSON.stringify(payloadForHmac)}:${JSON.stringify(body)}`;
199
+ return createHmac("sha256", secret).update(message).digest("hex");
200
+ }
201
+
202
+ function timingSafeCompare(a: string, b: string): boolean {
203
+ const bufA = Buffer.from(a, "hex");
204
+ const bufB = Buffer.from(b, "hex");
205
+ const maxLen = Math.max(bufA.length, bufB.length);
206
+ const safeA = Buffer.alloc(maxLen);
207
+ const safeB = Buffer.alloc(maxLen);
208
+ bufA.copy(safeA);
209
+ bufB.copy(safeB);
210
+ const equal = timingSafeEqual(safeA, safeB);
211
+ return equal && bufA.length === bufB.length;
212
+ }
213
+
214
+ /** Extract HMAC signature payload from request params. */
215
+ export function extractSignaturePayload(params: unknown): RpcSignedPayload | null {
216
+ if (!params || typeof params !== "object" || Array.isArray(params)) return null;
217
+ const obj = params as Record<string, unknown>;
218
+ if (
219
+ typeof obj.hmacVersion !== "number" ||
220
+ typeof obj.hmacOrigin !== "string" ||
221
+ typeof obj.hmacTimestamp !== "number" ||
222
+ typeof obj.hmacChannel !== "string" ||
223
+ typeof obj.hmacNonce !== "string" ||
224
+ typeof obj.hmacSignature !== "string"
225
+ ) {
226
+ return null;
227
+ }
228
+ return {
229
+ version: obj.hmacVersion,
230
+ origin: obj.hmacOrigin,
231
+ timestamp: obj.hmacTimestamp,
232
+ channel: obj.hmacChannel,
233
+ nonce: obj.hmacNonce,
234
+ signature: obj.hmacSignature,
235
+ };
236
+ }
@@ -12,7 +12,7 @@ import { createRunManifest, loadRunManifestById, updateRunStatus } from "../../s
12
12
  import { allTeams, discoverTeams } from "../../teams/discover-teams.ts";
13
13
  import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workflows.ts";
14
14
  import { validateWorkflowForTeam } from "../../workflows/validate-workflow.ts";
15
- import { assertCleanLeader, findGitRoot } from "../../worktree/worktree-manager.ts";
15
+ import { assertCleanLeaderAsync, findGitRootAsync } from "../../worktree/worktree-manager.ts";
16
16
 
17
17
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type-only import for TS inference
18
18
  const _typeCheck: typeof ExecuteTeamRunFn = null as never as typeof ExecuteTeamRunFn;
@@ -226,7 +226,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
226
226
  let resolvedCtx = ctx;
227
227
  if (workingDir) {
228
228
  try {
229
- const gitRoot = findGitRoot(workingDir);
229
+ const gitRoot = await findGitRootAsync(workingDir);
230
230
  if (gitRoot && gitRoot !== workingDir) {
231
231
  resolvedCtx = { ...ctx, cwd: gitRoot };
232
232
  }
@@ -241,7 +241,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
241
241
  if (params.workspaceMode === "worktree") {
242
242
  let gitRoot: string | undefined;
243
243
  try {
244
- gitRoot = findGitRoot(resolvedCtx.cwd);
244
+ gitRoot = await findGitRootAsync(resolvedCtx.cwd);
245
245
  } catch {
246
246
  // not a git repo
247
247
  }
@@ -256,7 +256,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
256
256
  const preCheckConfig = loadConfig(resolvedCtx.cwd);
257
257
  if (preCheckConfig.config.requireCleanWorktreeLeader !== false) {
258
258
  try {
259
- assertCleanLeader(gitRoot);
259
+ await assertCleanLeaderAsync(gitRoot);
260
260
  } catch (err) {
261
261
  const msg = err instanceof Error ? err.message : String(err);
262
262
  return result(
@@ -573,10 +573,31 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
573
573
  const executedConfig = effectiveRunConfig(loadedConfig.config, params.config);
574
574
  const runtime = await resolveCrewRuntime(executedConfig);
575
575
  const runtimeResolution = runtimeResolutionState(runtime);
576
+ // DEBUG: log what we received (gated to avoid stdout pollution in production)
577
+ if (process.env.PI_CREW_DEBUG_BUDGET === "1") {
578
+ console.log(
579
+ "[DEBUG budget] params keys:",
580
+ Object.keys(params),
581
+ "budgetTotal:",
582
+ params.budgetTotal,
583
+ "budgetWarning:",
584
+ params.budgetWarning,
585
+ "budgetAbort:",
586
+ params.budgetAbort,
587
+ );
588
+ }
576
589
  const executionManifest = {
577
590
  ...updatedManifest,
578
591
  runtimeResolution,
579
592
  runConfig: executedConfig,
593
+ // Persist budget config on the manifest so it's observable post-run
594
+ // (events.jsonl, status reads, audits). The team-runner reads these
595
+ // from the input, but persisting them means consumers can verify
596
+ // enforcement was armed without re-parsing the original call params.
597
+ ...(params.budgetTotal !== undefined ? { budgetTotal: params.budgetTotal } : {}),
598
+ ...(params.budgetWarning !== undefined ? { budgetWarning: params.budgetWarning } : {}),
599
+ ...(params.budgetAbort !== undefined ? { budgetAbort: params.budgetAbort } : {}),
600
+ ...(params.budgetUnlimited !== undefined ? { budgetUnlimited: params.budgetUnlimited } : {}),
580
601
  updatedAt: new Date().toISOString(),
581
602
  };
582
603
  atomicWriteJson(paths.manifestPath, executionManifest);
@@ -862,6 +883,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
862
883
  metricRegistry: ctx.metricRegistry,
863
884
  onJsonEvent: ctx.onJsonEvent,
864
885
  workspaceId: ctx.sessionId ?? ctx.cwd,
886
+ budgetTotal: params.budgetTotal,
887
+ budgetWarning: params.budgetWarning,
888
+ budgetAbort: params.budgetAbort,
889
+ budgetUnlimited: params.budgetUnlimited,
865
890
  });
866
891
  } finally {
867
892
  unregisterActiveRun(updatedManifest.runId);
@@ -1006,6 +1031,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
1006
1031
  metricRegistry: ctx.metricRegistry,
1007
1032
  onJsonEvent: ctx.onJsonEvent,
1008
1033
  workspaceId: ctx.cwd,
1034
+ budgetTotal: params.budgetTotal,
1035
+ budgetWarning: params.budgetWarning,
1036
+ budgetAbort: params.budgetAbort,
1037
+ budgetUnlimited: params.budgetUnlimited,
1009
1038
  });
1010
1039
  } finally {
1011
1040
  unregisterActiveRun(updatedManifest.runId);
@@ -714,6 +714,9 @@ async function main(): Promise<void> {
714
714
  switch (manifest.runKind ?? "team-run") {
715
715
  default: {
716
716
  // Existing "team-run" path — unchanged behavior.
717
+ // Forward budget fields from manifest (set by team-tool/run.ts
718
+ // from params.budgetTotal/etc.) so the team-runner's
719
+ // checkPerTaskBudget guard actually arms.
717
720
  result = await executeTeamRun({
718
721
  manifest,
719
722
  tasks,
@@ -728,6 +731,10 @@ async function main(): Promise<void> {
728
731
  reliability: runConfig.reliability,
729
732
  workspaceId: manifest.ownerSessionId ?? manifest.cwd,
730
733
  signal: abortController.signal,
734
+ ...(manifest.budgetTotal !== undefined ? { budgetTotal: manifest.budgetTotal } : {}),
735
+ ...(manifest.budgetWarning !== undefined ? { budgetWarning: manifest.budgetWarning } : {}),
736
+ ...(manifest.budgetAbort !== undefined ? { budgetAbort: manifest.budgetAbort } : {}),
737
+ ...(manifest.budgetUnlimited !== undefined ? { budgetUnlimited: manifest.budgetUnlimited } : {}),
731
738
  });
732
739
  break;
733
740
  }