@actagent/acpx 2026.6.2

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.
@@ -0,0 +1,296 @@
1
+ // ACPX tests cover config plugin behavior.
2
+ import fs from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import { resolveAcpxPluginConfig, resolveAcpxPluginRoot } from "./config.js";
7
+
8
+ const requireFromTest = createRequire(import.meta.url);
9
+ const TSX_IMPORT = requireFromTest.resolve("tsx");
10
+
11
+ function expectedMcpServerArgs(params: { sourceEntry: string; distEntry: string }): string[] {
12
+ const distEntry = path.resolve(params.distEntry);
13
+ if (fs.existsSync(distEntry)) {
14
+ return [distEntry];
15
+ }
16
+ return ["--import", TSX_IMPORT, path.resolve(params.sourceEntry)];
17
+ }
18
+
19
+ describe("embedded acpx plugin config", () => {
20
+ it("resolves workspace stateDir and cwd by default", () => {
21
+ const workspaceDir = path.resolve("/tmp/actagent-acpx");
22
+ const resolved = resolveAcpxPluginConfig({
23
+ rawConfig: undefined,
24
+ workspaceDir,
25
+ });
26
+
27
+ expect(resolved.cwd).toBe(workspaceDir);
28
+ expect(resolved.stateDir).toBe(path.join(workspaceDir, "state"));
29
+ expect(resolved.permissionMode).toBe("approve-reads");
30
+ expect(resolved.nonInteractivePermissions).toBe("fail");
31
+ expect(resolved.timeoutSeconds).toBe(120);
32
+ expect(resolved.agents).toStrictEqual({});
33
+ });
34
+
35
+ it("keeps explicit timeoutSeconds config", () => {
36
+ const resolved = resolveAcpxPluginConfig({
37
+ rawConfig: {
38
+ timeoutSeconds: 300,
39
+ },
40
+ workspaceDir: "/tmp/actagent-acpx",
41
+ });
42
+
43
+ expect(resolved.timeoutSeconds).toBe(300);
44
+ });
45
+
46
+ it("keeps explicit probeAgent config", () => {
47
+ const resolved = resolveAcpxPluginConfig({
48
+ rawConfig: {
49
+ probeAgent: "claude",
50
+ },
51
+ workspaceDir: "/tmp/actagent-acpx",
52
+ });
53
+
54
+ expect(resolved.probeAgent).toBe("claude");
55
+ });
56
+
57
+ it("accepts agent command overrides", () => {
58
+ const resolved = resolveAcpxPluginConfig({
59
+ rawConfig: {
60
+ agents: {
61
+ claude: { command: "claude --acp" },
62
+ codex: { command: "codex custom-acp" },
63
+ },
64
+ },
65
+ workspaceDir: "/tmp/actagent-acpx",
66
+ });
67
+
68
+ expect(resolved.agents).toEqual({
69
+ claude: "claude --acp",
70
+ codex: "codex custom-acp",
71
+ });
72
+ });
73
+
74
+ it("combines agent command with args array", () => {
75
+ const resolved = resolveAcpxPluginConfig({
76
+ rawConfig: {
77
+ agents: {
78
+ claude: {
79
+ command: "node",
80
+ args: ["/path/to/adapter.mjs", "--verbose"],
81
+ },
82
+ codex: {
83
+ command: "codex-acp",
84
+ args: ["--model", "gpt-5"],
85
+ },
86
+ },
87
+ },
88
+ workspaceDir: "/tmp/actagent-acpx",
89
+ });
90
+
91
+ expect(resolved.agents).toEqual({
92
+ claude: "node /path/to/adapter.mjs --verbose",
93
+ codex: "codex-acp --model gpt-5",
94
+ });
95
+ });
96
+
97
+ it("quotes agent args that need to survive command-line parsing as one token", () => {
98
+ const resolved = resolveAcpxPluginConfig({
99
+ rawConfig: {
100
+ agents: {
101
+ custom: {
102
+ command: "node",
103
+ args: ["/tmp/My Adapter.mjs", "--flag=value with spaces", "owner's-choice"],
104
+ },
105
+ },
106
+ },
107
+ workspaceDir: "/tmp/actagent-acpx",
108
+ });
109
+
110
+ expect(resolved.agents).toEqual({
111
+ custom: "node '/tmp/My Adapter.mjs' '--flag=value with spaces' 'owner'\"'\"'s-choice'",
112
+ });
113
+ });
114
+
115
+ it("handles agent command without args (backward compat)", () => {
116
+ const resolved = resolveAcpxPluginConfig({
117
+ rawConfig: {
118
+ agents: {
119
+ simple: { command: "simple-acp" },
120
+ },
121
+ },
122
+ workspaceDir: "/tmp/actagent-acpx",
123
+ });
124
+
125
+ expect(resolved.agents).toEqual({
126
+ simple: "simple-acp",
127
+ });
128
+ });
129
+
130
+ it("leaves probeAgent undefined by default so the runtime picks its built-in probe agent", () => {
131
+ const resolved = resolveAcpxPluginConfig({
132
+ rawConfig: undefined,
133
+ workspaceDir: "/tmp/actagent-acpx",
134
+ });
135
+
136
+ expect(resolved.probeAgent).toBeUndefined();
137
+ });
138
+
139
+ it("carries an explicit probeAgent through to the resolved plugin config, trimmed and lowercased", () => {
140
+ const resolved = resolveAcpxPluginConfig({
141
+ rawConfig: {
142
+ probeAgent: " OpenCode ",
143
+ },
144
+ workspaceDir: "/tmp/actagent-acpx",
145
+ });
146
+
147
+ expect(resolved.probeAgent).toBe("opencode");
148
+ });
149
+
150
+ it("rejects an empty probeAgent string", () => {
151
+ expect(() =>
152
+ resolveAcpxPluginConfig({
153
+ rawConfig: {
154
+ probeAgent: "",
155
+ },
156
+ workspaceDir: "/tmp/actagent-acpx",
157
+ }),
158
+ ).toThrow(/probeAgent must be a non-empty string/);
159
+ });
160
+
161
+ it("injects the built-in plugin-tools MCP server only when explicitly enabled", () => {
162
+ const resolved = resolveAcpxPluginConfig({
163
+ rawConfig: {
164
+ pluginToolsMcpBridge: true,
165
+ },
166
+ workspaceDir: "/tmp/actagent-acpx",
167
+ });
168
+
169
+ const server = resolved.mcpServers["actagent-plugin-tools"];
170
+ expect(server).toEqual({
171
+ command: process.execPath,
172
+ args: expectedMcpServerArgs({
173
+ sourceEntry: "src/mcp/plugin-tools-serve.ts",
174
+ distEntry: "dist/mcp/plugin-tools-serve.js",
175
+ }),
176
+ });
177
+ });
178
+
179
+ it("injects the built-in ACTAgent tools MCP server only when explicitly enabled", () => {
180
+ const resolved = resolveAcpxPluginConfig({
181
+ rawConfig: {
182
+ actAgentToolsMcpBridge: true,
183
+ },
184
+ workspaceDir: "/tmp/actagent-acpx",
185
+ });
186
+
187
+ const server = resolved.mcpServers["actagent-tools"];
188
+ expect(server).toEqual({
189
+ command: process.execPath,
190
+ args: expectedMcpServerArgs({
191
+ sourceEntry: "src/mcp/actagent-tools-serve.ts",
192
+ distEntry: "dist/mcp/actagent-tools-serve.js",
193
+ }),
194
+ });
195
+ });
196
+
197
+ it("resolves the plugin root from shared dist chunk paths", () => {
198
+ const moduleUrl = new URL("../../../dist/extensions/acpx/service-shared.js", import.meta.url)
199
+ .href;
200
+
201
+ expect(resolveAcpxPluginRoot(moduleUrl)).toBe(path.resolve("extensions/acpx"));
202
+ });
203
+
204
+ it("keeps the runtime json schema in sync with the manifest config schema", () => {
205
+ const pluginRoot = resolveAcpxPluginRoot();
206
+ const manifest = JSON.parse(
207
+ fs.readFileSync(path.join(pluginRoot, "actagent.plugin.json"), "utf8"),
208
+ ) as { configSchema?: unknown };
209
+
210
+ expect(manifest.configSchema).toStrictEqual({
211
+ type: "object",
212
+ additionalProperties: false,
213
+ properties: {
214
+ cwd: {
215
+ type: "string",
216
+ minLength: 1,
217
+ },
218
+ stateDir: {
219
+ type: "string",
220
+ minLength: 1,
221
+ },
222
+ permissionMode: {
223
+ type: "string",
224
+ enum: ["approve-all", "approve-reads", "deny-all"],
225
+ },
226
+ nonInteractivePermissions: {
227
+ type: "string",
228
+ enum: ["deny", "fail"],
229
+ },
230
+ pluginToolsMcpBridge: {
231
+ type: "boolean",
232
+ },
233
+ actAgentToolsMcpBridge: {
234
+ type: "boolean",
235
+ },
236
+ strictWindowsCmdWrapper: {
237
+ type: "boolean",
238
+ },
239
+ timeoutSeconds: {
240
+ type: "number",
241
+ minimum: 0.001,
242
+ default: 120,
243
+ },
244
+ queueOwnerTtlSeconds: {
245
+ type: "number",
246
+ minimum: 0,
247
+ },
248
+ probeAgent: {
249
+ type: "string",
250
+ minLength: 1,
251
+ },
252
+ mcpServers: {
253
+ type: "object",
254
+ additionalProperties: {
255
+ type: "object",
256
+ properties: {
257
+ command: {
258
+ type: "string",
259
+ minLength: 1,
260
+ description: "Command to run the MCP server",
261
+ },
262
+ args: {
263
+ type: "array",
264
+ items: { type: "string" },
265
+ description: "Arguments to pass to the command",
266
+ },
267
+ env: {
268
+ type: "object",
269
+ additionalProperties: { type: "string" },
270
+ description: "Environment variables for the MCP server",
271
+ },
272
+ },
273
+ required: ["command"],
274
+ },
275
+ },
276
+ agents: {
277
+ type: "object",
278
+ additionalProperties: {
279
+ type: "object",
280
+ properties: {
281
+ command: {
282
+ type: "string",
283
+ minLength: 1,
284
+ },
285
+ args: {
286
+ type: "array",
287
+ items: { type: "string" },
288
+ },
289
+ },
290
+ required: ["command"],
291
+ },
292
+ },
293
+ },
294
+ });
295
+ });
296
+ });
package/src/config.ts ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Resolves ACPX plugin config from raw user configuration. It locates the
3
+ * plugin root, injects optional MCP bridge servers, and applies runtime defaults.
4
+ */
5
+ import fs from "node:fs";
6
+ import { createRequire } from "node:module";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { formatPluginConfigIssue } from "actagent/plugin-sdk/extension-shared";
10
+ import { normalizeLowercaseStringOrEmpty } from "actagent/plugin-sdk/string-coerce-runtime";
11
+ import { AcpxPluginConfigSchema, DEFAULT_ACPX_TIMEOUT_SECONDS } from "./config-schema.js";
12
+ import type {
13
+ AcpxPluginConfig,
14
+ AcpxPermissionMode,
15
+ AcpxNonInteractivePermissionPolicy,
16
+ McpServerConfig,
17
+ AcpxMcpServer,
18
+ ResolvedAcpxPluginConfig,
19
+ } from "./config-schema.js";
20
+ export { type ResolvedAcpxPluginConfig } from "./config-schema.js";
21
+
22
+ const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "actagent-plugin-tools";
23
+ const ACPX_ACTAGENT_TOOLS_MCP_SERVER_NAME = "actagent-tools";
24
+ const requireFromHere = createRequire(import.meta.url);
25
+
26
+ function isAcpxPluginRoot(dir: string): boolean {
27
+ return (
28
+ fs.existsSync(path.join(dir, "actagent.plugin.json")) &&
29
+ fs.existsSync(path.join(dir, "package.json"))
30
+ );
31
+ }
32
+
33
+ function resolveNearestAcpxPluginRoot(moduleUrl: string): string {
34
+ let cursor = path.dirname(fileURLToPath(moduleUrl));
35
+ for (let i = 0; i < 3; i += 1) {
36
+ // Bundled entries live at the plugin root while source files still live under src/.
37
+ if (isAcpxPluginRoot(cursor)) {
38
+ return cursor;
39
+ }
40
+ const parent = path.dirname(cursor);
41
+ if (parent === cursor) {
42
+ break;
43
+ }
44
+ cursor = parent;
45
+ }
46
+ return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
47
+ }
48
+
49
+ function resolveWorkspaceAcpxPluginRoot(currentRoot: string): string | null {
50
+ if (
51
+ path.basename(currentRoot) !== "acpx" ||
52
+ path.basename(path.dirname(currentRoot)) !== "extensions" ||
53
+ path.basename(path.dirname(path.dirname(currentRoot))) !== "dist"
54
+ ) {
55
+ return null;
56
+ }
57
+ const workspaceRoot = path.resolve(currentRoot, "..", "..", "..", "extensions", "acpx");
58
+ return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
59
+ }
60
+
61
+ function resolveRepoAcpxPluginRoot(currentRoot: string): string | null {
62
+ const workspaceRoot = path.join(currentRoot, "extensions", "acpx");
63
+ return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
64
+ }
65
+
66
+ function resolveAcpxPluginRootFromACTAgentLayout(moduleUrl: string): string | null {
67
+ let cursor = path.dirname(fileURLToPath(moduleUrl));
68
+ for (let i = 0; i < 5; i += 1) {
69
+ const candidates = [
70
+ path.join(cursor, "extensions", "acpx"),
71
+ path.join(cursor, "dist", "extensions", "acpx"),
72
+ path.join(cursor, "dist-runtime", "extensions", "acpx"),
73
+ ];
74
+ for (const candidate of candidates) {
75
+ if (isAcpxPluginRoot(candidate)) {
76
+ return candidate;
77
+ }
78
+ }
79
+ const parent = path.dirname(cursor);
80
+ if (parent === cursor) {
81
+ break;
82
+ }
83
+ cursor = parent;
84
+ }
85
+ return null;
86
+ }
87
+ /** Resolve the ACPX plugin root across source, dist, and dist-runtime layouts. */
88
+ export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
89
+ const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl);
90
+ // In a live repo checkout, dist/ can be rebuilt out from under the running gateway.
91
+ // Prefer the stable source plugin root when a built extension is running beside it.
92
+ return (
93
+ resolveWorkspaceAcpxPluginRoot(resolvedRoot) ??
94
+ resolveRepoAcpxPluginRoot(resolvedRoot) ??
95
+ // Shared dist/dist-runtime chunks can load this module outside the plugin tree.
96
+ // Scan common ACTAgent layouts before falling back to the nearest path guess.
97
+ resolveAcpxPluginRootFromACTAgentLayout(moduleUrl) ??
98
+ resolvedRoot
99
+ );
100
+ }
101
+
102
+ const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
103
+ const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
104
+ const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
105
+ const DEFAULT_STRICT_WINDOWS_CMD_WRAPPER = true;
106
+
107
+ type ParseResult =
108
+ | { ok: true; value: AcpxPluginConfig | undefined }
109
+ | { ok: false; message: string };
110
+
111
+ function parseAcpxPluginConfig(value: unknown): ParseResult {
112
+ if (value === undefined) {
113
+ return { ok: true, value: undefined };
114
+ }
115
+ const parsed = AcpxPluginConfigSchema.safeParse(value);
116
+ if (!parsed.success) {
117
+ return { ok: false, message: formatPluginConfigIssue(parsed.error.issues[0]) };
118
+ }
119
+ return {
120
+ ok: true,
121
+ value: parsed.data as AcpxPluginConfig,
122
+ };
123
+ }
124
+
125
+ function resolveACTAgentRoot(currentRoot: string): string {
126
+ if (
127
+ path.basename(currentRoot) === "acpx" &&
128
+ path.basename(path.dirname(currentRoot)) === "extensions"
129
+ ) {
130
+ const parent = path.dirname(path.dirname(currentRoot));
131
+ if (path.basename(parent) === "dist") {
132
+ return path.dirname(parent);
133
+ }
134
+ return parent;
135
+ }
136
+ return path.resolve(currentRoot, "..");
137
+ }
138
+
139
+ function resolveTsxImportSpecifier(): string {
140
+ try {
141
+ return requireFromHere.resolve("tsx");
142
+ } catch {
143
+ return "tsx";
144
+ }
145
+ }
146
+
147
+ function shellQuoteCommandArg(arg: string): string {
148
+ if (!/[\s'"\\$|&;<>{}()*?[\]~`]/.test(arg)) {
149
+ return arg;
150
+ }
151
+ return `'${arg.replace(/'/g, "'\"'\"'")}'`;
152
+ }
153
+
154
+ function resolvePluginToolsMcpServerConfig(moduleUrl: string = import.meta.url): McpServerConfig {
155
+ const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
156
+ const actAgentRoot = resolveACTAgentRoot(pluginRoot);
157
+ const distEntry = path.join(actAgentRoot, "dist", "mcp", "plugin-tools-serve.js");
158
+ if (fs.existsSync(distEntry)) {
159
+ return {
160
+ command: process.execPath,
161
+ args: [distEntry],
162
+ };
163
+ }
164
+ const sourceEntry = path.join(actAgentRoot, "src", "mcp", "plugin-tools-serve.ts");
165
+ return {
166
+ command: process.execPath,
167
+ args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
168
+ };
169
+ }
170
+
171
+ function resolveACTAgentToolsMcpServerConfig(moduleUrl: string = import.meta.url): McpServerConfig {
172
+ const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
173
+ const actAgentRoot = resolveACTAgentRoot(pluginRoot);
174
+ const distEntry = path.join(actAgentRoot, "dist", "mcp", "actagent-tools-serve.js");
175
+ if (fs.existsSync(distEntry)) {
176
+ return {
177
+ command: process.execPath,
178
+ args: [distEntry],
179
+ };
180
+ }
181
+ const sourceEntry = path.join(actAgentRoot, "src", "mcp", "actagent-tools-serve.ts");
182
+ return {
183
+ command: process.execPath,
184
+ args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
185
+ };
186
+ }
187
+
188
+ function resolveConfiguredMcpServers(params: {
189
+ mcpServers?: Record<string, McpServerConfig>;
190
+ pluginToolsMcpBridge: boolean;
191
+ actAgentToolsMcpBridge: boolean;
192
+ moduleUrl?: string;
193
+ }): Record<string, McpServerConfig> {
194
+ const resolved = { ...params.mcpServers };
195
+ if (params.pluginToolsMcpBridge && resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
196
+ throw new Error(
197
+ `mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
198
+ );
199
+ }
200
+ if (params.actAgentToolsMcpBridge && resolved[ACPX_ACTAGENT_TOOLS_MCP_SERVER_NAME]) {
201
+ throw new Error(
202
+ `mcpServers.${ACPX_ACTAGENT_TOOLS_MCP_SERVER_NAME} is reserved when actAgentToolsMcpBridge=true`,
203
+ );
204
+ }
205
+ if (params.pluginToolsMcpBridge) {
206
+ resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(
207
+ params.moduleUrl,
208
+ );
209
+ }
210
+ if (params.actAgentToolsMcpBridge) {
211
+ resolved[ACPX_ACTAGENT_TOOLS_MCP_SERVER_NAME] = resolveACTAgentToolsMcpServerConfig(
212
+ params.moduleUrl,
213
+ );
214
+ }
215
+ return resolved;
216
+ }
217
+
218
+ /** Convert ACTAgent MCP server config into ACPX runtime MCP server entries. */
219
+ export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): AcpxMcpServer[] {
220
+ return Object.entries(mcpServers).map(([name, server]) => ({
221
+ name,
222
+ command: server.command,
223
+ args: [...(server.args ?? [])],
224
+ env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
225
+ name: envName,
226
+ value,
227
+ })),
228
+ }));
229
+ }
230
+
231
+ /** Validate and normalize raw ACPX plugin config for runtime startup. */
232
+ export function resolveAcpxPluginConfig(params: {
233
+ rawConfig: unknown;
234
+ workspaceDir?: string;
235
+ moduleUrl?: string;
236
+ }): ResolvedAcpxPluginConfig {
237
+ const parsed = parseAcpxPluginConfig(params.rawConfig);
238
+ if (!parsed.ok) {
239
+ throw new Error(parsed.message);
240
+ }
241
+ const normalized = parsed.value ?? {};
242
+ const workspaceDir = params.workspaceDir?.trim() || process.cwd();
243
+ const fallbackCwd = workspaceDir;
244
+ const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
245
+ const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state"));
246
+ const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
247
+ const actAgentToolsMcpBridge = normalized.actAgentToolsMcpBridge === true;
248
+ const mcpServers = resolveConfiguredMcpServers({
249
+ mcpServers: normalized.mcpServers,
250
+ pluginToolsMcpBridge,
251
+ actAgentToolsMcpBridge,
252
+ moduleUrl: params.moduleUrl,
253
+ });
254
+ const agents = Object.fromEntries(
255
+ Object.entries(normalized.agents ?? {}).map(([name, entry]) => {
256
+ const cmd = entry.command.trim();
257
+ const cmdArgs = entry.args ?? [];
258
+ const fullCommand =
259
+ cmdArgs.length > 0 ? `${cmd} ${cmdArgs.map(shellQuoteCommandArg).join(" ")}` : cmd;
260
+ return [normalizeLowercaseStringOrEmpty(name), fullCommand];
261
+ }),
262
+ );
263
+
264
+ // Lowercase probeAgent so lookups match the registry keys built above, which
265
+ // also go through normalizeLowercaseStringOrEmpty. Without this, a user who
266
+ // writes `probeAgent: "OpenCode"` would silently miss the stored "opencode"
267
+ // key.
268
+ const probeAgent = normalizeLowercaseStringOrEmpty(normalized.probeAgent) || undefined;
269
+
270
+ return {
271
+ cwd,
272
+ stateDir,
273
+ probeAgent,
274
+ permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
275
+ nonInteractivePermissions:
276
+ normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
277
+ pluginToolsMcpBridge,
278
+ actAgentToolsMcpBridge,
279
+ strictWindowsCmdWrapper:
280
+ normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
281
+ timeoutSeconds: normalized.timeoutSeconds ?? DEFAULT_ACPX_TIMEOUT_SECONDS,
282
+ queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
283
+ legacyCompatibilityConfig: {
284
+ strictWindowsCmdWrapper: normalized.strictWindowsCmdWrapper,
285
+ queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds,
286
+ },
287
+ mcpServers,
288
+ agents,
289
+ };
290
+ }
@@ -0,0 +1,22 @@
1
+ // ACPX tests cover manifest plugin behavior.
2
+ import fs from "node:fs";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ type AcpxPackageManifest = {
6
+ dependencies?: Record<string, string>;
7
+ devDependencies?: Record<string, string>;
8
+ };
9
+
10
+ const packageJson = JSON.parse(
11
+ fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
12
+ ) as AcpxPackageManifest;
13
+
14
+ describe("acpx package manifest", () => {
15
+ it("keeps runtime dependencies in the package manifest", () => {
16
+ expect(packageJson.dependencies?.acpx).toBeTypeOf("string");
17
+ expect(packageJson.dependencies?.acpx).not.toBe("");
18
+ expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.15.0");
19
+ expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.39.0");
20
+ expect(packageJson.devDependencies?.["@agentclientprotocol/claude-agent-acp"]).toBeUndefined();
21
+ });
22
+ });
@@ -0,0 +1,90 @@
1
+ // ACPX tests cover process lease plugin behavior.
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ createAcpxProcessLeaseStore,
8
+ ACTAGENT_ACPX_LEASE_ID_ARG,
9
+ ACTAGENT_ACPX_LEASE_ID_ENV,
10
+ ACTAGENT_GATEWAY_INSTANCE_ID_ARG,
11
+ ACTAGENT_GATEWAY_INSTANCE_ID_ENV,
12
+ withAcpxLeaseEnvironment,
13
+ type AcpxProcessLease,
14
+ } from "./process-lease.js";
15
+
16
+ function makeLease(index: number): AcpxProcessLease {
17
+ return {
18
+ leaseId: `lease-${index}`,
19
+ gatewayInstanceId: "gateway-test",
20
+ sessionKey: `agent:codex:acp:${index}`,
21
+ wrapperRoot: "/tmp/actagent/acpx",
22
+ wrapperPath: "/tmp/actagent/acpx/codex-acp-wrapper.mjs",
23
+ rootPid: 1000 + index,
24
+ commandHash: `hash-${index}`,
25
+ startedAt: index,
26
+ state: "open",
27
+ };
28
+ }
29
+
30
+ describe("createAcpxProcessLeaseStore", () => {
31
+ it("serializes concurrent lease saves without dropping records", async () => {
32
+ const stateDir = await mkdtemp(path.join(tmpdir(), "actagent-acpx-leases-"));
33
+ try {
34
+ const store = createAcpxProcessLeaseStore({ stateDir });
35
+ await Promise.all(Array.from({ length: 25 }, (_, index) => store.save(makeLease(index))));
36
+
37
+ const leases = await store.listOpen("gateway-test");
38
+ expect(leases.map((lease) => lease.leaseId).toSorted()).toEqual(
39
+ Array.from({ length: 25 }, (_, index) => `lease-${index}`).toSorted(),
40
+ );
41
+ } finally {
42
+ await rm(stateDir, { recursive: true, force: true });
43
+ }
44
+ });
45
+ });
46
+
47
+ describe("withAcpxLeaseEnvironment", () => {
48
+ it("adds lease environment and wrapper args on POSIX", () => {
49
+ const command = withAcpxLeaseEnvironment({
50
+ command: "node /tmp/actagent/acpx/codex-acp-wrapper.mjs",
51
+ leaseId: "lease-test",
52
+ gatewayInstanceId: "gateway-test",
53
+ platform: "darwin",
54
+ });
55
+
56
+ expect(command).toBe(
57
+ [
58
+ "env",
59
+ `${ACTAGENT_ACPX_LEASE_ID_ENV}=lease-test`,
60
+ `${ACTAGENT_GATEWAY_INSTANCE_ID_ENV}=gateway-test`,
61
+ "node /tmp/actagent/acpx/codex-acp-wrapper.mjs",
62
+ ACTAGENT_ACPX_LEASE_ID_ARG,
63
+ "lease-test",
64
+ ACTAGENT_GATEWAY_INSTANCE_ID_ARG,
65
+ "gateway-test",
66
+ ].join(" "),
67
+ );
68
+ });
69
+
70
+ it("keeps Windows logs keyed by lease id with wrapper args", () => {
71
+ const command = withAcpxLeaseEnvironment({
72
+ command: "node C:/actagent/acpx/codex-acp-wrapper.mjs",
73
+ leaseId: "lease-test",
74
+ gatewayInstanceId: "gateway-test",
75
+ platform: "win32",
76
+ });
77
+
78
+ expect(command).toBe(
79
+ [
80
+ "node C:/actagent/acpx/codex-acp-wrapper.mjs",
81
+ ACTAGENT_ACPX_LEASE_ID_ARG,
82
+ "lease-test",
83
+ ACTAGENT_GATEWAY_INSTANCE_ID_ARG,
84
+ "gateway-test",
85
+ ].join(" "),
86
+ );
87
+ expect(command).not.toContain(`${ACTAGENT_ACPX_LEASE_ID_ENV}=`);
88
+ expect(command).not.toContain(`${ACTAGENT_GATEWAY_INSTANCE_ID_ENV}=`);
89
+ });
90
+ });