@ai-sdk/harness-pi 1.0.35 → 1.0.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness-pi",
3
- "version": "1.0.35",
3
+ "version": "1.0.36",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -28,8 +28,8 @@
28
28
  "dependencies": {
29
29
  "@earendil-works/pi-coding-agent": "^0.79.0",
30
30
  "typebox": "^1.1.38",
31
- "@ai-sdk/harness": "1.0.35",
32
- "@ai-sdk/provider-utils": "5.0.10"
31
+ "@ai-sdk/harness": "1.0.36",
32
+ "@ai-sdk/provider-utils": "5.0.11"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "zod": "^3.25.76 || ^4.1.8"
package/src/pi-paths.ts CHANGED
@@ -17,6 +17,13 @@ export interface PiPathMapper {
17
17
  * allows explicitly configured sandbox roots such as `$HOME/.agents/skills`.
18
18
  */
19
19
  toReadableSandboxPath(inputPath: string): string;
20
+ /** Verify that a sandbox-side path is still inside `sandboxWorkDir`. */
21
+ assertSandboxPath(inputPath: string): string;
22
+ /**
23
+ * Verify that a sandbox-side path is inside `sandboxWorkDir` or an
24
+ * explicitly configured readable root.
25
+ */
26
+ assertReadableSandboxPath(inputPath: string): string;
20
27
  /** Translate any path to its POSIX-relative form under `sandboxWorkDir`. */
21
28
  toRelativePath(inputPath: string): string;
22
29
  }
@@ -73,11 +80,34 @@ export function createPiPathMapper(
73
80
  sandboxDir: path.posix.normalize(root.sandboxDir),
74
81
  })) ?? [];
75
82
 
83
+ const assertWorkspaceSandboxPath = (inputPath: string): string => {
84
+ const normalizedInput = path.posix.normalize(inputPath);
85
+ if (!isInsidePosixPath(normalizedSandbox, normalizedInput)) {
86
+ throw new Error(`Pi path escapes the workspace: ${inputPath}`);
87
+ }
88
+ return normalizedInput;
89
+ };
90
+
91
+ const assertReadableSandboxPath = (inputPath: string): string => {
92
+ const normalizedInput = path.posix.normalize(inputPath);
93
+ if (
94
+ !isInsidePosixPath(normalizedSandbox, normalizedInput) &&
95
+ !readableRoots.some(root =>
96
+ isInsidePosixPath(root.sandboxDir, normalizedInput),
97
+ )
98
+ ) {
99
+ throw new Error(`Pi path escapes the readable roots: ${inputPath}`);
100
+ }
101
+ return normalizedInput;
102
+ };
103
+
76
104
  const toWorkspaceSandboxPath = (inputPath: string): string => {
77
105
  if (path.posix.isAbsolute(inputPath)) {
78
106
  const normalizedInput = path.posix.normalize(inputPath);
79
- if (isInsidePosixPath(normalizedSandbox, normalizedInput)) {
80
- return normalizedInput;
107
+ try {
108
+ return assertWorkspaceSandboxPath(normalizedInput);
109
+ } catch {
110
+ // Absolute host paths are handled below.
81
111
  }
82
112
  }
83
113
 
@@ -110,18 +140,21 @@ export function createPiPathMapper(
110
140
  toReadableSandboxPath(inputPath: string) {
111
141
  if (path.posix.isAbsolute(inputPath)) {
112
142
  const normalizedInput = path.posix.normalize(inputPath);
113
- if (
114
- isInsidePosixPath(normalizedSandbox, normalizedInput) ||
115
- readableRoots.some(root =>
116
- isInsidePosixPath(root.sandboxDir, normalizedInput),
117
- )
118
- ) {
119
- return normalizedInput;
143
+ try {
144
+ return assertReadableSandboxPath(normalizedInput);
145
+ } catch {
146
+ // Absolute host paths are handled by workspace mapping below.
120
147
  }
121
148
  }
122
149
 
123
150
  return toWorkspaceSandboxPath(inputPath);
124
151
  },
152
+ assertSandboxPath(inputPath: string) {
153
+ return assertWorkspaceSandboxPath(inputPath);
154
+ },
155
+ assertReadableSandboxPath(inputPath: string) {
156
+ return assertReadableSandboxPath(inputPath);
157
+ },
125
158
  toRelativePath(inputPath: string) {
126
159
  const sandboxPath = path.posix.isAbsolute(inputPath)
127
160
  ? path.posix.normalize(inputPath)
@@ -65,6 +65,10 @@ interface RunShellResult {
65
65
  output: Buffer;
66
66
  }
67
67
 
68
+ function lastOutputLine(output: Buffer): string | undefined {
69
+ return output.toString('utf8').trim().split('\n').filter(Boolean).at(-1);
70
+ }
71
+
68
72
  export function createPiRemoteOps(options: PiRemoteOpsOptions): PiRemoteOps {
69
73
  const runShell = async (
70
74
  command: string,
@@ -94,9 +98,83 @@ export function createPiRemoteOps(options: PiRemoteOpsOptions): PiRemoteOps {
94
98
  };
95
99
  };
96
100
 
101
+ const resolveExistingSandboxPath = async (
102
+ remotePath: string,
103
+ inputPath: string,
104
+ ): Promise<string> => {
105
+ const result = await runShell(
106
+ [
107
+ `target=${shellQuote(remotePath)}`,
108
+ `if [ ! -e "$target" ]; then echo "__PI_REALPATH_NOT_FOUND__"; exit 2; fi`,
109
+ `resolved=$(realpath "$target" 2>/dev/null) || { echo "__PI_REALPATH_FAILED__"; exit 3; }`,
110
+ `printf '%s\\n' "$resolved"`,
111
+ ].join('; '),
112
+ );
113
+
114
+ const output = result.output.toString('utf8');
115
+ if (output.includes('__PI_REALPATH_NOT_FOUND__')) {
116
+ throw new Error(`Path not found: ${inputPath}`);
117
+ }
118
+ if (output.includes('__PI_REALPATH_FAILED__') || result.exitCode !== 0) {
119
+ throw new Error(`Unable to resolve path: ${inputPath}`);
120
+ }
121
+
122
+ const resolvedPath = lastOutputLine(result.output);
123
+ if (!resolvedPath) {
124
+ throw new Error(`Unable to resolve path: ${inputPath}`);
125
+ }
126
+ return resolvedPath;
127
+ };
128
+
129
+ const resolveReadableSandboxPath = async (
130
+ remotePath: string,
131
+ inputPath: string,
132
+ ): Promise<string> =>
133
+ options.paths.assertReadableSandboxPath(
134
+ await resolveExistingSandboxPath(remotePath, inputPath),
135
+ );
136
+
137
+ const resolveWritableSandboxPath = async (
138
+ remotePath: string,
139
+ inputPath: string,
140
+ ): Promise<string> => {
141
+ const result = await runShell(
142
+ [
143
+ `target=${shellQuote(remotePath)}`,
144
+ `if [ -e "$target" ] || [ -L "$target" ]; then resolved=$(realpath "$target" 2>/dev/null) || { echo "__PI_REALPATH_FAILED__"; exit 3; }; printf '%s\\n' "$resolved"; exit 0; fi`,
145
+ `dir=$(dirname "$target")`,
146
+ `base=$(basename "$target")`,
147
+ `missing="$base"`,
148
+ `while [ ! -e "$dir" ] && [ ! -L "$dir" ]; do parent=$(dirname "$dir"); if [ "$parent" = "$dir" ]; then echo "__PI_REALPATH_NOT_FOUND__"; exit 2; fi; missing="$(basename "$dir")/$missing"; dir="$parent"; done`,
149
+ `resolved_dir=$(realpath "$dir" 2>/dev/null) || { echo "__PI_REALPATH_FAILED__"; exit 3; }`,
150
+ `printf '%s/%s\\n' "$resolved_dir" "$missing"`,
151
+ ].join('; '),
152
+ );
153
+
154
+ const output = result.output.toString('utf8');
155
+ if (
156
+ output.includes('__PI_REALPATH_NOT_FOUND__') ||
157
+ output.includes('__PI_REALPATH_FAILED__') ||
158
+ result.exitCode !== 0
159
+ ) {
160
+ throw new Error(`Unable to resolve path: ${inputPath}`);
161
+ }
162
+
163
+ const resolvedPath = lastOutputLine(result.output);
164
+ if (!resolvedPath) {
165
+ throw new Error(`Unable to resolve path: ${inputPath}`);
166
+ }
167
+ return options.paths.assertSandboxPath(resolvedPath);
168
+ };
169
+
97
170
  const readBuffer = async (inputPath: string): Promise<Buffer> => {
171
+ const remotePath = options.paths.toReadableSandboxPath(inputPath);
172
+ const resolvedPath = await resolveReadableSandboxPath(
173
+ remotePath,
174
+ inputPath,
175
+ );
98
176
  const bytes = await options.sandbox.readBinaryFile({
99
- path: options.paths.toReadableSandboxPath(inputPath),
177
+ path: resolvedPath,
100
178
  });
101
179
  if (!bytes) {
102
180
  throw new Error(`Path not found: ${inputPath}`);
@@ -109,12 +187,18 @@ export function createPiRemoteOps(options: PiRemoteOpsOptions): PiRemoteOps {
109
187
  content: string,
110
188
  ): Promise<void> => {
111
189
  const remotePath = options.paths.toSandboxPath(inputPath);
112
- const previous = await options.sandbox.readBinaryFile({ path: remotePath });
113
- await runShell(`mkdir -p ${shellQuote(path.posix.dirname(remotePath))}`);
114
- await options.sandbox.writeTextFile({ path: remotePath, content });
190
+ const resolvedPath = await resolveWritableSandboxPath(
191
+ remotePath,
192
+ inputPath,
193
+ );
194
+ const previous = await options.sandbox.readBinaryFile({
195
+ path: resolvedPath,
196
+ });
197
+ await runShell(`mkdir -p ${shellQuote(path.posix.dirname(resolvedPath))}`);
198
+ await options.sandbox.writeTextFile({ path: resolvedPath, content });
115
199
  options.onFileChange?.(
116
200
  previous ? 'modify' : 'create',
117
- options.paths.toRelativePath(remotePath),
201
+ options.paths.toRelativePath(resolvedPath),
118
202
  Buffer.from(content, 'utf8'),
119
203
  );
120
204
  };
@@ -141,11 +225,15 @@ export function createPiRemoteOps(options: PiRemoteOpsOptions): PiRemoteOps {
141
225
  limit: number = 500,
142
226
  ): Promise<string[]> => {
143
227
  const remotePath = options.paths.toReadableSandboxPath(inputPath);
228
+ const resolvedPath = await resolveReadableSandboxPath(
229
+ remotePath,
230
+ inputPath,
231
+ );
144
232
  const result = await runShell(
145
233
  [
146
- `if [ ! -e ${shellQuote(remotePath)} ]; then echo "__PI_LS_NOT_FOUND__"; exit 2; fi`,
147
- `if [ ! -d ${shellQuote(remotePath)} ]; then echo "__PI_LS_NOT_DIR__"; exit 3; fi`,
148
- `cd ${shellQuote(remotePath)}`,
234
+ `if [ ! -e ${shellQuote(resolvedPath)} ]; then echo "__PI_LS_NOT_FOUND__"; exit 2; fi`,
235
+ `if [ ! -d ${shellQuote(resolvedPath)} ]; then echo "__PI_LS_NOT_DIR__"; exit 3; fi`,
236
+ `cd ${shellQuote(resolvedPath)}`,
149
237
  'ls -1Ap',
150
238
  ].join('; '),
151
239
  );
@@ -174,10 +262,14 @@ export function createPiRemoteOps(options: PiRemoteOpsOptions): PiRemoteOps {
174
262
  limit: number = 1_000,
175
263
  ): Promise<string[]> => {
176
264
  const remotePath = options.paths.toReadableSandboxPath(inputPath);
265
+ const resolvedPath = await resolveReadableSandboxPath(
266
+ remotePath,
267
+ inputPath,
268
+ );
177
269
  const result = await runShell(
178
270
  [
179
- `if [ ! -e ${shellQuote(remotePath)} ]; then echo "__PI_FIND_NOT_FOUND__"; exit 2; fi`,
180
- `if [ -d ${shellQuote(remotePath)} ]; then find ${shellQuote(remotePath)} -type f -print; else printf '%s\\n' ${shellQuote(remotePath)}; fi`,
271
+ `if [ ! -e ${shellQuote(resolvedPath)} ]; then echo "__PI_FIND_NOT_FOUND__"; exit 2; fi`,
272
+ `if [ -d ${shellQuote(resolvedPath)} ]; then find ${shellQuote(resolvedPath)} -type f -print; else printf '%s\\n' ${shellQuote(resolvedPath)}; fi`,
181
273
  ].join('; '),
182
274
  );
183
275
 
@@ -186,7 +278,7 @@ export function createPiRemoteOps(options: PiRemoteOpsOptions): PiRemoteOps {
186
278
  throw new Error(`Path not found: ${inputPath}`);
187
279
  }
188
280
 
189
- const searchRoot = remotePath;
281
+ const searchRoot = resolvedPath;
190
282
  return output
191
283
  .split('\n')
192
284
  .filter(Boolean)
@@ -218,10 +310,14 @@ export function createPiRemoteOps(options: PiRemoteOpsOptions): PiRemoteOps {
218
310
  },
219
311
  ): Promise<string> => {
220
312
  const remotePath = options.paths.toReadableSandboxPath(input.path ?? '.');
221
- const relativeTarget = options.paths.toRelativePath(remotePath);
313
+ const resolvedPath = await resolveReadableSandboxPath(
314
+ remotePath,
315
+ input.path ?? '.',
316
+ );
317
+ const relativeTarget = options.paths.toRelativePath(resolvedPath);
222
318
  const targetPath =
223
319
  relativeTarget.startsWith('../') || path.posix.isAbsolute(relativeTarget)
224
- ? remotePath
320
+ ? resolvedPath
225
321
  : relativeTarget;
226
322
  const flags = [
227
323
  '-R',
@@ -237,7 +333,7 @@ export function createPiRemoteOps(options: PiRemoteOpsOptions): PiRemoteOps {
237
333
  const limit = Math.max(1, input.limit ?? 100);
238
334
  const result = await runShell(
239
335
  [
240
- `if [ ! -e ${shellQuote(remotePath)} ]; then echo "__PI_GREP_NOT_FOUND__"; exit 2; fi`,
336
+ `if [ ! -e ${shellQuote(resolvedPath)} ]; then echo "__PI_GREP_NOT_FOUND__"; exit 2; fi`,
241
337
  `cd ${shellQuote(options.paths.sandboxWorkDir)}`,
242
338
  `grep ${flags.map(shellQuote).join(' ')} -- ${shellQuote(pattern)} ${shellQuote(targetPath)} 2>/dev/null | head -n ${limit}`,
243
339
  ].join('; '),