@oh-my-pi/pi-coding-agent 15.10.4 → 15.10.5

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.
Files changed (141) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/types/capability/rule-buckets.d.ts +1 -1
  3. package/dist/types/capability/rule.d.ts +6 -1
  4. package/dist/types/cli/update-cli.d.ts +11 -1
  5. package/dist/types/config/model-registry.d.ts +18 -1
  6. package/dist/types/discovery/at-imports.d.ts +15 -0
  7. package/dist/types/edit/diff.d.ts +3 -2
  8. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +1 -0
  9. package/dist/types/eval/backend.d.ts +7 -0
  10. package/dist/types/eval/js/context-manager.d.ts +1 -0
  11. package/dist/types/eval/js/executor.d.ts +2 -0
  12. package/dist/types/eval/js/index.d.ts +1 -1
  13. package/dist/types/eval/js/shared/helpers.d.ts +6 -0
  14. package/dist/types/eval/js/shared/runtime.d.ts +5 -0
  15. package/dist/types/eval/js/worker-protocol.d.ts +6 -0
  16. package/dist/types/eval/py/executor.d.ts +7 -0
  17. package/dist/types/eval/py/index.d.ts +1 -1
  18. package/dist/types/export/ttsr.d.ts +14 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -1
  20. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  21. package/dist/types/internal-urls/local-protocol.d.ts +10 -0
  22. package/dist/types/mcp/oauth-flow.d.ts +2 -2
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
  25. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +31 -2
  27. package/dist/types/modes/image-references.d.ts +8 -3
  28. package/dist/types/modes/interactive-mode.d.ts +1 -1
  29. package/dist/types/modes/theme/theme.d.ts +2 -1
  30. package/dist/types/modes/types.d.ts +2 -1
  31. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  32. package/dist/types/session/agent-session.d.ts +0 -2
  33. package/dist/types/tools/ask.d.ts +1 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +15 -0
  35. package/dist/types/tools/index.d.ts +17 -0
  36. package/dist/types/tools/render-utils.d.ts +1 -1
  37. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  38. package/dist/types/utils/block-context.d.ts +35 -0
  39. package/dist/types/utils/image-loading.d.ts +12 -0
  40. package/package.json +29 -9
  41. package/src/capability/rule-buckets.ts +4 -2
  42. package/src/capability/rule.ts +10 -1
  43. package/src/cli/auth-broker-cli.ts +6 -7
  44. package/src/cli/auth-gateway-cli.ts +1 -1
  45. package/src/cli/list-models.ts +5 -0
  46. package/src/cli/update-cli.ts +138 -16
  47. package/src/config/model-registry.ts +81 -2
  48. package/src/debug/index.ts +4 -8
  49. package/src/discovery/at-imports.ts +273 -0
  50. package/src/discovery/builtin-rules/index.ts +4 -0
  51. package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
  52. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
  53. package/src/discovery/helpers.ts +2 -1
  54. package/src/edit/diff.ts +114 -4
  55. package/src/edit/hashline/diff.ts +1 -1
  56. package/src/edit/hashline/execute.ts +1 -1
  57. package/src/edit/modes/patch.ts +6 -2
  58. package/src/edit/modes/replace.ts +1 -1
  59. package/src/edit/renderer.ts +12 -2
  60. package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
  61. package/src/eval/backend.ts +15 -0
  62. package/src/eval/js/context-manager.ts +4 -2
  63. package/src/eval/js/executor.ts +3 -0
  64. package/src/eval/js/index.ts +7 -1
  65. package/src/eval/js/shared/helpers.ts +53 -6
  66. package/src/eval/js/shared/runtime.ts +8 -0
  67. package/src/eval/js/worker-core.ts +1 -0
  68. package/src/eval/js/worker-protocol.ts +6 -0
  69. package/src/eval/py/executor.ts +12 -0
  70. package/src/eval/py/index.ts +7 -1
  71. package/src/eval/py/prelude.py +43 -4
  72. package/src/eval/py/runner.py +1 -0
  73. package/src/exa/render.ts +1 -1
  74. package/src/export/ttsr.ts +122 -1
  75. package/src/extensibility/extensions/types.ts +8 -1
  76. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  77. package/src/extensibility/plugins/doctor.ts +1 -1
  78. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
  79. package/src/goals/tools/goal-tool.ts +1 -1
  80. package/src/internal-urls/docs-index.generated.ts +6 -5
  81. package/src/internal-urls/local-protocol.ts +13 -0
  82. package/src/lsp/render.ts +8 -6
  83. package/src/mcp/oauth-flow.ts +3 -3
  84. package/src/mcp/render.ts +7 -1
  85. package/src/modes/components/custom-editor.ts +12 -6
  86. package/src/modes/components/login-dialog.ts +1 -1
  87. package/src/modes/components/oauth-selector.ts +4 -4
  88. package/src/modes/components/read-tool-group.ts +10 -3
  89. package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
  90. package/src/modes/components/status-line/index.ts +1 -0
  91. package/src/modes/components/status-line/types.ts +23 -8
  92. package/src/modes/components/tool-execution.ts +1 -1
  93. package/src/modes/components/transcript-container.ts +17 -10
  94. package/src/modes/components/user-message.ts +6 -3
  95. package/src/modes/components/welcome.ts +1 -1
  96. package/src/modes/controllers/extension-ui-controller.ts +143 -127
  97. package/src/modes/controllers/input-controller.ts +36 -10
  98. package/src/modes/controllers/mcp-command-controller.ts +28 -12
  99. package/src/modes/controllers/selector-controller.ts +4 -11
  100. package/src/modes/controllers/ssh-command-controller.ts +2 -2
  101. package/src/modes/image-references.ts +13 -7
  102. package/src/modes/interactive-mode.ts +2 -2
  103. package/src/modes/rpc/rpc-mode.ts +1 -1
  104. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
  105. package/src/modes/theme/theme.ts +95 -1
  106. package/src/modes/types.ts +2 -1
  107. package/src/modes/utils/ui-helpers.ts +14 -5
  108. package/src/prompts/tools/bash.md +1 -1
  109. package/src/prompts/tools/eval.md +4 -4
  110. package/src/sdk.ts +31 -14
  111. package/src/session/agent-session.ts +213 -155
  112. package/src/session/session-manager.ts +1 -1
  113. package/src/slash-commands/builtin-registry.ts +1 -1
  114. package/src/system-prompt.ts +15 -9
  115. package/src/task/render.ts +20 -8
  116. package/src/tools/ask.ts +14 -5
  117. package/src/tools/bash-interactive.ts +1 -1
  118. package/src/tools/bash.ts +14 -2
  119. package/src/tools/browser/render.ts +5 -2
  120. package/src/tools/browser/tab-worker.ts +211 -91
  121. package/src/tools/debug.ts +5 -2
  122. package/src/tools/eval-render.ts +6 -3
  123. package/src/tools/eval.ts +1 -1
  124. package/src/tools/gh-renderer.ts +29 -15
  125. package/src/tools/index.ts +32 -0
  126. package/src/tools/inspect-image-renderer.ts +12 -5
  127. package/src/tools/job.ts +9 -6
  128. package/src/tools/memory-render.ts +19 -5
  129. package/src/tools/read.ts +165 -18
  130. package/src/tools/render-utils.ts +3 -1
  131. package/src/tools/resolve.ts +1 -1
  132. package/src/tools/review.ts +1 -1
  133. package/src/tools/ssh.ts +4 -1
  134. package/src/tools/todo.ts +8 -1
  135. package/src/tools/tool-timeouts.ts +1 -1
  136. package/src/tools/write.ts +1 -1
  137. package/src/tui/code-cell.ts +1 -1
  138. package/src/utils/block-context.ts +312 -0
  139. package/src/utils/image-loading.ts +31 -1
  140. package/src/web/search/providers/codex.ts +1 -1
  141. package/src/web/search/render.ts +14 -6
package/src/edit/diff.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as Diff from "diff";
8
8
  import { resolveToCwd } from "../tools/path-utils";
9
+ import { type BlockContextSource, findBlockContextLines } from "../utils/block-context";
9
10
  import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace";
10
11
  import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
11
12
  import { readEditFileText } from "./read-file";
@@ -54,11 +55,109 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten
54
55
  return `${prefix}${lineNum}|${content}`;
55
56
  }
56
57
 
58
+ type DiffSource = "old" | "new";
59
+
60
+ interface ParsedNumberedDiffRow {
61
+ prefix: "+" | "-" | " ";
62
+ lineNumber: number;
63
+ content: string;
64
+ source: DiffSource;
65
+ }
66
+
67
+ function parseNumberedDiffRow(row: string): ParsedNumberedDiffRow | undefined {
68
+ const match = /^([+\- ])(\d+)\|(.*)$/s.exec(row);
69
+ if (!match) return undefined;
70
+ const prefix = match[1] as "+" | "-" | " ";
71
+ const lineNumber = Number.parseInt(match[2], 10);
72
+ if (!Number.isFinite(lineNumber)) return undefined;
73
+ return {
74
+ prefix,
75
+ lineNumber,
76
+ content: match[3] ?? "",
77
+ source: prefix === "+" ? "new" : "old",
78
+ };
79
+ }
80
+
81
+ function isDiffChangeRow(row: string | undefined): boolean {
82
+ return row !== undefined && (row.startsWith("+") || row.startsWith("-"));
83
+ }
84
+
85
+ function adjustedContextInsertIndex(rows: readonly string[], index: number): number {
86
+ let start = index;
87
+ while (start > 0 && isDiffChangeRow(rows[start - 1])) start--;
88
+ let end = index;
89
+ while (end < rows.length && isDiffChangeRow(rows[end])) end++;
90
+ return index > start && index < end ? end : index;
91
+ }
92
+
93
+ function insertBracketContextRows(
94
+ rows: string[],
95
+ source: DiffSource,
96
+ contextLines: ReadonlyMap<number, string>,
97
+ seenRows: Set<string>,
98
+ ): void {
99
+ const context = [...contextLines].sort(([left], [right]) => left - right);
100
+ for (const [lineNumber, text] of context) {
101
+ const row = formatNumberedDiffLine(" ", lineNumber, text);
102
+ if (seenRows.has(row)) continue;
103
+
104
+ let insertIndex = rows.length;
105
+ let previousSourceLine: number | undefined;
106
+ let nextSourceLine: number | undefined;
107
+ for (let i = 0; i < rows.length; i++) {
108
+ const parsed = parseNumberedDiffRow(rows[i]);
109
+ if (!parsed || parsed.source !== source) continue;
110
+ if (parsed.lineNumber < lineNumber) {
111
+ previousSourceLine = parsed.lineNumber;
112
+ continue;
113
+ }
114
+ nextSourceLine = parsed.lineNumber;
115
+ insertIndex = i;
116
+ break;
117
+ }
118
+
119
+ const chunk: string[] = [];
120
+ if (previousSourceLine !== undefined && lineNumber > previousSourceLine + 1) chunk.push("...");
121
+ chunk.push(row);
122
+ if (nextSourceLine !== undefined && nextSourceLine > lineNumber + 1) chunk.push("...");
123
+
124
+ const adjustedIndex = adjustedContextInsertIndex(rows, insertIndex);
125
+ rows.splice(adjustedIndex, 0, ...chunk);
126
+ for (const inserted of chunk) seenRows.add(inserted);
127
+ }
128
+ }
129
+
130
+ function addMatchingBracketContextRows(
131
+ rows: string[],
132
+ oldLines: readonly string[],
133
+ newLines: readonly string[],
134
+ source: BlockContextSource,
135
+ ): void {
136
+ const oldVisible: number[] = [];
137
+ const newVisible: number[] = [];
138
+ const seenRows = new Set(rows);
139
+
140
+ for (const row of rows) {
141
+ const parsed = parseNumberedDiffRow(row);
142
+ if (!parsed) continue;
143
+ if (parsed.source === "old") oldVisible.push(parsed.lineNumber);
144
+ else newVisible.push(parsed.lineNumber);
145
+ }
146
+
147
+ insertBracketContextRows(rows, "old", findBlockContextLines(oldLines, oldVisible, source), seenRows);
148
+ insertBracketContextRows(rows, "new", findBlockContextLines(newLines, newVisible, source), seenRows);
149
+ }
150
+
57
151
  /**
58
152
  * Generate a unified diff string with line numbers and context.
59
153
  * Returns both the diff string and the first changed line number (in the new file).
60
154
  */
61
- export function generateDiffString(oldContent: string, newContent: string, contextLines = 2): DiffResult {
155
+ export function generateDiffString(
156
+ oldContent: string,
157
+ newContent: string,
158
+ contextLines = 2,
159
+ source: BlockContextSource = {},
160
+ ): DiffResult {
62
161
  const parts = Diff.diffLines(oldContent, newContent);
63
162
  const output: string[] = [];
64
163
 
@@ -133,8 +232,10 @@ export function generateDiffString(oldContent: string, newContent: string, conte
133
232
  newLineNum++;
134
233
  }
135
234
 
235
+ // Mid-skip placeholder is omitted too: the jump between the trailing
236
+ // number of the leading context and the leading number of the
237
+ // trailing context conveys the gap, just like leading/trailing skips.
136
238
  if (middleSkip > 0) {
137
- output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
138
239
  oldLineNum += middleSkip;
139
240
  newLineNum += middleSkip;
140
241
  for (const line of linesToShow.slice(firstChunkLength)) {
@@ -160,6 +261,8 @@ export function generateDiffString(oldContent: string, newContent: string, conte
160
261
  }
161
262
  }
162
263
 
264
+ addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n"), source);
265
+
163
266
  return { diff: output.join("\n"), firstChangedLine };
164
267
  }
165
268
 
@@ -187,7 +290,12 @@ export interface ReplaceResult {
187
290
  * Generate a unified diff string without file headers.
188
291
  * Returns both the diff string and the first changed line number (in the new file).
189
292
  */
190
- export function generateUnifiedDiffString(oldContent: string, newContent: string, contextLines = 3): DiffResult {
293
+ export function generateUnifiedDiffString(
294
+ oldContent: string,
295
+ newContent: string,
296
+ contextLines = 3,
297
+ source: BlockContextSource = {},
298
+ ): DiffResult {
191
299
  const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines });
192
300
  const output: string[] = [];
193
301
  let firstChangedLine: number | undefined;
@@ -218,6 +326,8 @@ export function generateUnifiedDiffString(oldContent: string, newContent: string
218
326
  }
219
327
  }
220
328
 
329
+ addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n"), source);
330
+
221
331
  return { diff: output.join("\n"), firstChangedLine };
222
332
  }
223
333
 
@@ -805,7 +915,7 @@ export async function computeEditDiff(
805
915
  };
806
916
  }
807
917
 
808
- return generateDiffString(normalizedContent, result.content);
918
+ return generateDiffString(normalizedContent, result.content, undefined, { path });
809
919
  } catch (err) {
810
920
  return { error: err instanceof Error ? err.message : String(err) };
811
921
  }
@@ -230,7 +230,7 @@ export async function computeHashlineSectionDiff(
230
230
  if (options.streaming) return buildStreamingSectionDiff(section, normalized);
231
231
  const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options });
232
232
  if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
233
- return generateDiffString(normalized, result.text);
233
+ return generateDiffString(normalized, result.text, undefined, { path: section.path });
234
234
  } catch (err) {
235
235
  return { error: err instanceof Error ? err.message : String(err) };
236
236
  }
@@ -97,7 +97,7 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
97
97
  };
98
98
  }
99
99
 
100
- const diff = generateDiffString(result.before, result.after);
100
+ const diff = generateDiffString(result.before, result.after, undefined, { path: result.path });
101
101
  const preview = buildCompactDiffPreview(diff.diff);
102
102
  const meta = outputMeta()
103
103
  .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
@@ -1571,7 +1571,9 @@ export async function computePatchDiff(
1571
1571
  if (!normalizedOld && !normalizedNew) {
1572
1572
  return { diff: "", firstChangedLine: undefined };
1573
1573
  }
1574
- return generateUnifiedDiffString(normalizedOld, normalizedNew);
1574
+ return generateUnifiedDiffString(normalizedOld, normalizedNew, undefined, {
1575
+ path: result.change.newPath ?? result.change.path,
1576
+ });
1575
1577
  } catch (err) {
1576
1578
  return { error: err instanceof Error ? err.message : String(err) };
1577
1579
  }
@@ -1785,7 +1787,9 @@ export async function executePatchSingle(
1785
1787
  if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
1786
1788
  const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
1787
1789
  const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
1788
- diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew);
1790
+ diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, undefined, {
1791
+ path: result.change.newPath ?? result.change.path,
1792
+ });
1789
1793
  }
1790
1794
 
1791
1795
  let resultText: string;
@@ -1078,7 +1078,7 @@ export async function executeReplaceSingle(
1078
1078
  );
1079
1079
  invalidateFsScanAfterWrite(absolutePath);
1080
1080
 
1081
- const diffResult = generateDiffString(normalizedContent, result.content);
1081
+ const diffResult = generateDiffString(normalizedContent, result.content, undefined, { path });
1082
1082
  const resultText =
1083
1083
  result.count > 1
1084
1084
  ? `Successfully replaced ${result.count} occurrences in ${path}.`
@@ -260,6 +260,7 @@ function renderEditHeader(
260
260
  uiTheme: Theme,
261
261
  options: {
262
262
  icon: "pending" | "success" | "error";
263
+ iconOverride?: string;
263
264
  spinnerFrame?: number;
264
265
  op?: Operation;
265
266
  rawPath: string;
@@ -279,8 +280,16 @@ function renderEditHeader(
279
280
  const formatted = formatEditDescription(options.rawPath, uiTheme, descriptionOptions);
280
281
  const suffix = `${options.statsSuffix ?? ""}${options.extraSuffix ?? ""}`;
281
282
  const buildHeader = (description: string): string =>
282
- renderStatusLine({ icon: options.icon, spinnerFrame: options.spinnerFrame, title, description }, uiTheme) +
283
- suffix;
283
+ renderStatusLine(
284
+ {
285
+ icon: options.icon,
286
+ iconOverride: options.iconOverride,
287
+ spinnerFrame: options.spinnerFrame,
288
+ title,
289
+ description,
290
+ },
291
+ uiTheme,
292
+ ) + suffix;
284
293
 
285
294
  const header = buildHeader(formatted.description);
286
295
  const overflow = visibleWidth(header) - editHeaderLabelBudget(width, uiTheme);
@@ -633,6 +642,7 @@ function renderSingleFileResult(
633
642
  const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
634
643
  const header = renderEditHeader(width, uiTheme, {
635
644
  icon: isError ? "error" : "success",
645
+ iconOverride: !isError && !options.isPartial ? uiTheme.styledSymbol("tool.edit", "accent") : undefined,
636
646
  op,
637
647
  rawPath,
638
648
  rename,
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as path from "node:path";
3
+ import { TempDir } from "@oh-my-pi/pi-utils";
4
+ import { createHelpers, type HelperContext } from "../js/shared/helpers";
5
+
6
+ /**
7
+ * The eval helpers (`read`/`write`/`append`) must substitute injected on-disk
8
+ * roots for internal-URL schemes. Without it, `write("local://x.md")` hits a
9
+ * stdlib `path.resolve` that collapses `local://` to `local:/`, creating a junk
10
+ * `local:` directory under the cwd instead of landing where `read local://x.md`
11
+ * resolves. These lock the substitution contract and its guards.
12
+ */
13
+ function makeCtx(cwd: string, roots: Record<string, string>): HelperContext {
14
+ return {
15
+ cwd: () => cwd,
16
+ env: new Map(),
17
+ localRoots: () => roots,
18
+ emitStatus: () => {},
19
+ };
20
+ }
21
+
22
+ describe("eval js helpers internal-url resolution", () => {
23
+ it("writes, reads, and appends local:// under the injected root", async () => {
24
+ using tmp = TempDir.createSync("@eval-helpers-local-");
25
+ const root = path.join(tmp.path(), "local");
26
+ const helpers = createHelpers(makeCtx(tmp.path(), { local: root }));
27
+
28
+ const written = await helpers.writeFile("local://notes/merge-map.md", "hello");
29
+ expect(written).toBe(path.join(root, "notes", "merge-map.md"));
30
+ expect(await Bun.file(written).text()).toBe("hello");
31
+ expect(await helpers.read("local://notes/merge-map.md")).toBe("hello");
32
+
33
+ await helpers.append("local://notes/merge-map.md", " world");
34
+ expect(await helpers.read("local://notes/merge-map.md")).toBe("hello world");
35
+
36
+ // Regression: no literal `local:` directory created under the cwd.
37
+ expect(await Bun.file(path.join(tmp.path(), "local:")).exists()).toBe(false);
38
+ expect(await Bun.file(path.join(tmp.path(), "local:", "notes", "merge-map.md")).exists()).toBe(false);
39
+ });
40
+
41
+ it("rejects traversal and schemes without an injected root", async () => {
42
+ using tmp = TempDir.createSync("@eval-helpers-guard-");
43
+ const helpers = createHelpers(makeCtx(tmp.path(), { local: path.join(tmp.path(), "local") }));
44
+
45
+ await expect(helpers.writeFile("local://../escape.md", "x")).rejects.toThrow(/traversal|escapes/i);
46
+ await expect(helpers.writeFile("memory://x.md", "x")).rejects.toThrow(/not supported/i);
47
+ await expect(helpers.read("https://example.com/page")).rejects.toThrow(/not supported/i);
48
+ });
49
+
50
+ it("leaves plain relative and absolute paths resolving against the cwd", async () => {
51
+ using tmp = TempDir.createSync("@eval-helpers-plain-");
52
+ const helpers = createHelpers(makeCtx(tmp.path(), {}));
53
+
54
+ const rel = await helpers.writeFile("foo/bar.txt", "bar");
55
+ expect(rel).toBe(path.join(tmp.path(), "foo", "bar.txt"));
56
+ expect(await helpers.read("foo/bar.txt")).toBe("bar");
57
+ });
58
+ });
@@ -1,3 +1,4 @@
1
+ import { buildEvalUrlRoots, type LocalProtocolOptions } from "../internal-urls";
1
2
  import type { ToolSession } from "../tools";
2
3
  import type { EvalDisplayOutput, EvalLanguage, EvalStatusEvent } from "./types";
3
4
 
@@ -56,3 +57,17 @@ export interface ExecutorBackend {
56
57
  /** Execute one cell. Caller invokes once per cell and aggregates results. */
57
58
  execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult>;
58
59
  }
60
+
61
+ /**
62
+ * Resolve the on-disk roots that the eval helpers substitute for internal-URL
63
+ * schemes (currently `local://`). Prefers the session's own
64
+ * {@link LocalProtocolOptions} — the exact mapping `read local://…` uses — so an
65
+ * eval `write("local://x")` and a later `read local://x` agree on the location.
66
+ */
67
+ export function resolveEvalUrlRoots(session: ToolSession): Record<string, string> {
68
+ const options: LocalProtocolOptions = session.localProtocolOptions ?? {
69
+ getArtifactsDir: () => session.getArtifactsDir?.() ?? null,
70
+ getSessionId: () => session.getSessionId?.() ?? null,
71
+ };
72
+ return buildEvalUrlRoots(options);
73
+ }
@@ -68,6 +68,7 @@ export async function executeInVmContext(options: {
68
68
  sessionId: string;
69
69
  cwd: string;
70
70
  session: ToolSession;
71
+ localRoots?: Record<string, string>;
71
72
  reset?: boolean;
72
73
  code: string;
73
74
  filename: string;
@@ -100,7 +101,7 @@ export async function executeInVmContext(options: {
100
101
  }
101
102
  const session = await acquireSession(
102
103
  options.sessionKey,
103
- { cwd: options.cwd, sessionId: options.sessionId },
104
+ { cwd: options.cwd, sessionId: options.sessionId, localRoots: options.localRoots },
104
105
  options.timeoutMs,
105
106
  );
106
107
  return await runOnce(session, options);
@@ -132,6 +133,7 @@ async function runOnce(
132
133
  sessionId: string;
133
134
  cwd: string;
134
135
  session: ToolSession;
136
+ localRoots?: Record<string, string>;
135
137
  code: string;
136
138
  filename: string;
137
139
  runState: VmRunState;
@@ -171,7 +173,7 @@ async function runOnce(
171
173
  runId,
172
174
  code: options.code,
173
175
  filename: options.filename,
174
- snapshot: { cwd: options.cwd, sessionId: options.sessionId },
176
+ snapshot: { cwd: options.cwd, sessionId: options.sessionId, localRoots: options.localRoots },
175
177
  });
176
178
  return await promise;
177
179
  } finally {
@@ -24,6 +24,8 @@ export interface JsExecutorOptions {
24
24
  artifactPath?: string;
25
25
  artifactId?: string;
26
26
  session: ToolSession;
27
+ /** On-disk roots the helpers substitute for internal-URL schemes (e.g. `local://`). */
28
+ localRoots?: Record<string, string>;
27
29
  }
28
30
 
29
31
  export interface JsResult {
@@ -96,6 +98,7 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
96
98
  sessionId: options.sessionId,
97
99
  cwd: options.cwd ?? options.session.cwd,
98
100
  session: options.session,
101
+ localRoots: options.localRoots,
99
102
  reset: options.reset,
100
103
  code,
101
104
  filename: `js-cell-${crypto.randomUUID()}.js`,
@@ -1,5 +1,10 @@
1
1
  import type { ToolSession } from "../../tools";
2
- import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend";
2
+ import {
3
+ type ExecutorBackend,
4
+ type ExecutorBackendExecOptions,
5
+ type ExecutorBackendResult,
6
+ resolveEvalUrlRoots,
7
+ } from "../backend";
3
8
  import { executeJs } from "./executor";
4
9
 
5
10
  const JS_SESSION_PREFIX = "js:";
@@ -30,6 +35,7 @@ export default {
30
35
  onChunk: opts.onChunk,
31
36
  onStatus: opts.onStatus,
32
37
  session: opts.session,
38
+ localRoots: resolveEvalUrlRoots(opts.session),
33
39
  });
34
40
  return {
35
41
  output: result.output,
@@ -24,6 +24,12 @@ export interface HelperOptions {
24
24
  export interface HelperContext {
25
25
  cwd(): string;
26
26
  env: Map<string, string>;
27
+ /**
28
+ * On-disk roots for internal-URL schemes the helpers accept (e.g.
29
+ * `{ local: "/…/artifacts/local" }`). A path like `local://x.md` is rewritten
30
+ * to `<root>/x.md` before any filesystem op; unknown schemes are rejected.
31
+ */
32
+ localRoots(): Record<string, string>;
27
33
  emitStatus(event: JsStatusEvent): void;
28
34
  }
29
35
 
@@ -66,7 +72,7 @@ export function createHelpers(ctx: HelperContext): HelperBundle {
66
72
  if (!isWriteData(data)) {
67
73
  throw new ToolError("write() expects string, Blob, ArrayBuffer, or TypedArray data");
68
74
  }
69
- const filePath = resolvePath(ctx, rawPath);
75
+ const filePath = resolveHelperPath(ctx, rawPath, "write");
70
76
  if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) {
71
77
  await Bun.write(filePath, data);
72
78
  } else {
@@ -76,7 +82,7 @@ export function createHelpers(ctx: HelperContext): HelperBundle {
76
82
  return filePath;
77
83
  },
78
84
  append: async (rawPath, content) => {
79
- const target = resolvePath(ctx, rawPath);
85
+ const target = resolveHelperPath(ctx, rawPath, "write");
80
86
  await Bun.write(
81
87
  target,
82
88
  `${await Bun.file(target)
@@ -202,19 +208,60 @@ function getMergedEnv(ctx: HelperContext): Record<string, string> {
202
208
  return merged;
203
209
  }
204
210
 
211
+ const INTERNAL_URL_RE = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i;
212
+
205
213
  function resolvePath(ctx: HelperContext, value: string): string {
206
214
  if (path.isAbsolute(value)) return path.normalize(value);
207
215
  return path.resolve(ctx.cwd(), value);
208
216
  }
209
217
 
218
+ /**
219
+ * Map a raw helper path to an absolute filesystem path. Plain paths resolve
220
+ * against the cwd; an internal-URL whose scheme has an injected root (e.g.
221
+ * `local://`) is rewritten under that root; any other `scheme://` is rejected
222
+ * so we never silently create a literal `scheme:/` directory.
223
+ */
224
+ function resolveHelperPath(ctx: HelperContext, rawPath: string, op: "read" | "write"): string {
225
+ const match = INTERNAL_URL_RE.exec(rawPath);
226
+ if (!match) return resolvePath(ctx, rawPath);
227
+ const scheme = match[1].toLowerCase();
228
+ const root = ctx.localRoots()[scheme];
229
+ if (!root) {
230
+ throw new ToolError(`Protocol paths are not supported by ${op}(): ${rawPath}`);
231
+ }
232
+ return resolveUnderRoot(scheme, root, match[2], rawPath);
233
+ }
234
+
235
+ /** Resolve an internal-URL relative path under its root, mirroring the host
236
+ * local-protocol handler: decode, reject absolute/traversal, confine to root. */
237
+ function resolveUnderRoot(scheme: string, root: string, rawRelative: string, rawPath: string): string {
238
+ let relative: string;
239
+ try {
240
+ relative = decodeURIComponent(rawRelative.replaceAll("\\", "/"));
241
+ } catch {
242
+ throw new ToolError(`Invalid URL encoding in ${scheme}:// path: ${rawPath}`);
243
+ }
244
+ const rootPath = path.resolve(root);
245
+ if (relative === "") return rootPath;
246
+ if (path.isAbsolute(relative)) {
247
+ throw new ToolError(`Absolute paths are not allowed in ${scheme}:// URLs: ${rawPath}`);
248
+ }
249
+ const normalized = path.normalize(relative);
250
+ if (normalized.startsWith("..") || normalized.includes("/../") || normalized.includes("/..")) {
251
+ throw new ToolError(`Path traversal (..) is not allowed in ${scheme}:// URLs: ${rawPath}`);
252
+ }
253
+ const resolved = path.resolve(rootPath, normalized);
254
+ if (resolved !== rootPath && !resolved.startsWith(`${rootPath}${path.sep}`)) {
255
+ throw new ToolError(`${scheme}:// path escapes its root: ${rawPath}`);
256
+ }
257
+ return resolved;
258
+ }
259
+
210
260
  async function resolveRegularFile(
211
261
  ctx: HelperContext,
212
262
  rawPath: string,
213
263
  ): Promise<{ filePath: string; file: Bun.BunFile; size: number }> {
214
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(rawPath)) {
215
- throw new ToolError(`Protocol paths are not supported by read(): ${rawPath}`);
216
- }
217
- const filePath = resolvePath(ctx, rawPath);
264
+ const filePath = resolveHelperPath(ctx, rawPath, "read");
218
265
  const file = Bun.file(filePath);
219
266
  const stat = await file.stat();
220
267
  if (stat.isDirectory()) {
@@ -42,6 +42,11 @@ export interface RuntimeOptions {
42
42
  * via `setRunScope()` instead.
43
43
  */
44
44
  extraGlobals?: Record<string, unknown>;
45
+ /**
46
+ * On-disk roots the helpers substitute for internal-URL schemes (e.g.
47
+ * `{ local: "/…/artifacts/local" }`). Stable for the worker's lifetime.
48
+ */
49
+ localRoots?: Record<string, string>;
45
50
  }
46
51
 
47
52
  // Strict base64: characters from the standard alphabet plus optional `=` padding, and a
@@ -126,15 +131,18 @@ export class JsRuntime {
126
131
  #env: Map<string, string>;
127
132
  #als = new AsyncLocalStorage<RunContext>();
128
133
  #moduleLoader: LocalModuleLoader;
134
+ #localRoots: Record<string, string>;
129
135
 
130
136
  constructor(opts: RuntimeOptions) {
131
137
  this.#cwd = opts.initialCwd;
132
138
  this.sessionId = opts.sessionId;
133
139
  this.#env = new Map();
134
140
  this.#moduleLoader = new LocalModuleLoader(this.sessionId);
141
+ this.#localRoots = opts.localRoots ?? {};
135
142
  this.helpers = createHelpers({
136
143
  cwd: () => this.#activeCwd(),
137
144
  env: this.#env,
145
+ localRoots: () => this.#localRoots,
138
146
  emitStatus: event => this.#activeHooks("emitStatus")?.onDisplay({ type: "status", event }),
139
147
  });
140
148
  this.#install(opts.extraGlobals);
@@ -71,6 +71,7 @@ export class WorkerCore {
71
71
  this.#runtime = new JsRuntime({
72
72
  initialCwd: snapshot.cwd,
73
73
  sessionId: snapshot.sessionId,
74
+ localRoots: snapshot.localRoots,
74
75
  });
75
76
  return this.#runtime;
76
77
  }
@@ -5,6 +5,12 @@ export type { JsDisplayOutput } from "./shared/types";
5
5
  export interface SessionSnapshot {
6
6
  cwd: string;
7
7
  sessionId: string;
8
+ /**
9
+ * On-disk roots the helpers substitute for internal-URL schemes
10
+ * (e.g. `{ local: "/…/artifacts/local" }`). Lets `read`/`write`/`append`
11
+ * accept `local://…` paths instead of writing a literal `local:/` directory.
12
+ */
13
+ localRoots?: Record<string, string>;
8
14
  }
9
15
 
10
16
  export interface RunErrorPayload {
@@ -56,6 +56,13 @@ export interface PythonExecutorOptions {
56
56
  /** Artifact path/id for full output storage */
57
57
  artifactPath?: string;
58
58
  artifactId?: string;
59
+ /**
60
+ * On-disk roots the prelude helpers (`read`/`write`/`append`) substitute for
61
+ * internal-URL schemes (e.g. `{ local: "/…/artifacts/local" }`). Exported to
62
+ * the kernel as `PI_EVAL_LOCAL_ROOTS` (JSON) so `write("local://x")` lands
63
+ * where `read local://x` resolves instead of a literal `local:/` directory.
64
+ */
65
+ localRoots?: Record<string, string>;
59
66
  /**
60
67
  * ToolSession used to resolve host-side `tool.<name>(args)` calls made from
61
68
  * the Python prelude's bridge proxy. When omitted, the bridge env vars are
@@ -275,6 +282,7 @@ const MANAGED_KERNEL_ENV_KEYS = [
275
282
  "PI_TOOL_BRIDGE_URL",
276
283
  "PI_TOOL_BRIDGE_TOKEN",
277
284
  "PI_TOOL_BRIDGE_SESSION",
285
+ "PI_EVAL_LOCAL_ROOTS",
278
286
  ] as const;
279
287
 
280
288
  function buildKernelEnvPatch(options: {
@@ -282,13 +290,16 @@ function buildKernelEnvPatch(options: {
282
290
  artifactsDir?: string;
283
291
  bridgeSessionId?: string;
284
292
  bridge?: { url: string; token: string };
293
+ localRoots?: Record<string, string>;
285
294
  }): KernelRuntimeEnv {
295
+ const localRoots = options.localRoots;
286
296
  return {
287
297
  PI_SESSION_FILE: options.sessionFile ?? null,
288
298
  PI_ARTIFACTS_DIR: options.artifactsDir ?? null,
289
299
  PI_TOOL_BRIDGE_URL: options.bridge?.url ?? null,
290
300
  PI_TOOL_BRIDGE_TOKEN: options.bridge?.token ?? null,
291
301
  PI_TOOL_BRIDGE_SESSION: options.bridge && options.bridgeSessionId ? options.bridgeSessionId : null,
302
+ PI_EVAL_LOCAL_ROOTS: localRoots && Object.keys(localRoots).length > 0 ? JSON.stringify(localRoots) : null,
292
303
  };
293
304
  }
294
305
 
@@ -297,6 +308,7 @@ function buildKernelEnv(options: {
297
308
  artifactsDir?: string;
298
309
  bridgeSessionId?: string;
299
310
  bridge?: { url: string; token: string };
311
+ localRoots?: Record<string, string>;
300
312
  }): Record<string, string> | undefined {
301
313
  const patch = buildKernelEnvPatch(options);
302
314
  const env: Record<string, string> = {};
@@ -1,5 +1,10 @@
1
1
  import type { ToolSession } from "../../tools";
2
- import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend";
2
+ import {
3
+ type ExecutorBackend,
4
+ type ExecutorBackendExecOptions,
5
+ type ExecutorBackendResult,
6
+ resolveEvalUrlRoots,
7
+ } from "../backend";
3
8
  import { executePython, type PythonExecutorOptions } from "./executor";
4
9
  import { checkPythonKernelAvailability } from "./kernel";
5
10
 
@@ -34,6 +39,7 @@ export default {
34
39
  kernelMode,
35
40
  sessionFile: opts.sessionFile,
36
41
  artifactsDir: opts.session.getArtifactsDir?.() ?? undefined,
42
+ localRoots: resolveEvalUrlRoots(opts.session),
37
43
  kernelOwnerId: opts.kernelOwnerId,
38
44
  reset: opts.reset,
39
45
  artifactPath: opts.artifactPath,