@bermudi/pi-delegate 0.1.10 → 0.1.11

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,528 @@
1
+ /**
2
+ * Resolution and verification of the provider-scoped subagent extension
3
+ * allowlist.
4
+ *
5
+ * Subagents run with `noExtensions: true`. This module owns the single, narrow
6
+ * exception: a per-provider list of user-scope packages that may be injected as
7
+ * `additionalExtensionPaths` (today, remote compaction for `openai-codex`).
8
+ * Because those packages become executable code inside a subagent, every source
9
+ * is put through the same gauntlet before it is handed to the resource loader:
10
+ *
11
+ * 1. resolved in the **user scope only** — a project-local package is
12
+ * untrusted input and never becomes a subagent extension;
13
+ * 2. its canonical target must stay inside a trusted install root and outside
14
+ * the project;
15
+ * 3. a Git source must be checked out at the configured repository and, if
16
+ * pinned, at the configured commit;
17
+ * 4. an npm source with a version specifier must satisfy that range.
18
+ *
19
+ * **Provenance decides failure semantics.** A source the user listed in
20
+ * `delegate.json` is required and fails closed. A shipped default the user
21
+ * never mentioned is best-effort: missing, unverifiable, or broken, it is
22
+ * dropped *silently* and the subagent runs extension-free on Pi's native
23
+ * compaction. Silence there is deliberate — see `getProviderExtensionPaths`.
24
+ *
25
+ * All parsing of source strings goes through `pi-package-source.ts`, which
26
+ * wraps Pi's own parser. This module deliberately contains no Git URL grammar,
27
+ * no npm spec regex, and no `npm:`/`git:` prefix matching of its own.
28
+ */
29
+ import { execFileSync } from "node:child_process";
30
+ import { existsSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import { basename, dirname, join, resolve, sep } from "node:path";
33
+ import {
34
+ DefaultPackageManager,
35
+ SettingsManager,
36
+ } from "@earendil-works/pi-coding-agent";
37
+ import { getSubagentProviderExtensionSourcesForProvider } from "./config.ts";
38
+ import { canonicalPath, isPathWithinDirectory, isPathWithinDirectoryLexical } from "./trusted-paths.ts";
39
+ import {
40
+ parseGitOriginIdentity,
41
+ parsePackageSource,
42
+ PiPackageSourceError,
43
+ sameRepository,
44
+ type GitPackageSource,
45
+ type NpmPackageSource,
46
+ } from "./pi-package-source.ts";
47
+
48
+ /** Result of resolving a provider's allowlisted extension sources. */
49
+ export interface ProviderExtensionResolution {
50
+ /** User-scope package roots to inject as subagent extension paths. */
51
+ paths: string[];
52
+ /** Roots originating from shipped best-effort defaults; these may degrade
53
+ * silently — see the drop-site comments for why silence is the design. */
54
+ bestEffortPaths: Set<string>;
55
+ }
56
+
57
+ // When a shipped best-effort default (today npm:@bermudi/pi-codex) is absent —
58
+ // the normal state for most users — Pi's getInstalledPath can synchronously
59
+ // spawn `npm root -g` to check its legacy global fallback. Doing that once per
60
+ // task in a fan-out blocks the event loop N times and serializes the fan-out.
61
+ // Cache the *absence* per dispatch so only the first task pays the cost; the
62
+ // rest skip the lookup entirely. Absence is stable within a dispatch because
63
+ // nothing here ever installs.
64
+ const missingBestEffortSourceCache = new Set<string>();
65
+
66
+ /** Drop the per-dispatch absence cache. Called from host-deps invalidation. */
67
+ export function clearMissingProviderExtensionCache(): void {
68
+ missingBestEffortSourceCache.clear();
69
+ }
70
+
71
+ /**
72
+ * Whether a managed package's canonical target remains in a user install root.
73
+ *
74
+ * Pi's managed user installs live below `agentDir`; its legacy npm fallback
75
+ * lives below a global `node_modules` directory. Deriving the latter from the
76
+ * returned lexical path avoids invoking npm merely to validate a path, while
77
+ * still rejecting a package-directory symlink whose canonical target escapes
78
+ * that install root.
79
+ */
80
+ function isTrustedManagedTarget(agentDir: string, userPath: string): boolean {
81
+ const resolvedUserPath = canonicalPath(userPath);
82
+ if (isPathWithinDirectory(agentDir, resolvedUserPath)) return true;
83
+
84
+ const lexicalPath = resolve(userPath);
85
+ const nodeModulesMarker = `${sep}node_modules${sep}`;
86
+ const markerIndex = lexicalPath.lastIndexOf(nodeModulesMarker);
87
+ if (markerIndex < 0) return false;
88
+ const installRoot = lexicalPath.slice(
89
+ 0,
90
+ markerIndex + nodeModulesMarker.length - 1,
91
+ );
92
+ return isPathWithinDirectory(installRoot, resolvedUserPath);
93
+ }
94
+
95
+ /**
96
+ * Find the project boundary used by the extension trust check.
97
+ *
98
+ * `cwd` is allowed to be a package directory inside a larger checkout. Checking
99
+ * only that exact directory makes a user-scope symlink into a sibling project
100
+ * directory look safe. Git is the authoritative boundary when available;
101
+ * marker directories provide a conservative fallback for projects that are not
102
+ * Git worktrees. Returning undefined is intentional: callers then require a
103
+ * canonical managed target to remain under the trusted user agent directory.
104
+ */
105
+ function findExtensionProjectRoot(cwd: string): string | undefined {
106
+ const root = gitOutput(cwd, ["rev-parse", "--show-toplevel"]);
107
+ if (root) return canonicalPath(root);
108
+
109
+ let directory = canonicalPath(cwd);
110
+ if (directory === undefined) return undefined;
111
+ for (;;) {
112
+ if (
113
+ existsSync(join(directory, ".pi", "settings.json")) ||
114
+ existsSync(join(directory, ".pi", "agents")) ||
115
+ (directory !== homedir() &&
116
+ existsSync(join(directory, ".claude", "agents")))
117
+ ) {
118
+ return directory;
119
+ }
120
+ const parent = dirname(directory);
121
+ if (parent === directory) break;
122
+ directory = parent;
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ /** Run git in `cwd`, returning trimmed stdout or "" if the command fails. */
128
+ function gitOutput(cwd: string, args: string[]): string {
129
+ try {
130
+ return execFileSync("git", args, {
131
+ cwd,
132
+ encoding: "utf8",
133
+ stdio: ["ignore", "pipe", "ignore"],
134
+ timeout: 5000,
135
+ }).trim();
136
+ } catch {
137
+ // Not every path is a Git worktree, and a missing ref is a normal answer
138
+ // here. A timeout or non-zero exit is also treated as "no answer" so an
139
+ // unresponsive git never hangs delegation. Callers treat "" as "no
140
+ // answer" and fail closed where that matters.
141
+ return "";
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Reject a user installation whose checkout or ref differs from the configured
147
+ * source.
148
+ *
149
+ * Pi derives a Git package's install directory from the source's host/path, so
150
+ * a matching directory alone proves nothing about the checkout inside it: a
151
+ * source pinned to a tag and the same source unpinned share one directory.
152
+ * Reading the checkout's own origin and HEAD is what makes the pin mean
153
+ * something.
154
+ */
155
+ function verifyGitCheckout(
156
+ packageManager: DefaultPackageManager,
157
+ configured: GitPackageSource,
158
+ installedPath: string,
159
+ ): void {
160
+ try {
161
+ const origin = parseGitOriginIdentity(
162
+ packageManager,
163
+ gitOutput(installedPath, ["config", "--get", "remote.origin.url"]),
164
+ );
165
+ if (!origin || !sameRepository(origin, configured)) {
166
+ throw new Error("checkout has a different origin");
167
+ }
168
+
169
+ if (!configured.ref) return;
170
+ const head = gitOutput(installedPath, ["rev-parse", "--verify", "HEAD"]);
171
+ const target = gitOutput(installedPath, [
172
+ "rev-parse",
173
+ "--verify",
174
+ "--end-of-options",
175
+ `${configured.ref}^{commit}`,
176
+ ]);
177
+ if (!head || head !== target) {
178
+ throw new Error("checkout is at a different commit");
179
+ }
180
+ } catch (error) {
181
+ throw new Error(
182
+ "A configured provider extension is not checked out at its configured Git source or ref; delegation stopped.",
183
+ { cause: error },
184
+ );
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Apply Pi's own npm range semantics to the installed package, without
190
+ * duplicating semver logic and without installing or updating anything during
191
+ * delegation.
192
+ */
193
+ async function verifyNpmVersion(
194
+ configured: NpmPackageSource,
195
+ installedPath: string,
196
+ ): Promise<void> {
197
+ // An unconstrained source (`npm:pkg`) pins nothing, so there is nothing to
198
+ // verify beyond the path checks every source already passed.
199
+ if (configured.version === undefined) return;
200
+ if (configured.range === undefined) {
201
+ // Pi treats npm tags such as `@latest` as unconstrained when checking an
202
+ // installed package. They are registry aliases, not verifiable local
203
+ // version constraints, so fail closed instead of accepting any stale
204
+ // package that happens to sit at the same install path.
205
+ throw new Error(
206
+ "A configured provider extension uses an npm tag rather than a verifiable semver range; delegation stopped.",
207
+ );
208
+ }
209
+
210
+ let matches: boolean;
211
+ try {
212
+ matches = await configured.satisfiedBy(installedPath);
213
+ } catch (error) {
214
+ throw new Error(
215
+ "A configured provider extension version does not match the installed user-scope package; delegation stopped.",
216
+ { cause: error },
217
+ );
218
+ }
219
+ if (!matches) {
220
+ throw new Error(
221
+ "A configured provider extension version does not match the installed user-scope package; delegation stopped.",
222
+ );
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Full verification of one resolved source. Throws on any failure; the caller
228
+ * decides whether that is fatal (user-configured) or a silent drop
229
+ * (best-effort default).
230
+ */
231
+ async function verifyInstalledSource(
232
+ packageManager: DefaultPackageManager,
233
+ source: string,
234
+ installedPath: string,
235
+ trust: { agentDir: string; projectRoot: string | undefined },
236
+ ): Promise<void> {
237
+ const configured = parseSourceForVerification(packageManager, source);
238
+ const resolvedUserPath = canonicalPath(installedPath);
239
+
240
+ // A local source is allowed only when it resolves under the user agent
241
+ // directory. This closes the absolute/`..` path escape that a package
242
+ // manager's user-scope lookup otherwise permits.
243
+ if (
244
+ configured.type === "local" &&
245
+ !isPathWithinDirectory(trust.agentDir, resolvedUserPath)
246
+ ) {
247
+ throw new Error(
248
+ "A configured provider extension resolves outside the user agent directory; project-local extension paths are not allowed.",
249
+ );
250
+ }
251
+
252
+ // `cwd` may be a nested package directory. Validate against the repository
253
+ // (or project-marker) root, not just that exact directory, so a symlink from
254
+ // a user-scope managed package into a sibling such as /repo/extensions is
255
+ // never turned into executable subagent code. This check deliberately runs
256
+ // for local sources too: placing the user agent directory inside a project
257
+ // must not turn project code into a trusted extension.
258
+ if (
259
+ trust.projectRoot &&
260
+ isPathWithinDirectory(trust.projectRoot, resolvedUserPath)
261
+ ) {
262
+ throw new Error(
263
+ "A configured provider extension resolves inside the project directory; project-local extension paths are not allowed.",
264
+ );
265
+ }
266
+
267
+ // Managed sources must remain inside a canonical user install root as well.
268
+ // This is the conservative fallback when no project boundary is
269
+ // discoverable, and it also protects legacy global npm installs from a
270
+ // package-directory symlink that escapes their node_modules root.
271
+ if (
272
+ configured.type !== "local" &&
273
+ !isTrustedManagedTarget(trust.agentDir, installedPath)
274
+ ) {
275
+ throw new Error(
276
+ "A configured provider extension cannot be verified as a trusted user installation; project-local extension paths are not allowed.",
277
+ );
278
+ }
279
+
280
+ if (configured.type === "git") {
281
+ verifyGitCheckout(packageManager, configured, installedPath);
282
+ }
283
+ if (configured.type === "npm") {
284
+ await verifyNpmVersion(configured, installedPath);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Parse a configured source, re-reporting a broken parser contract in terms of
290
+ * the verification it defeated.
291
+ *
292
+ * An unverifiable source is treated exactly like a failed verification, but the
293
+ * user should read the failure as "this Git source could not be verified", not
294
+ * as an unrelated internal error. The original diagnosis survives as `cause`,
295
+ * so a Pi-version mismatch stays distinguishable from a bad checkout.
296
+ */
297
+ function parseSourceForVerification(
298
+ packageManager: DefaultPackageManager,
299
+ source: string,
300
+ ) {
301
+ try {
302
+ return parsePackageSource(packageManager, source);
303
+ } catch (error) {
304
+ if (error instanceof PiPackageSourceError && error.sourceType === "git") {
305
+ throw new Error(
306
+ "A configured provider extension is not checked out at its configured Git source or ref; delegation stopped.",
307
+ { cause: error },
308
+ );
309
+ }
310
+ if (error instanceof PiPackageSourceError && error.sourceType === "npm") {
311
+ throw new Error(
312
+ "A configured provider extension version does not match the installed user-scope package; delegation stopped.",
313
+ { cause: error },
314
+ );
315
+ }
316
+ throw error;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Resolve and verify every allowlisted extension source for `provider`.
322
+ *
323
+ * Provider-key normalization (trim + lowercase) lives in `config.ts` — the
324
+ * sources getter is the single owner of that logic, so this module never
325
+ * re-implements it. Provenance (required vs best-effort) is decided there too,
326
+ * by config presence: user-listed sources fail closed; shipped defaults degrade
327
+ * silently, because the extension-free path is Pi's normal operation, not a
328
+ * warning condition.
329
+ */
330
+ export async function getProviderExtensionPaths(
331
+ provider: string | undefined,
332
+ cwd: string,
333
+ agentDir: string,
334
+ ): Promise<ProviderExtensionResolution> {
335
+ const requested = getSubagentProviderExtensionSourcesForProvider(provider);
336
+ if (!requested.length) {
337
+ return { paths: [], bestEffortPaths: new Set<string>() };
338
+ }
339
+
340
+ const packageManager = new DefaultPackageManager({
341
+ cwd,
342
+ agentDir,
343
+ // Package lookup is a user-scope trust boundary. Pi's legacy npm fallback
344
+ // may execute the configured npmCommand to discover the global npm root,
345
+ // so project settings must never participate.
346
+ settingsManager: SettingsManager.create(cwd, agentDir, {
347
+ projectTrusted: false,
348
+ }),
349
+ });
350
+ const trust = { agentDir, projectRoot: findExtensionProjectRoot(cwd) };
351
+
352
+ const installedPaths = new Map<string, string>();
353
+ const missing: string[] = [];
354
+ for (const { source, required } of requested) {
355
+ const userPath = resolveUserScopePath(
356
+ packageManager,
357
+ source,
358
+ required,
359
+ agentDir,
360
+ );
361
+ if (!userPath) {
362
+ if (required) missing.push(source);
363
+ // A best-effort default that is not installed is skipped silently: for
364
+ // most users the package was never installed at all, and its absence is
365
+ // the normal, correct state — not something to warn about.
366
+ continue;
367
+ }
368
+ installedPaths.set(source, userPath);
369
+ }
370
+
371
+ if (missing.length > 0) {
372
+ const providerName = provider?.trim() || "the selected provider";
373
+ const sourceLabel = missing.length === 1 ? "source" : "sources";
374
+ throw new Error(
375
+ `Provider extension(s) for ${providerName} are not installed in the user scope (${missing.length} configured ${sourceLabel}). Install the configured sources with Pi before delegating; project-local installations are not allowed.`,
376
+ );
377
+ }
378
+
379
+ const paths = new Set<string>();
380
+ const bestEffortPaths = new Set<string>();
381
+ for (const { source, required } of requested) {
382
+ const userPath = installedPaths.get(source);
383
+ // Best-effort defaults that were not installed never reached the map.
384
+ if (!userPath) continue;
385
+ try {
386
+ await verifyInstalledSource(packageManager, source, userPath, trust);
387
+ } catch (error) {
388
+ if (required) throw error;
389
+ // A best-effort default that cannot be verified is skipped, not loaded
390
+ // and not fatal. Silent by design: an installed-but-broken package also
391
+ // fails in the parent's own extension inventory, where Pi surfaces it;
392
+ // this path only mirrors a signal the user has already seen.
393
+ continue;
394
+ }
395
+ paths.add(userPath);
396
+ if (!required) bestEffortPaths.add(userPath);
397
+ }
398
+
399
+ return { paths: [...paths], bestEffortPaths };
400
+ }
401
+
402
+ /**
403
+ * Look up a source in the user scope, short-circuiting the repeated cost of
404
+ * discovering that an optional default is simply not installed.
405
+ *
406
+ * Deliberately resolves only the user scope: project-local packages are
407
+ * untrusted input and must never become executable subagent extensions.
408
+ */
409
+ function resolveUserScopePath(
410
+ packageManager: DefaultPackageManager,
411
+ source: string,
412
+ required: boolean,
413
+ agentDir: string,
414
+ ): string | undefined {
415
+ // A required source is always looked up: its absence is fatal, so paying the
416
+ // lookup once per task is irrelevant next to stopping the dispatch.
417
+ if (required) return packageManager.getInstalledPath(source, "user");
418
+
419
+ const missingKey = `${agentDir}\0${source}`;
420
+ if (missingBestEffortSourceCache.has(missingKey)) return undefined;
421
+ const userPath = packageManager.getInstalledPath(source, "user");
422
+ if (!userPath) missingBestEffortSourceCache.add(missingKey);
423
+ return userPath;
424
+ }
425
+
426
+ /**
427
+ * Split allowlisted extension roots into the ones that must abort delegation
428
+ * and the ones that may be dropped and retried without.
429
+ *
430
+ * A root "failed" if it produced a load error or produced no loaded extension
431
+ * at all — a package can resolve successfully while exposing only skills or
432
+ * prompts, and a malformed manifest can expose nothing loadable. An error that
433
+ * no best-effort root claims stays fatal, including one that no supplied root
434
+ * claims at all.
435
+ *
436
+ * Pure, so the classification is testable without a resource loader.
437
+ */
438
+ export function partitionExtensionLoadFailures(input: {
439
+ extensionPaths: readonly string[];
440
+ loadedExtensionPaths: readonly string[];
441
+ extensionErrors: ReadonlyArray<{ path: string }>;
442
+ bestEffortRoots: ReadonlySet<string>;
443
+ }): { fatalCount: number; droppableRoots: string[] } {
444
+ const { extensionPaths, loadedExtensionPaths, extensionErrors } = input;
445
+ const bestEffortRoots = [...input.bestEffortRoots];
446
+
447
+ const failedRoots = new Set(
448
+ extensionPaths.filter(
449
+ (root) =>
450
+ !loadedExtensionPaths.some((extensionPath) =>
451
+ isPathWithinDirectoryLexical(root, extensionPath),
452
+ ) ||
453
+ extensionErrors.some((error) =>
454
+ isPathWithinDirectoryLexical(root, error.path),
455
+ ),
456
+ ),
457
+ );
458
+ const fatalErrors = extensionErrors.filter(
459
+ (error) =>
460
+ !bestEffortRoots.some((root) =>
461
+ isPathWithinDirectoryLexical(root, error.path),
462
+ ),
463
+ );
464
+ const fatalRoots = [...failedRoots].filter(
465
+ (root) => !input.bestEffortRoots.has(root),
466
+ );
467
+
468
+ return {
469
+ fatalCount: Math.max(fatalRoots.length, fatalErrors.length),
470
+ droppableRoots: [...failedRoots].filter((root) =>
471
+ input.bestEffortRoots.has(root),
472
+ ),
473
+ };
474
+ }
475
+
476
+ /**
477
+ * UI notifier for the provider-extension-loaded notice, primed from
478
+ * extension.ts `execute` (the only place the real, ui-bearing ctx exists —
479
+ * host-dep construction itself has no UI context). Consumed defensively:
480
+ * a throw means the ctx went stale (headless run, torn-down TUI) and simply
481
+ * un-primes the notifier so the next live execute re-primes it.
482
+ */
483
+ let providerExtensionNotifier: ((message: string) => void) | undefined;
484
+
485
+ /**
486
+ * Prime the UI notifier used for the best-effort extension-loaded notice.
487
+ * Idempotent and cheap; called at the top of every delegate execute. Pass
488
+ * `undefined` to un-prime (tests) — an un-primed notifier makes the notice a
489
+ * no-op, which is also the default state in headless/test runs.
490
+ */
491
+ export function registerProviderExtensionNotifier(
492
+ notify: ((message: string) => void) | undefined,
493
+ ): void {
494
+ providerExtensionNotifier = notify;
495
+ }
496
+
497
+ /** provider+root pairs already noticed this process. */
498
+ const noticedProviderExtensionRoots = new Set<string>();
499
+
500
+ /**
501
+ * Announce that a shipped best-effort provider integration actually loaded
502
+ * for delegated subagents. This is the deliberate inverse of the silent
503
+ * drop: absence of an optional integration is normal and never mentioned,
504
+ * but a default that IS active changes subagent behavior (remote compaction
505
+ * on codex models) invisibly — so it gets one info notice per process per
506
+ * provider+root, not one per dispatch. User-configured sources never get
507
+ * here: the user installed them knowingly and they fail closed.
508
+ */
509
+ export function noticeProviderExtensionLoaded(
510
+ provider: string | undefined,
511
+ root: string,
512
+ ): void {
513
+ const providerName = provider?.trim() || "provider";
514
+ const key = `${providerName.toLowerCase()}\0${root}`;
515
+ if (noticedProviderExtensionRoots.has(key)) return;
516
+ const notify = providerExtensionNotifier;
517
+ if (!notify) return;
518
+ const label = basename(root) || root;
519
+ try {
520
+ notify(`⚡ ${label} integration active for ${providerName} subagents`);
521
+ noticedProviderExtensionRoots.add(key);
522
+ } catch {
523
+ // Cosmetic notice on a stale ctx — fail open (status.ts precedent for
524
+ // cached-ctx notify): drop the notifier, keep the key un-noticed so a
525
+ // live ctx can still surface it later. Delegation is unaffected.
526
+ providerExtensionNotifier = undefined;
527
+ }
528
+ }