pi-crew 0.5.25 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/README.md +13 -11
- package/docs/patterns/command-agent-skill.md +71 -0
- package/package.json +1 -1
- package/skills/council/SKILL.md +163 -0
- package/src/agents/agent-config.ts +4 -1
- package/src/agents/discover-agents.ts +1 -0
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/registration/commands.ts +2 -1
- package/src/extension/registration/subagent-tools.ts +2 -0
- package/src/extension/registration/team-tool.ts +2 -0
- package/src/extension/registration/viewers.ts +1 -0
- 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 +9 -4
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool.ts +2 -1
- package/src/hooks/registry.ts +9 -1
- package/src/hooks/types.ts +14 -0
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +58 -3
- package/src/runtime/background-runner.ts +1 -1
- package/src/runtime/chain-parser.ts +192 -0
- package/src/runtime/chain-runner.ts +58 -0
- package/src/runtime/child-pi.ts +1 -1
- package/src/runtime/crew-agent-records.ts +4 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +3 -4
- package/src/runtime/dynamic-script-runner.ts +7 -7
- package/src/runtime/foreground-watchdog.ts +2 -2
- package/src/runtime/intercom-bridge.ts +178 -0
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/plan-templates.ts +200 -0
- package/src/runtime/post-checks.ts +10 -3
- package/src/runtime/run-drift.ts +220 -0
- package/src/runtime/sandbox.ts +26 -20
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +4 -2
- package/src/runtime/skill-instructions.ts +4 -1
- package/src/runtime/subagent-manager.ts +20 -2
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-graph.ts +79 -0
- package/src/runtime/task-id.ts +148 -0
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner/context-retrieval.ts +172 -0
- package/src/runtime/task-runner.ts +39 -1
- package/src/runtime/team-runner.ts +7 -0
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/state/contracts.ts +2 -1
- package/src/state/event-log.ts +16 -5
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +9 -2
- package/src/state/memory-store.ts +244 -0
- package/src/state/observation-store.ts +177 -0
- package/src/state/state-store.ts +4 -2
- 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/run-action-dispatcher.ts +2 -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/fingerprint.ts +183 -0
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/safe-paths.ts +6 -0
- package/src/workflows/discover-workflows.ts +5 -1
- package/src/workflows/intermediate-store.ts +173 -0
- package/src/workflows/workflow-config.ts +8 -0
- package/src/worktree/cleanup.ts +8 -5
- package/src/worktree/worktree-manager.ts +1 -1
|
@@ -25,9 +25,14 @@ import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
|
25
25
|
import { locateRunCwd } from "../team-tool.ts";
|
|
26
26
|
import { configRecord, result, type TeamContext } from "./context.ts";
|
|
27
27
|
|
|
28
|
-
function globMatch(value: string, pattern: string): boolean {
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
export function globMatch(value: string, pattern: string): boolean {
|
|
29
|
+
// Prevent ReDoS: reject excessively long patterns
|
|
30
|
+
if (pattern.length > 200) return false;
|
|
31
|
+
const regex = pattern
|
|
32
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex special chars
|
|
33
|
+
.replace(/\*/g, "[^/]*") // * matches non-slash characters only
|
|
34
|
+
.replace(/\?/g, "[^/]"); // ? matches single non-slash
|
|
35
|
+
return new RegExp(`^${regex}$`).test(value);
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
function safeReadContainedFile(baseDir: string, filePath: string | undefined): string | undefined {
|
|
@@ -364,7 +369,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
364
369
|
const updatedTask = claimTask(task, owner);
|
|
365
370
|
const tasks = loaded.tasks.map((item) => item.id === task.id ? updatedTask : item);
|
|
366
371
|
saveRunTasks(loaded.manifest, tasks);
|
|
367
|
-
appendEvent(loaded.manifest.eventsPath, { type: "task.claimed", runId: loaded.manifest.runId, taskId: task.id, data: { owner, token:
|
|
372
|
+
appendEvent(loaded.manifest.eventsPath, { type: "task.claimed", runId: loaded.manifest.runId, taskId: task.id, data: { owner, token: "[REDACTED]", leasedUntil: updatedTask.claim?.leasedUntil } });
|
|
368
373
|
return result(JSON.stringify(updatedTask.claim, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
|
|
369
374
|
});
|
|
370
375
|
} catch (error) {
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { effectiveAutonomousConfig, parseConfig, type PiTeamsAutonomousConfig, type PiTeamsConfig } from "../../config/config.ts";
|
|
2
2
|
|
|
3
|
+
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
4
|
+
|
|
5
|
+
/** Recursively strip dangerous prototype-pollution keys from all levels of an object. */
|
|
6
|
+
export function sanitizeObject<T>(obj: T): T {
|
|
7
|
+
if (obj === null || obj === undefined || typeof obj !== "object") return obj;
|
|
8
|
+
if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item)) as T;
|
|
9
|
+
const safe: Record<string, unknown> = {};
|
|
10
|
+
for (const key of Object.keys(obj as Record<string, unknown>)) {
|
|
11
|
+
if (DANGEROUS_KEYS.has(key)) continue;
|
|
12
|
+
safe[key] = sanitizeObject((obj as Record<string, unknown>)[key]);
|
|
13
|
+
}
|
|
14
|
+
return safe as T;
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
export function autonomousPatchFromConfig(config: unknown): PiTeamsAutonomousConfig {
|
|
4
18
|
const rootPatch = parseConfig(config).autonomous;
|
|
5
19
|
if (rootPatch) return rootPatch;
|
|
@@ -11,7 +25,7 @@ export function configPatchFromConfig(config: unknown): PiTeamsConfig {
|
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
export function effectiveRunConfig(base: PiTeamsConfig, rawOverride: unknown): PiTeamsConfig {
|
|
14
|
-
const patch = parseConfig(rawOverride);
|
|
28
|
+
const patch = sanitizeObject(parseConfig(rawOverride));
|
|
15
29
|
return {
|
|
16
30
|
...base,
|
|
17
31
|
...patch,
|
|
@@ -1258,7 +1258,8 @@ export function registerCrewGlobalRegistry(registry: CrewRegistry): void {
|
|
|
1258
1258
|
registry;
|
|
1259
1259
|
}
|
|
1260
1260
|
|
|
1261
|
-
|
|
1261
|
+
/** @internal */
|
|
1262
|
+
function getCrewGlobalRegistry(): CrewRegistry | undefined {
|
|
1262
1263
|
return (globalThis as Record<symbol | string, unknown>)[
|
|
1263
1264
|
CREW_REGISTRY_KEY
|
|
1264
1265
|
] as CrewRegistry | undefined;
|
package/src/hooks/registry.ts
CHANGED
|
@@ -35,6 +35,14 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
|
|
|
35
35
|
// environments, all hooks should set workspaceId to prevent cross-workspace access.
|
|
36
36
|
const scopedHooks = hooks.filter((h) => !h.workspaceId || h.workspaceId === ctx.workspaceId);
|
|
37
37
|
if (scopedHooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
|
|
38
|
+
const POLLUTED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
39
|
+
function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
|
|
40
|
+
const clean: Record<string, unknown> = {};
|
|
41
|
+
for (const [k, v] of Object.entries(data)) {
|
|
42
|
+
if (!POLLUTED_KEYS.has(k)) clean[k] = v;
|
|
43
|
+
}
|
|
44
|
+
return clean;
|
|
45
|
+
}
|
|
38
46
|
const start = Date.now();
|
|
39
47
|
const diagnostics: string[] = [];
|
|
40
48
|
let capturedModifications: Record<string, unknown> | undefined;
|
|
@@ -45,7 +53,7 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
|
|
|
45
53
|
return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: result.reason };
|
|
46
54
|
}
|
|
47
55
|
if (result.outcome === "modify" && result.data) {
|
|
48
|
-
Object.assign(ctx, result.data);
|
|
56
|
+
Object.assign(ctx, sanitizeMergeData(result.data));
|
|
49
57
|
capturedModifications = { ...result.data };
|
|
50
58
|
}
|
|
51
59
|
} catch (error) {
|
package/src/hooks/types.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type HookName =
|
|
2
2
|
| "before_run_start"
|
|
3
|
+
| "after_run_complete"
|
|
3
4
|
| "before_task_start"
|
|
5
|
+
| "after_task_complete"
|
|
4
6
|
| "task_result"
|
|
5
7
|
| "before_cancel"
|
|
6
8
|
| "before_retry"
|
|
@@ -8,8 +10,20 @@ export type HookName =
|
|
|
8
10
|
| "before_cleanup"
|
|
9
11
|
| "before_publish"
|
|
10
12
|
| "session_before_switch"
|
|
13
|
+
| "session_after_connect"
|
|
14
|
+
| "session_after_disconnect"
|
|
11
15
|
| "run_recovery";
|
|
12
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Hook exit codes inspired by claude-mem's lifecycle architecture:
|
|
19
|
+
* - 0 = allow (success)
|
|
20
|
+
* - 1 = warn (non-blocking error, continue)
|
|
21
|
+
* - 2 = block (blocking error, stop)
|
|
22
|
+
*/
|
|
23
|
+
/** @internal */ const HOOK_EXIT_SUCCESS = 0 as const;
|
|
24
|
+
/** @internal */ const HOOK_EXIT_WARN = 1 as const;
|
|
25
|
+
/** @internal */ const HOOK_EXIT_BLOCK = 2 as const;
|
|
26
|
+
|
|
13
27
|
export type HookMode = "blocking" | "non_blocking";
|
|
14
28
|
export type HookOutcome = "allow" | "block" | "modify" | "diagnostic";
|
|
15
29
|
|
package/src/i18n.ts
CHANGED
|
@@ -129,13 +129,26 @@ export function t(key: Key, params?: Params): string {
|
|
|
129
129
|
* @example
|
|
130
130
|
* addTranslations("vi", { "agent.requiresPrompt": "Agent cần prompt." })
|
|
131
131
|
*/
|
|
132
|
+
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
133
|
+
|
|
134
|
+
function stripDangerousKeys<T extends Record<string, unknown>>(obj: T): T {
|
|
135
|
+
const safe: Record<string, unknown> = {};
|
|
136
|
+
for (const key of Object.keys(obj)) {
|
|
137
|
+
if (!DANGEROUS_KEYS.has(key)) {
|
|
138
|
+
safe[key] = obj[key];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return safe as T;
|
|
142
|
+
}
|
|
143
|
+
|
|
132
144
|
export function addTranslations(locale: string, bundle: Partial<Record<Key, string>>): void {
|
|
133
145
|
if (!locale) return;
|
|
146
|
+
const safeBundle = stripDangerousKeys(bundle as Record<string, unknown>) as Partial<Record<Key, string>>;
|
|
134
147
|
const existing = translations[locale];
|
|
135
148
|
if (existing) {
|
|
136
|
-
Object.assign(existing,
|
|
149
|
+
Object.assign(existing, safeBundle);
|
|
137
150
|
} else {
|
|
138
|
-
translations[locale] = { ...
|
|
151
|
+
translations[locale] = { ...safeBundle };
|
|
139
152
|
}
|
|
140
153
|
}
|
|
141
154
|
|
|
@@ -8,6 +8,78 @@ import type { MetricExporter } from "./adapter.ts";
|
|
|
8
8
|
|
|
9
9
|
const gzipAsync = promisify(gzip);
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* SSRF protection: validate that an OTLP endpoint URL does not target
|
|
13
|
+
* private/reserved networks or dangerous protocols.
|
|
14
|
+
* Rejects: localhost, loopback, private IPs, link-local, cloud metadata,
|
|
15
|
+
* IPv6 private, file:// and javascript:// protocols.
|
|
16
|
+
* Only http:// and https:// to public hostnames are allowed.
|
|
17
|
+
*/
|
|
18
|
+
export function validateEndpoint(endpoint: string): void {
|
|
19
|
+
let url: URL;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(endpoint);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`Invalid OTLP endpoint URL: ${endpoint}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Only allow http and https protocols
|
|
27
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`OTLP endpoint must use http:// or https:// protocol, got: ${url.protocol}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hostname = url.hostname.toLowerCase();
|
|
34
|
+
|
|
35
|
+
// Reject known localhost names
|
|
36
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
37
|
+
throw new Error(`OTLP endpoint must not target localhost: ${endpoint}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Reject IPv6 loopback and private
|
|
41
|
+
if (hostname.startsWith("[")) {
|
|
42
|
+
const bare = hostname.replace(/^\[|\]$/g, "");
|
|
43
|
+
if (bare === "::1") {
|
|
44
|
+
throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
|
|
45
|
+
}
|
|
46
|
+
if (bare.toLowerCase().startsWith("fd") || bare.toLowerCase().startsWith("fc")) {
|
|
47
|
+
throw new Error(`OTLP endpoint must not target private IPv6 address: ${endpoint}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Reject IPv4 private/reserved ranges
|
|
52
|
+
// Match plain IPv4 addresses (not hostnames that look like IPs)
|
|
53
|
+
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
54
|
+
if (ipv4Match) {
|
|
55
|
+
const octets = [Number(ipv4Match[1]), Number(ipv4Match[2]), Number(ipv4Match[3]), Number(ipv4Match[4])];
|
|
56
|
+
// 127.x.x.x - loopback
|
|
57
|
+
if (octets[0] === 127) {
|
|
58
|
+
throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
|
|
59
|
+
}
|
|
60
|
+
// 10.x.x.x - private class A
|
|
61
|
+
if (octets[0] === 10) {
|
|
62
|
+
throw new Error(`OTLP endpoint must not target private network (10.0.0.0/8): ${endpoint}`);
|
|
63
|
+
}
|
|
64
|
+
// 172.16.x.x - 172.31.x.x - private class B
|
|
65
|
+
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
|
|
66
|
+
throw new Error(`OTLP endpoint must not target private network (172.16.0.0/12): ${endpoint}`);
|
|
67
|
+
}
|
|
68
|
+
// 192.168.x.x - private class C
|
|
69
|
+
if (octets[0] === 192 && octets[1] === 168) {
|
|
70
|
+
throw new Error(`OTLP endpoint must not target private network (192.168.0.0/16): ${endpoint}`);
|
|
71
|
+
}
|
|
72
|
+
// 169.254.x.x - link-local / cloud metadata
|
|
73
|
+
if (octets[0] === 169 && octets[1] === 254) {
|
|
74
|
+
throw new Error(`OTLP endpoint must not target link-local/metadata endpoint (169.254.0.0/16): ${endpoint}`);
|
|
75
|
+
}
|
|
76
|
+
// 0.x.x.x - this network
|
|
77
|
+
if (octets[0] === 0) {
|
|
78
|
+
throw new Error(`OTLP endpoint must not target this-network address (0.0.0.0/8): ${endpoint}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
11
83
|
// FIX (Round 15): Cap the number of snapshots per push to prevent OOM when
|
|
12
84
|
// the metric registry has grown large. The OTLP HTTP spec allows many metrics
|
|
13
85
|
// in one payload, but a single push > 10_000 metrics would balloon the
|
|
@@ -71,6 +143,7 @@ export class OTLPExporter implements MetricExporter {
|
|
|
71
143
|
private readonly registry: MetricRegistry;
|
|
72
144
|
|
|
73
145
|
constructor(opts: OTLPExporterOptions, registry: MetricRegistry) {
|
|
146
|
+
validateEndpoint(opts.endpoint);
|
|
74
147
|
this.opts = opts;
|
|
75
148
|
this.registry = registry;
|
|
76
149
|
}
|
|
@@ -22,6 +22,7 @@ import type { TeamConfig } from "../teams/team-config.ts";
|
|
|
22
22
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
23
23
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
24
24
|
import { refreshTaskGraphQueues } from "./task-graph-scheduler.ts";
|
|
25
|
+
import { getPlanTemplate, renderPlanTemplate, listPlanTemplates } from "./plan-templates.ts";
|
|
25
26
|
|
|
26
27
|
export interface AdaptivePlanTask {
|
|
27
28
|
role: string;
|
|
@@ -70,6 +71,29 @@ export function extractAdaptivePlanJson(text: string): string | undefined {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
export function parseAdaptivePlan(text: string, allowedRoles: string[]): AdaptivePlan | undefined {
|
|
74
|
+
// Check if the text is a plan template reference: "template:<name>" with optional JSON variables
|
|
75
|
+
const templateRefMatch = text.match(/^template:([a-zA-Z0-9_-]+)/);
|
|
76
|
+
if (templateRefMatch?.[1]) {
|
|
77
|
+
const template = getPlanTemplate(templateRefMatch[1]);
|
|
78
|
+
if (template) {
|
|
79
|
+
// Try to extract variables from the remaining text
|
|
80
|
+
let variables: Record<string, string> = {};
|
|
81
|
+
try {
|
|
82
|
+
const varsJson = text.slice(templateRefMatch[0].length).trim();
|
|
83
|
+
if (varsJson) variables = JSON.parse(varsJson);
|
|
84
|
+
} catch { /* use empty variables */ }
|
|
85
|
+
const rendered = renderPlanTemplate(templateRefMatch[1], variables);
|
|
86
|
+
if (rendered) {
|
|
87
|
+
// Convert RenderedPlan → AdaptivePlan
|
|
88
|
+
const phases: AdaptivePlanPhase[] = rendered.phases.map(phase => ({
|
|
89
|
+
name: phase.name,
|
|
90
|
+
tasks: [{ role: phase.role, task: phase.task }],
|
|
91
|
+
}));
|
|
92
|
+
return phases.length ? { phases } : undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
73
97
|
const raw = extractAdaptivePlanJson(text);
|
|
74
98
|
if (!raw) return undefined;
|
|
75
99
|
let parsed: unknown;
|
|
@@ -69,7 +69,8 @@ export function applyAttentionState(manifest: TeamRunManifest, agent: CrewAgentR
|
|
|
69
69
|
return updated;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
/** @internal */
|
|
73
|
+
function applyLongRunningCheck(
|
|
73
74
|
manifest: TeamRunManifest,
|
|
74
75
|
agent: CrewAgentRecord,
|
|
75
76
|
config: CrewControlConfig,
|
|
@@ -105,7 +106,8 @@ export function applyLongRunningCheck(
|
|
|
105
106
|
return updated;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
/** @internal */
|
|
110
|
+
function trackConsecutiveToolFailure(
|
|
109
111
|
manifest: TeamRunManifest,
|
|
110
112
|
agent: CrewAgentRecord,
|
|
111
113
|
toolName: string,
|
|
@@ -140,7 +142,8 @@ export function trackConsecutiveToolFailure(
|
|
|
140
142
|
return updated;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
|
|
145
|
+
/** @internal */
|
|
146
|
+
function resetConsecutiveToolFailures(
|
|
144
147
|
manifest: TeamRunManifest,
|
|
145
148
|
agent: CrewAgentRecord,
|
|
146
149
|
): void {
|
|
@@ -5,6 +5,7 @@ import * as path from "node:path";
|
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
7
7
|
import { appendEvent } from "../state/event-log.ts";
|
|
8
|
+
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
8
9
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
9
10
|
|
|
10
11
|
|
|
@@ -131,8 +132,62 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
|
|
|
131
132
|
const logPath = path.join(manifest.stateRoot, "background.log");
|
|
132
133
|
fs.mkdirSync(manifest.stateRoot, { recursive: true });
|
|
133
134
|
|
|
134
|
-
//
|
|
135
|
-
|
|
135
|
+
// SECURITY FIX: Use sanitizeEnvSecrets with same allow-list as child-pi.ts
|
|
136
|
+
// to prevent leaking all env vars (including secrets) to detached background runner.
|
|
137
|
+
// Previously, destructuring only removed PI_CREW_PARENT_PID but kept everything else.
|
|
138
|
+
const filteredEnv = sanitizeEnvSecrets(process.env, {
|
|
139
|
+
allowList: [
|
|
140
|
+
// Model provider API keys (same as child-pi.ts)
|
|
141
|
+
"MINIMAX_API_KEY",
|
|
142
|
+
"MINIMAX_GROUP_ID",
|
|
143
|
+
"OPENAI_API_KEY",
|
|
144
|
+
"OPENAI_ORG_ID",
|
|
145
|
+
"ANTHROPIC_API_KEY",
|
|
146
|
+
"GOOGLE_API_KEY",
|
|
147
|
+
"GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
|
|
148
|
+
"AZURE_OPENAI_API_KEY",
|
|
149
|
+
"AZURE_OPENAI_ENDPOINT",
|
|
150
|
+
"AWS_ACCESS_KEY_ID",
|
|
151
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
152
|
+
"AWS_REGION",
|
|
153
|
+
"ZEU_API_KEY",
|
|
154
|
+
"ZERODEV_API_KEY",
|
|
155
|
+
// Essential non-secret vars
|
|
156
|
+
"PATH",
|
|
157
|
+
"HOME",
|
|
158
|
+
"USER",
|
|
159
|
+
"SHELL",
|
|
160
|
+
"TERM",
|
|
161
|
+
"LANG",
|
|
162
|
+
"LC_ALL",
|
|
163
|
+
"LC_COLLATE",
|
|
164
|
+
"LC_CTYPE",
|
|
165
|
+
"LC_MESSAGES",
|
|
166
|
+
"LC_MONETARY",
|
|
167
|
+
"LC_NUMERIC",
|
|
168
|
+
"LC_TIME",
|
|
169
|
+
"XDG_CONFIG_HOME",
|
|
170
|
+
"XDG_DATA_HOME",
|
|
171
|
+
"XDG_CACHE_HOME",
|
|
172
|
+
"XDG_RUNTIME_DIR",
|
|
173
|
+
"NVM_BIN",
|
|
174
|
+
"NVM_DIR",
|
|
175
|
+
"NVM_INC",
|
|
176
|
+
"NODE_PATH",
|
|
177
|
+
"NODE_DISABLE_COLORS",
|
|
178
|
+
"NODE_EXTRA_CA_CERTS",
|
|
179
|
+
"NPM_CONFIG_REGISTRY",
|
|
180
|
+
"NPM_CONFIG_USERCONFIG",
|
|
181
|
+
"NPM_CONFIG_GLOBALCONFIG",
|
|
182
|
+
"PI_*",
|
|
183
|
+
"PI_CREW_*",
|
|
184
|
+
"PI_TEAMS_*",
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
// Block execution control vars from leaking
|
|
188
|
+
delete filteredEnv.PI_CREW_PARENT_PID;
|
|
189
|
+
delete filteredEnv.PI_CREW_EXECUTE_WORKERS;
|
|
190
|
+
delete filteredEnv.PI_TEAMS_EXECUTE_WORKERS;
|
|
136
191
|
|
|
137
192
|
const loader = resolveTypeScriptLoader();
|
|
138
193
|
if (!loader) {
|
|
@@ -159,7 +214,7 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
|
|
|
159
214
|
detached: true,
|
|
160
215
|
setsid: true,
|
|
161
216
|
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
-
env:
|
|
217
|
+
env: filteredEnv,
|
|
163
218
|
windowsHide: true,
|
|
164
219
|
} as unknown as Parameters<typeof spawn>[2];
|
|
165
220
|
const child = spawn(process.execPath, command.args, spawnOpts);
|
|
@@ -138,7 +138,7 @@ async function main(): Promise<void> {
|
|
|
138
138
|
try {
|
|
139
139
|
const logPath = path.join(_cwd, ".crew/state/runs", _runId, "background.log");
|
|
140
140
|
logFd = fs.openSync(logPath, "a");
|
|
141
|
-
const origWrite = (
|
|
141
|
+
const origWrite = (_prefix: string) => (data: unknown, ...args: unknown[]) => {
|
|
142
142
|
const msg = [data, ...args].map(String).join(" ") + "\n";
|
|
143
143
|
fs.writeSync(logFd!, msg);
|
|
144
144
|
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chain/Parallel DSL Parser — parses workflow chain expressions.
|
|
3
|
+
*
|
|
4
|
+
* Syntax:
|
|
5
|
+
* step1 -> step2 -> parallel(step3, step4) -> step5
|
|
6
|
+
* step1:3 -> step2 --with-context -> step3
|
|
7
|
+
* parallel(a, b, parallel(c, d)) -> e
|
|
8
|
+
*
|
|
9
|
+
* Pattern origin: pi-prompt-template-model chain-parser.ts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ChainStep {
|
|
13
|
+
/** Step name (maps to agent or workflow step ID) */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Nested parallel group */
|
|
16
|
+
parallel?: ChainStep[];
|
|
17
|
+
/** Loop count (default: 1) */
|
|
18
|
+
loopCount?: number;
|
|
19
|
+
/** Pass predecessor output as context */
|
|
20
|
+
withContext?: boolean;
|
|
21
|
+
/** Positional arguments */
|
|
22
|
+
args?: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a chain DSL string into an AST.
|
|
27
|
+
*
|
|
28
|
+
* @throws {Error} on syntax errors (unclosed parens, empty names, etc.)
|
|
29
|
+
*/
|
|
30
|
+
export function parseChainDSL(input: string): ChainStep[] {
|
|
31
|
+
const tokens = tokenize(input);
|
|
32
|
+
const parser = new ChainParser(tokens);
|
|
33
|
+
return parser.parse();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Tokenizer ────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
type TokenType = "NAME" | "ARROW" | "LPAREN" | "RPAREN" | "COMMA" | "COLON" | "NUMBER" | "FLAG" | "QUOTED";
|
|
39
|
+
|
|
40
|
+
interface Token {
|
|
41
|
+
type: TokenType;
|
|
42
|
+
value: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tokenize(input: string): Token[] {
|
|
46
|
+
const tokens: Token[] = [];
|
|
47
|
+
let i = 0;
|
|
48
|
+
|
|
49
|
+
while (i < input.length) {
|
|
50
|
+
// Skip whitespace
|
|
51
|
+
if (/\s/.test(input[i]!)) { i++; continue; }
|
|
52
|
+
|
|
53
|
+
// Arrow ->
|
|
54
|
+
if (input[i] === "-" && input[i + 1] === ">") {
|
|
55
|
+
tokens.push({ type: "ARROW", value: "->" });
|
|
56
|
+
i += 2; continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Punctuation
|
|
60
|
+
if (input[i] === "(") { tokens.push({ type: "LPAREN", value: "(" }); i++; continue; }
|
|
61
|
+
if (input[i] === ")") { tokens.push({ type: "RPAREN", value: ")" }); i++; continue; }
|
|
62
|
+
if (input[i] === ",") { tokens.push({ type: "COMMA", value: "," }); i++; continue; }
|
|
63
|
+
if (input[i] === ":") { tokens.push({ type: "COLON", value: ":" }); i++; continue; }
|
|
64
|
+
|
|
65
|
+
// Quoted argument
|
|
66
|
+
if (input[i] === '"' || input[i] === "'") {
|
|
67
|
+
const quote = input[i];
|
|
68
|
+
let str = "";
|
|
69
|
+
i++; // skip opening quote
|
|
70
|
+
while (i < input.length && input[i] !== quote) {
|
|
71
|
+
if (input[i] === "\\" && i + 1 < input.length) { i++; str += input[i]; }
|
|
72
|
+
else { str += input[i]!; }
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
if (i >= input.length) throw new Error("Unclosed quoted string in chain DSL");
|
|
76
|
+
i++; // skip closing quote
|
|
77
|
+
tokens.push({ type: "QUOTED", value: str });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Flag --with-context
|
|
82
|
+
if (input[i] === "-" && input[i + 1] === "-") {
|
|
83
|
+
let flag = "";
|
|
84
|
+
i += 2;
|
|
85
|
+
while (i < input.length && /[a-zA-Z0-9_-]/.test(input[i]!)) { flag += input[i]; i++; }
|
|
86
|
+
tokens.push({ type: "FLAG", value: flag });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Number
|
|
91
|
+
if (/[0-9]/.test(input[i]!)) {
|
|
92
|
+
let num = "";
|
|
93
|
+
while (i < input.length && /[0-9]/.test(input[i]!)) { num += input[i]; i++; }
|
|
94
|
+
tokens.push({ type: "NUMBER", value: num });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Name
|
|
99
|
+
if (/[a-zA-Z_]/.test(input[i]!)) {
|
|
100
|
+
let name = "";
|
|
101
|
+
while (i < input.length && /[a-zA-Z0-9_.-]/.test(input[i]!)) { name += input[i]; i++; }
|
|
102
|
+
tokens.push({ type: "NAME", value: name });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
throw new Error(`Unexpected character '${input[i]}' at position ${i} in chain DSL`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return tokens;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Recursive Descent Parser ─────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
class ChainParser {
|
|
115
|
+
private pos = 0;
|
|
116
|
+
|
|
117
|
+
constructor(private tokens: Token[]) {}
|
|
118
|
+
|
|
119
|
+
parse(): ChainStep[] {
|
|
120
|
+
const steps: ChainStep[] = [];
|
|
121
|
+
steps.push(this.parseStep());
|
|
122
|
+
while (this.peek("ARROW")) {
|
|
123
|
+
this.consume("ARROW");
|
|
124
|
+
steps.push(this.parseStep());
|
|
125
|
+
}
|
|
126
|
+
if (this.pos < this.tokens.length) {
|
|
127
|
+
throw new Error(`Unexpected token '${this.tokens[this.pos]?.value}' at position ${this.pos}`);
|
|
128
|
+
}
|
|
129
|
+
return steps;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private parseStep(): ChainStep {
|
|
133
|
+
// Check for parallel(...) construct
|
|
134
|
+
if (this.peek("NAME", "parallel")) {
|
|
135
|
+
this.consume("NAME"); // eat "parallel"
|
|
136
|
+
this.consume("LPAREN");
|
|
137
|
+
const parallel: ChainStep[] = [];
|
|
138
|
+
parallel.push(this.parseStep());
|
|
139
|
+
while (this.peek("COMMA")) {
|
|
140
|
+
this.consume("COMMA");
|
|
141
|
+
parallel.push(this.parseStep());
|
|
142
|
+
}
|
|
143
|
+
this.consume("RPAREN");
|
|
144
|
+
const step: ChainStep = { name: "parallel", parallel };
|
|
145
|
+
this.parseModifiers(step);
|
|
146
|
+
return step;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Normal step name
|
|
150
|
+
const name = this.consume("NAME").value;
|
|
151
|
+
const step: ChainStep = { name };
|
|
152
|
+
|
|
153
|
+
// Parse modifiers
|
|
154
|
+
this.parseModifiers(step);
|
|
155
|
+
return step;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private parseModifiers(step: ChainStep): void {
|
|
159
|
+
while (this.pos < this.tokens.length) {
|
|
160
|
+
if (this.peek("COLON")) {
|
|
161
|
+
this.consume("COLON");
|
|
162
|
+
const num = this.consume("NUMBER");
|
|
163
|
+
step.loopCount = Number.parseInt(num.value, 10);
|
|
164
|
+
} else if (this.peek("FLAG", "with-context")) {
|
|
165
|
+
this.consume("FLAG");
|
|
166
|
+
step.withContext = true;
|
|
167
|
+
} else if (this.peek("QUOTED")) {
|
|
168
|
+
const arg = this.consume("QUOTED");
|
|
169
|
+
step.args = step.args ?? [];
|
|
170
|
+
step.args.push(arg.value);
|
|
171
|
+
} else {
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private peek(type: TokenType, value?: string): boolean {
|
|
178
|
+
const tok = this.tokens[this.pos];
|
|
179
|
+
if (!tok) return false;
|
|
180
|
+
if (tok.type !== type) return false;
|
|
181
|
+
if (value !== undefined && tok.value !== value) return false;
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private consume(type: TokenType): Token {
|
|
186
|
+
const tok = this.tokens[this.pos];
|
|
187
|
+
if (!tok) throw new Error(`Expected ${type} but reached end of chain DSL`);
|
|
188
|
+
if (tok.type !== type) throw new Error(`Expected ${type} but got ${tok.type}('${tok.value}')`);
|
|
189
|
+
this.pos++;
|
|
190
|
+
return tok;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { HandoffSummary, HandoffManager, TaskPacket, TaskResult } from "./handoff-manager.ts";
|
|
14
|
+
import { parseChainDSL } from "./chain-parser.ts";
|
|
15
|
+
import type { ChainStep as DSLChainStep } from "./chain-parser.ts";
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Single step in a chain.
|
|
@@ -123,10 +125,25 @@ export class ChainRunner {
|
|
|
123
125
|
* parseChain('"Research AI trends" -> "Analyze findings"')
|
|
124
126
|
* parseChain("@step1 --model claude-opus-3 -> @step2")
|
|
125
127
|
*
|
|
128
|
+
* Also supports DSL syntax from chain-parser for advanced constructs:
|
|
129
|
+
* parseChain("step1 -> parallel(step2, step3) -> step4")
|
|
130
|
+
* parseChain("step1:3 -> step2 --with-context -> step3")
|
|
131
|
+
*
|
|
126
132
|
* @param chainString - The chain string to parse
|
|
127
133
|
* @returns Parsed chain specification
|
|
128
134
|
*/
|
|
129
135
|
parseChain(chainString: string): ChainSpec {
|
|
136
|
+
// Try DSL parser first for advanced syntax (parallel groups, loop counts, flags)
|
|
137
|
+
// Falls back to the simple split parser if DSL parsing fails
|
|
138
|
+
if (this.hasDSLConstructs(chainString)) {
|
|
139
|
+
try {
|
|
140
|
+
const dslSteps = parseChainDSL(chainString);
|
|
141
|
+
return this.dslToChainSpec(dslSteps, chainString);
|
|
142
|
+
} catch {
|
|
143
|
+
// DSL parse failed; fall through to simple parser
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
const stepStrings = chainString.split("->").map(s => s.trim());
|
|
131
148
|
|
|
132
149
|
const steps: ChainStep[] = stepStrings.map((step, index) => {
|
|
@@ -337,6 +354,47 @@ export class ChainRunner {
|
|
|
337
354
|
return parsed;
|
|
338
355
|
}
|
|
339
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Detect if chainString uses DSL constructs that require chain-parser.
|
|
359
|
+
* DSL features: parallel(...), :loopCount, --with-context flag
|
|
360
|
+
*/
|
|
361
|
+
private hasDSLConstructs(chainString: string): boolean {
|
|
362
|
+
return /\bparallel\s*\(/.test(chainString) ||
|
|
363
|
+
/\w+:\d+\b/.test(chainString) ||
|
|
364
|
+
/--with-context/.test(chainString);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Convert DSL AST steps (from chain-parser) to ChainSpec.
|
|
369
|
+
*/
|
|
370
|
+
private dslToChainSpec(dslSteps: DSLChainStep[], chainString: string): ChainSpec {
|
|
371
|
+
const steps: ChainStep[] = dslSteps.map((dslStep, index) => {
|
|
372
|
+
// For parallel groups, use a synthetic step name
|
|
373
|
+
if (dslStep.parallel) {
|
|
374
|
+
return {
|
|
375
|
+
name: dslStep.name,
|
|
376
|
+
context: {
|
|
377
|
+
parallel: dslStep.parallel.map(p => ({ name: p.name, loopCount: p.loopCount, withContext: p.withContext, args: p.args })),
|
|
378
|
+
},
|
|
379
|
+
loopCount: dslStep.loopCount,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const step: ChainStep = { name: dslStep.name };
|
|
383
|
+
if (dslStep.loopCount) step.context = { ...step.context, loopCount: dslStep.loopCount };
|
|
384
|
+
if (dslStep.withContext) step.context = { ...step.context, withContext: true };
|
|
385
|
+
if (dslStep.args && dslStep.args.length > 0) step.context = { ...step.context, args: dslStep.args };
|
|
386
|
+
return step;
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Extract global overrides using existing logic
|
|
390
|
+
const globalModel = this.extractGlobalFlag(chainString, "global-model");
|
|
391
|
+
const globalSkill = this.extractGlobalFlag(chainString, "global-skill");
|
|
392
|
+
const globalThinking = this.extractGlobalFlag(chainString, "global-thinking") as "fast" | "standard" | "deep" | undefined;
|
|
393
|
+
const continueOnError = this.extractGlobalFlag(chainString, "continue-on-error") === "true";
|
|
394
|
+
|
|
395
|
+
return { steps, globalModel, globalSkill, globalThinking, continueOnError };
|
|
396
|
+
}
|
|
397
|
+
|
|
340
398
|
/**
|
|
341
399
|
* Sanitize identifier to prevent injection.
|
|
342
400
|
*/
|