pi-crew 0.6.0 → 0.6.3
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 +225 -0
- package/README.md +70 -31
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/package.json +2 -2
- package/src/agents/agent-config.ts +2 -1
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +4 -3
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +3 -1
- package/src/extension/registration/team-tool.ts +3 -1
- package/src/extension/registration/viewers.ts +3 -2
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +12 -7
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +60 -11
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +175 -47
- package/src/hooks/registry.ts +84 -12
- package/src/hooks/types.ts +12 -3
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +86 -3
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +67 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +165 -38
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +6 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +4 -5
- package/src/runtime/dynamic-script-runner.ts +9 -6
- package/src/runtime/foreground-watchdog.ts +3 -3
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +11 -4
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +42 -21
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +109 -64
- package/src/runtime/skill-instructions.ts +114 -33
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +265 -53
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +98 -67
- package/src/runtime/team-runner.ts +186 -20
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/contracts.ts +2 -1
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +270 -75
- package/src/state/health-store.ts +71 -0
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +109 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/state-store.ts +333 -44
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +4 -3
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +287 -10
- package/src/worktree/cleanup.ts +117 -26
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-crew",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
|
|
5
5
|
"author": "baphuongna",
|
|
6
6
|
"license": "MIT",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"ajv": "^8.20.0",
|
|
85
85
|
"cli-highlight": "^2.1.11",
|
|
86
86
|
"diff": "^5.2.0",
|
|
87
|
-
"jiti": "^2.
|
|
87
|
+
"jiti": "^2.7.0",
|
|
88
88
|
"typebox": "^1.1.38"
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
@@ -69,7 +69,8 @@ export function getAgentSessionOptions(role: string): {
|
|
|
69
69
|
* @param agent - The agent configuration
|
|
70
70
|
* @param role - The role name to use for tool restrictions (defaults to agent.name)
|
|
71
71
|
*/
|
|
72
|
-
|
|
72
|
+
/** @internal */
|
|
73
|
+
function buildAgentSessionOptions(
|
|
73
74
|
agent: AgentConfig,
|
|
74
75
|
role?: string,
|
|
75
76
|
): {
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import type { RunMetrics } from "../state/run-metrics.ts";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/** @internal */
|
|
8
|
+
interface FeedbackLoopStats {
|
|
8
9
|
runsObserved: number;
|
|
9
10
|
avgSuccessRate: number;
|
|
10
11
|
recommendations: string[];
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
/** @internal */
|
|
15
|
+
class FeedbackLoop {
|
|
14
16
|
private runs: RunMetrics[] = [];
|
|
15
17
|
private static readonly MAX_RUNS = 1000;
|
|
16
18
|
|
package/src/config/config.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PiTeamsConfigSchema,
|
|
9
9
|
} from "../schema/config-schema.ts";
|
|
10
10
|
import { withFileLockSync } from "../state/locks.ts";
|
|
11
|
+
import { atomicWriteFile } from "../state/atomic-write.ts";
|
|
11
12
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
12
13
|
import { projectCrewRoot, projectPiRoot } from "../utils/paths.ts";
|
|
13
14
|
import { suggestConfigKey } from "./suggestions.ts";
|
|
@@ -79,7 +80,17 @@ function resolveHomeDir(): string {
|
|
|
79
80
|
// directory (e.g. withIsolatedHome) set PI_TEAMS_HOME to a tmp dir
|
|
80
81
|
// under /tmp; we skip the check in test environments (NODE_ENV=test)
|
|
81
82
|
// so existing tests don't break.
|
|
82
|
-
if (process.env.
|
|
83
|
+
if (process.env.PI_CREW_SKIP_HOME_CHECK === "1") {
|
|
84
|
+
return envValue;
|
|
85
|
+
}
|
|
86
|
+
// NOTE: NODE_ENV=test bypass is intentional for test isolation only.
|
|
87
|
+
// It allows tests to use isolated temporary directories (e.g. withIsolatedHome
|
|
88
|
+
// sets PI_TEAMS_HOME to /tmp). This is NOT a security boundary — tests that
|
|
89
|
+
// need the validation skipped should set PI_CREW_SKIP_HOME_CHECK=1 explicitly.
|
|
90
|
+
// WARNING: Tests must NOT rely on PI_TEAMS_HOME for security boundaries in
|
|
91
|
+
// test environments. Any test verifying path-restriction behavior should set
|
|
92
|
+
// PI_CREW_SKIP_HOME_CHECK=1 and validate the path directly.
|
|
93
|
+
if (process.env.NODE_ENV === "test") {
|
|
83
94
|
return envValue;
|
|
84
95
|
}
|
|
85
96
|
try {
|
|
@@ -481,10 +492,22 @@ function mergeConfig(
|
|
|
481
492
|
};
|
|
482
493
|
if (Object.keys(merged.otlp.headers ?? {}).length === 0)
|
|
483
494
|
delete merged.otlp.headers;
|
|
484
|
-
// Validate OTLP headers for injection attacks
|
|
495
|
+
// Validate OTLP headers for injection attacks:
|
|
496
|
+
// - Check top-level keys for dangerous prototype pollution patterns
|
|
497
|
+
// - Block all control characters (except tab=0x09, newline=0x0A) to prevent
|
|
498
|
+
// header injection via CR/LF/zero-byte/etc.
|
|
485
499
|
const invalidHeaders: string[] = [];
|
|
486
500
|
for (const [k, v] of Object.entries(merged.otlp.headers ?? {})) {
|
|
487
|
-
|
|
501
|
+
// Check top-level key for dangerous names (only top-level keys are checked)
|
|
502
|
+
const checkKey = (key: string): boolean => {
|
|
503
|
+
const lowerKey = key.toLowerCase();
|
|
504
|
+
if (DANGEROUS_OBJECT_KEYS.has(lowerKey)) return true;
|
|
505
|
+
return false;
|
|
506
|
+
};
|
|
507
|
+
if (checkKey(k)) { invalidHeaders.push(k); continue; }
|
|
508
|
+
// Block any control characters except tab (0x09) in values
|
|
509
|
+
const valStr = String(v);
|
|
510
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(valStr)) { invalidHeaders.push(k); }
|
|
488
511
|
}
|
|
489
512
|
if (invalidHeaders.length > 0) {
|
|
490
513
|
delete merged.otlp.headers;
|
|
@@ -511,10 +534,57 @@ const LIMIT_CEILINGS = {
|
|
|
511
534
|
runtimeGraceTurns: 1_000,
|
|
512
535
|
} as const;
|
|
513
536
|
|
|
514
|
-
|
|
537
|
+
/**
|
|
538
|
+
* Keys that could allow prototype pollution if merged into plain objects.
|
|
539
|
+
* NOTE: This set is comprehensive for ES2023 and earlier. When upgrading JavaScript
|
|
540
|
+
* versions, verify whether new dangerous Object.prototype properties have been added
|
|
541
|
+
* that could enable prototype pollution attacks.
|
|
542
|
+
*/
|
|
543
|
+
const DANGEROUS_OBJECT_KEYS = new Set([
|
|
544
|
+
"__proto__",
|
|
545
|
+
"constructor",
|
|
546
|
+
"prototype",
|
|
547
|
+
"hasOwnProperty",
|
|
548
|
+
"toString",
|
|
549
|
+
"valueOf",
|
|
550
|
+
"isPrototypeOf",
|
|
551
|
+
"propertyIsEnumerable",
|
|
552
|
+
"toLocaleString",
|
|
553
|
+
"__defineGetter__",
|
|
554
|
+
"__defineSetter__",
|
|
555
|
+
"__lookupGetter__",
|
|
556
|
+
"__lookupSetter__",
|
|
557
|
+
]);
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Strips dangerous Object.prototype keys from an object.
|
|
561
|
+
* Returns a new object built with Object.create(null) to prevent
|
|
562
|
+
* prototype pollution attacks.
|
|
563
|
+
*/
|
|
564
|
+
function sanitizeObject(obj: Record<string, unknown>): Record<string, unknown> {
|
|
565
|
+
const sanitized: Record<string, unknown> = Object.create(null);
|
|
566
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
567
|
+
// Case-insensitive check to catch __Proto__, CONSTRUCTOR, etc.
|
|
568
|
+
const lowerKey = key.toLowerCase();
|
|
569
|
+
if (DANGEROUS_OBJECT_KEYS.has(lowerKey)) continue;
|
|
570
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
571
|
+
sanitized[key] = sanitizeObject(value as Record<string, unknown>);
|
|
572
|
+
} else {
|
|
573
|
+
sanitized[key] = value;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return sanitized;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
515
580
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
516
581
|
return undefined;
|
|
517
|
-
|
|
582
|
+
// Defensive: create a sanitized copy to prevent prototype pollution.
|
|
583
|
+
// Uses Object.create(null) so the result has no prototype chain.
|
|
584
|
+
// WARNING: The returned object has no prototype methods (no hasOwnProperty,
|
|
585
|
+
// toString, etc.). Use Object.hasOwn(obj, key) or
|
|
586
|
+
// Object.prototype.hasOwnProperty.call(obj, key) for property checks.
|
|
587
|
+
return sanitizeObject(value as Record<string, unknown>);
|
|
518
588
|
}
|
|
519
589
|
|
|
520
590
|
function parseWithSchema<T extends TSchema>(
|
|
@@ -1190,7 +1260,29 @@ function unsetPath(record: Record<string, unknown>, dottedPath: string): void {
|
|
|
1190
1260
|
|
|
1191
1261
|
function readConfigRecord(filePath: string): Record<string, unknown> {
|
|
1192
1262
|
if (!fs.existsSync(filePath)) return {};
|
|
1193
|
-
|
|
1263
|
+
// Defense-in-depth: reject config files larger than 10 MB before parsing.
|
|
1264
|
+
// This prevents memory exhaustion and blocks deeply nested JSON that could
|
|
1265
|
+
// cause stack overflow during parsing.
|
|
1266
|
+
const MAX_CONFIG_SIZE = 10 * 1024 * 1024;
|
|
1267
|
+
const stat = fs.statSync(filePath);
|
|
1268
|
+
if (stat.size > MAX_CONFIG_SIZE) {
|
|
1269
|
+
logInternalError(
|
|
1270
|
+
"config.file-too-large",
|
|
1271
|
+
new Error(`config file exceeds ${MAX_CONFIG_SIZE} bytes`),
|
|
1272
|
+
`path=${filePath}; size=${stat.size}`,
|
|
1273
|
+
);
|
|
1274
|
+
return {};
|
|
1275
|
+
}
|
|
1276
|
+
// Parse with depth limit to prevent stack overflow from deeply nested JSON.
|
|
1277
|
+
// Nesting beyond 100 levels is almost certainly an attack or malformed file.
|
|
1278
|
+
const MAX_JSON_DEPTH = 100;
|
|
1279
|
+
let depth = 0;
|
|
1280
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"), (_key, value) => {
|
|
1281
|
+
if (++depth > MAX_JSON_DEPTH) {
|
|
1282
|
+
throw new Error(`config JSON exceeds max depth ${MAX_JSON_DEPTH}`);
|
|
1283
|
+
}
|
|
1284
|
+
return value;
|
|
1285
|
+
}) as unknown;
|
|
1194
1286
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
1195
1287
|
return raw as Record<string, unknown>;
|
|
1196
1288
|
}
|
|
@@ -1244,7 +1336,7 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
|
|
|
1244
1336
|
const projectPath = projectConfigPath(cwd);
|
|
1245
1337
|
const projectConfig = readOptionalConfig(projectPath);
|
|
1246
1338
|
// SECURITY FIX: Merge project config FIRST, then user config on top.
|
|
1247
|
-
//
|
|
1339
|
+
// Precedence formula: merge(projectConfig, userConfig) = userConfig wins.
|
|
1248
1340
|
// Sensitive fields have already been sanitized by sanitizeProjectConfig.
|
|
1249
1341
|
let effectiveConfig = {};
|
|
1250
1342
|
if (projectConfig.exists) {
|
|
@@ -1257,6 +1349,7 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
|
|
|
1257
1349
|
...projectConfig.warnings,
|
|
1258
1350
|
...projectSafeConfig.warnings,
|
|
1259
1351
|
);
|
|
1352
|
+
// merge(base=projectConfig, override=userConfig) → override wins
|
|
1260
1353
|
effectiveConfig = mergeConfig(effectiveConfig, projectSafeConfig.config);
|
|
1261
1354
|
}
|
|
1262
1355
|
// User config always takes precedence over project config
|
|
@@ -1265,19 +1358,20 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
|
|
|
1265
1358
|
|
|
1266
1359
|
|
|
1267
1360
|
// `.pi/pi-crew.json` is the project-owned config file.
|
|
1268
|
-
//
|
|
1269
|
-
// This
|
|
1361
|
+
// Merge project config FIRST (base), then user config on top (override).
|
|
1362
|
+
// This ensures user preferences always take precedence over project settings.
|
|
1363
|
+
// Sensitive fields have already been sanitized by sanitizeProjectConfig.
|
|
1270
1364
|
const piCrewJsonPath = projectPiCrewJsonPath(cwd);
|
|
1271
1365
|
const piCrewJsonConfig = readOptionalConfig(piCrewJsonPath);
|
|
1272
1366
|
if (piCrewJsonConfig.exists) {
|
|
1273
1367
|
warnings.push(...piCrewJsonConfig.warnings);
|
|
1274
|
-
// Merge project config first, then user config on top
|
|
1275
1368
|
const projectPart = sanitizeProjectConfig(
|
|
1276
1369
|
piCrewJsonPath,
|
|
1277
1370
|
config,
|
|
1278
1371
|
piCrewJsonConfig.config,
|
|
1279
1372
|
);
|
|
1280
1373
|
warnings.push(...projectPart.warnings);
|
|
1374
|
+
// base=project config, override=user config → user wins
|
|
1281
1375
|
const mergedProject = mergeConfig(projectPart.config, config);
|
|
1282
1376
|
config = mergedProject;
|
|
1283
1377
|
paths.push(piCrewJsonPath);
|
|
@@ -1319,10 +1413,9 @@ export function updateConfig(
|
|
|
1319
1413
|
merged = parseConfig(raw);
|
|
1320
1414
|
}
|
|
1321
1415
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1322
|
-
|
|
1416
|
+
atomicWriteFile(
|
|
1323
1417
|
filePath,
|
|
1324
1418
|
`${JSON.stringify(merged, null, 2)}\n`,
|
|
1325
|
-
"utf-8",
|
|
1326
1419
|
);
|
|
1327
1420
|
return { path: filePath, config: merged };
|
|
1328
1421
|
});
|
|
@@ -1349,11 +1442,9 @@ export function updateAutonomousConfig(
|
|
|
1349
1442
|
? (current.autonomous as Record<string, unknown>)
|
|
1350
1443
|
: {};
|
|
1351
1444
|
current.autonomous = { ...currentAutonomous, ...patch };
|
|
1352
|
-
|
|
1353
|
-
fs.writeFileSync(
|
|
1445
|
+
atomicWriteFile(
|
|
1354
1446
|
filePath,
|
|
1355
1447
|
`${JSON.stringify(current, null, 2)}\n`,
|
|
1356
|
-
"utf-8",
|
|
1357
1448
|
);
|
|
1358
1449
|
return { path: filePath, config: parseConfig(current) };
|
|
1359
1450
|
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error code taxonomy for pi-crew.
|
|
3
|
+
* Maps to semantic categories matching fallow's E001-E004 pattern.
|
|
4
|
+
*/
|
|
5
|
+
// Implemented as const object + type alias (not `enum`) so that Node's
|
|
6
|
+
// `--experimental-strip-types` can load this module. TypeScript `enum`
|
|
7
|
+
// syntax is not supported in strip-only mode.
|
|
8
|
+
export const ErrorCode = {
|
|
9
|
+
FileReadError: "E001", // Cannot read a file
|
|
10
|
+
FileWriteError: "E002", // Cannot write a file
|
|
11
|
+
TaskNotFound: "E003", // Referenced task ID does not exist
|
|
12
|
+
InvalidStatusTransition: "E004", // Run/task status cannot legally transition
|
|
13
|
+
ConfigError: "E005", // Malformed config or missing required field
|
|
14
|
+
ResourceNotFound: "E006", // Agent/team/workflow not found in discovery paths
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type ErrorCode = typeof ErrorCode[keyof typeof ErrorCode];
|
|
18
|
+
|
|
19
|
+
const DEFAULT_HELP: Record<ErrorCode, string | undefined> = {
|
|
20
|
+
[ErrorCode.FileReadError]: "Check that the file exists and that the process has read permission.",
|
|
21
|
+
[ErrorCode.FileWriteError]: "Check that the disk is not full and that the process has write permission.",
|
|
22
|
+
[ErrorCode.TaskNotFound]: "The task may have been removed or the run may be in an inconsistent state. Use `team status` to verify.",
|
|
23
|
+
[ErrorCode.InvalidStatusTransition]: "Verify the run status using `team status` before retrying.",
|
|
24
|
+
[ErrorCode.ConfigError]: "Check the configuration file for syntax errors or missing required fields.",
|
|
25
|
+
[ErrorCode.ResourceNotFound]: "Use `team list` to see available agents, teams, and workflows.",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Structured error type for pi-crew.
|
|
30
|
+
* Display format:
|
|
31
|
+
* error[E001]: Failed to read manifest.json: not found
|
|
32
|
+
* context: while loading run state
|
|
33
|
+
* help: Check that the file exists and that the process has read permission.
|
|
34
|
+
*/
|
|
35
|
+
export class CrewError extends Error {
|
|
36
|
+
readonly code: ErrorCode;
|
|
37
|
+
help?: string;
|
|
38
|
+
private _context?: string;
|
|
39
|
+
|
|
40
|
+
constructor(code: ErrorCode, message: string, help?: string) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "CrewError";
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.help = help ?? DEFAULT_HELP[code];
|
|
45
|
+
Object.defineProperty(this, "message", { enumerable: true });
|
|
46
|
+
Object.defineProperty(this, "code", { enumerable: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
withContext(context: string): this {
|
|
50
|
+
this._context = context;
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
withHelp(help: string): this {
|
|
55
|
+
this.help = help;
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
toString(): string {
|
|
60
|
+
let out = `error[${this.code}]: ${this.message}`;
|
|
61
|
+
if (this._context) out += `\n context: ${this._context}`;
|
|
62
|
+
if (this.help) out += `\n help: ${this.help}`;
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const errors = {
|
|
68
|
+
fileRead(path: string, source: NodeJS.ErrnoException): CrewError {
|
|
69
|
+
return new CrewError(
|
|
70
|
+
ErrorCode.FileReadError,
|
|
71
|
+
`Failed to read ${path}: ${source.code?.toLowerCase() ?? "unknown"}`,
|
|
72
|
+
).withContext("file system read operation");
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
fileWrite(path: string, source: NodeJS.ErrnoException): CrewError {
|
|
76
|
+
return new CrewError(
|
|
77
|
+
ErrorCode.FileWriteError,
|
|
78
|
+
`Failed to write ${path}: ${source.code?.toLowerCase() ?? "unknown"}`,
|
|
79
|
+
).withContext("file system write operation");
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
taskNotFound(taskId: string, runId?: string): CrewError {
|
|
83
|
+
const msg = runId
|
|
84
|
+
? `Task '${taskId}' not found in run '${runId}'`
|
|
85
|
+
: `Task '${taskId}' not found`;
|
|
86
|
+
return new CrewError(ErrorCode.TaskNotFound, msg);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
invalidStatusTransition(from: string, to: string): CrewError {
|
|
90
|
+
return new CrewError(
|
|
91
|
+
ErrorCode.InvalidStatusTransition,
|
|
92
|
+
`Invalid run status transition: ${from} -> ${to}`,
|
|
93
|
+
);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
config(message: string): CrewError {
|
|
97
|
+
return new CrewError(ErrorCode.ConfigError, message)
|
|
98
|
+
.withContext("configuration loading");
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
resourceNotFound(type: string, name: string): CrewError {
|
|
102
|
+
return new CrewError(
|
|
103
|
+
ErrorCode.ResourceNotFound,
|
|
104
|
+
`${type} '${name}' not found in any discovery path`,
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
} as const;
|
|
@@ -47,7 +47,7 @@ function isTaskActive(task: TeamTaskState): boolean {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function markActiveTasksAndAgentsFailed(run: TeamRunManifest, message: string): void {
|
|
50
|
-
const loaded = loadRunManifestById(run.cwd, run.runId);
|
|
50
|
+
const loaded = loadRunManifestById(run.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
51
51
|
const tasks = loaded?.tasks ?? [];
|
|
52
52
|
const failedAt = new Date().toISOString();
|
|
53
53
|
if (tasks.some(isTaskActive)) {
|
|
@@ -73,7 +73,7 @@ export function markDeadAsyncRunIfNeeded(run: TeamRunManifest, now = Date.now(),
|
|
|
73
73
|
const asyncPid = run.async.pid;
|
|
74
74
|
const message = `Background runner died unexpectedly; check background.log (${liveness.detail}).`;
|
|
75
75
|
return withRunLockSync(run, () => {
|
|
76
|
-
const fresh = loadRunManifestById(run.cwd, run.runId);
|
|
76
|
+
const fresh = loadRunManifestById(run.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
77
77
|
if (!fresh || !isActiveRunStatus(fresh.manifest.status)) return undefined;
|
|
78
78
|
const failed = updateRunStatus(fresh.manifest, "failed", message);
|
|
79
79
|
markActiveTasksAndAgentsFailed(failed, message);
|
|
@@ -125,6 +125,10 @@ export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifie
|
|
|
125
125
|
logInternalError("async-notifier", error, `interval=${intervalMs}`);
|
|
126
126
|
}
|
|
127
127
|
}, intervalMs);
|
|
128
|
+
// Defense-in-depth: never let the notifier timer keep the event loop alive.
|
|
129
|
+
// If stopAsyncRunNotifier is missed (session switch race), the next run of
|
|
130
|
+
// this interval is harmless, but the timer must not block process exit.
|
|
131
|
+
if (typeof state.interval.unref === "function") state.interval.unref();
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
export function stopAsyncRunNotifier(state: AsyncNotifierState): void {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
3
|
+
import { cleanupAllTrackedTempDirs } from "../runtime/pi-args.ts";
|
|
3
4
|
// NOTE: globalProgressTracker import kept for documentation but not directly used
|
|
4
5
|
// since we don't have agent IDs to untrack. Actual progress clearing should be
|
|
5
6
|
// handled by the progress tracker itself on shutdown.
|
|
@@ -112,12 +113,14 @@ async function cleanupChildProcesses(): Promise<void> {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
async function cleanupTempDirectories(): Promise<void> {
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// or the specific code that creates temporary workspaces.
|
|
116
|
+
// Clean up every temp dir created in this process. Previously this was
|
|
117
|
+
// a stub that just logged; it caused /tmp/pi-crew-* dirs to accumulate
|
|
118
|
+
// from killed test runs and child-pi invocations. See issue #<n>.
|
|
119
119
|
try {
|
|
120
|
-
|
|
120
|
+
const result = cleanupAllTrackedTempDirs();
|
|
121
|
+
if (result.cleaned > 0) {
|
|
122
|
+
console.log(`[pi-crew] Cleaned ${result.cleaned} tracked temp dirs (${result.failed} failed)`);
|
|
123
|
+
}
|
|
121
124
|
} catch (error) {
|
|
122
125
|
logInternalError("crew-cleanup.temp", error);
|
|
123
126
|
}
|
|
@@ -32,6 +32,8 @@ function requestId(raw: unknown): string | undefined {
|
|
|
32
32
|
|
|
33
33
|
function reply(events: EventBusLike, channel: string, id: string | undefined, payload: RpcReply): void {
|
|
34
34
|
if (!id) return;
|
|
35
|
+
// SECURITY: Validate requestId format to prevent channel injection.
|
|
36
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return;
|
|
35
37
|
events.emit(`${channel}:reply:${id}`, payload);
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -59,6 +61,36 @@ function isAllowedRpcOperation(operation: string): boolean {
|
|
|
59
61
|
return RPC_ALLOWED_OPERATIONS.has(operation);
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
// SECURITY (HIGH #4 fix): In-memory rate limiter for RPC run requests.
|
|
65
|
+
// Prevents any extension from spawning unlimited child processes.
|
|
66
|
+
const RPC_RATE_LIMIT_MAX = 5; // Max 5 RPC run requests...
|
|
67
|
+
const RPC_RATE_LIMIT_WINDOW_MS = 60_000; // ...per 60 seconds
|
|
68
|
+
const rpcRunTimestamps: number[] = [];
|
|
69
|
+
|
|
70
|
+
function checkRpcRateLimit(): { allowed: boolean; retryAfterMs?: number } {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
// Evict entries older than the window
|
|
73
|
+
const cutoff = now - RPC_RATE_LIMIT_WINDOW_MS;
|
|
74
|
+
while (rpcRunTimestamps.length > 0 && rpcRunTimestamps[0] < cutoff) {
|
|
75
|
+
rpcRunTimestamps.shift();
|
|
76
|
+
}
|
|
77
|
+
if (rpcRunTimestamps.length >= RPC_RATE_LIMIT_MAX) {
|
|
78
|
+
const oldestInWindow = rpcRunTimestamps[0];
|
|
79
|
+
const retryAfterMs = oldestInWindow + RPC_RATE_LIMIT_WINDOW_MS - now;
|
|
80
|
+
return { allowed: false, retryAfterMs: Math.max(retryAfterMs, 1000) };
|
|
81
|
+
}
|
|
82
|
+
return { allowed: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function recordRpcRun(): void {
|
|
86
|
+
rpcRunTimestamps.push(Date.now());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Reset the RPC rate limiter. Used primarily for testing. */
|
|
90
|
+
export function resetRpcRateLimit(): void {
|
|
91
|
+
rpcRunTimestamps.length = 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
62
94
|
function isAllowedRpcRunParams(params: TeamToolParamsValue): { ok: boolean; error?: string } {
|
|
63
95
|
// SECURITY: Require explicit intent for any RPC-initiated run creation.
|
|
64
96
|
// This prevents malicious extensions from spawning child Pi processes silently.
|
|
@@ -83,6 +115,12 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => vo
|
|
|
83
115
|
return typeof unsub === "function" ? unsub : () => {};
|
|
84
116
|
}
|
|
85
117
|
|
|
118
|
+
// SECURITY TRUST BOUNDARY: RPC channels (pi-crew:rpc:run, pi-crew:rpc:status,
|
|
119
|
+
// pi-crew:rpc:live-control) are accessible to any extension on the shared event
|
|
120
|
+
// bus. Mitigations applied: rate limiting (RPC_RATE_LIMIT_MAX), explicit intent
|
|
121
|
+
// requirement for runs, operation allowlist for live-control reads, and cwd
|
|
122
|
+
// containment validation. A full fix requires event-bus-level origin signing.
|
|
123
|
+
|
|
86
124
|
export function registerPiCrewRpc(events: EventBusLike | undefined, getCtx: () => ExtensionContext | undefined): PiCrewRpcHandle | undefined {
|
|
87
125
|
if (!events) return undefined;
|
|
88
126
|
const unsubs = [
|
|
@@ -90,6 +128,16 @@ export function registerPiCrewRpc(events: EventBusLike | undefined, getCtx: () =
|
|
|
90
128
|
on(events, "pi-crew:rpc:run", async (raw) => {
|
|
91
129
|
const id = requestId(raw);
|
|
92
130
|
try {
|
|
131
|
+
// SECURITY (HIGH #4 fix): Rate limit RPC run requests
|
|
132
|
+
const rateLimit = checkRpcRateLimit();
|
|
133
|
+
if (!rateLimit.allowed) {
|
|
134
|
+
reply(events, "pi-crew:rpc:run", id, {
|
|
135
|
+
success: false,
|
|
136
|
+
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.`,
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
recordRpcRun();
|
|
93
141
|
const ctx = getCtx();
|
|
94
142
|
if (!ctx) throw new Error("No active pi-crew session context.");
|
|
95
143
|
// Validate payload: only allow known fields from TeamToolParamsValue
|