@ai-sdk/harness-pi 1.0.4 → 1.0.6

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.4",
3
+ "version": "1.0.6",
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.4",
32
- "@ai-sdk/provider-utils": "5.0.0"
31
+ "@ai-sdk/harness": "1.0.6",
32
+ "@ai-sdk/provider-utils": "5.0.1"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "zod": "^3.25.76 || ^4.1.8"
package/src/pi-events.ts CHANGED
@@ -2,54 +2,48 @@ import { z } from 'zod/v4';
2
2
 
3
3
  /**
4
4
  * Pi `session.subscribe` emits a discriminated union of events. The exact
5
- * shape evolves with Pi versions; we accept the events with `passthrough()`
6
- * and extract only the fields we recognise. The `type` field is required
7
- * and stringly-typed because Pi may add new types we want to ignore.
5
+ * shape evolves with Pi versions; we accept loose objects and extract only
6
+ * the fields we recognise. The `type` field is required and stringly-typed
7
+ * because Pi may add new types we want to ignore.
8
8
  */
9
- export const piSessionEventSchema = z
10
- .object({
11
- type: z.string(),
12
- assistantMessageEvent: z
13
- .object({
14
- type: z.string().optional(),
15
- delta: z.string().optional(),
16
- })
17
- .passthrough()
18
- .optional(),
19
- toolCallId: z.string().optional(),
20
- toolName: z.string().optional(),
21
- args: z.unknown().optional(),
22
- input: z.unknown().optional(),
23
- result: z.unknown().optional(),
24
- content: z.unknown().optional(),
25
- isError: z.boolean().optional(),
26
- // Compaction events (`compaction_start` / `compaction_end`). `result` (a
27
- // `CompactionResult`) rides the shared `result` field above; `reason`
28
- // distinguishes manual vs automatic (threshold/overflow) compaction.
29
- reason: z.string().optional(),
30
- aborted: z.boolean().optional(),
31
- error: z
32
- .union([
33
- z.string(),
34
- z
35
- .object({
36
- errorMessage: z.string().optional(),
37
- stopReason: z.string().optional(),
38
- })
39
- .passthrough(),
40
- ])
41
- .optional(),
42
- message: z
43
- .object({
44
- role: z.string().optional(),
45
- content: z.unknown().optional(),
46
- stopReason: z.string().optional(),
9
+ export const piSessionEventSchema = z.looseObject({
10
+ type: z.string(),
11
+ assistantMessageEvent: z
12
+ .looseObject({
13
+ type: z.string().optional(),
14
+ delta: z.string().optional(),
15
+ })
16
+ .optional(),
17
+ toolCallId: z.string().optional(),
18
+ toolName: z.string().optional(),
19
+ args: z.unknown().optional(),
20
+ input: z.unknown().optional(),
21
+ result: z.unknown().optional(),
22
+ content: z.unknown().optional(),
23
+ isError: z.boolean().optional(),
24
+ // Compaction events (`compaction_start` / `compaction_end`). `result` (a
25
+ // `CompactionResult`) rides the shared `result` field above; `reason`
26
+ // distinguishes manual vs automatic (threshold/overflow) compaction.
27
+ reason: z.string().optional(),
28
+ aborted: z.boolean().optional(),
29
+ error: z
30
+ .union([
31
+ z.string(),
32
+ z.looseObject({
47
33
  errorMessage: z.string().optional(),
48
- })
49
- .passthrough()
50
- .optional(),
51
- })
52
- .passthrough();
34
+ stopReason: z.string().optional(),
35
+ }),
36
+ ])
37
+ .optional(),
38
+ message: z
39
+ .looseObject({
40
+ role: z.string().optional(),
41
+ content: z.unknown().optional(),
42
+ stopReason: z.string().optional(),
43
+ errorMessage: z.string().optional(),
44
+ })
45
+ .optional(),
46
+ });
53
47
 
54
48
  export type PiSessionEvent = z.infer<typeof piSessionEventSchema>;
55
49
 
@@ -2,6 +2,23 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
4
4
  import { z } from 'zod/v4';
5
+ import { shellQuote } from './pi-utils';
6
+
7
+ const PI_SESSION_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl?$/;
8
+
9
+ export function safePiSessionFileName(sessionFileName: string): string {
10
+ if (!PI_SESSION_FILE_NAME_PATTERN.test(sessionFileName)) {
11
+ throw new Error(`Invalid Pi session file name: ${sessionFileName}`);
12
+ }
13
+ return sessionFileName;
14
+ }
15
+
16
+ const piSessionFileNameSchema = z
17
+ .string()
18
+ .refine(
19
+ sessionFileName => PI_SESSION_FILE_NAME_PATTERN.test(sessionFileName),
20
+ 'Pi sessionFileName must be a safe .jsonl or .json basename.',
21
+ );
5
22
 
6
23
  /**
7
24
  * Schema for the adapter-specific portion of lifecycle state `data` produced
@@ -10,16 +27,54 @@ import { z } from 'zod/v4';
10
27
  * in the sandbox under `${sessionWorkDir}/.pi-sessions/<sessionFileName>` so
11
28
  * they survive cross-process resume via the sandbox snapshot.
12
29
  */
13
- export const piResumeStateSchema = z
14
- .object({
15
- sessionFileName: z.string().optional(),
16
- })
17
- .passthrough();
30
+ export const piResumeStateSchema = z.looseObject({
31
+ sessionFileName: piSessionFileNameSchema.optional(),
32
+ });
18
33
 
19
34
  export type PiResumeStateData = z.infer<typeof piResumeStateSchema>;
20
35
 
21
36
  const PI_SESSIONS_DIR = '.pi-sessions';
22
37
 
38
+ function resolveContainedHostPath(input: {
39
+ readonly baseDir: string;
40
+ readonly sessionFileName: string;
41
+ }): string {
42
+ const baseDir = path.resolve(input.baseDir);
43
+ const filePath = path.resolve(
44
+ baseDir,
45
+ safePiSessionFileName(input.sessionFileName),
46
+ );
47
+ const relativePath = path.relative(baseDir, filePath);
48
+ if (
49
+ relativePath === '' ||
50
+ relativePath.startsWith('..') ||
51
+ path.isAbsolute(relativePath)
52
+ ) {
53
+ throw new Error(`Invalid Pi session file name: ${input.sessionFileName}`);
54
+ }
55
+ return filePath;
56
+ }
57
+
58
+ function resolveContainedSandboxPath(input: {
59
+ readonly sessionWorkDir: string;
60
+ readonly sessionFileName: string;
61
+ }): string {
62
+ const sessionDir = path.posix.resolve(input.sessionWorkDir, PI_SESSIONS_DIR);
63
+ const filePath = path.posix.resolve(
64
+ sessionDir,
65
+ safePiSessionFileName(input.sessionFileName),
66
+ );
67
+ const relativePath = path.posix.relative(sessionDir, filePath);
68
+ if (
69
+ relativePath === '' ||
70
+ relativePath.startsWith('..') ||
71
+ path.posix.isAbsolute(relativePath)
72
+ ) {
73
+ throw new Error(`Invalid Pi session file name: ${input.sessionFileName}`);
74
+ }
75
+ return filePath;
76
+ }
77
+
23
78
  /**
24
79
  * Copy the Pi session file from the host's local mirror to a stable location
25
80
  * inside the sandbox workspace. Called during resumable lifecycle methods so
@@ -32,16 +87,18 @@ export async function persistSessionFileToSandbox(args: {
32
87
  readonly sessionFileName: string;
33
88
  readonly abortSignal?: AbortSignal;
34
89
  }): Promise<void> {
35
- const hostPath = path.join(args.hostSessionDir, args.sessionFileName);
90
+ const hostPath = resolveContainedHostPath({
91
+ baseDir: args.hostSessionDir,
92
+ sessionFileName: args.sessionFileName,
93
+ });
36
94
  const content = await readFile(hostPath);
37
- const remotePath = path.posix.join(
38
- args.sessionWorkDir,
39
- PI_SESSIONS_DIR,
40
- args.sessionFileName,
41
- );
95
+ const remotePath = resolveContainedSandboxPath({
96
+ sessionWorkDir: args.sessionWorkDir,
97
+ sessionFileName: args.sessionFileName,
98
+ });
42
99
  // Ensure the parent dir exists in the sandbox before writing.
43
100
  await args.sandbox.run({
44
- command: `mkdir -p ${path.posix.dirname(remotePath)}`,
101
+ command: `mkdir -p ${shellQuote(path.posix.dirname(remotePath))}`,
45
102
  ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
46
103
  });
47
104
  await args.sandbox.writeBinaryFile({
@@ -64,18 +121,20 @@ export async function pullSessionFileFromSandbox(args: {
64
121
  readonly sessionFileName: string;
65
122
  readonly abortSignal?: AbortSignal;
66
123
  }): Promise<string | undefined> {
67
- const remotePath = path.posix.join(
68
- args.sessionWorkDir,
69
- PI_SESSIONS_DIR,
70
- args.sessionFileName,
71
- );
124
+ const remotePath = resolveContainedSandboxPath({
125
+ sessionWorkDir: args.sessionWorkDir,
126
+ sessionFileName: args.sessionFileName,
127
+ });
72
128
  const bytes = await args.sandbox.readBinaryFile({
73
129
  path: remotePath,
74
130
  ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
75
131
  });
76
132
  if (!bytes) return undefined;
77
133
  await mkdir(args.hostSessionDir, { recursive: true });
78
- const hostPath = path.join(args.hostSessionDir, args.sessionFileName);
134
+ const hostPath = resolveContainedHostPath({
135
+ baseDir: args.hostSessionDir,
136
+ sessionFileName: args.sessionFileName,
137
+ });
79
138
  await writeFile(hostPath, bytes);
80
139
  return hostPath;
81
140
  }
package/src/pi-session.ts CHANGED
@@ -38,6 +38,7 @@ import { writePiSkills } from './pi-skills';
38
38
  import {
39
39
  persistSessionFileToSandbox,
40
40
  pullSessionFileFromSandbox,
41
+ safePiSessionFileName,
41
42
  } from './pi-resume-state';
42
43
  import {
43
44
  createPiTranslatorState,
@@ -268,11 +269,14 @@ export async function createPiSession(
268
269
  // host mirror so SessionManager.open can read it.
269
270
  let resumeSessionFilePath: string | undefined;
270
271
  if (input.isResume && input.resumeSessionFileName) {
272
+ const resumeSessionFileName = safePiSessionFileName(
273
+ input.resumeSessionFileName,
274
+ );
271
275
  resumeSessionFilePath = await pullSessionFileFromSandbox({
272
276
  sandbox,
273
277
  sessionWorkDir: input.sessionWorkDir,
274
278
  hostSessionDir,
275
- sessionFileName: input.resumeSessionFileName,
279
+ sessionFileName: resumeSessionFileName,
276
280
  ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
277
281
  });
278
282
  }
@@ -569,7 +573,7 @@ export async function createPiSession(
569
573
  // round-trip it without guessing the extension.
570
574
  const candidatePath = sessionManager.getSessionFile();
571
575
  if (candidatePath) {
572
- sessionFileName = path.basename(candidatePath);
576
+ sessionFileName = safePiSessionFileName(path.basename(candidatePath));
573
577
  }
574
578
 
575
579
  translatorState = createPiTranslatorState({