@ai-sdk/harness-acp 0.0.0 → 1.0.1
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/CHANGELOG.md +24 -0
- package/LICENSE +13 -0
- package/README.md +69 -3
- package/dist/bridge/host-tool-mcp.mjs +302 -0
- package/dist/bridge/host-tool-mcp.mjs.map +1 -0
- package/dist/bridge/index.mjs +3317 -0
- package/dist/bridge/index.mjs.map +1 -0
- package/dist/bridge/package.json +12 -0
- package/dist/bridge/pnpm-lock.yaml +816 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +2089 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -10
- package/src/acp-auth.ts +254 -0
- package/src/acp-bridge-protocol.ts +13 -0
- package/src/acp-harness.ts +107 -0
- package/src/index.ts +19 -0
- package/src/v1/acp-v1-bridge-protocol.ts +151 -0
- package/src/v1/acp-v1-harness.ts +1461 -0
- package/src/v1/acp-v1-lifecycle.ts +115 -0
- package/src/v1/acp-v1-prompt.ts +122 -0
- package/src/v1/acp-v1-settings.ts +97 -0
- package/src/v1/acp-v1-skills.ts +228 -0
- package/src/v1/acp-v1-turn-start-config.ts +93 -0
- package/src/v1/bridge/acp-diagnostics.ts +83 -0
- package/src/v1/bridge/acp-stream-capture.ts +152 -0
- package/src/v1/bridge/acp-v1-bridge-environment.ts +95 -0
- package/src/v1/bridge/agent-stderr-monitor.ts +52 -0
- package/src/v1/bridge/create-emit-stream-event.ts +97 -0
- package/src/v1/bridge/host-tool-correlation.ts +640 -0
- package/src/v1/bridge/host-tool-mcp-server.ts +180 -0
- package/src/v1/bridge/host-tool-mcp.ts +185 -0
- package/src/v1/bridge/host-tool-relay-client.ts +53 -0
- package/src/v1/bridge/host-tool-relay.ts +510 -0
- package/src/v1/bridge/index.ts +714 -0
- package/src/v1/bridge/package.json +12 -0
- package/src/v1/bridge/permission-controller.ts +152 -0
- package/src/v1/bridge/permission-mode.ts +163 -0
- package/src/v1/bridge/pnpm-lock.yaml +816 -0
- package/src/v1/bridge/profile-values.ts +97 -0
- package/src/v1/bridge/protocol-configuration.ts +146 -0
- package/src/v1/bridge/recovered-session.ts +145 -0
- package/src/v1/bridge/refresh-host-tool-catalog.ts +33 -0
- package/src/v1/bridge/session-lifecycle.ts +83 -0
- package/src/v1/bridge/stream-translator.ts +901 -0
- package/src/v1/implementation.ts +308 -0
- package/src/v1/index.ts +16 -0
- package/src/version.ts +6 -0
- package/index.js +0 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ACPAuthenticationProfileIdentity } from '../acp-auth';
|
|
2
|
+
import type {
|
|
3
|
+
ACPColdSessionState,
|
|
4
|
+
ACPTurnStartConfig,
|
|
5
|
+
} from './acp-v1-bridge-protocol';
|
|
6
|
+
|
|
7
|
+
export type ACPPromptGuidanceLifecycleState = {
|
|
8
|
+
readonly initialGuidanceApplied?: boolean;
|
|
9
|
+
readonly skillsMaterialized?: boolean;
|
|
10
|
+
readonly skillsFingerprint?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ACPBridgeCoordinates = {
|
|
14
|
+
readonly port: number;
|
|
15
|
+
readonly token: string;
|
|
16
|
+
readonly lastSeenEventId: number;
|
|
17
|
+
readonly sandboxId?: string;
|
|
18
|
+
readonly stateDir?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ACPLifecycleData = ACPPromptGuidanceLifecycleState & {
|
|
22
|
+
readonly implementationIdentity: string;
|
|
23
|
+
readonly authenticationProfile?: ACPAuthenticationProfileIdentity;
|
|
24
|
+
readonly acpSessionId?: string;
|
|
25
|
+
readonly bridge?: ACPBridgeCoordinates;
|
|
26
|
+
readonly coldSession?: ACPColdSessionState;
|
|
27
|
+
readonly turnStartConfig?: ACPTurnStartConfig;
|
|
28
|
+
readonly recovery?: {
|
|
29
|
+
readonly mode: 'disk-replay' | 'lossy-rerun';
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
};
|
|
32
|
+
readonly restoration?: {
|
|
33
|
+
readonly method: 'resume' | 'load';
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function resolveACPInitialGuidanceApplied({
|
|
38
|
+
isResume,
|
|
39
|
+
lifecycleState,
|
|
40
|
+
}: {
|
|
41
|
+
isResume: boolean;
|
|
42
|
+
lifecycleState: ACPPromptGuidanceLifecycleState | undefined;
|
|
43
|
+
}): boolean {
|
|
44
|
+
if (!isResume) return false;
|
|
45
|
+
return lifecycleState?.initialGuidanceApplied ?? true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function shouldMaterializeACPSkills({
|
|
49
|
+
isResume,
|
|
50
|
+
lifecycleState,
|
|
51
|
+
skillsFingerprint,
|
|
52
|
+
}: {
|
|
53
|
+
isResume: boolean;
|
|
54
|
+
lifecycleState: ACPPromptGuidanceLifecycleState | undefined;
|
|
55
|
+
skillsFingerprint: string;
|
|
56
|
+
}): boolean {
|
|
57
|
+
if (
|
|
58
|
+
isResume &&
|
|
59
|
+
lifecycleState?.skillsFingerprint != null &&
|
|
60
|
+
lifecycleState.skillsFingerprint !== skillsFingerprint
|
|
61
|
+
) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'ACP lifecycle state was created with a different set of skills.',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return !isResume || lifecycleState?.skillsMaterialized !== true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateACPLifecycleCompatibility({
|
|
70
|
+
harnessId,
|
|
71
|
+
lifecycleHarnessId,
|
|
72
|
+
implementationIdentity,
|
|
73
|
+
authenticationProfile,
|
|
74
|
+
lifecycleData,
|
|
75
|
+
sandboxId,
|
|
76
|
+
}: {
|
|
77
|
+
harnessId: string;
|
|
78
|
+
lifecycleHarnessId: string;
|
|
79
|
+
implementationIdentity: string;
|
|
80
|
+
authenticationProfile: ACPAuthenticationProfileIdentity;
|
|
81
|
+
lifecycleData: ACPLifecycleData;
|
|
82
|
+
sandboxId: string | undefined;
|
|
83
|
+
}): void {
|
|
84
|
+
if (lifecycleHarnessId !== harnessId) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`ACP lifecycle state was produced by harness ${JSON.stringify(
|
|
87
|
+
lifecycleHarnessId,
|
|
88
|
+
)}, but this harness is ${JSON.stringify(harnessId)}.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (lifecycleData.implementationIdentity !== implementationIdentity) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'ACP lifecycle state is incompatible with the configured implementation.',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
lifecycleData.authenticationProfile?.digest !== authenticationProfile.digest
|
|
98
|
+
) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'ACP lifecycle state is incompatible with the configured authentication profile.',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const expectedSandboxId = lifecycleData.bridge?.sandboxId;
|
|
104
|
+
if (
|
|
105
|
+
expectedSandboxId != null &&
|
|
106
|
+
sandboxId != null &&
|
|
107
|
+
expectedSandboxId !== sandboxId
|
|
108
|
+
) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`ACP lifecycle state belongs to sandbox ${JSON.stringify(
|
|
111
|
+
expectedSandboxId,
|
|
112
|
+
)}, not ${JSON.stringify(sandboxId)}.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HarnessCapabilityUnsupportedError,
|
|
3
|
+
type HarnessV1Prompt,
|
|
4
|
+
} from '@ai-sdk/harness';
|
|
5
|
+
import { z } from 'zod/v4';
|
|
6
|
+
|
|
7
|
+
export const acpTextContentBlockSchema = z.object({
|
|
8
|
+
type: z.literal('text'),
|
|
9
|
+
text: z.string(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export type ACPTextContentBlock = z.infer<typeof acpTextContentBlockSchema>;
|
|
13
|
+
|
|
14
|
+
export type ACPSkillCatalogEntry = {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly description: string;
|
|
17
|
+
readonly path: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function convertHarnessPromptToACPTextBlocks({
|
|
21
|
+
prompt,
|
|
22
|
+
harnessId,
|
|
23
|
+
}: {
|
|
24
|
+
prompt: HarnessV1Prompt;
|
|
25
|
+
harnessId: string;
|
|
26
|
+
}): ACPTextContentBlock[] {
|
|
27
|
+
if (typeof prompt === 'string') {
|
|
28
|
+
return [{ type: 'text', text: prompt }];
|
|
29
|
+
}
|
|
30
|
+
if (typeof prompt.content === 'string') {
|
|
31
|
+
return [{ type: 'text', text: prompt.content }];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const content: ACPTextContentBlock[] = [];
|
|
35
|
+
for (const part of prompt.content) {
|
|
36
|
+
if (part.type === 'text') {
|
|
37
|
+
content.push({ type: 'text', text: part.text });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (part.type === 'image') {
|
|
41
|
+
throw unsupportedPromptContent({
|
|
42
|
+
harnessId,
|
|
43
|
+
category: 'image content',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (part.type === 'file') {
|
|
47
|
+
const mediaCategory = part.mediaType.toLowerCase().split('/', 1)[0];
|
|
48
|
+
if (mediaCategory === 'image') {
|
|
49
|
+
throw unsupportedPromptContent({
|
|
50
|
+
harnessId,
|
|
51
|
+
category: 'image content',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (mediaCategory === 'audio') {
|
|
55
|
+
throw unsupportedPromptContent({
|
|
56
|
+
harnessId,
|
|
57
|
+
category: 'audio content',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
throw unsupportedPromptContent({
|
|
61
|
+
harnessId,
|
|
62
|
+
category: `embedded resource content with media type ${JSON.stringify(part.mediaType)}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
throw unsupportedPromptContent({
|
|
66
|
+
harnessId,
|
|
67
|
+
category: `content parts of type ${JSON.stringify(
|
|
68
|
+
(part as { readonly type?: unknown }).type,
|
|
69
|
+
)}`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return content;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function prependACPInitialGuidance({
|
|
76
|
+
prompt,
|
|
77
|
+
instructions,
|
|
78
|
+
skills,
|
|
79
|
+
}: {
|
|
80
|
+
prompt: ReadonlyArray<ACPTextContentBlock>;
|
|
81
|
+
instructions: string | undefined;
|
|
82
|
+
skills: ReadonlyArray<ACPSkillCatalogEntry>;
|
|
83
|
+
}): ACPTextContentBlock[] {
|
|
84
|
+
const hasInstructions = instructions != null && instructions.length > 0;
|
|
85
|
+
if (!hasInstructions && skills.length === 0) return [...prompt];
|
|
86
|
+
|
|
87
|
+
const lines = [
|
|
88
|
+
'<session-guidance>',
|
|
89
|
+
'This block is operating guidance from the harness, not user-authored content.',
|
|
90
|
+
];
|
|
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
|
+
}
|
|
104
|
+
lines.push('</session-guidance>');
|
|
105
|
+
|
|
106
|
+
return [{ type: 'text', text: lines.join('\n') }, ...prompt];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unsupportedPromptContent({
|
|
110
|
+
harnessId,
|
|
111
|
+
category,
|
|
112
|
+
}: {
|
|
113
|
+
harnessId: string;
|
|
114
|
+
category: string;
|
|
115
|
+
}): HarnessCapabilityUnsupportedError {
|
|
116
|
+
return new HarnessCapabilityUnsupportedError({
|
|
117
|
+
harnessId,
|
|
118
|
+
message:
|
|
119
|
+
`The ${harnessId} ACP harness supports only portable text prompts and does not support ${category}. ` +
|
|
120
|
+
'Pass a string or a user message whose content contains only text parts.',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { HarnessV1PermissionMode } from '@ai-sdk/harness';
|
|
2
|
+
|
|
3
|
+
export type ACPSerializablePrimitive = string | number | boolean | null;
|
|
4
|
+
|
|
5
|
+
export type ACPSerializableValue =
|
|
6
|
+
| ACPSerializablePrimitive
|
|
7
|
+
| ReadonlyArray<ACPSerializableValue>
|
|
8
|
+
| { readonly [key: string]: ACPSerializableValue };
|
|
9
|
+
|
|
10
|
+
export type ACPValueSource = {
|
|
11
|
+
readonly $source:
|
|
12
|
+
| 'gateway-api-key'
|
|
13
|
+
| 'gateway-base-url'
|
|
14
|
+
| 'gateway-authorization'
|
|
15
|
+
| 'client-app'
|
|
16
|
+
| 'client-app-name'
|
|
17
|
+
| 'client-app-version';
|
|
18
|
+
readonly prefix?: string;
|
|
19
|
+
readonly suffix?: string;
|
|
20
|
+
readonly ensureSuffix?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ACPProfileValue =
|
|
24
|
+
| ACPSerializablePrimitive
|
|
25
|
+
| ACPValueSource
|
|
26
|
+
| ReadonlyArray<ACPProfileValue>
|
|
27
|
+
| { readonly [key: string]: ACPProfileValue };
|
|
28
|
+
|
|
29
|
+
export type ACPNpmSimpleSource = {
|
|
30
|
+
readonly type: 'npm-simple';
|
|
31
|
+
readonly packageName: string;
|
|
32
|
+
/**
|
|
33
|
+
* Exact version to install. When omitted, the package's `latest` dist-tag is
|
|
34
|
+
* installed and no version takes part in the implementation identity, so a
|
|
35
|
+
* new upstream release does not invalidate existing lifecycle state.
|
|
36
|
+
*/
|
|
37
|
+
readonly packageVersion?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ACPNpmLockedSource = {
|
|
41
|
+
readonly type: 'npm-locked';
|
|
42
|
+
readonly packageJson: string;
|
|
43
|
+
readonly pnpmLockYaml: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ACPSource = ACPNpmSimpleSource | ACPNpmLockedSource;
|
|
47
|
+
|
|
48
|
+
export type ACPAuthentication = {
|
|
49
|
+
readonly methodId: string;
|
|
50
|
+
readonly meta?: Readonly<Record<string, ACPSerializableValue>>;
|
|
51
|
+
readonly clientCapabilities?: Readonly<Record<string, ACPSerializableValue>>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ACPProviderAuthenticationMode = 'auto' | 'direct' | 'ai-gateway';
|
|
55
|
+
|
|
56
|
+
export type ACPProviderAuthentication = {
|
|
57
|
+
readonly gateway: {
|
|
58
|
+
readonly env: Readonly<Record<string, ACPProfileValue>>;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type ACPPermissionModeTarget =
|
|
63
|
+
| {
|
|
64
|
+
readonly type: 'session-mode';
|
|
65
|
+
readonly modeId: string;
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
readonly type: 'session-config-option';
|
|
69
|
+
readonly configId: string;
|
|
70
|
+
readonly value: string | boolean;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type ACPPermissionModeMapping = Readonly<
|
|
74
|
+
Record<HarnessV1PermissionMode, ACPPermissionModeTarget | null>
|
|
75
|
+
>;
|
|
76
|
+
|
|
77
|
+
export type ACPV1Settings = {
|
|
78
|
+
readonly version?: 'v1';
|
|
79
|
+
readonly harnessId: string;
|
|
80
|
+
readonly auth?: ACPProviderAuthenticationMode;
|
|
81
|
+
readonly source: ACPSource;
|
|
82
|
+
readonly executable: string;
|
|
83
|
+
readonly args?: ReadonlyArray<string>;
|
|
84
|
+
readonly forwardEnv?: ReadonlyArray<string>;
|
|
85
|
+
/**
|
|
86
|
+
* Runtime environment values that are safe to persist in bootstrap and
|
|
87
|
+
* lifecycle compatibility identity.
|
|
88
|
+
*/
|
|
89
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
90
|
+
readonly authentication?: ACPAuthentication;
|
|
91
|
+
readonly providerAuthentication?: ACPProviderAuthentication;
|
|
92
|
+
readonly modelId?: string;
|
|
93
|
+
readonly permissionModeMapping?: ACPPermissionModeMapping;
|
|
94
|
+
readonly session?: {
|
|
95
|
+
readonly meta?: Readonly<Record<string, ACPSerializableValue>>;
|
|
96
|
+
};
|
|
97
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { HarnessV1Skill } from '@ai-sdk/harness';
|
|
4
|
+
import { writeSkills } from '@ai-sdk/harness/utils';
|
|
5
|
+
import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
|
|
6
|
+
import type { ACPSkillCatalogEntry } from './acp-v1-prompt';
|
|
7
|
+
|
|
8
|
+
const ACP_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
|
+
|
|
10
|
+
export function createACPSkillsFingerprint({
|
|
11
|
+
skills,
|
|
12
|
+
}: {
|
|
13
|
+
skills: ReadonlyArray<HarnessV1Skill>;
|
|
14
|
+
}): string {
|
|
15
|
+
const canonicalSkills = skills
|
|
16
|
+
.map(skill => ({
|
|
17
|
+
name: skill.name,
|
|
18
|
+
description: skill.description,
|
|
19
|
+
content: skill.content,
|
|
20
|
+
files: [...(skill.files ?? [])]
|
|
21
|
+
.map(file => ({
|
|
22
|
+
path: path.posix.normalize(file.path),
|
|
23
|
+
content: file.content,
|
|
24
|
+
}))
|
|
25
|
+
.sort((left, right) =>
|
|
26
|
+
compareCanonicalStrings({ left: left.path, right: right.path }),
|
|
27
|
+
),
|
|
28
|
+
}))
|
|
29
|
+
.sort((left, right) =>
|
|
30
|
+
compareCanonicalStrings({ left: left.name, right: right.name }),
|
|
31
|
+
);
|
|
32
|
+
return createHash('sha256')
|
|
33
|
+
.update(JSON.stringify(canonicalSkills))
|
|
34
|
+
.digest('hex');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveACPPrivateSessionDirectory({
|
|
38
|
+
sandboxHomeDir,
|
|
39
|
+
sessionWorkDir,
|
|
40
|
+
harnessId,
|
|
41
|
+
sessionId,
|
|
42
|
+
}: {
|
|
43
|
+
sandboxHomeDir: string;
|
|
44
|
+
sessionWorkDir: string;
|
|
45
|
+
harnessId: string;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
}): string {
|
|
48
|
+
const sessionKey = createHash('sha256').update(sessionId).digest('hex');
|
|
49
|
+
const rootDir = path.posix.join(
|
|
50
|
+
sandboxHomeDir,
|
|
51
|
+
'.ai-sdk',
|
|
52
|
+
'harness-acp',
|
|
53
|
+
harnessId,
|
|
54
|
+
sessionKey,
|
|
55
|
+
);
|
|
56
|
+
assertOutsideSessionWorkDir({ rootDir, sessionWorkDir });
|
|
57
|
+
return rootDir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function materializeACPSkills({
|
|
61
|
+
sandbox,
|
|
62
|
+
sandboxHomeDir,
|
|
63
|
+
sessionWorkDir,
|
|
64
|
+
harnessId,
|
|
65
|
+
sessionId,
|
|
66
|
+
skills,
|
|
67
|
+
shouldMaterialize,
|
|
68
|
+
abortSignal,
|
|
69
|
+
}: {
|
|
70
|
+
sandbox: Experimental_SandboxSession;
|
|
71
|
+
sandboxHomeDir: string;
|
|
72
|
+
sessionWorkDir: string;
|
|
73
|
+
harnessId: string;
|
|
74
|
+
sessionId: string;
|
|
75
|
+
skills: ReadonlyArray<HarnessV1Skill>;
|
|
76
|
+
shouldMaterialize: boolean;
|
|
77
|
+
abortSignal?: AbortSignal;
|
|
78
|
+
}): Promise<{
|
|
79
|
+
readonly catalog: ReadonlyArray<ACPSkillCatalogEntry>;
|
|
80
|
+
readonly rootDir: string;
|
|
81
|
+
}> {
|
|
82
|
+
validateACPSkills({ skills });
|
|
83
|
+
|
|
84
|
+
const rootDir = path.posix.join(
|
|
85
|
+
resolveACPPrivateSessionDirectory({
|
|
86
|
+
sandboxHomeDir,
|
|
87
|
+
sessionWorkDir,
|
|
88
|
+
harnessId,
|
|
89
|
+
sessionId,
|
|
90
|
+
}),
|
|
91
|
+
'skills',
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (shouldMaterialize) {
|
|
95
|
+
await writeSkills({
|
|
96
|
+
sandbox,
|
|
97
|
+
rootDir,
|
|
98
|
+
skills,
|
|
99
|
+
abortSignal,
|
|
100
|
+
skillNamePattern: ACP_SKILL_NAME_PATTERN,
|
|
101
|
+
invalidSkillNameMessage: ({ name }) =>
|
|
102
|
+
`Invalid ACP skill name ${JSON.stringify(name)}: expected a kebab-case slug.`,
|
|
103
|
+
invalidSkillFilePathMessage: ({ skillName, filePath }) =>
|
|
104
|
+
`Invalid ACP skill file path ${JSON.stringify(filePath)} for skill ${JSON.stringify(
|
|
105
|
+
skillName,
|
|
106
|
+
)}: expected a relative POSIX path without traversal.`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
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
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function validateACPSkills({
|
|
121
|
+
skills,
|
|
122
|
+
}: {
|
|
123
|
+
skills: ReadonlyArray<HarnessV1Skill>;
|
|
124
|
+
}): void {
|
|
125
|
+
const skillNames = new Set<string>();
|
|
126
|
+
for (const skill of skills) {
|
|
127
|
+
if (
|
|
128
|
+
!ACP_SKILL_NAME_PATTERN.test(skill.name) ||
|
|
129
|
+
skill.name === '.' ||
|
|
130
|
+
skill.name === '..'
|
|
131
|
+
) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Invalid ACP skill name ${JSON.stringify(skill.name)}: expected a kebab-case slug.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (skillNames.has(skill.name)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Duplicate ACP skill name ${JSON.stringify(skill.name)}.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
skillNames.add(skill.name);
|
|
142
|
+
|
|
143
|
+
const filePaths = new Set<string>();
|
|
144
|
+
for (const file of skill.files ?? []) {
|
|
145
|
+
const normalizedPath = validateACPAttachedFilePath({
|
|
146
|
+
skillName: skill.name,
|
|
147
|
+
filePath: file.path,
|
|
148
|
+
});
|
|
149
|
+
if (normalizedPath === 'SKILL.md') {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Invalid ACP skill file path ${JSON.stringify(file.path)} for skill ${JSON.stringify(
|
|
152
|
+
skill.name,
|
|
153
|
+
)}: SKILL.md is reserved for the skill definition.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (filePaths.has(normalizedPath)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Duplicate ACP skill file path ${JSON.stringify(file.path)} for skill ${JSON.stringify(
|
|
159
|
+
skill.name,
|
|
160
|
+
)}.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
filePaths.add(normalizedPath);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function validateACPAttachedFilePath({
|
|
169
|
+
skillName,
|
|
170
|
+
filePath,
|
|
171
|
+
}: {
|
|
172
|
+
skillName: string;
|
|
173
|
+
filePath: string;
|
|
174
|
+
}): string {
|
|
175
|
+
const containsTraversal = filePath
|
|
176
|
+
.split(/[\\/]/)
|
|
177
|
+
.some(segment => segment === '..');
|
|
178
|
+
const normalizedPath = path.posix.normalize(filePath);
|
|
179
|
+
const invalid =
|
|
180
|
+
filePath.length === 0 ||
|
|
181
|
+
filePath.endsWith('/') ||
|
|
182
|
+
filePath.includes('\\') ||
|
|
183
|
+
path.posix.isAbsolute(filePath) ||
|
|
184
|
+
path.win32.isAbsolute(filePath) ||
|
|
185
|
+
containsTraversal ||
|
|
186
|
+
normalizedPath === '.' ||
|
|
187
|
+
normalizedPath.startsWith('../') ||
|
|
188
|
+
normalizedPath.includes('/../') ||
|
|
189
|
+
normalizedPath.endsWith('/..');
|
|
190
|
+
if (invalid) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Invalid ACP skill file path ${JSON.stringify(filePath)} for skill ${JSON.stringify(
|
|
193
|
+
skillName,
|
|
194
|
+
)}: expected a relative POSIX path without traversal.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return normalizedPath;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertOutsideSessionWorkDir({
|
|
201
|
+
rootDir,
|
|
202
|
+
sessionWorkDir,
|
|
203
|
+
}: {
|
|
204
|
+
rootDir: string;
|
|
205
|
+
sessionWorkDir: string;
|
|
206
|
+
}): void {
|
|
207
|
+
const relative = path.posix.relative(sessionWorkDir, rootDir);
|
|
208
|
+
const isInside =
|
|
209
|
+
relative === '' ||
|
|
210
|
+
(!relative.startsWith('../') && !path.posix.isAbsolute(relative));
|
|
211
|
+
if (isInside) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`ACP skill directory ${JSON.stringify(rootDir)} must be outside sessionWorkDir ${JSON.stringify(
|
|
214
|
+
sessionWorkDir,
|
|
215
|
+
)}.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function compareCanonicalStrings({
|
|
221
|
+
left,
|
|
222
|
+
right,
|
|
223
|
+
}: {
|
|
224
|
+
left: string;
|
|
225
|
+
right: string;
|
|
226
|
+
}): number {
|
|
227
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
228
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { HarnessV1DebugConfig, HarnessV1ToolSpec } from '@ai-sdk/harness';
|
|
3
|
+
import type { ACPAuthenticationProfileIdentity } from '../acp-auth';
|
|
4
|
+
import {
|
|
5
|
+
acpSerializableToolSpecSchema,
|
|
6
|
+
type ACPBuiltinToolMapping,
|
|
7
|
+
type ACPColdSessionState,
|
|
8
|
+
type ACPTurnStartConfig,
|
|
9
|
+
type StartMessage,
|
|
10
|
+
} from './acp-v1-bridge-protocol';
|
|
11
|
+
import type { ACPTextContentBlock } from './acp-v1-prompt';
|
|
12
|
+
import type {
|
|
13
|
+
ACPPermissionModeMapping,
|
|
14
|
+
ACPSerializableValue,
|
|
15
|
+
} from './acp-v1-settings';
|
|
16
|
+
|
|
17
|
+
export function createACPTurnStartConfig({
|
|
18
|
+
prompt,
|
|
19
|
+
tools,
|
|
20
|
+
builtinTools,
|
|
21
|
+
permissionMode,
|
|
22
|
+
permissionModeMapping,
|
|
23
|
+
debug,
|
|
24
|
+
authenticationProfile,
|
|
25
|
+
sessionMeta,
|
|
26
|
+
}: {
|
|
27
|
+
prompt: ReadonlyArray<ACPTextContentBlock>;
|
|
28
|
+
tools: ReadonlyArray<HarnessV1ToolSpec>;
|
|
29
|
+
builtinTools: ReadonlyArray<ACPBuiltinToolMapping>;
|
|
30
|
+
permissionMode: NonNullable<StartMessage['permissionMode']>;
|
|
31
|
+
permissionModeMapping: ACPPermissionModeMapping | undefined;
|
|
32
|
+
debug: HarnessV1DebugConfig | undefined;
|
|
33
|
+
authenticationProfile: ACPAuthenticationProfileIdentity;
|
|
34
|
+
sessionMeta: Readonly<Record<string, ACPSerializableValue>> | undefined;
|
|
35
|
+
}): ACPTurnStartConfig {
|
|
36
|
+
return {
|
|
37
|
+
version: 1,
|
|
38
|
+
configurationFingerprint: createHash('sha256')
|
|
39
|
+
.update(
|
|
40
|
+
stableStringify({
|
|
41
|
+
authenticationProfile,
|
|
42
|
+
sessionMeta: sessionMeta ?? null,
|
|
43
|
+
builtinTools,
|
|
44
|
+
permissionModeMapping: permissionModeMapping ?? null,
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
.digest('hex'),
|
|
48
|
+
prompt: [...prompt],
|
|
49
|
+
tools: tools.map(tool => acpSerializableToolSpecSchema.parse(tool)),
|
|
50
|
+
builtinTools: [...builtinTools],
|
|
51
|
+
permissionMode,
|
|
52
|
+
...(permissionModeMapping == null ? {} : { permissionModeMapping }),
|
|
53
|
+
...(debug == null ? {} : { debug }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createACPColdSessionState({
|
|
58
|
+
turnStartConfig,
|
|
59
|
+
modelId,
|
|
60
|
+
}: {
|
|
61
|
+
turnStartConfig: ACPTurnStartConfig;
|
|
62
|
+
modelId: string | undefined;
|
|
63
|
+
}): ACPColdSessionState {
|
|
64
|
+
return {
|
|
65
|
+
version: turnStartConfig.version,
|
|
66
|
+
configurationFingerprint: turnStartConfig.configurationFingerprint,
|
|
67
|
+
tools: turnStartConfig.tools,
|
|
68
|
+
builtinTools: turnStartConfig.builtinTools,
|
|
69
|
+
permissionMode: turnStartConfig.permissionMode,
|
|
70
|
+
...(turnStartConfig.permissionModeMapping == null
|
|
71
|
+
? {}
|
|
72
|
+
: { permissionModeMapping: turnStartConfig.permissionModeMapping }),
|
|
73
|
+
...(modelId == null ? {} : { modelId }),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stableStringify(value: unknown): string {
|
|
78
|
+
return JSON.stringify(sortValue(value));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sortValue(value: unknown): unknown {
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
return value.map(item => sortValue(item));
|
|
84
|
+
}
|
|
85
|
+
if (value != null && typeof value === 'object') {
|
|
86
|
+
return Object.fromEntries(
|
|
87
|
+
Object.entries(value)
|
|
88
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
89
|
+
.map(([key, entry]) => [key, sortValue(entry)]),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|