@bacnh85/pi-subagent 0.5.0 → 0.7.0
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 +56 -0
- package/README.md +127 -4
- package/agent-format.md +12 -2
- package/agents/general-purpose.md +1 -0
- package/agents/reviewer.md +2 -0
- package/agents/scout.md +2 -0
- package/agents/worker.md +1 -0
- package/extensions/agents.ts +120 -10
- package/extensions/index.ts +309 -235
- package/extensions/render.ts +7 -7
- package/extensions/runner.ts +88 -45
- package/extensions/security.ts +504 -0
- package/extensions/service.ts +29 -18
- package/extensions/thread-viewer.ts +4 -127
- package/extensions/threads.ts +4 -11
- package/package.json +26 -11
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security and validation helpers for pi-subagent.
|
|
3
|
+
*
|
|
4
|
+
* Centralizes all trust-boundary checks so that internal callers (tool handler,
|
|
5
|
+
* service/event path, parallel executor, chain executor) cannot bypass them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Constants
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Tools that child agents may use. The subagent tool is never included. */
|
|
16
|
+
export const ALLOWED_CHILD_TOOLS = [
|
|
17
|
+
"read",
|
|
18
|
+
"grep",
|
|
19
|
+
"find",
|
|
20
|
+
"ls",
|
|
21
|
+
"bash",
|
|
22
|
+
"edit",
|
|
23
|
+
"write",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export const READ_ONLY_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
|
|
27
|
+
export const MUTATION_TOOLS: readonly string[] = ["edit", "write"];
|
|
28
|
+
export const EXECUTION_TOOLS: readonly string[] = ["bash"];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Default timeout applied to every child execution unless an explicit timeout
|
|
32
|
+
* is provided. Children may request a shorter-but-not-longer timeout within
|
|
33
|
+
* the allowed range.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1_000; // 10 minutes
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Absolute maximum timeout. Any requested value above this cap is rejected
|
|
39
|
+
* at validation time rather than silently clamped.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_TIMEOUT_MS = 60 * 60 * 1_000; // 60 minutes
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Canonical result status
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export type SubagentStatus = "success" | "partial" | "error" | "aborted" | "timeout";
|
|
48
|
+
|
|
49
|
+
/** Pi SDK stop reasons that indicate successful completion. */
|
|
50
|
+
const SUCCESS_REASONS = new Set(["stop", "end_turn", "completed"]);
|
|
51
|
+
|
|
52
|
+
/** Pi SDK stop reasons that indicate a partial / truncated response. */
|
|
53
|
+
const PARTIAL_REASONS = new Set(["length", "max_tokens", "context_limit"]);
|
|
54
|
+
|
|
55
|
+
/** Pi SDK stop reasons that indicate a provider or tool error. */
|
|
56
|
+
const ERROR_REASONS = new Set([
|
|
57
|
+
"error",
|
|
58
|
+
"tool_error",
|
|
59
|
+
"authentication_error",
|
|
60
|
+
"provider_error",
|
|
61
|
+
"content_filter",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map a raw stop reason string to a canonical SubagentStatus.
|
|
66
|
+
*
|
|
67
|
+
* `isAborted` and `isTimeout` take precedence because the Pi SDK may emit
|
|
68
|
+
* "aborted" or "error" for both cases. Callers should pass the flags derived
|
|
69
|
+
* from their own abort controllers.
|
|
70
|
+
*/
|
|
71
|
+
export function classifyStopReason(
|
|
72
|
+
reason: string | undefined,
|
|
73
|
+
isAborted: boolean,
|
|
74
|
+
isTimeout: boolean,
|
|
75
|
+
): SubagentStatus {
|
|
76
|
+
if (isAborted) return "aborted";
|
|
77
|
+
if (isTimeout) return "timeout";
|
|
78
|
+
if (!reason) return "success";
|
|
79
|
+
if (SUCCESS_REASONS.has(reason)) return "success";
|
|
80
|
+
if (PARTIAL_REASONS.has(reason)) return "partial";
|
|
81
|
+
if (ERROR_REASONS.has(reason)) return "error";
|
|
82
|
+
// Unknown stop reason — classify conservatively.
|
|
83
|
+
return "error";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Safe working-directory resolver
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export interface SafeCwdOptions {
|
|
91
|
+
/** The workspace root (parent session's cwd). Must be an absolute path. */
|
|
92
|
+
workspaceRoot: string;
|
|
93
|
+
/** The child's requested working directory, if any. */
|
|
94
|
+
childCwd?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Trusted user setting that allows child cwd outside the workspace.
|
|
97
|
+
* Must never come from the tool-calling model.
|
|
98
|
+
*/
|
|
99
|
+
allowExternalCwd?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SafeCwdResult {
|
|
103
|
+
path: string;
|
|
104
|
+
error?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve a child working directory that is guaranteed to be inside the
|
|
109
|
+
* workspace root.
|
|
110
|
+
*
|
|
111
|
+
* Rules:
|
|
112
|
+
* - No childCwd → return workspaceRoot.
|
|
113
|
+
* - Relative paths are resolved relative to workspaceRoot.
|
|
114
|
+
* - Absolute paths outside workspaceRoot are rejected unless allowExternalCwd.
|
|
115
|
+
* - `..` traversal that escapes workspaceRoot is rejected.
|
|
116
|
+
* - Symlinks are resolved via realpath and checked against workspaceRoot.
|
|
117
|
+
* - Non-existent directories are rejected.
|
|
118
|
+
* - Paths that are files instead of directories are rejected.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveSafeCwd(options: SafeCwdOptions): SafeCwdResult {
|
|
121
|
+
const { workspaceRoot, childCwd, allowExternalCwd } = options;
|
|
122
|
+
|
|
123
|
+
// Normalise workspace root to an absolute canonical path.
|
|
124
|
+
const resolvedRoot = resolveCanonical(workspaceRoot);
|
|
125
|
+
if (!resolvedRoot) {
|
|
126
|
+
return { path: "", error: `Workspace root does not exist: ${workspaceRoot}` };
|
|
127
|
+
}
|
|
128
|
+
if (!isDirectorySync(resolvedRoot)) {
|
|
129
|
+
return { path: "", error: `Workspace root is not a directory: ${workspaceRoot}` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// No child cwd → use workspace root.
|
|
133
|
+
if (!childCwd) {
|
|
134
|
+
return { path: resolvedRoot };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolve the child path.
|
|
138
|
+
const absPath = path.resolve(resolvedRoot, childCwd);
|
|
139
|
+
|
|
140
|
+
// If the resolved path escapes the workspace via `..`, the resolved path
|
|
141
|
+
// will differ from the canonical workspace root prefix. We check by
|
|
142
|
+
// resolving the canonical absolute path.
|
|
143
|
+
const canonicalPath = resolveCanonical(absPath);
|
|
144
|
+
if (!canonicalPath) {
|
|
145
|
+
return { path: "", error: `Child working directory does not exist: ${childCwd}` };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!isDirectorySync(canonicalPath)) {
|
|
149
|
+
return { path: "", error: `Child working directory is a file, not a directory: ${childCwd}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check if the child path is inside the workspace.
|
|
153
|
+
if (!isPathInside(canonicalPath, resolvedRoot)) {
|
|
154
|
+
if (allowExternalCwd) {
|
|
155
|
+
return { path: canonicalPath };
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
path: "",
|
|
159
|
+
error:
|
|
160
|
+
`Child working directory "${childCwd}" is outside the workspace root "${workspaceRoot}". ` +
|
|
161
|
+
`Paths outside the workspace are rejected by default.`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { path: canonicalPath };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check if `child` is inside `parent` or equal to it, after resolving both
|
|
170
|
+
* to canonical paths. Rejects `..` traversal that escapes `parent`.
|
|
171
|
+
*/
|
|
172
|
+
function isPathInside(child: string, parent: string): boolean {
|
|
173
|
+
// Both must be absolute.
|
|
174
|
+
if (!path.isAbsolute(child) || !path.isAbsolute(parent)) return false;
|
|
175
|
+
|
|
176
|
+
const relative = path.relative(parent, child);
|
|
177
|
+
// relative must not start with ".." and must not be an absolute path.
|
|
178
|
+
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve a path to its canonical real path, or return null if it doesn't exist.
|
|
183
|
+
*/
|
|
184
|
+
function resolveCanonical(p: string): string | null {
|
|
185
|
+
try {
|
|
186
|
+
return fs.realpathSync(p);
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Synchronously check if a path is a directory.
|
|
194
|
+
*/
|
|
195
|
+
function isDirectorySync(p: string): boolean {
|
|
196
|
+
try {
|
|
197
|
+
return fs.statSync(p).isDirectory();
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Tool allowlist validation
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
export interface ValidateToolsOptions {
|
|
208
|
+
/** Tool names from the agent definition or service override. */
|
|
209
|
+
tools: string[];
|
|
210
|
+
/** When true, only read-only tools are permitted. Mutation/execution tools are rejected. */
|
|
211
|
+
readOnly?: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ValidateToolsResult {
|
|
215
|
+
/** Deduplicated and validated tool names. */
|
|
216
|
+
tools: string[];
|
|
217
|
+
/** Validation errors, if any. Empty array means valid. */
|
|
218
|
+
errors: string[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Validate agent tool names against the allowlist.
|
|
223
|
+
*
|
|
224
|
+
* Always strips or rejects `subagent` to prevent recursive delegation.
|
|
225
|
+
* Deduplicates tool names.
|
|
226
|
+
* When `readOnly` is true, only READ_ONLY_TOOLS are permitted.
|
|
227
|
+
*/
|
|
228
|
+
export function validateAgentTools(options: ValidateToolsOptions): ValidateToolsResult {
|
|
229
|
+
const { readOnly = false } = options;
|
|
230
|
+
const seen = new Set<string>();
|
|
231
|
+
const tools: string[] = [];
|
|
232
|
+
const errors: string[] = [];
|
|
233
|
+
|
|
234
|
+
for (const raw of options.tools) {
|
|
235
|
+
const tool = raw.trim();
|
|
236
|
+
if (!tool) continue;
|
|
237
|
+
|
|
238
|
+
// Reject subagent regardless of casing (Pi tool names are case-sensitive
|
|
239
|
+
// but "subagent" should never pass through).
|
|
240
|
+
if (tool.toLowerCase() === "subagent") {
|
|
241
|
+
errors.push(`Tool "subagent" is not allowed in child agents (recursive delegation is prevented).`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Check against allowlist
|
|
246
|
+
if (!ALLOWED_CHILD_TOOLS.includes(tool as any)) {
|
|
247
|
+
errors.push(`Unknown tool "${tool}". Allowed tools: ${ALLOWED_CHILD_TOOLS.join(", ")}.`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Check read-only constraint
|
|
252
|
+
if (readOnly && !READ_ONLY_TOOLS.includes(tool)) {
|
|
253
|
+
errors.push(
|
|
254
|
+
`Tool "${tool}" is not allowed in read-only mode. Read-only tools: ${READ_ONLY_TOOLS.join(", ")}.`,
|
|
255
|
+
);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Deduplicate
|
|
260
|
+
if (seen.has(tool)) continue;
|
|
261
|
+
seen.add(tool);
|
|
262
|
+
tools.push(tool);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { tools, errors };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Timeout validation
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
export interface NormalizeTimeoutOptions {
|
|
273
|
+
/** Requested timeout value from tool params, if any. */
|
|
274
|
+
requested?: number;
|
|
275
|
+
/** Global default when no timeout is specified. */
|
|
276
|
+
defaultValue?: number;
|
|
277
|
+
/** Absolute maximum allowed value. */
|
|
278
|
+
maxValue?: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface NormalizeTimeoutResult {
|
|
282
|
+
/** The validated timeout in milliseconds, or undefined if none was set and no default applies. */
|
|
283
|
+
timeoutMs: number | undefined;
|
|
284
|
+
/** Error message if the value is invalid. */
|
|
285
|
+
error?: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Validate and normalise a timeout value.
|
|
290
|
+
*
|
|
291
|
+
* Rules:
|
|
292
|
+
* - Must be a finite integer if provided.
|
|
293
|
+
* - Must be positive (> 0).
|
|
294
|
+
* - Must not exceed maxValue.
|
|
295
|
+
* - If no value is provided, defaultValue is used.
|
|
296
|
+
* - Returns undefined only when no value and no default are configured.
|
|
297
|
+
*/
|
|
298
|
+
export function normalizeTimeout(options: NormalizeTimeoutOptions): NormalizeTimeoutResult {
|
|
299
|
+
const { requested, defaultValue = DEFAULT_TIMEOUT_MS, maxValue = MAX_TIMEOUT_MS } = options;
|
|
300
|
+
|
|
301
|
+
if (requested === undefined || requested === null) {
|
|
302
|
+
// No explicit timeout — apply default.
|
|
303
|
+
return { timeoutMs: defaultValue };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (typeof requested !== "number" || !Number.isFinite(requested)) {
|
|
307
|
+
return { timeoutMs: undefined, error: "Timeout must be a finite number." };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!Number.isInteger(requested)) {
|
|
311
|
+
return { timeoutMs: undefined, error: "Timeout must be an integer (milliseconds)." };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (requested <= 0) {
|
|
315
|
+
return { timeoutMs: undefined, error: "Timeout must be a positive integer." };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (requested > maxValue) {
|
|
319
|
+
return {
|
|
320
|
+
timeoutMs: undefined,
|
|
321
|
+
error: `Timeout ${requested}ms exceeds maximum allowed ${maxValue}ms (${maxValue / 60_000} minutes).`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { timeoutMs: requested };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Abort signal composition
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Combine multiple abort signals into one, with correct listener cleanup.
|
|
334
|
+
*
|
|
335
|
+
* Works as a polyfill for AbortSignal.any() with proper cleanup semantics:
|
|
336
|
+
* - If any input signal is already aborted, the returned signal aborts immediately.
|
|
337
|
+
* - All event listeners are removed after the first abort or when `cleanup()` is called.
|
|
338
|
+
*
|
|
339
|
+
* Returns an object with the combined signal and a cleanup function.
|
|
340
|
+
* Callers MUST call `cleanup()` in a `finally` block.
|
|
341
|
+
*/
|
|
342
|
+
export function createCombinedAbortSignal(
|
|
343
|
+
signals: (AbortSignal | undefined | null | false)[],
|
|
344
|
+
): { signal: AbortSignal; cleanup: () => void } {
|
|
345
|
+
const valid = signals.filter(Boolean) as AbortSignal[];
|
|
346
|
+
|
|
347
|
+
if (valid.length === 0) {
|
|
348
|
+
const controller = new AbortController();
|
|
349
|
+
return { signal: controller.signal, cleanup: () => {} };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (valid.length === 1) {
|
|
353
|
+
return { signal: valid[0], cleanup: () => {} };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Check if any is already aborted.
|
|
357
|
+
for (const sig of valid) {
|
|
358
|
+
if (sig.aborted) {
|
|
359
|
+
const controller = new AbortController();
|
|
360
|
+
controller.abort(sig.reason);
|
|
361
|
+
return { signal: controller.signal, cleanup: () => {} };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Use native AbortSignal.any when available.
|
|
366
|
+
if (typeof (AbortSignal as any).any === "function") {
|
|
367
|
+
const combined = (AbortSignal as any).any(valid);
|
|
368
|
+
return { signal: combined, cleanup: () => {} };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Manual fallback: create a controller and forward all signals.
|
|
372
|
+
const controller = new AbortController();
|
|
373
|
+
const listeners: Array<() => void> = [];
|
|
374
|
+
|
|
375
|
+
for (const sig of valid) {
|
|
376
|
+
const handler = () => {
|
|
377
|
+
controller.abort(sig.reason);
|
|
378
|
+
};
|
|
379
|
+
sig.addEventListener("abort", handler, { once: true });
|
|
380
|
+
listeners.push(() => sig.removeEventListener("abort", handler));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const cleanup = () => {
|
|
384
|
+
for (const remove of listeners) {
|
|
385
|
+
try {
|
|
386
|
+
remove();
|
|
387
|
+
} catch {
|
|
388
|
+
// Best-effort cleanup.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return { signal: controller.signal, cleanup };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Execution request validation
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
export const MAX_PARALLEL_TASKS = 8;
|
|
401
|
+
export const MAX_CONCURRENCY = 4;
|
|
402
|
+
export const MAX_CHAIN_LENGTH = 50;
|
|
403
|
+
export const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB
|
|
404
|
+
export const MAX_INSTRUCTIONS_LENGTH = 16 * 1024; // 16 KB
|
|
405
|
+
|
|
406
|
+
export interface ValidateExecutionRequestOptions {
|
|
407
|
+
agentName?: string;
|
|
408
|
+
task?: string;
|
|
409
|
+
tasks?: unknown[];
|
|
410
|
+
chain?: unknown[];
|
|
411
|
+
timeout?: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface ValidationError {
|
|
415
|
+
field: string;
|
|
416
|
+
message: string;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Generic validation for execution requests.
|
|
421
|
+
* Enforces limits that TypeBox schemas may not cover, and defends against
|
|
422
|
+
* internal callers that bypass the public tool schema.
|
|
423
|
+
*/
|
|
424
|
+
export function validateExecutionRequest(
|
|
425
|
+
options: ValidateExecutionRequestOptions,
|
|
426
|
+
): ValidationError[] {
|
|
427
|
+
const errors: ValidationError[] = [];
|
|
428
|
+
|
|
429
|
+
// Agent name
|
|
430
|
+
if (options.agentName !== undefined) {
|
|
431
|
+
if (typeof options.agentName !== "string" || options.agentName.trim().length === 0) {
|
|
432
|
+
errors.push({ field: "agent", message: "Agent name must be a non-empty string." });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Task
|
|
437
|
+
if (options.task !== undefined) {
|
|
438
|
+
if (typeof options.task !== "string") {
|
|
439
|
+
errors.push({ field: "task", message: "Task must be a string." });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Parallel tasks
|
|
444
|
+
if (options.tasks !== undefined) {
|
|
445
|
+
if (!Array.isArray(options.tasks)) {
|
|
446
|
+
errors.push({ field: "tasks", message: "Tasks must be an array." });
|
|
447
|
+
} else {
|
|
448
|
+
if (options.tasks.length > MAX_PARALLEL_TASKS) {
|
|
449
|
+
errors.push({
|
|
450
|
+
field: "tasks",
|
|
451
|
+
message: `Too many parallel tasks (${options.tasks.length}). Maximum is ${MAX_PARALLEL_TASKS}.`,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
for (let i = 0; i < options.tasks.length; i++) {
|
|
455
|
+
const t = options.tasks[i] as Record<string, unknown>;
|
|
456
|
+
if (!t || typeof t.agent !== "string" || !t.agent.trim()) {
|
|
457
|
+
errors.push({ field: `tasks[${i}].agent`, message: "Agent name must be a non-empty string." });
|
|
458
|
+
}
|
|
459
|
+
if (typeof t.task !== "string") {
|
|
460
|
+
errors.push({ field: `tasks[${i}].task`, message: "Task must be a string." });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Chain
|
|
467
|
+
if (options.chain !== undefined) {
|
|
468
|
+
if (!Array.isArray(options.chain)) {
|
|
469
|
+
errors.push({ field: "chain", message: "Chain must be an array." });
|
|
470
|
+
} else {
|
|
471
|
+
if (options.chain.length > MAX_CHAIN_LENGTH) {
|
|
472
|
+
errors.push({
|
|
473
|
+
field: "chain",
|
|
474
|
+
message: `Too many chain steps (${options.chain.length}). Maximum is ${MAX_CHAIN_LENGTH}.`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
for (let i = 0; i < options.chain.length; i++) {
|
|
478
|
+
const s = options.chain[i] as Record<string, unknown>;
|
|
479
|
+
if (!s || typeof s.agent !== "string" || !s.agent.trim()) {
|
|
480
|
+
errors.push({ field: `chain[${i}].agent`, message: "Agent name must be a non-empty string." });
|
|
481
|
+
}
|
|
482
|
+
if (typeof s.task !== "string") {
|
|
483
|
+
errors.push({ field: `chain[${i}].task`, message: "Task must be a string." });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return errors;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Truncate parallel task output to the per-task cap.
|
|
494
|
+
*/
|
|
495
|
+
export function truncateParallelOutput(output: string): string {
|
|
496
|
+
const byteLength = Buffer.byteLength(output, "utf8");
|
|
497
|
+
if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
|
|
498
|
+
|
|
499
|
+
let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
|
|
500
|
+
while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
|
|
501
|
+
truncated = truncated.slice(0, -1);
|
|
502
|
+
}
|
|
503
|
+
return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
|
|
504
|
+
}
|
package/extensions/service.ts
CHANGED
|
@@ -2,6 +2,12 @@ import { AuthStorage, type ExtensionContext } from "@earendil-works/pi-coding-ag
|
|
|
2
2
|
import type { AgentConfig } from "./agents.ts";
|
|
3
3
|
import { runSubAgent, type SubAgentResult } from "./runner.ts";
|
|
4
4
|
import { resolveModel } from "./model.ts";
|
|
5
|
+
import {
|
|
6
|
+
validateAgentTools,
|
|
7
|
+
normalizeTimeout,
|
|
8
|
+
resolveSafeCwd,
|
|
9
|
+
MAX_INSTRUCTIONS_LENGTH,
|
|
10
|
+
} from "./security.ts";
|
|
5
11
|
|
|
6
12
|
export const SUBAGENT_REQUEST_EVENT = "pi-subagent:run";
|
|
7
13
|
|
|
@@ -43,37 +49,42 @@ export async function runNamedAgent(options: {
|
|
|
43
49
|
// ponytail: env and headers stay on the parent modelRegistry — reuse it directly.
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
:
|
|
54
|
-
|
|
52
|
+
// Security: validate and normalise timeout.
|
|
53
|
+
const effectiveTimeoutMs = normalizeTimeout({ requested: options.timeout }).timeoutMs;
|
|
54
|
+
|
|
55
|
+
// Security: validate tools against allowlist.
|
|
56
|
+
const rawTools = options.agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
57
|
+
const toolValidation = validateAgentTools({ tools: rawTools });
|
|
58
|
+
if (toolValidation.errors.length > 0) {
|
|
59
|
+
throw new Error(`Tool validation errors for agent "${options.agent.name}": ${toolValidation.errors.join("; ")}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Security: validate cwd (service caller must provide valid cwd).
|
|
63
|
+
// The service path uses the same policy as the tool path.
|
|
64
|
+
const safeCwd = resolveSafeCwd({ workspaceRoot: options.ctx.cwd, childCwd: options.cwd });
|
|
65
|
+
if (safeCwd.error) {
|
|
66
|
+
throw new Error(safeCwd.error);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const contract = options.instructions?.slice(0, MAX_INSTRUCTIONS_LENGTH);
|
|
55
70
|
|
|
56
71
|
try {
|
|
57
72
|
const result = await runSubAgent({
|
|
58
|
-
cwd:
|
|
73
|
+
cwd: safeCwd.path,
|
|
59
74
|
systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
|
|
60
75
|
task: options.task,
|
|
61
|
-
tools:
|
|
76
|
+
tools: toolValidation.tools,
|
|
62
77
|
model,
|
|
63
78
|
authStorage,
|
|
64
79
|
modelRegistry,
|
|
65
|
-
signal,
|
|
80
|
+
signal: options.signal,
|
|
81
|
+
timeoutMs: effectiveTimeoutMs,
|
|
66
82
|
agentName: options.agent.name,
|
|
67
83
|
thinkingLevel: options.agent.thinking,
|
|
68
84
|
onMessage: options.onMessage,
|
|
69
85
|
});
|
|
70
|
-
if (timeoutController?.signal.aborted && !options.signal?.aborted) {
|
|
71
|
-
result.exitCode = 1;
|
|
72
|
-
result.stopReason = "timeout";
|
|
73
|
-
result.errorMessage ||= `Timeout after ${options.timeout}ms`;
|
|
74
|
-
}
|
|
75
86
|
return result;
|
|
76
87
|
} finally {
|
|
77
|
-
|
|
88
|
+
// No manual timeout handling needed — runSubAgent handles timeouts internally.
|
|
78
89
|
}
|
|
79
90
|
}
|