@kodelyth/acpx 2026.5.39 → 2026.5.42
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/AGENTS.md +54 -0
- package/CLAUDE.md +54 -0
- package/dist/index.js +14 -0
- package/dist/process-reaper-DdVqzAA_.js +370 -0
- package/dist/register.runtime.js +53 -0
- package/dist/runtime-D9qhNKmy.js +741 -0
- package/dist/runtime-api.js +4 -0
- package/dist/service-CXeUME_-.js +1483 -0
- package/dist/setup-api.js +16 -0
- package/index.test.ts +119 -0
- package/index.ts +19 -0
- package/klaw.plugin.json +12 -27
- package/package.json +2 -2
- package/register.runtime.test.ts +104 -0
- package/register.runtime.ts +86 -0
- package/runtime-api.ts +49 -0
- package/setup-api.ts +18 -0
- package/src/acpx-runtime-compat.d.ts +65 -0
- package/src/claude-agent-acp-completion.test.ts +187 -0
- package/src/codex-auth-bridge.test.ts +688 -0
- package/src/codex-auth-bridge.ts +780 -0
- package/src/codex-trust-config.ts +297 -0
- package/src/config-schema.ts +118 -0
- package/src/config.test.ts +285 -0
- package/src/config.ts +281 -0
- package/src/manifest.test.ts +21 -0
- package/src/process-lease.test.ts +89 -0
- package/src/process-lease.ts +179 -0
- package/src/process-reaper.test.ts +330 -0
- package/src/process-reaper.ts +434 -0
- package/src/runtime-internals/error-format.mjs +6 -0
- package/src/runtime-internals/mcp-command-line.mjs +123 -0
- package/src/runtime-internals/mcp-command-line.test.ts +59 -0
- package/src/runtime-internals/mcp-proxy.mjs +121 -0
- package/src/runtime-internals/mcp-proxy.test.ts +130 -0
- package/src/runtime.test.ts +1817 -0
- package/src/runtime.ts +1261 -0
- package/src/service.test.ts +802 -0
- package/src/service.ts +630 -0
- package/tsconfig.json +16 -0
- package/index.js +0 -7
- package/register.runtime.js +0 -7
- package/runtime-api.js +0 -7
- package/setup-api.js +0 -7
- /package/{error-format.mjs → dist/error-format.mjs} +0 -0
- /package/{mcp-command-line.mjs → dist/mcp-command-line.mjs} +0 -0
- /package/{mcp-proxy.mjs → dist/mcp-proxy.mjs} +0 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
function stripTomlComment(line: string): string {
|
|
4
|
+
let quote: "'" | '"' | null = null;
|
|
5
|
+
let escaping = false;
|
|
6
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
7
|
+
const ch = line[index];
|
|
8
|
+
if (escaping) {
|
|
9
|
+
escaping = false;
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (quote === '"' && ch === "\\") {
|
|
13
|
+
escaping = true;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (quote) {
|
|
17
|
+
if (ch === quote) {
|
|
18
|
+
quote = null;
|
|
19
|
+
}
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (ch === "'" || ch === '"') {
|
|
23
|
+
quote = ch;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (ch === "#") {
|
|
27
|
+
return line.slice(0, index);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return line;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseTomlString(value: string): string | undefined {
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(trimmed) as string;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
43
|
+
return trimmed.slice(1, -1);
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseTomlDottedKey(value: string): string[] {
|
|
49
|
+
const parts: string[] = [];
|
|
50
|
+
let current = "";
|
|
51
|
+
let quote: "'" | '"' | null = null;
|
|
52
|
+
let escaping = false;
|
|
53
|
+
|
|
54
|
+
for (const ch of value.trim()) {
|
|
55
|
+
if (escaping) {
|
|
56
|
+
current += ch;
|
|
57
|
+
escaping = false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (quote === '"' && ch === "\\") {
|
|
61
|
+
current += ch;
|
|
62
|
+
escaping = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (quote) {
|
|
66
|
+
current += ch;
|
|
67
|
+
if (ch === quote) {
|
|
68
|
+
quote = null;
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (ch === "'" || ch === '"') {
|
|
73
|
+
quote = ch;
|
|
74
|
+
current += ch;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ch === ".") {
|
|
78
|
+
parts.push(current.trim());
|
|
79
|
+
current = "";
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
current += ch;
|
|
83
|
+
}
|
|
84
|
+
if (current.trim()) {
|
|
85
|
+
parts.push(current.trim());
|
|
86
|
+
}
|
|
87
|
+
return parts.map((part) => parseTomlString(part) ?? part);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseProjectHeader(line: string): string | undefined {
|
|
91
|
+
const trimmed = line.trim();
|
|
92
|
+
if (!trimmed.startsWith("[") || !trimmed.endsWith("]") || trimmed.startsWith("[[")) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const parts = parseTomlDottedKey(trimmed.slice(1, -1));
|
|
96
|
+
return parts.length === 2 && parts[0] === "projects" ? parts[1] : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseTrustedInlineProjectEntries(value: string): string[] {
|
|
100
|
+
const trusted: string[] = [];
|
|
101
|
+
const entryPattern =
|
|
102
|
+
/(?<key>"(?:\\.|[^"\\])*"|'[^']*'|[A-Za-z0-9_\-/.~:]+)\s*=\s*\{(?<body>[^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g;
|
|
103
|
+
for (const match of value.matchAll(entryPattern)) {
|
|
104
|
+
const key = match.groups?.key;
|
|
105
|
+
const body = match.groups?.body;
|
|
106
|
+
if (!key || !body || !/\btrust_level\s*=\s*["']trusted["']/.test(body)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const projectPath = parseTomlString(key) ?? key.trim();
|
|
110
|
+
if (projectPath) {
|
|
111
|
+
trusted.push(projectPath);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return trusted;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function extractTrustedCodexProjectPaths(configToml: string): string[] {
|
|
118
|
+
const trusted = new Set<string>();
|
|
119
|
+
let currentProjectPath: string | undefined;
|
|
120
|
+
let inProjectsTable = false;
|
|
121
|
+
|
|
122
|
+
for (const rawLine of configToml.split(/\r?\n/)) {
|
|
123
|
+
const line = stripTomlComment(rawLine).trim();
|
|
124
|
+
if (!line) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (line.startsWith("[")) {
|
|
128
|
+
currentProjectPath = parseProjectHeader(line);
|
|
129
|
+
inProjectsTable = line === "[projects]";
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (currentProjectPath && /^trust_level\s*=\s*["']trusted["']\s*$/.test(line)) {
|
|
134
|
+
trusted.add(currentProjectPath);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const assignment =
|
|
139
|
+
/^(?<key>"(?:\\.|[^"\\])*"|'[^']*'|[A-Za-z0-9_\-/.~:]+)\s*=\s*(?<value>.+)$/.exec(line);
|
|
140
|
+
if (!assignment?.groups) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const key = parseTomlString(assignment.groups.key) ?? assignment.groups.key;
|
|
145
|
+
const value = assignment.groups.value.trim();
|
|
146
|
+
if (inProjectsTable && /^\{.*\}$/.test(value)) {
|
|
147
|
+
if (/\btrust_level\s*=\s*["']trusted["']/.test(value) && key) {
|
|
148
|
+
trusted.add(key);
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (key === "projects" || inProjectsTable) {
|
|
153
|
+
for (const projectPath of parseTrustedInlineProjectEntries(value)) {
|
|
154
|
+
trusted.add(projectPath);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return Array.from(trusted);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const INHERITED_TOP_LEVEL_CODEX_CONFIG_KEYS = new Set([
|
|
163
|
+
"model",
|
|
164
|
+
"model_provider",
|
|
165
|
+
"model_reasoning_effort",
|
|
166
|
+
"sandbox_mode",
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
const INHERITED_MODEL_PROVIDER_CONFIG_KEYS = new Set([
|
|
170
|
+
"name",
|
|
171
|
+
"base_url",
|
|
172
|
+
"wire_api",
|
|
173
|
+
"env_key",
|
|
174
|
+
"env_key_instructions",
|
|
175
|
+
"requires_openai_auth",
|
|
176
|
+
"request_max_retries",
|
|
177
|
+
"stream_max_retries",
|
|
178
|
+
"stream_idle_timeout_ms",
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
function parseTableHeader(line: string): string[] | undefined {
|
|
182
|
+
const trimmed = line.trim();
|
|
183
|
+
if (!trimmed.startsWith("[") || !trimmed.endsWith("]") || trimmed.startsWith("[[")) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
return parseTomlDottedKey(trimmed.slice(1, -1));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isInheritedModelProviderTable(parts: string[] | undefined): boolean {
|
|
190
|
+
return parts?.[0] === "model_providers" && parts.length === 2;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseTopLevelAssignmentKey(line: string): string | undefined {
|
|
194
|
+
const assignment = /^(?<key>[A-Za-z0-9_-]+)\s*=\s*(?<value>.+)$/.exec(line);
|
|
195
|
+
return assignment?.groups?.key;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function extractInheritedCodexRuntimeConfig(configToml: string): string {
|
|
199
|
+
const inheritedLines: string[] = [];
|
|
200
|
+
let inAnyTable = false;
|
|
201
|
+
let inInheritedTable = false;
|
|
202
|
+
let pendingInheritedTableHeader = "";
|
|
203
|
+
|
|
204
|
+
function flushInheritedTableHeader(): void {
|
|
205
|
+
if (!pendingInheritedTableHeader) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (inheritedLines.length > 0 && inheritedLines[inheritedLines.length - 1] !== "") {
|
|
209
|
+
inheritedLines.push("");
|
|
210
|
+
}
|
|
211
|
+
inheritedLines.push(pendingInheritedTableHeader);
|
|
212
|
+
pendingInheritedTableHeader = "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const rawLine of configToml.split(/\r?\n/)) {
|
|
216
|
+
const trimmedLine = rawLine.trim();
|
|
217
|
+
const semanticLine = stripTomlComment(rawLine).trim();
|
|
218
|
+
|
|
219
|
+
if (trimmedLine.startsWith("[")) {
|
|
220
|
+
const tableParts = parseTableHeader(trimmedLine);
|
|
221
|
+
inAnyTable = true;
|
|
222
|
+
inInheritedTable = isInheritedModelProviderTable(tableParts);
|
|
223
|
+
if (inInheritedTable) {
|
|
224
|
+
pendingInheritedTableHeader = rawLine.trimEnd();
|
|
225
|
+
} else {
|
|
226
|
+
pendingInheritedTableHeader = "";
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (inInheritedTable) {
|
|
232
|
+
if (!semanticLine) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const key = parseTopLevelAssignmentKey(semanticLine);
|
|
236
|
+
if (!key || !INHERITED_MODEL_PROVIDER_CONFIG_KEYS.has(key)) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
flushInheritedTableHeader();
|
|
240
|
+
inheritedLines.push(rawLine.trimEnd());
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (inAnyTable) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const key = parseTopLevelAssignmentKey(semanticLine);
|
|
249
|
+
if (!key) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!INHERITED_TOP_LEVEL_CODEX_CONFIG_KEYS.has(key)) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
inheritedLines.push(rawLine.trimEnd());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
while (inheritedLines.length > 0 && inheritedLines[inheritedLines.length - 1] === "") {
|
|
259
|
+
inheritedLines.pop();
|
|
260
|
+
}
|
|
261
|
+
return inheritedLines.join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function renderIsolatedCodexConfig(params: {
|
|
265
|
+
sourceConfigToml?: string;
|
|
266
|
+
projectPaths: string[];
|
|
267
|
+
}): string {
|
|
268
|
+
const normalized = Array.from(
|
|
269
|
+
new Set(
|
|
270
|
+
params.projectPaths
|
|
271
|
+
.map((projectPath) => projectPath.trim())
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.map((projectPath) => path.resolve(projectPath)),
|
|
274
|
+
),
|
|
275
|
+
).toSorted((left, right) => left.localeCompare(right));
|
|
276
|
+
|
|
277
|
+
const inheritedConfig = params.sourceConfigToml
|
|
278
|
+
? extractInheritedCodexRuntimeConfig(params.sourceConfigToml)
|
|
279
|
+
: "";
|
|
280
|
+
|
|
281
|
+
return [
|
|
282
|
+
"# Generated by Klaw for Codex ACP sessions.",
|
|
283
|
+
inheritedConfig,
|
|
284
|
+
...normalized.flatMap((projectPath) => [
|
|
285
|
+
"",
|
|
286
|
+
`[projects.${JSON.stringify(projectPath)}]`,
|
|
287
|
+
'trust_level = "trusted"',
|
|
288
|
+
]),
|
|
289
|
+
"",
|
|
290
|
+
]
|
|
291
|
+
.filter((line, index, lines) => !(line === "" && lines[index - 1] === ""))
|
|
292
|
+
.join("\n");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function renderIsolatedCodexProjectTrustConfig(projectPaths: string[]): string {
|
|
296
|
+
return renderIsolatedCodexConfig({ projectPaths });
|
|
297
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
|
|
4
|
+
export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
|
|
5
|
+
|
|
6
|
+
const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
|
|
7
|
+
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_ACPX_TIMEOUT_SECONDS = 120;
|
|
10
|
+
|
|
11
|
+
export type McpServerConfig = {
|
|
12
|
+
command: string;
|
|
13
|
+
args?: string[];
|
|
14
|
+
env?: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type AcpxMcpServer = {
|
|
18
|
+
name: string;
|
|
19
|
+
command: string;
|
|
20
|
+
args: string[];
|
|
21
|
+
env: Array<{ name: string; value: string }>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AcpxPluginConfig = {
|
|
25
|
+
cwd?: string;
|
|
26
|
+
stateDir?: string;
|
|
27
|
+
probeAgent?: string;
|
|
28
|
+
permissionMode?: AcpxPermissionMode;
|
|
29
|
+
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
|
|
30
|
+
pluginToolsMcpBridge?: boolean;
|
|
31
|
+
openClawToolsMcpBridge?: boolean;
|
|
32
|
+
strictWindowsCmdWrapper?: boolean;
|
|
33
|
+
timeoutSeconds?: number;
|
|
34
|
+
queueOwnerTtlSeconds?: number;
|
|
35
|
+
mcpServers?: Record<string, McpServerConfig>;
|
|
36
|
+
agents?: Record<string, { command: string; args?: string[] }>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ResolvedAcpxPluginConfig = {
|
|
40
|
+
cwd: string;
|
|
41
|
+
stateDir: string;
|
|
42
|
+
probeAgent?: string;
|
|
43
|
+
permissionMode: AcpxPermissionMode;
|
|
44
|
+
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
|
|
45
|
+
pluginToolsMcpBridge: boolean;
|
|
46
|
+
openClawToolsMcpBridge: boolean;
|
|
47
|
+
strictWindowsCmdWrapper: boolean;
|
|
48
|
+
timeoutSeconds?: number;
|
|
49
|
+
queueOwnerTtlSeconds: number;
|
|
50
|
+
legacyCompatibilityConfig: {
|
|
51
|
+
strictWindowsCmdWrapper?: boolean;
|
|
52
|
+
queueOwnerTtlSeconds?: number;
|
|
53
|
+
};
|
|
54
|
+
mcpServers: Record<string, McpServerConfig>;
|
|
55
|
+
agents: Record<string, string>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const nonEmptyTrimmedString = (message: string) =>
|
|
59
|
+
z.string({ error: message }).trim().min(1, { error: message });
|
|
60
|
+
|
|
61
|
+
const McpServerConfigSchema = z.object({
|
|
62
|
+
command: nonEmptyTrimmedString("command must be a non-empty string").describe(
|
|
63
|
+
"Command to run the MCP server",
|
|
64
|
+
),
|
|
65
|
+
args: z
|
|
66
|
+
.array(z.string({ error: "args must be an array of strings" }), {
|
|
67
|
+
error: "args must be an array of strings",
|
|
68
|
+
})
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Arguments to pass to the command"),
|
|
71
|
+
env: z
|
|
72
|
+
.record(z.string(), z.string({ error: "env values must be strings" }), {
|
|
73
|
+
error: "env must be an object of strings",
|
|
74
|
+
})
|
|
75
|
+
.optional()
|
|
76
|
+
.describe("Environment variables for the MCP server"),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const AcpxPluginConfigSchema = z.strictObject({
|
|
80
|
+
cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(),
|
|
81
|
+
stateDir: nonEmptyTrimmedString("stateDir must be a non-empty string").optional(),
|
|
82
|
+
probeAgent: nonEmptyTrimmedString("probeAgent must be a non-empty string").optional(),
|
|
83
|
+
permissionMode: z
|
|
84
|
+
.enum(ACPX_PERMISSION_MODES, {
|
|
85
|
+
error: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
|
|
86
|
+
})
|
|
87
|
+
.optional(),
|
|
88
|
+
nonInteractivePermissions: z
|
|
89
|
+
.enum(ACPX_NON_INTERACTIVE_POLICIES, {
|
|
90
|
+
error: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
|
|
91
|
+
})
|
|
92
|
+
.optional(),
|
|
93
|
+
pluginToolsMcpBridge: z.boolean({ error: "pluginToolsMcpBridge must be a boolean" }).optional(),
|
|
94
|
+
openClawToolsMcpBridge: z
|
|
95
|
+
.boolean({ error: "openClawToolsMcpBridge must be a boolean" })
|
|
96
|
+
.optional(),
|
|
97
|
+
strictWindowsCmdWrapper: z
|
|
98
|
+
.boolean({ error: "strictWindowsCmdWrapper must be a boolean" })
|
|
99
|
+
.optional(),
|
|
100
|
+
timeoutSeconds: z
|
|
101
|
+
.number({ error: "timeoutSeconds must be a number >= 0.001" })
|
|
102
|
+
.min(0.001, { error: "timeoutSeconds must be a number >= 0.001" })
|
|
103
|
+
.default(DEFAULT_ACPX_TIMEOUT_SECONDS),
|
|
104
|
+
queueOwnerTtlSeconds: z
|
|
105
|
+
.number({ error: "queueOwnerTtlSeconds must be a number >= 0" })
|
|
106
|
+
.min(0, { error: "queueOwnerTtlSeconds must be a number >= 0" })
|
|
107
|
+
.optional(),
|
|
108
|
+
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
|
109
|
+
agents: z
|
|
110
|
+
.record(
|
|
111
|
+
z.string(),
|
|
112
|
+
z.strictObject({
|
|
113
|
+
command: nonEmptyTrimmedString("agents.<id>.command must be a non-empty string"),
|
|
114
|
+
args: z.array(z.string({ error: "args must be an array of strings" })).optional(),
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
.optional(),
|
|
118
|
+
});
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { resolveAcpxPluginConfig, resolveAcpxPluginRoot } from "./config.js";
|
|
6
|
+
|
|
7
|
+
const requireFromTest = createRequire(import.meta.url);
|
|
8
|
+
const TSX_IMPORT = requireFromTest.resolve("tsx");
|
|
9
|
+
|
|
10
|
+
function expectedSourceMcpServerArgs(entrypoint: string): string[] {
|
|
11
|
+
return ["--import", TSX_IMPORT, path.resolve(entrypoint)];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("embedded acpx plugin config", () => {
|
|
15
|
+
it("resolves workspace stateDir and cwd by default", () => {
|
|
16
|
+
const workspaceDir = path.resolve("/tmp/klaw-acpx");
|
|
17
|
+
const resolved = resolveAcpxPluginConfig({
|
|
18
|
+
rawConfig: undefined,
|
|
19
|
+
workspaceDir,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(resolved.cwd).toBe(workspaceDir);
|
|
23
|
+
expect(resolved.stateDir).toBe(path.join(workspaceDir, "state"));
|
|
24
|
+
expect(resolved.permissionMode).toBe("approve-reads");
|
|
25
|
+
expect(resolved.nonInteractivePermissions).toBe("fail");
|
|
26
|
+
expect(resolved.timeoutSeconds).toBe(120);
|
|
27
|
+
expect(resolved.agents).toStrictEqual({});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps explicit timeoutSeconds config", () => {
|
|
31
|
+
const resolved = resolveAcpxPluginConfig({
|
|
32
|
+
rawConfig: {
|
|
33
|
+
timeoutSeconds: 300,
|
|
34
|
+
},
|
|
35
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(resolved.timeoutSeconds).toBe(300);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps explicit probeAgent config", () => {
|
|
42
|
+
const resolved = resolveAcpxPluginConfig({
|
|
43
|
+
rawConfig: {
|
|
44
|
+
probeAgent: "claude",
|
|
45
|
+
},
|
|
46
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(resolved.probeAgent).toBe("claude");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("accepts agent command overrides", () => {
|
|
53
|
+
const resolved = resolveAcpxPluginConfig({
|
|
54
|
+
rawConfig: {
|
|
55
|
+
agents: {
|
|
56
|
+
claude: { command: "claude --acp" },
|
|
57
|
+
codex: { command: "codex custom-acp" },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(resolved.agents).toEqual({
|
|
64
|
+
claude: "claude --acp",
|
|
65
|
+
codex: "codex custom-acp",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("combines agent command with args array", () => {
|
|
70
|
+
const resolved = resolveAcpxPluginConfig({
|
|
71
|
+
rawConfig: {
|
|
72
|
+
agents: {
|
|
73
|
+
claude: {
|
|
74
|
+
command: "node",
|
|
75
|
+
args: ["/path/to/adapter.mjs", "--verbose"],
|
|
76
|
+
},
|
|
77
|
+
codex: {
|
|
78
|
+
command: "codex-acp",
|
|
79
|
+
args: ["--model", "gpt-5"],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(resolved.agents).toEqual({
|
|
87
|
+
claude: "node /path/to/adapter.mjs --verbose",
|
|
88
|
+
codex: "codex-acp --model gpt-5",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("quotes agent args that need to survive command-line parsing as one token", () => {
|
|
93
|
+
const resolved = resolveAcpxPluginConfig({
|
|
94
|
+
rawConfig: {
|
|
95
|
+
agents: {
|
|
96
|
+
custom: {
|
|
97
|
+
command: "node",
|
|
98
|
+
args: ["/tmp/My Adapter.mjs", "--flag=value with spaces", "owner's-choice"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(resolved.agents).toEqual({
|
|
106
|
+
custom: "node '/tmp/My Adapter.mjs' '--flag=value with spaces' 'owner'\"'\"'s-choice'",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("handles agent command without args (backward compat)", () => {
|
|
111
|
+
const resolved = resolveAcpxPluginConfig({
|
|
112
|
+
rawConfig: {
|
|
113
|
+
agents: {
|
|
114
|
+
simple: { command: "simple-acp" },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(resolved.agents).toEqual({
|
|
121
|
+
simple: "simple-acp",
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("leaves probeAgent undefined by default so the runtime picks its built-in probe agent", () => {
|
|
126
|
+
const resolved = resolveAcpxPluginConfig({
|
|
127
|
+
rawConfig: undefined,
|
|
128
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(resolved.probeAgent).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("carries an explicit probeAgent through to the resolved plugin config, trimmed and lowercased", () => {
|
|
135
|
+
const resolved = resolveAcpxPluginConfig({
|
|
136
|
+
rawConfig: {
|
|
137
|
+
probeAgent: " OpenCode ",
|
|
138
|
+
},
|
|
139
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(resolved.probeAgent).toBe("opencode");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("rejects an empty probeAgent string", () => {
|
|
146
|
+
expect(() =>
|
|
147
|
+
resolveAcpxPluginConfig({
|
|
148
|
+
rawConfig: {
|
|
149
|
+
probeAgent: "",
|
|
150
|
+
},
|
|
151
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
152
|
+
}),
|
|
153
|
+
).toThrow(/probeAgent must be a non-empty string/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("injects the built-in plugin-tools MCP server only when explicitly enabled", () => {
|
|
157
|
+
const resolved = resolveAcpxPluginConfig({
|
|
158
|
+
rawConfig: {
|
|
159
|
+
pluginToolsMcpBridge: true,
|
|
160
|
+
},
|
|
161
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const server = resolved.mcpServers["klaw-plugin-tools"];
|
|
165
|
+
expect(server).toEqual({
|
|
166
|
+
command: process.execPath,
|
|
167
|
+
args: expectedSourceMcpServerArgs("src/mcp/plugin-tools-serve.ts"),
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("injects the built-in Klaw tools MCP server only when explicitly enabled", () => {
|
|
172
|
+
const resolved = resolveAcpxPluginConfig({
|
|
173
|
+
rawConfig: {
|
|
174
|
+
openClawToolsMcpBridge: true,
|
|
175
|
+
},
|
|
176
|
+
workspaceDir: "/tmp/klaw-acpx",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const server = resolved.mcpServers["klaw-tools"];
|
|
180
|
+
expect(server).toEqual({
|
|
181
|
+
command: process.execPath,
|
|
182
|
+
args: expectedSourceMcpServerArgs("src/mcp/klaw-tools-serve.ts"),
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("resolves the plugin root from shared dist chunk paths", () => {
|
|
187
|
+
const moduleUrl = new URL("../../../dist/extensions/acpx/service-shared.js", import.meta.url)
|
|
188
|
+
.href;
|
|
189
|
+
|
|
190
|
+
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(path.resolve("extensions/acpx"));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("keeps the runtime json schema in sync with the manifest config schema", () => {
|
|
194
|
+
const pluginRoot = resolveAcpxPluginRoot();
|
|
195
|
+
const manifest = JSON.parse(
|
|
196
|
+
fs.readFileSync(path.join(pluginRoot, "klaw.plugin.json"), "utf8"),
|
|
197
|
+
) as { configSchema?: unknown };
|
|
198
|
+
|
|
199
|
+
expect(manifest.configSchema).toStrictEqual({
|
|
200
|
+
type: "object",
|
|
201
|
+
additionalProperties: false,
|
|
202
|
+
properties: {
|
|
203
|
+
cwd: {
|
|
204
|
+
type: "string",
|
|
205
|
+
minLength: 1,
|
|
206
|
+
},
|
|
207
|
+
stateDir: {
|
|
208
|
+
type: "string",
|
|
209
|
+
minLength: 1,
|
|
210
|
+
},
|
|
211
|
+
permissionMode: {
|
|
212
|
+
type: "string",
|
|
213
|
+
enum: ["approve-all", "approve-reads", "deny-all"],
|
|
214
|
+
},
|
|
215
|
+
nonInteractivePermissions: {
|
|
216
|
+
type: "string",
|
|
217
|
+
enum: ["deny", "fail"],
|
|
218
|
+
},
|
|
219
|
+
pluginToolsMcpBridge: {
|
|
220
|
+
type: "boolean",
|
|
221
|
+
},
|
|
222
|
+
openClawToolsMcpBridge: {
|
|
223
|
+
type: "boolean",
|
|
224
|
+
},
|
|
225
|
+
strictWindowsCmdWrapper: {
|
|
226
|
+
type: "boolean",
|
|
227
|
+
},
|
|
228
|
+
timeoutSeconds: {
|
|
229
|
+
type: "number",
|
|
230
|
+
minimum: 0.001,
|
|
231
|
+
default: 120,
|
|
232
|
+
},
|
|
233
|
+
queueOwnerTtlSeconds: {
|
|
234
|
+
type: "number",
|
|
235
|
+
minimum: 0,
|
|
236
|
+
},
|
|
237
|
+
probeAgent: {
|
|
238
|
+
type: "string",
|
|
239
|
+
minLength: 1,
|
|
240
|
+
},
|
|
241
|
+
mcpServers: {
|
|
242
|
+
type: "object",
|
|
243
|
+
additionalProperties: {
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: {
|
|
246
|
+
command: {
|
|
247
|
+
type: "string",
|
|
248
|
+
minLength: 1,
|
|
249
|
+
description: "Command to run the MCP server",
|
|
250
|
+
},
|
|
251
|
+
args: {
|
|
252
|
+
type: "array",
|
|
253
|
+
items: { type: "string" },
|
|
254
|
+
description: "Arguments to pass to the command",
|
|
255
|
+
},
|
|
256
|
+
env: {
|
|
257
|
+
type: "object",
|
|
258
|
+
additionalProperties: { type: "string" },
|
|
259
|
+
description: "Environment variables for the MCP server",
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
required: ["command"],
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
agents: {
|
|
266
|
+
type: "object",
|
|
267
|
+
additionalProperties: {
|
|
268
|
+
type: "object",
|
|
269
|
+
properties: {
|
|
270
|
+
command: {
|
|
271
|
+
type: "string",
|
|
272
|
+
minLength: 1,
|
|
273
|
+
},
|
|
274
|
+
args: {
|
|
275
|
+
type: "array",
|
|
276
|
+
items: { type: "string" },
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
required: ["command"],
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|