@ai-sdk/harness-pi 1.0.104 → 1.0.105

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.104",
3
+ "version": "1.0.105",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,8 +26,8 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/harness": "1.0.102",
30
- "@ai-sdk/provider-utils": "5.0.36",
29
+ "@ai-sdk/harness": "1.0.103",
30
+ "@ai-sdk/provider-utils": "5.0.37",
31
31
  "@earendil-works/pi-ai": "0.74.2",
32
32
  "@earendil-works/pi-coding-agent": "^0.84.3",
33
33
  "pi-mcp-adapter": "2.12.1",
@@ -37,7 +37,7 @@
37
37
  "zod": "^3.25.76 || ^4.1.8"
38
38
  },
39
39
  "devDependencies": {
40
- "@ai-sdk/sandbox-just-bash": "1.0.102",
40
+ "@ai-sdk/sandbox-just-bash": "1.0.103",
41
41
  "@types/node": "22.19.19",
42
42
  "@vercel/ai-tsconfig": "0.0.0",
43
43
  "tsup": "^8.5.1",
package/src/pi-session.ts CHANGED
@@ -11,7 +11,8 @@ import {
11
11
  type Skill,
12
12
  type ToolDefinition,
13
13
  } from '@earendil-works/pi-coding-agent';
14
- import { mkdir, rm } from 'node:fs/promises';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
15
16
  import { tmpdir } from 'node:os';
16
17
  import path from 'node:path';
17
18
  import { Type } from 'typebox';
@@ -282,6 +283,36 @@ interface DeferredRerunBarrier {
282
283
  readonly cancel: (reason?: unknown) => void;
283
284
  }
284
285
 
286
+ async function isWorkspaceAvailableOnHost({
287
+ sandbox,
288
+ sessionWorkDir,
289
+ }: {
290
+ sandbox: SandboxSession;
291
+ sessionWorkDir: string;
292
+ }): Promise<boolean> {
293
+ // A host path existing at the same location is not enough to prove that it
294
+ // belongs to the sandbox. Round-trip a unique marker through the sandbox
295
+ // filesystem API before using the workspace directly.
296
+ const probePath = path.join(
297
+ sessionWorkDir,
298
+ `.ai-sdk-harness-pi-${randomUUID()}`,
299
+ );
300
+ const probeContent = randomUUID();
301
+
302
+ try {
303
+ await writeFile(probePath, probeContent, { flag: 'wx' });
304
+ const sandboxContent = await sandbox.readBinaryFile({ path: probePath });
305
+ return (
306
+ sandboxContent != null &&
307
+ Buffer.from(sandboxContent).equals(Buffer.from(probeContent))
308
+ );
309
+ } catch {
310
+ return false;
311
+ } finally {
312
+ await rm(probePath, { force: true }).catch(() => {});
313
+ }
314
+ }
315
+
285
316
  export async function createPiSession(
286
317
  input: CreatePiSessionInput,
287
318
  ): Promise<HarnessV1Session> {
@@ -301,28 +332,34 @@ export async function createPiSession(
301
332
  // sub-directory tree on disk.
302
333
  const safeSessionId = input.sessionId.replace(/[\\/: ]/g, '-');
303
334
  const hostRoot = path.join(tmpdir(), 'ai-sdk-harness', 'pi', safeSessionId);
304
- const hostWorkDir = path.join(hostRoot, 'workspace');
305
335
  const hostAgentDir = path.join(hostRoot, 'agent');
306
336
  const hostSessionDir = path.join(hostRoot, 'sessions');
337
+ const toolSafeSandboxSession = getRestrictedSandboxSession(
338
+ input.sandboxSession,
339
+ );
307
340
 
308
341
  // Pi runs in this host process but must behave as though it lives in the
309
342
  // sandbox workspace: its working directory is the real `sessionWorkDir`
310
343
  // (where `setup()` clones and where the sandbox-backed tools operate), so the
311
344
  // paths Pi advertises to the model — most notably the "Current working
312
- // directory" line in its system prompt — resolve inside the sandbox. The
313
- // workspace VFS maps that sandbox path to the host-side mirror so Pi's own
314
- // `fs`-based resource loading (`.pi/`, `AGENTS.md`) still works on the host.
315
- // `sessionWorkDir` is a sandbox path (e.g. `/vercel/sandbox/...`) that does
316
- // not exist on the host, so it is a safe, collision-free VFS mount point.
345
+ // directory" line in its system prompt — resolve inside the sandbox. When
346
+ // the sandbox filesystem is remote, the workspace VFS maps that sandbox path
347
+ // to a scoped host mirror for Pi's own `fs`-based resource loading. When the
348
+ // sandbox and harness share a filesystem, Pi uses the workspace directly so
349
+ // extensions can inspect project files beyond the scoped resource paths.
317
350
  const sessionWorkDir = input.sessionWorkDir;
351
+ const workspaceAvailableOnHost = await isWorkspaceAvailableOnHost({
352
+ sandbox: toolSafeSandboxSession,
353
+ sessionWorkDir,
354
+ });
355
+ const hostWorkDir = workspaceAvailableOnHost
356
+ ? path.resolve(sessionWorkDir)
357
+ : path.join(hostRoot, 'workspace');
318
358
 
319
359
  await mkdir(hostWorkDir, { recursive: true });
320
360
  await mkdir(hostAgentDir, { recursive: true });
321
361
  await mkdir(hostSessionDir, { recursive: true });
322
362
 
323
- const toolSafeSandboxSession = getRestrictedSandboxSession(
324
- input.sandboxSession,
325
- );
326
363
  const sandboxHomeDir = await resolveSandboxHomeDir({
327
364
  sandbox: toolSafeSandboxSession,
328
365
  ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
@@ -358,11 +395,13 @@ export async function createPiSession(
358
395
 
359
396
  // Snapshot sandbox state into the host mirror BEFORE the VFS goes live so
360
397
  // Pi sees the workspace as soon as it boots.
361
- await syncHostWorkspaceFromSandbox({
362
- sandbox: toolSafeSandboxSession,
363
- sandboxWorkDir: input.sessionWorkDir,
364
- hostWorkDir,
365
- });
398
+ if (!workspaceAvailableOnHost) {
399
+ await syncHostWorkspaceFromSandbox({
400
+ sandbox: toolSafeSandboxSession,
401
+ sandboxWorkDir: input.sessionWorkDir,
402
+ hostWorkDir,
403
+ });
404
+ }
366
405
 
367
406
  // Mount only the workspace: the model's view of the workspace lives at
368
407
  // `sessionWorkDir` and is backed by `hostWorkDir`. The agent and session
@@ -370,7 +409,9 @@ export async function createPiSession(
370
409
  // Pi state (auth, model registry, session journal) that must never surface
371
410
  // in the sandbox or the workspace mirror.
372
411
  const workspaceVfs = new PiWorkspaceVfs();
373
- workspaceVfs.mount(hostWorkDir, sessionWorkDir);
412
+ if (!workspaceAvailableOnHost) {
413
+ workspaceVfs.mount(hostWorkDir, sessionWorkDir);
414
+ }
374
415
 
375
416
  const paths = createPiPathMapper({
376
417
  hostWorkDir,
@@ -1011,7 +1052,7 @@ export async function createPiSession(
1011
1052
  settingsManager,
1012
1053
  resourceLoader,
1013
1054
  customTools,
1014
- ...(hasMcpServers
1055
+ ...(hasExtensionFactories
1015
1056
  ? { noTools: 'builtin' as const }
1016
1057
  : { tools: toolNames }),
1017
1058
  ...(input.settings.thinkingLevel
@@ -1147,11 +1188,13 @@ export async function createPiSession(
1147
1188
  await reloadResourcesOnly();
1148
1189
  turnAbortController.signal.throwIfAborted();
1149
1190
  }
1150
- await syncHostWorkspaceFromSandbox({
1151
- sandbox: toolSafeSandboxSession,
1152
- sandboxWorkDir: input.sessionWorkDir,
1153
- hostWorkDir,
1154
- });
1191
+ if (!workspaceAvailableOnHost) {
1192
+ await syncHostWorkspaceFromSandbox({
1193
+ sandbox: toolSafeSandboxSession,
1194
+ sandboxWorkDir: input.sessionWorkDir,
1195
+ hostWorkDir,
1196
+ });
1197
+ }
1155
1198
  turnAbortController.signal.throwIfAborted();
1156
1199
 
1157
1200
  // Fresh translator state for the new turn — keep the tool sets the