@ai-sdk/harness-acp 1.0.25 → 1.0.29

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-acp",
3
- "version": "1.0.25",
3
+ "version": "1.0.29",
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.87",
30
- "@ai-sdk/provider-utils": "5.0.30",
29
+ "@ai-sdk/harness": "1.0.91",
30
+ "@ai-sdk/provider-utils": "5.0.32",
31
31
  "ws": "^8.21.0"
32
32
  },
33
33
  "peerDependencies": {
@@ -50,6 +50,7 @@ export type ACPHarnessSettings<TBuiltinTools extends ToolSet = {}> = {
50
50
  readonly authentication?: ACPV1Settings['authentication'];
51
51
  readonly providerAuthentication?: ACPV1Settings['providerAuthentication'];
52
52
  readonly modelId?: ACPV1Settings['modelId'];
53
+ readonly skillsDirectory?: ACPV1Settings['skillsDirectory'];
53
54
  readonly instructionMapping?: ACPV1Settings['instructionMapping'];
54
55
  readonly outputSchemaMapping?: ACPV1Settings['outputSchemaMapping'];
55
56
  readonly permissionModeMapping?: ACPV1Settings['permissionModeMapping'];
@@ -85,6 +86,7 @@ const acpResumeStateSchema = z.object({
85
86
  .optional(),
86
87
  })
87
88
  .optional(),
89
+ sandboxCredentialEnvironment: z.record(z.string(), z.string()).optional(),
88
90
  acpSessionId: z.string().optional(),
89
91
  bridge: acpBridgeCoordsSchema.optional(),
90
92
  coldSession: acpColdSessionStateSchema.optional(),
@@ -103,6 +105,7 @@ const acpResumeStateSchema = z.object({
103
105
  initialGuidanceApplied: z.boolean().optional(),
104
106
  skillsMaterialized: z.boolean().optional(),
105
107
  skillsFingerprint: z.string().optional(),
108
+ skillsDirectory: z.string().optional(),
106
109
  });
107
110
 
108
111
  export function createACP<TBuiltinTools extends ToolSet = {}>(
@@ -17,12 +17,12 @@ import {
17
17
  applyCredentialForwarding,
18
18
  createBridgeErrorHandler,
19
19
  createBridgeStartupError,
20
+ createSandboxCredentialEnvironment,
20
21
  classifyDiskLog,
21
22
  drainBridgeProcessStream,
22
23
  forwardBridgeProcessStream,
23
24
  getRestrictedSandboxSession,
24
25
  markBridgeStarting,
25
- maskSandboxCredentials,
26
26
  resolveSandboxDefaultWorkingDirectory,
27
27
  resolveSandboxHomeDir,
28
28
  SandboxChannel,
@@ -76,7 +76,6 @@ import {
76
76
  import {
77
77
  convertHarnessPromptToACPTextBlocks,
78
78
  prependACPInitialGuidance,
79
- type ACPSkillCatalogEntry,
80
79
  } from './acp-v1-prompt';
81
80
  import type {
82
81
  ACPInstructionMapping,
@@ -91,6 +90,7 @@ import {
91
90
  createACPSkillsFingerprint,
92
91
  materializeACPSkills,
93
92
  resolveACPPrivateSessionDirectory,
93
+ resolveACPSkillsDirectory,
94
94
  } from './acp-v1-skills';
95
95
 
96
96
  const HARNESS_ID_REGEXP = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -237,6 +237,25 @@ export function createACPV1<TBuiltinTools extends ToolSet = {}>({
237
237
  sandboxSession,
238
238
  abortSignal: startOptions.abortSignal,
239
239
  });
240
+ const continueFrom =
241
+ startOptions.continueFrom ?? startOptions.resumeFrom?.continueFrom;
242
+ const lifecycleState = continueFrom ?? startOptions.resumeFrom;
243
+ const isContinue = continueFrom != null;
244
+ const isResume = lifecycleState != null;
245
+ const lifecycleData =
246
+ lifecycleState != null
247
+ ? (lifecycleState.data as unknown as ACPLifecycleData)
248
+ : undefined;
249
+ if (lifecycleData != null && lifecycleState != null) {
250
+ validateACPLifecycleCompatibility({
251
+ harnessId: settings.harnessId,
252
+ lifecycleHarnessId: lifecycleState.harnessId,
253
+ implementationIdentity,
254
+ authenticationProfile,
255
+ lifecycleData,
256
+ sandboxId,
257
+ });
258
+ }
240
259
  const implementationEnvironment = resolveImplementationEnvironment({
241
260
  implementation,
242
261
  env,
@@ -261,6 +280,9 @@ export function createACPV1<TBuiltinTools extends ToolSet = {}>({
261
280
  }),
262
281
  ]),
263
282
  ];
283
+ let sandboxCredentialEnvironment:
284
+ | Readonly<Record<string, string>>
285
+ | undefined;
264
286
 
265
287
  if (
266
288
  settings.credentialBrokering != null &&
@@ -271,31 +293,36 @@ export function createACPV1<TBuiltinTools extends ToolSet = {}>({
271
293
  resolvedProviderAuthentication,
272
294
  clientApp,
273
295
  });
296
+ const brokeringEnvironment = {
297
+ ...implementationEnvironment,
298
+ ...providerEnvironment,
299
+ };
300
+ sandboxCredentialEnvironment =
301
+ lifecycleData?.sandboxCredentialEnvironment ??
302
+ (await createSandboxCredentialEnvironment({
303
+ environment: brokeringEnvironment,
304
+ credentialEnvironmentVariables:
305
+ credentialForwardingEnvironmentVariables,
306
+ credentialForwarding: settings.credentialForwarding,
307
+ }));
308
+ sandboxImplementationEnvironment = {
309
+ ...brokeringEnvironment,
310
+ ...sandboxCredentialEnvironment,
311
+ };
274
312
  const requestTransformations = settings.credentialBrokering({
275
- env: {
276
- ...implementationEnvironment,
277
- ...providerEnvironment,
278
- },
313
+ env: brokeringEnvironment,
314
+ sandboxEnv: sandboxImplementationEnvironment,
279
315
  });
280
316
  if (requestTransformations.length > 0) {
281
317
  await sandboxSession.addRequestTransformations(
282
318
  requestTransformations,
283
319
  );
284
320
  }
285
- sandboxImplementationEnvironment = maskSandboxCredentials({
286
- environment: implementationEnvironment,
287
- credentialEnvironmentVariables,
288
- });
289
-
290
- /*
291
- * Each profile environment variable is resolved with its own name as
292
- * the fake Gateway credential. This preserves profile transformations
293
- * without exposing the real credential to the sandbox process.
294
- */
295
- sandboxProviderEnvironment = resolveBrokeredProviderEnvironment({
296
- resolvedProviderAuthentication,
297
- clientApp,
298
- });
321
+ sandboxProviderEnvironment =
322
+ resolvedProviderAuthentication.providerAuthentication?.type ===
323
+ 'ai-gateway'
324
+ ? {}
325
+ : undefined;
299
326
  } else if (settings.credentialBrokering != null) {
300
327
  warnCredentialBrokeringUnavailable();
301
328
  }
@@ -339,47 +366,32 @@ export function createACPV1<TBuiltinTools extends ToolSet = {}>({
339
366
  harnessId: settings.harnessId,
340
367
  sessionId: startOptions.sessionId,
341
368
  });
369
+ const skillsDirectory = resolveACPSkillsDirectory({
370
+ implementationHomeDir:
371
+ implementation.source.type === 'install-command'
372
+ ? `${resolvedImplementationDir}/home`
373
+ : sandboxHomeDir,
374
+ skillsDirectory: settings.skillsDirectory,
375
+ sessionWorkDir: workDir,
376
+ });
342
377
  const bridgeStateDir = `${privateSessionDir}/bridge`;
343
- const continueFrom =
344
- startOptions.continueFrom ?? startOptions.resumeFrom?.continueFrom;
345
- const lifecycleState = continueFrom ?? startOptions.resumeFrom;
346
- const isContinue = continueFrom != null;
347
- const isResume = lifecycleState != null;
348
- const lifecycleData =
349
- lifecycleState != null
350
- ? (lifecycleState.data as unknown as ACPLifecycleData)
351
- : undefined;
352
- if (lifecycleData != null && lifecycleState != null) {
353
- validateACPLifecycleCompatibility({
354
- harnessId: settings.harnessId,
355
- lifecycleHarnessId: lifecycleState.harnessId,
356
- implementationIdentity,
357
- authenticationProfile,
358
- lifecycleData,
359
- sandboxId,
360
- });
361
- }
362
378
  const skills = startOptions.skills ?? [];
363
379
  const skillsFingerprint = createACPSkillsFingerprint({ skills });
364
380
  const shouldMaterializeSkills = shouldMaterializeACPSkills({
365
381
  isResume,
366
382
  lifecycleState: lifecycleData,
367
383
  skillsFingerprint,
384
+ skillsDirectory,
368
385
  });
369
- let skillCatalog: ReadonlyArray<ACPSkillCatalogEntry> = [];
370
386
  if (skills.length > 0) {
371
- skillCatalog = (
372
- await materializeACPSkills({
373
- sandbox: toolSafeSandboxSession,
374
- sandboxHomeDir,
375
- sessionWorkDir: workDir,
376
- harnessId: settings.harnessId,
377
- sessionId: startOptions.sessionId,
378
- skills,
379
- shouldMaterialize: shouldMaterializeSkills,
380
- abortSignal: startOptions.abortSignal,
381
- })
382
- ).catalog;
387
+ await materializeACPSkills({
388
+ sandbox: toolSafeSandboxSession,
389
+ rootDir: skillsDirectory,
390
+ sessionWorkDir: workDir,
391
+ skills,
392
+ shouldMaterialize: shouldMaterializeSkills,
393
+ abortSignal: startOptions.abortSignal,
394
+ });
383
395
  }
384
396
  const report = startOptions.observability?.report;
385
397
  const onDiagnostic = report
@@ -451,8 +463,8 @@ export function createACPV1<TBuiltinTools extends ToolSet = {}>({
451
463
  isResume: true,
452
464
  lifecycleState: lifecycleData,
453
465
  }),
454
- skillCatalog,
455
466
  skillsFingerprint,
467
+ skillsDirectory,
456
468
  acpSessionId: lifecycleData.acpSessionId,
457
469
  bridgePort: coords.port,
458
470
  bridgeToken: coords.token,
@@ -463,6 +475,7 @@ export function createACPV1<TBuiltinTools extends ToolSet = {}>({
463
475
  turnStartConfig: lifecycleData.turnStartConfig,
464
476
  recoveryStatus: lifecycleData.recovery,
465
477
  restoration: lifecycleData.restoration,
478
+ sandboxCredentialEnvironment,
466
479
  replayOnly: false,
467
480
  lossyRerun: false,
468
481
  });
@@ -544,12 +557,14 @@ export function createACPV1<TBuiltinTools extends ToolSet = {}>({
544
557
  }
545
558
 
546
559
  const forwardedImplementationEnvironment =
547
- await applyCredentialForwarding({
548
- environment: sandboxImplementationEnvironment,
549
- credentialEnvironmentVariables:
550
- credentialForwardingEnvironmentVariables,
551
- credentialForwarding: settings.credentialForwarding,
552
- });
560
+ sandboxCredentialEnvironment == null
561
+ ? await applyCredentialForwarding({
562
+ environment: sandboxImplementationEnvironment,
563
+ credentialEnvironmentVariables:
564
+ credentialForwardingEnvironmentVariables,
565
+ credentialForwarding: settings.credentialForwarding,
566
+ })
567
+ : sandboxImplementationEnvironment;
553
568
  const port = resolveBridgePort({
554
569
  sandboxSession,
555
570
  override: portOverride,
@@ -714,8 +729,8 @@ export function createACPV1<TBuiltinTools extends ToolSet = {}>({
714
729
  isResume,
715
730
  lifecycleState: lifecycleData,
716
731
  }),
717
- skillCatalog,
718
732
  skillsFingerprint,
733
+ skillsDirectory,
719
734
  acpSessionId: lifecycleData?.acpSessionId,
720
735
  bridgePort: boundPort,
721
736
  bridgeToken: token,
@@ -742,6 +757,7 @@ export function createACPV1<TBuiltinTools extends ToolSet = {}>({
742
757
  coldRestoration == null
743
758
  ? lifecycleData?.restoration
744
759
  : { method: coldRestoration },
760
+ sandboxCredentialEnvironment,
745
761
  replayOnly: respawnStrategy?.mode === 'disk-replay',
746
762
  lossyRerun: respawnStrategy?.mode === 'lossy-rerun',
747
763
  });
@@ -781,45 +797,6 @@ function resolveProviderEnvironment({
781
797
  });
782
798
  }
783
799
 
784
- function resolveBrokeredProviderEnvironment({
785
- resolvedProviderAuthentication,
786
- clientApp,
787
- }: {
788
- resolvedProviderAuthentication: ReturnType<
789
- typeof resolveACPProviderAuthentication
790
- >;
791
- clientApp: ACPClientApp;
792
- }): Record<string, string> {
793
- const providerAuthentication =
794
- resolvedProviderAuthentication.providerAuthentication;
795
- if (providerAuthentication?.type !== 'ai-gateway') return {};
796
-
797
- const baseUrl = requireResolvedEnvironmentValue({
798
- environment: resolvedProviderAuthentication.env,
799
- name: 'AI_SDK_ACP_GATEWAY_BASE_URL',
800
- });
801
-
802
- return Object.fromEntries(
803
- Object.entries(providerAuthentication.env).map(
804
- ([environmentVariableName, value]) => {
805
- const environment = resolveACPLaunchEnvironment({
806
- providerAuthentication: {
807
- type: 'ai-gateway',
808
- env: { [environmentVariableName]: value },
809
- },
810
- gateway: {
811
- apiKey: environmentVariableName,
812
- baseUrl,
813
- clientAppName: clientApp.name,
814
- clientAppVersion: clientApp.version,
815
- },
816
- });
817
- return [environmentVariableName, environment[environmentVariableName]!];
818
- },
819
- ),
820
- );
821
- }
822
-
823
800
  function resolveACPProviderCredentialEnvironmentVariables({
824
801
  providerAuthentication,
825
802
  }: {
@@ -1071,8 +1048,8 @@ function createSession({
1071
1048
  mcpServers,
1072
1049
  isMcpToolCall,
1073
1050
  initialGuidanceApplied: initialGuidanceAppliedAtStart,
1074
- skillCatalog,
1075
1051
  skillsFingerprint,
1052
+ skillsDirectory,
1076
1053
  acpSessionId: acpSessionIdAtStart,
1077
1054
  bridgePort,
1078
1055
  bridgeToken,
@@ -1083,6 +1060,7 @@ function createSession({
1083
1060
  turnStartConfig: turnStartConfigAtStart,
1084
1061
  recoveryStatus,
1085
1062
  restoration,
1063
+ sandboxCredentialEnvironment,
1086
1064
  replayOnly,
1087
1065
  lossyRerun,
1088
1066
  }: {
@@ -1103,8 +1081,8 @@ function createSession({
1103
1081
  mcpServers: Record<string, unknown> | undefined;
1104
1082
  isMcpToolCall: ((toolCall: ACPToolCall) => boolean) | undefined;
1105
1083
  initialGuidanceApplied: boolean;
1106
- skillCatalog: ReadonlyArray<ACPSkillCatalogEntry>;
1107
1084
  skillsFingerprint: string;
1085
+ skillsDirectory: string;
1108
1086
  acpSessionId: string | undefined;
1109
1087
  bridgePort: number;
1110
1088
  bridgeToken: string;
@@ -1115,6 +1093,7 @@ function createSession({
1115
1093
  turnStartConfig: ACPTurnStartConfig | undefined;
1116
1094
  recoveryStatus: ACPLifecycleData['recovery'];
1117
1095
  restoration: ACPLifecycleData['restoration'];
1096
+ sandboxCredentialEnvironment: Readonly<Record<string, string>> | undefined;
1118
1097
  replayOnly: boolean;
1119
1098
  lossyRerun: boolean;
1120
1099
  }): HarnessV1Session {
@@ -1370,6 +1349,9 @@ function createSession({
1370
1349
  }): ACPLifecycleData => ({
1371
1350
  implementationIdentity,
1372
1351
  authenticationProfile,
1352
+ ...(sandboxCredentialEnvironment == null
1353
+ ? {}
1354
+ : { sandboxCredentialEnvironment }),
1373
1355
  ...(latestACPSessionId == null ? {} : { acpSessionId: latestACPSessionId }),
1374
1356
  ...(bridge == null ? {} : { bridge }),
1375
1357
  ...(latestTurnStartConfig == null
@@ -1388,6 +1370,7 @@ function createSession({
1388
1370
  initialGuidanceApplied,
1389
1371
  skillsMaterialized: true,
1390
1372
  skillsFingerprint,
1373
+ skillsDirectory,
1391
1374
  });
1392
1375
 
1393
1376
  return {
@@ -1458,7 +1441,6 @@ function createSession({
1458
1441
  instructionMapping == null
1459
1442
  ? options.instructions
1460
1443
  : undefined,
1461
- skills: skillCatalog,
1462
1444
  }),
1463
1445
  ...(instructionMapping == null
1464
1446
  ? {}
@@ -8,6 +8,7 @@ export type ACPPromptGuidanceLifecycleState = {
8
8
  readonly initialGuidanceApplied?: boolean;
9
9
  readonly skillsMaterialized?: boolean;
10
10
  readonly skillsFingerprint?: string;
11
+ readonly skillsDirectory?: string;
11
12
  };
12
13
 
13
14
  export type ACPBridgeCoordinates = {
@@ -21,6 +22,7 @@ export type ACPBridgeCoordinates = {
21
22
  export type ACPLifecycleData = ACPPromptGuidanceLifecycleState & {
22
23
  readonly implementationIdentity: string;
23
24
  readonly authenticationProfile?: ACPAuthenticationProfileIdentity;
25
+ readonly sandboxCredentialEnvironment?: Readonly<Record<string, string>>;
24
26
  readonly acpSessionId?: string;
25
27
  readonly bridge?: ACPBridgeCoordinates;
26
28
  readonly coldSession?: ACPColdSessionState;
@@ -49,10 +51,12 @@ export function shouldMaterializeACPSkills({
49
51
  isResume,
50
52
  lifecycleState,
51
53
  skillsFingerprint,
54
+ skillsDirectory,
52
55
  }: {
53
56
  isResume: boolean;
54
57
  lifecycleState: ACPPromptGuidanceLifecycleState | undefined;
55
58
  skillsFingerprint: string;
59
+ skillsDirectory: string;
56
60
  }): boolean {
57
61
  if (
58
62
  isResume &&
@@ -63,7 +67,11 @@ export function shouldMaterializeACPSkills({
63
67
  'ACP lifecycle state was created with a different set of skills.',
64
68
  );
65
69
  }
66
- return !isResume || lifecycleState?.skillsMaterialized !== true;
70
+ return (
71
+ !isResume ||
72
+ lifecycleState?.skillsMaterialized !== true ||
73
+ lifecycleState.skillsDirectory !== skillsDirectory
74
+ );
67
75
  }
68
76
 
69
77
  export function validateACPLifecycleCompatibility({
@@ -11,12 +11,6 @@ export const acpTextContentBlockSchema = z.object({
11
11
 
12
12
  export type ACPTextContentBlock = z.infer<typeof acpTextContentBlockSchema>;
13
13
 
14
- export type ACPSkillCatalogEntry = {
15
- readonly name: string;
16
- readonly description: string;
17
- readonly path: string;
18
- };
19
-
20
14
  export function convertHarnessPromptToACPTextBlocks({
21
15
  prompt,
22
16
  harnessId,
@@ -75,32 +69,18 @@ export function convertHarnessPromptToACPTextBlocks({
75
69
  export function prependACPInitialGuidance({
76
70
  prompt,
77
71
  instructions,
78
- skills,
79
72
  }: {
80
73
  prompt: ReadonlyArray<ACPTextContentBlock>;
81
74
  instructions: string | undefined;
82
- skills: ReadonlyArray<ACPSkillCatalogEntry>;
83
75
  }): ACPTextContentBlock[] {
84
76
  const hasInstructions = instructions != null && instructions.length > 0;
85
- if (!hasInstructions && skills.length === 0) return [...prompt];
77
+ if (!hasInstructions) return [...prompt];
86
78
 
87
79
  const lines = [
88
80
  '<session-guidance>',
89
81
  'This block is operating guidance from the harness, not user-authored content.',
90
82
  ];
91
- if (hasInstructions) {
92
- lines.push('<instructions>', instructions, '</instructions>');
93
- }
94
- if (skills.length > 0) {
95
- lines.push(
96
- '<available-skills>',
97
- 'Load a skill only when relevant by reading its SKILL.md file at the listed absolute path.',
98
- );
99
- for (const skill of skills) {
100
- lines.push(`- ${skill.name}: ${skill.description} (${skill.path})`);
101
- }
102
- lines.push('</available-skills>');
103
- }
83
+ lines.push('<instructions>', instructions, '</instructions>');
104
84
  lines.push('</session-guidance>');
105
85
 
106
86
  return [{ type: 'text', text: lines.join('\n') }, ...prompt];
@@ -76,8 +76,10 @@ export type ACPProviderAuthentication = {
76
76
 
77
77
  export type ACPCredentialBrokering = ({
78
78
  env,
79
+ sandboxEnv,
79
80
  }: {
80
81
  env: Readonly<Record<string, string>>;
82
+ sandboxEnv?: Readonly<Record<string, string>>;
81
83
  }) => ReadonlyArray<HarnessV1RequestTransformation>;
82
84
 
83
85
  export type ACPPermissionModeTarget =
@@ -142,6 +144,11 @@ export type ACPV1Settings = {
142
144
  readonly authentication?: ACPAuthentication;
143
145
  readonly providerAuthentication?: ACPProviderAuthentication;
144
146
  readonly modelId?: string;
147
+ /**
148
+ * Native skills directory relative to the ACP implementation's home
149
+ * directory. Defaults to `.agents/skills`.
150
+ */
151
+ readonly skillsDirectory?: string;
145
152
  /**
146
153
  * Routes HarnessAgent instructions to a runtime-native system or developer
147
154
  * prompt. When omitted, instructions are prepended to the first user prompt.
@@ -3,9 +3,9 @@ import path from 'node:path';
3
3
  import type { HarnessV1Skill } from '@ai-sdk/harness';
4
4
  import { writeSkills } from '@ai-sdk/harness/utils';
5
5
  import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
6
- import type { ACPSkillCatalogEntry } from './acp-v1-prompt';
7
6
 
8
7
  const ACP_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
+ export const DEFAULT_ACP_SKILLS_DIRECTORY = '.agents/skills';
9
9
 
10
10
  export function createACPSkillsFingerprint({
11
11
  skills,
@@ -59,37 +59,21 @@ export function resolveACPPrivateSessionDirectory({
59
59
 
60
60
  export async function materializeACPSkills({
61
61
  sandbox,
62
- sandboxHomeDir,
62
+ rootDir,
63
63
  sessionWorkDir,
64
- harnessId,
65
- sessionId,
66
64
  skills,
67
65
  shouldMaterialize,
68
66
  abortSignal,
69
67
  }: {
70
68
  sandbox: Experimental_SandboxSession;
71
- sandboxHomeDir: string;
69
+ rootDir: string;
72
70
  sessionWorkDir: string;
73
- harnessId: string;
74
- sessionId: string;
75
71
  skills: ReadonlyArray<HarnessV1Skill>;
76
72
  shouldMaterialize: boolean;
77
73
  abortSignal?: AbortSignal;
78
- }): Promise<{
79
- readonly catalog: ReadonlyArray<ACPSkillCatalogEntry>;
80
- readonly rootDir: string;
81
- }> {
74
+ }): Promise<void> {
82
75
  validateACPSkills({ skills });
83
-
84
- const rootDir = path.posix.join(
85
- resolveACPPrivateSessionDirectory({
86
- sandboxHomeDir,
87
- sessionWorkDir,
88
- harnessId,
89
- sessionId,
90
- }),
91
- 'skills',
92
- );
76
+ assertOutsideSessionWorkDir({ rootDir, sessionWorkDir });
93
77
 
94
78
  if (shouldMaterialize) {
95
79
  await writeSkills({
@@ -106,15 +90,40 @@ export async function materializeACPSkills({
106
90
  )}: expected a relative POSIX path without traversal.`,
107
91
  });
108
92
  }
93
+ }
109
94
 
110
- return {
111
- rootDir,
112
- catalog: skills.map(skill => ({
113
- name: skill.name,
114
- description: skill.description,
115
- path: path.posix.join(rootDir, skill.name, 'SKILL.md'),
116
- })),
117
- };
95
+ export function resolveACPSkillsDirectory({
96
+ implementationHomeDir,
97
+ skillsDirectory = DEFAULT_ACP_SKILLS_DIRECTORY,
98
+ sessionWorkDir,
99
+ }: {
100
+ implementationHomeDir: string;
101
+ skillsDirectory?: string;
102
+ sessionWorkDir: string;
103
+ }): string {
104
+ const containsTraversal = skillsDirectory
105
+ .split(/[\\/]/)
106
+ .some(segment => segment === '..');
107
+ const normalizedDirectory = path.posix.normalize(skillsDirectory);
108
+ if (
109
+ skillsDirectory.length === 0 ||
110
+ skillsDirectory.includes('\\') ||
111
+ path.posix.isAbsolute(skillsDirectory) ||
112
+ path.win32.isAbsolute(skillsDirectory) ||
113
+ containsTraversal ||
114
+ normalizedDirectory === '.' ||
115
+ normalizedDirectory.startsWith('../') ||
116
+ normalizedDirectory.includes('/../') ||
117
+ normalizedDirectory.endsWith('/..')
118
+ ) {
119
+ throw new Error(
120
+ `ACP skillsDirectory ${JSON.stringify(skillsDirectory)} must be a relative POSIX path without traversal.`,
121
+ );
122
+ }
123
+
124
+ const rootDir = path.posix.join(implementationHomeDir, normalizedDirectory);
125
+ assertOutsideSessionWorkDir({ rootDir, sessionWorkDir });
126
+ return rootDir;
118
127
  }
119
128
 
120
129
  function validateACPSkills({