@ai-sdk/harness-pi 1.0.71 → 1.0.73

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.71",
3
+ "version": "1.0.73",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -30,7 +30,7 @@
30
30
  "@earendil-works/pi-coding-agent": "^0.80.10",
31
31
  "pi-mcp-adapter": "2.12.1",
32
32
  "typebox": "^1.1.38",
33
- "@ai-sdk/harness": "1.0.70",
33
+ "@ai-sdk/harness": "1.0.72",
34
34
  "@ai-sdk/provider-utils": "5.0.27"
35
35
  },
36
36
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -9,4 +9,4 @@ export const pi = createPi();
9
9
  export { createPi } from './pi-harness';
10
10
  export { VERSION } from './version';
11
11
  export type { PiHarnessSettings } from './pi-harness';
12
- export type { PiAuthOptions } from './pi-auth';
12
+ export type { PiAuthenticationMode, PiAuthOptions } from './pi-auth';
package/src/pi-auth.ts CHANGED
@@ -8,12 +8,20 @@ import { VERSION } from './version';
8
8
  type ProviderConfigInput = Parameters<ModelRegistry['registerProvider']>[1];
9
9
 
10
10
  /**
11
- * Pi auth options. Exactly one of `gateway` or `customEnv` is honoured
12
- * (precedence: explicit `customEnv`, then explicit `gateway`, then ambient
13
- * gateway from `process.env`). To use multiple providers, use `customEnv`
14
- * with the standard `<PREFIX>_API_KEY` / `<PREFIX>_BASE_URL` pattern.
11
+ * Pi auth options. Choose an explicit mode or rely on 'auto' (precedence:
12
+ * explicit gateway, then OpenAI / Anthropic / custom environment variables).
15
13
  */
16
- export type PiAuthOptions = {
14
+ export type PiAuthenticationMode =
15
+ | 'auto'
16
+ | 'openai'
17
+ | 'anthropic'
18
+ | 'custom'
19
+ | 'ai-gateway';
20
+
21
+ /**
22
+ * @deprecated Passing an object to auth options is deprecated. Use a `PiAuthenticationMode` string value ("auto" | "openai" | "anthropic" | "custom" | "ai-gateway") instead, and pass credentials via environment variables.
23
+ */
24
+ export type LegacyPiAuthOptions = {
17
25
  readonly gateway?: {
18
26
  readonly apiKey?: string;
19
27
  readonly baseUrl?: string;
@@ -31,6 +39,8 @@ export type PiAuthOptions = {
31
39
  readonly customEnv?: Record<string, string>;
32
40
  };
33
41
 
42
+ export type PiAuthOptions = PiAuthenticationMode | LegacyPiAuthOptions;
43
+
34
44
  const DEFAULT_GATEWAY_BASE_URL = 'https://ai-gateway.vercel.sh';
35
45
  const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
36
46
  const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
@@ -90,22 +100,79 @@ export function resolvePiEnv({
90
100
  options: PiAuthOptions | undefined;
91
101
  env: NodeJS.ProcessEnv;
92
102
  }): Record<string, string> {
93
- const customEnvConfigured = hasConfiguredValue(options?.customEnv);
103
+ const normalizedOptions = normalizePiAuthToLegacyAuth(options);
104
+ const customEnvConfigured = hasConfiguredValue(normalizedOptions?.customEnv);
94
105
  if (customEnvConfigured) {
95
- return resolveCustomEnv({ customEnv: options!.customEnv ?? {} });
106
+ return resolveCustomEnv({ customEnv: normalizedOptions!.customEnv ?? {} });
96
107
  }
97
108
 
98
- const gatewayConfigured = hasConfiguredValue(options?.gateway);
109
+ const gatewayConfigured = hasConfiguredValue(normalizedOptions?.gateway);
99
110
  const gatewayAuthFromEnv = getAiGatewayAuthFromEnv({ env });
100
111
  if (gatewayConfigured) {
101
- const apiKey = options!.gateway?.apiKey ?? gatewayAuthFromEnv.apiKey;
102
- const baseUrl = options!.gateway?.baseUrl ?? gatewayAuthFromEnv.baseUrl;
112
+ const apiKey =
113
+ normalizedOptions!.gateway?.apiKey ?? gatewayAuthFromEnv.apiKey;
114
+ const baseUrl =
115
+ normalizedOptions!.gateway?.baseUrl ?? gatewayAuthFromEnv.baseUrl;
103
116
  if (apiKey) {
104
117
  return { AI_GATEWAY_API_KEY: apiKey, AI_GATEWAY_BASE_URL: baseUrl };
105
118
  }
106
119
  return {};
107
120
  }
108
121
 
122
+ // Handle explicit string modes with process env
123
+ if (typeof options === 'string') {
124
+ switch (options) {
125
+ case 'openai':
126
+ if (env.OPENAI_API_KEY) {
127
+ return {
128
+ OPENAI_API_KEY: env.OPENAI_API_KEY,
129
+ ...(env.OPENAI_BASE_URL
130
+ ? { OPENAI_BASE_URL: env.OPENAI_BASE_URL }
131
+ : {}),
132
+ };
133
+ }
134
+ return {};
135
+ case 'anthropic':
136
+ if (env.ANTHROPIC_API_KEY) {
137
+ return {
138
+ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY,
139
+ ...(env.ANTHROPIC_BASE_URL
140
+ ? { ANTHROPIC_BASE_URL: env.ANTHROPIC_BASE_URL }
141
+ : {}),
142
+ ...(env.ANTHROPIC_AUTH_TOKEN
143
+ ? { ANTHROPIC_AUTH_TOKEN: env.ANTHROPIC_AUTH_TOKEN }
144
+ : {}),
145
+ };
146
+ }
147
+ return {};
148
+ case 'custom': {
149
+ const result: Record<string, string> = {};
150
+ for (const [key, value] of Object.entries(env)) {
151
+ if (
152
+ value &&
153
+ (key.endsWith('_API_KEY') ||
154
+ key.endsWith('_BASE_URL') ||
155
+ key === 'ANTHROPIC_AUTH_TOKEN')
156
+ ) {
157
+ result[key] = value;
158
+ }
159
+ }
160
+ return result;
161
+ }
162
+ case 'ai-gateway':
163
+ if (gatewayAuthFromEnv.apiKey) {
164
+ return {
165
+ AI_GATEWAY_API_KEY: gatewayAuthFromEnv.apiKey,
166
+ AI_GATEWAY_BASE_URL: gatewayAuthFromEnv.baseUrl,
167
+ };
168
+ }
169
+ return {};
170
+ case 'auto':
171
+ default:
172
+ break;
173
+ }
174
+ }
175
+
109
176
  // Ambient gateway fallback.
110
177
  if (gatewayAuthFromEnv.apiKey) {
111
178
  return {
@@ -114,7 +181,19 @@ export function resolvePiEnv({
114
181
  };
115
182
  }
116
183
 
117
- return {};
184
+ // 'auto' fallback: pick up any other provider credentials from the env.
185
+ const ambient: Record<string, string> = {};
186
+ for (const [key, value] of Object.entries(env)) {
187
+ if (
188
+ value &&
189
+ (key.endsWith('_API_KEY') ||
190
+ key.endsWith('_BASE_URL') ||
191
+ key === 'ANTHROPIC_AUTH_TOKEN')
192
+ ) {
193
+ ambient[key] = value;
194
+ }
195
+ }
196
+ return ambient;
118
197
  }
119
198
 
120
199
  export async function registerPiProviders({
@@ -128,15 +207,105 @@ export async function registerPiProviders({
128
207
  registries: PiRegistries;
129
208
  clientApp?: string;
130
209
  }): Promise<void> {
131
- if (hasConfiguredValue(options?.customEnv)) {
210
+ const normalizedOptions = normalizePiAuthToLegacyAuth(options);
211
+ if (hasConfiguredValue(normalizedOptions?.customEnv)) {
132
212
  await registerCustomProviders({
133
- customEnv: options!.customEnv ?? {},
213
+ customEnv: normalizedOptions!.customEnv ?? {},
134
214
  registries,
135
215
  clientApp,
136
216
  });
137
217
  return;
138
218
  }
139
219
 
220
+ // Legacy customEnv was handled above. Everything else reduces to a mode:
221
+ // string modes pass through, `undefined` is 'auto', and legacy gateway
222
+ // objects fall through to the trailing gateway-registration block.
223
+ const mode =
224
+ typeof options === 'string' ? options : options == null ? 'auto' : 'legacy';
225
+
226
+ switch (mode) {
227
+ case 'openai': {
228
+ const env = pickOpenAIEnv(resolvedEnv);
229
+ await registerCustomProviders({
230
+ customEnv: { ...pickOpenAIEnv(process.env), ...env },
231
+ registries,
232
+ clientApp,
233
+ });
234
+ return;
235
+ }
236
+ case 'anthropic': {
237
+ const env = pickAnthropicEnv(resolvedEnv);
238
+ await registerCustomProviders({
239
+ customEnv: { ...pickAnthropicEnv(process.env), ...env },
240
+ registries,
241
+ clientApp,
242
+ });
243
+ return;
244
+ }
245
+ case 'custom': {
246
+ // 'custom' registers every provider with credentials in the env.
247
+ const env = pickProviderEnv(resolvedEnv);
248
+ await registerCustomProviders({
249
+ customEnv: { ...pickProviderEnv(process.env), ...env },
250
+ registries,
251
+ clientApp,
252
+ });
253
+ return;
254
+ }
255
+ case 'ai-gateway': {
256
+ const gatewayAuth = getAiGatewayAuthFromEnv({ env: process.env });
257
+ const gatewayApiKey =
258
+ resolvedEnv.AI_GATEWAY_API_KEY ?? gatewayAuth.apiKey;
259
+ const gatewayBaseUrl =
260
+ resolvedEnv.AI_GATEWAY_BASE_URL ?? gatewayAuth.baseUrl;
261
+ if (!gatewayApiKey) return;
262
+ await register({
263
+ registries,
264
+ provider: 'vercel-ai-gateway',
265
+ apiKey: gatewayApiKey,
266
+ config: createGatewayProviderConfig({
267
+ apiKey: gatewayApiKey,
268
+ baseUrl: gatewayBaseUrl,
269
+ clientApp,
270
+ }),
271
+ });
272
+ return;
273
+ }
274
+ case 'legacy':
275
+ break; // handled below
276
+ case 'auto':
277
+ default: {
278
+ // 'auto' (the default): prefer the AI Gateway; only when no gateway
279
+ // credentials exist, fall back to other providers found in the env.
280
+ const gatewayAuth = getAiGatewayAuthFromEnv({ env: process.env });
281
+ const gatewayApiKey =
282
+ resolvedEnv.AI_GATEWAY_API_KEY ?? gatewayAuth.apiKey;
283
+ const gatewayBaseUrl =
284
+ resolvedEnv.AI_GATEWAY_BASE_URL ?? gatewayAuth.baseUrl;
285
+ if (gatewayApiKey) {
286
+ await register({
287
+ registries,
288
+ provider: 'vercel-ai-gateway',
289
+ apiKey: gatewayApiKey,
290
+ config: createGatewayProviderConfig({
291
+ apiKey: gatewayApiKey,
292
+ baseUrl: gatewayBaseUrl,
293
+ clientApp,
294
+ }),
295
+ });
296
+ return;
297
+ }
298
+ const env = pickProviderEnv(resolvedEnv);
299
+ await registerCustomProviders({
300
+ customEnv: { ...pickProviderEnv(process.env), ...env },
301
+ registries,
302
+ clientApp,
303
+ });
304
+ return;
305
+ }
306
+ }
307
+
308
+ // Legacy explicit gateway object options.
140
309
  const apiKey = resolvedEnv.AI_GATEWAY_API_KEY;
141
310
  const baseUrl = resolvedEnv.AI_GATEWAY_BASE_URL;
142
311
  if (!apiKey || !baseUrl) return;
@@ -149,6 +318,76 @@ export async function registerPiProviders({
149
318
  });
150
319
  }
151
320
 
321
+ function pickOpenAIEnv(
322
+ env: NodeJS.ProcessEnv | Record<string, string>,
323
+ ): Record<string, string> {
324
+ const result: Record<string, string> = {};
325
+ if (env.OPENAI_API_KEY) result.OPENAI_API_KEY = env.OPENAI_API_KEY;
326
+ if (env.OPENAI_BASE_URL) result.OPENAI_BASE_URL = env.OPENAI_BASE_URL;
327
+ return result;
328
+ }
329
+
330
+ function pickAnthropicEnv(
331
+ env: NodeJS.ProcessEnv | Record<string, string>,
332
+ ): Record<string, string> {
333
+ const result: Record<string, string> = {};
334
+ if (env.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
335
+ if (env.ANTHROPIC_BASE_URL)
336
+ result.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL;
337
+ if (env.ANTHROPIC_AUTH_TOKEN)
338
+ result.ANTHROPIC_AUTH_TOKEN = env.ANTHROPIC_AUTH_TOKEN;
339
+ return result;
340
+ }
341
+
342
+ /**
343
+ * Filters an env object down to provider-credential keys (`*_API_KEY`,
344
+ * `*_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`). Pi does not read provider
345
+ * credentials from the environment itself — providers are only registered
346
+ * through `registerProvider` / `setRuntimeApiKey` — so we must extract the
347
+ * relevant entries before handing them to `registerCustomProviders`.
348
+ */
349
+ function pickProviderEnv(
350
+ env: NodeJS.ProcessEnv | Record<string, string>,
351
+ ): Record<string, string> {
352
+ const result: Record<string, string> = {};
353
+ for (const [key, value] of Object.entries(env)) {
354
+ if (
355
+ value &&
356
+ (key.endsWith('_API_KEY') ||
357
+ key.endsWith('_BASE_URL') ||
358
+ key === 'ANTHROPIC_AUTH_TOKEN')
359
+ ) {
360
+ result[key] = value;
361
+ }
362
+ }
363
+ return result;
364
+ }
365
+
366
+ function normalizePiAuthToLegacyAuth(
367
+ options: PiAuthOptions | undefined,
368
+ ): LegacyPiAuthOptions | undefined {
369
+ if (options == null || options === 'auto') {
370
+ return undefined;
371
+ }
372
+ if (typeof options === 'string') {
373
+ switch (options) {
374
+ case 'ai-gateway':
375
+ return { gateway: {} };
376
+ case 'custom':
377
+ case 'openai':
378
+ case 'anthropic':
379
+ return { customEnv: {} };
380
+ default:
381
+ return undefined;
382
+ }
383
+ }
384
+
385
+ console.warn(
386
+ '[pi] Passing an object to auth options is deprecated. Use a string mode ("auto" | "openai" | "anthropic" | "custom" | "ai-gateway") instead, and pass credentials via environment variables.',
387
+ );
388
+ return options;
389
+ }
390
+
152
391
  function resolveCustomEnv({
153
392
  customEnv,
154
393
  }: {
@@ -1,6 +1,6 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
- import { shellQuote } from '@ai-sdk/harness/utils';
4
4
  import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
5
5
  import { z } from 'zod/v4';
6
6
 
@@ -24,8 +24,8 @@ const piSessionFileNameSchema = z
24
24
  * Schema for the adapter-specific portion of lifecycle state `data` produced
25
25
  * by Pi's resumable lifecycle methods. Carries the basename
26
26
  * (including extension) of the Pi session file. The actual session bytes live
27
- * in the sandbox under `${sessionWorkDir}/.pi-sessions/<sessionFileName>` so
28
- * they survive cross-process resume via the sandbox snapshot.
27
+ * in a private, session-scoped directory under sandbox HOME so they survive
28
+ * cross-process resume without appearing in the agent workspace.
29
29
  */
30
30
  export const piResumeStateSchema = z.looseObject({
31
31
  sessionFileName: piSessionFileNameSchema.optional(),
@@ -33,7 +33,32 @@ export const piResumeStateSchema = z.looseObject({
33
33
 
34
34
  export type PiResumeStateData = z.infer<typeof piResumeStateSchema>;
35
35
 
36
- const PI_SESSIONS_DIR = '.pi-sessions';
36
+ export function resolvePiPrivateSessionDirectory(input: {
37
+ readonly sandboxHomeDir: string;
38
+ readonly sessionWorkDir: string;
39
+ readonly sessionId: string;
40
+ }): string {
41
+ const sessionKey = createHash('sha256').update(input.sessionId).digest('hex');
42
+ const privateSessionDir = path.posix.join(
43
+ input.sandboxHomeDir,
44
+ '.ai-sdk',
45
+ 'harness-pi',
46
+ sessionKey,
47
+ );
48
+ const relativePath = path.posix.relative(
49
+ input.sessionWorkDir,
50
+ privateSessionDir,
51
+ );
52
+ if (
53
+ relativePath === '' ||
54
+ (!relativePath.startsWith('../') && !path.posix.isAbsolute(relativePath))
55
+ ) {
56
+ throw new Error(
57
+ `Pi private session directory ${JSON.stringify(privateSessionDir)} must be outside sessionWorkDir ${JSON.stringify(input.sessionWorkDir)}.`,
58
+ );
59
+ }
60
+ return privateSessionDir;
61
+ }
37
62
 
38
63
  function resolveContainedHostPath(input: {
39
64
  readonly baseDir: string;
@@ -56,10 +81,10 @@ function resolveContainedHostPath(input: {
56
81
  }
57
82
 
58
83
  function resolveContainedSandboxPath(input: {
59
- readonly sessionWorkDir: string;
84
+ readonly privateSessionDir: string;
60
85
  readonly sessionFileName: string;
61
86
  }): string {
62
- const sessionDir = path.posix.resolve(input.sessionWorkDir, PI_SESSIONS_DIR);
87
+ const sessionDir = path.posix.resolve(input.privateSessionDir);
63
88
  const filePath = path.posix.resolve(
64
89
  sessionDir,
65
90
  safePiSessionFileName(input.sessionFileName),
@@ -76,13 +101,13 @@ function resolveContainedSandboxPath(input: {
76
101
  }
77
102
 
78
103
  /**
79
- * Copy the Pi session file from the host's local mirror to a stable location
80
- * inside the sandbox workspace. Called during resumable lifecycle methods so
81
- * the session survives a sandbox snapshot or a process handoff.
104
+ * Copy the Pi session file from the host's local mirror to private sandbox
105
+ * state. Called during resumable lifecycle methods so the session survives a
106
+ * sandbox snapshot or a process handoff.
82
107
  */
83
108
  export async function persistSessionFileToSandbox(args: {
84
109
  readonly sandbox: Experimental_SandboxSession;
85
- readonly sessionWorkDir: string;
110
+ readonly privateSessionDir: string;
86
111
  readonly hostSessionDir: string;
87
112
  readonly sessionFileName: string;
88
113
  readonly abortSignal?: AbortSignal;
@@ -93,14 +118,9 @@ export async function persistSessionFileToSandbox(args: {
93
118
  });
94
119
  const content = await readFile(hostPath);
95
120
  const remotePath = resolveContainedSandboxPath({
96
- sessionWorkDir: args.sessionWorkDir,
121
+ privateSessionDir: args.privateSessionDir,
97
122
  sessionFileName: args.sessionFileName,
98
123
  });
99
- // Ensure the parent dir exists in the sandbox before writing.
100
- await args.sandbox.run({
101
- command: `mkdir -p ${shellQuote(path.posix.dirname(remotePath))}`,
102
- ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
103
- });
104
124
  await args.sandbox.writeBinaryFile({
105
125
  path: remotePath,
106
126
  content,
@@ -116,20 +136,20 @@ export async function persistSessionFileToSandbox(args: {
116
136
  */
117
137
  export async function pullSessionFileFromSandbox(args: {
118
138
  readonly sandbox: Experimental_SandboxSession;
119
- readonly sessionWorkDir: string;
139
+ readonly privateSessionDir: string;
120
140
  readonly hostSessionDir: string;
121
141
  readonly sessionFileName: string;
122
142
  readonly abortSignal?: AbortSignal;
123
143
  }): Promise<string | undefined> {
124
144
  const remotePath = resolveContainedSandboxPath({
125
- sessionWorkDir: args.sessionWorkDir,
145
+ privateSessionDir: args.privateSessionDir,
126
146
  sessionFileName: args.sessionFileName,
127
147
  });
128
148
  const bytes = await args.sandbox.readBinaryFile({
129
149
  path: remotePath,
130
150
  ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
131
151
  });
132
- if (!bytes) return undefined;
152
+ if (bytes == null) return undefined;
133
153
  await mkdir(args.hostSessionDir, { recursive: true });
134
154
  const hostPath = resolveContainedHostPath({
135
155
  baseDir: args.hostSessionDir,
package/src/pi-session.ts CHANGED
@@ -44,6 +44,7 @@ import { writePiSkills } from './pi-skills';
44
44
  import {
45
45
  persistSessionFileToSandbox,
46
46
  pullSessionFileFromSandbox,
47
+ resolvePiPrivateSessionDirectory,
47
48
  safePiSessionFileName,
48
49
  } from './pi-resume-state';
49
50
  import {
@@ -289,16 +290,21 @@ export async function createPiSession(
289
290
  await mkdir(hostSessionDir, { recursive: true });
290
291
 
291
292
  const sandbox = input.sandboxSession.restricted();
293
+ const sandboxHomeDir = await resolveSandboxHomeDir({
294
+ sandbox,
295
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
296
+ });
297
+ const privateSessionDir = resolvePiPrivateSessionDirectory({
298
+ sandboxHomeDir,
299
+ sessionWorkDir: input.sessionWorkDir,
300
+ sessionId: input.sessionId,
301
+ });
292
302
  const permissionMode = input.permissionMode ?? 'allow-all';
293
303
  let sandboxSkillRootDir: string | undefined;
294
304
  let harnessSkills: Skill[] = [];
295
305
 
296
306
  // Materialise harness-provided skills into sandbox HOME, not the workspace.
297
307
  if (input.skills.length > 0) {
298
- const sandboxHomeDir = await resolveSandboxHomeDir({
299
- sandbox,
300
- ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
301
- });
302
308
  sandboxSkillRootDir = path.posix.join(sandboxHomeDir, '.agents', 'skills');
303
309
  harnessSkills = createHarnessPiSkills({
304
310
  skills: input.skills,
@@ -321,7 +327,7 @@ export async function createPiSession(
321
327
  );
322
328
  resumeSessionFilePath = await pullSessionFileFromSandbox({
323
329
  sandbox,
324
- sessionWorkDir: input.sessionWorkDir,
330
+ privateSessionDir,
325
331
  hostSessionDir,
326
332
  sessionFileName: resumeSessionFileName,
327
333
  ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
@@ -553,7 +559,7 @@ export async function createPiSession(
553
559
  if (!sessionFileName) return;
554
560
  await persistSessionFileToSandbox({
555
561
  sandbox,
556
- sessionWorkDir: input.sessionWorkDir,
562
+ privateSessionDir,
557
563
  hostSessionDir,
558
564
  sessionFileName,
559
565
  });