@bermudi/pi-delegate 0.1.10 → 0.1.12

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/tickets.ts CHANGED
@@ -9,21 +9,21 @@ import type {
9
9
  import { ASYNC_TICKET_TTL_MS } from "./constants.ts";
10
10
  import {
11
11
  fmtDuration,
12
- fmtTokens,
13
12
  formatCompletedTask,
14
13
  formatTaskId,
15
- shortenPath,
16
14
  trunc,
17
- getActivityAge,
18
- formatActivityLabel,
19
- taskMetaBase,
20
- relativeTouchedSummary,
21
15
  findTouchedOverlaps,
22
16
  formatTouchedOverlapWarning,
23
17
  } from "./format.ts";
24
18
  import { isCrossLeafTicket } from "./leaf.ts";
25
- import { renderOutputForPoll } from "./spill.ts";
26
19
  import { scheduleDeadline } from "./timer.ts";
20
+ import {
21
+ emptyTicketPollResult,
22
+ formatCancelPreview,
23
+ formatLiveTicketPoll,
24
+ missingTicketPollResult,
25
+ rosterTicketPollResult,
26
+ } from "./ticket-format.ts";
27
27
  import { aggregateTaskResults, emptyUsage } from "./usage.ts";
28
28
  import { recordCall } from "./telemetry.ts";
29
29
  import type {
@@ -115,6 +115,39 @@ export function sweepTickets(): void {
115
115
  }
116
116
  }
117
117
 
118
+ /** Options for `settleTicket`. */
119
+ export interface SettleTicketOptions {
120
+ status: "done" | "failed" | "cancelled";
121
+ /** Recorded on the ticket (unexpected worker failure); omitted leaves any
122
+ * existing error untouched. */
123
+ error?: string;
124
+ }
125
+
126
+ /**
127
+ * Transition an active ticket to a terminal state. Single owner of the
128
+ * terminal transition — status, completion timestamp, error, and the
129
+ * busy-session index — so the normal-completion, unexpected-error, and
130
+ * shutdown paths cannot drift apart. Result delivery is deliberately outside
131
+ * this transition: a host `sendMessage()` failure must not undo or re-enter
132
+ * worker settlement. Settling is idempotent: `completedAt` is the settle
133
+ * marker, and a second settle attempt is a loud no-op.
134
+ */
135
+ export function settleTicket(
136
+ ticket: AsyncTicket,
137
+ opts: SettleTicketOptions,
138
+ ): void {
139
+ if (ticket.completedAt) {
140
+ console.error(
141
+ `[delegate] ticket '${ticket.id}' already settled as '${ticket.status}'; ignoring settle as '${opts.status}'`,
142
+ );
143
+ return;
144
+ }
145
+ ticket.status = opts.status;
146
+ if (opts.error !== undefined) ticket.error = opts.error;
147
+ ticket.completedAt = Date.now();
148
+ syncTicketBusyIndex(ticket);
149
+ }
150
+
118
151
  /**
119
152
  * Finalize an active ticket during host shutdown and resolve any blocking
120
153
  * waiters. This intentionally does not send a follow-up: the host is exiting.
@@ -122,21 +155,21 @@ export function sweepTickets(): void {
122
155
  export function cancelTicketForShutdown(ticket: AsyncTicket): void {
123
156
  if (ticket.status !== "running" && ticket.status !== "cancelling") return;
124
157
  ticket.controller.abort();
125
- ticket.status = "cancelled";
126
- ticket.completedAt = Date.now();
127
- syncTicketBusyIndex(ticket);
158
+ settleTicket(ticket, { status: "cancelled" });
128
159
  settleTicketWaiters(ticket);
129
160
  if (ticket.callRecord) {
161
+ const completedAt = ticket.completedAt ?? Date.now();
130
162
  const { totalTokens, totalCost } = aggregateTaskResults(ticket.results);
131
163
  recordCall(
132
164
  {
133
165
  ...ticket.callRecord,
134
166
  status: "cancelled",
135
- wall_ms: ticket.completedAt - (ticket.callStartedAt ?? ticket.created),
167
+ wall_ms: completedAt - (ticket.callStartedAt ?? ticket.created),
136
168
  total_tokens: totalTokens,
137
169
  total_cost: totalCost,
138
170
  },
139
171
  ticket.telemetryGeneration,
172
+ ticket.telemetryConfig,
140
173
  );
141
174
  }
142
175
  }
@@ -168,9 +201,9 @@ export function isSessionBusy(sessionId: string): string | null {
168
201
  * is also "failed".
169
202
  *
170
203
  * Callers are expected to have already handled "cancelled" / "cancelling"
171
- * (set by handleCancel) before invoking this; those paths set `ticket.status`
172
- * directly and skip this function via the `if (ticket.status === "running")`
173
- * guard in execute().
204
+ * (set by handleCancel / cancelTicketForShutdown) before invoking this;
205
+ * dispatchAsync's completion path routes both through `settleTicket`, which
206
+ * only calls this for a still-"running" ticket.
174
207
  */
175
208
  export function resolveFinalTicketStatus(
176
209
  ticket: AsyncTicket,
@@ -206,6 +239,9 @@ export function formatCompletedTicket(
206
239
  parts.push(
207
240
  `${statusTag}${succeeded}/${ticket.results.length} ${completionLabel} · ${fmtDuration(elapsedTotal)} wall time\n`,
208
241
  );
242
+ if (ticket.dispatchWarning) {
243
+ parts.push(`WARNING: ${ticket.dispatchWarning}`);
244
+ }
209
245
 
210
246
  const pendingLabelFor = (index: number): string => {
211
247
  if (ticket.status !== "cancelled") {
@@ -226,7 +262,7 @@ export function formatCompletedTicket(
226
262
  parts.push(`[${pendingLabelFor(i)}]`);
227
263
  continue;
228
264
  }
229
- parts.push(...formatCompletedTask(t, r));
265
+ parts.push(...formatCompletedTask(t, r, ticket.config));
230
266
  }
231
267
 
232
268
  const completedResults = ticket.results.filter(
@@ -262,6 +298,7 @@ export function formatCompletedTicket(
262
298
  ticketId: ticket.id,
263
299
  status: ticket.status,
264
300
  overlapWarning: overlapWarning || undefined,
301
+ dispatchWarning: ticket.dispatchWarning,
265
302
  },
266
303
  };
267
304
  }
@@ -304,9 +341,21 @@ function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
304
341
  ticketId: ticket.id,
305
342
  status: ticket.status,
306
343
  overlapWarning: overlapWarning || undefined,
344
+ dispatchWarning: ticket.dispatchWarning,
307
345
  };
308
346
  }
309
347
 
348
+ function appendDispatchWarnings(
349
+ base: string,
350
+ details: DelegateDetails,
351
+ ): string {
352
+ const warnings = [
353
+ details.dispatchWarning ? `WARNING: ${details.dispatchWarning}` : undefined,
354
+ details.overlapWarning,
355
+ ].filter((warning): warning is string => warning !== undefined);
356
+ return warnings.length ? `${base}\n\n${warnings.join("\n\n")}` : base;
357
+ }
358
+
310
359
  function buildWaitRunningUpdate(
311
360
  ticket: AsyncTicket,
312
361
  ): AgentToolResult<DelegateDetails> {
@@ -326,9 +375,7 @@ function buildWaitRunningUpdate(
326
375
  if (pending > 0) parts.push(`${pending} queued`);
327
376
 
328
377
  const details = buildWaitDetails(ticket);
329
- const text =
330
- parts.join(" · ") +
331
- (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
378
+ const text = appendDispatchWarnings(parts.join(" · "), details);
332
379
  return {
333
380
  content: [{ type: "text", text }],
334
381
  details,
@@ -366,8 +413,7 @@ function buildWaitAbortResult(
366
413
  ): AgentToolResult<DelegateDetails> {
367
414
  const details = buildWaitDetails(ticket);
368
415
  const base = `Wait for ticket ${ticket.id} aborted · ticket continues ${ticket.status} in the background`;
369
- const text =
370
- base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
416
+ const text = appendDispatchWarnings(base, details);
371
417
  return {
372
418
  content: [{ type: "text", text }],
373
419
  details,
@@ -517,21 +563,31 @@ export function deliverTicketResults(
517
563
 
518
564
  const crossLeaf = isCrossLeafTicket(ticket);
519
565
 
520
- pi.sendMessage(
521
- {
522
- customType: "async_delegate_result",
523
- content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
524
- display: true,
525
- details: {
526
- ...formatted.details,
527
- ticketId: ticket.id,
528
- status: ticket.status,
566
+ try {
567
+ pi.sendMessage(
568
+ {
569
+ customType: "async_delegate_result",
570
+ content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
571
+ display: true,
572
+ details: {
573
+ ...formatted.details,
574
+ ticketId: ticket.id,
575
+ status: ticket.status,
576
+ },
529
577
  },
530
- },
531
- crossLeaf
532
- ? { deliverAs: "nextTurn" }
533
- : { deliverAs: "steer", triggerTurn: true },
534
- );
578
+ crossLeaf
579
+ ? { deliverAs: "nextTurn" }
580
+ : { deliverAs: "steer", triggerTurn: true },
581
+ );
582
+ } catch (error) {
583
+ // Delivery is an observer boundary. The terminal ticket remains pollable
584
+ // even when the host cannot accept an unsolicited follow-up.
585
+ console.error(
586
+ `[delegate] failed to deliver results for ticket '${ticket.id}'; it remains pollable`,
587
+ error,
588
+ );
589
+ return "none";
590
+ }
535
591
  return crossLeaf ? "deferred" : "steer";
536
592
  }
537
593
 
@@ -545,195 +601,23 @@ export function handlePoll(
545
601
 
546
602
  // Only use top-level ticket param — per-task prompt is NOT a ticket ID
547
603
  const ticketId = params.ticket;
548
-
549
- // No ticket specified — list all
550
604
  if (!ticketId) {
551
605
  const tickets = [...ticketRegistry.values()];
552
- if (!tickets.length) {
553
- return {
554
- content: [
555
- {
556
- type: "text",
557
- text: [
558
- "No async tickets.",
559
- "",
560
- "To spawn a subagent: delegate({ tasks: [{ agent, prompt }] }).",
561
- "For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `ticketAction`.",
562
- ].join("\n"),
563
- },
564
- ],
565
- details: {
566
- tasks: [],
567
- results: [],
568
- progress: [],
569
- parentModel: parentModelId,
570
- },
571
- };
572
- }
573
- const lines = tickets.map((t) => {
574
- const icon =
575
- t.status === "running" || t.status === "cancelling"
576
- ? "⏳"
577
- : t.status === "done"
578
- ? "✓"
579
- : "✗";
580
- const done = t.progress.filter((p) => p.status === "done").length;
581
- const age = fmtDuration(Date.now() - t.created);
582
- // Agent roster — compact, deduplicated (a ticket may run the same agent
583
- // several times). Helps a human tell tickets apart at a glance.
584
- const agentSet = [
585
- ...new Set(t.progress.map((p) => p.agent).filter(Boolean)),
586
- ];
587
- const agents = agentSet.length
588
- ? ` · ${agentSet.slice(0, 3).join(", ")}${agentSet.length > 3 ? ` +${agentSet.length - 3}` : ""}`
589
- : "";
590
- let line = `${icon} ${t.id}${agents} · ${done}/${t.progress.length} tasks · ${t.status} · ${age}`;
591
- // Copy-pasteable controls for running/cancelling tickets — a human can grab these
592
- // straight out of the TUI without retyping the ticket id.
593
- if (t.status === "running" || t.status === "cancelling") {
594
- line += `\n poll: delegate({ ticketAction: "poll", ticket: "${t.id}" })`;
595
- if (t.status === "running") {
596
- line += `\n cancel: delegate({ ticketAction: "cancel", ticket: "${t.id}", force: true })`;
597
- }
598
- }
599
- return line;
600
- });
601
- return {
602
- content: [{ type: "text", text: `Async tickets:\n${lines.join("\n")}` }],
603
- details: {
604
- tasks: [],
605
- results: [],
606
- progress: [],
607
- parentModel: parentModelId,
608
- },
609
- };
606
+ return tickets.length
607
+ ? rosterTicketPollResult(tickets, parentModelId)
608
+ : emptyTicketPollResult(parentModelId);
610
609
  }
611
610
 
612
- // Specific ticket
613
611
  const ticket = ticketRegistry.get(ticketId);
614
- if (!ticket) {
615
- return {
616
- content: [
617
- {
618
- type: "text",
619
- text: `Ticket '${ticketId}' not found. It may have expired or never existed.`,
620
- },
621
- ],
622
- details: {
623
- tasks: [],
624
- results: [],
625
- progress: [],
626
- parentModel: parentModelId,
627
- },
628
- };
629
- }
612
+ if (!ticket) return missingTicketPollResult(ticketId, parentModelId);
630
613
 
631
614
  if (ticket.status === "running" || ticket.status === "cancelling") {
632
- const failedCount = ticket.progress.filter(
633
- (p) => p.status === "failed",
634
- ).length;
635
- // Settled = done + failed. Used for the "all finished" guidance check.
636
- const settledCount = ticket.progress.filter(
637
- (p) => p.status === "done" || p.status === "failed",
638
- ).length;
639
- const totalCount = ticket.progress.length;
640
- const runningCount = ticket.progress.filter(
641
- (p) => p.status === "running",
642
- ).length;
643
- const pendingCount = ticket.progress.filter(
644
- (p) => p.status === "pending",
645
- ).length;
646
- const totalTools = ticket.progress.reduce((sum, p) => sum + p.toolUses, 0);
647
- const totalTokens = ticket.progress.reduce((sum, p) => sum + p.tokens, 0);
648
- const lines: string[] = [];
649
- // Index-aligned sparse array — same shape as ticket.results, so consumers
650
- // can correlate results[i] with progress[i] and tasks[i].
651
- const completedResults: (TaskResult | undefined)[] = new Array(
652
- ticket.progress.length,
653
- ).fill(undefined);
654
-
655
- for (let i = 0; i < ticket.progress.length; i++) {
656
- const p = ticket.progress[i]!;
657
- const r = ticket.results[i];
658
-
659
- if (p.status === "done" && r) {
660
- const meta = taskMetaBase(r);
661
- if (r.touchedFiles.length > 0) {
662
- const t = ticket.resolved[i]!;
663
- const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
664
- if (touched) meta.push(`touched (best-effort): ${touched}`);
665
- }
666
- lines.push(`✓ ${r.agent}${formatTaskId(r.id)} · ${meta.join(" · ")}`);
667
- if (r.output && r.output !== "(no output)") {
668
- lines.push(renderOutputForPoll(r.output));
669
- }
670
- completedResults[i] = r;
671
- } else if (p.status === "failed" && r) {
672
- const meta = taskMetaBase(r);
673
- if (r.touchedFiles.length > 0) {
674
- const t = ticket.resolved[i]!;
675
- const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
676
- if (touched) meta.push(`touched (best-effort): ${touched}`);
677
- }
678
- const errorText = r.error ?? "unknown error";
679
- lines.push(
680
- `✗ ${r.agent}${formatTaskId(r.id)} · ${errorText} · ${meta.join(" · ")}`,
681
- );
682
- if (r.sessionFile)
683
- lines.push(` session: ${shortenPath(r.sessionFile)}`);
684
- if (r.output && r.output !== "(no output)")
685
- lines.push(renderOutputForPoll(r.output));
686
- completedResults[i] = r;
687
- } else if (p.status === "running") {
688
- const parts: string[] = [formatActivityLabel(p)];
689
- if (p.toolUses > 0)
690
- parts.push(`${p.toolUses} tool${p.toolUses === 1 ? "" : "s"}`);
691
- if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
692
- const age = getActivityAge(p.lastActivityAt);
693
- if (age) parts.push(age);
694
- lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
695
- } else {
696
- lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
697
- }
698
- }
699
-
700
- const completedForOverlap = completedResults.filter(
701
- (r): r is TaskResult => r !== undefined,
702
- );
703
- const overlapWarning = formatTouchedOverlapWarning(
704
- findTouchedOverlaps(completedForOverlap),
705
- );
706
-
707
- const headerStatus =
708
- ticket.status === "cancelling" ? "CANCELLING" : "RUNNING";
709
- const headerParts: string[] = [
710
- `Ticket ${ticket.id}: ${headerStatus}`,
711
- `${settledCount}/${totalCount} finalized`,
712
- ];
713
- if (runningCount > 0) headerParts.push(`${runningCount} active`);
714
- if (pendingCount > 0) headerParts.push(`${pendingCount} queued`);
715
- if (failedCount > 0) headerParts.push(`${failedCount} failed`);
716
- headerParts.push(`${totalTools} tool${totalTools === 1 ? "" : "s"}`);
717
- headerParts.push(`${fmtTokens(totalTokens)} tokens`);
718
- headerParts.push(`(${fmtDuration(Date.now() - ticket.created)})`);
719
- const header = headerParts.join(" · ");
720
- const guidance =
721
- ticket.status === "cancelling"
722
- ? "Cancellation requested. Active subagents are aborting and returning partial results. Wait without timeoutMs for final status; do not repeatedly poll."
723
- : settledCount === totalCount
724
- ? ""
725
- : "If you need the final result in this turn, call wait once with timeoutMs omitted. Otherwise stop calling ticket controls and let the final result auto-deliver after this turn; repeated polling will not speed it up.";
726
-
615
+ const snapshot = formatLiveTicketPoll(ticket);
727
616
  return {
728
- content: [
729
- {
730
- type: "text",
731
- text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}${overlapWarning ? `\n\n${overlapWarning}` : ""}`,
732
- },
733
- ],
617
+ content: [{ type: "text", text: snapshot.text }],
734
618
  details: {
735
619
  tasks: ticket.tasks,
736
- results: completedResults.map(
620
+ results: snapshot.completedResults.map(
737
621
  (r, i) => r ?? pendingResultPlaceholder(ticket.resolved[i]),
738
622
  ),
739
623
  progress: [...ticket.progress],
@@ -742,53 +626,15 @@ export function handlePoll(
742
626
  // (friction #2). The LLM-facing content still names the ticket id too.
743
627
  ticketId: ticket.id,
744
628
  status: ticket.status,
745
- overlapWarning: overlapWarning || undefined,
629
+ overlapWarning: snapshot.overlapWarning || undefined,
630
+ dispatchWarning: ticket.dispatchWarning,
746
631
  },
747
632
  };
748
633
  }
749
634
 
750
- // Done / Failed / Cancelled — full results
751
635
  return formatCompletedTicket(ticket);
752
636
  }
753
637
 
754
- function buildCancelPreview(ticket: AsyncTicket): string {
755
- const finalized = ticket.progress.filter(
756
- (p) => p.status === "done" || p.status === "failed",
757
- ).length;
758
- const running = ticket.progress.filter((p) => p.status === "running").length;
759
- const pending = ticket.progress.filter((p) => p.status === "pending").length;
760
- const lines: string[] = [
761
- `Ticket ${ticket.id}: cancellation preview`,
762
- `${finalized}/${ticket.progress.length} finalized · ${running} active · ${pending} queued`,
763
- ];
764
-
765
- for (let i = 0; i < ticket.progress.length; i++) {
766
- const p = ticket.progress[i]!;
767
- if (p.status === "done") {
768
- lines.push(`✓ ${p.agent}${formatTaskId(p.id)} · completed`);
769
- } else if (p.status === "failed") {
770
- lines.push(`✗ ${p.agent}${formatTaskId(p.id)} · ${p.error ?? "failed"}`);
771
- } else if (p.status === "running") {
772
- const parts: string[] = [formatActivityLabel(p)];
773
- if (p.toolUses > 0)
774
- parts.push(`${p.toolUses} tool${p.toolUses === 1 ? "" : "s"}`);
775
- if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
776
- const age = getActivityAge(p.lastActivityAt);
777
- if (age) parts.push(age);
778
- lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
779
- } else {
780
- lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
781
- }
782
- }
783
-
784
- lines.push(
785
- "",
786
- "WARNING: Cancelling now will abort active subagents. Files already written or shell commands already executed are NOT rolled back.",
787
- `To proceed, call delegate({ ticketAction: "cancel", ticket: "${ticket.id}", force: true }).`,
788
- );
789
- return lines.join("\n");
790
- }
791
-
792
638
  /** Preview or request cancellation of a running async ticket. */
793
639
  export function handleCancel(params: {
794
640
  ticket?: string;
@@ -825,9 +671,7 @@ export function handleCancel(params: {
825
671
  }
826
672
  if (!params.force) {
827
673
  const details = buildWaitDetails(ticket);
828
- const text =
829
- buildCancelPreview(ticket) +
830
- (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
674
+ const text = appendDispatchWarnings(formatCancelPreview(ticket), details);
831
675
  return {
832
676
  content: [{ type: "text", text }],
833
677
  details,
@@ -836,8 +680,7 @@ export function handleCancel(params: {
836
680
  requestTicketCancel(ticket);
837
681
  const details = buildWaitDetails(ticket);
838
682
  const base = `Ticket '${ticketId}' is cancelling; workers are settling. Poll for final status.`;
839
- const text =
840
- base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
683
+ const text = appendDispatchWarnings(base, details);
841
684
  return {
842
685
  content: [{ type: "text", text }],
843
686
  details,
package/tools.ts CHANGED
@@ -29,6 +29,18 @@ TOOL_FACTORIES.grep = createGrepTool;
29
29
  TOOL_FACTORIES.find = createFindTool;
30
30
  TOOL_FACTORIES.ls = createLsTool;
31
31
 
32
+ /** Tools supplied by the allowlisted provider extension rather than Pi core. */
33
+ const PROVIDER_TOOLS: Readonly<Record<string, readonly string[]>> = {
34
+ "openai-codex": ["web_search"],
35
+ };
36
+
37
+ export function availableToolNames(modelProvider?: string): string[] {
38
+ return [
39
+ ...Object.keys(TOOL_FACTORIES),
40
+ ...(modelProvider ? (PROVIDER_TOOLS[modelProvider] ?? []) : []),
41
+ ];
42
+ }
43
+
32
44
  /** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
33
45
  * Unknown names pass through unchanged for the caller to validate.
34
46
  * Returns a deduped list. */
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Path containment primitives for the subagent trust boundary.
3
+ *
4
+ * These decide whether a resolved package directory is allowed to become
5
+ * executable subagent code, so they compare **canonical** paths: a symlink
6
+ * whose target escapes an install root must not be able to launder itself
7
+ * through a lexically-innocent path.
8
+ */
9
+ import { realpathSync } from "node:fs";
10
+ import { isAbsolute, relative, resolve, sep } from "node:path";
11
+
12
+ /**
13
+ * Resolve a path through symlinks, or `undefined` if the path cannot be
14
+ * canonicalized.
15
+ *
16
+ * A path that cannot be resolved to a real filesystem target is never accepted
17
+ * as trusted: returning `undefined` lets callers fail closed rather than
18
+ * falling back to lexical resolution, which a symlink whose target escapes an
19
+ * install root could launder itself through.
20
+ */
21
+ export function canonicalPath(candidate: string): string | undefined {
22
+ try {
23
+ return realpathSync(candidate);
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ /** Whether `candidate` is `directory` itself or lies beneath it, after symlink
30
+ * resolution. Returns `false` when either path cannot be canonicalized. */
31
+ export function isPathWithinDirectory(
32
+ directory: string | undefined,
33
+ candidate: string | undefined,
34
+ ): boolean {
35
+ if (directory === undefined || candidate === undefined) return false;
36
+ const canonicalDirectory = canonicalPath(directory);
37
+ const canonicalCandidate = canonicalPath(candidate);
38
+ if (canonicalDirectory === undefined || canonicalCandidate === undefined) {
39
+ return false;
40
+ }
41
+ const relativePath = relative(canonicalDirectory, canonicalCandidate);
42
+ return (
43
+ relativePath === "" ||
44
+ (relativePath !== ".." &&
45
+ !relativePath.startsWith(`..${sep}`) &&
46
+ !isAbsolute(relativePath))
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Whether `candidate` is `directory` itself or lies beneath it, using lexical
52
+ * resolution only (no symlink resolution).
53
+ *
54
+ * Use this for containment checks on paths that are already trusted or that
55
+ * need not exist on disk (e.g. classifying load failures by root). For the
56
+ * subagent trust boundary, use `isPathWithinDirectory` instead — a symlink
57
+ * whose target escapes an install root must not launder itself through a
58
+ * lexical check.
59
+ */
60
+ export function isPathWithinDirectoryLexical(
61
+ directory: string,
62
+ candidate: string,
63
+ ): boolean {
64
+ const relativePath = relative(resolve(directory), resolve(candidate));
65
+ return (
66
+ relativePath === "" ||
67
+ (relativePath !== ".." &&
68
+ !relativePath.startsWith(`..${sep}`) &&
69
+ !isAbsolute(relativePath))
70
+ );
71
+ }