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/CHANGELOG.md +28 -0
- package/dist/build-meta.json +4725 -4677
- package/dist/index.mjs +2060 -1760
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
- package/src/extension/cross-extension-rpc.ts +162 -131
- package/src/extension/rpc-hmac.ts +236 -0
- package/src/extension/team-tool/run.ts +33 -4
- package/src/runtime/background-runner.ts +7 -0
- package/src/runtime/child-pi.ts +62 -81
- package/src/runtime/cross-extension-rpc.ts +22 -3
- package/src/runtime/dynamic-workflow-context.ts +3 -3
- package/src/runtime/task-runner.ts +4 -4
- package/src/runtime/team-runner.ts +130 -1
- package/src/runtime/tool-output-pruner.ts +5 -3
- package/src/state/event-log.ts +18 -0
- package/src/state/types.ts +5 -0
- package/src/tools/safe-bash-extension.ts +2 -2
- package/src/tools/safe-bash.ts +100 -0
- package/src/utils/env-filter.ts +49 -0
- package/src/utils/token-counter.ts +67 -0
- package/src/worktree/worktree-manager.ts +365 -1
- package/docs/A +0 -358
- package/docs/M-A +0 -357
- package/docs/Y +0 -357
- package/docs/a +0 -357
- package/docs/aA +0 -358
- package/docs//303/242mmaAAA/303/242 +0 -357
package/package.json
CHANGED
|
@@ -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
|
|
147
|
-
//
|
|
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(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
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:
|
|
235
|
+
error: error instanceof Error ? error.message : String(error),
|
|
171
236
|
});
|
|
172
|
-
return;
|
|
173
237
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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:
|
|
267
|
+
error: error instanceof Error ? error.message : String(error),
|
|
200
268
|
});
|
|
201
|
-
return;
|
|
202
269
|
}
|
|
203
|
-
|
|
204
|
-
|
|
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(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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:
|
|
326
|
+
error: error instanceof Error ? error.message : String(error),
|
|
263
327
|
});
|
|
264
|
-
return;
|
|
265
328
|
}
|
|
266
|
-
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|