@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.
- package/CHANGELOG.md +52 -0
- package/dist/types/capability/rule-buckets.d.ts +1 -1
- package/dist/types/capability/rule.d.ts +6 -1
- package/dist/types/cli/update-cli.d.ts +11 -1
- package/dist/types/config/model-registry.d.ts +18 -1
- package/dist/types/discovery/at-imports.d.ts +15 -0
- package/dist/types/edit/diff.d.ts +3 -2
- package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +7 -0
- package/dist/types/eval/js/context-manager.d.ts +1 -0
- package/dist/types/eval/js/executor.d.ts +2 -0
- package/dist/types/eval/js/index.d.ts +1 -1
- package/dist/types/eval/js/shared/helpers.d.ts +6 -0
- package/dist/types/eval/js/shared/runtime.d.ts +5 -0
- package/dist/types/eval/js/worker-protocol.d.ts +6 -0
- package/dist/types/eval/py/executor.d.ts +7 -0
- package/dist/types/eval/py/index.d.ts +1 -1
- package/dist/types/export/ttsr.d.ts +14 -0
- package/dist/types/extensibility/extensions/types.d.ts +8 -1
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
- package/dist/types/internal-urls/local-protocol.d.ts +10 -0
- package/dist/types/mcp/oauth-flow.d.ts +2 -2
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
- package/dist/types/modes/components/status-line/index.d.ts +1 -0
- package/dist/types/modes/components/status-line/types.d.ts +31 -2
- package/dist/types/modes/image-references.d.ts +8 -3
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -1
- package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +0 -2
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/browser/tab-worker.d.ts +15 -0
- package/dist/types/tools/index.d.ts +17 -0
- package/dist/types/tools/render-utils.d.ts +1 -1
- package/dist/types/tools/tool-timeouts.d.ts +1 -1
- package/dist/types/utils/block-context.d.ts +35 -0
- package/dist/types/utils/image-loading.d.ts +12 -0
- package/package.json +29 -9
- package/src/capability/rule-buckets.ts +4 -2
- package/src/capability/rule.ts +10 -1
- package/src/cli/auth-broker-cli.ts +6 -7
- package/src/cli/auth-gateway-cli.ts +1 -1
- package/src/cli/list-models.ts +5 -0
- package/src/cli/update-cli.ts +138 -16
- package/src/config/model-registry.ts +81 -2
- package/src/debug/index.ts +4 -8
- package/src/discovery/at-imports.ts +273 -0
- package/src/discovery/builtin-rules/index.ts +4 -0
- package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
- package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
- package/src/discovery/helpers.ts +2 -1
- package/src/edit/diff.ts +114 -4
- package/src/edit/hashline/diff.ts +1 -1
- package/src/edit/hashline/execute.ts +1 -1
- package/src/edit/modes/patch.ts +6 -2
- package/src/edit/modes/replace.ts +1 -1
- package/src/edit/renderer.ts +12 -2
- package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
- package/src/eval/backend.ts +15 -0
- package/src/eval/js/context-manager.ts +4 -2
- package/src/eval/js/executor.ts +3 -0
- package/src/eval/js/index.ts +7 -1
- package/src/eval/js/shared/helpers.ts +53 -6
- package/src/eval/js/shared/runtime.ts +8 -0
- package/src/eval/js/worker-core.ts +1 -0
- package/src/eval/js/worker-protocol.ts +6 -0
- package/src/eval/py/executor.ts +12 -0
- package/src/eval/py/index.ts +7 -1
- package/src/eval/py/prelude.py +43 -4
- package/src/eval/py/runner.py +1 -0
- package/src/exa/render.ts +1 -1
- package/src/export/ttsr.ts +122 -1
- package/src/extensibility/extensions/types.ts +8 -1
- package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
- package/src/extensibility/plugins/doctor.ts +1 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
- package/src/goals/tools/goal-tool.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +6 -5
- package/src/internal-urls/local-protocol.ts +13 -0
- package/src/lsp/render.ts +8 -6
- package/src/mcp/oauth-flow.ts +3 -3
- package/src/mcp/render.ts +7 -1
- package/src/modes/components/custom-editor.ts +12 -6
- package/src/modes/components/login-dialog.ts +1 -1
- package/src/modes/components/oauth-selector.ts +4 -4
- package/src/modes/components/read-tool-group.ts +10 -3
- package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
- package/src/modes/components/status-line/index.ts +1 -0
- package/src/modes/components/status-line/types.ts +23 -8
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/modes/components/transcript-container.ts +17 -10
- package/src/modes/components/user-message.ts +6 -3
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +143 -127
- package/src/modes/controllers/input-controller.ts +36 -10
- package/src/modes/controllers/mcp-command-controller.ts +28 -12
- package/src/modes/controllers/selector-controller.ts +4 -11
- package/src/modes/controllers/ssh-command-controller.ts +2 -2
- package/src/modes/image-references.ts +13 -7
- package/src/modes/interactive-mode.ts +2 -2
- package/src/modes/rpc/rpc-mode.ts +1 -1
- package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
- package/src/modes/theme/theme.ts +95 -1
- package/src/modes/types.ts +2 -1
- package/src/modes/utils/ui-helpers.ts +14 -5
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/eval.md +4 -4
- package/src/sdk.ts +31 -14
- package/src/session/agent-session.ts +213 -155
- package/src/session/session-manager.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/system-prompt.ts +15 -9
- package/src/task/render.ts +20 -8
- package/src/tools/ask.ts +14 -5
- package/src/tools/bash-interactive.ts +1 -1
- package/src/tools/bash.ts +14 -2
- package/src/tools/browser/render.ts +5 -2
- package/src/tools/browser/tab-worker.ts +211 -91
- package/src/tools/debug.ts +5 -2
- package/src/tools/eval-render.ts +6 -3
- package/src/tools/eval.ts +1 -1
- package/src/tools/gh-renderer.ts +29 -15
- package/src/tools/index.ts +32 -0
- package/src/tools/inspect-image-renderer.ts +12 -5
- package/src/tools/job.ts +9 -6
- package/src/tools/memory-render.ts +19 -5
- package/src/tools/read.ts +165 -18
- package/src/tools/render-utils.ts +3 -1
- package/src/tools/resolve.ts +1 -1
- package/src/tools/review.ts +1 -1
- package/src/tools/ssh.ts +4 -1
- package/src/tools/todo.ts +8 -1
- package/src/tools/tool-timeouts.ts +1 -1
- package/src/tools/write.ts +1 -1
- package/src/tui/code-cell.ts +1 -1
- package/src/utils/block-context.ts +312 -0
- package/src/utils/image-loading.ts +31 -1
- package/src/web/search/providers/codex.ts +1 -1
- 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(
|
|
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(
|
|
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 ?? [])
|
package/src/edit/modes/patch.ts
CHANGED
|
@@ -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}.`
|
package/src/edit/renderer.ts
CHANGED
|
@@ -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(
|
|
283
|
-
|
|
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
|
+
});
|
package/src/eval/backend.ts
CHANGED
|
@@ -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 {
|
package/src/eval/js/executor.ts
CHANGED
|
@@ -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`,
|
package/src/eval/js/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { ToolSession } from "../../tools";
|
|
2
|
-
import
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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);
|
|
@@ -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 {
|
package/src/eval/py/executor.ts
CHANGED
|
@@ -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> = {};
|
package/src/eval/py/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { ToolSession } from "../../tools";
|
|
2
|
-
import
|
|
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,
|