@cr1ms0n/pi-subagent 0.8.8 → 0.9.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.
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Pi child startup-handshake contract for Jev-routed subagent tasks.
3
+ *
4
+ * A routed dispatch must prove *before* the real task prompt reaches the child that the
5
+ * child actually loaded the expected execution model and exactly the finalized tool set.
6
+ * Parent-side catalog knowledge is not proof: Pi silently drops unknown `--tools` names,
7
+ * and a child may load a different (possibly older) copy of this package, whose nested
8
+ * `subagent` tool would bypass routing entirely.
9
+ *
10
+ * This module is the single source of truth for the bounded, non-secret handshake:
11
+ *
12
+ * - manifest + acknowledgement schemas and the private command naming scheme,
13
+ * - the pure command resolver and acknowledgement verifier,
14
+ * - the host-version gate for the one baseline that was actually verified.
15
+ *
16
+ * It is imported by `src/backends/pi.ts` (writes the temporary manifest and loads the
17
+ * private extension), `src/child-preflight.ts` (runs inside the child and answers), and
18
+ * `src/runner.ts` (drives and verifies). It carries no HTTP, config, policy or process
19
+ * dependencies, so it also loads cleanly from an isolated offline harness.
20
+ *
21
+ * Nothing here is a sandbox. It is a capability check against stale installs, dropped
22
+ * tools and misconfigured children — not a defence against a malicious local extension.
23
+ */
24
+
25
+ import { Buffer } from "node:buffer";
26
+ import * as crypto from "node:crypto";
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ /** Custom-message type of the child-side acknowledgement. */
32
+ export const PREFLIGHT_ACK_TYPE = "pi-subagent-preflight-ack";
33
+ /** Schema tag inside the acknowledgement payload. */
34
+ export const PREFLIGHT_ACK_SCHEMA = "pi-subagent-preflight-ack/1";
35
+ /** Schema tag inside the temporary expectation manifest. */
36
+ export const PREFLIGHT_MANIFEST_SCHEMA = "pi-subagent-preflight-manifest/1";
37
+ /** Private command prefix; the nonce makes each invocation name unique per run. */
38
+ export const PREFLIGHT_COMMAND_PREFIX = "pi_subagent_preflight_";
39
+ /** Env var carrying the *path* to the temporary manifest (never its contents in argv). */
40
+ export const PREFLIGHT_MANIFEST_ENV = "PI_SUBAGENT_PREFLIGHT_MANIFEST";
41
+
42
+ /**
43
+ * Lowest Pi host version whose startup contract was actually exercised offline.
44
+ * A *known* older host is refused rather than silently degraded; an unknown version is
45
+ * accepted only because the behavioural handshake (command + acknowledgement) already
46
+ * proved the contract exists.
47
+ */
48
+ export const MIN_VERIFIED_HOST_VERSION = "0.86.0";
49
+
50
+ /** Stop reason a failed startup check produces. Deliberately not in the transient set. */
51
+ export const PREFLIGHT_FAILURE_STOP_REASON = "capability_mismatch";
52
+
53
+ /** Local bounds; all handshake input is untrusted child output. */
54
+ const MAX_MANIFEST_BYTES = 64 * 1024;
55
+ const MAX_ACK_TOOLS = 512;
56
+ const MAX_ACK_NESTED = 512;
57
+ const MAX_NAME_LENGTH = 256;
58
+ const MAX_MODEL_LENGTH = 512;
59
+ const MAX_PROBLEMS_IN_MESSAGE = 8;
60
+ const NONCE_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
61
+
62
+ /** Bounded, non-secret expectation the backend writes and the child reads back. */
63
+ export interface PreflightManifest {
64
+ readonly schema: typeof PREFLIGHT_MANIFEST_SCHEMA;
65
+ readonly nonce: string;
66
+ /** Exact `provider/modelId` the child must have active. */
67
+ readonly model: string;
68
+ /** Exact finalized active tool set (Jev selection + mandatory local controls). */
69
+ readonly tools: readonly string[];
70
+ /** Nested dispatch tools whose loaded source must be this package's extension entry. */
71
+ readonly nestedTools?: readonly string[];
72
+ }
73
+
74
+ /** What the control side verifies the acknowledgement against. */
75
+ export interface PreflightExpectation {
76
+ readonly nonce: string;
77
+ readonly model: string;
78
+ readonly tools: readonly string[];
79
+ readonly nestedTools?: readonly string[];
80
+ /** Expected own extension entry paths; defaults to the current package's entries. */
81
+ readonly ownEntryPaths?: readonly string[];
82
+ /** Expected source path of the private preflight command; defaults to this package's. */
83
+ readonly preflightCommandPath?: string | null;
84
+ }
85
+
86
+ /** One provenance row for a nested tool, echoed by the child in its acknowledgement. */
87
+ export interface PreflightToolSource {
88
+ readonly name: string;
89
+ readonly path?: string | null;
90
+ readonly source?: string | null;
91
+ }
92
+
93
+ export interface PreflightAckPayload {
94
+ readonly schema?: unknown;
95
+ readonly nonce?: unknown;
96
+ readonly model?: { provider?: unknown; id?: unknown } | null;
97
+ readonly tools?: unknown;
98
+ readonly nestedToolsWithSource?: unknown;
99
+ readonly host?: { version?: unknown } | null;
100
+ }
101
+
102
+ /** Result-message prefix for a refused routed startup, stable for callers/TUI matching. */
103
+ export const STARTUP_FAILURE_RESULT_PREFIX = "Subagent startup check failed";
104
+
105
+ /**
106
+ * Marker for a startup capability failure carried by a *plain* `Error`.
107
+ *
108
+ * This repository forbids custom `Error` subclasses, so a pre-spawn/startup refusal is a
109
+ * plain Error whose message carries a stable, bounded marker plus an owned code; the
110
+ * runner narrows it with `readStartupFailure` and maps it to a non-transient
111
+ * `PREFLIGHT_FAILURE_STOP_REASON` result instead of rethrowing for control flow.
112
+ */
113
+ export const STARTUP_FAILURE_MARKER = "subagent-startup-check-failed";
114
+
115
+ /** Build a plain Error carrying an owned startup failure code. */
116
+ export function startupFailure(code: string, detail: string): Error {
117
+ const error = new Error(`${STARTUP_FAILURE_MARKER}: ${code}: ${detail}`);
118
+ (error as Error & { startupCode?: string }).startupCode = code;
119
+ return error;
120
+ }
121
+
122
+ /** Narrow any thrown value to an owned startup failure, or undefined. */
123
+ export function readStartupFailure(error: unknown): { code: string; detail: string } | undefined {
124
+ if (!error || typeof error !== "object") return undefined;
125
+ const code = (error as { startupCode?: unknown }).startupCode;
126
+ if (typeof code === "string" && code.length > 0) {
127
+ const raw = (error as { message?: unknown }).message;
128
+ const message = typeof raw === "string" ? raw : "";
129
+ const prefix = `${STARTUP_FAILURE_MARKER}: ${code}: `;
130
+ const detail = message.startsWith(prefix) ? message.slice(prefix.length) : message;
131
+ return { code, detail: detail || "startup check failed" };
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ /** Bounded startup timeout used when a routed child never answers the handshake. */
137
+ export function startupTimeoutDetail(budgetMs: number): string {
138
+ return `The child did not complete startup verification within ${Math.max(0, Math.round(budgetMs))} ms.`;
139
+ }
140
+
141
+ export function isValidPreflightNonce(value: unknown): value is string {
142
+ return typeof value === "string" && NONCE_PATTERN.test(value);
143
+ }
144
+
145
+ export function createPreflightNonce(): string {
146
+ return crypto.randomBytes(12).toString("hex");
147
+ }
148
+
149
+ export function preflightCommandBase(nonce: string): string {
150
+ return `${PREFLIGHT_COMMAND_PREFIX}${nonce}`;
151
+ }
152
+
153
+ /**
154
+ * Normalize a filesystem path for provenance comparison. Symlinked installs and Windows
155
+ * drive-case differences must not turn an identical copy into a false mismatch.
156
+ */
157
+ export function normalizeFsPath(value: unknown): string | null {
158
+ if (typeof value !== "string" || value.length === 0) return null;
159
+ let resolved: string;
160
+ try {
161
+ resolved = path.resolve(value);
162
+ } catch {
163
+ resolved = value;
164
+ }
165
+ try {
166
+ resolved = fs.realpathSync(resolved);
167
+ } catch {
168
+ /* path may not exist from this process's view; keep the lexical form */
169
+ }
170
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
171
+ }
172
+
173
+ /**
174
+ * Absolute path of this package's private preflight extension, used both as the explicit
175
+ * `-e` target and as the expected `sourceInfo.path` of the startup command.
176
+ */
177
+ export function ownPreflightExtensionPath(baseUrl: string = import.meta.url): string | null {
178
+ try {
179
+ const candidate = path.join(path.dirname(fileURLToPath(baseUrl)), "child-preflight.ts");
180
+ return fs.existsSync(candidate) ? normalizeFsPath(candidate) : null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Package extension entries that legitimately host the nested `subagent` / `subagent_wait`
188
+ * tools. Derived from this module's own location so a child that loaded a different
189
+ * installed copy is detected rather than trusted.
190
+ */
191
+ export function ownExtensionEntryCandidates(baseUrl: string = import.meta.url): string[] {
192
+ let srcDir: string;
193
+ try {
194
+ srcDir = path.dirname(fileURLToPath(baseUrl));
195
+ } catch {
196
+ return [];
197
+ }
198
+ const root = path.resolve(srcDir, "..");
199
+ const candidates: string[] = [];
200
+ for (const relative of ["extensions/subagent.ts", "src/extension.ts"]) {
201
+ const absolute = path.join(root, relative);
202
+ if (!fs.existsSync(absolute)) continue;
203
+ const normalized = normalizeFsPath(absolute);
204
+ if (normalized) candidates.push(normalized);
205
+ }
206
+ return candidates;
207
+ }
208
+
209
+ export type PreflightManifestParse =
210
+ | { readonly ok: true; readonly manifest: PreflightManifest }
211
+ | { readonly ok: false; readonly code: string; readonly message: string };
212
+
213
+ /** Validate untrusted manifest text read from the temporary file. */
214
+ export function parsePreflightManifest(raw: unknown): PreflightManifestParse {
215
+ if (typeof raw !== "string") {
216
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest was not readable text." };
217
+ }
218
+ if (Buffer.byteLength(raw, "utf8") > MAX_MANIFEST_BYTES) {
219
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest exceeded the local size bound." };
220
+ }
221
+ let parsed: any;
222
+ try {
223
+ parsed = JSON.parse(raw);
224
+ } catch {
225
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest was not valid JSON." };
226
+ }
227
+ if (!parsed || typeof parsed !== "object" || parsed.schema !== PREFLIGHT_MANIFEST_SCHEMA) {
228
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest schema tag did not match." };
229
+ }
230
+ if (!isValidPreflightNonce(parsed.nonce)) {
231
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest carried an unusable correlation nonce." };
232
+ }
233
+ if (typeof parsed.model !== "string" || parsed.model.length === 0 || parsed.model.length > MAX_MODEL_LENGTH) {
234
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest carried no usable model expectation." };
235
+ }
236
+ const tools = readNameList(parsed.tools, MAX_ACK_TOOLS);
237
+ if (!tools) {
238
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest carried no usable tool list." };
239
+ }
240
+ let nestedTools: string[] | undefined;
241
+ if (parsed.nestedTools !== undefined) {
242
+ nestedTools = readNameList(parsed.nestedTools, MAX_ACK_NESTED) ?? undefined;
243
+ if (parsed.nestedTools !== undefined && nestedTools === undefined) {
244
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest carried an unusable nested-tool list." };
245
+ }
246
+ }
247
+ return {
248
+ ok: true,
249
+ manifest: { schema: PREFLIGHT_MANIFEST_SCHEMA, nonce: parsed.nonce, model: parsed.model, tools, nestedTools },
250
+ };
251
+ }
252
+
253
+ function readNameList(value: unknown, maxCount: number): string[] | null {
254
+ if (!Array.isArray(value) || value.length > maxCount) return null;
255
+ const out: string[] = [];
256
+ for (const entry of value) {
257
+ if (typeof entry !== "string" || entry.length === 0 || entry.length > MAX_NAME_LENGTH) return null;
258
+ out.push(entry);
259
+ }
260
+ return out;
261
+ }
262
+
263
+ export interface ResolvedPreflightCommand {
264
+ readonly name: string;
265
+ readonly description?: string;
266
+ readonly source?: string;
267
+ readonly path?: string;
268
+ readonly sourceInfoSource?: string;
269
+ readonly scope?: string;
270
+ readonly origin?: string;
271
+ }
272
+
273
+ export type PreflightCommandResolution =
274
+ | { readonly ok: true; readonly code: "verified"; readonly invocableName: string; readonly entry: ResolvedPreflightCommand }
275
+ | {
276
+ readonly ok: false;
277
+ readonly code:
278
+ | "command-absent"
279
+ | "command-path-mismatch"
280
+ | "command-ambiguous"
281
+ | "command-source-not-extension";
282
+ readonly invocableName: null;
283
+ readonly candidates: ReadonlyArray<{ name: string; source?: string; path: string | null }>;
284
+ };
285
+
286
+ /**
287
+ * Resolve the invocable startup command from a raw `get_commands` payload.
288
+ *
289
+ * Requires BOTH the nonce-specific name AND an expected source file path: Pi suffixes
290
+ * duplicate command names (`name:1`, `name:2`), so name-only matching could select a
291
+ * different extension's copy — and an unverified slash command would be treated as an
292
+ * ordinary model prompt.
293
+ */
294
+ export function resolvePreflightCommand(
295
+ commands: unknown,
296
+ baseName: string,
297
+ expectedPaths: string | readonly string[],
298
+ allowedSources: readonly string[] = ["extension"],
299
+ ): PreflightCommandResolution {
300
+ const expected = (Array.isArray(expectedPaths) ? expectedPaths : [expectedPaths])
301
+ .map((candidate) => normalizeFsPath(candidate))
302
+ .filter((candidate): candidate is string => candidate !== null);
303
+
304
+ const list = Array.isArray(commands) ? commands : [];
305
+ const byName = list.filter(
306
+ (entry: any) =>
307
+ entry &&
308
+ typeof entry === "object" &&
309
+ typeof entry.name === "string" &&
310
+ (entry.name === baseName || entry.name.startsWith(`${baseName}:`)),
311
+ );
312
+ const describe = (entry: any) => ({
313
+ name: String(entry?.name ?? ""),
314
+ source: typeof entry?.source === "string" ? entry.source : undefined,
315
+ path: typeof entry?.sourceInfo?.path === "string" ? entry.sourceInfo.path : null,
316
+ });
317
+
318
+ if (byName.length === 0) {
319
+ return { ok: false, code: "command-absent", invocableName: null, candidates: [] };
320
+ }
321
+ const byPath = byName.filter((entry: any) => {
322
+ const observed = normalizeFsPath(entry?.sourceInfo?.path);
323
+ return observed !== null && expected.includes(observed);
324
+ });
325
+ if (byPath.length === 0) {
326
+ return { ok: false, code: "command-path-mismatch", invocableName: null, candidates: byName.map(describe) };
327
+ }
328
+ if (byPath.length > 1) {
329
+ return { ok: false, code: "command-ambiguous", invocableName: null, candidates: byPath.map(describe) };
330
+ }
331
+ const entry: any = byPath[0];
332
+ if (typeof entry.source !== "string" || !allowedSources.includes(entry.source)) {
333
+ return { ok: false, code: "command-source-not-extension", invocableName: null, candidates: [describe(entry)] };
334
+ }
335
+ return {
336
+ ok: true,
337
+ code: "verified",
338
+ invocableName: entry.name,
339
+ entry: {
340
+ name: entry.name,
341
+ description: typeof entry.description === "string" ? entry.description : undefined,
342
+ source: entry.source,
343
+ path: typeof entry.sourceInfo?.path === "string" ? entry.sourceInfo.path : undefined,
344
+ sourceInfoSource: typeof entry.sourceInfo?.source === "string" ? entry.sourceInfo.source : undefined,
345
+ scope: typeof entry.sourceInfo?.scope === "string" ? entry.sourceInfo.scope : undefined,
346
+ origin: typeof entry.sourceInfo?.origin === "string" ? entry.sourceInfo.origin : undefined,
347
+ },
348
+ };
349
+ }
350
+
351
+ /** Parse the child's custom-message content. Never throws; returns null when unusable. */
352
+ export function parsePreflightAckContent(content: unknown): PreflightAckPayload | null {
353
+ if (typeof content !== "string" || content.length === 0) return null;
354
+ if (Buffer.byteLength(content, "utf8") > MAX_MANIFEST_BYTES) return null;
355
+ try {
356
+ const parsed = JSON.parse(content);
357
+ return parsed && typeof parsed === "object" ? (parsed as PreflightAckPayload) : null;
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
362
+
363
+ /** `major.minor.patch` compare; unparseable input is "unknown", never "older". */
364
+ export function compareHostVersion(version: unknown): "ok" | "unsupported" | "unknown" {
365
+ if (typeof version !== "string") return "unknown";
366
+ const parse = (value: string): number[] | null => {
367
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.trim());
368
+ if (!match) return null;
369
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
370
+ };
371
+ const actual = parse(version);
372
+ const floor = parse(MIN_VERIFIED_HOST_VERSION);
373
+ if (!actual || !floor) return "unknown";
374
+ for (let index = 0; index < 3; index += 1) {
375
+ if (actual[index] > floor[index]) return "ok";
376
+ if (actual[index] < floor[index]) return "unsupported";
377
+ }
378
+ return "ok";
379
+ }
380
+
381
+ /**
382
+ * Verify an acknowledgement against the local expectation. Returns problem codes; an empty
383
+ * list means the child provably loaded the expected model and the exact expected tools.
384
+ *
385
+ * The child's own claims are never trusted for authority: every required tool must be
386
+ * reported active, and any extra active tool is a failure (a silently broadened child is
387
+ * exactly what this check exists to catch).
388
+ */
389
+ export function verifyPreflightAck(ack: unknown, expectation: PreflightExpectation): string[] {
390
+ const problems: string[] = [];
391
+ if (!ack || typeof ack !== "object") return ["ack-malformed"];
392
+ const payload = ack as PreflightAckPayload;
393
+ if (payload.schema !== PREFLIGHT_ACK_SCHEMA) problems.push("ack-schema-mismatch");
394
+ if (payload.nonce !== expectation.nonce) problems.push("nonce-mismatch");
395
+
396
+ const hostVersion = payload.host?.version;
397
+ if (compareHostVersion(hostVersion) === "unsupported") {
398
+ problems.push(`host-version-unsupported:${String(hostVersion).slice(0, 32)}`);
399
+ }
400
+
401
+ const model = payload.model;
402
+ if (!model || typeof model !== "object") {
403
+ problems.push("model-absent");
404
+ } else {
405
+ const provider = typeof model.provider === "string" ? model.provider : "";
406
+ const id = typeof model.id === "string" ? model.id : "";
407
+ if (!provider || !id || `${provider}/${id}` !== expectation.model) problems.push("model-mismatch");
408
+ }
409
+
410
+ if (!Array.isArray(payload.tools)) {
411
+ problems.push("tools-not-array");
412
+ } else if (payload.tools.length > MAX_ACK_TOOLS) {
413
+ problems.push("ack-too-large");
414
+ } else {
415
+ const active = new Set<string>();
416
+ for (const entry of payload.tools) {
417
+ if (typeof entry !== "string" || entry.length === 0 || entry.length > MAX_NAME_LENGTH) {
418
+ problems.push("tool-name-invalid");
419
+ continue;
420
+ }
421
+ if (active.has(entry)) problems.push(`tool-duplicate:${entry}`);
422
+ active.add(entry);
423
+ }
424
+ for (const required of expectation.tools) {
425
+ if (!active.has(required)) problems.push(`missing-tool:${required}`);
426
+ }
427
+ for (const observed of active) {
428
+ if (!expectation.tools.includes(observed)) problems.push(`unexpected-tool:${observed}`);
429
+ }
430
+ }
431
+
432
+ const nested = expectation.nestedTools ?? [];
433
+ if (nested.length > 0) {
434
+ const expectedEntries = expectation.ownEntryPaths ?? ownExtensionEntryCandidates();
435
+ const rows = Array.isArray(payload.nestedToolsWithSource) ? payload.nestedToolsWithSource : [];
436
+ if (rows.length > MAX_ACK_NESTED) problems.push("ack-too-large");
437
+ const byName = new Map<string, PreflightToolSource>();
438
+ for (const row of rows) {
439
+ if (!row || typeof row !== "object") continue;
440
+ const candidate = row as PreflightToolSource;
441
+ if (typeof candidate.name !== "string") continue;
442
+ if (!byName.has(candidate.name)) byName.set(candidate.name, candidate);
443
+ }
444
+ for (const tool of nested) {
445
+ const row = byName.get(tool);
446
+ if (!row) {
447
+ problems.push(`nested-tool-source-missing:${tool}`);
448
+ continue;
449
+ }
450
+ const source = typeof row.source === "string" ? row.source : "";
451
+ if (!source || source === "builtin" || source === "sdk") {
452
+ problems.push(`nested-tool-source-not-extension:${tool}`);
453
+ continue;
454
+ }
455
+ const observed = normalizeFsPath(row.path);
456
+ if (!observed || expectedEntries.length === 0 || !expectedEntries.includes(observed)) {
457
+ problems.push(`nested-tool-source-mismatch:${tool}`);
458
+ }
459
+ }
460
+ }
461
+
462
+ return problems;
463
+ }
464
+
465
+ /** Bounded, safe diagnostic text for a verification failure. */
466
+ export function summarizePreflightProblems(problems: readonly string[]): string {
467
+ if (problems.length === 0) return "startup acknowledgement rejected";
468
+ const shown = problems.slice(0, MAX_PROBLEMS_IN_MESSAGE).map((problem) => problem.slice(0, 96));
469
+ const suffix = problems.length > shown.length ? ` (+${problems.length - shown.length} more)` : "";
470
+ return shown.join("; ") + suffix;
471
+ }
472
+
473
+ /** Bounded command-resolution diagnostic for a failure detail. */
474
+ export function summarizeCommandResolution(resolution: PreflightCommandResolution): string {
475
+ if (resolution.ok) return `verified /${resolution.invocableName}`;
476
+ const candidates = resolution.candidates
477
+ .slice(0, 3)
478
+ .map((candidate) => `${candidate.name || "<unnamed>"}@${candidate.path ?? candidate.source ?? "unknown"}`)
479
+ .join(", ");
480
+ return `${resolution.code}${candidates ? ` [${candidates.slice(0, 300)}]` : ""}`;
481
+ }