@ai-sdk/harness-pi 1.0.35 → 1.0.37

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.37",
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.37",
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-harness.ts CHANGED
@@ -33,6 +33,13 @@ export type PiHarnessSettings = {
33
33
  * `thinkingLevel` option on `createAgentSession`.
34
34
  */
35
35
  readonly thinkingLevel?: PiThinkingLevel;
36
+ /**
37
+ * Directory holding Pi's global agent config (auth.json, models.json,
38
+ * settings.json). When omitted, a per-session temp dir is used. Pass the
39
+ * user's agent dir (e.g. `~/.pi/agent/`) to reuse their CLI auth and
40
+ * model settings.
41
+ */
42
+ readonly agentDir?: string;
36
43
  };
37
44
 
38
45
  const PI_BUILTIN_TOOLS = {
@@ -148,6 +155,7 @@ export function createPi(
148
155
  ...(startOpts.abortSignal
149
156
  ? { abortSignal: startOpts.abortSignal }
150
157
  : {}),
158
+ ...(settings.agentDir ? { agentDir: settings.agentDir } : {}),
151
159
  });
152
160
  },
153
161
  };
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('; '),
package/src/pi-session.ts CHANGED
@@ -202,6 +202,13 @@ export interface CreatePiSessionInput {
202
202
  readonly builtinToolFiltering?: HarnessV1BuiltinToolFiltering;
203
203
  readonly resumeSessionFileName?: string;
204
204
  readonly abortSignal?: AbortSignal;
205
+ /**
206
+ * Directory holding Pi's global agent config (auth.json, models.json,
207
+ * settings.json). When omitted, a per-session temp dir is used (the
208
+ * harness cannot reuse existing CLI logins). Pass the user's agent dir
209
+ * (e.g. `~/.pi/agent/`) to reuse their CLI auth and model settings.
210
+ */
211
+ readonly agentDir?: string;
205
212
  }
206
213
 
207
214
  interface PendingToolResult {
@@ -321,12 +328,18 @@ export async function createPiSession(
321
328
 
322
329
  // Pi auth + model registry are global to this Pi session. These live on the
323
330
  // real host filesystem (`hostAgentDir`), never in the sandbox/workspace.
324
- const authStorage = AuthStorage.create(path.join(hostAgentDir, 'auth.json'));
331
+ // When `agentDir` is provided, use it instead so the harness can reuse
332
+ // existing CLI logins and model/settings config.
333
+ const agentDir = input.agentDir ?? hostAgentDir;
334
+ const authStorage = AuthStorage.create(path.join(agentDir, 'auth.json'));
325
335
  const modelRegistry = ModelRegistry.create(
326
336
  authStorage,
327
- path.join(hostAgentDir, 'models.json'),
337
+ path.join(agentDir, 'models.json'),
328
338
  );
329
- const settingsManager = SettingsManager.inMemory();
339
+ const settingsManager =
340
+ input.agentDir != null
341
+ ? SettingsManager.create(hostWorkDir, agentDir)
342
+ : SettingsManager.inMemory();
330
343
 
331
344
  // Run-scoped env (for the model resolver's gateway fallback heuristic).
332
345
  const resolverEnv = resolvePiEnv({